* 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>
* fix(hub): reconcile keepalive-only sessions to an idle lifecycle
`sessionCache.expireInactive` only expires a session when `activeAt` goes
stale, and `session-alive` refreshes `activeAt` every ~2s for as long as the
CLI socket lives. A session whose agent has done nothing for days therefore
stays `active: true` / `lifecycleState: running` forever — socket pinging was
being read as agent health.
Add the missing second signal. `active` keeps its meaning (the CLI is
reachable); `metadata.lifecycleState` moves `running` -> `idle` once the hub
has seen nothing but keepalives for `HAPI_SESSION_IDLE_TIMEOUT_MS` (default
12h, `0` disables), and back to `running` on the next real progress.
`active` is deliberately not flipped: the socket really is up, and a false
`active: false` would let `resumeSession` spawn a second agent against a live
process and would unlock dedup-merge / delete on it — the #1705 class of bug.
- `sessionCache.recordAgentProgress` tracks a progress clock that keepalives
can never touch: messages in either direction, queued prompts, background
tasks. Seeded from `updatedAt` so a hub restart re-derives it from disk.
- `reconcileKeepaliveIdle` runs on the existing 5s inactivity tick. Sessions
that are thinking, running background tasks or holding a pending permission
request are never marked; `metadata.idleReconcileExempt` opts out entirely.
- Only real progress clears `idle`, never `thinking` — Cursor ACP emits
ambient idle/running churn (#1553) that would otherwise flap the lifecycle
every tick.
- `isLiveLifecycleState` replaces the `=== 'running'` literals so an `idle`
row does not read as dead to the archive route or the Cursor migrator.
- Web splits keepalive-only sessions into their own session-list bucket.
Refs #1820. Distinct from #842 (`active=false` with a stale `running` badge);
this is the inverse.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(hub): seed the idle clock from stored messages across hub restart
PR review on #1821: a cold `SessionCache` had no observed progress and fell
back to `updatedAt` alone. Assistant messages deliberately do not move
`updatedAt`, so a session that was streaming output a minute before a hub
restart read as hours idle and got marked on the very next tick — a false
`idle` that persisted until the next progress event.
Seed the clock once from `getNewestMessagePosition`, which is the durable
record of that output. `updatedAt` stays a floor on top of the seed: todos /
teamState / agentState writes bump it without routing through
`recordAgentProgress`.
Regression test recreates a SessionCache over the same store after recent
assistant output and confirms keepalives no longer cause premature idle
classification — and that the same session is still marked once the output
itself goes stale.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: cover the socket wake-up path and the idle session-list bucket
Follow-up review on #1821 reported no findings but flagged two uncovered
paths. Both have bad failure modes, so close them.
- Hub: an assistant message arriving through `registerSessionHandlers` must
wake an idle session. Assistant messages never bump `updatedAt`, so the
`onAgentProgress` hook is the only thing that can clear the mark — a
refactor dropping that wiring would silently false-idle every actively
working session, and nothing would have caught it.
- Web: extract the inline bucketing from the `runningSessions` useMemo into a
pure `bucketRunningSessions`, matching how the rest of SessionList's logic
is exported and tested. Covers idle vs quiet-but-ready separation, no
double-bucketing, the transition back out of idle on thinking/background
task/pending request, and the disconnected/pinned skips.
No behaviour change.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: cover the idle lifecycle at both isLiveLifecycleState call sites
Third review round reported no findings but named the two guards this PR
changed as uncovered. Both protect against reading a keepalive-idle row as a
dead one:
- Cursor migrator: an idle-marked row still has a live CLI behind it, so
preflight must refuse it without `forceArchiveRunning`. A regression here
would let the migrator transplant the ACP session directory out from under
a running Cursor runner.
- Archive route: once a keepalive-idle session finally loses its socket it
must stay archivable, rather than being stranded behind a 409.
Both tests were confirmed non-vacuous by reverting each guard to the
`=== 'running'` literal; each then fails.
No behaviour change.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: stop the restart regression leaking its temp DB on failure
The file-backed store's `rmSync` was a trailing call, so any failing
assertion above it skipped cleanup and left a temp DB behind — one per failed
run. Noticed while verifying the test was non-vacuous, which deliberately
fails it.
Wrap in try/finally. Verified: a forced failure now leaves zero
`/tmp/hapi-1820-*` directories.
Test-only.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
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>
* fix(web): keep pin dividers coherent under search relevance sort
Address tiann's #1842 review: preserve project-pinned sections when
ranking, drop ineffective single-term IDF, add title phrase bonus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): add component regression for search pin dividers
Render tiann's four-session repro under query "home" and assert the
session list draws at most one project pin divider.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): rank session list search by field weight and IDF
Boolean match + recency buried distinctive title hits under ubiquitous
path segments like /home/. Keep paths searchable; score title higher and
down-weight terms that match most of the corpus. Boundary match so home
does not hit homelab.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): use boundary match as search score bonus, not a gate
sessionMatchesQuery is shared with @-mentions and the share picker; keeping
substring inclusion restores as-you-type. Boundary affinity multiplies the
field×IDF score instead. Path parts stay separate for wildcards; match set
is explicit so filtering never reads score > 0.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pick search field hit by post-bonus contribution
Raw field weight could beat a lighter boundary hit that scores higher
after the ×1.75 multiplier. Compare contribution after the bonus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): drop invalid updatedAt from summary fixture
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Add current-proposal decoding, dedicated implementation requests and composer-only continuation on iOS and Android. Keep pending/errors across row recycling, reject stale actions and prevent duplicate submissions.
Include native regression tests and localized plan controls.
* fix(web): force-dismiss indeterminate queued rows on cancel/edit
When cancel returns busy for an already-indeterminate QueuedMessagesBar
row, keep the optimistic removal instead of re-sticking the row so Edit/X
cannot leave the UI dead. Upgrade busy to invoked when queued-state
reconcile shows the steer already landed, so Edit toasts instead of
prefilling a duplicate.
Fixes#1839
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): never prefill Edit when queued cancel returns busy
Indeterminate rows can still be a live dispatch. Prefilling on busy
risked a duplicate send if the original steer later landed. Keep
force-dismiss for Cancel; toast on Edit+busy without restoring the draft.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep force-dismissed indeterminate rows for late consume
Hide abandoned outcome-unknown rows from the queued bar via
queueDismissed so Cancel/Edit still clear the stuck chip, while leaving
the message in the window for a later messages-consumed SSE.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): close queueDismissed races on cancel busy and requeue
Restore the hidden hold before getQueuedState so concurrent consume
SSE can land, skip overwriting it in onSuccess, and clear dismissal
when markMessagesRequeued confirms FIFO again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): undismiss force-held row when cancel busy finds FIFO
If getQueuedState reports the localId is queued again after a failed
steer, clear queueDismissed so Edit/Cancel return without prefilling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): preserve queueDismissed across refresh and merges
Force-dismiss of indeterminate rows survived Cancel, but latest-page
refetch and same-ID merges dropped the client-only flag. Carry it while
the authoritative row remains uninvoked and indeterminate.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): clear queueDismissed on reconnect FIFO reconcile
Reconnect queued-state reconciliation now markMessagesRequeued for
confirmed queuedLocalIds so force-dismissed rows regain Edit/Cancel
after a missed messages-requeued SSE.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Group ordinary Codex commands with default tools across web, iOS and Android while preserving exploration and user-shell boundaries.
Add regression tests, generated protocol fixtures and shared-command coverage in the iOS transcript UI suite.
Inject getAccessToken into FcmService for unit tests instead of a
process-wide mock.module that stubbed loadServiceAccount without
project_id and broke resolveFcmConfig / androidPushConfig when the
suite ran after fcmService.
Fixes#1832
Co-authored-by: Cursor <cursoragent@cursor.com>