* feat(voice): voice personality, picker catalog, and prompt layer foundation
- voicePickerCatalog.ts: per-backend voice lists for Gemini and Qwen with
resolve helpers (resolveGeminiLiveVoice, resolveQwenRealtimeVoice)
- voicePersonality.ts: VoicePersonalityPreferences schema, presets, composed
system prompt with identity/character/response-length layers
- voicePromptLayers.ts: buildResolvedVoiceSystemPrompt, preset delivery snippets
- voiceSystemPromptParam.ts: hub-side base64url decode for ?systemPrompt=
- voicePickerPreferences.ts, voicePersonalitySession.ts: browser-side encode,
decode, and storage helpers
- useVoicePersonality: React hook for preferences persistence
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): preset delivery included when non-balanced preset selected; restore test typecheck
- isDefaultVoicePersonality: add preset check so warm/calm/direct presets
trigger the delivery snippet instead of being treated as default
- web/tsconfig.json: remove test file exclusion from typecheck (restoring
strict coverage of test code); fix resulting type error in mock declaration
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): include use_speaker_boost in ElevenLabs TTS override payload
The checkbox persisted the pref but ttsDiffersFromDefault and
buildElevenLabsTtsOverride both omitted it, so the setting was never
sent to the agent.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(voice): update speaker_boost test to assert it IS included in override
The previous test asserted use_speaker_boost was omitted; now it's
correctly included in the TTS payload.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): authorize use_speaker_boost in ElevenLabs override schema
Add use_speaker_boost to both the VoiceAgentConfig tts override type
and the buildVoiceAgentConfig() platform_settings so the field is
accepted by the ElevenLabs agent runtime.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): propagate full language code through composed prompt, not just zh
getDefaultVoiceSystemPrompt and resolveComposedVoiceSystemPrompt were
filtering language to zh-only before passing to composeVoiceAgentPrompt.
Now append buildVoiceLanguageBlock(language) after composition so French,
Spanish, Japanese etc. reach Gemini/Qwen sessions correctly.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): only append language block when language explicitly set
Building language block unconditionally when no language is given
caused getDefaultVoiceSystemPrompt() to diverge from VOICE_SYSTEM_PROMPT.
Only append the block when a code is explicitly provided.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): always include language block for Gemini/Qwen in composed prompt
When auto-detect is on (language=undefined), the composed prompt sent
via hub proxy was losing the language auto-detect instruction because
the block was only added when language was explicitly set.
Now: ElevenLabs skips the block (has its own language field); Gemini/Qwen
always include it — undefined produces the auto-detect block, an explicit
code produces the appropriate language instruction.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime
Rebased from Overbaker/hapi#401 onto current main. Adds a pluggable voice
backend architecture that extends the existing ElevenLabs integration:
- Gemini 2.5 Live (gemini-live): Google real-time audio via WebSocket
with full function calling (messageCodingAgent, processPermissionRequest)
- Qwen Realtime (qwen-realtime): Alibaba DashScope via hub WebSocket
proxy (browser cannot set Authorization header directly)
- VoiceBackendSession: dynamic backend selector with React.lazy loading,
gates voice button until backend module is registered
- Hub WS proxies: JWT-authenticated /api/voice/gemini-ws and
/api/voice/qwen-ws endpoints in Bun.serve, with message queueing during
upstream connect to prevent dropped setup frames
- AudioWorklet pipeline: inline Blob URL recorder, 24 kHz PCM player,
serial tool call execution, AudioContext created in user gesture for mobile
- Backend discovery: GET /voice/backend + POST /voice/gemini-token /
POST /voice/qwen-token hub routes; frontend auto-detects active backend
Merge notes:
- Rebased 135 upstream commits cleanly; HappyComposer keeps upstream's
configurable enter-behavior setting (supersedes hard-coded Ctrl+Enter)
- Converted gemini test files from bun:test to vitest (web package uses vitest)
- All 221 hub tests and 636 web tests pass; TypeScript clean
* fix(voice): restore user mic mute state after Gemini turn completes
turnComplete handler was unconditionally calling setMuted(false), which
re-enabled the mic track even when the user had manually muted. Now
restores to state.micMuted instead.
* fix(voice): remove hard-coded Chinese language from Gemini backend
buildGeminiLiveConfig was appending VOICE_CHINESE_LANGUAGE_BLOCK which
forced Gemini to always respond in Mandarin regardless of user locale.
Gemini now uses the neutral base prompt and responds in the language the
user speaks to it, consistent with the ElevenLabs behaviour.
* fix(voice): reset modelSpeaking in cleanup to unblock mic on restart
If the session closes while Gemini is mid-speech, cleanup() left
state.modelSpeaking=true. The next startSession() would then drop all
mic audio in sendAudioChunk() until a model turn eventually flipped
the flag — effectively deaf until page reload.
* fix(voice): guard stale close handlers in Gemini and Qwen sessions
ws.onclose operated on module-level state.ws, not the socket that fired
the event. A rapid stop/restart could cause the old socket's onclose to
call cleanup() after the new socket was assigned, tearing down the live
session. Guard with `if (state.ws !== ws) return` before cleanup.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): remove hard-coded Chinese language from Qwen backend
Matches the Gemini fix — both backends now use VOICE_SYSTEM_PROMPT
without the Chinese language block, giving consistent English-default
behaviour across all non-ElevenLabs backends.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(voice): proactive/reactive toggle in voice settings
Adds a "Proactive voice" toggle (default: off = reactive) to the Voice
Assistant settings section.
Reactive (default): initial context and agent-ready events are fed
silently; the assistant waits for the user to speak first.
Proactive: original behaviour — Gemini/Qwen narrate context on connect
and speak unprompted when the agent finishes a task. ElevenLabs is also
affected via onReady sending a user message rather than a silent update.
Covers all three backends uniformly. localStorage key: hapi-voice-proactive.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): normalize WS close codes, drop barrel re-exports, fix SSE visibility
- hub/server.ts: add toClientCloseCode() to normalize reserved upstream
close codes (1005/1006/1015) to 1011 before forwarding to browser;
abnormal upstream drops (1006) would otherwise throw on clientWs.close()
and leave the browser socket open
- realtime/index.ts: remove static GeminiLiveVoiceSession and QwenVoiceSession
barrel exports; VoiceBackendSession lazy-imports both, so barrel re-exports
created static dependencies that defeated the intended code-split
- App.tsx: gate global useVisibilityReporter on !sessionEventSubscription so
the always-on SSE connection does not suppress native Web Push notifications
for sessions the user is not currently viewing
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): respect language setting in Gemini/Qwen; fix voice-start toggle label
- buildGeminiLiveConfig() now accepts optional language param; appends
VOICE_CHINESE_LANGUAGE_BLOCK only when language === 'zh'
- GeminiLiveVoiceSession passes config.language through
- QwenVoiceSession conditionally builds basePrompt from language setting
- Fixes silent no-op when user selects Chinese in voice settings on
Gemini/Qwen backends (was ElevenLabs-only)
- Rename voice-start toggle label to 'Start voice session with summary'
- Fix description: clarifies the choice is about session-open behaviour
(summary vs greeting), not ongoing narration
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): send greeting trigger in reactive mode for Gemini
Gemini Live has no built-in first-message like ElevenLabs agents do;
without an explicit turnComplete:true it sits silently. In reactive mode
(default, toggle off) now sends a greeting instruction after any silent
context feed so Gemini introduces itself and invites the user to speak.
Proactive mode is unchanged: the context summary is the opening speech.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): suppress Gemini self-identification and context leak in greeting
- VOICE_SYSTEM_PROMPT: explicit instruction never to call itself Gemini,
Google, or any underlying model/provider name — always HAPI
- Greeting trigger text: instruct to greet as HAPI only, suppress model
name and any reference to context/recent activity in the opening line
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): address code review findings — error handling, proxy, audio
Gemini + Qwen client:
- onerror now sets setupDone/sessionReady and nulls state.ws before
calling reject(), so the stale-close guard trips in onclose and
prevents a duplicate statusCallback('error') on WS failure
Gemini client:
- Proactive mode with no initialContext now falls through to the
greeting trigger instead of sitting silently
- Remove unused handleBargeIn callback (dead code)
Qwen client:
- Add input_audio_sample_rate: 16000 to session.update so PCM rate
is declared explicitly rather than relying on DashScope's default
Hub proxy:
- Remove no-op ternary in Gemini flush loop and message handler
(typeof x === 'string' ? x : x); use upstream.send(msg) directly
- Qwen onerror now calls upstreamMap.delete() before closing client,
eliminating the stale map entry window
- Align Qwen hub fallback model string with QWEN_REALTIME_MODEL
constant ('qwen3-omni-flash-realtime')
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): trailing-slash WS URL, Qwen session.update schema
hub/voice.ts:
- Replace string-concat WS URL construction with buildVoiceWsUrl() which
uses URL API to set protocol/pathname cleanly — fixes double-slash when
HAPI_PUBLIC_URL has a trailing slash (would silently skip the proxy route)
QwenVoiceSession.tsx:
- Wrap tool definitions in {type:'function', function:{...}} as required
by Qwen-Omni realtime schema — previous flat shape caused session.update
rejection before audio capture could start
- Use pcm16/pcm24 audio formats matching DashScope spec; remove
input_audio_sample_rate (encoded in format name)
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): await audio capture before setMuted; sanitize upstream close codes
GeminiLiveVoiceSession + QwenVoiceSession:
- startAudioCapture() is now async and awaits recorder.start() before
calling setMuted() — previously setMuted ran before getUserMedia resolved
so a session restarted while muted would open the mic anyway
- statusCallback('connected') now fires after audio is ready
- setMuted() called unconditionally (not just when true) to correctly
apply saved state in either direction
hub/src/web/server.ts:
- Both Gemini and Qwen close() handlers now pass the client code through
toClientCloseCode() before forwarding to upstream — prevents reserved
codes (e.g. 1006) from causing WebSocket.close() to throw and leave
the upstream session open until provider timeout
- Reason string capped at 123 bytes (WebSocket protocol limit)
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): wrap startAudioCapture in try/catch to propagate mic errors
An unhandled rejection inside the async onmessage callback does not
propagate to the outer startSession Promise — the UI hangs on
'connecting' and the provider socket stays partially open. Wrapping
the await in try/catch calls cleanup()/statusCallback('error')/reject()
so failures surface correctly.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): propagate backend discovery failure instead of silently falling back to ElevenLabs
fetchVoiceBackend no longer catches errors and defaults to 'elevenlabs' — any
network or server failure now throws so VoiceBackendSession can surface it via
onStatusChange('error', ...) rather than silently mounting the wrong backend.
VoiceBackendSession also resets backend state to null when api changes, so
a stale ElevenLabs registration from a prior discovery cannot persist into
a new session.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): throw on unrecognised backend value instead of silently falling back to ElevenLabs
Unknown backend strings (future values, typos) now throw rather than defaulting
to elevenlabs, closing the narrow remaining form of the original misrouting bug.
Also removes the unnecessary `as VoiceBackendResponse` cast.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): add Qwen greeting/proactive trigger; fix socket buffer for base64 uploads
Qwen session.updated handler now sends the same proactive summary or greeting
trigger that Gemini does — previously it started silently in both proactive and
reactive modes.
maxHttpBufferSize raised to 68 MiB to account for base64 expansion: 50 MiB
decoded files become ~66.7 MiB as base64 JSON, so the previous 55 MiB ceiling
would disconnect uploads above ~41 MiB before they reached the CLI.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): replace unsupported conversation.item.create with session.update for Qwen text
Qwen's realtime API only supports conversation.item.create for function_call_output.
Sending it with type:'message' for greetings/context was invalid and could fail
before the user spoke.
sendTextMessage and sendContextualUpdate now update session instructions via
session.update (accumulating context into the system prompt) and trigger
response.create only when a spoken reply is needed — matching Qwen's supported
client event surface.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): guard session.updated re-entry and reset config on session start
session.updated now returns early after the first ack — subsequent session.update
calls (instruction appends) also echo session.updated but must not re-trigger
audio capture or the greeting path.
currentSessionConfig is now reset to null at the top of startSession so a stale
config from a failed previous session cannot leak into the new one.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): assert wsUrl presence for Gemini proxy connections
Without this guard, a missing wsUrl in the hub token response would
silently attempt to connect directly to Google with "proxied" as the
API key — producing a confusing auth failure instead of a clear error.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): correct Qwen audio formats and default voice
DashScope realtime API accepts only 'pcm' for both input and output
audio formats. The pcm16/pcm24 values caused session.update rejection
before audio capture could start, leaving the Qwen backend unusable.
Also updates the default voice from Mia (not in the qwen3-omni-flash-
realtime voice list) to Cherry, which is documented as supported.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): close AudioContext on failed voice session start
Failed token fetch, microphone denial, or WebSocket error during
setup left state.playbackContext open. Each failure path now calls
cleanup() before throwing/rejecting, preventing AudioContext leaks
on mobile browsers with hard limits on concurrent contexts.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* chore: restore non-voice files to upstream/main state
Reverts changes to files that shouldn't differ from upstream:
- .gitignore: remove fork-only AGENTS.local.md entry
- web/src/App.tsx: restore dual-subscription SSE pattern (scope-aware)
- web/src/hooks/useSSE.ts: restore SSEScope/scope parameter
- web/src/hooks/useSSE.test.ts: restore (was accidentally deleted)
- web/src/lib/appSseSubscriptions.ts: restore (was accidentally deleted)
- web/src/lib/appSseSubscriptions.test.ts: restore (was accidentally deleted)
- hub/src/sync/syncEngine.ts: restore (off-topic change)
* fix(voice): harden Gemini and Qwen WS proxies against client abuse
Hub sends HAPI-owned Gemini setup on proxy connect and rejects client
setup frames. Qwen proxy always uses QWEN_REALTIME_MODEL instead of a
client query parameter. Shared buildGeminiLiveSetupMessage() keeps wire
format in one place.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(voice): harden Qwen proxy — hub-owned setup, client frame allowlist
Mirror the Gemini proxy security model for Qwen:
- Hub sends initial session.update (voice/tools/instructions) on upstream
connect so the browser cannot override config fields.
- Proxy message() now calls isQwenSafeClientFrame() and closes the
connection (1008) if a client session.update touches any field other
than 'instructions' (blocks tool/voice/modality overrides).
- QwenVoiceSession no longer sends session.update on session.created;
it waits for the hub-relayed session.updated and then sends only
instruction-only updates for context/proactive content.
- Language passed as query param (?language=zh) so hub builds the
correct Chinese system prompt without a client-supplied session.update.
- buildQwenSessionUpdateMessage() and isQwenSafeClientFrame() added to
@hapi/protocol/voice; 9 new unit tests cover filter edge cases.
* fix(voice): respect Qwen session.created→session.update protocol ordering
DashScope requires session.update to be sent AFTER session.created is
received, not immediately on WebSocket open. Previously the hub sent
session.update in upstream.onopen, which violated this ordering and
risked the config being processed in an uninitialized session context.
Add pendingSetupMap to buffer the hub-owned session.update payload.
The onmessage handler now relays session.created to the browser first,
then immediately sends the pending session.update to DashScope — matching
the protocol ordering the old browser-side code used (which waited for
session.created before sending session.update).
Also remove maxHttpBufferSize from the socket.io Engine config. That
setting is unrelated to voice backends; upstream/main had no such limit
set and it is not introduced by this PR.
* fix(voice): use Realtime tool shape for Qwen session.update (not chat-completions)
Qwen Realtime session.update expects tools as flat objects:
{ type: 'function', name, description, parameters }
The previous code used the chat-completions shape:
{ type: 'function', function: { name, description, parameters } }
DashScope may reject session.update or silently ignore tools with the
nested shape, causing tool calls to fail at runtime. Fix applied in
buildQwenSessionUpdateMessage(); test updated to assert flat shape and
that no nested `function` key is present.
* fix(voice): update Qwen Realtime model, voice, and endpoint for intl service
Live-tested against DashScope international API:
- Model: qwen3-omni-flash-realtime → qwen3.5-omni-flash-realtime
(previous model ID did not exist on DashScope)
- Default voice: Cherry → Tina
(confirmed from session.created response on qwen3.5-omni-flash-realtime)
- Default WS base: dashscope.aliyuncs.com → dashscope-intl.aliyuncs.com
(international accounts use the -intl endpoint; China endpoint rejects
international API keys; QWEN_REALTIME_WS_URL env var still overrides)
* fix(voice): correct Qwen text injection and generalise language handling
Two dogfooding fixes verified against live Qwen Realtime session:
sendTextMessage: switch from instruction-injection to conversation.item.create
Qwen Realtime requires a user conversation item before response.create.
The previous approach (updateInstructions + response.create) produced
"input messages do not contain elements with role user" errors. Now sends
{type:message, role:user, content:[{type:input_text}]} then response.create.
sendContextualUpdate is unchanged (instruction-only, no response trigger).
Language handling: replace zh-only branch with buildVoiceLanguageBlock()
Previously, only language='zh' added any instruction; all other languages
(including English) sent no language block, causing Qwen to drift to Chinese.
buildVoiceLanguageBlock() now covers three cases:
- 'zh'/'zh-*': existing Chinese block (unchanged)
- explicit code ('en','es','fr',...): "Always respond in [Language]"
- undefined/auto: "Detect the user's language and maintain it"
Applied to buildGeminiLiveConfig, buildQwenSessionUpdateMessage, and the
client-side currentInstructions mirror in QwenVoiceSession.
Also removes the Gemini hub proxy's zh-only filter, which was discarding
explicit language selections other than Chinese.
* fix(hub): gate Gemini client frames until upstream setupComplete
Hub sends its owned setup on upstream open, then waits for Google's
setupComplete acknowledgment before flushing queued client frames.
isGeminiSetupCompleteFrame() detects the {"setupComplete":{}} message;
message() queues instead of forwarding while pendingMap is live.
Addresses the repeated Major finding from bot review on PR #743.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(hub): cap Gemini setup-window pending queue at 1 MiB
An authenticated client could flood the queue between upstream.onopen
and Google's setupComplete acknowledgment. Add pendingBytesMap tracking
and close with 1009 if the budget is exceeded.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(gemini): pass all language codes to hub proxy, not just zh
Language selection for French, Spanish, Japanese etc. was silently
dropped — only 'zh' was forwarded as a query param.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(voice): expand LANGUAGE_NAMES to cover full ElevenLabs language set
Codes like 'no', 'da', 'fi', 'pt-br', 'bg', 'ro', 'cs', 'el', 'ms',
'tl', 'uk', 'hu', 'hr', 'sk' were falling through to raw-code prompts
("Always respond in no"). Now resolve to proper display names.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): per-session scratchlist (workbench) panel
Adds a per-session "scratchlist" panel above the composer for parking
notes / drafts / parking-lot ideas that are explicitly held — never
auto-sent. This is distinct from the existing queue (QueuedMessagesBar):
- Queue = conveyor belt: messages auto-fire once the agent is idle.
- Scratchlist = workbench: held until the operator promotes them.
The amber accent and "held — not sent" pill make the visual distinction
obvious so operators don't mistake one for the other.
Features:
- Collapsible per-session panel (collapsed by default, persisted in
localStorage).
- Add (Enter) / delete / reorder (up/down) entries.
- Promote-to-composer copies into the composer for editing (entry
stays — copy semantics).
- Promote-to-queue routes through the existing onSend path so the
entry shows up in QueuedMessagesBar; entry is removed only on
accepted send.
- Entries persist per session under hapi.scratchlist.v1.<sessionId>.
- Confirm-on-delete only for entries longer than 100 chars.
- Ctrl/Cmd+Shift+S focuses the add-input.
- en + zh-CN strings.
v1 scope: localStorage-only. Hub-sync deferred to v2 to keep the
diff small and reviewable.
Test coverage:
- web/src/lib/scratchlist.test.ts — 21 tests (storage round-trip,
add/delete/reorder/cap, malformed-JSON resilience, confirm threshold).
- web/src/components/AssistantChat/ScratchlistPanel.test.tsx — 13
tests (collapse persistence, hydration, add/delete/reorder UI,
promote-to-composer copy semantics, promote-to-queue accepted /
rejected paths, per-session isolation).
Closes#11
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scratchlist): block focus into collapsed panel via inert
Upstream review (tiann/hapi#772, codex bot) flagged that the collapsed
scratchlist body was visually hidden via CSS only - the textarea and
action buttons stayed mounted, focusable, and clickable while their
ancestor was aria-hidden. Tab into invisible controls + a hidden
subtree with focusable descendants is an a11y violation.
Apply `inert` to the inner content, gated on the collapsed state.
This removes the subtree from the focus, pointer, and accessibility
trees while keeping the grid-template-rows expand animation intact
(no conditional remount, so the open/close transition still runs).
Add a regression test that asserts `inert` is present while collapsed
and removed (or empty) while expanded, so a future revert of the fix
trips immediately.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(scratchlist): add Playwright e2e + isolated fixture page
The unit suite under jsdom can't verify the parts of the scratchlist
that actually live in the browser:
- `inert` blocks focus (jsdom ignores `inert`)
- the grid-template-rows collapse animation
- localStorage surviving a full page reload
- per-session keying surviving cross-route navigation
- Ctrl/Cmd+Shift+S firing the global expand+focus shortcut
Add a Playwright config + spec that drives a real Chromium against a
new Vite-served fixture (`web/e2e-fixtures/scratchlist-fixture.html`).
The fixture mounts the production `ScratchlistPanel` in isolation
inside an `I18nProvider` and exposes the promote callbacks on
`window.__scratchlistE2E` so the spec can assert that promote-to-
composer and promote-to-queue receive the right text without having
to spin up the hub, auth, or socket layer.
Nine specs cover:
1. starts collapsed, toggles
2. collapsed inner is `inert` and refuses focus / pointer
3. add: entry appears, draft clears, count updates
4. persistence across full page reload
5. promote-to-composer fires callback (entry stays - copy semantics)
6. promote-to-queue success path (entry removed)
7. promote-to-queue failure path (entry retained for retry)
8. Ctrl+Shift+S expands + focuses input
9. per-session isolation across navigation
Wires `bun run test:e2e` and `test:e2e:ui` at the repo root and
documents the harness in `web/README.md`. Bumps `playwright` 1.49.1
-> 1.60.0 alongside the new `@playwright/test` dep so the bundled
chromium-headless-shell-1223 (Chrome 148) is used; the older 131
binary SIGTRAPs on this kernel during launch. Adds
`test-results/` and `playwright-report/` to `.gitignore`.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scratchlist): key host by session.id to prevent cross-session leak
Upstream review (tiann/hapi#772, codex bot follow-up) flagged a state
leak across same-route session switches. ScratchlistPanel reads
`sessionId` once via `useState(() => readScratchlist(sessionId))` and
rehydrates in a `useEffect`. SessionChat stays mounted when the
operator switches sessions on the same `/sessions/$sessionId` route,
so the panel sees a new `sessionId` prop without unmounting. Effect
order during the prop change:
1. render with sessionId=B but stale entries=[A's items]
2. rehydrate effect: setEntries(read(B)) -> queues correction
3. persist effect (deps [sessionId, entries] both changed):
persistScratchlist(B, [A's items]) -> writes A into B
4. re-render with sessionId=B, entries=B's items
5. persist effect: persistScratchlist(B, B's items)
-> overwrites the bug write
The bug is transient (step 3's write is corrected by step 5) but
real: any read between steps 3 and 5 (another tab, a SW prefetch,
manual inspection) sees A's data under B's key.
Fix is one line: `key={props.session.id}` on `<ScratchlistHost>`.
React unmounts and remounts the host when the key changes, so the
new mount's useState initializer reads B's storage from scratch and
never touches B's key with A's data. This is the React-canonical
"reset state on prop change" pattern; cleaner than chasing the race
inside the panel.
Add an e2e regression test that:
- installs a `localStorage.setItem` spy in `addInitScript`
- mounts the fixture under session A and adds an entry
- clears the spy, then switches to session B in-place via
`window.__scratchlistE2E.setSessionId('leak-B')` (no page reload)
- asserts no recorded write to `hapi.scratchlist.v1.leak-B`
contained A's text (catches the transient corrupting write
deterministically, before the correction overwrites it)
- round-trips back to A to confirm A's storage is intact
The fixture grows a `?key=0` mode that drops the host's `key=` prop.
Verified red/green: with `key=0` the regression test fails on the
spy-detected corrupting write; with the fix in place (default), all
10 e2e specs pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): apply messages-consumed on global SSE connection
The global all-sessions SSE subscription returned early on message-stream
events without updating the message-window store. When session-scoped SSE
was reconnecting or the user had another session selected, messages-consumed
never cleared the queued bar even though the hub had stamped invoked_at.
Also harden mergeMessages so a stale invokedAt:null snapshot cannot clobber
an existing ack timestamp.
Fixestiann/hapi#758
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web,hub): resume never-started inactive sessions on first send
Hub fresh-spawns when inactive session has path but no agent thread id and
zero messages. Web guards resume, updates inactive banner copy, and surfaces
resume_unavailable before POST /resume when resume is impossible.
Fixestiann/hapi#759
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): scope sessionResume guard to current flavor only
Hub `resolveAgentResumeId` only honors the metadata.flavor's id; the web
guard was falling back across all flavors so a cursor session with a stale
codexSessionId still tried to resume and 409'd. Mirror the hub switch and
default to claude when flavor is unknown.
Addresses HAPI Bot review on tiann/hapi#761.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): allow claude session resume via hub message-id recovery
Hub `resolveAgentResumeId` falls back to `recoverClaudeSessionIdFromMessages`
on the claude branch when `metadata.claudeSessionId` is absent, so the web
guard must not block inactive claude sessions that have stored messages but
no metadata id. Other flavors have no such recovery path and stay rejected.
Addresses second HAPI Bot review thread on tiann/hapi#761
(`web/src/lib/sessionResume.ts:41`).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): add UriConfirmDialog component
Add a Radix Dialog-based confirmation modal for custom URI scheme
navigation. Follows the RenameSessionDialog pattern.
- UriConfirmDialog: shows URI, scheme label, Cancel/Open/Always-allow buttons
- i18n keys: dialog.uri.{title,description,open,alwaysAllow}
* feat(web): autolink non-https URI schemes in markdown
Add a remark plugin that converts raw `scheme://...` text nodes into
link nodes for non-http(s) schemes. GFM already handles http/https;
this plugin handles the remainder (obsidian://, vscode://, slack://, etc.).
- No scheme allowlist: every `scheme://` pattern is converted; the
sanitize layer (urlTransform) and onClick layer (classifyScheme) handle
blocking/confirmation downstream.
- Runs before remarkStripCjkAutolink so the CJK-strip plugin sees the
new link nodes and can trim trailing CJK punctuation from them.
- Trailing punctuation (.,;!?) stripped from matched URIs.
- Unit tests: conversion, partial-match, escape, explicit link bypass,
code-block bypass, trailing-punct trimming.
* feat(web): linkify custom URI schemes via markdown <a> handler
Wire up 4-layer URI security policy in the markdown renderer:
1. URL sanitize (deny-only): urlTransform strips javascript:/data:/vbscript:/file:
using classifyScheme as single source of truth (handles percent-encoding,
case-insensitive, whitespace-prefix bypass patterns).
2. onClick intercept: custom <A> component classifies each href —
- IANA safe (https/http/irc/ircs/mailto/xmpp): navigate directly.
- Deny (javascript/data/vbscript/file): preventDefault silently.
- Custom (obsidian/vscode/slack/…): preventDefault + open UriConfirmDialog.
3. UriConfirmProvider: one dialog lifted to each markdown root (MarkdownText,
Reasoning, MarkdownRenderer). Shared isAllowed state across all <a> tags in
the subtree — "Always allow" click updates every link in one React commit.
4. Intra-tab cross-provider sync (P7e.1): module-level schemeListeners Set so
sibling UriConfirmProviders (MarkdownText + Reasoning in AssistantMessage)
receive allowed-scheme updates synchronously without waiting for the window
storage event (which only fires in other tabs). Cross-tab sync continues via
the existing window storage event listener.
5. "Always allow" persisted to localStorage (hapi-allowed-schemes). Custom
schemes once allowed navigate directly on subsequent clicks, no dialog gate.
href="#" in DOM for unallowed custom schemes prevents middle-click bypass.
Deny-scheme href="" prevents any navigation even if localStorage tampered.
Security: classifyScheme decodes percent-encoding before scheme extraction,
blocking %6Aavascript:, jav%61script:, javascript%3A (single-encoded colon)
and double-encoded variants. DENY_SCHEMES checked after localStorage lookup so
tampered allowed-list cannot promote deny schemes.
Tests: classifyScheme 6-axis security bypass, denyOnlyTransform, localStorage
roundtrip, cross-tab storage event, <A> click handler cases.
* fix(web): block control-char-spliced deny schemes in classifyScheme
Browsers silently strip ASCII control characters (\t, \n, \r) and
whitespace from URL scheme names during navigation. A scheme like
`java\nscript:alert(1)` was navigated as `javascript:` while our
literal string comparison classified it as 'custom', allowing it
past the deny list and into window.open().
Introduce normalizedScheme() that:
- applies 2 rounds of decodeURIComponent so double-encoded schemes
(javascript%253A → javascript%3A → javascript:) are fully unwrapped
before comparison
- strips [\x00-\x1F\x7F\s] from the extracted scheme name, matching
the browser's own normalization
classifyScheme() now delegates to normalizedScheme() so both the
denyOnlyTransform (urlTransform) path and the <A> onClick path benefit
from the same normalization.
Tests added for \n / \t / \r / space spliced into scheme, and verify
that double-encoded colon is now caught via scheme-match (not just
the no-colon fallback).
* fix(web): preserve relative markdown links from being blocked
Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) were
silently preventDefault'd in <A>'s onClick handler. denyOnlyTransform
correctly passed them through (no colon → not a scheme URL), but the
click handler called classifyScheme(href) which returned 'deny' for
any input with no valid scheme separator — then the deny branch fired.
Add hasScheme(href): checks whether the first ':' appears before any
path/query/fragment boundary ('/', '?', '#'). When hasScheme is false
the href is treated as 'iana' so the browser or SPA router can navigate
normally with no dialog and no preventDefault.
Also wrap renderA() with <I18nProvider> so the UriConfirmDialog that
UriConfirmProvider may render does not throw outside its translation
context during tests.
Fixes a regression that broke all relative-path markdown links once the
custom-URI-scheme onClick handler was added.
* test(web): cover percent-encoded scheme control char + protocol-relative href
Round-5 internal hostile review noted two coverage gaps on the bot-fixup commits:
- `java%0Ascript:alert(1)` (percent-encoded newline in the scheme name) takes the
same decode→strip code path as the literal `java\nscript:` case but was only
tested literally. Add an explicit test so a future refactor that drops the
decode-then-strip ordering would be caught.
- Protocol-relative URLs (`//host/path`) have no colon, so `hasScheme` returns
false and `<A>` treats them as scheme-less — browsers then navigate them as
the current origin's protocol. Existing relative-href tests covered absolute
paths, hashes, queries, and colon-in-path, but not the protocol-relative
variant. Add one assertion.
Also extend the `hasScheme` JSDoc to note that protocol-relative URLs are
intentionally treated as scheme-less.
* fix(web): preserve balanced parens/brackets in autolinked URIs
The trailing-punctuation strip used to drop every `)` / `]` from the end
of a matched URI, even when the URL body had an unmatched opener. So a
URI like `obsidian://open?file=Note(1)` was rendered with href
`obsidian://open?file=Note(1` plus a separate `)` text node, opening a
broken deep link.
Match the GFM autolink-literal behaviour: when the trailing character is
`)` or `]`, keep it iff the URL body has more opening counterparts than
closers (so the trailing closer balances an earlier opener and belongs
to the URL). Other trailing punctuation (`.,;!?:>'"`) and unmatched
closers still strip as before.
Add tests for the balanced cases (`Note(1)`, `Note[1]`, nested
`(a(b)c)`), the "balanced URL followed by a period" case, and a
regression test that an unmatched `).` after a URL is still stripped.
* feat(web): group consecutive tool-use cards
Add a web-only visible projection that groups consecutive root-level execution tools into expandable cards.
Keep approval and question tools standalone, reuse older-history loading on expand, and add regression coverage for grouping and UI behavior.
* fix(web): hydrate oldest visible tool group
Mark needsOlderHistory on the first visible grouped tool run even when earlier visible blocks are non-tool content, and add regression coverage for the boundary.
* fix(web): continue grouped history hydration
Decouple ToolGroupCard older-history chaining from the shared loading flag, invalidate stale hydration runs safely, and add regression coverage for multi-page hydration.
* fix(web): harden grouped tool hydration
- retry incomplete group hydration after transient pagination contention\n- keep approved and denied permissioned tool cards eligible for grouping\n- cover both regressions with targeted web tests
* fix(web): keep Codex permission cards standalone
- treat CodexPermission as a semantic grouping boundary even after approval\n- keep permissioned execution tools groupable while preserving permission milestones\n- add regression coverage for Codex permission eligibility and boundary behavior
* fix(web): narrow incomplete tool-group hydration
- only mark groups at the oldest visible boundary as needing older history\n- avoid auto-paginating complete groups behind text, standalone tools, or permission milestones\n- add regression coverage for the adjacent boundary cases
* feat(web): polish chat rendering
Refresh the web chat presentation across user messages, tool cards, code blocks, diffs, reasoning, and Mermaid diagrams.\n\nAdd focused regression coverage for bubble/status behavior, code and diff rendering, clipboard output, Mermaid theming, and message-window updates.
* fix(web): stabilize chat tool rendering
Preserve manual scroll anchors while older messages and tool dialogs update, and align code, diff, and tool result rendering with chat typography.
Add chat font-weight settings, ignore local Playwright CLI artifacts, document the Angular commit-message convention, and cover the scroll, result, code, diff, and settings behavior with focused tests.
Constraint: User requested committing all current workspace diffs with Angular-style commit messaging
Tested: bun run typecheck:web && bun run test:web && git diff --check
Co-authored-by: OmX <omx@oh-my-codex.dev>
* style(tool-card): polish question and permission card styles
Align AskUserQuestion option surfaces and permission action hierarchy with the existing tool card visual language while preserving interaction logic. Extract shared option presentation helpers and theme-driven hover/muted colors to reduce duplication.
Constraint: Frontend style-only polish; preserve existing permission and answer submission behavior
Rejected: Keep screenshot artifacts in the repo | they are local visual review output, not source
Confidence: high
Scope-risk: narrow
Tested: git diff --check; bun run typecheck:web; bun run test:web; bun run build:web
Not-tested: manual cross-browser visual QA beyond local Playwright inspection
Co-authored-by: OmX <omx@oh-my-codex.dev>
* fix(cli): keep Claude remote plan prompts actionable
Handle Claude remote /plan locally so HAPI switches plan permission mode before forwarding any prompt text. This avoids Claude Code treating /plan as an unknown skill and ending with only a ready event.
Constraint: Claude SDK result messages are not conversation log entries, so command handling must happen before the prompt reaches Claude.\nRejected: surfacing SDK result summaries in web chat | would expose transport-level summaries broadly instead of fixing the slash-command path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Claude remote slash commands that alter runtime mode in the CLI special-command parser.\nTested: bun test cli/src/parsers/specialCommands.test.ts; bun typecheck; git diff --check\nNot-tested: Manual GitHub-hosted runner deployment.
* fix(web): polish tool result rendering
* fix(web): preserve collapsed session order
* fix(chat): settle initial thread scroll
* fix(settings): remove chat font weight option
* fix(web): remove font weight bootstrap code
* chore: remove unrelated branch artifacts
* test(web): update consumed message invocation test
* fix(chat): cancel initial scroll settling on manual scroll
---------
Co-authored-by: huhaoyu.hahahu <huhaoyu.hahahu@bytedance.com>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Normalize hub toast text in the web client for i18n coverage (including Ready for input notifications) and stop deduplicating session rows by agentSessionId so outline/group counts reflect user-visible sessions.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(acp): derive tool_call input from kind+title fallback
Gemini 2.5 Flash and 3 Flash Preview omit rawInput entirely on
tool_call events while emitting prose (non-JSON) thoughts. Neither
the existing rawInput path nor JSON-thought hoisting fires, so the
UI shows "Input: null" alongside a perfectly readable title like
"README.md" or "ls -la /tmp".
Add a conservative fallback that maps known kinds to a minimal
input shape:
read -> { file_path: title }
execute -> { command: title }
search -> { pattern: title }
think -> null (topic-update prose has no clean arg mapping)
unknown -> null (no guessing on shapes we have not verified)
Priority: rawInput > hoisted JSON thought > kind+title derive.
Lock the new behaviour with synthetic unit tests (8 cases) and a
real-Gemini fixture suite captured from gemini-3-flash-preview
and gemini-2.5-flash via ACP stdio (4 fixtures, 33/27/13/4 raw
sessionUpdate events). The fixtures double as regression guards
against future ACP handler changes.
* fix(web): suppress duplicate subtitle when equal to tool title
Gemini ACP emits a tool_call whose title field is a human-readable
summary (often the verbatim shell command or file path). Combined with
the kind+title input fallback, an unknown-tool card ends up with the
same string in both the title and subtitle slots — e.g. title
"cat /tmp/hello.txt" over subtitle "cat /tmp/hello.txt".
Add a guard in getToolPresentation's unknown-tool branch: emit
subtitle only when it differs from toolName. The known-tool and
mcp__* branches are unaffected.
* test(acp): align Gemini fixtures to current model set
- Drop gemini-2.5-flash fixtures: the captures came from a model that
is not part of the PR's evidence model set, and re-running the
capture is gated on quota that is not currently available.
- Refresh gemini-3-flash-preview read_file / run_shell fixtures with
a fresh live capture so they reflect the latest ACP shape (e.g.
a `kind: think` tool_call expressing reasoning when the model emits
no agent_thought_chunk).
- Update fixture-replay expectations: read_file no longer requires
reasoning chunks (zero are emitted on this path) and now requires
>= 2 tool_calls (think + read).
* feat(web): promote semantic title for Gemini ACP tool cards
When the unknown-tool ToolCard would render the same string as both
the title and the subtitle, promote a semantic label to the title
slot so the card reads like a sentence:
cat /tmp/hello.txt → Run shell / cat /tmp/hello.txt
README.md → Read file / README.md
*.ts → Search / *.ts
This is a web-only ergonomic change; the underlying ACP message
shape (tool_name = title, input = derived from kind+title) is
unchanged. Builds on the dedup guard so the title-equals-subtitle
case is now handled by promotion rather than by hiding the subtitle.
* fix(acp): derive tool_call.input for kind=edit from locations[0].path
Gemini's write_file and replace tools both surface as ACP tool_call
with kind="edit" and rawInput omitted. The path lives on locations[0]
from the very first event; the title is prose like "Writing to foo.txt"
or "foo.txt: old => new", which is not safely usable as a file_path.
Extend the kind+title fallback to read locations[0].path when kind is
"edit", and synthesize { file_path } from it. Title fallback is
intentionally not used here so we never feed prose into file_path.
Lock the behaviour in with two new fixtures captured live from
gemini-3-flash-preview (write_file and replace) plus two synthetic
unit tests covering the locations-present and locations-empty paths.
* test(acp): add gemini-3.1-pro-preview fixtures for regression coverage
Captured 4 raw ACP `sessionUpdate` sequences from a live
`gemini-3.1-pro-preview` session via the same isolated hub +
runner + spawn pattern used for the existing flash captures
(read_file 31 events / run_shell 83 events / write_file 4 events /
edit_file 11 events).
The pro tier reuses the same kind/title shape as flash:
`rawInput` is omitted on every tool_call across read / execute /
edit kinds, so the kind+title (and locations[0].path for edit)
fallback is exactly what derives the modal Input. Locking these
fixtures in guards against future regressions on a second model.
The fixture-based regression test gains 4 entries (read / shell /
write / edit) mirroring the flash matrix; assertions are unchanged.
ACP handler suite: 53 -> 57 pass.
* refactor(opencode): declare ModelChange capability and add model field to OpencodeMode
Mark opencode flavor as supporting model change by adding Capabilities.ModelChange
to FLAVOR_CAPS.opencode. Add optional `model` field to OpencodeMode so the
set-session-config handler can carry a model alongside the existing permissionMode.
Pure structural change: no behavior change yet. The mid-session model change RPC
and UI wiring follow in subsequent commits, gated by this capability.
* feat(acp): branch setModel by flavor, capture session models metadata, expose getSessionModelsMetadata on AgentBackend interface
Adds an optional `flavor` argument to `AcpSdkBackend.setModel` so it can
dispatch the right `session/*` RPC for each agent flavor without changing
the call site Gemini already uses. Both Gemini and OpenCode wire to
`session/set_model`; the OpenCode response only carries `_meta.opencode`,
so the backend updates the cached `currentModelId` optimistically while
preserving the previously captured `availableModels`.
Captures `availableModels` and `currentModelId` from `session/new` /
`session/load` / `session/set_model` responses into per-session metadata,
exposed as `getSessionModelsMetadata(sessionId)` on the `AgentBackend`
interface so the hub can forward the snapshot to the web client.
* feat(opencode): accept model in set-session-config RPC and forward to launcher
Mirror the Gemini set-session-config handler so the web UI can change the
OpenCode model mid-session. Validates incoming model strings, persists null
("Default") for keepalive metadata, and pushes a keepAlive immediately so
the hub UI reflects the change without waiting for the next 2s tick.
Forward the model through opencodeLoop and into queued OpencodeMode entries
so the launcher can detect a per-batch model change. Add a setModel helper
on OpencodeSession to store the chosen model on the shared session base.
Wire --model up the runner path: parse --model <value> in commands/opencode.ts
and stop excluding opencode in buildCliArgs so the runner spawns OpenCode
with the user-selected initial model.
* feat(opencode): switch model mid-session via ACP RPC
Mirror the Gemini pattern from PR #543: when a user picks a different model
between turns, call backend.setModel with flavor='opencode' so the ACP backend
sends session/set_session_config_option (configId='model') to the running
OpenCode CLI. The next turn then runs against the new model.
The first batch on a fresh session seeds currentBackendModel without firing the
RPC — the OpenCode CLI was launched with that model via --model and there is
nothing to switch yet. If the running build does not implement the RPC we learn
that from the first method-not-found response, latch inline switching off, and
notify the user once. Other errors fall back to the previous model and surface
a one-line failure message.
* feat(hub): expose model selection and discovery for OpenCode sessions
Generalize the /sessions/:id/model guard via supportsModelChange so any flavor
that advertises the ModelChange capability becomes accepted automatically. This
piggybacks on the capability SSOT introduced in PR #400 and turns OpenCode on
without listing flavors inline.
Add a /sessions/:id/opencode-models endpoint that mirrors the existing
codex-models pattern. The endpoint forwards a per-session listOpencodeModels
RPC to the running OpenCode launcher, which returns the availableModels and
currentModelId metadata captured from the ACP session/new and
session/set_session_config_option responses. The web UI consumes this to
render the model dropdown without round-tripping ACP itself.
* feat(web): render OpenCode model dropdown in the chat composer
Mirror the Codex pattern in SessionChat: query /sessions/:id/opencode-models
via a new useOpencodeModels hook and feed the result into the composer's
availableModelOptions. The AssistantChat model dropdown now lists the user's
ollama / mlx / OpenCode Zen models with the same provider/model label that the
ACP server reports, so the picker matches the OpenCode TUI.
Stop falling back to the Claude composer model list when the flavor is
opencode and no custom options are supplied — that fallback briefly surfaced
unrelated Claude models in OpenCode sessions before the RPC response landed.
The NewSession flow keeps an empty MODEL_OPTIONS.opencode for now: model
discovery requires an active OpenCode ACP session, so the dropdown becomes
available once the session boots and stays empty (and hidden) at creation
time.
* feat(cli,hub): add cwd-based OpenCode model discovery RPC
Adds a short-lived `opencode acp` probe that runs `initialize` +
`session/new` against a target cwd to read the `availableModels` /
`currentModelId` snapshot, then tears the subprocess down. Results are
cached for 60s per cwd and concurrent probes coalesce into a single
spawn.
Exposes the probe through:
- `listOpencodeModelsForCwd` JSON-RPC handler on the CLI
- `RpcGateway.listOpencodeModelsForCwd` / `SyncEngine.listOpencodeModelsForCwd`
- `GET /api/machines/:id/opencode-models?cwd=...` on the hub
This lets the web NewSession form discover OpenCode models for a chosen
directory before any session is spawned.
* feat(web): add OpenCode model selector to NewSession with loading and default highlight
Adds a `OpencodeModelSelector` panel that the NewSession form swaps in
when the OpenCode flavor is selected. The panel:
- queries the new `GET /api/machines/:id/opencode-models?cwd=...` endpoint
via `useOpencodeModelsForCwd` (TanStack Query, 60s staleTime, no retry),
- shows a labelled spinner + skeleton rows while discovering,
- renders an inline error with a Retry button when probing fails,
- renders an empty-state message when the directory yields no models,
- highlights the OpenCode-reported `currentModelId` with a "Default" badge
and auto-selects it (or the first option) so the form has a sensible
value if the user hits Enter without scrolling.
Selection is reset whenever the agent / machine / directory changes so a
new probe can establish a fresh default. Directory input is wrapped in
`useDeferredValue` so per-keystroke edits do not spawn a fresh
`opencode acp` probe. The chosen model is forwarded on session spawn
via the existing OpenCode `model` parameter.
Adds en + zh-CN locale strings for the loading / failure / empty / retry
/ default-badge labels.
* fix(cli): guard /machines/:id/opencode-models handler with workspace root check
The machine-scoped `listOpencodeModelsForCwd` RPC handler was registered by
`registerCommonHandlers` without any workspace-root check, so a web client
could pass an arbitrary `cwd` and have the runner spawn an `opencode acp`
subprocess plus a `session/new` against that path. That broke runner
isolation, since peer machine-scoped handlers (`list-directory`,
`spawn-happy-session`) already enforce the configured workspace root.
Re-register the handler in `ApiMachineClient` so it reuses the existing
`resolveForWorkspaceCheck` (realpath-based, with missing-tail walking) and
`isWithinWorkspaceRoot` helpers before delegating to the lower-level probe.
The resolved cwd is forwarded down so symlinked-but-contained paths still
work, while traversal attempts are rejected with the same error shape the
peer handlers use. Added unit tests around the new dispatch path. Addresses
HAPI Bot review on PR #558.
* fix(web): gate opencode model discovery on cwd existence
The new-session form previously enabled `useOpencodeModelsForCwd` as
soon as the OpenCode agent, machine, and any non-empty directory string
were present. Because that hook calls `/machines/:id/opencode-models`
and the CLI handler starts an `opencode acp` probe for that cwd, normal
typing through partial paths could launch expensive 30s OpenCode
subprocesses for non-existent directories before the path-existence
result had validated the final cwd.
Reorder NewSession so `useMachinePathsExists` runs before
`useOpencodeModelsForCwd`, then gate the discovery hook on the
directory having been positively confirmed to exist
(`pathExistence[deferredDirectory] === true`). The decision is
factored into a small pure helper `shouldEnableOpencodeModelDiscovery`
so the contract can be unit-tested without provider scaffolding.
* fix(web): keep current opencode model on shortcut without dynamic options
`getNextModelForFlavor` is invoked by the global Ctrl/Cmd+M shortcut in
`HappyComposer`, which is now active for OpenCode sessions because the
agent backend declares the `ModelChange` capability. When the dynamic
OpenCode model list has not yet been loaded — e.g. the user presses the
shortcut before `/opencode-models` returns — the function received an
`undefined`/empty `customOptions` and fell through to the Claude preset
cycler, which would emit `sonnet`/`opus` for an OpenCode session. The
following turn then attempted `session/set_model` with a Claude model id
that no OpenCode provider can serve.
Add an `opencode` branch that returns the (normalized) current model
unchanged when no dynamic options are available, mirroring the existing
empty-list policy of `getModelOptionsForFlavor`. Unit tests cover the
undefined / empty / null-current-model variants and lock the
no-Claude-fallback contract. Addresses HAPI Bot review on PR #558.
* refactor(web): extract shared task tool helpers
* feat(web): show subagent task trace in tool dialog
Task tool modals previously showed only Input and Result. This adds a
Trace section between them that surfaces the child tool calls already
wired through the reducer into block.children.
- TraceSection collapses by default when completed, expands when
running or error so the relevant state is visible on open
- Each child row toggles an inline expand (Input/Result) to avoid
nested Dialogs
- Header summarises call count, token total and duration via
readSummaryFields() typed parser, falling back gracefully when any
value is absent
- formatTaskChildLabel / TaskStateIcon imported from shared helpers.tsx
(extracted in prior refactor commit) — no local duplicates
- Task name guard: getTaskTraceChildren returns null for non-Task blocks
- children prop renamed to items in TraceSectionInner / TraceChildList
(react/no-children-prop anti-pattern removed)
- i18n: tool.trace and tool.trace.callsSuffix keys added for en and
zh-CN; useTranslation hooked up to header label and calls suffix
- 15 unit tests: getTaskTraceChildren (guard, filter, non-Task null),
getTraceSummaryText (3 branches), TraceSection (open/close/toggle/
summary/empty)
* feat(web): include input view in trace row expand
Expanded child rows in the Task trace section now render both an Input
section and a Result section, matching the pattern used in the parent
ToolCard dialog. Tools with a registered FullInputView use it; all
others fall back to a JSON CodeBlock. Closes bot review on PR #539.
* feat(web): add workspace browser for multi-directory navigation
Add /browse route with a folder browser that lets users navigate
filesystem directories on connected machines and launch sessions
from any folder. Supports saved workspace paths and direct path
input. The "Start Session" action pre-fills the NewSession form.
- CLI: register machine-level `list-directory` RPC handler
- Hub: add POST /machines/:id/list-directory route
- Web: add WorkspaceBrowser component with git repo detection
- Web: add /browse route with navigation from sessions sidebar
- Web: support initialDirectory/initialMachineId in NewSession
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add --workspace-root opt-in scoping for /browse and session spawn
Adds a single new flag, \`--workspace-root <path>\` (with \`~\` / \`~/foo\`
expansion), on \`hapi runner start\` and \`hapi runner start-sync\`.
When set:
- The runner reports the path in machine metadata.
- The list-directory and spawn-session RPC handlers reject paths outside
the root, so the web UI can't escape the configured tree even if
someone crafts a request manually.
- The /browse page in the web UI auto-opens that root, restricts the
breadcrumb / go-up to its subtree, and shows directory entries with
git-repo annotations.
- The /sessions/new form keeps its existing free-text directory input
plus autocomplete + recent-paths chips, and gains a small "Browse"
button (next to the input) that opens /browse for picking a folder.
- Reconnect-time metadata sync ensures stale records get the field
filled in (or cleared when the flag is dropped on a later restart),
so the hub state matches the CLI's intent.
When unset:
- Runner behaves like the legacy hapi (no scoping, no browse feature).
- /browse renders an informative state pointing at the flag instead of
blocking the user.
- The /sessions/new form looks identical to the pre-change behavior;
the "Browse" button is hidden.
Includes a startup banner so \`runner start-sync\` no longer looks like
it hung, and surfaces the workspace-root sync result on stdout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hub): preserve workspaceRoot when rehydrating machines from store
MachineCache.refreshMachine() rebuilt the metadata object from an
explicit field allowlist, so any field not in the list (including the
new workspaceRoot) was silently dropped on every read — even though it
was correctly written to the store.
Add workspaceRoot to the zod schema, the Machine interface, and the
hand-rolled projection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): friendlier empty state on /sessions
When there are zero sessions the page used to be a vast blank
rectangle with just the "0 sessions in 0 projects" caption. Render a
centered empty state instead: a calendar/agenda icon, a short heading
and hint, and two buttons — "Start a session" (→ /sessions/new) and
"Browse workspace" (→ /browse).
SessionList gains an optional onBrowse prop. Router wires it on the
sessions page so the secondary button resolves; other callers can leave
it unset to hide that button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: document --workspace-root flag in cli/README and root README
Add a short paragraph under "Runner management" in cli/README.md
explaining what \`--workspace-root\` enables (scoped /browse tree,
list/spawn enforcement, tilde expansion) and that omitting it keeps
the legacy behavior. Mention the workspace browser in the top-level
README's Features list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR #526 review feedback
Three findings from the review bot:
1. [Major] Workspace-scope check was lexical only. With workspaceRoot
= /safe, a symlink such as /safe/out -> /etc would pass the relative-
path test and let list-directory / spawn-happy-session reach paths
outside the configured root. realpath the workspaceRoot at construction
time, and resolve every incoming path through realpath (walking up to
the nearest existing parent for spawn targets that haven't been
created yet) before the containment check.
2. [Minor] \`hapi runner start --workspace-root\` with no value used to
drop the flag silently and start the runner unscoped. Now treats a
missing or flag-shaped next argument as an error.
3. [Minor] /sessions/new's "Browse" button always opened /browse using
localStorage's last-used machine, ignoring the user's current
selection. NewSession already passes machineId in its callback;
forward it through the /browse search params and seed
WorkspaceBrowser with it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): gate list-directory RPC behind --workspace-root opt-in
Without a configured workspaceRoot, isWithinWorkspaceRoot() returns
true unconditionally, leaving the new list-directory RPC able to
enumerate any path on the runner. The Web UI already hides Browse
for these machines, but the backend should enforce the opt-in too.
Refuse the RPC up front when no workspace root is configured.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(web): reconnect SSE immediately when tab becomes visible
The SSE watchdog skips heartbeat checks while the tab is hidden. If the
connection dies in the background, the user sees stale messages after
switching back and has to wait up to 10 s for the next watchdog tick.
Add a visibilitychange listener that checks heartbeat staleness
immediately when the tab becomes visible and reconnects if stale.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): map visibility-recovery reason in reconnecting banner
The new 'visibility-recovery' reconnect reason was not mapped in
getReasonLabel(), so the raw string would appear in the UI banner.
Add localized labels for both en and zh-CN.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* feat(web): add appearance setting (follow system / dark / light)
Add user-facing appearance preference to the settings page, allowing
users to choose between Follow System, Dark, and Light themes. The
preference is persisted to localStorage and takes priority over
automatic detection in the existing theme pipeline.
* fix(web): update theme on cross-tab appearance change
The storage event handler only updated React state without calling
updateScheme(), leaving data-theme and useTheme subscribers stale.
* fix(web): move cross-tab appearance sync to global initializeTheme
The storage event listener was inside useAppearance(), which is only
mounted on the settings page. Other pages never received cross-tab
theme updates. Move the listener into initializeTheme() so all pages
respond to appearance changes from other tabs.
Replace hardcoded English strings with translation function calls
in SpawnSession.tsx and router.tsx. Add newSession.title key for
the NewSessionPage header to match the newSession.* namespace.
- Add ReconnectingBanner component to show "Reconnecting..." status
- Track SSE connection state in App.tsx with onDisconnect handler
- Add onBlocked callback to useSendMessage for handling blocked send attempts
- Provide haptic feedback when send is blocked due to no API or session
- Show toast notification when message cannot be sent due to server disconnection
- Add translation keys for reconnecting message and send blocked feedback in en/zh-CN
close#125
* feat(web): add About section to settings page
- Add website link to hapi.run
- Display app version from CLI package
- Display protocol version from shared module
- Add Vitest testing setup with settings page tests
🤖 Generated with Claude Code
* test(web): add tests for website link and i18n key usage
Address residual risks mentioned in PR review:
- Test website link URL and security attributes (target, rel)
- Verify correct i18n keys are used for About section via spy
Simplify test setup by using real I18nProvider and en locale.
🤖 Generated with Claude Code