Commit Graph
393 Commits
Author SHA1 Message Date
e6b723f693 feat(web): auto-focus terminal on open (#876)
When the terminal page loaded, focus stayed on the page body — the user
had to click/tap into the xterm area before keystrokes were captured.
Add terminal.focus() at the end of handleTerminalMount so focus lands
inside the terminal as soon as the xterm instance is attached to the DOM.
The quick-input buttons already call terminalRef.current?.focus() after
each press; this extends the same pattern to the initial mount.

Closes #875

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 09:10:54 +08:00
55d1bbb7bd feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844)
* feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP

Closes #824

When the operator reopens a legacy stream-json Cursor session in HAPI,
the hub now transparently transplants its `~/.cursor/chats/<wsh>/<uuid>/store.db`
into `~/.cursor/acp-sessions/<uuid>/`, verifies it loads via `agent acp`,
flips `metadata.cursorSessionProtocol = 'acp'`, and removes the legacy
source - all before `resumeSession` returns. Subsequent opens are pure ACP.

The primary justification is safety, not feature parity. #784 (`cursor-agent`
fabricates `Questions skipped by the user` responses in legacy stream-json
mode) still fires regularly in dogfood despite #801's mitigation: the agent
ships destructive side effects against fabricated consent. Migration to ACP
closes the protocol-level door because the `AskQuestion` tool does not exist
on the ACP side, so there is nothing to fabricate.

working. That tradeoff was reasonable at the time. The accumulated #784
evidence makes legacy sessions actively unsafe; this PR makes the upgrade
path invisible enough that users stop avoiding it.

A pre-PR spike established that legacy and ACP `store.db` files use the
identical SQLite schema; only the directory layout differs. The migrator
therefore:

1. Sanity-checks the source store and pre-flips state (`session.active`,
   `lifecycleState`, on-disk presence, target collision)
2. Optionally archives a stale-running row (`forceArchiveRunning: true` is
   the default for the auto-migrate path because the caller already
   verified `session.active === false`)
3. Atomically creates `~/.cursor/acp-sessions/<uuid>/` with mode `0o700`
4. Copies `store.db` and chmods to `0o600` (multi-user-host hardening)
5. Writes a minimal `meta.json` sidecar (`schemaVersion`, `cwd`, optional
   `title`) with mode `0o600`
6. Spawns `agent acp` under HAPI_HOME isolation and verifies the session
   loads via `session/load`. On long histories the verify also drives a
   trivial single-turn prompt; on short ones load-only is enough
7. Flips `cursorSessionProtocol = 'acp'` AND clears the
   `cursorMigrationState` banner flag in a SINGLE metadata write
8. Removes the legacy source store (only after verify succeeded and the
   protocol flip committed). The legacy `~/.cursor/chats` parent dir is
   left as-is

Every failure leaves the legacy state intact. No `rm` fires without a
verify success AND a committed protocol flip.

The transplant takes 15-20s on long histories (copy a multi-hundred-MB
store, spawn `agent acp`, replay thousands of notifications, tear down
the probe). Without a progress indicator the wait reads as "broken" to a
fresh reviewer. A minimal banner ships alongside the migrator:

- Hub sets `metadata.cursorMigrationState = 'in_progress'` BEFORE the
  long-running transplant. The session-cache refresh emits the existing
  `session-updated` SSE event (no new event type), so the web client
  picks it up in milliseconds. No client-side polling needed.
- Hub clears the flag in the SAME metadata write that flips
  `cursorSessionProtocol` to `'acp'` on success, so the banner disappears
  in the same render tick the chat re-renders as ACP - no flicker window.
- Hub clears the flag explicitly in the auto-migrate helper's `finally`
  on failure/exception, so the banner never gets stuck if migration
  falls back to the legacy launcher.
- Web renders an accessible (role=status, aria-live=polite) banner with
  an indeterminate spinner. Deliberately no fake percentage - we do not
  have phase data and a fake progress bar would lie.

This PR is intentionally sequenced AFTER swear01's three ACP mop-up PRs
(merged today as ad038bbf, 8094b500, fa363c2f), all of which are
prerequisite for safe concurrent ACP launches.

The verify probe spawns `agent acp` directly via `AcpVerifyProbe` under
HAPI_HOME isolation (the migrator overrides `HOME` to a temp dir for the
verify pass), so it never touches `<real-HAPI_HOME>/locks/agent-acp-active/`
at all. Per swear01's #835 design note, the post-flip ACP launcher claims
the lock through the standard `registerActiveAcpTransport` entry and
behaves like any other concurrent ACP start. The migrator itself never
writes `pid` or `count` files directly.

The auto-migrate path is gated by `HAPI_CURSOR_LEGACY_AUTO_MIGRATE`. Set
to `0`, `false`, `no`, or `off` to suppress it entirely (legacy sessions
keep running through the existing stream-json launcher). Default is on.

A REST endpoint at `POST /api/sessions/:id/migrate-to-acp` allows
explicit migration of a single session outside the sync-on-open path
(e.g. for a specific cold archived session a user wants to re-engage).
Bulk migration surfaces (CLI subcommand, web button, bulk REST endpoint,
candidate-listing API) were deliberately stripped per reviewer feedback;
per-session sync-on-open + this escape hatch are the only two paths.

- 343 hub unit tests (4 new for the migration banner flag transitions,
  53 for the migrator core, 32 for the verify probe, 15 for the auto-
  migrate helper guard matrix, plus existing suites)
- 3 integration tests against a real `agent acp` (skipped by default
  unless `HAPI_CURSOR_LEGACY_MIGRATOR_INTEGRATION=1`)
- 10 web unit tests for the banner component (visibility paths + a11y)
- Typecheck clean across cli, web, hub

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): address upstream Codex review findings on #844 (2 majors)

