Commit Graph
109 Commits
Author SHA1 Message Date
HimehaneandGitHub a965b0ab21 fix codex session import merge (#1123) (#1127)
修复 Codex 会话导入合并后列表为空的问题。

Fix Codex session import merge so a session is not detected as a duplicate of itself and deleted during merge.
2026-07-22 23:31:46 +08:00
64834467e3 feat(codex): import and resume sessions from runners (#1088)
* fix codex import resume flow

* fix hub restart session active state

* fix codex transcript workspace scoping

* Address Codex import review findings

* Fix Codex import machine selection

* Update Codex sessions error test

* Address Codex import review findings

* Preserve forked Codex session id on sync

* Make Codex duplicate cleanup source-aware

* Handle Codex archive failures

* Limit existing session flag to Codex

* Preserve Codex import machine binding

* fix: rebase runner Codex import onto current main

* fix: preserve runner-scoped Codex import behavior

---------

Co-authored-by: syy <815728149@qq.com>
2026-07-19 14:14:42 +08:00
Shawn TianandGitHub d809fca433 fix: reconcile stale queued messages (#1063)
Recover missed messages-consumed events from authoritative Hub state after session SSE reconnects.
2026-07-18 12:18:47 +08:00
SSU-WEI HUANGandGitHub 520c3f511a fix: verify Cursor chat store before reopen (#1037)
* test: reproduce issue #841

* test: cover Cursor chat store discovery

* fix: verify Cursor chat store before resume (closes #841)

* test: preserve non-Cursor resume behavior

* test: cover conservative Cursor resume gating

* fix: gate Cursor reopen until store verification

* test: cover legacy Cursor drawer fallback

* fix: scan unique legacy Cursor store drawer

* test: preserve raw Cursor workspace path hashing

* fix: hash raw Cursor workspace path

* test: pin Cursor probe owner and machine

* fix: probe Cursor store on recorded owner

* test: normalize Cursor probe owner home

* fix: normalize Cursor probe owner home
2026-07-16 12:34:41 +08:00
8ee04500b9 fix(hub,cli): coerce null session activeAt so resume cannot 500 (#1026)
Legacy rows and inserts left sessions.active_at NULL while SessionSchema
required a number, so CLI GET /cli/sessions/:id failed Zod and resume
surfaced HTTP 500. Persist active_at on insert, harden hub read coerce,
and nullish-transform activeAt in SessionSchema (output stays number).

Fixes #1025

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 09:00:17 +08:00
SSU-WEI HUANGandGitHub b9eed7c071 feat: add Grok Build support (#1030)
* test: define Grok Build integration behavior

* feat: add Grok Build agent integration

* test: cover Grok permissions and resume paths

* docs: add Grok Build setup guide

* fix: scope Grok ACP discovery to session cwd

* fix: align Grok permission UI semantics

* docs: clarify Grok runner setup

* test: require Grok create model and effort options

* feat: add Grok create model and effort pickers

* test: define Grok runtime parity behavior

* feat: add Grok runtime ACP controls and discovery

* fix: tighten Grok runtime controls

* fix: suppress nonfatal Grok title quota errors

* feat: support Grok Auto permission mode

* feat: forward ACP native session titles for Grok

* fix: guard Grok Windows shell arguments
2026-07-13 08:41:30 +08:00
de07643828 fix(codex): improve local session import compatibility (#995)
Co-authored-by: LIUZHIRU <ryuu@fine-net.co.jp>
2026-07-12 18:44:39 +08:00
ejj.ccandGitHub 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
2026-07-12 18:38:56 +08:00
weishu 5a377e38b9 fix(codex): defer session persistence until user activity 2026-07-12 11:00:08 +08:00
quecai-niuandGitHub a2465c782b [codex] fix Qwen realtime compatibility (#977)
* fix: improve Qwen realtime compatibility

* fix: preserve Qwen endpoint query parameters
2026-07-11 10:41:02 +08:00
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>
2026-06-29 11:41:53 +08:00
2ab3b39887 fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919) (#923)
* fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919)

These four contained bugs were uncovered by a 2026-06-15 hub-restart
incident where `hapi-restart-hub` SIGTERMed 23 cursor ACP sessions.
Each fix lands independently of the architectural #915 (hub-restart
cascade-archive) and the hypothesis-pending #917 (reopen creates dead
session); audit-trail correctness and idempotency wins stand on their
own.

  Fresh ACP sessions could be SIGTERMed during the async `update-metadata`
  ACK round-trip, stranding the on-disk ACP store with no DB handle. Add
  `ApiSessionClient.flushMetadata()` and await it after `onSessionFoundWithProtocol`
  on the fresh-session branch. Resume-path pre-registration (PR #834) is
  unchanged.

  Hub-restart-cascade SIGTERMs went through the same path as web-UI
  Archive clicks, both writing archiveReason='User terminated'. New
  default is 'Hub restart'; the KillSession RPC handler (the
  authoritative user-archive signal) now explicitly stamps
  'User terminated' before cleanupAndExit. SIGINT (local-terminal Ctrl-C)
  keeps the 'User terminated' label too.

  `rpcGateway.killSession` threw a generic Error when no target socket
  was registered, and the archive route surfaced that as 500. Add typed
  `RpcTargetMissingError`, narrow on it in `syncEngine.archiveSession`,
  fall back to a hub-side `markSessionArchivedFromHub` write so
  lifecycleState still flips to 'archived'. Drop the requireActive
  guard on the route and 2xx-noop for already-archived rows.

  without refresh, producing forever-409 on rename/reopen until an
  unrelated event triggered a cache refresh. `renameSession`,
  `clearSessionArchiveMetadata`, `restoreSessionArchiveMetadata` now
  retry-with-refresh (5 attempts, then throw) mirroring the existing
  good pattern in `mergeSessions`.

Refs tiann/hapi#913
Refs tiann/hapi#914
Refs tiann/hapi#916
Refs tiann/hapi#919

AI disclosure: implementation by Claude Sonnet 4.5 (Cursor agent peer)
under operator supervision. Issue triage by a sibling discovery agent.
Per CONTRIBUTING.md AI-assisted contributions policy.

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

* fix(cli): runner-spawned children use 'Stopped by runner' as default archive reason

Addresses bot review of #923: with the #914 default-archiveReason flip to
'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`,
webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all
mislabel as 'Hub restart' which is also inaccurate audit-trail noise.

Smallest defensible change: parameterise the lifecycle default via
HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set
'Stopped by runner' on spawn. Terminal-launched sessions (no runner
parent, no env var) still default to 'Hub restart' since hub-restart
cascade documented at #915 is the most plausible SIGTERM source for
those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT
Ctrl-C, markCrash uncaught exception) still win.

Two new unit tests cover the env-var default and the override
precedence.

Refs tiann/hapi#914.

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

* fix(hub): markSessionArchivedFromHub surfaces persistence failures as 5xx

Addresses second-round bot review of #923 (Major): `markSessionArchivedFromHub`
silently returned on DB write errors and on exhausted version-retry
attempts, which would let `/archive` claim 200 OK while the row stayed
unarchived. That regresses the #916 acceptance criterion that non-RPC
errors during archive must still propagate as 5xx.

Both fall-through paths now throw, matching the contract of the
sibling writers in this file (renameSession, mergeSessions). The
sessionModel test suite gains two cases that spy on
`store.sessions.updateSessionMetadata` to force `error` and
`version-mismatch` shapes and asserts the helper throws. The existing
route test at `hub/src/web/routes/sessions.test.ts:1015` already
covers the route-level 500 propagation for any error thrown out of
`archiveSession`, so no new route test is needed.

Imports `spyOn` from `bun:test` to match this test file's runtime
(the rest of the hub package uses bun:test, not vitest).

Refs tiann/hapi#916.

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

* revert(cli): drop HAPI_DEFAULT_ARCHIVE_REASON env override

Reverts `1c8972a3`. Bot review round 3 surfaced that the env-on-spawn
approach (the bot's own round-1 suggestion shape) mislabels
hub-restart-cascade SIGTERMs against runner-spawned children: systemd
killcgroup on `hapi-runner.service` stop sends SIGTERM to all
runner-children directly, and those would archive as 'Stopped by runner'
instead of 'Hub restart'.

The two suggestions are mutually incompatible without adding an IPC
channel (stdio: 'ipc' on spawn) so the runner can stamp
setArchiveReason via childProcess.send() before SIGTERMing. That is a
refactor, not a smallest-defensible change.

Going back to the simple shape: SIGTERM default is 'Hub restart' for
everyone, runner-internal stop paths share that label. The
audit-trail-correctness criterion from the #914 issue is met
(SIGTERM no longer falsely labels as 'User terminated'). Finer
attribution between cascade vs runner-stop is deferred as a follow-up.

Refs tiann/hapi#914.

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

* fix(cli): clean completions get 'Session completed', not 'Hub restart'

Addresses bot review round 4 of #923 (Major): every agent runner
(runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode)
calls setSessionEndReason('completed') on the natural exit path
without touching archiveReason. With the SIGTERM default flipped to
'Hub restart', clean completions were now archived as restart
cascades.

Fix: setSessionEndReason flips archiveReason to 'Session completed'
when it transitions to 'completed' AND no caller has already overridden
the archive reason. This covers all six agent runners with a single
setter change (no per-runner edits).

Two new tests cover the natural-completion default and the override
precedence (explicit setArchiveReason still wins).

Refs tiann/hapi#914.

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

* fix(hub): restore inactive-session guard on /archive except split-brain

Addresses post-rebase bot review Major on #923: dropping requireActive
entirely let normal inactive non-archived rows (completed stubs, UI
Delete/Reopen targets) fall through to archiveSession, which could stamp
archivedBy=hub on sessions that were never active.

Restore the 409 for inactive rows unless metadata.lifecycleState is
still 'running' (hub-restart split-brain cleanup case from #916).
Two route tests cover the guard and the exception.

Refs tiann/hapi#916.

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

* fix(cli): merge runnerLifecycle tests after upstream rebase

Post-rebase fix: Session completed tests referenced makeFakeSession
which was renamed to createMockApiSessionWithMetadataCapture when
merging upstream hasExplicitSessionEndReason tests with #914 archive
reason coverage.

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

* fix(cli): pass lifecycle object to KillSession handler in Pi runner

Upstream #862 (Pi agent) landed after this branch was cut. runPi.ts
still registered the legacy bare cleanupAndExit callback, so web
Archive for Pi sessions would persist archiveReason: Hub restart
instead of User terminated. One-line fix matching the other six
agent runners.

Refs tiann/hapi#914.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 17:37:32 +08:00
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>
2026-06-18 10:15:22 +08:00
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>
2026-06-18 10:11:40 +08:00
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>
2026-06-18 10:11:31 +08:00
26d3c2eb34 fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds (closes #939) (#948)
* fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds

Emit session-ready from the CLI after ACP load/newSession completes; hub
resumeSession and cursor dedup wait for that signal before merging rows so a
failed session/load no longer deletes the archived session the operator can retry.

Refs #917. Closes #939.

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

* fix(hub): gate session-ready wait on cursor ACP protocol only

Legacy stream-json Cursor resumes use cursorLegacyRemoteLauncher, which does
not emit session-ready; limiting the defer-merge and dedup gates to ACP avoids
60s resume_failed timeouts on those sessions.

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

* fix(hub): block ACP dedup until session-ready, including on session-end

Inactive ACP spawns that never emitted session-ready could still trigger
deduplicateByAgentSessionId on session-end and delete the original row.
Require session-ready for all ACP dedup paths and skip end-of-session dedup
when load never succeeded.

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

* fix(hub): restore session-end dedup for non-ACP cursor duplicates

Only skip the session-end dedup retry for Cursor ACP rows that never emitted
session-ready. Codex/Claude/legacy Cursor duplicates still merge when the live
row ends.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:10:10 +08:00
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>
2026-06-18 10:01:10 +08:00
SSU-WEI HUANGandGitHub 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
2026-06-17 10:27:33 +08:00
8526a9475e fix: bind imported Codex sessions to matching machine (#886)
Co-authored-by: dzshzx <22311806+dzshzx@users.noreply.github.com>
2026-06-17 10:26:43 +08:00
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>
2026-06-11 09:11:58 +08:00
55d1bbb7bd feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844)
* feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP

Closes #824

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:57:55 +08:00
cad58cfa0b fix(opencode): use ACP-reported reasoning effort options (#853)
* fix(opencode): use ACP-reported reasoning effort options

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

Fixes #852

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

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

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

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

---------

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

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

This change adds a single one-click affordance:

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

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

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

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

Closes #819

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:29:31 +08:00
cd99cfbc25 fix(hub): preserve session metadata across archive transitions (#825)
* fix(hub): preserve flavor session ids in metadata across archive transitions

When a session ends (terminate, crash, local-launch failure, handoff),
the runner's archive write replaces sessions.metadata wholesale. If the
CLI's locally cached Metadata is null (e.g. Zod parse failed at bootstrap
and api.ts nulled it out) or stale, the spread in archiveAndClose ships
a sparse blob and the resume token (cursorSessionId, codexSessionId,
claudeSessionId, etc.) gets cleared from the row even though the
on-disk chat data is still intact.

Fix at the hub layer because update-metadata is the single chokepoint
for every metadata write surface (CLI, web, future): in the store-level
updateSessionMetadata, read the prior row's metadata inside a
transaction and carry forward a small allowlist of flavor resume tokens
when the incoming write omits them. Explicit overwrites still win.

The allowlist mirrors pickExistingSessionMetadata in sessionFactory.ts
which already preserves the same fields on bootstrap.

Closes tiann/hapi#820

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

* fix(hub): address cold-review findings on metadata merge

Three bot findings on the initial patch:

1. (P1) Sparse archive payloads still resulted in metadata blobs that
   failed MetadataSchema parse downstream — required `path`/`host` were
   not in the carry-forward set, so even though the resume token
   survived, hub session cache and CLI getSession nulled-out the row
   and resume_unavailable came back. Add PARSE_IDENTITY_FIELDS = `path`,
   `host` to the carry-forward.

2. (P2) Preserving `cursorSessionProtocol` whenever it was omitted
   carried a stale protocol over to a freshly written `cursorSessionId`,
   misrouting a future remote resume. Pair-aware logic: drop the prior
   protocol when next sets a new id; preserve the protocol only when
   next is silent on both id and protocol.

3. (P2) The successful update-metadata broadcast emitted the pre-merge
   payload to other CLIs in the session room, so even though the DB row
   was preserved, peer caches diverged. Switch the broadcast value to
   `result.value` (the persisted merged value) so live caches stay in
   sync with the truth.

Refactor preserveProtocolResumeFields into mergeSessionMetadata with
two tiers (PARSE_IDENTITY_FIELDS + SIMPLE_RESUME_TOKENS) plus the
cursor pair handler. 6 new tests cover the regressions; existing 16
still pass plus 1 new socket-level test for the broadcast.

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

* fix(hub): preserve flavor + machineId across sparse metadata merges

Bot P2 on the prior fix: PARSE_IDENTITY_FIELDS (path, host) made the
blob parseable and SIMPLE_RESUME_TOKENS preserved the chat-id, but
flavor and machineId were still being dropped by sparse archive
payloads. Consequences:

- flavor: hub/src/web/routes/sessions.ts and sync/syncEngine.ts read
  `metadata?.flavor ?? 'claude'` to pick which session id field to
  resume. With flavor missing, a Cursor/Codex/Gemini session was
  routed as Claude and the preserved cursorSessionId was ignored.

- machineId: telegram/bot.ts and the CLI's resumable listing read
  `metadata?.machineId` to scope sessions to the current host. With
  machineId missing, the row dropped out of the resume picker.

Add a third carry-forward tier ROUTING_FIELDS = [flavor, machineId]
between PARSE_IDENTITY_FIELDS and SIMPLE_RESUME_TOKENS in
mergeSessionMetadata. 3 new tests cover preservation, no-invention,
and explicit override.

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

* fix(hub,cli): support explicit-clear sentinel for carry-forward fields

Upstream cold-review (Major): the carry-forward semantics introduced
in the prior commits ("omit field → preserve from prior") collide
with cli/src/codex/session.ts resetCodexThread(), which intentionally
clears codexSessionId by deleting it from the metadata blob before
calling updateMetadata. With omit-as-preserve, the cleared id was
restored from the prior row and /clear on a Codex session no longer
dropped the persisted thread.

Add an explicit-clear sentinel: when next sets a carry-forward field
to `null`, the merge drops the key entirely from the persisted blob
(key removed; not stored as null since MetadataSchema fields are
`string().optional()`). `undefined` (key missing from next) keeps its
"carry forward" meaning. The two semantics now compose cleanly:

  - next.field = "x"   → next wins (caller sets a new value)
  - next.field = null  → drop the field (caller intentionally clears)
  - next omits field   → carry forward prior (caller didn't touch it)

Update resetCodexThread() to send `codexSessionId: null` so the
reset actually drops the persisted thread under the new merge.

4 new hub tests cover: explicit clear of a single token, clear-one-
preserve-others independence, no-op clear on a never-set field, and
the success-ack value reflects the cleared blob. cli/src/codex tests
(224/224) and hub suite (301/301) green; bun typecheck clean.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:28:00 +08:00
3a8693f380 feat(cursor): migrate remote sessions to ACP with model/variant pickers (#799)
* feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:51:35 +08:00
a812a51dd7 feat(voice): backend voice picker + advanced controls behind disclosure (#742) (#743)
* feat(voice): voice personality, picker catalog, and prompt layer foundation

- voicePickerCatalog.ts: per-backend voice lists for Gemini and Qwen with
  resolve helpers (resolveGeminiLiveVoice, resolveQwenRealtimeVoice)
- voicePersonality.ts: VoicePersonalityPreferences schema, presets, composed
  system prompt with identity/character/response-length layers
- voicePromptLayers.ts: buildResolvedVoiceSystemPrompt, preset delivery snippets
- voiceSystemPromptParam.ts: hub-side base64url decode for ?systemPrompt=
- voicePickerPreferences.ts, voicePersonalitySession.ts: browser-side encode,
  decode, and storage helpers
- useVoicePersonality: React hook for preferences persistence

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

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

* fix(voice): preset delivery included when non-balanced preset selected; restore test typecheck

- isDefaultVoicePersonality: add preset check so warm/calm/direct presets
  trigger the delivery snippet instead of being treated as default
- web/tsconfig.json: remove test file exclusion from typecheck (restoring
  strict coverage of test code); fix resulting type error in mock declaration

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

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

* fix(voice): include use_speaker_boost in ElevenLabs TTS override payload

The checkbox persisted the pref but ttsDiffersFromDefault and
buildElevenLabsTtsOverride both omitted it, so the setting was never
sent to the agent.

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

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

* test(voice): update speaker_boost test to assert it IS included in override

The previous test asserted use_speaker_boost was omitted; now it's
correctly included in the TTS payload.

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

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

* fix(voice): authorize use_speaker_boost in ElevenLabs override schema

Add use_speaker_boost to both the VoiceAgentConfig tts override type
and the buildVoiceAgentConfig() platform_settings so the field is
accepted by the ElevenLabs agent runtime.

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

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

* fix(voice): propagate full language code through composed prompt, not just zh

getDefaultVoiceSystemPrompt and resolveComposedVoiceSystemPrompt were
filtering language to zh-only before passing to composeVoiceAgentPrompt.
Now append buildVoiceLanguageBlock(language) after composition so French,
Spanish, Japanese etc. reach Gemini/Qwen sessions correctly.

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

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

* fix(voice): only append language block when language explicitly set

Building language block unconditionally when no language is given
caused getDefaultVoiceSystemPrompt() to diverge from VOICE_SYSTEM_PROMPT.
Only append the block when a code is explicitly provided.

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

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

* fix(voice): always include language block for Gemini/Qwen in composed prompt

When auto-detect is on (language=undefined), the composed prompt sent
via hub proxy was losing the language auto-detect instruction because
the block was only added when language was explicitly set.

Now: ElevenLabs skips the block (has its own language field); Gemini/Qwen
always include it — undefined produces the auto-detect block, an explicit
code produces the appropriate language instruction.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-05 21:43:04 +08:00
f086949a8a feat(web,hub): export session conversation (#808)
* test: reproduce issue #793

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 21:40:59 +08:00
59c29e8423 feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime (#692)
* feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime

Rebased from Overbaker/hapi#401 onto current main. Adds a pluggable voice
backend architecture that extends the existing ElevenLabs integration:

- Gemini 2.5 Live (gemini-live): Google real-time audio via WebSocket
  with full function calling (messageCodingAgent, processPermissionRequest)
- Qwen Realtime (qwen-realtime): Alibaba DashScope via hub WebSocket
  proxy (browser cannot set Authorization header directly)
- VoiceBackendSession: dynamic backend selector with React.lazy loading,
  gates voice button until backend module is registered
- Hub WS proxies: JWT-authenticated /api/voice/gemini-ws and
  /api/voice/qwen-ws endpoints in Bun.serve, with message queueing during
  upstream connect to prevent dropped setup frames
- AudioWorklet pipeline: inline Blob URL recorder, 24 kHz PCM player,
  serial tool call execution, AudioContext created in user gesture for mobile
- Backend discovery: GET /voice/backend + POST /voice/gemini-token /
  POST /voice/qwen-token hub routes; frontend auto-detects active backend

Merge notes:
- Rebased 135 upstream commits cleanly; HappyComposer keeps upstream's
  configurable enter-behavior setting (supersedes hard-coded Ctrl+Enter)
- Converted gemini test files from bun:test to vitest (web package uses vitest)
- All 221 hub tests and 636 web tests pass; TypeScript clean

* fix(voice): restore user mic mute state after Gemini turn completes

turnComplete handler was unconditionally calling setMuted(false), which
re-enabled the mic track even when the user had manually muted. Now
restores to state.micMuted instead.

* fix(voice): remove hard-coded Chinese language from Gemini backend

buildGeminiLiveConfig was appending VOICE_CHINESE_LANGUAGE_BLOCK which
forced Gemini to always respond in Mandarin regardless of user locale.
Gemini now uses the neutral base prompt and responds in the language the
user speaks to it, consistent with the ElevenLabs behaviour.

* fix(voice): reset modelSpeaking in cleanup to unblock mic on restart

If the session closes while Gemini is mid-speech, cleanup() left
state.modelSpeaking=true. The next startSession() would then drop all
mic audio in sendAudioChunk() until a model turn eventually flipped
the flag — effectively deaf until page reload.

* fix(voice): guard stale close handlers in Gemini and Qwen sessions

ws.onclose operated on module-level state.ws, not the socket that fired
the event. A rapid stop/restart could cause the old socket's onclose to
call cleanup() after the new socket was assigned, tearing down the live
session. Guard with `if (state.ws !== ws) return` before cleanup.

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

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

* fix(voice): remove hard-coded Chinese language from Qwen backend

Matches the Gemini fix — both backends now use VOICE_SYSTEM_PROMPT
without the Chinese language block, giving consistent English-default
behaviour across all non-ElevenLabs backends.

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

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

* feat(voice): proactive/reactive toggle in voice settings

Adds a "Proactive voice" toggle (default: off = reactive) to the Voice
Assistant settings section.

Reactive (default): initial context and agent-ready events are fed
silently; the assistant waits for the user to speak first.

Proactive: original behaviour — Gemini/Qwen narrate context on connect
and speak unprompted when the agent finishes a task. ElevenLabs is also
affected via onReady sending a user message rather than a silent update.

Covers all three backends uniformly. localStorage key: hapi-voice-proactive.

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

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

* fix(voice): normalize WS close codes, drop barrel re-exports, fix SSE visibility

- hub/server.ts: add toClientCloseCode() to normalize reserved upstream
  close codes (1005/1006/1015) to 1011 before forwarding to browser;
  abnormal upstream drops (1006) would otherwise throw on clientWs.close()
  and leave the browser socket open

- realtime/index.ts: remove static GeminiLiveVoiceSession and QwenVoiceSession
  barrel exports; VoiceBackendSession lazy-imports both, so barrel re-exports
  created static dependencies that defeated the intended code-split

- App.tsx: gate global useVisibilityReporter on !sessionEventSubscription so
  the always-on SSE connection does not suppress native Web Push notifications
  for sessions the user is not currently viewing

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

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

* fix(voice): respect language setting in Gemini/Qwen; fix voice-start toggle label

- buildGeminiLiveConfig() now accepts optional language param; appends
  VOICE_CHINESE_LANGUAGE_BLOCK only when language === 'zh'
- GeminiLiveVoiceSession passes config.language through
- QwenVoiceSession conditionally builds basePrompt from language setting
- Fixes silent no-op when user selects Chinese in voice settings on
  Gemini/Qwen backends (was ElevenLabs-only)

- Rename voice-start toggle label to 'Start voice session with summary'
- Fix description: clarifies the choice is about session-open behaviour
  (summary vs greeting), not ongoing narration

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

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

* fix(voice): send greeting trigger in reactive mode for Gemini

Gemini Live has no built-in first-message like ElevenLabs agents do;
without an explicit turnComplete:true it sits silently. In reactive mode
(default, toggle off) now sends a greeting instruction after any silent
context feed so Gemini introduces itself and invites the user to speak.

Proactive mode is unchanged: the context summary is the opening speech.

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

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

* fix(voice): suppress Gemini self-identification and context leak in greeting

- VOICE_SYSTEM_PROMPT: explicit instruction never to call itself Gemini,
  Google, or any underlying model/provider name — always HAPI
- Greeting trigger text: instruct to greet as HAPI only, suppress model
  name and any reference to context/recent activity in the opening line

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

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

* fix(voice): address code review findings — error handling, proxy, audio

Gemini + Qwen client:
- onerror now sets setupDone/sessionReady and nulls state.ws before
  calling reject(), so the stale-close guard trips in onclose and
  prevents a duplicate statusCallback('error') on WS failure

Gemini client:
- Proactive mode with no initialContext now falls through to the
  greeting trigger instead of sitting silently
- Remove unused handleBargeIn callback (dead code)

Qwen client:
- Add input_audio_sample_rate: 16000 to session.update so PCM rate
  is declared explicitly rather than relying on DashScope's default

Hub proxy:
- Remove no-op ternary in Gemini flush loop and message handler
  (typeof x === 'string' ? x : x); use upstream.send(msg) directly
- Qwen onerror now calls upstreamMap.delete() before closing client,
  eliminating the stale map entry window
- Align Qwen hub fallback model string with QWEN_REALTIME_MODEL
  constant ('qwen3-omni-flash-realtime')

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

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

* fix(voice): trailing-slash WS URL, Qwen session.update schema

hub/voice.ts:
- Replace string-concat WS URL construction with buildVoiceWsUrl() which
  uses URL API to set protocol/pathname cleanly — fixes double-slash when
  HAPI_PUBLIC_URL has a trailing slash (would silently skip the proxy route)

QwenVoiceSession.tsx:
- Wrap tool definitions in {type:'function', function:{...}} as required
  by Qwen-Omni realtime schema — previous flat shape caused session.update
  rejection before audio capture could start
- Use pcm16/pcm24 audio formats matching DashScope spec; remove
  input_audio_sample_rate (encoded in format name)

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

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

* fix(voice): await audio capture before setMuted; sanitize upstream close codes

GeminiLiveVoiceSession + QwenVoiceSession:
- startAudioCapture() is now async and awaits recorder.start() before
  calling setMuted() — previously setMuted ran before getUserMedia resolved
  so a session restarted while muted would open the mic anyway
- statusCallback('connected') now fires after audio is ready
- setMuted() called unconditionally (not just when true) to correctly
  apply saved state in either direction

hub/src/web/server.ts:
- Both Gemini and Qwen close() handlers now pass the client code through
  toClientCloseCode() before forwarding to upstream — prevents reserved
  codes (e.g. 1006) from causing WebSocket.close() to throw and leave
  the upstream session open until provider timeout
- Reason string capped at 123 bytes (WebSocket protocol limit)

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

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

* fix(voice): wrap startAudioCapture in try/catch to propagate mic errors

An unhandled rejection inside the async onmessage callback does not
propagate to the outer startSession Promise — the UI hangs on
'connecting' and the provider socket stays partially open. Wrapping
the await in try/catch calls cleanup()/statusCallback('error')/reject()
so failures surface correctly.

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

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

* fix(voice): propagate backend discovery failure instead of silently falling back to ElevenLabs

fetchVoiceBackend no longer catches errors and defaults to 'elevenlabs' — any
network or server failure now throws so VoiceBackendSession can surface it via
onStatusChange('error', ...) rather than silently mounting the wrong backend.

VoiceBackendSession also resets backend state to null when api changes, so
a stale ElevenLabs registration from a prior discovery cannot persist into
a new session.

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

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

* fix(voice): throw on unrecognised backend value instead of silently falling back to ElevenLabs

Unknown backend strings (future values, typos) now throw rather than defaulting
to elevenlabs, closing the narrow remaining form of the original misrouting bug.
Also removes the unnecessary `as VoiceBackendResponse` cast.

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

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

* fix(voice): add Qwen greeting/proactive trigger; fix socket buffer for base64 uploads

Qwen session.updated handler now sends the same proactive summary or greeting
trigger that Gemini does — previously it started silently in both proactive and
reactive modes.

maxHttpBufferSize raised to 68 MiB to account for base64 expansion: 50 MiB
decoded files become ~66.7 MiB as base64 JSON, so the previous 55 MiB ceiling
would disconnect uploads above ~41 MiB before they reached the CLI.

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

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

* fix(voice): replace unsupported conversation.item.create with session.update for Qwen text

Qwen's realtime API only supports conversation.item.create for function_call_output.
Sending it with type:'message' for greetings/context was invalid and could fail
before the user spoke.

sendTextMessage and sendContextualUpdate now update session instructions via
session.update (accumulating context into the system prompt) and trigger
response.create only when a spoken reply is needed — matching Qwen's supported
client event surface.

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

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

* fix(voice): guard session.updated re-entry and reset config on session start

session.updated now returns early after the first ack — subsequent session.update
calls (instruction appends) also echo session.updated but must not re-trigger
audio capture or the greeting path.

currentSessionConfig is now reset to null at the top of startSession so a stale
config from a failed previous session cannot leak into the new one.

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

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

* fix(voice): assert wsUrl presence for Gemini proxy connections

Without this guard, a missing wsUrl in the hub token response would
silently attempt to connect directly to Google with "proxied" as the
API key — producing a confusing auth failure instead of a clear error.

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

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

* fix(voice): correct Qwen audio formats and default voice

DashScope realtime API accepts only 'pcm' for both input and output
audio formats. The pcm16/pcm24 values caused session.update rejection
before audio capture could start, leaving the Qwen backend unusable.

Also updates the default voice from Mia (not in the qwen3-omni-flash-
realtime voice list) to Cherry, which is documented as supported.

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

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

* fix(voice): close AudioContext on failed voice session start

Failed token fetch, microphone denial, or WebSocket error during
setup left state.playbackContext open. Each failure path now calls
cleanup() before throwing/rejecting, preventing AudioContext leaks
on mobile browsers with hard limits on concurrent contexts.

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

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

* chore: restore non-voice files to upstream/main state

Reverts changes to files that shouldn't differ from upstream:
- .gitignore: remove fork-only AGENTS.local.md entry
- web/src/App.tsx: restore dual-subscription SSE pattern (scope-aware)
- web/src/hooks/useSSE.ts: restore SSEScope/scope parameter
- web/src/hooks/useSSE.test.ts: restore (was accidentally deleted)
- web/src/lib/appSseSubscriptions.ts: restore (was accidentally deleted)
- web/src/lib/appSseSubscriptions.test.ts: restore (was accidentally deleted)
- hub/src/sync/syncEngine.ts: restore (off-topic change)

* fix(voice): harden Gemini and Qwen WS proxies against client abuse

Hub sends HAPI-owned Gemini setup on proxy connect and rejects client
setup frames. Qwen proxy always uses QWEN_REALTIME_MODEL instead of a
client query parameter. Shared buildGeminiLiveSetupMessage() keeps wire
format in one place.

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

* fix(voice): harden Qwen proxy — hub-owned setup, client frame allowlist

Mirror the Gemini proxy security model for Qwen:

- Hub sends initial session.update (voice/tools/instructions) on upstream
  connect so the browser cannot override config fields.
- Proxy message() now calls isQwenSafeClientFrame() and closes the
  connection (1008) if a client session.update touches any field other
  than 'instructions' (blocks tool/voice/modality overrides).
- QwenVoiceSession no longer sends session.update on session.created;
  it waits for the hub-relayed session.updated and then sends only
  instruction-only updates for context/proactive content.
- Language passed as query param (?language=zh) so hub builds the
  correct Chinese system prompt without a client-supplied session.update.
- buildQwenSessionUpdateMessage() and isQwenSafeClientFrame() added to
  @hapi/protocol/voice; 9 new unit tests cover filter edge cases.

* fix(voice): respect Qwen session.created→session.update protocol ordering

DashScope requires session.update to be sent AFTER session.created is
received, not immediately on WebSocket open. Previously the hub sent
session.update in upstream.onopen, which violated this ordering and
risked the config being processed in an uninitialized session context.

Add pendingSetupMap to buffer the hub-owned session.update payload.
The onmessage handler now relays session.created to the browser first,
then immediately sends the pending session.update to DashScope — matching
the protocol ordering the old browser-side code used (which waited for
session.created before sending session.update).

Also remove maxHttpBufferSize from the socket.io Engine config. That
setting is unrelated to voice backends; upstream/main had no such limit
set and it is not introduced by this PR.

* fix(voice): use Realtime tool shape for Qwen session.update (not chat-completions)

Qwen Realtime session.update expects tools as flat objects:
  { type: 'function', name, description, parameters }

The previous code used the chat-completions shape:
  { type: 'function', function: { name, description, parameters } }

DashScope may reject session.update or silently ignore tools with the
nested shape, causing tool calls to fail at runtime. Fix applied in
buildQwenSessionUpdateMessage(); test updated to assert flat shape and
that no nested `function` key is present.

* fix(voice): update Qwen Realtime model, voice, and endpoint for intl service

Live-tested against DashScope international API:
- Model: qwen3-omni-flash-realtime → qwen3.5-omni-flash-realtime
  (previous model ID did not exist on DashScope)
- Default voice: Cherry → Tina
  (confirmed from session.created response on qwen3.5-omni-flash-realtime)
- Default WS base: dashscope.aliyuncs.com → dashscope-intl.aliyuncs.com
  (international accounts use the -intl endpoint; China endpoint rejects
  international API keys; QWEN_REALTIME_WS_URL env var still overrides)

* fix(voice): correct Qwen text injection and generalise language handling

Two dogfooding fixes verified against live Qwen Realtime session:

sendTextMessage: switch from instruction-injection to conversation.item.create
  Qwen Realtime requires a user conversation item before response.create.
  The previous approach (updateInstructions + response.create) produced
  "input messages do not contain elements with role user" errors. Now sends
  {type:message, role:user, content:[{type:input_text}]} then response.create.
  sendContextualUpdate is unchanged (instruction-only, no response trigger).

Language handling: replace zh-only branch with buildVoiceLanguageBlock()
  Previously, only language='zh' added any instruction; all other languages
  (including English) sent no language block, causing Qwen to drift to Chinese.
  buildVoiceLanguageBlock() now covers three cases:
    - 'zh'/'zh-*': existing Chinese block (unchanged)
    - explicit code ('en','es','fr',...): "Always respond in [Language]"
    - undefined/auto: "Detect the user's language and maintain it"
  Applied to buildGeminiLiveConfig, buildQwenSessionUpdateMessage, and the
  client-side currentInstructions mirror in QwenVoiceSession.
  Also removes the Gemini hub proxy's zh-only filter, which was discarding
  explicit language selections other than Chinese.

* fix(hub): gate Gemini client frames until upstream setupComplete

Hub sends its owned setup on upstream open, then waits for Google's
setupComplete acknowledgment before flushing queued client frames.
isGeminiSetupCompleteFrame() detects the {"setupComplete":{}} message;
message() queues instead of forwarding while pendingMap is live.

Addresses the repeated Major finding from bot review on PR #743.

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

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

* fix(hub): cap Gemini setup-window pending queue at 1 MiB

An authenticated client could flood the queue between upstream.onopen
and Google's setupComplete acknowledgment. Add pendingBytesMap tracking
and close with 1009 if the budget is exceeded.

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

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

* fix(gemini): pass all language codes to hub proxy, not just zh

Language selection for French, Spanish, Japanese etc. was silently
dropped — only 'zh' was forwarded as a query param.

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

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

* fix(voice): expand LANGUAGE_NAMES to cover full ElevenLabs language set

Codes like 'no', 'da', 'fi', 'pt-br', 'bg', 'ro', 'cs', 'el', 'ms',
'tl', 'uk', 'hu', 'hr', 'sk' were falling through to raw-code prompts
("Always respond in no"). Now resolve to proper display names.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:56:52 +08:00
f9ef3a4489 feat(codex): import local Codex sessions into Hapi (#796)
* local: add Codex Desktop session sync controls

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

---------

Co-authored-by: Codex Local <codex-local@example.invalid>
2026-06-04 17:53:12 +08:00
SSU-WEI HUANGandGitHub 39fba5292c fix: add Telegram notification context (#768) 2026-06-02 13:08:32 +08:00
cc4025abdb fix(web,hub): queued bar SSE + never-started inactive resume (#761)
* fix(web): apply messages-consumed on global SSE connection

The global all-sessions SSE subscription returned early on message-stream
events without updating the message-window store. When session-scoped SSE
was reconnecting or the user had another session selected, messages-consumed
never cleared the queued bar even though the hub had stamped invoked_at.

Also harden mergeMessages so a stale invokedAt:null snapshot cannot clobber
an existing ack timestamp.

Fixes tiann/hapi#758

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

* fix(web,hub): resume never-started inactive sessions on first send

Hub fresh-spawns when inactive session has path but no agent thread id and
zero messages. Web guards resume, updates inactive banner copy, and surfaces
resume_unavailable before POST /resume when resume is impossible.

Fixes tiann/hapi#759

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

* fix(web): scope sessionResume guard to current flavor only

Hub `resolveAgentResumeId` only honors the metadata.flavor's id; the web
guard was falling back across all flavors so a cursor session with a stale
codexSessionId still tried to resume and 409'd. Mirror the hub switch and
default to claude when flavor is unknown.

Addresses HAPI Bot review on tiann/hapi#761.

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

* fix(web): allow claude session resume via hub message-id recovery

Hub `resolveAgentResumeId` falls back to `recoverClaudeSessionIdFromMessages`
on the claude branch when `metadata.claudeSessionId` is absent, so the web
guard must not block inactive claude sessions that have stored messages but
no metadata id. Other flavors have no such recovery path and stay rejected.

Addresses second HAPI Bot review thread on tiann/hapi#761
(`web/src/lib/sessionResume.ts:41`).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:07:13 +08:00
SSU-WEI HUANGandGitHub 5b797bb95d feat(opencode): slash command support (#671) (#753) 2026-05-31 19:35:31 +08:00
3cc03eb9da fix(hub): skip redundant set-session-config RPC on resume (#740)
resumeSession already passes permissionMode to spawnSession. The follow-up
applySessionConfig raced session-alive (handler not registered yet) and
returned resume_failed after hub restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 10:11:10 +08:00
c58e8cea9e fix(cursor): persist resume id early and return 409 for resume_unavailable (#745)
Remote cursor launcher now mirrors local launcher by writing cursorSessionId
to hub metadata as soon as --resume is known, before the agent init event.
POST /sessions/:id/resume maps resume_unavailable to 409 with clearer guidance.

Fixes tiann/hapi#744

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:40:05 +08:00
HeavyGeeandGitHub ec3722aba9 feat(web): session list status indicators (attention + scheduled) (#699) 2026-05-29 15:43:21 +08:00
6f2bb7d32b feat(opencode): add plan mode, reasoning effort, and status telemetry (#688)
* feat(opencode): support plan mode

* feat(opencode): support reasoning effort

* feat(opencode): surface context usage in web

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

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

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

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

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

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

* fix(opencode): cap model discovery polling

---------

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

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

* fix(voice): apply PR review feedback for agent selection and preview cleanup
2026-05-27 11:17:01 +08:00
45cf002510 fix(hub): persist permissionMode across hub restart (#710)
Store the last known permission mode in session metadata so YOLO and other
modes survive hub restarts, resume after archive, and reconnect without
waiting for CLI keepalive.

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

Fixes tiann/hapi#693

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 14:45:43 +08:00
NightWatcher314andGitHub c417330d55 fix(skills): scope completions by session flavor (#667) 2026-05-24 10:59:32 +08:00
763f45acdd feat: add support for Kimi Code CLI and fixed some bugs (#659)
* Add Kimi agent support via ACP protocol

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

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

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

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

* Fix Kimi ACP tool call input decoding on web

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

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

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

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

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

* Add kimi support to web UI

* Fix some bugs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-05-22 10:08:14 +08:00
weishu 8e3bba9303 refactor: share REST mutation schemas 2026-05-21 14:49:51 +08:00
weishu e88a9075df refactor: share core REST payload types 2026-05-21 14:35:47 +08:00
weishu 9704227ce2 refactor: share machine runner schemas 2026-05-21 14:29:42 +08:00
weishu b79f50f815 Share RPC method constants 2026-05-21 10:53:31 +08:00
weishu 41f6b37d69 Structure SSE update patches 2026-05-21 10:44:46 +08:00
weishu 2e1e2d39db Share slash command definitions 2026-05-21 10:36:25 +08:00
weishu d6f97065c1 Share REST and RPC response types 2026-05-21 10:31:50 +08:00
weishu 9698aa9f4c Unify agent flavor definitions 2026-05-21 10:23:57 +08:00