mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
55d1bbb7bd946ecf21f5625aeabe98cb3f90e1a7
66
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
ddf3a5545b | fix(web): add missing i18n keys for session.inactive banner (#851) | ||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
ec3722aba9 | feat(web): session list status indicators (attention + scheduled) (#699) | ||
|
|
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 |
||
|
|
7d5a5ee919 | chore: remove obsolete spawn leftovers | ||
|
|
ce2e76a42e | Add Windows remote terminal support (#642) | ||
|
|
6e32b20524 |
feat(web): linkify custom URI schemes with a confirm prompt (#633)
* feat(web): add UriConfirmDialog component
Add a Radix Dialog-based confirmation modal for custom URI scheme
navigation. Follows the RenameSessionDialog pattern.
- UriConfirmDialog: shows URI, scheme label, Cancel/Open/Always-allow buttons
- i18n keys: dialog.uri.{title,description,open,alwaysAllow}
* feat(web): autolink non-https URI schemes in markdown
Add a remark plugin that converts raw `scheme://...` text nodes into
link nodes for non-http(s) schemes. GFM already handles http/https;
this plugin handles the remainder (obsidian://, vscode://, slack://, etc.).
- No scheme allowlist: every `scheme://` pattern is converted; the
sanitize layer (urlTransform) and onClick layer (classifyScheme) handle
blocking/confirmation downstream.
- Runs before remarkStripCjkAutolink so the CJK-strip plugin sees the
new link nodes and can trim trailing CJK punctuation from them.
- Trailing punctuation (.,;!?) stripped from matched URIs.
- Unit tests: conversion, partial-match, escape, explicit link bypass,
code-block bypass, trailing-punct trimming.
* feat(web): linkify custom URI schemes via markdown <a> handler
Wire up 4-layer URI security policy in the markdown renderer:
1. URL sanitize (deny-only): urlTransform strips javascript:/data:/vbscript:/file:
using classifyScheme as single source of truth (handles percent-encoding,
case-insensitive, whitespace-prefix bypass patterns).
2. onClick intercept: custom <A> component classifies each href —
- IANA safe (https/http/irc/ircs/mailto/xmpp): navigate directly.
- Deny (javascript/data/vbscript/file): preventDefault silently.
- Custom (obsidian/vscode/slack/…): preventDefault + open UriConfirmDialog.
3. UriConfirmProvider: one dialog lifted to each markdown root (MarkdownText,
Reasoning, MarkdownRenderer). Shared isAllowed state across all <a> tags in
the subtree — "Always allow" click updates every link in one React commit.
4. Intra-tab cross-provider sync (P7e.1): module-level schemeListeners Set so
sibling UriConfirmProviders (MarkdownText + Reasoning in AssistantMessage)
receive allowed-scheme updates synchronously without waiting for the window
storage event (which only fires in other tabs). Cross-tab sync continues via
the existing window storage event listener.
5. "Always allow" persisted to localStorage (hapi-allowed-schemes). Custom
schemes once allowed navigate directly on subsequent clicks, no dialog gate.
href="#" in DOM for unallowed custom schemes prevents middle-click bypass.
Deny-scheme href="" prevents any navigation even if localStorage tampered.
Security: classifyScheme decodes percent-encoding before scheme extraction,
blocking %6Aavascript:, jav%61script:, javascript%3A (single-encoded colon)
and double-encoded variants. DENY_SCHEMES checked after localStorage lookup so
tampered allowed-list cannot promote deny schemes.
Tests: classifyScheme 6-axis security bypass, denyOnlyTransform, localStorage
roundtrip, cross-tab storage event, <A> click handler cases.
* fix(web): block control-char-spliced deny schemes in classifyScheme
Browsers silently strip ASCII control characters (\t, \n, \r) and
whitespace from URL scheme names during navigation. A scheme like
`java\nscript:alert(1)` was navigated as `javascript:` while our
literal string comparison classified it as 'custom', allowing it
past the deny list and into window.open().
Introduce normalizedScheme() that:
- applies 2 rounds of decodeURIComponent so double-encoded schemes
(javascript%253A → javascript%3A → javascript:) are fully unwrapped
before comparison
- strips [\x00-\x1F\x7F\s] from the extracted scheme name, matching
the browser's own normalization
classifyScheme() now delegates to normalizedScheme() so both the
denyOnlyTransform (urlTransform) path and the <A> onClick path benefit
from the same normalization.
Tests added for \n / \t / \r / space spliced into scheme, and verify
that double-encoded colon is now caught via scheme-match (not just
the no-colon fallback).
* fix(web): preserve relative markdown links from being blocked
Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) were
silently preventDefault'd in <A>'s onClick handler. denyOnlyTransform
correctly passed them through (no colon → not a scheme URL), but the
click handler called classifyScheme(href) which returned 'deny' for
any input with no valid scheme separator — then the deny branch fired.
Add hasScheme(href): checks whether the first ':' appears before any
path/query/fragment boundary ('/', '?', '#'). When hasScheme is false
the href is treated as 'iana' so the browser or SPA router can navigate
normally with no dialog and no preventDefault.
Also wrap renderA() with <I18nProvider> so the UriConfirmDialog that
UriConfirmProvider may render does not throw outside its translation
context during tests.
Fixes a regression that broke all relative-path markdown links once the
custom-URI-scheme onClick handler was added.
* test(web): cover percent-encoded scheme control char + protocol-relative href
Round-5 internal hostile review noted two coverage gaps on the bot-fixup commits:
- `java%0Ascript:alert(1)` (percent-encoded newline in the scheme name) takes the
same decode→strip code path as the literal `java\nscript:` case but was only
tested literally. Add an explicit test so a future refactor that drops the
decode-then-strip ordering would be caught.
- Protocol-relative URLs (`//host/path`) have no colon, so `hasScheme` returns
false and `<A>` treats them as scheme-less — browsers then navigate them as
the current origin's protocol. Existing relative-href tests covered absolute
paths, hashes, queries, and colon-in-path, but not the protocol-relative
variant. Add one assertion.
Also extend the `hasScheme` JSDoc to note that protocol-relative URLs are
intentionally treated as scheme-less.
* fix(web): preserve balanced parens/brackets in autolinked URIs
The trailing-punctuation strip used to drop every `)` / `]` from the end
of a matched URI, even when the URL body had an unmatched opener. So a
URI like `obsidian://open?file=Note(1)` was rendered with href
`obsidian://open?file=Note(1` plus a separate `)` text node, opening a
broken deep link.
Match the GFM autolink-literal behaviour: when the trailing character is
`)` or `]`, keep it iff the URL body has more opening counterparts than
closers (so the trailing closer balances an earlier opener and belongs
to the URL). Other trailing punctuation (`.,;!?:>'"`) and unmatched
closers still strip as before.
Add tests for the balanced cases (`Note(1)`, `Note[1]`, nested
`(a(b)c)`), the "balanced URL followed by a period" case, and a
regression test that an unmatched `).` after a URL is still stripped.
|
||
|
|
b2a30c2e39 | feat(hub,web): support scheduling messages for future delivery (#590) | ||
|
|
0e594da84d | Render Codex review messages | ||
|
|
7bb7c7d91a | feat(web): configure session preview limit (#629) | ||
|
|
be0a41172a |
feat(web): add directory quick session action (#624)
* fix(codex): support app-server plan mode * fix(codex): broaden plan mode compatibility checks * feat(web): add directory quick session action * fix(web): hide quick session action for unknown directory |
||
|
|
94b644c3f3 | feat(web): add image file preview (#623) | ||
|
|
60af9835b4 | feat(web): 优化聚合 tool use 展示与聊天背景设置 (#619) | ||
|
|
af3491e046 |
feat(web): group consecutive tool-use cards (#604)
* feat(web): group consecutive tool-use cards Add a web-only visible projection that groups consecutive root-level execution tools into expandable cards. Keep approval and question tools standalone, reuse older-history loading on expand, and add regression coverage for grouping and UI behavior. * fix(web): hydrate oldest visible tool group Mark needsOlderHistory on the first visible grouped tool run even when earlier visible blocks are non-tool content, and add regression coverage for the boundary. * fix(web): continue grouped history hydration Decouple ToolGroupCard older-history chaining from the shared loading flag, invalidate stale hydration runs safely, and add regression coverage for multi-page hydration. * fix(web): harden grouped tool hydration - retry incomplete group hydration after transient pagination contention\n- keep approved and denied permissioned tool cards eligible for grouping\n- cover both regressions with targeted web tests * fix(web): keep Codex permission cards standalone - treat CodexPermission as a semantic grouping boundary even after approval\n- keep permissioned execution tools groupable while preserving permission milestones\n- add regression coverage for Codex permission eligibility and boundary behavior * fix(web): narrow incomplete tool-group hydration - only mark groups at the oldest visible boundary as needing older history\n- avoid auto-paginating complete groups behind text, standalone tools, or permission milestones\n- add regression coverage for the adjacent boundary cases |
||
|
|
d8f3083c75 |
fix(web): 完善 Files 页面 i18n (#607)
* fix(web): 补全 Files 页面 i18n * fix(web): 修正 Files git 错误聚合翻译 |
||
|
|
3a6574f2b9 | fix(web): compact terminal tool cards by default (#601) | ||
|
|
0006d04f9e | feat: support multiple workspace roots (#584) | ||
|
|
08d3d9e111 | feat(web): add composer enter behavior setting (#586) | ||
|
|
f7a40bd573 |
feat(web): polish chat rendering and fix remote session interactions (#567)
* feat(web): polish chat rendering Refresh the web chat presentation across user messages, tool cards, code blocks, diffs, reasoning, and Mermaid diagrams.\n\nAdd focused regression coverage for bubble/status behavior, code and diff rendering, clipboard output, Mermaid theming, and message-window updates. * fix(web): stabilize chat tool rendering Preserve manual scroll anchors while older messages and tool dialogs update, and align code, diff, and tool result rendering with chat typography. Add chat font-weight settings, ignore local Playwright CLI artifacts, document the Angular commit-message convention, and cover the scroll, result, code, diff, and settings behavior with focused tests. Constraint: User requested committing all current workspace diffs with Angular-style commit messaging Tested: bun run typecheck:web && bun run test:web && git diff --check Co-authored-by: OmX <omx@oh-my-codex.dev> * style(tool-card): polish question and permission card styles Align AskUserQuestion option surfaces and permission action hierarchy with the existing tool card visual language while preserving interaction logic. Extract shared option presentation helpers and theme-driven hover/muted colors to reduce duplication. Constraint: Frontend style-only polish; preserve existing permission and answer submission behavior Rejected: Keep screenshot artifacts in the repo | they are local visual review output, not source Confidence: high Scope-risk: narrow Tested: git diff --check; bun run typecheck:web; bun run test:web; bun run build:web Not-tested: manual cross-browser visual QA beyond local Playwright inspection Co-authored-by: OmX <omx@oh-my-codex.dev> * fix(cli): keep Claude remote plan prompts actionable Handle Claude remote /plan locally so HAPI switches plan permission mode before forwarding any prompt text. This avoids Claude Code treating /plan as an unknown skill and ending with only a ready event. Constraint: Claude SDK result messages are not conversation log entries, so command handling must happen before the prompt reaches Claude.\nRejected: surfacing SDK result summaries in web chat | would expose transport-level summaries broadly instead of fixing the slash-command path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Claude remote slash commands that alter runtime mode in the CLI special-command parser.\nTested: bun test cli/src/parsers/specialCommands.test.ts; bun typecheck; git diff --check\nNot-tested: Manual GitHub-hosted runner deployment. * fix(web): polish tool result rendering * fix(web): preserve collapsed session order * fix(chat): settle initial thread scroll * fix(settings): remove chat font weight option * fix(web): remove font weight bootstrap code * chore: remove unrelated branch artifacts * test(web): update consumed message invocation test * fix(chat): cancel initial scroll settling on manual scroll --------- Co-authored-by: huhaoyu.hahahu <huhaoyu.hahahu@bytedance.com> Co-authored-by: OmX <omx@oh-my-codex.dev> |
||
|
|
fad5dbbc30 |
fix(web): localize toast messages and keep full session counts (#573)
Normalize hub toast text in the web client for i18n coverage (including Ready for input notifications) and stop deduplicating session rows by agentSessionId so outline/group counts reflect user-visible sessions. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
136badb86e |
fix(gemini): surface tool_call input on Gemini ACP cards (#562)
* fix(acp): derive tool_call input from kind+title fallback
Gemini 2.5 Flash and 3 Flash Preview omit rawInput entirely on
tool_call events while emitting prose (non-JSON) thoughts. Neither
the existing rawInput path nor JSON-thought hoisting fires, so the
UI shows "Input: null" alongside a perfectly readable title like
"README.md" or "ls -la /tmp".
Add a conservative fallback that maps known kinds to a minimal
input shape:
read -> { file_path: title }
execute -> { command: title }
search -> { pattern: title }
think -> null (topic-update prose has no clean arg mapping)
unknown -> null (no guessing on shapes we have not verified)
Priority: rawInput > hoisted JSON thought > kind+title derive.
Lock the new behaviour with synthetic unit tests (8 cases) and a
real-Gemini fixture suite captured from gemini-3-flash-preview
and gemini-2.5-flash via ACP stdio (4 fixtures, 33/27/13/4 raw
sessionUpdate events). The fixtures double as regression guards
against future ACP handler changes.
* fix(web): suppress duplicate subtitle when equal to tool title
Gemini ACP emits a tool_call whose title field is a human-readable
summary (often the verbatim shell command or file path). Combined with
the kind+title input fallback, an unknown-tool card ends up with the
same string in both the title and subtitle slots — e.g. title
"cat /tmp/hello.txt" over subtitle "cat /tmp/hello.txt".
Add a guard in getToolPresentation's unknown-tool branch: emit
subtitle only when it differs from toolName. The known-tool and
mcp__* branches are unaffected.
* test(acp): align Gemini fixtures to current model set
- Drop gemini-2.5-flash fixtures: the captures came from a model that
is not part of the PR's evidence model set, and re-running the
capture is gated on quota that is not currently available.
- Refresh gemini-3-flash-preview read_file / run_shell fixtures with
a fresh live capture so they reflect the latest ACP shape (e.g.
a `kind: think` tool_call expressing reasoning when the model emits
no agent_thought_chunk).
- Update fixture-replay expectations: read_file no longer requires
reasoning chunks (zero are emitted on this path) and now requires
>= 2 tool_calls (think + read).
* feat(web): promote semantic title for Gemini ACP tool cards
When the unknown-tool ToolCard would render the same string as both
the title and the subtitle, promote a semantic label to the title
slot so the card reads like a sentence:
cat /tmp/hello.txt → Run shell / cat /tmp/hello.txt
README.md → Read file / README.md
*.ts → Search / *.ts
This is a web-only ergonomic change; the underlying ACP message
shape (tool_name = title, input = derived from kind+title) is
unchanged. Builds on the dedup guard so the title-equals-subtitle
case is now handled by promotion rather than by hiding the subtitle.
* fix(acp): derive tool_call.input for kind=edit from locations[0].path
Gemini's write_file and replace tools both surface as ACP tool_call
with kind="edit" and rawInput omitted. The path lives on locations[0]
from the very first event; the title is prose like "Writing to foo.txt"
or "foo.txt: old => new", which is not safely usable as a file_path.
Extend the kind+title fallback to read locations[0].path when kind is
"edit", and synthesize { file_path } from it. Title fallback is
intentionally not used here so we never feed prose into file_path.
Lock the behaviour in with two new fixtures captured live from
gemini-3-flash-preview (write_file and replace) plus two synthetic
unit tests covering the locations-present and locations-empty paths.
* test(acp): add gemini-3.1-pro-preview fixtures for regression coverage
Captured 4 raw ACP `sessionUpdate` sequences from a live
`gemini-3.1-pro-preview` session via the same isolated hub +
runner + spawn pattern used for the existing flash captures
(read_file 31 events / run_shell 83 events / write_file 4 events /
edit_file 11 events).
The pro tier reuses the same kind/title shape as flash:
`rawInput` is omitted on every tool_call across read / execute /
edit kinds, so the kind+title (and locations[0].path for edit)
fallback is exactly what derives the modal Input. Locking these
fixtures in guards against future regressions on a second model.
The fixture-based regression test gains 4 entries (read / shell /
write / edit) mirroring the flash matrix; assertions are unchanged.
ACP handler suite: 53 -> 57 pass.
|
||
|
|
9ee014098a |
feat(opencode): support model selection and mid-session model change (#558)
* refactor(opencode): declare ModelChange capability and add model field to OpencodeMode
Mark opencode flavor as supporting model change by adding Capabilities.ModelChange
to FLAVOR_CAPS.opencode. Add optional `model` field to OpencodeMode so the
set-session-config handler can carry a model alongside the existing permissionMode.
Pure structural change: no behavior change yet. The mid-session model change RPC
and UI wiring follow in subsequent commits, gated by this capability.
* feat(acp): branch setModel by flavor, capture session models metadata, expose getSessionModelsMetadata on AgentBackend interface
Adds an optional `flavor` argument to `AcpSdkBackend.setModel` so it can
dispatch the right `session/*` RPC for each agent flavor without changing
the call site Gemini already uses. Both Gemini and OpenCode wire to
`session/set_model`; the OpenCode response only carries `_meta.opencode`,
so the backend updates the cached `currentModelId` optimistically while
preserving the previously captured `availableModels`.
Captures `availableModels` and `currentModelId` from `session/new` /
`session/load` / `session/set_model` responses into per-session metadata,
exposed as `getSessionModelsMetadata(sessionId)` on the `AgentBackend`
interface so the hub can forward the snapshot to the web client.
* feat(opencode): accept model in set-session-config RPC and forward to launcher
Mirror the Gemini set-session-config handler so the web UI can change the
OpenCode model mid-session. Validates incoming model strings, persists null
("Default") for keepalive metadata, and pushes a keepAlive immediately so
the hub UI reflects the change without waiting for the next 2s tick.
Forward the model through opencodeLoop and into queued OpencodeMode entries
so the launcher can detect a per-batch model change. Add a setModel helper
on OpencodeSession to store the chosen model on the shared session base.
Wire --model up the runner path: parse --model <value> in commands/opencode.ts
and stop excluding opencode in buildCliArgs so the runner spawns OpenCode
with the user-selected initial model.
* feat(opencode): switch model mid-session via ACP RPC
Mirror the Gemini pattern from PR #543: when a user picks a different model
between turns, call backend.setModel with flavor='opencode' so the ACP backend
sends session/set_session_config_option (configId='model') to the running
OpenCode CLI. The next turn then runs against the new model.
The first batch on a fresh session seeds currentBackendModel without firing the
RPC — the OpenCode CLI was launched with that model via --model and there is
nothing to switch yet. If the running build does not implement the RPC we learn
that from the first method-not-found response, latch inline switching off, and
notify the user once. Other errors fall back to the previous model and surface
a one-line failure message.
* feat(hub): expose model selection and discovery for OpenCode sessions
Generalize the /sessions/:id/model guard via supportsModelChange so any flavor
that advertises the ModelChange capability becomes accepted automatically. This
piggybacks on the capability SSOT introduced in PR #400 and turns OpenCode on
without listing flavors inline.
Add a /sessions/:id/opencode-models endpoint that mirrors the existing
codex-models pattern. The endpoint forwards a per-session listOpencodeModels
RPC to the running OpenCode launcher, which returns the availableModels and
currentModelId metadata captured from the ACP session/new and
session/set_session_config_option responses. The web UI consumes this to
render the model dropdown without round-tripping ACP itself.
* feat(web): render OpenCode model dropdown in the chat composer
Mirror the Codex pattern in SessionChat: query /sessions/:id/opencode-models
via a new useOpencodeModels hook and feed the result into the composer's
availableModelOptions. The AssistantChat model dropdown now lists the user's
ollama / mlx / OpenCode Zen models with the same provider/model label that the
ACP server reports, so the picker matches the OpenCode TUI.
Stop falling back to the Claude composer model list when the flavor is
opencode and no custom options are supplied — that fallback briefly surfaced
unrelated Claude models in OpenCode sessions before the RPC response landed.
The NewSession flow keeps an empty MODEL_OPTIONS.opencode for now: model
discovery requires an active OpenCode ACP session, so the dropdown becomes
available once the session boots and stays empty (and hidden) at creation
time.
* feat(cli,hub): add cwd-based OpenCode model discovery RPC
Adds a short-lived `opencode acp` probe that runs `initialize` +
`session/new` against a target cwd to read the `availableModels` /
`currentModelId` snapshot, then tears the subprocess down. Results are
cached for 60s per cwd and concurrent probes coalesce into a single
spawn.
Exposes the probe through:
- `listOpencodeModelsForCwd` JSON-RPC handler on the CLI
- `RpcGateway.listOpencodeModelsForCwd` / `SyncEngine.listOpencodeModelsForCwd`
- `GET /api/machines/:id/opencode-models?cwd=...` on the hub
This lets the web NewSession form discover OpenCode models for a chosen
directory before any session is spawned.
* feat(web): add OpenCode model selector to NewSession with loading and default highlight
Adds a `OpencodeModelSelector` panel that the NewSession form swaps in
when the OpenCode flavor is selected. The panel:
- queries the new `GET /api/machines/:id/opencode-models?cwd=...` endpoint
via `useOpencodeModelsForCwd` (TanStack Query, 60s staleTime, no retry),
- shows a labelled spinner + skeleton rows while discovering,
- renders an inline error with a Retry button when probing fails,
- renders an empty-state message when the directory yields no models,
- highlights the OpenCode-reported `currentModelId` with a "Default" badge
and auto-selects it (or the first option) so the form has a sensible
value if the user hits Enter without scrolling.
Selection is reset whenever the agent / machine / directory changes so a
new probe can establish a fresh default. Directory input is wrapped in
`useDeferredValue` so per-keystroke edits do not spawn a fresh
`opencode acp` probe. The chosen model is forwarded on session spawn
via the existing OpenCode `model` parameter.
Adds en + zh-CN locale strings for the loading / failure / empty / retry
/ default-badge labels.
* fix(cli): guard /machines/:id/opencode-models handler with workspace root check
The machine-scoped `listOpencodeModelsForCwd` RPC handler was registered by
`registerCommonHandlers` without any workspace-root check, so a web client
could pass an arbitrary `cwd` and have the runner spawn an `opencode acp`
subprocess plus a `session/new` against that path. That broke runner
isolation, since peer machine-scoped handlers (`list-directory`,
`spawn-happy-session`) already enforce the configured workspace root.
Re-register the handler in `ApiMachineClient` so it reuses the existing
`resolveForWorkspaceCheck` (realpath-based, with missing-tail walking) and
`isWithinWorkspaceRoot` helpers before delegating to the lower-level probe.
The resolved cwd is forwarded down so symlinked-but-contained paths still
work, while traversal attempts are rejected with the same error shape the
peer handlers use. Added unit tests around the new dispatch path. Addresses
HAPI Bot review on PR #558.
* fix(web): gate opencode model discovery on cwd existence
The new-session form previously enabled `useOpencodeModelsForCwd` as
soon as the OpenCode agent, machine, and any non-empty directory string
were present. Because that hook calls `/machines/:id/opencode-models`
and the CLI handler starts an `opencode acp` probe for that cwd, normal
typing through partial paths could launch expensive 30s OpenCode
subprocesses for non-existent directories before the path-existence
result had validated the final cwd.
Reorder NewSession so `useMachinePathsExists` runs before
`useOpencodeModelsForCwd`, then gate the discovery hook on the
directory having been positively confirmed to exist
(`pathExistence[deferredDirectory] === true`). The decision is
factored into a small pure helper `shouldEnableOpencodeModelDiscovery`
so the contract can be unit-tested without provider scaffolding.
* fix(web): keep current opencode model on shortcut without dynamic options
`getNextModelForFlavor` is invoked by the global Ctrl/Cmd+M shortcut in
`HappyComposer`, which is now active for OpenCode sessions because the
agent backend declares the `ModelChange` capability. When the dynamic
OpenCode model list has not yet been loaded — e.g. the user presses the
shortcut before `/opencode-models` returns — the function received an
`undefined`/empty `customOptions` and fell through to the Claude preset
cycler, which would emit `sonnet`/`opus` for an OpenCode session. The
following turn then attempted `session/set_model` with a Claude model id
that no OpenCode provider can serve.
Add an `opencode` branch that returns the (normalized) current model
unchanged when no dynamic options are available, mirroring the existing
empty-list policy of `getModelOptionsForFlavor`. Unit tests cover the
undefined / empty / null-current-model variants and lock the
no-Claude-fallback contract. Addresses HAPI Bot review on PR #558.
|
||
|
|
9c117679f0 |
feat(web): collapse long code and terminal output previews (#546)
* feat(web): collapse long code and terminal output previews * fix(web): keep fallback tool output complete * fix(web): keep raw json details uncollapsed |
||
|
|
7c954ad901 |
feat(web): add sidebar search and per-group preview limit (#547)
* feat(web): add sidebar search and per-group preview limit * test(web): cover session list search previews |
||
|
|
52ec08b6cb |
feat(web): show subagent task trace in tool dialog (#539)
* refactor(web): extract shared task tool helpers * feat(web): show subagent task trace in tool dialog Task tool modals previously showed only Input and Result. This adds a Trace section between them that surfaces the child tool calls already wired through the reducer into block.children. - TraceSection collapses by default when completed, expands when running or error so the relevant state is visible on open - Each child row toggles an inline expand (Input/Result) to avoid nested Dialogs - Header summarises call count, token total and duration via readSummaryFields() typed parser, falling back gracefully when any value is absent - formatTaskChildLabel / TaskStateIcon imported from shared helpers.tsx (extracted in prior refactor commit) — no local duplicates - Task name guard: getTaskTraceChildren returns null for non-Task blocks - children prop renamed to items in TraceSectionInner / TraceChildList (react/no-children-prop anti-pattern removed) - i18n: tool.trace and tool.trace.callsSuffix keys added for en and zh-CN; useTranslation hooked up to header label and calls suffix - 15 unit tests: getTaskTraceChildren (guard, filter, non-Task null), getTraceSummaryText (3 branches), TraceSection (open/close/toggle/ summary/empty) * feat(web): include input view in trace row expand Expanded child rows in the Task trace section now render both an Input section and a Result section, matching the pattern used in the parent ToolCard dialog. Tools with a registered FullInputView use it; all others fall back to a JSON CodeBlock. Closes bot review on PR #539. |
||
|
|
d66547ff46 | Add web conversation outline (#534) | ||
|
|
010dc41369 |
feat: workspace browser with --workspace-root opt-in scoping (#526)
* feat(web): add workspace browser for multi-directory navigation Add /browse route with a folder browser that lets users navigate filesystem directories on connected machines and launch sessions from any folder. Supports saved workspace paths and direct path input. The "Start Session" action pre-fills the NewSession form. - CLI: register machine-level `list-directory` RPC handler - Hub: add POST /machines/:id/list-directory route - Web: add WorkspaceBrowser component with git repo detection - Web: add /browse route with navigation from sessions sidebar - Web: support initialDirectory/initialMachineId in NewSession Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add --workspace-root opt-in scoping for /browse and session spawn Adds a single new flag, \`--workspace-root <path>\` (with \`~\` / \`~/foo\` expansion), on \`hapi runner start\` and \`hapi runner start-sync\`. When set: - The runner reports the path in machine metadata. - The list-directory and spawn-session RPC handlers reject paths outside the root, so the web UI can't escape the configured tree even if someone crafts a request manually. - The /browse page in the web UI auto-opens that root, restricts the breadcrumb / go-up to its subtree, and shows directory entries with git-repo annotations. - The /sessions/new form keeps its existing free-text directory input plus autocomplete + recent-paths chips, and gains a small "Browse" button (next to the input) that opens /browse for picking a folder. - Reconnect-time metadata sync ensures stale records get the field filled in (or cleared when the flag is dropped on a later restart), so the hub state matches the CLI's intent. When unset: - Runner behaves like the legacy hapi (no scoping, no browse feature). - /browse renders an informative state pointing at the flag instead of blocking the user. - The /sessions/new form looks identical to the pre-change behavior; the "Browse" button is hidden. Includes a startup banner so \`runner start-sync\` no longer looks like it hung, and surfaces the workspace-root sync result on stdout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hub): preserve workspaceRoot when rehydrating machines from store MachineCache.refreshMachine() rebuilt the metadata object from an explicit field allowlist, so any field not in the list (including the new workspaceRoot) was silently dropped on every read — even though it was correctly written to the store. Add workspaceRoot to the zod schema, the Machine interface, and the hand-rolled projection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): friendlier empty state on /sessions When there are zero sessions the page used to be a vast blank rectangle with just the "0 sessions in 0 projects" caption. Render a centered empty state instead: a calendar/agenda icon, a short heading and hint, and two buttons — "Start a session" (→ /sessions/new) and "Browse workspace" (→ /browse). SessionList gains an optional onBrowse prop. Router wires it on the sessions page so the secondary button resolves; other callers can leave it unset to hide that button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: document --workspace-root flag in cli/README and root README Add a short paragraph under "Runner management" in cli/README.md explaining what \`--workspace-root\` enables (scoped /browse tree, list/spawn enforcement, tilde expansion) and that omitting it keeps the legacy behavior. Mention the workspace browser in the top-level README's Features list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR #526 review feedback Three findings from the review bot: 1. [Major] Workspace-scope check was lexical only. With workspaceRoot = /safe, a symlink such as /safe/out -> /etc would pass the relative- path test and let list-directory / spawn-happy-session reach paths outside the configured root. realpath the workspaceRoot at construction time, and resolve every incoming path through realpath (walking up to the nearest existing parent for spawn targets that haven't been created yet) before the containment check. 2. [Minor] \`hapi runner start --workspace-root\` with no value used to drop the flag silently and start the runner unscoped. Now treats a missing or flag-shaped next argument as an error. 3. [Minor] /sessions/new's "Browse" button always opened /browse using localStorage's last-used machine, ignoring the user's current selection. NewSession already passes machineId in its callback; forward it through the /browse search params and seed WorkspaceBrowser with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): gate list-directory RPC behind --workspace-root opt-in Without a configured workspaceRoot, isWithinWorkspaceRoot() returns true unconditionally, leaving the new list-directory RPC able to enumerate any path on the runner. The Web UI already hides Browse for these machines, but the backend should enforce the opt-in too. Refuse the RPC up front when no workspace root is configured. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
97be34e21c | Add Codex model selection | ||
|
|
79a13d26c6 | Fix Codex reasoning effort resume and updates | ||
|
|
450b4f8aa7 |
fix(web): reconnect SSE immediately when tab becomes visible (#398)
* fix(web): reconnect SSE immediately when tab becomes visible The SSE watchdog skips heartbeat checks while the tab is hidden. If the connection dies in the background, the user sees stale messages after switching back and has to wait up to 10 s for the next watchdog tick. Add a visibilitychange listener that checks heartbeat staleness immediately when the tab becomes visible and reconnects if stale. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): map visibility-recovery reason in reconnecting banner The new 'visibility-recovery' reconnect reason was not mapped in getReasonLabel(), so the raw string would appear in the UI banner. Add localized labels for both en and zh-CN. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
92885ddef0 | fix(web): hide unsupported Codex slash commands in remote mode (#357) | ||
|
|
a200fe9628 |
feat(claude): add effort setting parity with model across stack (#353)
Co-authored-by: Xiaoyi <xiaoyizhang@microsoft.com> |
||
|
|
895654ddf6 | fix(terminal): prevent infinite reconnect loop on Windows hosts (#336) | ||
|
|
ea45797b6f | web: add terminal font size setting (#324) | ||
|
|
eb18530fed | fix(web): warn before creating missing session directories (#317) | ||
|
|
cb09f0b898 | feat: add codex reasoning effort option (#297) | ||
|
|
16829b7c78 | Add support for codex plan mode | ||
|
|
329d28a93c | remove , using instead | ||
|
|
02c8e12e80 | unify model selection | ||
|
|
a0c35bc225 |
feat(web): add appearance setting (follow system / dark / light) (#253)
* feat(web): add appearance setting (follow system / dark / light) Add user-facing appearance preference to the settings page, allowing users to choose between Follow System, Dark, and Light themes. The preference is persisted to localStorage and takes priority over automatic detection in the existing theme pipeline. * fix(web): update theme on cross-tab appearance change The storage event handler only updated React state without calling updateScheme(), leaving data-theme and useTheme subscribers stale. * fix(web): move cross-tab appearance sync to global initializeTheme The storage event listener was inside useAppearance(), which is only mounted on the settings page. Other pages never received cross-tab theme updates. Move the listener into initializeTheme() so all pages respond to appearance changes from other tabs. |