Finding 1 (Major) — verify probe `agentLookupHome` ignored `metadata.homeDir`
in service-account hub deployments. `migrateOne` resolved the legacy store
under `metadata.homeDir` (the recorded session-owner home) but the default
createProbe factory still set `agentLookupHome` from `this.deps.homeDir()`
(the hub user's home). On a service-account hub, the store lookup
succeeded but `agent acp` discovery fell back to the hub user's
`~/.local/bin`, so verify silently failed and sync-on-open quietly fell
back to legacy.

Fix:
- Widen `CursorLegacyMigratorDeps.createProbe` signature from
  `(env) => AcpVerifyProbe` to `(env, agentLookupHome) => AcpVerifyProbe`
- Default factory uses the passed `agentLookupHome`
- `verifyInTempHome` threads `opts.sourceHome` (already the resolved
  session-owner home) through as the 2nd arg

2 new regression tests pin the contract:
- service-account case (metadata.homeDir != deps.homeDir()): captured
  agentLookupHome MUST equal metadata.homeDir
- legacy session record (no metadata.homeDir): falls back to
  deps.homeDir() correctly

Finding 2 (Major) — `bun.lock` win32-x64 pinned to 0.20.0 while
`cli/package.json` required 0.20.1 (rebase artifact from the v0.20.0 →
v0.20.1 release commit landing in upstream/main between the original
spike and the rebase). Frozen-install Windows users would either get
the wrong native binary or have the lock rejected.

Fix: regenerated bun.lock so the entry resolves
`@twsxtd/hapi-win32-x64@0.20.1`. `bun install --frozen-lockfile` now
passes clean.

Test budget:
- 391 hub unit tests pass (2 new for createProbe agentLookupHome
  contract, +0 regressions)
- Typecheck clean across cli + web + hub
- `bun install --frozen-lockfile` clean

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:57:55 +08:00
SSU-WEI HUANGandGitHub ddf3a5545b fix(web): add missing i18n keys for session.inactive banner (#851) 2026-06-10 13:57:16 +08:00
cad58cfa0b fix(opencode): use ACP-reported reasoning effort options (#853)
* fix(opencode): use ACP-reported reasoning effort options

Expose thought_level options from OpenCode ACP to the web UI via RPC/API
instead of hardcoded presets, and validate effort values before setConfigOption.

Fixes #852

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(opencode): sync hub effort after coerced setConfigOption

When resolveThoughtLevelEffort falls back to a different supported value,
roll back session state after a successful ACP update so keepalive and the
web UI do not keep advertising the rejected effort.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:57:03 +08:00
3473a88d67 feat(web): auto-return to chat when remote terminal exits (#857)
When the shell exited inside the remote terminal view, the page kept
showing a banner ("Terminal exited with code 0.") and left the user
stranded with no obvious next step. On mobile this is awkward, and it
does not match the muscle memory from native terminal emulators where
typing `exit` closes the tab/window.

Schedule a goBack() shortly after `terminal:exit` fires so the user
briefly sees the exit info, then returns to the session chat (same
destination as the existing back arrow via useAppGoBack).

The auto-close timer is cleared on unmount, on sessionId change, and
when the socket reconnects after a transient drop so a stale exit
event cannot navigate away from a freshly reconnected terminal.

Closes #856

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:56:50 +08:00
hangerandGitHub e2625b8b47 feat: add Fable model presets for Claude sessions (#860)
* feat: add Fable model presets for Claude sessions

Claude Code 2.x accepts the fable / fable[1m] model aliases for
Fable 5. hapi passes the model string through verbatim, so adding
the presets to CLAUDE_MODEL_LABELS surfaces them in the new-session
and composer model pickers, labels, and the 1M context-window
heuristic.

* test: update modelOptions full-list assertions for Fable presets

Addresses review feedback on #860: getModelOptionsForFlavor appends
every Claude preset, so the two complete-array expectations must
include the new fable entries.
2026-06-10 13:56:30 +08:00
393cd7bfbb feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive (#798)
* feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive

The v1 always-visible amber band proved too heavy for what's a 20% feature
in a typical session. v1.1 dials it back to a composer-toggle that opens
an on-demand drawer, paired with a reusable FUE (First-User Experience)
primitive so existing operators get a subtle pulsing dot + on-click
explainer the first time they see the toggle.

UX changes:
- Notepad icon in the composer toolbar (next to schedule-send) toggles
  scratchlist mode. Drawer renders only while mode is on.
- Composer's send button repaints amber and reads "Send to scratchlist"
  while the mode is sticky; submit routes adds into the scratchlist
  instead of the chat. Click the icon again to leave.
- Small entry-counter badge appears on the toggle when entries exist;
  empty-state shows just the icon (no zero-state guilt UI).

New reusable FUE primitive:
- web/src/lib/use-fue.ts: state machine (unseen → engaging → acknowledged)
  with localStorage persistence, namespaced under hapi.fue.v1.<featureId>
  so it can't collide with any future upstream onboarding flow.
- web/src/components/Fue.tsx: <FueDot> (small pulsing badge) and
  <FueCallout> (portal-rendered popover with title/body + "Got it"
  affirmative-action dismiss). No auto-timeout — reading speed varies
  and silent disappearance undercuts user trust.
- AGENTS.md adds a "Adding new web features — consider an FUE" section
  so future contributors discover the primitive.

Refactors:
- ScratchlistPanel.tsx: split rendering into <ScratchlistInventory>
  (presentational list) and <ScratchlistDrawer> (composer-controlled
  drawer with hint copy). Original <ScratchlistPanel> kept exported
  for the existing fixture-based tests.
- SessionChat.tsx: scratchlist state lifted into useScratchlist hook
  so the composer-toolbar counter and the drawer share one source of
  truth. onSend wrapped to route through scratchlist.add when mode
  is on.

Tests:
- 9 useFue hook tests (initial state, engage idempotency, no
  auto-acknowledge, dismiss, featureId switching, post-acknowledged
  engage no-op, resetFue helper).
- 5 placement helper tests (above/below switching, viewport edge
  clamping, visualViewport offset support).
- All 21 existing scratchlist lib tests + 14 ScratchlistPanel tests
  continue to pass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): prevent cross-session leak in useScratchlist hook

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > useScratchlist persists current `entries` whenever `sessionId`
  > changes. On A -> B navigation, React first commits with B's id
  > and A's entries; after paint, this persist effect can write A's
  > entries to hapi.scratchlist.v1.B before the rehydrate effect
  > loads B. The previous keyed panel existed specifically to avoid
  > this race.

Lifting state out of the v1 panel (which sidestepped the race via
key={props.session.id} forced remount) re-introduced this same data-
loss window. The composer-controlled drawer in v1.1 cannot remount
on session change because its parent SessionChat doesn't either.

Fix: keep the loaded sessionId in state alongside the entries so they
swap atomically, and persist against the LOADED sessionId rather than
the prop. After A->B, the loaded sessionId is still A until rehydrate
runs, so a spurious persist re-writes A's storage with A's entries -
a no-op instead of a corruption.

Tests:
- New use-scratchlist.test.ts with 6 tests:
  - hydrates from localStorage on mount
  - add() persists to current session's storage only
  - rerender to a new session preserves the new session's existing entries
  - after switching, add() targets the new session
  - regression test that spies on Storage.prototype.setItem and asserts
    the rerender lifecycle never produces a (B-key, A-entries) write
  - remove()/move() target the loaded sessionId
- The setItem-spy test correctly fails against the buggy code (verified
  by temporarily reverting the fix) and passes with the fix in place.
- Full web suite: 88 files, 756 tests, all green. Typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): route attachment/scheduled submits to chat instead of dropping them

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > Prevent scratchlist mode from dropping attachments — in scratchlist
  > mode the wrapper returns success after adding only `text`, while
  > HappyComposer still treats composer attachments as sendable input.
  > A text+attachment submit therefore routes through this branch,
  > stores only the text, and silently discards the attachment instead
  > of sending or preserving it.

Same hazard applies to scheduledAt: scratchlist entries are pure-text
notes - they can't represent attachments or schedule metadata - so any
submit carrying either MUST fall through to props.onSend (chat) even
when the scratchlist toggle is on. Otherwise the wrapper short-circuits
to scratchlist.add(text), reports success to the composer, and the
composer dutifully clears attachments + schedule that the user just
queued.

Fix: extracted the routing rule into shouldRouteToScratchlist(mode,
attachments, scheduledAt) - returns true only when mode is on AND the
payload is pure text. onSendForComposer uses it.

Tests:
- 5 new shouldRouteToScratchlist unit tests (mode off, mode on +
  text-only, mode on + attachments, mode on + schedule, mode on + both)
- All in web/src/components/SessionChat.test.ts (13 tests total now)
- Full web suite: 88 files, 761 tests, all green. Typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): clear pendingSchedule when scratchlist-mode submission falls back to chat

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > The follow-up change correctly falls through to props.onSend when
  > scratchlist mode is on but scheduledAt is present, yet the
  > accepted-send cleanup still checks only !scratchlistMode. That
  > means a scheduled chat send made while the amber scratchlist UI is
  > active is accepted, but pendingSchedule stays set, so the next
  > normal send can accidentally reuse the same schedule.

Fix: handleSend now gates the cleanup branch on the actual route taken
(routedToScratchlist) rather than the scratchlist UI state. Reuses the
same shouldRouteToScratchlist helper so route + cleanup share a single
source of truth.

Tests:
- 2 new tests in SessionChat.test.ts that pin the decision matrix
  handleSend depends on:
  - 'cleanup gate: scheduled chat send while scratchlist toggle is on
     still clears schedule'
  - 'cleanup gate: pure-text scratchlist add does NOT clear schedule'
- Full web suite: 88 files, 763 tests, all green. Typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): UnifiedButton must reflect actual routing, not raw scratchlist toggle

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > Send button advertises scratchlist routing even when the submit
  > will go to chat — shouldRouteToScratchlist correctly falls back
  > to normal chat for attachments or scheduledAt, but UnifiedButton
  > still turns amber and labels the action as "Send to scratchlist"
  > whenever scratchlistMode is true. A scheduled send or attachment
  > send made in that state will be submitted to chat while the UI
  > says it is being stashed, which can send content to the agent
  > unexpectedly.

Fix:
- UnifiedButton's prop renamed `scratchlistMode` -> `routesToScratchlist`
  to make the contract explicit: "this submit really will go to the
  scratchlist", not "the scratchlist toggle is on".
- The call site computes `routesToScratchlist` from
  `scratchlistMode && !hasAttachments && pendingSchedule == null`,
  mirroring SessionChat's shouldRouteToScratchlist exactly. The button
  is now amber + "Send to scratchlist" only when the actual send path
  will hit scratchlist; attachments / pending schedule force a chat-
  style render that matches the real routing.
- UnifiedButton exported so it can be unit-tested directly.

Tests:
- 3 new render tests in ComposerButtons.test.tsx covering:
  - routesToScratchlist=true → amber + "Send to scratchlist"
  - routesToScratchlist=false → black + "Send" (the regression case)
  - omitted prop → defaults to chat-style render
- Full web suite: 89 files, 766 tests, all green. Typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): exit scratchlist mode when promoting an entry to the composer

Per upstream review on PR #798 (HAPI Bot, follow-up after b256fe5):

  > Found one major issue: promoting a scratchlist item to the composer
  > keeps scratchlist mode enabled, so the next send re-adds it to the
  > scratchlist instead of sending to chat.

Promoting an entry to the composer means "I want to send this for real
now". With scratchlist mode still on, the next composer submit routes
back to scratchlist (per the v1.1 modal-mode contract), so the user's
click loop becomes promote -> send -> re-add -> nothing-actually-sent.

Fix: ScratchlistDrawerHost now calls onExitScratchlistMode whenever it
promotes an entry to the composer. Promote-to-queue does NOT exit the
mode (queue path bypasses the wrapper anyway, and the operator may
still be capturing related notes).

Tests:
- Exported ScratchlistDrawerHost so its host-level callbacks can be
  unit-tested in isolation (previously only ScratchlistDrawer was
  testable; the wiring was untested).
- New SessionChat.exit-mode.test.tsx with 2 tests:
  - promote-to-composer fires setText AND onExitScratchlistMode
  - promote-to-queue fires onSend but does NOT exit mode
- Full web suite: 90 files, 768 tests, all green. Typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(scratchlist): Ctrl/Cmd+Shift+S toggles scratchlist mode (v1.1 hotkey)

The v1 always-visible panel had Ctrl/Cmd+Shift+S to expand the panel and
focus the input. v1.1 mounts the drawer only when scratchlistMode is on,
so the v1 listener (inside the panel) is dead code: it can't fire while
the drawer is unmounted, and the user has no way to open the drawer
without clicking the toolbar icon. Re-bind the shortcut at SessionChat
scope so it's always alive and toggles the mode.

Convention matches sibling globals (Ctrl/Cmd-m cycles agent model).
Ctrl/Cmd-Shift-S is unreserved by Chrome / Firefox / Safari (browser
Save As is Ctrl-S / Cmd-S, no Shift), so the user's save-page muscle
memory keeps working. Modifier requirement (Ctrl/Cmd+Shift) means it
can't collide with literal-character typing in any input - no focus
suppression needed.

The matcher is extracted to a pure helper isScratchlistToggleHotkey
so it's unit testable without mounting SessionChat. 6 new tests pin
the modifier matrix:
  - Ctrl+Shift+S (Linux/Windows) -> match
  - Cmd+Shift+S (macOS)          -> match
  - Cmd/Ctrl+S without Shift     -> reject (browser Save reservation)
  - bare S / Shift+S             -> reject (literal typing)
  - Ctrl+Shift+Alt+S             -> reject (avoid OS clashes)
  - other modifier+key combos    -> reject

Tooltip + FUE body now mention the hotkey so it's discoverable from
the same UI surface that introduces the feature (en + zh-CN).

Web suite 90 files / 774 tests, all green. Typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): hotkey skips dialogs / inputs / contentEditable

