Runner injects HAPI_AGENT_CONTEXT into session CLIs; TerminalManager was
forwarding it into human-facing web Terminal shells, so operator tools
(lane B auth, stack-switch guards) refused a real person. Strip agent
markers, keep HAPI_SESSION_ID, set HAPI_USER_TERMINAL=1.
Fixes#2013
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): stamp and reap orphan HAPI MCP bridge helpers
MCP stdio bridges survive agent-root death (OOM / partial kills) and
were invisible to #1911 argv orphan reap. Stamp --hapi-session-id on
bridge spawn and match mcp --url helpers in orphanReap.
Fixes#2003
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): use ApiSessionClient.sessionId for MCP bridge stamp
bootstrapSession returns ApiSessionClient directly; session.client was a
typecheck failure that broke CI typecheck:cli on #2004.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: retrigger Codex PR review after 503 infra failure
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(android): add Russian locale
Translate all 518 app strings into values-ru/strings.xml, register ru in
locales_config.xml, and add AppLanguage.RUSSIAN to the in-app language
picker.
* fix(android): use count-agnostic Russian forms for dynamic counts
The *_many strings used a genitive-plural noun after the number, which is
wrong for 2-4 (e.g. "2 инструментов"). The house style avoids <plurals>,
so phrase these labels with the noun first ("Инструменты: %1") instead:
tool groups, review findings, queued/new messages, agent steps, questions,
lines, chars, edits, usage requests, scratchlist count and attachment cap.
* fix(android): make the process-steps label count-safe
* fix(android): use a count-safe token label in the usage tooltip
---------
Co-authored-by: mrudy <mrudy@local>
* feat(web): add Russian locale
- Add web/src/lib/locales/ru.ts
- Register 'ru' in the locale registry, i18n context, language switcher and settings
- Format outline timestamps with the day.month convention for 'ru'
* feat(web): detect initial locale from Telegram and browser
The Telegram Mini App runs in its own web view, so a language chosen in
the browser does not carry over and the app always opened in English.
Pick the first session locale from an explicit stored choice, then the
Telegram user's language_code, then navigator.language, then English.
* feat(web): translate the newly localized strings
Russian values for the keys added by the hardcoded-string fix (see the
separate web i18n PR). Keeping them on this branch so the locale PR is
complete when both land.
* feat(web): add Russian for the newly localized strings
* fix(web): recheck the Telegram language after a slow SDK load
* fix(web): keep a manual language choice over late Telegram detection
---------
Co-authored-by: mrudy <mrudy@local>
* feat(ios): add Russian locale
Add Russian translations to Localizable.xcstrings for all 591 catalog
entries (reusing matching Android translations and translating the rest);
zh-Hans and format placeholders are preserved.
* fix(ios): make Russian selectable and use correct plural forms
- add AppLanguage.russian ("ru"), its display name, the AppleLanguages
override, and register ru in the Xcode project's knownRegions so the
language picker can actually select Russian
- add Russian one/few/many/other plural variations for %lld sessions,
%lld attachments and %@, %lld sessions
- use count-agnostic phrasing for the other dynamic counts (tools, edits,
lines, chars, findings, notes, queued messages, questions, ...)
- correct Input/Output to Ввод/Вывод
- cover the new language case in ThemePrefsTests
* fix(ios): use Russian plural forms for the remaining count labels
* fix(ios): pass the step count to the plural localization
* fix(ios): pass tool counts to the plural localization
* fix(ios): keep the singular English tool-group labels
---------
Co-authored-by: mrudy <mrudy@local>
* 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>
* feat(web): add Ctrl/Cmd+Shift+D dictate keyboard shortcut
Toggle composer dictation or voice assistant via the same path as the
mic buttons, with dialog/input guards and tooltip documentation.
Fixestiann/hapi#1900
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): address dictate hotkey bot findings on PR 1902
Ignore key repeat on the session listener and align assistant-mode
invoke guards with UnifiedButton send mode (canSend).
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.
Links in a session open via window.open(_blank). For an installed
standalone-display PWA, navigating to a URL outside the manifest's scope
is a platform behavior (Chromium/Edge show it as a minimal overlay with a
close affordance), not custom code here — but closing it can mean the
underlying page had a real top-level teardown, not just an in-app remount.
useComposerDraft only ever persisted the draft in its effect's cleanup —
reliable for a React-managed unmount (switching sessions), but browsers
don't guarantee that cleanup runs to completion before a genuine
top-level navigation completes, so the save could simply never happen.
Extracts the existing save logic into persistNow() and calls it both from
the unmount cleanup (unchanged) and a new visibilitychange listener (when
document.visibilityState becomes 'hidden'), mirroring the pattern already
used elsewhere in this codebase (useSSE.ts, useAuth.ts, useSyncingState.ts,
usePwaUpdate.ts, useVisibilityReporter.ts) for the same class of problem.
Fixes#1882
Co-authored-by: Ian Stevenson <is@lockhouse.systems>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(web): setting to open external links in a new tab
Left-clicking an external http(s) link renders it with no explicit
target, defaulting to _self. In an installed/standalone PWA this gets
intercepted by the browser and shown as a captive in-app overlay
instead of leaving the app; in a normal tab it navigates away from
HAPI entirely. Right-click already opens in a new tab, but that's not
a fair ask for every link.
Add an opt-in Settings > Display > Links toggle that forces
target="_blank" rel="noopener noreferrer" on genuine external http(s)
links only (relative/SPA hrefs and other schemes like mailto are left
untouched). Defaults to off, preserving today's behaviour, since the
overlay may be intentional/liked by some users.
Vibe Coding disclosure: implemented with Claude Sonnet 5 (Claude Code).
* fix(web): simplify link-toggle setting description per review
The description mentioned the target attribute (an implementation
detail, not user-facing) and asserted the in-app overlay is what every
installed PWA shows, which was only actually confirmed on Chromium/
Edge. Describe the user-visible behaviour only, without promising a
specific rendering across all installed-app environments.
* fix(web): stop promising specific behaviour when installed as an app
Per follow-up review: describing the setting as "always" opening links
in a new tab "even when installed as an app" still guarantees a
specific outcome across every browser's PWA handling, which isn't
something this change can verify or promise. State only what the
setting does (open in a separate tab), not how every installed-app
environment will render that.
---------
Co-authored-by: Ian Stevenson <is@lockhouse.systems>
* 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>