mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
73584e925a01e287665c9aa747f1d505911c898e
427
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
73584e925a |
feat(cursor): multitask slash, autoReview mode, native worktree/add-dir (#1014)
* feat(cursor): multitask slash, autoReview mode, native worktree/add-dir Close the highest-value Cursor Agent gaps for remote HAPI: expand ACP-safe slash pass-through (/multitask, worktree, add-dir, …), add autoReview permission mode (--auto-review spawn + mid-session slash), and route Cursor New Session worktrees through agent --worktree instead of HAPI sibling trees. Fixes #1013 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): accept --mode autoReview for hapi cursor Align --mode parsing with CURSOR_PERMISSION_MODES so documented `hapi cursor --mode autoReview` enables Smart Auto instead of silently falling back to default. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
b104c38f3a |
fix(web): show Codex reasoning effort in SessionHeader (#1016)
Surface the same reasoning label already shown in the composer StatusBar in the top SessionHeader for codex/opencode sessions. Also show an explicit Fast badge only when serviceTier is fast (#1004-aligned). Closes #1015 (header display portion). Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
d160203bb2 |
fix(codex): support dynamic reasoning efforts (#1012)
* fix(codex): support model-reported reasoning efforts * fix(web): prevent service worker edge caching * ci: retrigger stuck Actions run * fix(codex): accept dynamic reasoning effort values * fix(web): restore reasoning effort on model switch failure |
||
|
|
4c76668a6c | refine message actions and metadata | ||
|
|
8782b8a110 |
fix(codex): align local transcript message projection
Use semantic events as the visible text source and preserve completed plans as ordered plan proposal cards. |
||
|
|
65e1708c78 |
feat(web): mermaid diagram lightbox on click (#741)
* feat(web): mermaid diagram lightbox on click Click rendered mermaid blocks in chat to open a zoomable full-screen viewer. Re-renders from source in the modal with the current theme. Closes #737. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): fit mermaid lightbox to viewport on open Auto-scale diagrams to fill the viewer instead of opening at intrinsic mermaid size. Reset returns to fit; zoom label is relative to fit (100%). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): fit mermaid lightbox to device screen not inner panel Use visualViewport for fit scale, full-screen pan layer, and a floating toolbar so the diagram can use the whole display. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): show mermaid lightbox by reusing inline SVG Second mermaid.render on open often left a 0×0 SVG while fit scale was computed from the loading placeholder. Reuse the inline SVG in the modal and measure viewBox with retried fit-to-screen. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): uniquify mermaid SVG ids in lightbox clone Inlining the same mermaid markup twice duplicates element ids and breaks url(#ref) resolution in the modal copy. Prefix ids and hrefs for lightbox only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): give mermaid lightbox SVG explicit dimensions Mermaid emits width="100%" with max-width in px; that collapses to 0×0 inside the centered lightbox layer. Derive width/height from viewBox for the uniquified lightbox clone. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): render mermaid lightbox via isolated SVG data URL String id rewrites broke mermaid's embedded CSS so only labels appeared zoomed. Rasterize the inline SVG to a data-URL img instead of duplicating markup in the DOM. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): lightbox re-renders SVG for sequence diagrams Data-URL images drop or blank some mermaid diagram types (sequence). Re-render with a modal-specific id into inline SVG on a code-bg panel, and add sequence theme variables for dark/light. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): mermaid lightbox uses inline SVG in shadow DOM Reuse the inline render in an isolated shadow root so sequence CSS stays intact, and fit the viewport from viewBox dimensions instead of the loading placeholder or width="100%" layout. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): Playwright lightbox coverage per mermaid diagram type Add e2e harness and a script that opens the lightbox for each diagram kind (flowchart through kanban). Fit uses inline getBBox() so compact charts like gitGraph fill the viewport. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): bounded Playwright via webServer, fix gantt fit sizing Playwright owns Vite lifecycle (no agent-spawned dev server). Fit uses viewBox unless viewBox padding is excessive (gitGraph); wide charts use width-based coverage in e2e. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(web): gitignore Playwright test-results Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): address PR 741 bot feedback (typecheck, fit floor, gitignore) Guard lightbox open when svg is null; allow fit scale down to 0.01 while keeping 0.25 minimum for manual zoom; ignore Playwright test-results/ correctly. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): Playwright asserts click expands diagram vs inline Measure inline vs lightbox bounding box after click; require visible growth (area ratio or max dimension) plus dialog + shadow SVG content. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): Playwright against live HAPI session for mermaid lightbox Add seed script for a dedicated chat session, live hub Playwright suite (HAPI_LIVE=1), and dogfood doc. Live tests fail until driver serves shadow-DOM lightbox (catches gray-box regression on stale bundles). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): undo wrapper transform in lightbox fit; carry fit floor in zoom Resolves PR #741 review threads (HAPI Bot Major): 1. measureSvgIntrinsicSize / measureContentSize prefer intrinsic dimensions (viewBox -> width/height attrs -> img.naturalSize) before getBoundingClientRect. When the rect is the only signal, divide by scaleRef.current so the 50/200ms refit retries stop compounding with the wrapper's scale(...) transform. Large diagrams no longer jump tiny or oversize after async render completes. 2. Interactive zoom (wheel/keys/buttons/pinch) now clamps with Math.min(MIN_SCALE, baseScaleRef.current). A diagram fitted below the normal 25% floor stays reachable instead of snapping back to 25% and clipping. Zoom-out button disabled threshold uses the same min. 3. Add Vitest coverage for both helpers (intrinsic precedence, scale-aware rect fallback, divide-by-zero guard) so regressions surface without needing the full Playwright stack. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): mermaid seed refuses to wipe non-fixture sessions HAPI Bot Major (PR #741): SESSION_ID is documented as overridable, and the script unconditionally deletes every message for the target session before seeding fixtures. If pointed at a real session id, that's silent data loss. Refuse to proceed when an existing session id has a tag other than 'mermaid-lightbox-e2e'. New ids and the canonical fixture session still seed normally; real sessions throw before any DELETE runs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): normalize mermaid svg for lightbox shadow root Mermaid emits width="100%" on every diagram. Inside a shadow root whose host has no explicit size, that collapses to zero in Chromium for most diagram types - only ones that ship pixel attrs (e.g. journey) happen to render. Operator confirmed on the live driver: every diagram except journey opened to a grey rounded square. MermaidLightboxSvg now runs normalizeMermaidSvgForStandaloneDisplay before injecting (strips width/height="100%", bakes viewBox dims as pixels) and sets :host{display:inline-block} so the host sizes to the SVG. Inline svg in chat is unchanged - only the lightbox copy is normalized. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep mermaid lightbox content below the toolbar Operator screenshot showed the diagram top (e.g. pie 'Pets' title) clipped behind the toolbar bar. Two causes: 1. getScreenFitSize used the full viewport height, so the fit scale sized the diagram to fill an area the toolbar overlapped. 2. The viewport (drag/zoom area) was inset-0; content centered on the full viewport center, not the visible region's center, pushing the top behind the toolbar. Measure the toolbar with a ResizeObserver, subtract its height from the fit calculation (clamped at zero), and start the viewport region below the toolbar (top: toolbarHeight). Fit scale recomputes whenever toolbar height changes. Adds Vitest coverage for getScreenFitSize reserved-top math. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): guard ResizeObserver before constructing it HAPI Bot Major (PR #741): Vitest jsdom does not polyfill ResizeObserver, so the toolbar measure effect throws ReferenceError when the existing mermaid-diagram React tests open the lightbox. Same code path is also brittle in any browser/webview without the API. Fall back to plain window 'resize' listener when ResizeObserver is absent. Toolbar height won't auto-update on element resize without it, but the lightbox still renders and the resize listener catches the common viewport-rotation case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): live mermaid playwright wrapper runs from repo root HAPI Bot Minor (PR #741): the wrapper sets cwd to scripts/, but the test:mermaid-lightbox:live npm script lives in the repo-root package.json, so spawning npm there exited before Playwright started. Switch cwd to the repo root and drop the unused WEB_DIR constant. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): accept signed viewBox values in mermaid lightbox normalize HAPI Bot Minor (PR #741): the viewBox regex only matched digits, dots, and spaces, so a valid viewBox with negative origin (e.g. '-8 -8 640 480') returned null. normalizeMermaidSvgForStandaloneDisplay then became a no-op and left width='100%', re-introducing the zero-sized lightbox render this PR is meant to fix for the affected diagrams. Switch to the bot's suggested regex (signed numbers, single or double quotes, comma or space separators) and reject NaN parts. Adds Vitest coverage for signed origins, single quotes, comma separators, the malformed/no-viewBox null paths, and an end-to-end normalize test that fails against the old regex. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): align @playwright/test on 1.60.0 across workspaces HAPI Bot Major (PR #741): web/package.json pinned @playwright/test at 1.49.1 while the root workspace and bun.lock were on 1.60.0. The mismatch surfaced after rebasing onto upstream/main, where the root had already moved to 1.60.0 while my web devDependency lagged from an older commit. A frozen install would reject the lockfile and the new web e2e script could resolve a different Playwright than root scripts. Bump the web devDependency to 1.60.0 and regenerate bun.lock so all workspaces share one Playwright version. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): move mermaid playwright fixtures out of public HAPI Bot Minor (PR #741): the e2e and smoke fixtures lived under web/public, so Vite copied them verbatim into web/dist and the hub asset generator embedded them in production bundles. Both pages import Vite dev-only paths (/@react-refresh and /src/dev/...), so the production /mermaid-lightbox-{e2e,smoke}.html routes would 404 on those imports. Move both fixtures to web/e2e-fixtures/ to match the existing scratchlist-fixture pattern (relative ../src/dev import, served by Vite at /e2e-fixtures/...) and update the Playwright spec to hit the new path. Build now ships 112 PWA precache entries instead of 114 (both fixtures excluded from dist). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
a82dd49049 |
feat(web): markdown Source | Preview toggle in session file pane (#957)
* feat(web): markdown Source | Preview toggle in session file pane Add Source | Preview toggle for .md/.mdx files in the session file route, defaulting to preview with localStorage persistence. Reuse chat markdown pipeline via MarkdownRenderer standalone mode (no assistant-ui thread). Includes unit tests, Playwright smoke, and e2e fixture. Closes tiann/hapi#954 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): cast standalone MarkdownRenderer components for react-markdown Soup verify gate: defaultComponents merge type is wider than react-markdown Components; standalone file-pane path needs explicit cast. * fix(web): route file-pane markdown fences through SyntaxHighlighter Standalone file preview now mirrors chat code-block rendering: fenced blocks use SyntaxHighlighter and MARKDOWN_COMPONENTS_BY_LANGUAGE (mermaid included) without requiring ThreadPrimitive context. Addresses HAPI Bot Major on tiann/hapi#957. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): detect fenced vs inline code in standalone markdown preview Move block detection to the pre override (react-markdown v10 does not pass inline to custom code components). Add inline-code regression test. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
018bcb8eaa | fix(web): clean up React tests globally (#993) | ||
|
|
58cfc330f9 | fix(web): show standard for untouched Codex service tier (#1010) | ||
|
|
e45fde51e9 |
fix(claude): stop 1M/200k context-window flicker in the status bar (#992)
* refactor(claude): thread session's selected model into SDKToLogConverter
Adds an optional selectedModel field to the converter's context, wired
from session.getModel() in the launcher, so a later commit can seed the
turn-1 contextWindow estimate for presets whose system/init model
arrives without the "[1m]" suffix. No behavior change yet.
* fix(claude): key contextWindow cache by model to stop 1M/200k flicker
The remote launcher re-emits system/init on every turn for the same
converter instance. Its init-time estimate only checked whether the
model string ended in "[1m]", but current claude CLI versions strip
that suffix from system/init for some 1M presets (fable[1m] arrives as
"claude-fable-5"), so the estimate guessed 200k for them. The one
authoritative value is result.modelUsage[<model>].contextWindow, which
arrives after the heuristic has already injected 200k into that turn's
assistant message and then gets clobbered back to 200k by the very
next turn's init - producing the observed 200k<->1M oscillation in the
web status bar.
Cache the authoritative contextWindow per model id instead of a single
session-wide number, and only let system/init seed a heuristic guess
for a model that has no cached value yet, so a same-model re-init no
longer downgrades an already-learned value.
Two observed facts about the CLI's model ids drive the design:
system/init.model and the result.modelUsage keys always agree with
each other within a session (both bare for plain/fable[1m], both
suffixed for opus[1m]/sonnet[1m]), while each per-turn assistant
message reports its model bare and thus can't distinguish a 200k plain
preset from its 1M "[1m]" variant on tiers where they share a base id.
So the cache is keyed on the raw id (init/result agree, no
normalization) and assistant injection looks the value up via
resolvedModel (the last init id) rather than the lossy message.model.
Keying raw keeps a plain preset and its [1m] variant on distinct
entries; looking up via resolvedModel also means sidechain (Task
subagent) messages carry the main session window rather than the
subagent's own, since the web status bar picks the most recent usage
message without filtering sidechains and would otherwise flicker to
the subagent's smaller window while it runs.
For presets whose init model arrives bare even though they are 1M
(fable[1m]), the originally-selected preset - which preserves the
"[1m]" suffix - seeds the turn-1 estimate, kept live across mid-session
model switches via updateSelectedModel() (called from the launcher on
every turn) so it never goes stale.
* fix(web): recognize [1m] suffix on full Claude model ids in budget fallback
getContextBudgetTokens already special-cased "[1m]" for short preset
values (e.g. "opus[1m]") but fell through to the default 200k budget
for full model ids (e.g. "claude-opus-4-8[1m]"), which is what the CLI
now reports once context_window isn't available and this fallback is
consulted. Check the suffix on that branch too so it stays a correct
last-resort even without a session-provided context_window.
* refactor(web): merge duplicate Claude context-budget branches
isClaudeModelPreset(trimmedModel) and the startsWith('claude-') branch
below it had become byte-for-byte identical bodies after the [1m]
suffix check was added to both. Merge them into one condition; no
behavior change.
* fix(claude): distinguish fable from fable[1m] when the CLI reports both bare
The per-model contextWindow cache keyed on the raw system/init model id,
on the assumption that a 1M preset and its plain form always land on
distinct ids. That holds for opus[1m]/sonnet[1m] (the CLI reports the
"[1m]" suffix on their init and result ids) but not for fable: the CLI
reports both "fable" and "fable[1m]" with the same bare id
"claude-fable-5". So the "seed only if not already cached" guard would
skip re-seeding when switching fable[1m] -> fable, leaving the stale 1M
in place until fable's own result arrived - the same switch flicker this
change set out to remove, just for fable specifically.
Fold the selected preset's "[1m]" back into the cache key
(computeContextWindowKey): when the init model arrives bare but the
session selected an "[1m]" preset, key the entry as "<id>[1m]" so the 1M
and plain variants stay distinct; ids the CLI already suffixed are left
as-is. Seeding, lookups, and the current-model result entry all use this
resolved key. Subagent result entries (e.g. haiku) keep their own raw id
so the session's "[1m]" is never folded onto a model that isn't the
selected one.
|
||
|
|
b44885ae67 |
feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953)
* feat(gemini): remove launchable Gemini CLI agent, keep sessions readable Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped serving requests 2026-06-18). This removes the ability to launch/create Gemini CLI sessions while keeping existing stored Gemini sessions fully readable in the web UI. Removed (no longer launchable): - cli/src/gemini/ runtime (runGemini, loop, local/remote launchers, session, ACP backend, config, scanner) + GeminiDisplay ink view - `hapi gemini` command + registry entry + usage line - runner spawn branch & buildCliArgs mapping now reject gemini with a clear error; resume dispatch throws a clear "no longer supported" error - gemini dropped from the new-session agent selector via new CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults Kept (read path — existing sessions still validate, load, render): - `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS - AgentFlavorIcon badge, model-option labels, ACP message normalization, metadata.geminiSessionId, hub session dedup/resume-id Note: the Gemini *Live voice* backend is a separate feature and is untouched. Adds read-guarantee tests (stored gemini validates; excluded from creatable). typecheck + full suite green. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): reject gemini resume before handoff (#953 review) HAPI Bot [Major]: `hapi resume <active-gemini-session>` called handoffSessionToLocal() — which tells the running remote agent to exit — before reaching the gemini-unsupported throw in dispatchLocalResume, so it could stop the live/readable session and then fail locally. Move the gemini guard into resumeCommand.run before the handoff, so an active Gemini session is left running/readable instead of being stopped. Keep the dispatch-layer guard as defense-in-depth. Adds a regression test asserting handoffSessionToLocal is not called for an active gemini target. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): harden against stale gemini input (#953 review) Two [Minor] follow-ups from HAPI Bot: - newSessionFormDraft: coerce a restored browse draft's agent to a creatable flavor, so a pre-removal 'gemini' draft cannot submit agent:'gemini' even though the selector no longer offers it. - buildCliArgs: reject 'gemini' explicitly instead of silently falling through to the 'claude' command if the exported helper is reused outside the guarded spawnSession path. Updated the buildCliArgs precedence test to a creatable agent and added a test asserting buildCliArgs('gemini') throws. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): reset dependent draft fields when coercing stale agent (#953 review) Follow-up [Minor]: coercing a stale gemini draft's agent to claude left model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' } draft restored as claude *with* a Gemini model, which handleCreate() then sent to the runner. Now reset model / cursorSelectedBase / effort / modelReasoningEffort to defaults whenever the agent is coerced. Adds a regression test. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review) HAPI Bot [Major]: after removing geminiCommand from the registry, resolveCommand() treats `gemini` as an unknown subcommand and falls through to the default Claude command (forwarding "gemini" as an arg), so `hapi gemini` silently started Claude instead of reporting the sunset. Add an explicit tombstone `gemini` command that prints the sunset error and exits 1. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(web): assert AgentSelector hides the sunset Gemini agent (#953) Render regression test confirming the new-session AgentSelector offers exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: HAPI <noreply@hapi.run> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
26a24bb6ce |
feat(web,hub,cli): show machine health in session sidebar (#962)
* feat(web,hub,cli): show machine load in session sidebar Runners attach OS health snapshots to machine-alive heartbeats; the hub caches them and the web session list renders load or CPU between the machine label and session count. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web,cli): show CPU and RAM pressure in machine health badge Sidebar label now combines CPU and RAM percentages for overload signaling; load stays in the tooltip on Unix. Prime CPU sampling so the first heartbeat includes usage, not just memory. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): visual machine health meters with tooltip Replace bare CPU/RAM text with labeled mini bar gauges, chip border tint by severity, and a HoverTooltip explaining capacity and overload guidance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): widen machine health tooltip with horizontal layout Allow a generous popover width and lay CPU/RAM/load out side by side so the capacity tooltip reads wider and less tall than the chip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to row left edge Wide tooltip was align=end on the chip, so it grew left off-screen. Use row-span positioning on the machine tile button instead. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): machine host card with OS label and inline health Turn the session sidebar machine row into a bordered host panel with OS metadata and side-by-side CPU/RAM meters embedded in the tile instead of a flat label line matching project rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep machine host tile single-row height Collapse the machine header back to one py-1.5 row with OS and compact inline health beside the name, and restore the original project indent without the extra nested rail or second header line. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): show CPU core count in machine health tooltip When the runner reports cpuCount, the tooltip reads "CPU across all 6 cores" instead of the generic all-cores label. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: add machine health sidebar screenshots Dogfood captures for the session sidebar machine tile and capacity tooltip, for upstream PR review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): clear machine-alive priming timeout on disconnect Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so disconnect/shutdown during the delay cannot leave a stray interval alive. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop dogfood screenshots from upstream PR diff Review evidence lives in the PR discussion only; no need to ship PNGs in the repo long-term. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): truncate long machine OS/host metadata in sidebar row Bound the metadata span so a long hostname cannot push the health chip or session count off-screen in narrow sidebars. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): reveal machine health tooltip on keyboard row focus Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine header button so keyboard users can read the health tooltip like session rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): use MemAvailable for Linux RAM pressure on Bun Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which made sidebar RAM read ~99% while btop showed ~40% used. Parse /proc/meminfo MemAvailable instead so used percent matches operator tools. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web,cli): show machine uptime in sidebar tiles and tooltip Collect os.uptime() as uptimeSeconds on keepalive and render compact up 1h 54m in the machine meta row plus an Uptime line in the health tooltip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to chip not row align=row positioned the tooltip below the full machine header button, so the collapsible project panel painted over it on hover. Use align=end with a min-width panel so mouse and keyboard tooltips stay visible. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
5ade952218 |
fix(cursor): support ACP parameterized model picker (#969)
* test: reproduce issue #968 * fix: support Cursor parameterized model picker (closes #968) |
||
|
|
f51e06e8f3 |
fix(web): stop pinning resolved permission cards to the bottom of the chat (#974)
agentState keeps an answered permission request in completedRequests. When its tool_use message is not in the loaded window, the permission-only synthesis appended a card to the end of the timeline — and there is no chronological re-sort, so the card stays pinned above the composer as a stale "answered" card that never moves to its place in history. With several answered asks this piles up at the bottom of the chat. Synthesize a card only for a *pending* request (the case that needs an answerable card when its message hasn't loaded). A resolved request is history and renders only via its own message when that message is in the window. |
||
|
|
a0259b531e |
feat(web): drag-and-drop files onto chat panel to add as attachments (#936)
* feat(web): drag-and-drop files onto chat panel to add as attachments Closes #935 Adds a `useDragOver` hook that detects when a file is being dragged over the browser window and suppresses the browser's default file-open behaviour for drops outside the accept zone. A new `DragDropZone` component wraps the inner `AssistantRuntimeProvider` content in `SessionChat`. It shows a semi-transparent overlay (dashed border + "Drop to attach" label) on the right-side chat panel as soon as any file drag is detected — regardless of where the pointer is on the page. Dropping on the right panel adds the files as composer attachments via the existing `api.composer().addAttachment()` path. Drops on the left sidebar are suppressed (no navigation, no attachment). via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): disable drag-drop zone when pendingSchedule is active The backend rejects requests with both scheduledAt and attachments. DragDropZone now respects pendingSchedule the same way paste and the attach button do — disabled=true suppresses the overlay, sets dropEffect='none', and skips addAttachment on drop. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): harden drag-drop default-action handling Address HAPI Bot review on #936: - useDragOver: cancel the browser's default file-open/navigation on the document-level `drop` event for file payloads, not only on `dragover`. Preventing default on `dragover` alone still lets the browser open a file dropped outside any zone (e.g. the sidebar), which could unload the app. - DragDropZone: only preventDefault when the drop payload actually contains files, so non-file drops (e.g. dragging selected text into the composer) keep their default browser behaviour. Add regression tests for both. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(web): use Simplified Chinese for composer.dropToAttach in zh-CN Address HAPI Bot review on #936: the new zh-CN string used Traditional Chinese forms (放開以附加檔案) in the Simplified Chinese locale, which is inconsistent with neighbouring keys (e.g. composer.attach = 添加文件). Use 松开以添加文件 to match. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: HAPI <noreply@hapi.run> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b1910b6b2e |
feat(web): session header files and outline view toggles (#952)
* feat(web): session header files and outline view toggles Files and outline icons in SessionHeader act as depressed toggles; files view shares the session header and places refresh beside the search box. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(web): add Playwright handoff script for session view toggles Supports new-feature-intake visual gate: files toggle pressed + refresh beside search. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dev): handoff script avoid networkidle on live hub SSE HAPI keeps connections open on :3006; domcontentloaded is the correct wait. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): place filesystem refresh outside search field Refresh is a sibling of the search pill, not inside it, so the control is visually and structurally separate from file search. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
02a0aa6733 |
fix(cursor): surface agent errors with warning styling in web UI (#871)
* test: reproduce issue #864 Assert Cursor error paths emit agent error payloads and web UI renders them as warning-styled events instead of neutral session messages. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): surface agent errors with warning styling in web UI (closes #864) Route Cursor stderr, init, prompt, and legacy exit failures through sendAgentMessage({ type: 'error' }) and teach the web chat layer to render error events with a warning icon instead of neutral info text. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
bfd0f4a376 | fix(web): keep Codex restart control clear of close button (#880) | ||
|
|
8f3ea10df7 |
fix(web): mechanical repair of GFM tables with off-by-one separator rows (#902)
* feat(web): mechanical repair of GFM tables with off-by-one separator rows Adds a remark plugin (remarkRepairTables) that runs after remark-gfm and silently fixes the dominant broken-table pattern seen in agent output: the separator row has fewer pipe-delimited cells than the header row. remark-gfm follows the GFM spec and silently truncates the table to the separator column count, dropping header and data cells. This plugin reads the original source via file.value position data, detects the mismatch, pads the separator row, and re-parses the corrected block so all columns are preserved. Analysis of 7 days of session data: 975 apparent table blocks, 879 flagged broken. Of those, 744 (84.6%) were false positives (inline pipes in prose and shell commands). The separator off-by-one pattern accounted for the majority of genuine failures (~94 of 135 real broken tables). The plugin is wired into MARKDOWN_PLUGINS and MARKDOWN_PLUGINS_WITH_BREAKS, immediately after remarkGfm where position data is available. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * test(web): strengthen remarkRepairTables test suite - Remove unused parseTableCols helper - Assert alignment markers (:-- / --:) are preserved in repaired separator - Add header-only table test (header + broken separator, no data rows) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): remove dead repairCount + add structural column-count assertion - Drop repairCount from visitTables — increment was never read at call site - Add per-row cell count assertion to the 3-column repair test to catch structural regressions that content-presence checks would miss via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * test(web): add structural column-count assertions to remaining repair tests Off-by-N (4-column), alignment-hints, and header-only tests now verify each output row has the correct number of cells, not just content presence. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): skip escaped pipes in countSourceCells to prevent false repairs \| inside a GFM table cell is a literal pipe character, not a cell delimiter. The previous split('|') approach miscounted cells in headers like | A \| B | C |, treating a valid 2-column table as 3-column and padding the separator unnecessarily. Replaces the split with a character-scan that tracks escape state. Adds a test asserting the separator column count stays at 2 for tables with escaped pipes in the header. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): re-parse repaired table with main processor to preserve inline extensions parseTableBlock() previously created a bare remarkParse+remarkGfm processor, so inline math (or other pipeline extensions) inside a repaired table cell was parsed as plain text and lost after repair. Fix: use this (the Processor instance unified passes to the plugin factory) to re-parse the repaired block, so all registered extensions apply. Removes the now-unused remarkParse/remarkGfm/unified imports. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): rewrite repair plugin as string preprocessor The previous implementation visited `table` AST nodes after remark-gfm parsed the source. But remark-gfm 4.x degrades a mismatched-separator table (separator row has fewer cells than the header row) to a paragraph node entirely — no `table` node is ever produced, so the visitor never triggered and the repair was a no-op. New approach: scan `file.value` for broken separator rows BEFORE the AST is built, pad them in-place, then re-parse the corrected source so remark-gfm produces proper table nodes. Export `repairMarkdownTables` as a named function for direct testing. Update the unit tests to actually discriminate between a repaired table (stringified lines start with `|`) and the old broken paragraph output (stringified lines start with `\|`, escaped by remark-stringify). via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): skip fenced code blocks and preserve indentation in repair scan The string-level scanner was modifying table-like lines inside fenced code blocks (``` / ~~~) — a bug reported in PR review (Major). Also preserves original leading whitespace when replacing a separator line so indented tables are not affected. Add tests for fenced-code skip, ~~~ variant, and correct repair after a fence closes. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): harden remark-repair-tables against code-span pipes and mixed fences - countSourceCells: strip backtick code spans before counting column boundaries — a header like | `a | b` | c | is 2 columns, not 3 - repairMarkdownTables: track fenceChar ('`'|'~'|null) instead of a boolean toggle so ``` inside ~~~ no longer incorrectly flips fence state - add 2 tests: code-span-with-pipe in header, backtick inside tilde fence - fix stale comment in markdown-text.tsx (plugin reads file.value, not AST nodes) - drop no-op .trimStart() (padSeparatorLine already returns a trimmed string) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): handle double-backtick code spans and preserve tree root on re-parse - countSourceCells: use /`+[^`]*?`+/g so double-backtick spans like `` `a | b` `` are also stripped before counting column boundaries - remarkRepairTables: Object.assign(tree, newTree) instead of only copying children, so position/data from the root node are preserved via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): require closing fence to match opener length (GFM fence rule) A ```` fence must not be closed by ``` — GFM specifies the closer must use the same marker character AND be at least as long as the opener sequence. Track fenceLength alongside fenceChar so longer-backtick fences stay open until a closer of equal or greater length arrives. Also tighten the fence-match regex from /^\s*/ to /^ {0,3}/ to match the GFM spec (fences are valid with up to 3 spaces of indentation, not arbitrary whitespace). Adds a regression test for the ```` / ``` case. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): closing fence must have only whitespace after the marker (GFM rule) GFM §4.5: a fence closing sequence may only be followed by optional spaces. A content line like \`\`\`ts inside a code block is not a valid closer, so we must not clear fenceChar when the remainder of the line is non-whitespace. Captures rest after the marker and guards the close branch with /^\s*$/. Opening fences are unaffected (info strings on openers remain valid). Adds a regression test: ``` opener, ```ts content line, ``` closer. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
f5c0ef245b |
fix: active-only session filter + paginated "Show N more" (closes #901) (#903)
* test: reproduce issue #901 (active-only filter + paginated show more) * fix: active-only session filter + paginated 'Show N more' (closes #901) Add a persisted 'Active sessions only' toggle in Settings -> Display that hides inactive sessions in the sidebar while keeping the selected session visible. Change 'Show N more' to reveal one batch (preview-limit size) per click instead of expanding every hidden session at once, with 'Show less' to collapse back to the initial preview. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
22bf7e04d2 |
fix(web): persist file explorer expanded tree and scroll position across navigation (#911)
* fix(web): persist file explorer expanded tree and scroll position across navigation Expanded folder state and scroll position in the Directories tab were stored only in local React state, so navigating to a file and back would reset the tree to the root and scroll to top. Now both are saved to sessionStorage (keyed by sessionId) on every change and restored on remount, so the explorer resumes exactly where the user left off. Closes #910 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): key DirectoryTree by sessionId to prevent stale expanded state across sessions When navigating between sessions, React can reuse the same DirectoryTree instance. The useState lazy initializer only runs on first mount, so the tree would hydrate with the wrong session's expanded set and then overwrite the new session's storage key. Adding key={sessionId} forces a fresh mount per session. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
a8e08e8014 |
feat(web): add download button to file viewer (#926)
Adds a download icon button to the file viewer toolbar (next to copy-path). Clicking it decodes the existing base64 file content into a Blob and triggers a browser download — no new backend endpoint required. Works for text, binary, and image files. Button is hidden until the file has loaded successfully. Closes #924 via [HAPI](https://hapi.run) Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
2643f17840 |
feat(web): Web Share Target -> Android system share sheet integration (#933)
* feat(web): Web Share Target -> composer attachment preload
PWA manifest now declares a `share_target` so Android Chrome surfaces
HAPI in the system share sheet for any app (Photos, Files, browser).
Pipeline on share:
1. Service worker intercepts POST /share, parses the multipart payload
(title/text/url + N files), persists it in IndexedDB under a
transfer id, and 303-redirects to /share?id=<id>. The 303 forces
Chrome to convert the POST into a GET so the SPA route mounts.
2. New /share route loads the transfer, previews the content, and
lets the user pick a recent active session (top 5 by activeAt) or
a "+ New session". Tapping a session stashes the transfer id in
sessionStorage and navigates to /sessions/:id.
3. SessionChat mounts a ShareSeedConsumer once the AssistantRuntime
is up; it consumes the pending transfer once per mount, seeds
composer text + per-file attachments via the existing
attachmentAdapter, then deletes the IDB row so a refresh of the
session page does not replay the upload.
The whole feature reuses the existing /sessions/:id/upload endpoint;
no hub or shared changes.
Limitations (also disclosed in the PR body):
- PWA must be installed; Android Chrome only registers share_target
on install. iOS Safari ignores the manifest field entirely.
- File MIME accept list is broad (`*/*` fallback); some Chrome
versions still filter despite this.
Tests:
- shareTransfer.test.ts (8) covers payload parse, multi-file order,
type fallback, ingest redirect shape and error propagation.
- sharePendingState.test.ts (3) covers atomic consume + overwrite.
Closes: pending upstream issue (filed before PR per intake doc).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): freeze picker session list at mount, sort by updatedAt, drop top-5 cap
The /share picker was visually re-shuffling under the operator's finger
as SSE events rolled in: every session metadata patch refreshed the
React Query cache, the useMemo recomputed, and items reordered (often
within a second of opening the share sheet). Sort key was activeAt,
which heartbeats every few seconds while a session is connected,
making the noise floor even higher.
Three changes:
- Snapshot the active-session list once when sessions finish loading
via useState + a deferred useEffect. The picker is a one-shot
interaction; closing the share sheet and re-sharing produces a
fresh snapshot, so freezing for the duration of the picker view is
the right trade.
- Sort by updatedAt desc to match SessionList's canonical "most
recent interaction first" order. updatedAt only moves on
user-meaningful events, not heartbeats.
- Drop the TOP_SESSIONS=5 cap. The picker is already inside an
app-scroll-y container, so showing all active sessions and letting
the operator scroll matches the operator's mental model better
than an arbitrary truncation.
Per operator dogfood report: "list of recent sessions is constantly
updating; should be just a scrollable list, from most recent
interaction to not."
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): base-aware share_target paths for subpath PWA deploys
Manifest share_target.action, SW POST matching, and ingest 303 redirects
were hard-coded to /share. Standalone builds with --base /<repo>/ put
scope/start_url under the subpath but left the share action at origin
root, so Chrome posted outside the SW scope and the handler never ran.
Extract shareTargetPathnameFromBase() (used at build time in
vite.config.ts and at runtime via import.meta.env.BASE_URL in sw.ts and
shareTransfer.ts). Normalizes base to a trailing slash before URL
resolution so /repo and /repo/ both resolve to /repo/share.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): defer sessionStorage arm until new session spawn succeeds
The "+ New session" picker path called setSharePendingTransfer before a
session existed. Cancel, spawn failure, or backing out left a stale id in
sessionStorage that the next unrelated SessionChat mount would consume.
Pass shareTransferId via /sessions/new search params instead; arm the
consumer only in handleSuccess after spawn, and delete the IDB row on
cancel.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): append share text to existing composer draft
ShareSeedConsumer called setText(seedText) unconditionally, clobbering
per-session drafts restored by useComposerDraft from sessionStorage.
Merge share title/text/url after any in-composer text or saved draft,
joined with a blank line. Pass sessionId into ShareSeedConsumer so
getDraft() can be consulted when the composer is still empty.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): preserve shareTransferId through /browse detour
New-session spawn from the share picker could lose shareTransferId when
the operator opened /browse to pick a folder: handleChooseFolder and
BrowsePage handleStartSession dropped the search param, so handleSuccess
never armed the composer consumer.
Thread shareTransferId through browseRoute search validation and both
navigation hops.
Addresses upstream PR #933 review (Major).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/share): consume pending transfer in effect for StrictMode
ShareSeedConsumer called consumeSharePendingTransfer during render.
React.StrictMode double-invokes render in dev; the discarded pass
deleted the sessionStorage key before the committed render seeded.
Move consume into a mount-only useEffect and gate the seed effect on
transferReady.
Addresses upstream PR #933 review (Minor).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
dfb1805fd6 |
feat(web): OLED Black theme + per-appearance custom colors (#937)
* test: reproduce issue #866 * feat(web): OLED Black theme + per-appearance custom colors (closes #866) Add an explicit OLED Black appearance (true #000 canvas, border-based elevation) alongside system/dark/light, and a curated "key color" customizer. Each key color (background, surface, text, hint, accent, border, user bubble) cascades to its --app-* tokens and is stored per appearance so a color tuned for light never leaks onto pure black. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
ce67823fc3 |
feat(web,hub): rich hover tooltips on session-list attention indicators (#941)
* feat(web): rich hover tooltips on session-list attention indicators
The session-row attention dots and the future-scheduled clock icon used
plain `title=""` attributes which gave only a one-word label ("Permission
required"). Replace those with hover/focus-revealed tooltips that name
*which* tools are blocking, count background tasks, surface the
"updated Nm ago" timestamp, and explain the pending schedule.
To make per-tool copy possible without an extra round trip,
`SessionSummary` now carries a structured slice of the pending tool
requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first:
pendingRequests: Array<{ id; kind; tool; since }>
`pendingRequestsCount` remains the authoritative total;
`pendingRequestKinds` is still derived from the FULL request set so a
single `'input'` request beyond the cap still surfaces its kind on the
session row.
The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no
portal, no positioning JS — so it composes cheaply inside the existing
session-row `<button>` and stays out of the way on touch devices, which
keep getting the same `aria-label` the old `title=""` attribute provided
to screen readers.
Test coverage: shared derivation + cap + tie-break + full-set kind
behaviour; web tooltip render across all four attention kinds plus
mixed-kind overflow suppression and aria-label exposure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): opaque tooltip surface; drop redundant 'updated Nm ago' body
Two operator-feedback fixes on the new session-list HoverTooltip:
1. Tooltip background was bg-[var(--app-bg)] - the same variable as the
session row underneath - so the tooltip looked translucent and the row
text bled through. Switch to bg-[var(--app-secondary-bg)] (#2C2C2E
dark / #f3f4f6 light, both opaque) and bump shadow-md -> shadow-lg.
Telegram-themed clients still pick up tg-theme-secondary-bg-color so
the tooltip stays on-theme.
2. The 'unread' attention dot tooltip rendered 'New activity / Updated 5m
ago', but the relative-time pill ('5m ago') is already on the right
edge of the same session row. The tooltip body just duplicated info.
Render only the title for the unread case; drop the
session.tooltip.unread.body i18n key from en + zh-CN.
The other tooltip kinds (permission/input list tools, background lists
task count) keep their bodies - those facts are not visible elsewhere on
the row.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web,hub): show scheduled fire time in session-list clock tooltip
The schedule clock tooltip previously said only "Will fire when due."
while the row already showed a relative updated-at pill. Extend the
session-list API with nextScheduledAt (MIN future scheduled_at per
session, same filter as futureScheduledMessageCount) and render:
- single scheduled: "Fires in 5m · Jun 16, 1:45 PM"
- multiple: "Next in 5m · Jun 16, 1:45 PM · +2 more"
Extract formatScheduledTime from QueuedMessagesBar into web/lib/
scheduledTime.ts alongside formatFutureRelativeTime and the tooltip
composer. SSE upsert preserves nextScheduledAt until the list refetch
that already runs on schedule-related events.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): wire session-row keyboard focus to HoverTooltip a11y
Address PR #941 Major review: aria-describedby and tooltip visibility
were on a non-focusable inner span, so keyboard users tabbing the session
row button never received the rich tooltip description and
group-focus-within never matched.
- Session row button owns aria-describedby (attention + schedule ids)
- Add group/session-row + SESSION_ROW_TOOLTIP_FOCUS_CLASS reveal on
:focus-visible
- HoverTooltip takes required id; drop inner aria-label/describedby
- useSessionRowTooltipIds helper composes stable row tooltip ids
- Tests for id wiring and parent-focus reveal classes
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
5f27abddd4 |
feat(web): in-app PWA update prompt when new service worker is available (#946)
* feat(web): in-app PWA update prompt when new service worker is available (closes #938) User-controlled reload with a persistent banner, visibility-triggered SW checks, and an expandable rationale. Switches registerType to prompt. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): align vite.config with soup layers for clean driver merge Keeps registerType prompt while matching garden IWER stubs and PWA share_target shape expected by feat/pwa-share-target in the manifest. Co-authored-by: Cursor <cursoragent@cursor.com> * Revert "fix(web): align vite.config with soup layers for clean driver merge" This reverts commit 6f0915b0884d029a2413d8819a4dfe81d7c4e595. * fix(web): make PWA reload apply waiting service worker updates Handle SKIP_WAITING in injectManifest sw.ts and reload via controllerchange with a timed fallback when vite-plugin-pwa prompt mode does not navigate. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): satisfy setTimeout mock typing in PWA reload tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): register PWA service worker before auth gates Mount PwaUpdateProvider at app root and show the update banner on login and error screens so registerSW runs for logged-out users too. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): offset PWA update banner below top status banners Reserve top-12 when syncing or reconnecting so the reload prompt stays visible above SyncingBanner and ReconnectingBanner. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): offset PWA update banner below voice error banner Use PwaUpdateBannerWithStatusOffset inside VoiceProvider so voice errors share the same top-12 reservation as sync and reconnect banners. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
78155a9d27 |
perf(web): add staleTime to useSession to suppress focus/mount refetches (#884) (#885)
* perf(web): suppress useSession refetch storm (closes #884) Two compounding behaviours in the React client were producing a sustained ~100 req/sec stream of GET /api/sessions/<uuid> to the hub on installs with a moderately-sized session fleet: 1. `useSession` had no `staleTime`, so window-focus and remount each triggered a fresh REST round-trip even though SSE was already pushing the same data via `patchSessionDetail`. 2. `useSSE`'s `session-added`/`session-updated` handler unconditionally queued a per-session `invalidateQueries({queryKey: ['session', id]})` whenever the incoming SSE payload was not a structured patch, fanning out a refetch to every still-observed `useSession` regardless of whether the user was currently viewing that session detail. Fixes: - `useSession` now sets `staleTime: 30_000` (exported as `SESSION_DETAIL_STALE_TIME_MS` for testability/tuning). SSE remains authoritative for freshness; the REST endpoint is a cold-start path. - `useSSE` only queues per-session detail invalidation when an active observer is mounted for that session. The new `hasActiveSessionDetailObserver` helper checks the TanStack query cache for `getObserversCount() > 0`. List-summary invalidation is unchanged (sidebar still updates). No behaviour change for the patch-path: structured `SessionPatch` events still flow through `patchSessionDetail` + `patchSessionSummary` and update in place. The fallback path is what we are taming. Measured on the reporter's box pre-fix: 31,944 GET /api/sessions/<uuid> hits over a 5-minute idle window across 132 distinct session UUIDs (~106 req/sec). Expected post-fix: ~0 for sessions whose detail page is not currently open, gated by `staleTime` for navigation thrash. Tests: - `useSession.test.ts` asserts `SESSION_DETAIL_STALE_TIME_MS` is set. - `useSSE.test.ts` adds 4 cases for `hasActiveSessionDetailObserver` covering no-cache, cache-without-observer, mounted-observer, and cross-session isolation. * fix(web): revert observer-gating in useSSE (address PR #885 review) The Codex review on #885 correctly flagged that `hasActiveSessionDetailObserver`-gating around the two `queueSessionDetailInvalidation` fallback paths broke an important correctness invariant: with `staleTime: 30_000` in place, skipping the invalidation entirely (instead of letting TanStack mark the cache stale) means a subsequent remount within 30s will serve the stale cached detail without a REST recovery fetch. This regressed real backend code paths. Hub emits `session-updated` events with no structured `data` field on todos / teamState / metadata / agentState changes (see `hub/src/socket/handlers/cli/sessionHandlers.ts:117,128,216,263`), which hit the gated `else` branch. Root cause of the over-correction was a misunderstanding of TanStack v5 semantics: `invalidateQueries` with the default `refetchType: 'active'` is *already* a network no-op for unobserved queries — it just marks them stale. The manual observer-count check was structurally redundant *and* incorrectly suppressed the stale marking. Revert: restore the original unconditional `queueSessionDetailInvalidation` calls on both fallback branches. Drop the `hasActiveSessionDetailObserver` helper export and its 4 unit-test cases. Keep Fix A (`staleTime: 30_000` on `useSession`) intact — that change is independently safe and addresses the focus-refetch / remount-refetch class of redundant requests. * docs(web): correct staleTime rationale in useSession (#884) Stand-in cold review on PR #885 caught that the comment overstated the fix's reach. `web/src/lib/query-client.ts:7` already sets the global default `refetchOnWindowFocus: false` and `staleTime: 5_000`, so the per-query `staleTime: 30_000` does NOT cut focus-refetches (there were none) and only extends the remount/reconnect-no-refetch window from 5s to 30s. Rewrite the comment to be accurate about scope: the change suppresses remount refetches within a 30s window, and explicit `invalidateQueries` (SSE fallback path, reconnect-recovery in `App.tsx`) still refetches active observers — so live updates and recovery flows are preserved. No code behaviour change; comment-only edit. * fix(web): invalidate all cached session details on SSE reconnect Codex review on PR #885 caught a real regression introduced by the `SESSION_DETAIL_STALE_TIME_MS = 30_000` change: the reconnect-recovery handler in `App.tsx` only invalidated the *currently-selected* session's detail. With per-query staleTime extended from 5s (global default) to 30s, a previously-viewed but non-selected session whose cache was still within the freshness window could serve stale data after the SSE channel missed updates during the disconnect. Scenario: 1. User views session A → cache populated, fresh. 2. User switches to session B → A's observer unmounts, cache lingers (gcTime: 5min). 3. SSE disconnects. Session A receives updates server-side that no patch event reaches the client. 4. SSE reconnects. Old `handleSseConnect` only invalidated `session(selectedSessionId=B)`, NOT A. 5. User navigates back to A within 30s → useSession remounts → cache is still considered fresh by staleTime → no REST recovery fetch → user sees stale A data. Fix: broaden the per-session invalidation in `handleSseConnect` from `['session', selectedSessionId]` to the prefix `['session']`, which matches every cached session-detail entry. Active observers refetch (same as before — only the selected session was active), inactive cached entries get marked stale so the next remount refetches. Performance impact: zero new fetches on reconnect (the selected session is still the only one with an active observer in practice). Marking inactive entries stale is metadata-only, free. This restores the pre-staleTime invariant where every cached session detail was either fresh (just fetched) or actively re-fetched on reconnect, and matches the documented contract that SSE is the authoritative freshness signal while REST is the cold-start / reconnect-recovery path. --------- Co-authored-by: heavygee <heavygee@users.noreply.github.com> |
||
|
|
a858256620 |
fix(web,hub): surface inactive-session error on text-only send (closes #918) (#922)
Sending text via the web composer to an archived/inactive session
silently dropped on the floor: the hub returned 409 but the web client
swallowed the failure with a console.error in the resolveSessionId catch
branch, leaving the operator with no signal and no recovery path.
Hub: add a machine-readable `code: 'session_inactive'` to the 409 body
so the web client can discriminate this branch without string-matching
the i18n-able human message.
Web (router.tsx, useSendMessage.ts, HappyComposer.tsx):
- useSendMessage now fires `onError` on resolveSessionId rejection,
not just on POST /messages failure -- closes the visibility hole
when the inactive session has no resume target or resume itself
fails.
- The route classifies the thrown error: a 409 + session_inactive
code or a synthetic ApiError thrown from resolveSessionId attaches
a Reopen action to the existing inline composer-error affordance.
Plain 4xx / 5xx / network keep the legacy text-restore UX
untouched.
- Reopen calls api.reopenSession (the same path as SessionList's
Reopen menu item), invalidates the session queries, and navigates
to the resumed sessionId. Per the orchestrator brief's friction
pass on #917 the affordance does NOT auto-replay the send; the
operator re-clicks Send on the restored composer text.
Tests:
- hub messages.test.ts: 409 carries `code: 'session_inactive'`.
- useSendMessage.test.tsx: ApiError(409, session_inactive) from POST
flows through onError; resolveSessionId rejection flows through
onError keyed by the original sessionId; 500 keeps the legacy
fallback path with no code attached.
AI disclosure: implemented by an AI agent (Claude Opus 4.7) acting on
operator instructions; tests pass locally (bun typecheck + bun run
test for hub and web).
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
fc8c32e07a |
fix: reliable generated-image display + stop client remount storm (#927) (#934)
* test: reproduce issue #927 * fix(hub): cache generated images + raise socket buffer cap (closes #927) Generated-image display was slow and could silently fail: - The /generated-images route sent `Cache-Control: no-store`, so every card remount (session switch, scroll, reload) re-ran the full HTTP -> socket.io RPC -> base64 round-trip. The bytes for an imageId are immutable, so serve them `private, max-age=31536000, immutable` + ETag. - socket.io / bun-engine `maxHttpBufferSize` was left at the 1 MB default, while the MCP tool accepts images up to 25 MB. The base64 CLI -> hub ack frame for anything above ~750 KB raw exceeded the cap and was dropped. Raise the buffer to comfortably carry the largest allowed image. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(hub): short-circuit generated-image revalidation with a 304 (#927) The imageId is an immutable content fingerprint, so use it as the ETag and answer If-None-Match with 304 before issuing the readGeneratedImage RPC. This makes the ETag actually useful: revalidation now skips the CLI socket round-trip entirely, and still serves correctly even after the image was evicted from the CLI's in-memory store. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): stabilize ApiClient identity across token refresh (#927) The ApiClient was rebuilt whenever the token value changed (useMemo dep), even though every request already reads the live token via getToken/tokenRef. On a flaky/remote connection, repeated 401s -> onUnauthorized -> forced refresh churned `api`'s identity, which remounts everything keyed on it: VoiceBackendSession ([props.api]) -> Voice re-register spam, and GeneratedImageCard ([ctx.api]) -> per-image refetch storm, feeding a render avalanche. Depend on auth presence (hasToken) instead. Reproduced with a useAuth hook test (red->green); full web suite stays green. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
0bf17b818e |
fix(web): use button CSS variables for Retry button on error screen (#889)
--app-link resolves to #ffffff in dark mode, making the Retry button invisible (white text on white background). Switch to --app-button / --app-button-text which have correct contrast in both themes. Fixes #43 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
e23ae1b265 |
feat: add Pi Coding Agent support (#862)
* docs: spec for hapi-pi-agent-backend
* docs: spec retrospect for hapi-pi-agent-backend
* docs: plan for hapi-pi-agent-backend
* docs: plan retrospect for hapi-pi-agent-backend
* feat(pi): add hapi pi command with JSONL transport and event converter
- PiTransport: spawn pi --mode rpc, JSONL stdio, ENOENT/EPIPE handling
- PiEventConverter: Pi AgentEvent → HAPI AgentMessage conversion
- runPi: session lifecycle, dual-track event routing, model switching
- pi command: CLI registration with PI_PERMISSION_MODES
- Shared: add 'pi' to AGENT_FLAVORS, FLAVOR_CAPS, FLAVOR_LABELS
30 tests passing (15 transport + 15 converter)
* fix(pi): add Pi RPC types, fix double-cleanup/double-start/converter safety net
- Add cli/src/pi/types.ts with PiAgentEvent/PiResponseEvent discriminated unions
- PiTransport: constructor uses options object, double-start guard, drop log
- PiEventConverter: typed events via type assertions, top-level try/catch
- runPi: safeCleanup guard prevents double-cleanup race, sendAgentMessage
for converted events, keepAlive() for session pings
- 33 tests passing
* docs: dev phase reviews and test results for hapi-pi-agent-backend
- Business logic review: pass (0 must_fix)
- Standards review: pass (0 must_fix)
- Taste review: P0 types issue fixed in code
- Robustness review v2: pass (v1 3 MUST_FIX all fixed)
- Integration review: pass (0 must_fix)
- Test results: 33 passing, all type errors resolved
* docs: taste review v2 pass after type definition fixes
* docs: dev retrospect for hapi-pi-agent-backend
* test: test execution for hapi-pi-agent-backend (20/20 pass)
* fix: add taste_review symlink for gate pattern match
* docs: test retrospect for hapi-pi-agent-backend
* fix(web): add pi to MODEL_OPTIONS Record type
* ci: PR and CI evidence for hapi-pi-agent-backend
* docs: overall retrospect for hapi-pi-agent-backend (all 5 phases)
* test(pi): add buffer split, missing fields, and handleResponse tests
- PiTransport: buffer cross-chunk reassembly test
- PiEventConverter: tool_execution_end with missing result/toolCallId
- handleResponse: 10 tests covering all branches (error, get_state,
set_model, new_session, abort, prompt, unknown command)
- Extract handleResponse to accept onUpdate callback for testability
- Total: 46 tests passing (was 33)
* fix(pi): set requiresRuntimeAssets to false — pi runs as subprocess, no native tools needed
* refactor(cli): lazy import ensureRuntimeAssets to reduce startup overhead
* docs: add 15 manual E2E protocol test cases (TC-4-xx) based on real Pi RPC capture
- TC-4-01 to TC-4-15: manual tests covering tool execution, thinking
lifecycle, multi-turn, abort, error scenarios, model switch, cleanup
- Priority: P0 (tool fields, failure, thinking, multi-turn, abort)
> P1 (basic conversation, write tool, model switch, usage) > P2 (edge cases)
- Includes actual Pi RPC event sequence from live capture as reference
- e2e-test-plan.md updated with test environment setup instructions
- Total test cases: 35 (6 unit + 14 integration + 15 manual)
* test: E2E protocol test results for hapi-pi (11/15 pass)
P0/P1 automated tests (8/8 pass):
- TC-4-01: Basic text conversation ✓
- TC-4-02: Tool read (field names verified) ✓
- TC-4-03: Tool write (file created) ✓
- TC-4-04: Tool failure (isError=true) ✓
- TC-4-05: Thinking lifecycle + usage ✓
- TC-4-06: Multi-turn context retention ✓
- TC-4-07: Abort generation ✓
- TC-4-14: Token count ✓
- TC-4-15: Extension UI events ignored ✓
P2 results:
- TC-4-10: Invalid token → 401 ✓
- TC-4-12: Ctrl+C cleanup, no orphans ✓
- TC-4-08: ENOENT (harness issue, exit code correct)
- TC-4-11: set_model not supported by Pi (success=false)
- TC-4-13: Pi crash (harness output capture issue)
* test: fix TC-4-11 result — Pi set_model works with correct provider/modelId
Previous test used invalid provider='' + modelId='deepseek-chat'.
Re-tested with provider='deepseek' + modelId='deepseek-v4-flash':
- set_model success=true
- model switched glm-5.1 → deepseek-v4-flash
- subsequent prompt confirmed working
Final E2E results: 12/15 PASS, 2 FAIL (test harness), 1 SKIP
* chore: remove .xyz-harness/ from git tracking, add to .gitignore
Local harness workflow artifacts should not be tracked in the repo.
* fix(pi): resolve web UI bugs for hapi-pi integration
Five bugs fixed for end-to-end pi session via hapi web UI:
1. runner buildCliArgs: add 'pi' branch to spawn correct command
(was falling back to 'claude', launching wrong agent)
2. runPi: implement real keep-alive (2s interval) to prevent hub
30s timeout marking session inactive
3. runPi: bump keep-alive to active state during agent/turn_start
4. sessionResume: add 'pi' to flavor switch and resume condition
(was returning undefined, causing 'cannotResume' on inactive session)
5. PiEventConverter: emit codex-compatible {type:'message',message:...}
/{type:'reasoning',message:...} with streamId; dedup by skipping
text_start/text_end (only send deltas) to avoid triple-rendered text
6. PiTransport: fallback to stdout 'end' event when child process
close event doesn't fire (bun spawn quirk)
Verified end-to-end: web UI shows pi reasoning + reply correctly,
session stays online, no duplicate text.
* fix(pi): address 4 web UI display bugs in hapi-pi integration
Three of four follow-up bugs reported after the initial fix (6c28949):
1. Stuck in 'queued' status — fix
Pi's runner doesn't use MessageQueue2, so the base session's
onBatchConsumed hook never fires. Add a FIFO of pending localIds
in runPi and emit messages-consumed on agent_start. turn_start
is intentionally skipped (it can fire multiple times per agent
run after tool calls). A prompt rejection from Pi also consumes
the localId so the next prompt isn't poisoned.
2. AI thinking only displays ':' — fix
Pi emits pure incremental deltas (text_delta / thinking_delta)
per token. The web reducer dedupes reasoning by streamId WITHIN
one message's content array only — separate wire messages
produce separate renders. Without accumulation, 50 deltas = 50
reasoning renders, of which the reducer keeps only the last
delta (a single character like ':').
3. Output text on separate lines — fix
Same root cause as #2 but for text: the reducer appends each
text AgentMessage as a new agent-text block (no dedup), so 50
deltas become a 50-row character-by-character column.
4. Tool call execution status (in_progress -> completed)
The tool result wire CodexMessage type is 'tool-call-result'
(with callId + is_error?); the internal AgentMessage 'tool_result'
is converted to that. Status mapping is preserved.
Implementation: extract a PiMessageAccumulator class (testable in
isolation) that mirrors codex's ReasoningProcessor pattern:
- message_start resets state and streamId
- text_delta / thinking_delta append to internal text / reasoning
- text_start/thinking_start/text_end/thinking_end ignored (they
carry full partial state — would duplicate)
- message_end flushes (max 1 reasoning + 1 text message, in order)
- turn_end safety net flushes if active
- flushIfActive() exposed for transport close / crash
The converter now routes AgentMessage through convertAgentMessage
so the wire format is codex-shaped (matches opencode/gemini/kimi
path). AgentMessage 'text' and CodexMessage 'message' both gain
optional id; convertAgentMessage preserves caller-provided id for
streamId-based dedup on the web side.
Tests: 16 new PiMessageAccumulator tests + 5 updated
PiEventConverter tests + 4 messageConverter tests, all passing.
Full suite: 909/910 (1 unrelated macOS path normalization). tsc
clean.
* fix(pi): review round 1 - 1 must-fix issue
The web session-resume helper referenced metadata.piSessionId, but the
shared MetadataSchema does not define the field, and the back-end has no
path to populate it (Pi session resume is out of scope per spec.md).
This caused web typecheck to fail and would also have produced a
runtime 'resume_unavailable' from the hub if a user tried to resume a Pi
session that had any user messages (the stale 'flavor === pi' branch in
inactiveSessionCanResume claimed resume was supported).
Revert the two early Pi branches from the web resume helper. Add a
comment pointing at the spec and noting what to undo when back-end
resume ships (re-add 'case pi' + 'piSessionId' on MetadataSchema +
extend hub resolveAgentResumeId).
* fix(pi): review round 2 - 4 must-fix issues
1. cli/src/runner/run.ts buildCliArgs: stop forwarding --resume to the pi
binary. Pi session resume is out of scope (no piSessionId on
Metadata), so forwarding would create an orphan session the hub can't
track. Hub already returns null from resolveAgentResumeId for
flavor='pi' and falls through to fresh spawn; this just hardens the
runner layer to match.
2. cli/src/pi/runPi.ts: cache currentProvider from get_state and use it
for subsequent set_model RPCs. Pi's set_model requires both provider
and modelId, but the bootstrap-time code emitted provider: '' which
Pi rejects. The bootstrap-time model is still applied by Pi at
startup, so suppressing set_model until get_state arrives is a no-op
for same-model configs rather than a wrong-model emit.
3. web/src/components/AssistantChat/modelOptions.ts: add explicit pi
branches to getModelOptionsForFlavor and getNextModelForFlavor.
Without them, Pi sessions fell through to the Claude preset cycler,
which would push sonnet/opus ids into a Pi session via
set-session-config. Mirrors the opencode handling introduced earlier.
Tests added/updated: buildCliArgs covers pi + claude resume; handleResponse
mirror test covers provider caching; modelOptions tests cover pi
no-fallback behavior for both option list and cycler.
* fix(pi): add session resume support and fix review issues
- Add piSessionId to MetadataSchema (shared/src/schemas.ts)
- Persist piSessionId from get_state response to metadata (cli/src/pi/runPi.ts)
- Pass --session-id to Pi spawn on resume (cli/src/pi/runPi.ts)
- Add pi branch to resolveAgentResumeId (hub/src/sync/syncEngine.ts)
- Add case 'pi' to resolveAgentSessionIdFromMetadata (web/src/lib/sessionResume.ts)
- Replace pi resume skip guard with --session-id forwarding (cli/src/runner/run.ts)
- Preserve piSessionId in pickExistingSessionMetadata (cli/src/agent/sessionFactory.ts)
- Add pi badge to AgentFlavorIcon (web/src/components/AgentFlavorIcon.tsx)
- Fix transport.onClose crash-marking on normal shutdown (cli/src/pi/runPi.ts)
* fix(pi): review round 1 - 3 must-fix issues
- resume.ts: add pi branch to dispatchLocalResume() so hapi resume
dispatches to runPi instead of falling through to cursor
- runPi.ts: accept existingSessionId and use bootstrapExistingSession
when resuming, matching other agents' pattern
- agentCommandOptions.ts: parse --session-id in addition to --resume
so runner-spawned pi resume actually forwards the session ID
- types.ts: export PiPermissionMode alongside other agent permission
mode types for consistent import convention
* fix(pi): review round 2 - 2 must-fix issues
* refactor(workflow): improve pi-adaptation-review-loop robustness
- Switch from structured output to file-based JSON output for reliability
- Replace per-round file limit (20→30) with clear wording (remove misleading split-commits instruction)
- Return { data, error } from readResultFile() to surface parse/validation failures in abortReason
- Fix lastMustFix sentinel: initialize to null, use ?? for explicit N/A reporting
- Add getAgentDirs() to dynamically discover agent dirs from cli/src/
- Document rollbackTo() atomic-round design intent
- Add isValidIssue() validation, runFinalCleanup() helper, git repo pre-check
* test(pi): add coverage for pi flavor across shared, cli, and web
- shared/flavors.test.ts: pi/kimi capability, label, known, supports
- shared/modes.test.ts: PI_PERMISSION_MODES contract, per-mode checks
(7-mode allowed/denied matrix)
- web/AssistantChat/modelOptions.test.ts: pi shortcut vs Claude
cycler, normalize filter (auto/default/whitespace), kimi/cursor/
opencode cross-flavor consistency
- web/lib/sessionResume.test.ts: piSessionId resolver, cross-flavor
stale-id protection, inactiveSessionCanResume for pi, regression
coverage for all 6 other flavors
- web/components/AgentFlavorIcon.test.tsx: pi badge styling
(bg-[#5b21b6]), Un fallback, case/whitespace normalize,
className override
- cli/commands/agentCommandOptions.test.ts: --session-id
(pi-specific flag), --resume alias, PI mode validation,
--yolo vs explicit-mode priority
137 new test cases, all passing. Full suite: 96 files / 933 tests
green (unrelated apiMachine.test.ts macOS /private/var path issue
remains as documented in handoff).
* feat(pi): implement P0 — context budget bar + dynamic model discovery
P0-1: Context Budget Bar
- Add pi branch to modelConfig.ts getContextBudgetTokens()
- Conservative 200K default context window for Pi sessions
P0-2: CLI-side model discovery
- Add get_available_models to PiRpcCommand type
- Auto-send get_available_models after get_state in runPi.ts
- Cache model list and push to session metadata
- Register ListPiModels RPC handler with promise-based transport query
P0-3: Hub-side routing
- Add listPiModelsForSession to rpcGateway and syncEngine
- Add REST endpoint GET /sessions/:id/pi-models (pi sessions only)
P0-4: Web-side rendering
- Add PiModelSummary type to shared apiTypes
- Add usePiModels hook (TanStack Query, stale 60s)
- Add getSessionPiModels to API client
- Add sessionPiModels query key
- Wire piModelOptions into SessionChat availableModelOptions
- Model dropdown renders discovered models or falls back to Default
* fix(pi): address code review findings + pre-existing test issue
Review fixes:
- Fix race condition in sendPiRpcAndWait: use incremental id as key
instead of command type, preventing resolver overwrite on concurrent
calls (e.g. auto-discovery + ListPiModels RPC)
- Extract parsePiModels() to eliminate duplicated model parsing logic
between handleResponse and ListPiModels RPC handler (DRY)
- Add resolvePendingRpc() call in error response path to prevent
promise leaks when Pi rejects an RPC with an id
- Add piModelsState.error guard to onModelChange in SessionChat,
matching the pattern used by codex and cursor flavors
Pre-existing fix:
- Fix apiMachine.test.ts symlink assertion on macOS (/var vs
/private/var) by applying realpathSync to the expected path
* feat(pi): P1 — session rename sync, thinking level UI, skills/commands
P1-1: Session Rename → Pi notification
- Add set_session_name to PiRpcCommand
- Register RenamePiSession RPC handler in CLI
- Hub syncEngine.renameSession now forwards to Pi CLI for active sessions
- Hub rpcGateway + REST endpoint added
P1-2: Thinking Level support
- Add Pi thinking level constants to shared/src/piThinkingLevel.ts
(off/minimal/low/medium/high/xhigh)
- Add ThinkingLevel capability to Pi flavor in flavors.ts
- sessionConfigRpc now supports effortMode for Pi thinking level
- runPi captures thinkingLevel from get_state and forwards via
set_thinking_level
- Hub effort endpoint accepts pi sessions (was claude-only)
- Web: piThinkingLevelOptions.ts + HappyComposer renders Pi options
when flavor=pi
P1-3: Skills/Commands discovery
- Add get_commands to PiRpcCommand, auto-discover after get_state
- Register ListPiCommands + ListSlashCommands RPC handlers in CLI
(maps Pi commands to HAPI SlashCommand format)
- Hub: listPiCommandsForSession + REST GET /sessions/:id/pi-commands
- Web: usePiCommands hook + api client + query keys
Also fixes:
- Pre-existing ZodError.errors → ZodError.issues in hub/socket/server.ts
- Updated test expectation for effort endpoint error message
* feat(pi): implement P2 features — steer, queue modes, history, native images
P2-1: Steer/Follow-up
- Track piIsStreaming state from agent_start/turn_start/turn_end/agent_end
- When streaming, onUserMessage sends steer instead of prompt
- Added PiSteer/PiFollowUp RPC methods + hub routing + REST endpoints
P2-2: Queue modes
- Added set_steering_mode/set_follow_up_mode to PiRpcCommand
- CLI RPC handlers with mode state tracking
- Hub routing + REST POST endpoints
- Web API client methods
P2-3: History replay
- Added get_messages to PiRpcCommand
- CLI handler converts Pi AgentMessage to PiMessageEntry format
- Hub RPC routing + REST GET /sessions/:id/pi-messages
- Web usePiMessages hook + query key
P2-4: Native image passing
- Added PiImageContent type for base64 image data
- extractPiImages() helper reads attachment files as base64
- prompt/steer commands now include images field
- Falls back to @path text reference for non-image/unreadable files
* feat(pi): implement P3 advanced features — compact, fork, clone, switch, stats, export
P3 features for Pi agent integration:
- Compact: compact RPC with custom instructions, set_auto_compaction toggle
- Fork: fork at entry ID, get_fork_messages for fork context
- Clone: clone current Pi session
- Switch Session: switch Pi to a different session by path
- Session Stats: get token counts, message counts, cost
- HTML Export: export session as HTML file
All features follow existing P2 pattern:
- CLI: RPC handlers in runPi.ts with sendPiRpcAndWait
- Hub: rpcGateway + syncEngine routing + REST endpoints
- Web: API client methods + query keys + type exports + hooks (stats, fork messages)
Total: 8 new REST endpoints, 9 RPC handlers, 6 web API methods
Typecheck: all 3 packages pass (cli+hub+web)
Tests: 1155 pass (263 hub + 803 web + 89 shared), 0 failures
* refactor(pi): clean up runPi.ts imports and readability
- Replace require('fs') with top-level import { readFileSync } from 'fs'
- Extract handleGetState() as standalone function from handleResponse
switch case (get_state case: 35 lines → 4 lines dispatch)
Typecheck: all 3 packages pass
Tests: 1066 pass (263 hub + 803 web), 0 failures
* fix(pi): remove native image passing, fix version pollution
- Remove extractPiImages helper and PiImageContent type: all
attachments now use @path text references via
formatMessageWithAttachments, consistent with every other agent
- Remove images field from prompt/steer/follow_up RPC commands
- Remove unused readFileSync import
- Restore cli/package.json version from test pollution
(0.0.0-integration-test-should-be-auto-cleaned-up-51369 → 0.20.0)
Typecheck: all 3 packages pass
Tests: 1286 pass, 0 failures
* refactor(pi): extract hub helper, unify web hooks, fix import style
- Hub: extract withPiSession helper eliminating boilerplate across 15
Pi REST endpoints (~400 lines → ~150 lines)
- Web: unify usePiForkMessages and usePiSessionStats to return
destructured typed fields matching usePiModels/usePiCommands pattern
- Web: move 15 Pi response types from inline import() to top-level
named imports in api/client.ts
- CLI: remove duplicate PiCommandSummary/PiCommandsResponse from
types.ts, re-export from @hapi/protocol/apiTypes
Typecheck: all 3 packages pass
Tests: 1286 pass, 0 failures
* chore: untrack .agents/skills and .pi, fix .xyz-harness in gitignore
* refactor: remove unused text message id from converter layer, update gitignore
* fix: update tests for pi resume support and text id removal
* fix: restore cursor resume branch in buildCliArgs
* refactor: remove pi-specific rename from syncEngine, align with other agents
* refactor: remove effort field from sessionConfigRpc, Pi self-handles RPC
Pi agent now self-handles SetSessionConfig RPC (like Claude) using
the existing field, instead of adding a parallel
field to the shared sessionConfigRpc helper which only knows about
.
- Remove effort/effortMode from sessionConfigRpc types and logic
- runPi.ts: self-register RPC handler with PiThinkingLevel validation
- Reuse resolveSessionConfigPermissionMode from sessionConfigRpc
* refactor: consolidate Pi RPC layer from 36 methods to 3 generics
rpcGateway: 12 methods → callPiRpc<T>
syncEngine: 12 passthroughs → callPiRpc<T> delegate
web client: 12 methods → callPiEndpoint<T>
routes: use engine.callPiRpc with RPC_METHODS constants
hooks: use callPiEndpoint, add missing type imports
* chore: revert unrelated apiMachine test change
* refactor: remove unused ThinkingLevel capability from flavors
Pi's thinking level is an effort variant, not a separate capability.
The ThinkingLevel constant and supportsThinkingLevel() had zero callers
— the frontend uses flavor-based branching for effort option rendering.
* refactor: drop Pi prefix from generic RPC method names
* refactor: remove 13 Pi RPC methods with no UI consumers
Steer: already handled by onUserMessage auto-routing
Follow-up: redundant with HAPI message queue
ListPiCommands/GetMessages/ForkMessages/SessionStats: no UI
Compact/SetAutoCompaction/Fork/Clone/SwitchSession/ExportHtml: no UI
SetSteeringMode/SetFollowUpMode: no UI
Kept: ListPiModels (has UI), SetSessionConfig, ListSlashCommands, Abort, Switch
Deleted: 4 web hooks, 13 RPC handlers, 12 REST routes, 13 rpcMethods entries
Net: -730 lines
* refactor: extract session.ts and loop.ts from runPi.ts
Restructure Pi agent following Codex pattern (without Local/Remote
splitting since Pi only has remote mode):
- session.ts: PiSession class managing state + hub communication
- loop.ts: response parsing, RPC resolver, transport event wiring
- runPi.ts: thin entry (bootstrap, RPC handlers, lifecycle)
Changes from review:
- Encapsulate RPC resolver in PiRpcResolver class (session-scoped,
not module-level singleton)
- Remove unused extractTextFromPiMessage export
- Fix inline import('./types') → top-level import
* refactor: normalize Pi file naming and improve test coverage
- Rename PiTransport.ts → piTransport.ts, PiEventConverter.ts →
piEventConverter.ts, PiMessageAccumulator.ts → piMessageAccumulator.ts
(match project-wide camelCase convention)
- Delete handleResponse.test.ts (tested stale copy of inline function)
- Add loop.test.ts with 20 tests covering parsePiModels,
parsePiCommands, wireTransportEvents integration, and sendPiRpcAndWait
- Total Pi tests: 73 (was 53)
* test: add E2E harness with 4 core helpers and integration specs
Helper functions in e2e/harness.ts capture the four non-obvious
interactions discovered during the 2026-06-09 retest:
- longPress: SessionActionMenu is triggered by 500ms press, not click
- mockOffline: useOnlineStatus hook listens to navigator.onLine +
window offline event, not CDP Network.emulateNetworkConditions
- pollForText: thinking indicator flickers in <1s, 3s polling misses
- isVisible: element.offsetParent returns null for position:fixed
dialogs even when visible; use getBoundingClientRect
Plus Chrome lifecycle (startChrome/stopChrome, never pkill chrome)
and hub API helpers (loginWithToken, listSessions).
5 integration specs (e2e/integration/) cover:
- yolo-permission: toggle + localStorage persistence (4 cases)
- codex-dialog: pre-flight check + dialog render (3 cases)
- stress: 10 concurrent + invalid JWT + malformed + unknown
endpoint (5 cases, all PASS)
All 12 integration cases pass. Full E2E results in
.xzy-harness/2026-06-09-full-e2e-retest/ (67 cases, 0 functional
bugs found).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve Pi model selection and thinking level issues
- Fix PiModelPanel: use provider+modelId composite for selection check
and React key, preventing duplicate highlights for same-name models
across different providers
- Fix PiThinkingLevelPanel: unify thinkingLevelMap filtering logic by
extracting shared isThinkingLevelSupported utility
- Fix HappyComposer: auto-reset effort to highest supported level when
switching models, update label to reflect effective level
* refactor: remove 29 dead exports from feat-pi-support
Remove unused types, methods, and re-exports identified by dead code audit:
shared/src/apiTypes.ts (19):
- SessionModelIdentifier, ListPiCommandsResponse
- PiSteeringMode, PiFollowUpMode, PiSteerResponse, PiFollowUpResponse
- PiQueueModeResponse, PiMessageEntry, PiMessagesResponse
- PiCompactResponse, PiSetAutoCompactionResponse
- PiForkResponse, PiForkMessageEntry, PiForkMessagesResponse
- PiCloneResponse, PiSwitchSessionResponse
- PiSessionStats, PiSessionStatsResponse, PiExportHtmlResponse
cli/src/pi/types.ts (6):
- PiSessionStats, PiCompactionResult, PiForkMessageEntry (dead local duplicates)
- PiCommandsResponse, PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS (dead re-exports)
cli/src/pi/piMessageAccumulator.ts (1):
- flushIfActive() method (comment claimed runPi calls it, but it doesn't)
cli/src/pi/piTransport.ts (1):
- isRunning() method (never called in production code)
web/ (2):
- ProviderGroup, PiThinkingLevelOption (unnecessary exports, made local)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: resolve 7 PR review issues in Pi support
#3 Remove duplicated PI_THINKING_LEVELS in schemas.ts, import from @hapi/protocol
#2 Add piAvailableModels field to MetadataSchema (schema-runtime consistency)
#6 Replace hardcoded flavor names with supportsEffort() in effort route
#1 Move PiRpcResolver from module-level singleton to PiSession instance
#4 Add piCachedModels fallback in piModelOptions useMemo
#7 Merge message_update dead branch into unified not-converted case
#10 Fix misleading Pi model list comments in modelOptions.ts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: normalize Pi model object to string in hub sessionCache (#5), remove extra blank line in rpcGateway (#8)
#5: applySessionConfig now extracts modelId from { provider, modelId }
before passing to setSessionModel / session.model, preventing
[object Object] from being stored in SQLite when Pi switches models.
#8: Remove double blank line before RpcGateway class declaration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(pi): preserve piAvailableModels on resume, document SetSessionConfig divergence
- sessionFactory: preserve piAvailableModels in pickExistingSessionMetadata
so web shows cached models on inactive-session view without RPC round-trip
- sessionConfigRpc: extend resolveNullableSessionModel to accept
{provider, modelId} object form for schema consistency
- runPi: document why Pi manually registers SetSessionConfig instead of
reusing registerSessionConfigRpc (wire protocol needs separate fields)
- package.json: restore version to 0.20.0
* refactor: remove unused Pi types, extract JsonLineParser, clean up review findings
- Remove 13 unused PiRpcCommand variants and PiStreamingBehavior type (YAGNI)
- Remove unnecessary exports on 3 internal Zod schemas in pi/schemas.ts
- Extract JsonLineParser base class to utils/, shared by PiTransport,
CodexAppServerClient, and AcpStdioTransport (eliminates 3x duplicate
handleStdout buffer logic)
- Remove DEV-only duplicate session ID detection from SessionList.tsx
(debug code unrelated to Pi support scope)
- Add comments explaining key prefix rationale in SessionChat.tsx
* chore: remove unrelated E2E test harness from Pi support PR
E2E harness (codex-dialog, stress, yolo-permission, scratchlist specs)
was introduced in this branch but tests generic HAPI behavior unrelated
to Pi agent support. Should live in a separate PR.
* fix: wrap cursor model change handler for union type compatibility
* fix: apply startup --model to Pi and remove duplicate lockfile entry
1. --model startup bug:
- Add initialModel to PiSession to preserve startup model
- handleGetState preserves initialModel instead of overwriting with Pi default
- get_available_models handler resolves provider from cached models and sends set_model
2. bun.lock duplicate key:
- Remove duplicate @twsxtd/hapi-win32-x64@0.20.0 entry
- Fixes CI lockfile regeneration that caused hono type errors
* fix: update test expectation for effort endpoint error message
* fix(pi): resolve 8 link-review defects + abort session termination
- W1C-D-1: hasSameAgentSessionIds missing piSessionId/kimiSessionId
+ extractAgentSessionId also needs piSessionId recognition
- D-1: dispatchLocalResume pi branch missing effort param
- W1B-1-01: buildCliArgs only passes --effort for claude, not pi
- W2B-D-2: effort=null does not send set_thinking_level to Pi
- D-3: turn_start does not consume pendingLocalIds
- D-7: keep_alive falls into default case in convertPiEvent
- D-9: finally overwrites sessionEndReason set by Switch/Abort
- W2B-D-3: ListPiModels RPC does not update metadata
- Abort handler: remove cleanupAndExit, only cancel current turn
Also: Switch handler returns { success: true } for consistency
Test coverage: 13 new test cases across 5 files
* fix: restore cli version from integration test placeholder
* fix(pi): send restored thinking level to Pi subprocess on startup
opts.effort was stored in piSession.currentThinkingLevel but never
forwarded via set_thinking_level during the startup sequence, causing
runner-spawned and resumed sessions to show the restored effort in
HAPI while Pi kept its default.
* fix: restore cli package version from integration test residue
* fix(pi): switch-to-remote handler preserves session instead of terminating
Replace lifecycle.cleanupAndExit() with createModeChangeHandler + keepAlive
in the Switch RPC handler. Pi runs as a single long-lived subprocess
without BaseLocalLauncher's restart loop, so cleanupAndExit() permanently
destroyed the session on mode switch. The web handoff button now correctly
changes control mode while keeping Pi alive.
* fix(pi): remove permission mode selector (Pi RPC has no runtime switching)
Pi's --mode rpc is non-interactive and auto-approves all tool execution;
there is no set_permission_mode command in the protocol. The selector
reported success without changing Pi's behavior, misleading users.
Remove the concept across all four packages:
- shared: getPermissionModesForFlavor('pi') returns [] (cascades to
hub 400 + web UI auto-hide via length===0 guards); drop
PI_PERMISSION_MODES / PiPermissionMode
- cli: strip permissionMode from PiSession/runPi/pi command/resume;
drop the no-op SetSessionConfig permission branch that stored state
without forwarding to the subprocess
- web: delete PiPermissionPanel.tsx; remove panel block + imports
from HappyComposer
* fix(cli): realpath workspace root in apiMachine test assertion
The handler realpaths the cwd as a symlink-escape guard, so on macOS
/var/folders/... resolves to /private/var/folders/... The test compared
against the un-resolved path and failed on macOS. Use realpathSync on
the expected value for cross-platform consistency (no-op on Linux where
/tmp has no symlink prefix).
* fix(pi): keepalive reads current mode instead of constructor-time startingMode
The Switch handler updated controlledByUser but PiSession.pushKeepAlive()
still emitted the readonly startingMode every 2s, so a runner-started
session switched to local would flip back to remote on the next keepalive.
Replace readonly startingMode with a mutable mode field; add setMode()
that updates it and re-pushes keepAlive immediately. The Switch RPC
handler now calls setMode() before handleModeChange.
* fix(pi): runner no longer passes permission flags to Pi subprocess
After removing the Pi permission selector, the Pi command parser rejects
--permission-mode and ignores --yolo. But the shared buildCliArgs tail in
the runner still appended these flags for Pi sessions, making runner-
spawned Pi children exit before registering a session.
Guard the permission/yolo append with agent !== 'pi'.
* fix(pi): preserve provider identity when persisting selected Pi model
The hub's applySessionConfig normalized Pi's { provider, modelId } object
down to a plain modelId string for the shared session.model field, losing
the provider. On reload or next render, web's selectedPiModel lookup
matched by modelId alone — if two providers share a modelId, the wrong
one was highlighted, and subsequent model/thinking-level changes sent the
wrong provider to the Pi subprocess.
Add a provider-qualified piSelectedModel field to session metadata
(schema + persistPiSelectedModel mirroring persistPreferredPermissionMode).
Web's selectedPiModel now prefers the provider-qualified match and only
falls back to modelId-only matching when absent.
* fix(pi): model picker checkmark follows provider-qualified selection
selectedPiModel already resolves via provider+modelId, but the model
panel's currentPiModel still matched by modelId alone — so with two
providers sharing a modelId the checkmark pointed at the wrong row.
Reuse selectedPiModel directly.
* fix(pi): steer messages consumed immediately, not queued in pendingLocalIds
onUserMessage unconditionally pushed localId into pendingLocalIds, but a
steer (sent while piIsStreaming) does not start a new turn — so the
steer's localId was never drained by turn_start. The next normal prompt's
turn_start would consume the stale steer localId instead, leaving the
new prompt's bubble stuck in the queued bar.
Only queue localId for the prompt path. Steer path emits
messages-consumed immediately.
* fix(pi): clear stale thinking level when switching to non-reasoning model
The model-change effect early-returned when selectedPiModel.reasoning ===
false, leaving the previously-set effort (e.g. 'high') persisted on the
session. The UI hid the thinking picker for the non-reasoning model, but
the hub still forwarded the stale effort as set_thinking_level — with no
visible control to clear it.
Call onEffortChange(null) for non-reasoning models.
* fix(pi): return provider-qualified model in SetSessionConfig applied
The CLI handler returned only currentModel (bare string), so the hub's
applySessionConfig saw a non-object model and cleared
metadata.piSelectedModel via persistPiSelectedModel(session, null) —
undoing the provider that was just stored on the inbound config.
Return { provider, modelId } when both are known so the hub keeps the
provider-qualified metadata intact across active model changes.
* fix(pi): preserve piSelectedModel in bootstrapExistingSession metadata
The metadata whitelist rebuild kept piAvailableModels but omitted
piSelectedModel, so the first resume/local-handoff update dropped the
provider identity — after which web fell back to modelId-only matching
and could select the wrong provider for duplicate modelIds.
* fix(pi): await Pi confirmation before reporting model/effort applied
SetSessionConfig was fire-and-forget — transport.send wrote JSONL to
stdin and returned immediately. If Pi rejected an invalid provider/model
or thinking level, the hub still persisted the new value and the UI
reported success while Pi kept the old runtime state.
Use sendPiRpcAndWait so a failed set_model/set_thinking_level rejects
the web request and leaves the session config unchanged.
* fix(pi): resolve set_model RPC so awaited model switch does not time out
SetSessionConfig awaits sendPiRpcAndWait(set_model) before reporting the
model applied, but handleResponse's set_model branch updated state and
fell through without calling resolvePendingRpc. The pending RPC promise
then waited the full 10s timeout and rejected, making /sessions/:id/model
return 409 even though Pi accepted the change. Mirror every other branch
by resolving the pending RPC after updating currentModel/currentProvider.
* fix(pi): drain pending localId on turn_start only; throw when set_model suppressed
- loop.ts: split agent_start/turn_start branches. Pi emits both per prompt;
draining on both popped the FIFO twice and shipped an undefined localId to
the hub. agent_start now only sets thinking state; turn_start drains.
- runPi.ts: when set_model is suppressed (provider unknown), throw instead of
silently returning applied, so the hub returns 409 rather than persisting a
piSelectedModel Pi never received.
- loop.test.ts: assert agent_start does not drain; add regression test that a
single turn drains exactly one real localId.
* fix(pi): exclude Pi from generic Ctrl/Cmd+M model cycler
SessionChat fed piModelOptions into HappyComposer.availableModelOptions,
so the global Ctrl/Cmd+M shortcut ran getNextModelForFlavor over the Pi
list and called onModelChange with a bare modelId string. Pi needs
{ provider, modelId } to disambiguate duplicate model IDs across
providers; a bare string made runPi fall back to the first cached
provider match (wrong provider) or throw when the provider was unknown.
Drop the piModelOptions useMemo and pass undefined for Pi, mirroring
modelOptions.ts where the Pi branch already returns the current model
unchanged (no-op) when no custom options are supplied. Pi model changes
now go only through the dedicated provider-qualified picker (piModels).
* fix(pi): commit PiSession config only after Pi confirms the RPC
SetSessionConfig previously mutated piSession.currentModel /
currentProvider / currentThinkingLevel BEFORE awaiting
sendPiRpcAndWait(set_model / set_thinking_level). When Pi rejected the
value or the RPC timed out, the handler threw and the route returned
409, but PiSession kept the unconfirmed values; the 2s keepalive then
reported them back to the hub, where handleSessionAlive persisted a
model/effort Pi never accepted.
Resolve the requested model/effort into locals first, send the RPCs,
and only commit to PiSession after each await resolves. The null
(clear-model) path needs no RPC so it still commits immediately; the
unknown-provider path still throws without committing.
* fix(pi): apply startup model only after Pi confirms set_model
Two startup paths persisted the requested --model before Pi confirmed it:
1. handleGetState set session.currentModel = session.initialModel as soon
as get_state returned, using the unconfirmed startup model instead of
Pi's actual default. If the model was unavailable or rejected, the 2s
keepAlive reported it to the hub, which persisted/showed a model Pi
never accepted.
2. get_available_models then sent set_model fire-and-forget, so a Pi
rejection was never observed and currentModel stayed on the bad value.
Fix: handleGetState now reports Pi's real current model (newModel) while
a startup model is merely requested. get_available_models resolves the
provider from the cached list, awaits set_model, and commits
currentModel/currentProvider only on success — on rejection it logs and
keeps Pi's default. The await is fired detached so the
get_available_models RPC itself still resolves for ListPiModels.
* fix(pi): do not persist startup model before Pi confirms set_model
The startup --model still reached the hub unconfirmed via two paths the
previous Fix #13 left open:
1. bootstrapSession({ model: opts.model }) seeded the hub session model
at creation time, and SessionCache.handleSessionAlive persists every
non-undefined keepAlive model — so an unavailable/rejected model was
stored and shown before get_available_models/set_model ran.
2. PiSession constructor set this.currentModel = opts.model, so the very
first keepAlive (sent by startKeepAlive before any RPC confirms the
model) reported the unconfirmed value.
Pass model: undefined to bootstrapSession and start PiSession.currentModel
at null; opts.model is still captured as initialModel and applied/committed
only after get_available_models confirms it exists and set_model succeeds
(Fix #13). The hub now sees Pi's real current model from the first
get_state keepAlive and switches to the requested model only once accepted.
Also add sendPiRpcAndWait contract tests pinning the await<->resolve
symmetry (Fix #10): set_model/set_thinking_level/get_available_models must
resolve before timeout on a success response, and reject on a Pi error.
* fix(pi): apply startup effort only after Pi confirms set_thinking_level
runPi restored opts.effort straight into piSession.currentThinkingLevel
before startKeepAlive ran, and pushKeepAlive persists effort — so a
resumed/runner-spawned session could store/show a thinking level Pi
rejected or ignored. This is the effort analog of the startup-model
confirmation contract (Fix #13/#14).
Capture the requested effort into a local startupThinkingLevel instead of
mutating currentThinkingLevel up front. After transport.start() and the
get_state/get_available_models/get_commands sends, await set_thinking_level
and commit currentThinkingLevel + push a keepAlive only on success; on
rejection keep Pi's default (already reported by get_state). The await is
detached so the run loop is not blocked, and get_state is sent before the
set so its authoritative baseline lands first and cannot clobber the
confirmed value.
* fix(pi): omit unknown runtime config from keepalive, don't clear persisted state
Fix #14 changed PiSession.currentModel to start at null so the startup
--model was not leaked before confirmation. But the hub treats keepAlive
model:null as an explicit clear (sessionCache.ts only skips when the
field is undefined), so the first heartbeat (startKeepAlive runs before
get_state) now erased a resumed Pi session's persisted model/effort
before Pi reported its real state.
Distinguish "unknown" from "clear": currentModel/currentThinkingLevel
start undefined and keepAlive omits undefined fields (via
getKeepAliveRuntime), so the hub leaves persisted values alone until Pi
confirms. null remains an explicit clear and is still forwarded. Once
get_state/set_model/set_thinking_level confirm a value it is set and
reported normally.
* fix(pi): disable Ctrl/Cmd+M model cycler for Pi entirely
Fix #11 removed piModelOptions from availableModelOptions, assuming
getNextModelForFlavor('pi', model, undefined) was a no-op. It is not:
the Pi branch returns normalizeCurrentModel(model), i.e. the current
modelId as a bare string, so the shortcut still called onModelChange with
a bare modelId. That loses the provider and can pick the wrong cached
match, clear the model when session.model is empty, or hit 'provider is
not yet known'. Short-circuit the handler for Pi so model changes go only
through the dedicated provider-qualified PiModelPanel.
* fix(pi): persist piSelectedModel from get_state and startup set_model paths
Pi stores session.model as the bare modelId and relies on
metadata.piSelectedModel ({ provider, modelId }) to disambiguate
duplicate modelId values across providers in the web picker and
thinking-level filtering. But piSelectedModel was only written by the web
/sessions/:id/model path (hub persistPiSelectedModel). The runtime paths
that set currentModel/currentProvider — get_state, the startup
get_available_models set_model, and the set_model response — only
keepAlive'd the bare modelId, so a Pi session on Pi's default model,
resumed from CLI, or started with --model had no provider identity in
metadata and could render/filter against the wrong provider.
Add persistSelectedPiModel(session) (no-op unless both fields are known)
and call it after get_state, after a successful startup set_model, and
after the set_model response updates the fields. This mirrors what the
web picker already does.
* fix(pi): default startingMode to remote — Pi has no local TUI path
A terminal `hapi pi` launch defaulted to startingMode 'local' and marked
the session controlledByUser, but Pi only runs as `pi --mode rpc` with
piped stdio — there is no local terminal/TUI input path like Claude/Codex
have. The terminal user could not drive the session and the web treated
it as local-controlled, so the first terminal Pi session was stuck until
manually switched from the web.
Default to 'remote' so the session is immediately drivable from the web.
An explicit opts.startingMode (runner path) still takes precedence.
* fix(pi): resume with remote startingMode — no local TUI path
The previous Fix #19 changed the `hapi pi` default to remote, but
`hapi resume` still passed startingMode: 'local' into runPi for Pi
sessions, re-introducing the same unsupported local-control state on the
resume path: setControlledByUser publishes controlledByUser while Pi has
no terminal/TUI input, hiding/rejecting remote-only controls until a web
switch. Pass 'remote' here too and update the resume test accordingly.
* fix: restore e2e/scratchlist.spec.ts deleted from main by mistake
The earlier "remove unrelated E2E harness" commit (d1e5b4c) deleted the
whole e2e/ directory this branch had added, but scratchlist.spec.ts is a
main-branch Playwright spec (the only file under playwright testDir
./e2e). Its removal left `bun run test:e2e` with no tests to run while
the script and playwright.config.ts still point at that directory.
Restore scratchlist.spec.ts from main; the unrelated harness files
(HARNESS.md, harness.*, integration/*.mts) that were genuinely
branch-only additions stay removed.
---------
Co-authored-by: pi <pi@local>
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
c311afddca |
fix(codex): Fast mode (service tier) toggle + /fast command (closes #898) (#904)
* test: reproduce issue #898 (Codex fast mode service tier) * fix(codex): add Fast mode (service tier) toggle and /fast command (closes #898) * feat(codex+web): Fast mode UI toggle with full persistence Wires the Codex Fast mode (service tier) end-to-end so it can be toggled from the web composer and survives reload/handoff: - shared: serviceTier on Session/SessionPatch, session-alive payload, resume target, and a SessionServiceTierRequest schema - cli: AgentSessionBase carries serviceTier through keepAlive; runCodex syncs it to the session instance - hub: service_tier column (schema v10 + migration), store setter, sessionCache + syncEngine plumbing, POST /sessions/:id/service-tier - web: api.setServiceTier + mutation, a Fast/Standard toggle in the composer settings (gated to Codex GPT-5.5/5.4), and StatusBar now reflects the real tier instead of the effort heuristic Refs #898 * fix(codex): preserve unset/persisted service tier on startup keepalive Addresses HAPI Bot [Major] on PR #904: applyCurrentConfigToSession ran setServiceTier(currentServiceTier ?? null) on wrapper-ready, collapsing the untouched `undefined` state into explicit Standard. The immediate setCollaborationMode keepalive then persisted serviceTier: null, silently downgrading resumed Fast sessions and disabling account-default Fast. - Seed currentServiceTier from the persisted session (sessionInfo.serviceTier), so a resumed Fast thread keeps running Fast. - Only call setServiceTier when the tier is explicit (!== undefined), preserving the three-state omit semantics at the keepalive boundary. - Add regression tests: persisted Fast is re-asserted; untouched omits the tier. * feat(codex+web): gate Fast toggle on catalog-advertised service tier The Fast toggle was gated on a model-name regex (gpt-5.5/5.4), which still showed a no-op control to API-key users — Fast credits only apply with ChatGPT login. Codex's model/list catalog advertises the service tiers actually available for each model in the current auth/plan context, so gate on that instead: - cli: capture serviceTiers (ids) per model in ModelListItem + normalizeModel - shared: CodexModelSummary.serviceTiers (flows through the existing getSessionCodexModels pass-through; no hub change needed) - web: codexModelAdvertisesFastTier(sessionModel, models) replaces the regex; SessionChat gates the toggle on it (hidden while the catalog is loading/errored). The toggle now only appears when toggling it will actually take effect. Refs #898 * fix(codex): make explicit Standard service tier sticky across resume Addresses HAPI Bot [Major] (round 2): a single persisted null conflated "untouched" with "explicit Standard". A user who turned Fast off persisted null, but startup mapped null -> undefined (untouched) and omitted serviceTier, so an account/thread-default Fast could silently return after restart/resume. Introduce a distinct stored representation: - 'fast' / 'standard' are explicit user choices; null/undefined = untouched. - Translate 'standard' -> Codex app-server serviceTier: null ONLY when building thread/turn params (toAppServerServiceTier); untouched omits the field. - /fast off now stores 'standard'; the web Standard option sends 'standard'. - Tighten SessionServiceTierRequest to enum(['fast','standard']) so stray tier strings are never forwarded. Tests: sticky-Standard-on-resume regression; turn/thread params translate 'standard'->null and omit on untouched; hub route applies fast/standard and rejects unsupported values + local sessions. Refs #898 * fix(codex): recognize real Fast tier (id 'priority', name 'Fast') in catalog gate Live E2E against an authed Codex session revealed the model catalog advertises the Fast tier with id 'priority' and display name 'Fast' (not id 'fast'), so the /fast/i gate — which only saw tier ids — wrongly hid the toggle for valid ChatGPT users on gpt-5.5/gpt-5.4. Capture both the tier id and name as lowercased tokens so the existing name-based match recognizes 'Fast'. The sent value stays 'fast' (the documented service_tier value / raw additionalSpeedTiers request tier). Verified end-to-end: gpt-5.5/gpt-5.4 gate on, gpt-5.4-mini off. Refs #898 * fix(codex): preserve service tier across session resume Resuming a Codex session spawns a fresh session (serviceTier null) and merges the old one in. Unlike model/effort/permissionMode, serviceTier was neither threaded through the resume spawn nor preserved in mergeSessionData, so a resumed Fast (or explicit Standard) session silently reverted to the account default. Thread serviceTier through the spawn path like its siblings: - hub: resumeSession passes session.serviceTier to spawnSession; rpcGateway + syncEngine carry it in the spawn RPC payload; mergeSessionData preserves it old->new (safety net). - cli: SpawnSessionOptions.serviceTier; apiMachine forwards it; buildCliArgs emits --service-tier for codex; the codex command parses it; runCodex seeds currentServiceTier from the spawn override first (opts.serviceTier ?? sessionInfo.serviceTier), so a resumed thread immediately runs the right tier. Verified end-to-end: set Fast -> kill process -> reopen -> resumed session (new id) still runs Fast. Tests: buildCliArgs --service-tier (codex only), runCodex spawn-override seed, mergeSessionData service-tier preservation. Refs #898 * fix(codex): send advertised 'priority' tier id for Fast, not 'fast' The model catalog advertises the Fast tier with request id 'priority' (display name 'Fast'), and OpenAI docs confirm service_tier='fast' maps to the request value 'priority'. The app-server serviceTier override is a raw request value that does not validate unknown strings (a live probe accepted 'bogus-xyz'), so sending 'fast' risks being silently ignored — no Fast applied. Translate the stored 'fast' state to app-server 'priority' at the thread/turn param boundary (toAppServerServiceTier); the stored/UI/command representation stays 'fast'/'standard'. Verified live: a turn with serviceTier='priority' runs and consumes the Fast-tier rate budget. Addresses HAPI Bot [Major]. Refs #898 * fix(codex): validate --service-tier CLI value (fast|standard) Addresses HAPI Bot [Minor]: the internal --service-tier spawn arg accepted any non-empty string, unlike the web /service-tier enum, so a malformed value could be seeded into currentServiceTier and persisted via keepalive. Parse it to 'fast'|'standard' and reject anything else, matching the web endpoint. Refs #898 |
||
|
|
93d004148d |
feat: support Claude Code 'auto' permission mode (closes #858) (#879)
Add 'auto' as a first-class HAPI permission mode for claude-flavored sessions, enforced by Claude's classifier rather than emulated in canCallTool. Includes mode configuration, CLI respawn on auto transitions, plan-exit targeting, API extensions, and documentation updates. |
||
|
|
3e2e48222a |
fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) (#877)
* fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) The legacy-to-ACP migrator's `findLegacyChatStore()` walks `~/.cursor/chats/<workspace-hash>/<cursorSessionId>/store.db` via `readdirSync()` and returns the FIRST match. When the same cursor session id exists in more than one workspace-hash drawer (operator opened the session from a worktree, an old workspace clone, etc.) the readdir order picks an arbitrary candidate. The migrator then transplants alien content into the ACP target, deletes the source drawer, and reports success - because the verify probe only checks "loads cleanly", not "loaded the right content". Operator session resurrects with no recall of its real history. Four-part fix (all four must land together): 1. Path-priority discovery in `findLegacyChatStore(id, home, cwd?)`: - Optional 3rd arg = canonical workspace path (caller passes `session.metadata.path`). - Compute md5(cwd) and check that drawer FIRST. - Fall back to readdir scan only if the canonical drawer is empty. - If 2+ candidates remain after fallback, throw `AmbiguousLegacyStoreError` listing all of them (workspaceHash, sizeBytes, mtimeMs). 2. Ambiguity surface in `maybeAutoMigrateLegacyCursorSession`: - Catch `ambiguous_legacy_store` / `size_mismatch` refusals and promote `cursorMigrationState` from 'in_progress' to a new 'ambiguous' state instead of silently clearing the banner. Operator sees an actionable web-banner. 3. Size sanity check before transplant: - Compare HAPI's known message count (new `MessageStore.countMessages` + `CursorLegacyMigratorDeps.getHapiMessageCount` dep) against the candidate `store.db`'s blob count. If message count > 100 AND blob count < messageCount/4, refuse with `size_mismatch`. - Skipped when message count is 0 (brand-new session) or the dep is unwired (unit tests, CLI direct callers). 4. Diagnostic logging on every successful transplant: - `[migrator] transplanted` info log capturing cursorSessionId, picked workspaceHash, candidate count discovered, sourceBytes, sourceBlobCount, targetAcpPath, sourceRemoved, canonical-path md5. Future regressions of this bug shape are diagnosable from `journalctl -u hapi-hub` without blob-overlap forensics. Tests added in `hub/src/cursor/cursorLegacyMigrator.test.ts`: - regression guard for single-drawer discovery - canonical-path wins over readdir order - ambiguity throws with all candidates listed (3-drawer + 2-drawer no-canonical-arg variants) - canonical-path resolves ambiguity cleanly - listLegacyChatStoreCandidates enumeration - workspaceHashFromPath shape - migrateOne happy path with canonical workspace + 3 sibling decoys - migrateOne refuses with ambiguous_legacy_store (3 drawers, no canonical match) and leaves all sources untouched - migrateOne proceeds when canonical path resolves - size_mismatch refuses tiny candidate when messageCount=6000 - size_mismatch passes when candidate blob count meets the floor - size sanity skipped on messageCount=0, missing dep, throwing dep, boundary (messageCount=100) - countLegacyStoreBlobs returns counts / null on bad path And in `hub/src/sync/syncEngineAutoMigrate.test.ts`: - cursorMigrationState promoted to 'ambiguous' on ambiguous_legacy_store / size_mismatch refusals. Schema: - `shared/src/schemas.ts`: cursorMigrationState enum gains 'ambiguous'. - `shared/src/apiTypes.ts`: CursorMigrateRefusalReason gains 'ambiguous_legacy_store' + 'size_mismatch'. Real-world repro (operator's tooling session, 2026-06-09): three legacy drawers contained one cursor session id - one with the real 21k-blob history, two with stale 19/568-blob diagnostic snapshots. Migrator silently transplanted the 568-blob alien content; resurrected session had no memory of prior history. Manual rescue completed; this fix prevents recurrence and surfaces the ambiguity to the operator instead. * fix(cursor): address cold review on migrator path-priority fix Self-review against the cold-PR rubric surfaces four polish items on the previous commit; all four addressed in-loop before push. - Major: `migrator:transplanted` candidate count was captured AFTER the source rm, so for the dominant single-candidate happy path the log reported `candidateCount=0, sourceRemoved=true`. Useless for diagnosing a future regression of the bug shape this PR is fixing. Snapshot candidates + source-side size + source-side blob count BEFORE any destructive step and use those for the log. - Minor: `sourceBytes` and `sourceBlobCount` were read from the destination path (acpSessionDir/store.db). The cp guarantees they match, but the field names imply source-side measurement. Now they measure the source directly. - Minor: `setCursorMigrationStateAmbiguous` silently returned false on cache miss / repeated version mismatch / write failure, letting the finally{} block clear the banner without any log. Now emits a warn-level log so the gap is diagnosable from journalctl. - Minor: `findLegacyChatStore` is exported public API and used as a free function in unit tests. An out-of-band caller bypassing preflightSession could pass `..` or `/etc/passwd` and have the inner `join(chatsRoot, wsh, id, 'store.db')` resolve to an arbitrary on- disk path. The probe is read-only `statSync` so blast radius is small, but enforce the same CURSOR_SESSION_ID_RE at the function boundary as a defence-in-depth. New unit test locks the behaviour. Hub test suite: 414 pass, 0 fail. Typecheck clean across cli/web/hub. * fix(cursor): cold-review polish on migrator path-priority (tiann/hapi#873) - Web `CursorMigrationBanner` now renders a "Manual review needed" state for `cursorMigrationState === 'ambiguous'` (Major #1: caller was promoting the metadata flag but no UI surfaced it). - Pin the md5-fixture contract for `workspaceHashFromPath`: raw, no-normalization, trailing-slash-distinct hashes computed via `printf '%s' <path> | md5sum` (Major #2: prevents algorithm drift that would silently revert path-priority discovery to fallback). - Snapshot full candidate set BEFORE the canonical fast-path resolves a single drawer so the `migrator:transplanted` log reports the decision-time count, not a post-rm undercount (Minor #1). - Warn log when canonical-path drawer is missing but readdir hands back exactly one candidate - regression-equivalent behaviour, but the size mismatch warrants a journalctl trail (path-normalization corner case the maintainer can grep for). - Boundary test: `messageCount = 101` (first value above the skip threshold) engages the size sanity check, pinning the cutoff contract (Nit). - Schema docstring on `cursorMigrationState` enum spelling out the banner contract per value (Nit). - syncEngine `getHapiMessageCount` warn-logs `countMessages` throws instead of silently downgrading to 0 (would chronically disable the floor). Drafted with claude-4.6-sonnet-thinking via Cursor; reviewed and tested by the operator. tiann/hapi#873. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): correct log-search strings in ambiguous banner copy The en/zh-CN locale strings told users to grep for 'migrator:ambiguous_legacy_store' and 'migrator:size_mismatch' but the hub emits '[migrator] ambiguous legacy store; refusing transplant' and '[migrator] size sanity check refused transplant'. Fix both locale files to quote the actual log prefix so the journalctl grep the operator is directed to actually hits. Addresses tiann/hapi#877 bot finding (Minor). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): address #877 bot Minor findings (trim + boundary guard) - Remove .trim() from canonical path before hashing: Cursor hashes raw workspace-path bytes; trimming a POSIX path with leading/ trailing spaces would hash to the wrong drawer, causing a false canonical miss and potential ambiguity refusal. - Add CURSOR_SESSION_ID_RE guard to listLegacyChatStoreCandidates: the function was exported without the same traversal-ID boundary check present in findLegacyChatStore. A future direct caller bypassing findLegacyChatStore could stat paths outside the intended <wsh>/<cursorSessionId>/store.db shape. - Move CURSOR_SESSION_ID_RE declaration above both functions that reference it so there is no temporal-dead-zone hazard. Addresses tiann/hapi#877 bot review Minor findings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
e6b723f693 |
feat(web): auto-focus terminal on open (#876)
When the terminal page loaded, focus stayed on the page body — the user had to click/tap into the xterm area before keystrokes were captured. Add terminal.focus() at the end of handleTerminalMount so focus lands inside the terminal as soon as the xterm instance is attached to the DOM. The quick-input buttons already call terminalRef.current?.focus() after each press; this extends the same pattern to the initial mount. Closes #875 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
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) | ||
|
|
cad58cfa0b |
fix(opencode): use ACP-reported reasoning effort options (#853)
* fix(opencode): use ACP-reported reasoning effort options Expose thought_level options from OpenCode ACP to the web UI via RPC/API instead of hardcoded presets, and validate effort values before setConfigOption. Fixes #852 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(opencode): sync hub effort after coerced setConfigOption When resolveThoughtLevelEffort falls back to a different supported value, roll back session state after a successful ACP update so keepalive and the web UI do not keep advertising the rejected effort. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
3473a88d67 |
feat(web): auto-return to chat when remote terminal exits (#857)
When the shell exited inside the remote terminal view, the page kept
showing a banner ("Terminal exited with code 0.") and left the user
stranded with no obvious next step. On mobile this is awkward, and it
does not match the muscle memory from native terminal emulators where
typing `exit` closes the tab/window.
Schedule a goBack() shortly after `terminal:exit` fires so the user
briefly sees the exit info, then returns to the session chat (same
destination as the existing back arrow via useAppGoBack).
The auto-close timer is cleared on unmount, on sessionId change, and
when the socket reconnects after a transient drop so a stale exit
event cannot navigate away from a freshly reconnected terminal.
Closes #856
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
e2625b8b47 |
feat: add Fable model presets for Claude sessions (#860)
* feat: add Fable model presets for Claude sessions Claude Code 2.x accepts the fable / fable[1m] model aliases for Fable 5. hapi passes the model string through verbatim, so adding the presets to CLAUDE_MODEL_LABELS surfaces them in the new-session and composer model pickers, labels, and the 1M context-window heuristic. * test: update modelOptions full-list assertions for Fable presets Addresses review feedback on #860: getModelOptionsForFlavor appends every Claude preset, so the two complete-array expectations must include the new fable entries. |
||
|
|
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> |
||
|
|
8094b500f3 |
fix(web): hide sidebar fake sessions for Cursor resume/archive (#836)
* fix(web): dedupe sidebar sessions by flavor resume id Wire deduplicateSessionsByAgentId into SessionList and resolve cursor threads via cursorSessionId so resume/archive no longer shows duplicate inactive rows for the same ACP session. Fixes #833 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): use SessionSummary.agentSessionId for sidebar dedup SessionList only receives SessionSummary from the API; native ids like cursorSessionId are already mapped into metadata.agentSessionId by toSessionSummary. Drop resolveAgentSessionIdFromMetadata to fix typecheck. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): hide inactive empty session stubs in sidebar Filter inactive rows with no agentSessionId and no title signal before grouping sessions, and expose lifecycleState on SessionSummary for future sidebar rules. Completes the #833 P0 follow-up alongside agent-id dedup. Fixes #833 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): scope sidebar dedup key by flavor Prevent cross-flavor collisions when flattened agentSessionId retains a stale native id. Add regression test and relax claudeRemote CI timeout. Fixes #833 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
ad038bbf2e |
fix(cursor): merge SKU catalog under ACP lock and refcount agent guard (#835)
Fixes incomplete cliModelSkus while agent acp holds the CLI lock (#831) and replace single-pid ACP lock with cross-process refcount (#832). Web picker merges machine/session catalogs and waits for SKU readiness before showing variant labels. Fixes #831 Fixes #832 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
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>
|
||
|
|
9349acba62 |
fix(web): use session flavor label in voice context formatters (closes #680) (#815)
Replaces hardcoded \"Claude Code\" strings in voice context injections with the active session's flavor label (Cursor, Codex, Gemini, etc.) via getFlavorLabel() from @hapi/protocol. Falls back to \"coding agent\" for unknown or missing flavors. Threads an agentLabel string param through: - formatMessage - formatNewSingleMessage - formatNewMessages - formatHistory - formatSessionFull - formatPermissionRequest - buildSessionVoiceContextPlan (passes to its internal formatMessage call) voiceHooks adds a single getAgentLabel(session) helper that reads session.metadata.flavor and resolves the display label once per call. Tests updated to cover the new parameter shape and to assert that \"Claude Code\" is never substituted regardless of agent flavor. formatReadyEvent already used a generic \"coding agent\" phrasing and needs no signature change. Closes #680 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
c87f88f02f |
fix(web): disable single-dollar inline math in remark-math (#805)
* fix(web/markdown): disable single-dollar inline math in remark-math Default remark-math configuration treats $...$ as inline LaTeX, which turns any prose containing two currency amounts (e.g. "save $400 vs the $200 plan") into a KaTeX block — paragraphs collapse, whitespace is stripped, the running text is re-rendered as math symbols. Pass `singleDollarTextMath: false` to remarkMath so single $ is plain text. Block math `$$...$$` (on its own line) still renders, matching GitHub-flavored markdown semantics. Single source of truth: MARKDOWN_PLUGINS is shared by MarkdownText, Reasoning, and MarkdownRenderer — fix lands in all three surfaces. Adds 3 regression tests that drive the unified pipeline end-to-end: prose with multiple "$N" amounts produces no `class="katex"` and no `<math>` element; `$$...$$` block math still does. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(web): declare unified/remark-parse/remark-rehype/hast-util-to-html The new markdown-text regression test imports these directly to drive the unified pipeline end-to-end. They were resolving via transitive deps from remark-math and rehype-katex, which is fragile — a future dep upgrade can remove the transitives and break the test. Declare them explicitly under devDependencies. No code change; lockfile records the same versions that were already installed transitively (unified@11.0.5, remark-parse@11.0.0, remark-rehype@11.1.2, hast-util-to-html@9.0.5). Addresses the HAPI Bot review finding on PR #805. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
66ba312117 |
fix(web): suppress Mermaid render error SVGs (#813)
* test: reproduce issue #785 * fix: suppress Mermaid render errors (closes #785) |
||
|
|
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> |
||
|
|
f4c3513abd | fix(web): drop stale queued-message ghosts on at-bottom refresh (#811) | ||
|
|
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> |