* fix(runner,hub): reap detached driver CLI on archive and tracking loss
Archive always verifies via stopRunnerSession; runner stopSession scans
argv orphans and awaits webhook-timeout tree-kill so PPID=1 children
cannot accumulate after KillSession miss or map drop.
Fixes#1910
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): keep --hapi-session-id reap-only; test orphan kill path
Claude/cursor swallow the stamp without bootstrap reuse, and
reapRunnerSpawnedOrphans is unit-tested as the stopSession map-miss path.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): address #1911 CI and bot Majors
Use Vitest in orphanReap tests, add Pi session.on mock, refuse archive
when machine StopSession is unreachable, trust full tree-kill results,
and argv-sweep orphans after every mapped stop.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): gate late-webhook kill on timeout orphans; fail closed on scan error
Track webhook-timeout PIDs separately so recovered shared roots are not
killed, and treat ps-list failures as still_alive rather than empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): keep shared Codex wrapper alive when archiving one root
StopSession must detach a single shared root without tree-killing the
wrapper PID while sibling roots remain, including after KillSession has
already marked the runtime inactive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): keep shared Codex siblings after restart tracking loss
Before persisted-PID or argv orphan kills, consult the durable runtime
registry for active sibling roots on the same wrapper PID so archiving
one root after a runner restart cannot tree-kill live shared sessions.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli): deliver hub-authored archive metadata to reconnecting CLIs
When KillSession misses and archive is written via markSessionArchivedFromHub,
emit Socket.IO update-session to the session room. On reconnect, ApiSessionClient
reconciles GET /cli/sessions/:id metadata so hub-archived still fires.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): argv-scan orphans even when shared siblings remain
Remove session-wide early returns that skipped orphan reaping whenever any
shared wrapper had active siblings. Keep only the PID-specific filter so
older untracked CLIs with the same HAPI id are still reaped.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): protect tracked shared wrappers when runtime registry is empty
Argv orphan sweeps must exclude in-memory shared wrapper PIDs that still
host sibling roots, not only durable registry siblings — otherwise a missing
runtime record lets the sweep tree-kill the wrapper while archiving one root.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): adopt untracked nonshared CLIs after runner restart
When a runner-spawned Claude webhook arrives with no TrackedSession (typical
after restart mid-bootstrap), durably adopt the PID instead of ignoring it so
StopSession can reap. Keep killing only nonshared timeout orphans; never kill
shared Codex wrappers on late webhooks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): keep recovered shared wrappers for registry siblings
After restart adoption tracks only a newly reported Codex root, archiving
it must consult the durable registry before tree-killing the wrapper PID
so older active sibling roots survive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): do not archive on KillSession ack when StopSession is unknown
KillSession returns before cleanupAndExit finishes. When the runner cannot
find the PID (unknown), wait for the session KillSession handler to drop
before archiving; refuse if the CLI socket remains reachable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli): confirm KillSession pid via StopSession before archive
KillSession now returns the CLI pid. When session-id StopSession is
unknown, archive confirms that OS pid (without treating socket loss as
exit) and still refuses when confirmation stays unknown or still_alive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner,hub): tree-kill adopted CLIs and require PID start markers
Adopted post-restart runner sessions now use killProcessTreeByPid when no
ChildProcess handle exists. KillSession carries a process-start marker so
raw PID confirmation cannot tree-kill a reused OS pid.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner,hub): verify adopted PID markers and hub-archive after stop
Adopted sessions compare the persisted start marker before tree-kill and
drop stale tracking on mismatch. After confirmed StopSession, hub-authors
archive metadata when the CLI did not finish its flush.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): refuse unconfirmed shared/adopted stops
Keep shared wrappers when siblings remain, but return unknown unless
runtimeControl already ended the root. Adopted PIDs without a start
marker no longer tree-kill by tracked PID alone.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): require inactive binding before sibling-kept archive
Siblings alone must not flip StopSession to stopped on retry, while an
acknowledged inactive Codex registry binding remains stop proof. Handle
PID- markers before any tracked kill so reuse cannot nuke strangers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): distinguish orphan reap from unknown keep-wrapper base
Pass unknown into finishWithOrphanSweep so a successful argv reap stays
stopped instead of being overwritten by an absent registry binding.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): honor orphan reap when adopted PID lacks a start marker
finishWithOrphanSweep with an unknown base lets a successful argv kill
report stopped instead of forcing unknown after the marker probe fails.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): confirm archive via metadata.hostPid tombstone
When StopSession(sessionId) is unknown and KillSession supplies no
confirmable pid+marker, probe PID-<metadata.hostPid>. already_gone
allows archive; still_alive/unknown still refuse (#1911 Peer #1820).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): escalate Windows taskkill to /F like SIGTERM→SIGKILL
Soft taskkill /T is refused on win32 console trees; orphanReap left
orphans still_alive. waitForProcessToDie now force-escalates on both
platforms so cross-platform reaping matches.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): widen process.kill mock signal to string | number
Match @types/node process.kill(pid, signal?: string | number) so the
Windows taskkill escalation mocks typecheck on tip 54f141368.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): tasklist liveness + immediate /F after soft taskkill refuse
Windows orphan reap still failed in 5ms on Teemo: soft taskkill refused
but escalate waited on an unreliable process.kill(0) probe. Prefer
tasklist for win32 liveness and escalate to /F as soon as the PID is
still live after soft kill.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): coerce win32 ps-list PIDs and escalate soft taskkill to /F
String PIDs from ps-list made Number.isFinite fail so orphan kill
returned still_alive without calling taskkill. Coerce PIDs, prefer
tasklist liveness, and escalate soft taskkill to /F like SIGTERM→SIGKILL.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): Windows orphan scan via CIM CommandLine + taskkill /F escalate
ps-list/fastlist is missing CommandLine and often missing from single-exe
bundles, so Teemo orphan reap returned scan_failed (logged as live PIDs).
List via Win32_Process CIM, coerce PIDs, escalate soft taskkill to /F.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): grace-wait after successful soft taskkill before /F
Escalate immediately only when soft taskkill is refused. When soft
succeeds but the PID is still draining, honor the existing grace period
before force (Codex Major on #1911).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): re-verify start marker before orphan argv tree-kill
Capture getProcessStartMarker after the argv match list, re-check
immediately before killTree, and skip on mismatch/null-while-alive so
PID reuse cannot tree-kill an unrelated process on the orphan fallback.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): preallocate HAPI id before fresh machine spawn
Fresh POST /machines/:id/spawn omitted existingSessionId, so argv never
got a reap stamp. Preallocate the hub row (fork/clear pattern) and pass
that id so a runner restart before the first webhook remains reapeable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): close three post-prealloc unreapable paths
Pass --hapi-session-id into Claude/cursor/kimi/copilot bootstrap so the
preallocated hub row is used. Treat null marker re-check while alive as
still_alive (not stopped). Keep the preallocated stub when spawn-fail
StopSession cannot confirm the child is gone.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): adopt-stub via reservedSessionId, not existingSessionId
`--hapi-session-id` binds a fresh create bootstrap to a hub-preallocated
UUID (getOrCreateSession id) without taking the reopen path. Non-UUID
stamps stay reap-only to match hub create schema. Restores integration
spawn invariants broken by mapping hapi→existingSessionId.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): close Opus cold-read B1–B3 for #1911
B1: capture start markers from the same process snapshot as argv
match (POSIX ps lstart / Win32 CreationDate); re-check before kill.
Continue other orphans when one target is unconfirmed.
B2: Windows killProcessTreeByPid verifies every collected descendant,
not root-only — taskkill /T exit 0 is signalled, not gone.
B3: runner sets childStarted:false on pre-exec rejection; hub deletes
the preallocated stub without StopSession. Post-exec ambiguity still
keeps the row until stop confirms. Terminal archives no longer refuse
when no runner is connected.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): explicit adopt for hub-preallocated session stubs
Fresh machine spawn preallocates with tag machine-spawn:<uuid>; CLI
create with a different tag and the same id 409'd on identity conflict.
Add adoptPreallocatedSession (hub-internal stub check) and POST
/cli/sessions { adopt: true, id } so create binds the reserved row and
overwrites tag/metadata without guessing the hub tag. Tests fail-first
on the real prealloc→CLI create path.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): preserve snapshot markers + unify Windows CIM start markers
Runner stopSession was still on findOrphans (PID-only), discarding same-snapshot
markers and reopening the PID-reuse kill window. Switch both call sites to
findTargets. Force UTC ISO 'o' CreationDate for list and single-PID probe so
ConvertTo-Json cannot disagree with getProcessStartMarker on WinPS 5.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): exercise production orphan sweep wiring in tests
Extract findStopSessionOrphanTargets as the stopSession discovery path both
run.ts sweeps call. Add tests that drive that helper end-to-end (not an
injected findOrphans seam), assert run.ts source wiring, and cover CIM-list
ISO vs probe ISO agreement plus the broken WinPS shape mismatch.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli): never delete live spawn on RPC timeout; release stub tags
Ambiguous machine-spawn failures (timeout / unset childStarted) no longer
call stopRunnerSession or deleteSession — that path killed healthy late
boots and CASCADE-wiped transcripts. Pre-exec delete still requires a real
machine-spawn stub (store tag check). Metadata updates release the stub
tag so reopen flavors are not permanently adoptable; adopt refuses
archived stubs. POSIX pgrep ENOENT fails closed instead of root-only verify.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): parseable Windows tree scan + fail closed on CIM failure
Space-joined PowerShell for collectWindowsProcessTree was a WinPS parse
error ($seen=@{} $bfs=@()), so every scan fell open to [root] and B2
false-stopped again. Join with newlines; return scan_failed on error/empty;
killProcessTreeByPid refuses root-only verify. Assert on generated command
text (Teemo-confirmed) so mocks cannot hide the next malformed script.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): reopen stamps existing-session-id; scan-fail signals root
Opus Critical on #1911: runner always stamps --existing-session-id for
known hub ids so Claude/Kimi/Copilot reopen does not hit adopt-stub 409.
On pgrep/CIM tree-scan failure still signal the verified root but never
claim stopped. Archive when both KillSession and machine RPC are missing.
Empty Win process-list stdout and strict Codex registry reads fail closed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): OK: sentinel for Windows tree scan; adopt CAS
CIM failure under SilentlyContinue printed root-only with exit 0 —
byte-identical to a healthy childless tree, so scan_failed never fired
and killProcessTreeByPid false-stopped (#1911 Overseer B1). Require
ErrorActionPreference=Stop, trap exit 1, and an OK: success sentinel;
parse anything else as scan_failed. Same Stop/trap on the machine-wide
list command. Execute the generated scripts under Docker pwsh in CI
when the image is present. Treat pgrep status=null as scan_failed;
coarsen Windows tasklist poll; wrap adoptPreallocatedSession in a
transaction with stub-tag CAS.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): reservedSessionId for fresh prealloc; signal Win survivors
Fresh machine-spawn stubs must not stamp --existing-session-id — that
sends Codex down reopen without a thread binding (#1911 bot Major) and
re-broke local HTTP non-UUID hints as reopen (round-4). Hub passes
prealloc as reservedSessionId; buildCliArgs stamps --hapi-session-id
for reserved/sessionId and --existing-session-id only for live reopen.
Codex parses --hapi-session-id and adopts via bootstrapSession.
Windows tree kill now taskkills surviving pre-scan PIDs after /T misses
broken intermediate links.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): fail-loud Docker pwsh CI; scope Codex registry; stub leak
CI skipped all four windowsProcessTree.pwsh tests on 48bb (image inspect
without pull). Pull the image in test.yml and throw under CI when missing.
Scope strict runtime-registry failure to Codex stop contexts so one bad
JSON cannot block archive machine-wide. Stamp childStarted:false on
machine RPC outside_workspace_roots. Add Codex reservedSessionId prealloc
and runSharedCodex regression coverage for the #1911 Critical.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): single-use reservedSessionId; skip UUID HTTP hints
Consume the machine-spawn reservation on first prepare() so a second
Codex root/fork cannot re-adopt a consumed stub (409). Stop stamping
UUID local-HTTP sessionId as --hapi-session-id (adopt hard-fail). Update
stale hub spawn mocks for the reservedSessionId arity.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): archive never-started machine-spawn stubs on unknown
Ambiguous keep-stubs (startedBy=runner, no hostPid, still machine-spawn
tagged) made StopSession return unknown forever while the runner was
online, so archive threw permanently. Treat unknown + prealloc stub +
no hostPid as already_gone. Also reject spawn success when the runner
reports a different id than the preallocated stub.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli): refuse dual-RPC-miss archive; generation-check Win survivors
Both KillSession and StopSession missing is not proof a detached CLI exited —
keep the row unconfirmed until the runner reconnects. Windows tree kill now
re-checks process-start markers before individually signalling survivors so
PID reuse is not killed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): refuse archived reopen in bootstrapExistingSession
Hub-archived rows could be resurrected when a late-booting child hit
--existing-session-id: buildSessionMetadata stamped lifecycleState=running
and updateMetadata overwrote the archive. Adopt already rejected archived
stubs; reopen now matches. Intentional revive still goes through hub
reopenSession first. Correct stub-hatch comment (no post-archive argv sweep).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli): reject un-archive in updateSessionMetadata CAS
Hub-side transactional guard (Discovery #1911 M1): refuse metadata writes
that clear archived lifecycle unless allowUnarchive (reopen clear). Client
stops infinite updateMetadata backoff when mismatch applies archived.
Clear archive immediately before spawn so Pi/PTY attempt snapshots stay valid.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli): success+preserve hub archive; M3 reservedSessionId split
Replace version-mismatch refuse (livelock in CAS backoff) with success +
merge-preserve of hub-archived fields unless allowUnarchive. Ack path
routes through noteHubArchived so the CLI exits (criterion 6). Clear-
before-spawn remains the authorized revive. Thread --hapi-session-id to
reservedSessionId for shared-parser flavors (adopt≠reopen).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): fail-closed orphan ps signal; PID- registry; spawn timeouts
Overseer B1: signalled ps (status null) is scan_failed, not empty table.
B2: unreadable registry on PID-* stops returns null (no soft []).
B3: 10s timeout on process/orphanReap spawn.sync. Narrow bootstrap
archived belt to archivedBy=hub to match hub merge-preserve.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): hubArchived latch; PTY archiveSnapshot durability
Criterion 6: noteHubArchived latches hubArchived so late
registerKillSessionHandler registrations still EXIT (EventEmitter
does not replay). Mirror Pi: persist archiveSnapshot on
ptyResumeAttempt across clear-before-spawn and quarantine, restore
on failed-resume cleanup (#1911 Discovery Majors).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): Codex hub-archived exit; AC6 latch test production order
C2: SharedCodexRoot registers hub-archived (and latch check) beside
its private KillSession — Codex never called registerKillSessionHandler.
C1: keep latch+subscribe; test write-then-register order Discovery proved.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): type Codex hub-archived end mock for tsc
vi.fn(async () => {}) inferred zero-arg calls; RootHost['end']
signature unblocks end.mock.calls[0][0] (TS2493).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): catch hub-archived Codex host.end rejection
void this.host.end() discarded archive-RPC failures as unhandled
rejections; Codex has no unhandledRejection handler, so Node can
exit the shared wrapper and kill sibling roots mid-turn.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): change_title updates metadata.name for visible renames
Agent change_title was writing metadata.summary only, so spawn --name
sessions kept showing the old title. Route explicit renames through
metadata.name (web rename semantics) across MCP, Codex, and Pi.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): voice context prefers metadata.name for session title
Voice bootstrap/full formatters only read summary.text, so after
change_title sets name the spoken context kept a stale title. Use the
same name→summary precedence as getSessionTitle.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: polish title rename tests after cold review
Rename the Codex root title test to match last-writer-wins behavior,
and skip the voice full-context summary header when no title signal
exists (avoid literal undefined).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(kimi): discover configured models dynamically
* fix(kimi): address dynamic model review findings
Probe settlement (Major): parse `kimi provider list --json` on the child's
'close' event instead of 'exit', so stdio is fully drained first, and run the
parse inside a guarded settle() helper. A parser throw previously escaped the
promise from an async event callback and reached the runner's uncaughtException
shutdown handler instead of listKimiModelsForCwd's catch. The helper also
settles the promise exactly once across timeout, spawn error and close, names
the terminating signal when there is no exit code, and tolerates a kill() that
throws.
Running-session discovery (Minor): add a session-scoped Kimi catalog. The
shared RPC method `listKimiModels`, a CLI handler registered on the session
connection with the probe cwd taken from the session metadata (never from RPC
arguments), hub rpcGateway/syncEngine pass-throughs, and
GET /api/sessions/:id/kimi-models gated on an active session of flavor `kimi`.
SessionChat now reads the catalog from that endpoint, so an active session
advertises its models even when no background runner holds the machine socket.
New Session keeps the machine-scoped discovery unchanged, and model switching
still goes through the existing ACP set_model path.
* fix(kimi): register session model discovery before the local/remote loop
The session catalog handler was registered inside the remote launcher, after
ACP initialization. A session started with `hapi kimi` in local mode therefore
answered the web picker's request with an unregistered-handler error, and the
failed query was not retried or invalidated when remote control was taken over.
The catalog is backend independent, so it is now registered in runKimi session
setup alongside the other session-level handlers, using the session's working
directory, and the late registration in the remote launcher is removed. This
covers local, remote and resumed sessions on the same session connection.
Adds cli/src/kimi/runKimi.test.ts asserting the handler is registered after
bootstrap and before the loop, and that the probe cwd is the session's working
directory. The launcher wiring assertion moves out of
kimiRemoteLauncher.test.ts with the registration.
* fix(cursor): pin Auto to CLI --model auto instead of account default
Selecting Auto was treated as unset/ACP default[] after the Default→Auto rename. Persist and spawn `auto` so the picker matches `agent --list-models`.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): keep live ACP model until Auto actually applies
Pin session.model to auto for the next `--model auto` spawn, but do not claim the live backend switched when ACP only advertises default[].
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): reject live Auto unless ACP auto or spawn confirmed it
Do not persist Auto when the running catalog has no auto option. Clear the spawn-auto flag after a concrete model apply so Auto → concrete → Auto cannot fake a live switch.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): keep ACP wire ids spawn-safe and apply effort
Cursor's parameterized picker (`clientCapabilities._meta.parameterizedModelPicker`)
advertises bare model bases plus separate `fast` / `thought_level` config options.
`buildCursorModelsSnapshotFromAcp` synthesized every base as
`${base}[fast=${fast}]`, and Cursor rejects any bracket id whose parameter set is
not that model's complete set, so 74 of the 76 published picker ids failed at ACP
`session/new` with `Cannot use this model` (#1818).
- Stop synthesizing wire ids: keep the advertised values verbatim (bare bases are
accepted by `--model`, defaults applied). Parameter sets differ per model, so a
synthesized id cannot be validated from the options alone.
- Flag such catalogs as `parameterized` so variant CLI skus still attach to their
base rows — the ACP apply path expresses them through config options.
- Apply effort in `applyParameterizedCursorModel` (ids `effort`, `reasoning`,
`reasoning_effort`), applied against the config options Cursor re-advertises for
the newly selected model, and skipped on models without that axis.
- Match CLI sku bases across Cursor's legacy `cursor-` family prefix
(`cursor-grok-4.6-high` → ACP base `grok-4.6`) so those variant rows are not
dropped.
- Version the shared on-disk catalog cache (v2) so runners that already cached the
rejected synthesized ids re-probe instead of serving them.
Verified with live `cursor-agent 2026.09.10-fd3934a` ACP runs: the catalog no
longer contains bracket ids, every advertised base and sampled variant sku spawns,
and in-session `claude-opus-4-8-low` / `cursor-grok-4.6-low` set the matching
`effort`/`reasoning` option.
* test(cursor): cover aliased effort ids and thinking coexistence
Covers the branches the HAPI bot review called out for #1818: the
`xhigh` → `extra-high` alias on `gpt-5.5` (`reasoning`), the
`reasoning_effort` id used by `gemini-3.8-flash`, and switching effort on a
model that also advertises Claude's separate `thought_level` `thinking`
toggle.
* test(cursor): cover legacy sku base and post-switch effort option
Adds the two apply-path cases the HAPI bot review called out for #1818:
resolving `cursor-grok-4.6-high` onto an advertised `grok-4.6` base, and
reading the effort option Cursor advertises for the newly selected model
(`reasoning` for gpt-5.5 → `effort` for claude-opus-4-8) instead of the
previous model's options.
* test(cursor): create the cache dir in the migration test
The version-envelope test wrote $HAPI_HOME/cache/cursor-models.json without
creating the directory, so running it alone (or filtering with -t) failed with
ENOENT before the assertion; it only passed because sibling tests in the same
file had already created the path. Reported in the HAPI bot review of #1822.
Verified: `vitest run ... -t 'ignores pre-versioned'` → 1 passed, whole file → 4 passed,
and every other changed test file passes when run standalone.
* Clarify Cursor Auto live-switch limitation in session pickers
* Reconcile partial Cursor model changes before acknowledging Auto
* Serialize Cursor model changes across clients with the existing async lock
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Grok ACP emits true agent_message_chunk deltas. Default overlap
dedupe treats a later "0" after "300" as an already-buffered suffix
and drops it, so the hub stores 300 instead of 3000.
Set textChunkMode: 'delta' on createGrokBackend, matching OpenCode.
Co-authored-by: Aqi77su <304702613+Aqi77su@users.noreply.github.com>
* fix(cli,hub): finalize canceled permission requests and surface stale answers
When Claude Code sends control_cancel_request for a pending AskUserQuestion,
the abort handler only removed the local pendingRequests entry and rejected
the promise — it never finalized the request in agentState the way the
session-level cancelPendingRequests path already does. A late answer to the
now-stale widget would still pass the hub's requests[id] check, forward via
RPC, land on a CLI that had already forgotten the request, and get silently
absorbed with no error anywhere.
Two changes, needed together:
- permissionHandler.ts's abort handler now calls finalizeRequest with
status: 'canceled', so cancellation is reflected in agentState the same
way a session-level cancel already is. Once synced to the hub, the
existing requests[requestId] check in permissions.ts naturally rejects a
late answer with 404 before it ever reaches the CLI.
- For the narrower race where an answer's RPC reaches the CLI before that
sync lands, handleMissingPendingResponse now throws instead of only
logging. RpcHandlerManager.handleRequest already turns a thrown error
into a `{ error }` RPC response; RpcGateway.approvePermission/
denyPermission now check for that and throw a typed
PermissionRequestNotFoundError, which the hub route turns into a 409
instead of silently returning { ok: true }.
Fixes#1735
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli,hub): match the specific not-found message instead of any RPC error
approvePermission/denyPermission treated any error on the Permission RPC
method as "request not found", reasoning only in a code comment that
nothing else on that path currently throws. Share the exact message via a
new PERMISSION_REQUEST_NOT_FOUND_MESSAGE constant in shared/rpcMethods.ts,
imported by both sides, so a future unrelated throw in handlePermissionResponse
surfaces as itself instead of being relabeled as a stale request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Ian Stevenson <is@lockhouse.systems>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Revert 8357da0a9d and its later shared-runtime integration.
Remove automatic model fallback, account usage polling, and the related UI, protocol fields, tests, and documentation without adding replacement quota handling.
Keep generic model pagination and method probing required by the current shared-session architecture. Cover idle sessions staying online without usage polling or agent-state churn.
Separate top-level help and version flags from agent arguments. Require explicit agents in scripts and preserve command argument boundaries.
Validation: bun typecheck, bun run test, targeted runner integration tests, and PTY/source/compiled argv smoke checks.
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.
Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.
Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
Vitest's 5s default flakes under GHA Defender/cold-start on the only
unit test that real-spawns on Windows (#1823). Keep the global unit
default unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
Bridge main-session PermissionRequest hooks without suppressing the native
terminal dialog. Reconcile replies against native results and clean up on
timeout, cancellation, mode switches, and session changes.
Keep reply IDs distinct from native tool IDs across web and native clients;
add protocol fixtures and regression tests.
Refs #1796
* fix(agy): say what the model picker is actually waiting on
The spinner in the New Session AGY picker read "Checking Antigravity
authentication…", but nothing at that point checks authentication — the
machine is running `agy models`, and the sign-in prompt is a separate
branch below it, shown only when agy reports the failure.
Name the wait after the work: "Fetching available models…", the same
words agy prints while it fetches.
* refactor(agy): describe a probe by its outcome, not by its response
The probe function returned a finished `AgyModelsResponse`, so "agy could
not be reached" and "agy listed no models" both arrived as a successful
response carrying the hardcoded mirror, and the caller could no longer
tell which had happened. Every policy decision about that answer has to
live inside the probe as a result.
Hand back what the probe observed — a live catalog, an auth failure, or
nothing usable — and let the caller turn it into a response. Same
behaviour: the mirror still stands in for both failure modes, and the
60s cache still holds whatever came out.
* fix(agy): serve the model catalog stale-while-revalidate
The `agy models` probe is a whole agy invocation — around 3s on a good
day, 15s when it times out — and the 60s window meant the New Session
picker paid that again a minute after the last look.
Keep the last listing agy actually returned and answer from it: fresh for
ten minutes, then still answered while a probe refreshes behind it, until
the entry is a day old and stops standing in for the machine at all. A
probe that times out or loses auth leaves that entry alone, so a blip no
longer empties a working picker, and the hardcoded mirror is no longer
recorded as if the machine had reported it.
Three things fall out of that and are handled here. A machine whose
sign-in has actually gone bad would otherwise look healthy for a day, so
an auth failure rides along with the catalog it can still serve — and,
because nothing else would re-probe a catalog that is still fresh, a
warning riding on the answer is itself a reason to look again. A failed
probe is not repeated on the very next request either, or a machine where
agy hangs would spawn it once per poll.
An explicit refresh always costs a probe, and never rides one that was
already running when it was asked for.
* fix(agy): let Retry force a fresh model catalog probe
With the catalog held for ten minutes, Retry would otherwise hand back the
answer it was pressed to replace, so the intent travels to the machine:
`?refresh=true` on the machine route, an optional RPC param, and a
one-shot flag on the query so ordinary mount and focus refetches stay
cheap. Every hop is optional, so a hub and a runner on different versions
still talk — the older side ignores it and answers from its cache.
Retry also has to be reachable, and honest, in the state that needs it.
The machine now answers with both a usable catalog and the sign-in failure
behind it, so the picker keeps the list and says why it may be out of
date, with the button right there rather than only once there is nothing
left to show. Pressing it runs agy, which can take tens of seconds, so the
button says so while it does.
The client contract covers both: `agy-models` is the one catalog route
that can carry an `error` on a successful response, and the one that
takes a refresh parameter.
* fix(agy): use the machine catalog in the in-session model picker
New Session already asks the machine what `agy models` lists, but a
session that is already open offered the built-in list in
`shared/src/models.ts`. That list is a hand-maintained mirror, so a model
agy started offering after the last release could be picked for a new
session and not for the one already running.
Point the composer at the same machine catalog. The mirror stays as the
fallback for the moment before the machine answers, and a model the
session is already on is kept selectable — and readable, when it is one
of the known presets — even after the catalog moves on without it.
* fix(agy): announce a model catalog re-check that changed the answer
Serving the last known catalog answers the picker instantly, but a picker
that was already open kept showing that answer until the user closed and
reopened it — the machine had no way to say it had found something newer.
Say it on the stream that already carries machine changes. The machine
daemon — the only process that answers `<machineId>:listAgyModels` —
emits it, and the hub forwards it as `machine-agy-models-updated` with
nothing but the machineId. Namespace resolution, per-machine delivery and
reconnect replay all come from the existing path.
What counts as a change is what the route would answer, not what sits in
the cache. That distinction carries the cases: a sign-in that lapsed or
came back changes no models yet changes what the user is told; a machine
whose agy was signed out has been answering from the hardcoded mirror, and
its first real listing is the largest change there is, for every client
except the one awaiting it.
* fix(agy): re-read the announced machine's model catalog
On `machine-agy-models-updated`, cancel and refetch that one machine's
catalog query — the app's global connection is always subscribed, so an
open picker redraws wherever it is.
Cancelling first is what makes it correct rather than merely likely.
query-core cancels an in-flight fetch only when the query already holds
data, so a picker opening for the first time would otherwise join the
request already on its way and settle on the listing the announcement
replaced. The refetch is answered from the machine's cache, so it starts
no probe and cannot bounce another announcement back.
A reconnect the hub could not replay takes the resync path, which clears
the agy catalogs the same way — that path has no announcement to fall back
on, so it is the one that can least afford to join a stale request.
* fix(agy): keep a model the user picked when the catalog moves under them
The catalog can now change while the New Session form is open, and the
form dropped any selection the machine no longer advertised — including
one the user had just made.
Keep that one, and list it as no longer listed so the form does not imply
agy is still offering it. A model restored from a draft or a saved
preference is still dropped: it may never have been runnable here.
* fix(agy): announce uncached authentication changes
* feat(pi): auto-title Pi sessions via bundled hapi_change_title extension
Pi sessions never got automatic titles: HAPI set PI_RPC_EMIT_TITLE=1 but
Pi does not implement it, and unlike the Claude/Codex/OpenCode launchers
the Pi bridge neither registers a change_title tool nor injects a title
instruction. This materializes a bundled Pi extension at launch and
passes it via --extension, giving Pi sessions the same titling flow:
- hapi_change_title tool (namespaced to avoid collisions with user
extensions, mirroring the OpenCode launcher naming)
- first-turn system-prompt instruction matching the Claude/Codex wording
- title lands via ctx.ui.setTitle(), which the existing extension UI
bridge already syncs into session metadata
Removes the dead PI_RPC_EMIT_TITLE env var. Requires pi >= 0.35.0
(when --extension landed, 2026-01).
Closes#1669 by giving sessions titles from the session model itself,
with zero extra title-provider calls.
* fix(pi): publish title extension atomically and keep retitle rule
Review findings from the HAPI PR bot:
- [Major] Publish the generated extension atomically. All Pi sessions of a
HAPI version share the same versioned path and Pi treats extension-load
errors as fatal, so a plain writeFile could break a concurrent launch with
a half-written file. The extension is now written to a unique temp file and
moved into place with rename; if rename loses a race against another
launcher that already published the file, the existing copy is kept and the
temp file is cleaned up.
- [Minor] Inject the title instruction on every before_agent_start instead of
stopping after the first title, preserving the objective-change retitle
rule from the persistent Claude/Codex instruction.
Adds a concurrent-materialization test and a handler test that executes the
title tool and asserts a later turn still carries the instruction.
* test(pi): fix handler return type in title extension test
* fix(pi): keep bundled title extension compatible with older Pi releases
Follow-up review finding: the embedded extension assumed three behaviors
that only exist in recent Pi releases, while HAPI has no Pi minimum-version
floor, so accepted older installations either failed to load the extension
or reached the tool without setting a title:
- import TypeBox via '@sinclair/typebox', the specifier both the legacy
extension loaders and the current one (which aliases it to the bundled
'typebox') resolve;
- normalize the execute() context positionally: current Pi passes
(toolCallId, params, signal, onUpdate, ctx), legacy releases passed
(toolCallId, params, onUpdate, ctx, signal);
- call ctx.ui.setTitle() directly instead of gating on ctx.hasUI, which
older RPC releases report as false even though setTitle emits the
extension UI event (this extension only runs under HAPI's RPC bridge).
Tests now exercise both call orders, including hasUI: false.
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
* test(claude): reproduce fork child deadlock when init never arrives
* fix(claude): accept first fork child prompt on SessionStart:fork hook
A forked child starts query() before any prompt exists, and the SDK
emits 'init' only after the first prompt is sent. Waiting for init
deadlocked fork children until forever. Materialization is already
signaled by the SessionStart:fork hook, so accept the child prompt on
that signal instead.
* chore: restore bun.lock
* fix(cli): identify the Windows runner process through CIM
isHapiRunnerProcess() checks that the PID persisted in runner state still
belongs to a HAPI runner. On Windows it read the command line through wmic
and returned true for any live PID when that spawn failed.
wmic is absent on current Windows 11 builds, so that branch is the only one
those hosts take and the check degrades to a liveness test. When a reboot
hands the stale runner PID to another process, runner start adopts it and
exits without starting a runner.
Query the command line through PowerShell Get-CimInstance Win32_Process
first and keep wmic as the fallback, matching getProcessStartMarker() in
this file. A probe that succeeds without reporting a command line falls
through to the next one, and an unreadable identity still preserves the
live state, so a real runner is never discarded on a probe that came back
empty.
* fix(cli): keep an unverifiable runner pid out of destructive paths
The identity check answered a boolean, so "alive but unidentifiable" had to
collapse into one of the two verdicts. It collapsed into "this is the
runner", and runner start acts on that by calling stopRunner(), which force
kills the persisted pid once the HTTP stop times out. A reused pid that no
probe can read was therefore still reachable by a kill.
Report the identity as runner, foreign, unknown or dead instead. Only a
confirmed runner is reported as running. An unknown pid is neither signalled
nor cleaned up, because clearing the state would also drop the lock that
keeps a second runner from starting, and the pid may still be a healthy
runner behind a probe that failed transiently.
The posix branch had the same fail-open through isProcessAlive and now takes
the shared unknown path.
* fix(cli): gate the runner force kill on a confirmed identity
Two paths reach stopRunner() with a boolean that cannot carry "unverified".
runner start stops an existing runner, and the spawned start-sync child reads
the same answer through isRunnerRunningCurrentlyInstalledHappyVersion(), where
a false result is read as a version mismatch and also calls stopRunner().
Propagating the unknown state to each caller would leave the next path open,
so check the identity where the process is actually signalled: only a
confirmed runner is force killed.
Also stop reading WMIC's column header as a command line. wmic get CommandLine
prints the header even when the property is empty or unreadable, so trimmed
stdout was never empty on success and an unreadable process was classified as
foreign, which cleared the state and the lock instead of preserving them.
* fix(cli): gate the whole runner stop on a confirmed identity
The identity check sat in front of the force kill, but stopRunner() first
sends an unauthenticated POST /stop to the port recorded in runner state.
An http request is a signal too: if the pid and its port were both reused,
an unrelated local service receives that request before the check runs.
Move the check to the start of stopRunner() so a pid that is not a confirmed
runner is never contacted at all. The later force-kill check is redundant
once the whole sequence is gated, so it goes away.
* fix(codex): ignore late app-server writes during disconnect
* fix(codex): scope app-server responses to their process
---------
Co-authored-by: huxiang <huxiang@myai.tech>
The runner integration suite spawns sessions whose new agent-availability
preflight requires an installable Claude CLI. CI runners have none, so every
spawn returned agent_unavailable and four suite tests failed.
The isolated test env now writes a minimal stub claude binary into the temp
home and points workers at it via HAPI_CLAUDE_PATH (the same override the
production launcher honors), keeping production behavior untouched.
* refactor(agy): extract the agy models probe from the fetch flow
One invocation and the decision of what to do with its output were
tangled in a single promise. Split them so the probe can be given
different arguments, and run more than once, without duplicating the
stream and timeout handling.
No behaviour change: the same argument vector, timeout, stream
handling and fallbacks remain, and the existing tests are untouched.
* fix(agy): read the model list from agy's structured output
The picker recovered ids and labels from `agy models`, whose table is
meant for people to read: it has emitted display names only, then
bare ids, then tab-separated id/label pairs over the 1.1.x line, and
each shape change silently sent the probe back to the hardcoded
mirror. Since agy started offering Gemini 3.7 Flash, that mirror is
what users see, so the three new entries never appear.
agy publishes the same listing as a structured payload, so read that
instead: `agy --output-format=json models` returns
`command.data.models[] = {id, label}`. The flag is global, so it goes
before the subcommand and takes its `=` form.
Releases that predate the flag ignore it and print the table, so the
text parser stays as the fallback, and it now also understands the
tab-separated shape. Every release checked, 1.0.16 through 1.1.13,
ignores the flag rather than rejecting it; a build that rejected it
would emit no models at all, so ask once more without the flag when
the first invocation yields nothing either parser can read.
* fix(agy): follow the Gemini 3.7 Flash row in the model picker
agy now lists Gemini 3.7 Flash as the top row of the `/model` TUI
picker, but the hardcoded row table still starts at 3.6 Flash. A
session whose current model is that row can never change its model:
the picker cannot identify the current row and the change is
rejected.
Add the 3.7 row to MODEL_ROWS/TARGETS. Every existing row shifts
down by one, which leaves their relative deltas, and therefore the
navigation keys emitted between them, unchanged. Mirror the three
new ids into AGY_MODEL_LABELS so the session pickers offer them too.
* fix(acp): carry the live reasoning marker on the wire payload
ACP agents stream thoughts a token at a time, so the handler coalesces
them into a buffer and re-sends the whole buffer under a stable stream
id every 250ms. The converter dropped the marker that says a payload is
one of those throttled snapshots, leaving the hub unable to tell a
replaceable snapshot from the settled message that closes the stream.
Mirrors how the text variant already forwards streamSnapshot.
* fix(hub): keep one stored message per reasoning stream
OpenCode reasoning arrives as a series of growing snapshots sharing one
stream id, and every snapshot was persisted as its own message. A 26h
session reached 48,844 rows and 63MB, and because the web budgets a
fixed number of messages, its 400-message window covered barely three
minutes of conversation — scrolling up walked through duplicate
snapshots instead of history.
Retire a stream's earlier live snapshots once their replacement is
stored. Sweeping only after the insert matters: the two statements are
separate transactions, so clearing first would leave a window where a
crash takes the whole stream. Only rows marked live are eligible and the
replacement is spared, so a stream always keeps at least one row and the
settled message that closes it is never removed.
Live rendering is unchanged: the web still receives every snapshot and
already folds them by stream id.
* fix(web): spend the message window on conversation, not repeated snapshots
The window budgets raw messages, but a reasoning stream renders as a
single folded block no matter how many snapshots it arrived in. On
sessions recorded before the hub started retiring them, those snapshots
fill the window on their own: in one 26h session the newest 400 messages
covered 202 seconds, so scrolling up paged through duplicates instead of
history.
Collapse each stream to its newest snapshot before trimming. Rendering
is unchanged — the timeline already folds them by stream id — and rows
without a stream id are never touched.
* fix(ios,android): port reasoning-snapshot compaction to the native windows
The window logic in HapiProtocol and :core:protocol is a one-to-one port
of the web store, so collapsing superseded reasoning snapshots only on
the web left the native windows budgeting raw snapshot rows. The hub
stores one row per stream now, but a client that already holds the older
snapshots still spends its window on them.
Add the same stream-id reader and compaction to both ports, in the shape
each already uses for agent-run rows, and pin the behaviour with a
pagination fixture. Both fixture suites enumerate shared/fixtures/pagination
from disk, so the ports cannot drift from the web again without CI saying
so.