Bot finding on PR #798 (PRRT_kwDOQuQOSc6HGtLn): the window-level
Ctrl/Cmd+Shift+S listener fires for every focus target, so the
shortcut can toggle scratchlist mode "behind" an open modal (rename
session, schedule picker, FUE callout, image preview), making the
next composer send route to scratchlist instead of chat. UX bug.

Add isScratchlistHotkeyBlockedTarget(target) and gate the listener
on it. Block targets:

  - any descendant of an open [role="dialog"] (Radix UI's
    DialogContent renders role="dialog"; FueCallout, ScheduleTimePicker,
    ImagePreview also use role="dialog")
  - HTMLInputElement (single-line inputs)
  - HTMLSelectElement
  - any contentEditable host (with attribute-based fallback for jsdom,
    which doesn't implement isContentEditable)

NOT blocked:
  - HTMLTextAreaElement (the composer textarea is the expected focus
    target when the operator presses the hotkey - blocking would
    defeat the shortcut)
  - the document body / unfocused targets

8 new unit tests pin the matrix. Function is exported / pure so
callers can reuse the same blocked-target rule for future global
shortcuts. Suggested fix from the bot applied modulo:

  - Use !== null on closest() result (explicit boolean for return type)
  - Add attribute-based contentEditable fallback for jsdom test env

* feat(scratchlist): copy-to-clipboard action on each entry

Add a per-entry "Copy to clipboard" button between the send-to-queue and
delete actions. On click, write the entry text via the shared
safeCopyToClipboard helper (which already handles the navigator.clipboard
primary path + the execCommand fallback for Safari / non-secure-context
edges); on success, briefly flip the icon to a check and the
aria-label/title to "Copied!" for 1500ms so the operator gets visual +
screen-reader confirmation. Failures (clipboard denied AND execCommand
fallback unavailable) silently no-op rather than throw at the click
handler.

Mirrored across both surfaces:
  - ScratchlistInventory (used by the v1.1 composer-toggle drawer)
  - ScratchlistPanel inline list (the v1 always-visible panel)

A small useCopiedFeedback() hook owns the "which entry just got copied"
state + the 1.5s auto-clear timeout. Pure state machine; the caller
wires safeCopyToClipboard separately so the hook itself stays free of
jsdom clipboard quirks. Cleared on unmount via the standard ref-tracked
timeout pattern, so promote-and-navigate-away can't leak.

Locale keys: scratchlist.action.copy / scratchlist.action.copied (en + zh-CN).

Three new tests:
  - v1 panel happy path: writeText called with the entry text, button
    flips to the "Copied!" label, entry is preserved (copy is non-destructive).
  - v1 panel failure path: writeText rejects AND execCommand returns
    false; button stays in "Copy to clipboard" state — no false success.
  - v1.1 drawer happy path: writeText called, label flips, and crucially
    no other entry handlers (onSend, onDelete, setText, onExitScratchlistMode)
    fire — copy is independent of all the other actions.

Web suite 90 files / 785 tests, all green. Typecheck clean.

* fix(scratchlist): reset all per-session state via keyed wrapper

Bot finding on PR #798 (PRRT_kwDOQuQOSc6HHOsa): when the operator
navigates between sessions on the same route (/sessions/A ->
/sessions/B), React reuses the SessionChat component instance.
Effects run AFTER the first paint, so for a single render window the
new session is rendered with the previous session's scratchlist
entries (useScratchlist's rehydrate-effect) AND drawer-open state
(scratchlistMode reset effect). Visual leak; drawer actions targeting
stale state.

Apply the bot's suggested fix verbatim modulo the type extraction:

    export function SessionChat(props) {
        return <SessionChatInner key={props.session.id} {...props} />
    }

Canonical React idiom for "fully reset state on prop change": the
keyed wrapper unmounts and re-mounts the inner component when
session.id changes, so every hook (useScratchlist's initial-state
factory, useState, useHappyRuntime, ...) starts fresh. This
supersedes the now-redundant effect-based reset:

  - useEffect(() => { setScratchlistMode(false) }, [session.id])  REMOVED

useScratchlist's atomic-loaded-sessionId persistence (added on the
prior PR round) stays as defense-in-depth for any caller that uses
the hook without the keyed-wrapper pattern.

Web suite 90 files / 785 tests, all green. Typecheck clean.

* fix(web): retain composer text on send failure (closes #776)

When the message composer submits and the hub responds with a 4xx/5xx
or the fetch fails outright, assistant-ui clears the composer
synchronously the moment send is invoked. Without intervention the
operator's typed text is destroyed at exactly the moment they most
need it preserved. SessionChat additionally clears any pending
schedule on accept, so a failed scheduled send was also silently
downgrading to immediate on the next attempt.

Behaviour:

- useSendMessage exposes onError({ sessionId, text, scheduledAt, error })
  so the route can hand the input back to the composer. sessionId is
  the resolved target (post-resolveSessionId), so an inactive-session
  resume that resolves a new id, kicks off async navigation, then
  fails the POST restores into the resumed session's composer rather
  than the old one.

- router.tsx stores sendErrors keyed by sessionId. Per-session lookup
  replaces the clear-on-session-change effect, so errors do not bleed
  between sessions and a session-scoped failure persists across
  navigation.

- HappyComposer accepts ComposerSendError, restores text via
  api.composer().setText() once per failure id, and re-establishes any
  pending schedule via onSchedule({ type: 'absolute', ms: scheduledAt }).
  It renders a red ring on the composer wrapper and a role="alert"
  inline message; both clear the moment the operator types or sends.

- onError forks on input.attachments. Text-only sends use the
  composer-restore path (removeOptimisticMessage drops the row so the
  failed bubble does not duplicate the restored text). Attachment
  sends keep the legacy failed-bubble UX (status='failed' + in-thread
  retry button) because the composer-restore path can't reinstate
  uploaded attachment metadata. retryMessage extracts attachments
  from the stored optimistic message via getMessageAttachments so
  failed-bubble retry of an attachment send re-fires with its files.

Acceptance (issue #776):

- Submit -> 500/502/503/network error -> composer text not cleared
- Submit -> 400/401/403 -> composer text not cleared, error inline
- Submit -> 2xx -> composer clears as today
- Operator can edit retained text and retry without re-typing
- Failed scheduled sends restore as scheduled, not as immediate

Tests in web/src/hooks/mutations/useSendMessage.test.tsx cover
text-only 4xx/5xx/network retention, scheduled-send carry-through,
optimistic-row removal on text-only failure, sessionId carry-through
under resolveSessionId, attachment failure fallback, and attachment
retry preservation. Full web suite passes (705 tests). bun typecheck
clean. No SCHEMA_VERSION bump (frontend-only).

* fix(test): correct AttachmentMetadata fixture shape + JSX namespace import

Two pre-existing test-only typecheck failures surfaced once scratchlist v1.1
was stacked into the driver soup.

* SessionChat.test.ts - the attachment() fixture used the legacy schema
  (kind, sizeBytes) instead of the current AttachmentMetadataSchema
  (filename, size, path). Updated to match the live shape so the cast
  is honest.

* ComposerButtons.test.tsx - JSX namespace is no longer global under
  the current TS lib config; switched the helper signature from
  JSX.Element to React's ReactElement (same runtime, named import).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): soften panel chrome - drop strong amber fill, keep subtle accent border (#812)

Per #812 (and PR 827 from @swear01) the always-visible amber fill
on the scratchlist panel was too loud as a scroll element. This
swaps the warning *fill* for the chat-user-surface tone and uses
neutral text/pills/focus, but keeps the warning *border* as a soft
accent so the panel still reads as a different destination from a
normal user message.

The strong destination signal continues to live on the composer
Send button (it goes amber-500 only while scratchlist mode is
routing) and the active toggle button - those carry the
moment-of-action signal the user actually presses, and ComposerButtons
tests + the FUE copy already depend on that behavior, so they're
unchanged.

Credit to @swear01 (PR 827) for the styling note; this branch
absorbs that restyle and supersedes the Settings-toggle approach
because v1.1 hides the panel by default behind the composer drawer
toggle (no Settings entry needed).

Adds a regression-guard test asserting the panel uses the
chat-user-surface bg + warning-border (not the warning fill).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:49:42 +08:00
8094b500f3 fix(web): hide sidebar fake sessions for Cursor resume/archive (#836)
* fix(web): dedupe sidebar sessions by flavor resume id

Wire deduplicateSessionsByAgentId into SessionList and resolve cursor
threads via cursorSessionId so resume/archive no longer shows duplicate
inactive rows for the same ACP session.

Fixes #833

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): use SessionSummary.agentSessionId for sidebar dedup

SessionList only receives SessionSummary from the API; native ids like
cursorSessionId are already mapped into metadata.agentSessionId by
toSessionSummary. Drop resolveAgentSessionIdFromMetadata to fix typecheck.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): hide inactive empty session stubs in sidebar

Filter inactive rows with no agentSessionId and no title signal before
grouping sessions, and expose lifecycleState on SessionSummary for future
sidebar rules. Completes the #833 P0 follow-up alongside agent-id dedup.

Fixes #833

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): scope sidebar dedup key by flavor

Prevent cross-flavor collisions when flattened agentSessionId retains a
stale native id. Add regression test and relax claudeRemote CI timeout.

Fixes #833

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:31:09 +08:00
ad038bbf2e fix(cursor): merge SKU catalog under ACP lock and refcount agent guard (#835)
Fixes incomplete cliModelSkus while agent acp holds the CLI lock (#831) and
replace single-pid ACP lock with cross-process refcount (#832). Web picker
merges machine/session catalogs and waits for SKU readiness before showing
variant labels.

Fixes #831
Fixes #832

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:30:43 +08:00
cb72703649 feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows (#826)
* feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows

Archived sessions retain their full transcript and metadata in the DB, but
today there is no path back to them from the web UI; the only way to revive
one is shell access plus sqlite metadata patching plus a manual /resume call.

This change adds a single one-click affordance:

- Hub: new POST /api/sessions/:id/reopen route on the existing sessions
  router. The route delegates to a new engine method `reopenSession` that:
  - is idempotent (active session -> 200 with `resumed:false`),
  - validates Cursor sessions still have a `cursorSessionId` once they have
    any messages (otherwise we cannot resume the agent thread),
  - clears `lifecycleState='archived'`, `archivedBy`, `archiveReason` via a
    versioned metadata update, and stamps `lifecycleStateSince`,
  - defaults `cursorSessionProtocol='stream-json'` for pre-#799 Cursor
    sessions (sessions that have a `cursorSessionId` but no protocol set),
    so routing still reaches the legacy launcher; ACP sessions keep their
    explicit protocol,
  - forwards to the same `resumeSession` path the existing /resume route
    uses, including the `canFreshSpawnNeverStartedSession` fallback.

  422 is returned with `{ missing: [...] }` when the agent metadata needed
  to resume is gone; other engine errors map to 404/409/503/500 with the
  existing shape (mirrors /resume).

- Web: a "Reopen" entry in the SessionActionMenu that appears next to
  "Delete" on inactive sessions only. Wired into both the SessionList rows
  and the SessionHeader more-menu, with a small dismissable error dialog
  for the 422 missing-metadata case.

- Tests: route-level coverage for the four response shapes (200 reopen,
  200 idempotent, 404, 422) plus 409/503 error mappings; sessionCache
  tests for the archive-metadata clear (including the legacy Cursor
  protocol default); React component test for the menu item rendering on
  inactive vs active sessions; mutation hook test for the api wiring and
  the ApiError surface needed by the UI.

Closes #819

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(reopen): address codex review findings on fork PR #33

Four P2 findings from the cold-review bot, three fixed and one explained:

1. Mutation now returns the reopen response so the UI can route to a possibly
   different sessionId. SyncEngine.resumeSession may merge the row into a
   freshly-spawned session id (matching the send-message resume flow); the
   chat view now navigates there, the row list calls onSelect on the new id.
2. reopenSession on the client now goes through `request()` instead of a
   hand-rolled fetch, so 401 + onUnauthorized refresh works the same as
   every other session action. `request()` now throws `ApiError` (with
   status/code/body) on non-401 errors - backward compatible because
   ApiError extends Error.
3. (Reply only) Pre-#799 Cursor protocol propagates correctly without the
   extra plumbing the bot suggested: `clearSessionArchiveMetadata` writes
   `cursorSessionProtocol='stream-json'` to the DB; the CLI's
   `bootstrapExistingSession` preserves it via `pickExistingSessionMetadata`;
   if it's still absent at the launcher, `isLegacyCursorSession` defaults
   to stream-json whenever `cursorSessionId` is present.
4. Archive metadata is now restored when resume fails. `reopenSession`
   captures a snapshot of `lifecycleState`/`archivedBy`/`archiveReason`/
   `lifecycleStateSince` before the clear; if `resumeSession` returns an
   error (no machine online, spawn timeout, etc.), the snapshot is put
   back via the new `SessionCache.restoreSessionArchiveMetadata`. Engine
   test covers both the rollback and the no-rollback-on-success cases.

Error rendering helper moved to `web/src/lib/reopenError.ts` so the chat
header and the session row share one implementation, and gained a unit test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve engine error codes in ApiError.code on /reopen

`/sessions/:id/reopen` returns `{ error, code }` where `code` is the stable
taxonomy (`no_machine_online`, `resume_unavailable`, etc.) and `error` is the
human-readable message. The generic `request()` error path was reading only
`parsed.error`, so `ApiError.code` ended up being a message like
"No machine online" rather than `no_machine_online`, breaking taxonomy-based
branching in web callers.

`parseErrorCode` now prefers `parsed.code` and falls back to `parsed.error`
for legacy routes that only set `error`. Added api/client.test.ts covering
the three response shapes /reopen actually emits (503 with code, 500 without
code, 422 with missing[]).

Addresses upstream codex-action review on tiann/hapi#826.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(reopen): restore archive metadata exactly on rollback (drop fresh lifecycleStateSince)

For an archived session that predates `lifecycleStateSince` (the field is
absent from its metadata), `clearSessionArchiveMetadata` stamps a fresh
timestamp. If `resumeSession` then fails, the rollback was leaving that
fresh timestamp in place, making the rolled-back row look like it was
just archived rather than preserving the original lifecycle age.

`restoreSessionArchiveMetadata` now does an EXACT restore: when a snapshot
field is undefined the corresponding key on the metadata is deleted, not
left alone. Applies symmetrically to lifecycleState / archivedBy /
archiveReason / lifecycleStateSince. Test updated to assert the deletion
of the fresh timestamp.

Addresses upstream codex-action review on tiann/hapi#826.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:29:31 +08:00
9349acba62 fix(web): use session flavor label in voice context formatters (closes #680) (#815)
Replaces hardcoded \"Claude Code\" strings in voice context injections
with the active session's flavor label (Cursor, Codex, Gemini, etc.)
via getFlavorLabel() from @hapi/protocol. Falls back to \"coding agent\"
for unknown or missing flavors.

Threads an agentLabel string param through:

- formatMessage
- formatNewSingleMessage
- formatNewMessages
- formatHistory
- formatSessionFull
- formatPermissionRequest
- buildSessionVoiceContextPlan (passes to its internal formatMessage call)

voiceHooks adds a single getAgentLabel(session) helper that reads
session.metadata.flavor and resolves the display label once per call.

Tests updated to cover the new parameter shape and to assert that
\"Claude Code\" is never substituted regardless of agent flavor.

formatReadyEvent already used a generic \"coding agent\" phrasing and
needs no signature change.

Closes #680

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:27:00 +08:00
c87f88f02f fix(web): disable single-dollar inline math in remark-math (#805)
* fix(web/markdown): disable single-dollar inline math in remark-math

Default remark-math configuration treats $...$ as inline LaTeX, which
turns any prose containing two currency amounts (e.g. "save $400 vs the
$200 plan") into a KaTeX block — paragraphs collapse, whitespace is
stripped, the running text is re-rendered as math symbols.

Pass `singleDollarTextMath: false` to remarkMath so single $ is plain
text. Block math `$$...$$` (on its own line) still renders, matching
GitHub-flavored markdown semantics.

Single source of truth: MARKDOWN_PLUGINS is shared by MarkdownText,
Reasoning, and MarkdownRenderer — fix lands in all three surfaces.

Adds 3 regression tests that drive the unified pipeline end-to-end:
prose with multiple "$N" amounts produces no `class="katex"` and no
`<math>` element; `$$...$$` block math still does.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(web): declare unified/remark-parse/remark-rehype/hast-util-to-html

The new markdown-text regression test imports these directly to drive the
unified pipeline end-to-end. They were resolving via transitive deps from
remark-math and rehype-katex, which is fragile — a future dep upgrade can
remove the transitives and break the test.

Declare them explicitly under devDependencies. No code change; lockfile
records the same versions that were already installed transitively
(unified@11.0.5, remark-parse@11.0.0, remark-rehype@11.1.2,
hast-util-to-html@9.0.5).

Addresses the HAPI Bot review finding on PR #805.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:26:41 +08:00
SSU-WEI HUANGandGitHub 66ba312117 fix(web): suppress Mermaid render error SVGs (#813)
* test: reproduce issue #785

* fix: suppress Mermaid render errors (closes #785)
2026-06-06 19:51:48 +08:00
3a8693f380 feat(cursor): migrate remote sessions to ACP with model/variant pickers (#799)
* feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers

Move stream-json remote launcher to legacy path and add ACP launcher with
set_config_option model/mode sync, optimistic keepalive on config changes, and
shared catalog caching. Web gets dual base/effort Cursor pickers for session and
new-session flows; hide composer status bar when Cursor sends no usage_update.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli,web,shared): Cursor model picker — ACP wires + CLI sku variants

Enrich the web/mobile picker with agent --list-models SKUs grouped under
ACP wire bases, fix session-open base highlight, and keep catalog discovery
safe while the ACP transport holds the CLI lock.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor-acp): apply ACP default model when web resets to Default

Web sends model: null for Default; push session/set_config_option with the
ACP default[] wire so Cursor backend matches hub state. Regression tests
for setModel(null) and applyModelConfig(null).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(acp): clear stale agent-acp lock when owning process is gone

Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or
crash so listCursorModels can run cold probes again. Regression tests for
guard and catalog discovery.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cursor): use live pid for ACP lock handler tests

Stale-lock cleanup clears dead pids; handler tests must simulate an
active lock with the current process pid to avoid cold probes/timeouts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(acp): scope agent CLI lock guard to Cursor agent command only

Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active;
that blocked listCursorModels while unrelated backends were running.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,web): reject Cursor model changes for local sessions

Hub returns 409 when controlledByUser is set, matching Codex. Web hides
model and variant pickers for local Cursor sessions so users do not hit
a dead RPC path. Document pre-push-review in AGENTS.md.

Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): send stable ids for Cursor ask_question replies

Parse and submit question.id and option.id so ACP receives keys like
{ approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:51:35 +08:00
hangerandGitHub f4c3513abd fix(web): drop stale queued-message ghosts on at-bottom refresh (#811) 2026-06-06 11:08:33 +08:00
a812a51dd7 feat(voice): backend voice picker + advanced controls behind disclosure (#742) (#743)
* 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>
2026-06-05 21:43:04 +08:00
SSU-WEI HUANGandGitHub d09168778c fix(web): preserve user prompt line breaks (#804)
* test: reproduce issue #794

* fix: preserve user prompt line breaks (closes #794)
2026-06-05 21:41:37 +08:00
f086949a8a feat(web,hub): export session conversation (#808)
* test: reproduce issue #793

* fix: add session conversation export (closes #793)

* fix(hub): sort session export by display time for invoked scheduled messages

Export now uses COALESCE(invoked_at, created_at) ordering so JSON/Markdown
exports match the visible chat chronology after scheduled messages are invoked.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): escape newlines in session export YAML front matter

Prevent session metadata containing newlines or quotes from breaking
Markdown export front matter.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 21:40:59 +08:00
dbc9646d06 Show and filter work directories in Codex import (#810)
* Add workdir filter to Codex import dialog

* Fix filtered select all in Codex import

---------

Co-authored-by: userZ <spare.nets-0y@icloud.com>
2026-06-05 21:40:32 +08:00
59c29e8423 feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime (#692)
* 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>
2026-06-04 17:56:52 +08:00
18bcb522e1 feat(web): per-session scratchlist (workbench) panel (#772)
* 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>
2026-06-04 17:55:29 +08:00
hangerandGitHub bffbe1b861 fix(web): add close button to dialog so modals are dismissable on mobile (#792)
* fix(web): add close button to dialog so modals are dismissable on mobile

The shared DialogContent had no close affordance — desktop users could
press Escape or click the overlay, but on mobile (no Escape key, dialog
spans calc(100vw-24px) leaving almost no tappable overlay) there was no
way to dismiss it. Add a DialogPrimitive.Close X button in the top-right,
fixing every dialog that uses this component at once.

* fix(web): reserve header space for dialog close button

Address review feedback: the absolutely-positioned close button overlaps
the top-right of every dialog. Long/breaking titles (e.g. DiffView's
break-all filename) could wrap underneath the 32px tap target. Add pr-12
to DialogHeader rather than padding DialogContent globally, so the title
row clears the button while body content (code blocks, diffs) keeps full
width.

* fix(web): localize dialog close button aria-label

Use the existing button.close locale string instead of a hardcoded
"Close" so screen-reader users get the label in their language (zh-CN: 关闭).
2026-06-04 17:53:40 +08:00
f9ef3a4489 feat(codex): import local Codex sessions into Hapi (#796)
* local: add Codex Desktop session sync controls

* feat(codex): import local Codex sessions into Hapi

---------

Co-authored-by: Codex Local <codex-local@example.invalid>
2026-06-04 17:53:12 +08:00
junesandGitHub 30564601ed fix(cli,web): hide Windows spawn windows and show queued attachments (#765)
* fix(cli,web): hide Windows spawn windows and show queued attachments

* fix(web): preserve attachment-only queued edit text
2026-06-01 18:50:16 +08:00
449cf6af0a feat(cli): wire Cursor /summarize and /clear slash builtins (#747)
* feat(cursor): wire /summarize and /clear slash builtins for remote sessions

Seed cursor builtins for web autocomplete, parse summarize/clear in
cursorRemoteLauncher (pass-through to agent -p; reject /clear with args).

Fixes tiann/hapi#738

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): isolate slash commands before message queue batching

Parse summarize/clear at enqueue time (runCursor) with pushIsolateAndClear
so waitForMessagesAndGetAsString never merges a slash with the next prompt.
Adds queue policy tests for invalid /clear + following message.

Addresses PR #747 review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): preserve pending messages when isolating slash commands

pushIsolateAndClear() wipes the entire queue, so a normal prompt queued
before /summarize or /clear would be silently dropped. Add pushIsolated()
- isolation without clearing - and route Cursor slash commands through
it instead. Adds queue tests covering the preserve-then-isolate path.

Addresses PR #747 review.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:07:57 +08:00
cc4025abdb fix(web,hub): queued bar SSE + never-started inactive resume (#761)
* 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.

Fixes tiann/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.

Fixes tiann/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>
2026-06-01 12:07:13 +08:00
junesandGitHub a1d144d290 fix(web): handle legacy session summaries (#751) 2026-05-31 19:35:43 +08:00
hangerandGitHub 6200ae1370 feat(web): align Claude effort options with Claude Code --effort levels (#731)
The Claude effort selector (New Session config + in-session composer) only
offered auto/medium/high/max, missing `low` and `xhigh` — yet `claude --effort`
actually accepts low/medium/high/xhigh/max. Add the two missing levels in both
places so the selector faithfully mirrors the CLI.

Extract the level list + labels into one shared constant
(@hapi/protocol: shared/src/effort.ts, mirroring CLAUDE_MODEL_PRESETS) so the
two UIs derive from a single source and can't drift again. No backend change:
the effort string is free-form end-to-end through to the --effort flag.

ultracode is intentionally excluded — it is a TUI-only /effort session setting,
not an --effort value (the CLI rejects `--effort ultracode`).
2026-05-30 12:41:00 +08:00
HeavyGeeandGitHub ec3722aba9 feat(web): session list status indicators (attention + scheduled) (#699) 2026-05-29 15:43:21 +08:00
AnanovoandGitHub 7457a8fc32 fix(web): refine image preview interactions (#723) 2026-05-29 15:42:59 +08:00
AnanovoandGitHub c78785a0b2 fix(web): open tool details from inline previews (#725)
* fix(web): open tool details from inline previews

* fix(web): ignore nested controls in tool previews
2026-05-29 10:22:54 +08:00
27e58570c3 chore(web): upgrade @tanstack/react-router to ^1.170.8 (#722)
* chore(web): upgrade @tanstack/react-router to ^1.170.8

Fixes QuotaExceededError in scroll restoration: upstream @tanstack/react-router
>=1.145.6 wraps sessionStorage.setItem with try-catch, preventing the crash when
scroll restoration cache exceeds quota.

Refs: #683, #716, #721

* fix(web): adapt scrollStorageGuard to @tanstack/router-core >=1.145.6 API

`scrollRestorationCache` was removed from the public exports; replace with
`storageKey` import and simplify `hardResetScrollRestorationPersistedState`
to a plain `removeItem`. Remove the now-stale in-memory cache sync path and
its associated tests. Upstream try-catch (>=1.145.6) covers crash prevention;
this guard continues to proactively prune sessionStorage.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-05-29 09:47:12 +08:00
hangerandGitHub 4b4e112a2c fix claude model option merging (#726) 2026-05-29 09:45:40 +08:00
hangerandGitHub 485bb46db8 fix(claude): propagate real contextWindow from SDK result to web (#720)
The "Default" model in NewSession sends no --model flag, so Claude CLI
picks its own default (e.g. Opus 4.7 [1m] on Pro accounts). The web
status bar then falls back to 200K - 10K headroom = 190K because the
Claude SDK path never plumbs the real per-model contextWindow through
to the wire-level `modelContextWindow`, unlike the ACP/Codex backends.

Fix the gap in three places:

- cli/src/claude/sdk/types.ts: declare optional `modelUsage` on
  SDKResultMessage to surface what Claude CLI already emits
  (`modelUsage[<model>].contextWindow`).

- cli/src/claude/utils/sdkToLogConverter.ts: on system.init, capture
  the resolved model name (full form with `[1m]` suffix) and derive
  an initial contextWindow from the suffix. On every assistant
  message, inject the cached contextWindow into `usage.context_window`
  when absent. On result, refine the cache with the authoritative
  value from `modelUsage`.

- web/src/chat/normalizeAgent.ts: forward `context_window` through
  the assistant usage normalization, so the existing reducer path
  (reducer.ts:175 → StatusBar.tsx:175) can render the real window.

Closes #719.
2026-05-28 15:02:20 +08:00
AnanovoandGitHub a5c5ee7a2a fix(web): show agent flavor icon in session header (#714)
* fix(web): reuse agent flavor icon in session header

* fix(web): use neutral badge for unknown agent flavor
2026-05-27 21:54:04 +08:00
AnanovoandGitHub cf378dfe31 feat(web): add lightbox preview for chat images (#715) 2026-05-27 21:53:06 +08:00
6f2bb7d32b feat(opencode): add plan mode, reasoning effort, and status telemetry (#688)
* feat(opencode): support plan mode

* feat(opencode): support reasoning effort

* feat(opencode): surface context usage in web

Bridge OpenCode ACP usage updates into the existing token-count pipeline so the web status bar can show live context and cache information without a separate UI path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(opencode): restrict plan mode to remote, rollback reasoning effort on failure

- Block local OpenCode plan startup (tools not enforced in local path)
- Allow remote OpenCode plan only (ACP permission handler denies tools)
- Guard web /permission-mode endpoint for local OpenCode plan sessions
- Rollback session reasoning effort when OpenCode rejects set_config_option
- Wire rollback callback through opencodeLoop to runOpencode closure
- Add tests: local plan rejected, remote plan allowed, web guard, effort rollback

* fix(web): auto-retry OpenCode models query to populate model selector without refresh

- Retry early failures (RPC may still be registering on new sessions)
- Poll briefly until availableModels is non-empty
- Stop polling once model options are discovered
- Add tests for retry/poll/stop policy

* fix(opencode): cap model discovery polling

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 11:17:24 +08:00
HeavyGeeandGitHub d5a67b717c feat(voice): dynamic settings voice picker with safe fallback + preview (#690)
* feat(voice): dynamic settings voice picker with safe fallback + preview

* fix(voice): honor picker with configured agent and stop preview on unmount

* fix(voice): apply PR review feedback for agent selection and preview cleanup
2026-05-27 11:17:01 +08:00
SSU-WEI HUANGandGitHub 166b711fec fix(web): submit schedule picker on enter (#703)
* fix(web): submit schedule picker on enter

* fix(web): center schedule picker on touch tablets
2026-05-27 07:14:14 +08:00
cfc8508651 fix(web): reset scroll restoration when sessionStorage quota hits (#707)
* fix(web): reset scroll restoration cache when sessionStorage is full

TanStack keeps scroll state in RAM; pruning only the JSON blob did not stop
repeat quota throws. On persist failure for the scroll key, clear the library
cache when guarding real sessionStorage. Treat any write error on that key
(not only QuotaExceededError-shaped) as recoverable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): cover scroll cache hard reset and non-quota recovery

Exercise TanStack scrollRestorationCache reset on real sessionStorage,
mock-storage isolation, and generic storage write failures for #708.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): sync pruned scroll cache and avoid hard-reset recursion

After a successful storage prune, align TanStack's in-memory
scrollRestorationCache with the trimmed payload. Temporarily unwrap
sessionStorage.setItem when writing through the library cache so hard
reset cannot recurse through the guard (Codex PR review #707).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 07:13:49 +08:00
c1eccc0de2 fix(web): guarantee unique assistant-ui thread message IDs (#706)
* fix(web): guarantee unique assistant-ui thread message IDs

Suffix duplicate kind:id pairs before ExternalStore sync; skip duplicate hub
rows in SessionChat normalization. Align outline scroll targets with the
new user-text thread id shape. Closes #704.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): reuse thread-id wrappers for useExternalMessageConverter cache

WeakMap stable BlockWithThreadMessageId objects keyed on reconciled block
refs so streaming appends do not invalidate assistant-ui converter caches
(PR #706 review).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 07:13:16 +08:00
SSU-WEI HUANGandGitHub 1d03f186d6 feat(cursor): support model selection (#684) 2026-05-26 16:13:38 +08:00
9af6696a68 fix(web): keep global SSE alive for session list status updates (#694)
When a session is open, the web app now keeps an always-on all:true SSE
connection for sidebar session-updated events while using a second
session-scoped stream for message delivery. Also bump session activity on
hub sendMessage so web-originated sends refresh list timestamps.

Fixes tiann/hapi#693

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 14:45:43 +08:00
3258c52947 fix(web): embed agent text in voice ready event for readback (#682)
* fix(web): embed agent text in voice ready event for readback

Voice onReady now extracts the last speakable assistant message and
embeds it in the ready inject so ConvAI can summarize without the user
re-prompting. Also formats Codex/Cursor stream-json messages for live
context updates and session history.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): use domain-neutral voice formatter fixtures

Replace jellybot/subtitle dogfood strings in tests with generic examples.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): guard extractSpeakableFromContent for non-arrays in formatMessage

extractSpeakableFromContent also handles content arrays (joins text items),
so calling it unconditionally before the existing array loop caused mixed
text+tool_use payloads to return early without formatting the tool_use item.
Guard with !isContentArray so the loop handles arrays as before.

Adds regression test: mixed text+tool_use array must produce both the text
and the tool-call line (was red before this fix).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): narrow extractSpeakableFromContent to codex type only

The helper matched any object with a string type and a data property,
so sendSessionEvent({ type: 'message', message }) events (which arrive as
{ type: 'event', data: { type: 'message', message } }) were falsely formatted
as speakable assistant text and could be selected as the ready readback.

Narrow the Codex path to content.type === 'codex' as the comment already states.
Adds regression test: session status event must return null from formatMessage.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-05-25 13:31:23 +08:00
NightWatcher314andGitHub 0fa21a121a fix(web): preserve session preview folding (#666) 2026-05-24 10:59:53 +08:00
andGitHub 04d3d02627 fix(web): Replace anti-pattern of assigning role="button" to entire message div (#665)
* fix(web): replace message-level aria-expanded with explicit toggle button

Replace the anti-pattern of assigning role=\"button\" and aria-expanded to
the entire message content div. Instead, show an explicit \"Show info\" /
\"Hide info\" button next to the timestamp.

Also add a visible background container to MessageMetadata so users can
actually see when metadata expands/collapses.

Remove now-unused metadataToggle.ts and its tests.

* fix(web): include turnCount in hasMetadata and pass it through codexReview branch
2026-05-22 21:28:28 +08:00
andGitHub 9e35067aa9 feat(web): show timestamps on chat messages (#664) 2026-05-22 13:14:26 +08:00
763f45acdd feat: add support for Kimi Code CLI and fixed some bugs (#659)
* Add Kimi agent support via ACP protocol

Add full integration for the Kimi Code CLI agent using the standard
Agent Client Protocol (ACP). Includes:

- kimi command and CLI registry wiring
- Local launcher spawning kimi directly
- Remote launcher with ACP stdio transport via AcpSdkBackend
- Session management with resume support
- Permission handler supporting all Kimi permission modes
- Terminal UI display component
- Runtime config resolving model from env and ~/.kimi/config.toml

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Fix Kimi ACP tool call input decoding on web

Kimi streams tool arguments as JSON text inside the content array
(e.g. {\"command\": \"df -h\"}) instead of rawInput/kind. The handler
now extracts input from three sources in priority order:

1. rawInput (Claude/Codex path)
2. kind + title fallback (Gemini path)
3. content JSON text (Kimi path)

Also handles:
- rawInput: null no longer blocks the kind+title fallback
- Title prefixes like \"Shell: free -h\" are stripped to extract args
- Stale placeholder inputs are re-derived when the title updates
- Normalized kind aliases (shell, run, read_file, write, etc.)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Add kimi support to web UI

* Fix some bugs

* fix(kimi): dedupe repeated tool_call display in terminal UI

* fix(web): keep tool block immutable so React detects input/state changes

* fix(web): recognise Kimi subagent titles like 'Agent: ...' as subagent tools

* fix(web): allow-for-session for ACP agents (kimi, cursor)

PermissionFooter treated all non-codex sessions as Claude, sending
Claude-specific acceptEdits/allowTools to ACP agents that don't
support them. Hub rejected acceptEdits for kimi, and the ACP
PermissionAdapter ignored allowTools.

- Only show 'allow all edits' for Claude sessions
- Send decision: approved_for_session for non-Claude ACP agents
- Update status display to check decision field

* fix(web): lookup subagent sidechains by tool-call id instead of msg id

* fix(web): don't trim newest messages when loading older history

fetchOlderMessages was using trimVisible(merged, 'prepend') which kept
the oldest 400 messages and dropped the newest ones. This caused:
1. Latest messages to disappear when user loaded older history
2. User to see no visible change when new old messages were drowned
   in the 400-message window.

Remove the incorrect trim so all fetched older messages are retained
alongside the current window. Subsequent ingestIncomingMessages
(append mode) will naturally keep the window bounded when new agent
messages arrive.

* fix(cli): route Kimi session resume to runKimi instead of runCursor

Kimi was present in AGENT_FLAVORS but dispatchLocalResume had no branch
for it, so resuming a Kimi session fell through to the Cursor launcher.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): pass selected model to Kimi ACP backend via KIMI_MODEL env

createKimiBackend was ignoring opts.model and only setting KIMI_PROJECT_DIR.
Use buildKimiEnv so the selected model reaches the subprocess as KIMI_MODEL.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): bound message window on older loads with dedicated larger cap

fetchOlderMessages was keeping all messages unbounded, causing
sessionStorage bloat on repeated pagination. Reintroduce trimming
with OLDER_LOAD_WINDOW_SIZE (800) so growth is capped while the
newest messages are still preserved for far longer than before.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): revert sidechain lookup to message id, matching tracer/grouping pipeline

tracer.ts sets sidechainId to the parent message id, and reducer.ts groups
by sidechainId. A prior commit changed reducerTimeline.ts to look up by
tool-call id (c.id), which broke sidechain attachment. Revert to msg.id
so the lookup matches the actual grouping key end-to-end.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): gate ACP title prefix stripping to known tool-kind labels

extractTitleArgument stripped at the first colon unconditionally,
corrupting commands/paths like curl http://localhost:3000 or
Windows paths. Now it only strips when the prefix normalizes to
the same tool kind as the event, verified via regex.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(shared): include kimi in isCodexFamilyFlavor for ACP permission UI

Kimi is an ACP-style agent that supports the abort decision, but
isCodexFamilyFlavor excluded it, so PermissionFooter rendered the
non-Codex Allow/Deny UI without the Abort button.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-05-22 10:08:14 +08:00
b06251bd53 fix: stream opencode reasoning updates (#661)
Emit throttled ACP reasoning snapshots with stable stream ids so OpenCode reasoning updates render live without one row per token.

Collapse matching reasoning snapshots in the web timeline and avoid stale session 404 redirects during refetch.

Tests: targeted Vitest suite and bun typecheck.

Co-authored-by: twshe <twshe@outlook.com>
2026-05-22 08:57:50 +08:00
CoColateandGitHub 5b90a48b33 fix(web): respect manual session path collapse (#656) 2026-05-21 21:18:27 +08:00