mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
9384ce031b9cea30c19cccf0ced97e116ee3fdbb
234
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
01dabad204 |
fix(sessions): stabilize ordering by latest user input
Persist and backfill the last user-message timestamp, and use it for deterministic session ordering across web and native clients while preserving manual pins. Remove the fixed-order setting and guard ordering against transcript echoes, replayed messages, and stale REST/SSE updates. Add regression coverage and regenerate the SSE fixture. |
||
|
|
5fd1179d2d | fix(hub): repair v24 migration test fixture | ||
|
|
23122ccbc0 |
feat(hub): persist agent turns and report them through hapi usage
Whether a session's agent is thinking lived only in the session cache, so once a turn ended nothing could say how long the agent had worked, and a hub restart forgot even the turn in progress. Tools that track time (Weft, for one) had no way to see when agents on other machines were busy. Schema v28 adds session_turns: a row opens when a session starts thinking (a prompt queued through the hub, the agent reporting it, or a patch) and closes when it stops, the session ends, or its keepalives expire, at the last keepalive. Rows are only written on those transitions, never per keepalive. Turns move with a session when it is merged away. At startup the hub closes turns left open by the previous process at the session's newest message. If the agent carries on thinking within 10 minutes of the restart, the same turn continues, so the work between that message and the restart isn't lost. The deadline counts from the restart, and a keepalive or patch reporting idle, the session ending, or a new prompt ends the chance: those start a new turn. GET /api/activity returns, for the caller's namespace, each session's turns in a window (default 24 h, at most 31 days) with what identifies it (flavor, agent session id, machine, path, title) and the permission prompts and questions it waited on, taken from agent_state. `hapi usage [--since] [--until] [--json]` prints a summary or a hapi.activity.v1 report for other tools, with distinct exit codes for bad arguments (2), missing or refused credentials (3) and a hub too old to answer (4). The summary counts only the part of each turn inside the window and subtracts overlapping waits once. It reuses the peer commands' token exchange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e733dffe10 |
Prevent duplicate replay fanout and bound stalled SSE writes (#2223)
* Prevent replay fanout and bound stalled SSE writes * Handle streams aborted during replay * Preserve repeated consumption acknowledgements * Bound SSE queues by size while allowing short bursts |
||
|
|
bce61e5048 |
hub: extend offline-detection grace for thinking sessions (#1877)
A session's keepalive is emitted over a volatile socket channel, so any transport outage longer than the 30s expiry window flips the session offline even though the CLI process is alive mid-turn and its outbound messages are buffered for delivery on reconnect — the session comes back the instant the socket reconnects, which surfaces to clients as a spurious "offline during thinking" state. Give sessions with thinking=true a 5-minute expiry grace instead. The grace is bounded, resets to the plain 30s timeout as soon as a keepalive reports thinking=false, and genuinely dead processes still expire on the extended timeout. via [HAPI](https://hapi.run) Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
009e6d7799 |
feat(codex): allow historical forks while a turn is running (#2185)
* feat(codex): allow historical forks while a turn is running * fix(web): fork user-only completed turns during generation |
||
|
|
575e995480 |
feat(hub): generate fallback titles after the first completed turn (#2075)
* feat(hub): generate fallback titles after the first completed turn * fix(hub): recognize batched first-turn title prompts * fix(hub): track heartbeat-only first turns for title fallback * fix(hub): preserve title turn identity across restarts |
||
|
|
577ab7546b |
feat(sessions): add explicit per-session title locks (#2072)
* feat(sessions): add explicit per-session title locks * fix(sessions): preserve title locks across rename entry points |
||
|
|
ae147e85db |
feat(opencode): import local session history (#1677)
* feat(cli): list local OpenCode sessions over machine RPC * feat(hub): import OpenCode sessions into HAPI * feat(web): import OpenCode history from new-session picker * fix(cli): derive OpenCode imported message ids from part ids Random ids made every scan differ, so delta classification flagged unchanged transcripts as diverged on re-import. * fix(cli): surface unreadable OpenCode db as rpc error A locked or unopenable opencode.db now fails the listing instead of reporting an empty success; summaries normalize timestamps and the web import toast counts only created or actually-appended sessions. * fix(hub): fail closed on opencode re-import past live HAPI continuation Imported entries must remain a strict prefix of the native transcript. Insertions ahead of the imported boundary or new native messages after the session continued in HAPI now fail closed instead of appending duplicated or reordered history. * fix(web,hub): apply selected launch config when importing OpenCode history Pass the model, reasoning effort, and permission mode chosen in the new-session picker through the import request and persist them on the imported HAPI session via applySessionConfig, so reopen launches with the visible selection instead of stale or default values. * fix(hub,cli): preserve explicit launch resets and skip streaming parts on import - Launch config keys are applied by presence, so explicit null/default selections from the picker must reach applySessionConfig to clear stale model, effort, or permission values on re-import. Requests that omit the fields entirely still leave persisted config untouched. - Assistant text parts are mutable while a response streams. Mirror the live scanner gate and import only finished (time.end set), synthetic, or user text so an in-progress row can never be stored and later finalized as transcript_diverged. * test(hub): fix launch-config reset assertions to satisfy strict types * fix(cli): fail opencode transcript reads on query errors instead of partial snapshots Swallowed SQLite errors could return a truncated message list that the hub later rejects as transcript_diverged with no repair path. Full transcript reads now propagate query failures; summary lastUserMessage extraction stays resilient per message. * test(cli): cover unreadable-part-table transcript read stays non-partial * fix(hub): scope launch-config failures to the per-session import result A thrown applySessionConfig call used to escape the bulk handler as an unstructured 500 even though transcripts were already persisted. Catch it per session and report it as a config_failed result so remaining sessions in a bulk request still import. Also cover the mid-transcript part-query failure with an injected opener that rejects instead of returning a holey transcript. * fix(cli): read opencode transcripts inside a deferred snapshot transaction Messages and their parts were read in separate statements, so a concurrent OpenCode write could leave a hole in the imported sequence that positional re-import checks then reject forever. Wrap each full transcript read in BEGIN DEFERRED/COMMIT with rollback on failure. * fix(cli): follow machine path policy for OpenCode imports * fix(web): use unified permission mode for OpenCode imports * fix(opencode): harden session import consistency * fix(hub): preserve metadata on rejected OpenCode imports * fix(hub): refresh OpenCode import state under lock * fix(hub): resolve OpenCode imports after locking * fix(opencode): bind imported resume to source machine |
||
|
|
ffd950a220 |
fix(codex): drop safe-yolo from Codex permission modes (#2183)
* fix(codex): drop safe-yolo from Codex permission modes Every Codex session runs on the native shared runtime (runCodex is runSharedCodex), which rejects safe-yolo. The mode stayed in CODEX_PERMISSION_MODES and six filters hid it again in the web picker, composer, hub permission-mode route, hapi set-mode and both native apps. API callers that validate against the flavor catalog (e.g. a spawn with permissionMode: 'safe-yolo') still passed it through and only failed inside the runner. Remove it from the Codex catalog (shared, iOS, Android, fixture) and delete the filters. autoReview is the native replacement. Safe Yolo stays for Gemini, Kimi and Copilot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ios): Codex option list now has four modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fe2fbb881d |
fix: list Claude background tasks from the snapshot instead of guessing (#2177)
* fix: list Claude background tasks from the snapshot instead of guessing
The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".
The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): only attribute a Bash command from its background launch result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(native): accept backgroundTasks in strict session patch decoders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
01f80e186a |
fix(peer-delivery): truthful accepted vs consumer-confirmed receipt (#2071)
ping-peer now sends a ping-peer- localId and reports hub-accepted instead of delivered; the hub exempts those rows from the session-end synthetic sweep so only a real runtime ACK confirms them; web shows pending vs delivered badges only on peer rows, excluding failed, indeterminate and scheduled states. |
||
|
|
4b3c5a4baf |
docs(api): clarify session ordering and client parity (#1983)
* docs(api): clarify session ordering and client parity * chore: retrigger PR checks |
||
|
|
4d137acdcb |
perf(hub): index session-list future-scheduled lookups (schema v27) (#1949)
GET /api/sessions runs countFutureScheduledBySessionIds and minFutureScheduledAtBySessionIds over every session id in the namespace. No existing index matches the full predicate set (invoked_at IS NULL AND local_id IS NOT NULL AND scheduled_at IS NOT NULL AND scheduled_at > ? AND delivery_state = 'queued'), so SQLite probes idx_messages_local_id — one pass over every local message, twice per session-list refresh, even though the matching set is almost always empty. Add a partial index on (session_id, scheduled_at) covering every constant predicate (schema v26→v27, base schema + step migration), turning both lookups into a near-empty index range scan that also serves the single-session variant. Measured on a long-lived hub DB (~20k messages, 37 sessions): ~8-10ms per query → ~0.04ms. Co-authored-by: Claude Code <noreply@anthropic.com> |
||
|
|
cd0b8e3589 |
fix(claude): stop locking Claude sessions to three hard-coded model aliases (#1948)
* feat(shared): derive Claude model choices from the CLI catalog The choices are the rows of the claude CLI's own /model picker, in its order and with its display names. The stored value is what claude --model receives: an alias is resolved again each time the session's claude process starts, a full id stays on its model, so following and staying need no new field. Effort support is read per model so a model switch can drop an effort level the next model does not offer. * feat(cli): discover Claude models from the CLI's initialize response A machine RPC starts claude in stream-json mode, sends only an initialize control request and reads the models it reports, so no turn runs and no transcript is written. claude --print runs a directory's project hooks and .mcp.json servers without a trust prompt, so the probe starts from the home directory with user settings only, no MCP servers and hooks disabled. Successful catalogs are cached per claude executable for five minutes and concurrent requests share one probe; failures are not cached. * feat(hub): expose the Claude model catalog per machine GET /machines/:id/claude-models forwards to the runner's listClaudeModels RPC. A runner that predates the handler answers with the stable rpc_target_missing code so clients can keep their built-in list. * feat(web): offer the machine's Claude models in the model pickers The composer and the create-session form list the rows the machine's claude CLI shows in its own /model picker, as it shows them. Built-in presets the catalog does not list remain selectable after it. Until the catalog arrives, or on a runner without discovery, the built-in presets are shown as before. A saved Claude model is no longer reset to Default when the built-in list lacks it; the create-session form keeps it and marks it as no longer listed when the catalog dropped it. * feat(hub,web): keep Claude effort within what the selected model offers For Claude sessions the model endpoint accepts an optional effort, so a model switch and the effort it keeps reach the session as one config change. Other flavors are refused: Pi, for one, commits the model before its thinking level. When the machine's catalog says the next Claude model does not offer the current effort, the composer clears it in that same request. The create-session form drops such an effort however the model was set, including a restored preference or draft. Effort choices in both places narrow to the selected model's levels; without a catalog every level stays available as before. * fix(web): retry Claude model discovery after a failed probe The machine answers a failed probe with success:false over HTTP 200. The query cached that as an answer for its stale time, so a user who signed in and reopened a picker within a minute still saw only the built-in models. A failed probe is now a query error, which is not kept, and the next picker to mount asks the machine again. * fix(web): list only the Claude CLI's models once its catalog is known The built-in presets were kept after the catalog so that a partial list could not hide Opus. The catalog is the CLI's own /model list, though, not a partial one, and the presets added entries the CLI does not show (Sonnet 1M, Opus 1M, Fable 1M). With a catalog the list is now exactly its rows; a saved model it does not list stays as the current model, and New Session marks it as no longer listed. Without a catalog the built-in presets are shown as before. * fix(shared,web): list earlier Claude versions under Other models Claude Code's model sheet shows each family's newest model first and every earlier version under an "Other models" heading. The CLI's catalog lists models in that order but does not say where the split is, so the first row of each family is taken as its newest and later rows of the same family as earlier versions. The composer's model list and New Session's model select now show the same two groups, keeping the CLI order within each. Nothing is folded: every row stays one click away, as it does in Claude Code. * test(web): pin that Claude 1M variants come from the CLI catalog Claude Code 2.1.105, 2.1.200, 2.1.240 and 2.1.274 list a 1M choice as its own catalog row (opus[1m], claude-fable-5[1m]); 2.1.303 and 2.1.310 list none, and on 2.1.310 opus and sonnet run with a 1M context window. The catalog-only list therefore offers a 1M variant exactly when the CLI does. Pin that and record why the built-in [1m] presets are not appended. |
||
|
|
3e68e29110 |
fix(codex): reuse live model catalogs and extend discovery cache (#1938)
* fix(codex): reuse live model catalogs and extend discovery cache Closes #1937 * fix(codex): track relative model catalog changes * fix(hub): expose missing Codex session model RPC code |
||
|
|
6ff9c35d58 |
fix(hub): advance the notify-cause resume watermark so ingest stays bounded (#1844)
Every assistant message that ends with an AGENT_NOTIFY_SUMMARY footer re-ingests the work_ad cause from the session message store. When a session runs many turns without a new invoked inbound (automation-style runners), the sticky path copied the previous cause verbatim, which also froze the resume point (causeSeq + causeCursorMessageId) at the original inbound. Each subsequent notify then re-loaded and re-parsed the entire session tail since that inbound — content decode is zstd + JSON.parse per row, so the cost grows without bound as the session grows and blocks the hub event loop for seconds per notify. Measured on a self-hosted hub with a ~10k-message session whose last consumed inbound sat at seq 891 (v0.29.1): every notify footer stalled /health for ~4.7s and cost ~5000 page reads, repeating per notify. With this change the first notify pays a one-time catch-up scan; the resume point then advances past each assistant turn and subsequent notifies complete in the low milliseconds. - resolveWorkAdCause: on the sticky path, advance causeSeq and causeCursorMessageId to the current assistant message while keeping the cause identity (messageId/text/kind). The advance is skipped while unconsumed queued or unmatured-scheduled inbounds still sit below the watermark — those can mature into a later cause and must stay inside the scan window. - loadMessagesForCause: when the cursor row was trimmed away by retention, resume after the numeric causeSeq watermark (validated against the session's current minimum seq) instead of degrading to a full session read on every later notify. A history move (mergeSessionHistory) empties the session and restarts its seq space, so a stale low watermark still selects the full read there. - ingestNotifySummaryFromMessage: resolve the assistant seq by message id instead of scanning the loaded window, which after the watermark advance no longer contains the notify-bearing row. - store: add getMinSeq helper for the watermark validation. Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
5d7e13fc5d |
fix(hub,cli): keep inactive file previews available through runner (#1727)
* fix(hub,cli): keep inactive file previews available through runner * fix(cli): bound runner workspace file reads * fix(cli): constrain runner git fallback pathspecs * fix(cli): allow dot-prefixed workspace paths |
||
|
|
0aec975434 |
fix(codex): gate history actions until native thread is ready (#1604)
* fix(codex): gate history actions until native thread is ready * fix(codex): clear history capabilities during bootstrap * style(codex): normalize CLI file line endings * fix(codex): await history capability persistence * fix(codex): restore goal status coverage * fix(codex): await bootstrap metadata persistence * chore: retrigger PR review * fix(codex): scope metadata readiness flush * fix(codex): gate metadata flush by stale history |
||
|
|
57d09c9796 |
fix(hub): stop routine agent-state updates from bumping session updatedAt (#2210)
Accepted agent-state writes (Codex usage snapshots, controlledByUser, request completion) set updated_at = now, so idle sessions showed "just now" and reordered the web active-session list. Only advance updated_at when the update adds a new permission request. Fixes #1855 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
927b4971b9 |
feat(dsh): model selection for DeepSeek Harness (ACP config options) (#2030)
* feat(acp): flatten Zed-style grouped configOptions choices
DeepSeek Harness 0.2.x (and Zed-style agents generally) nest select
choices one level under group entries that carry no `value` of their
own:
options: [{ group, name, options: [{ value, name }] }]
Both the per-session config-option descriptor and the model-metadata
merge previously read only flat `{ value, name }` entries, so grouped
pickers surfaced zero choices. Flatten recursively in a shared helper;
flat options are unaffected.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* feat(dsh): runtime model switching through ACP config options
dsh advertises its model catalog as a Zed-style grouped config option
(provider groups with JSON-encoded provider+model tuple values) instead
of session/set_model, so wire it through the generic config-option rail:
- shared: ListDshModels/ListDshModelsForMachine RPC methods, response
types, and grant dsh the ModelChange flavor capability
- cli: DshMode becomes {model} and rides the queued-batch mode like
other flavors; the launcher applies model switches per-turn via
session/set_config_option, discovers the live current model from the
config option after newSession, rolls back (and warns) when a switch
is rejected, and serves ListDshModels from session metadata. A
machine-scoped probe (60s single-flight cache) backs the new-session
catalog; --model is now accepted by the dsh command
- hub: dsh-models passthrough routes (machine + session scope)
Co-Authored-By: Claude Code <noreply@anthropic.com>
* feat(web): dsh model selector for new sessions and in-chat switching
New-session form gets a Default-plus-catalog picker fed by the machine
probe (opaque tuple values verbatim, friendly names as labels, stale
draft picks dropped when the catalog rotates). Running sessions get the
same catalog over the session-scoped hook; switching rides the existing
generic model-change path now that dsh advertises ModelChange.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(dsh): honor explicit Default reset and cover pending catalog discovery
The launcher now captures the server default before applying any spawn
override, and a queued `model: null` (the picker's "Default" after an
explicit switch) switches back to it instead of silently keeping the
last applied model; DshMode keeps null distinct from undefined all the
way from the config RPC through the queue (runDsh no longer collapses
it). The web model-option helpers get explicit dsh branches mirroring
opencode: pending, failed, or empty ACP discovery renders no Claude
presets and the Ctrl/Cmd+M cycler keeps the current model. NewSession
restores dshSelectedModel from preferred launch settings alongside
agy/opencode (the existing catalog-validation effect drops stale tuples)
with remount, forward-on-create, and fallback tests.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(dsh): keep unset model preferences distinct from Default resets
Two review follow-ups:
- currentModel collapses "no preference yet" into null, so a session
started without --model on a server that reports no current model
queued model:null on every ordinary turn — the launcher read each one
as an explicit "Default" reset and emitted 'Cannot reset the DSH
model' every turn. The preference is now three-state (undefined =
nothing requested, null = picker's explicit Default, string = model),
encoded through dshModeForPreference() so preference-less turns carry
no model field at all.
- ListDshModels was registered only after initialize/session/new and
discovery finished; a picker opened during startup hit an
unregistered-method rejection with no retry path and stayed empty
until remounted. The handler is now registered before initialization
and parks on a readiness promise resolved once the session exists and
discovery has settled (rejected with the startup error otherwise).
Co-Authored-By: Claude Code <noreply@anthropic.com>
* docs: DSH advertises model selection over ACP
The capability blurbs still said HAPI exposes no model pickers for DSH and
that the ACP server has no model switching. Update them to the wire reality:
models are listed via ListDshModels and applied per turn with
session/set_config_option. Resume remains out of scope on this branch.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
|
||
|
|
ad13695678 |
fix(hub): single-flight cache for session enumeration RPCs (#2032)
* fix(hub): single-flight cache for session enumeration RPCs GET /api/sessions/:id/slash-commands and /skills proxy ListSlashCommands/ ListSkills to the session's runner/engine with the default 30s RPC timeout. When the engine is mid-turn (or its socket is reconnecting), these RPCs queue for tens of seconds (observed 20s+ in production) while clients re-fetch both enumerations on every session open. Enumerations change rarely, so serve them through a small cache: - concurrent callers share one in-flight fetch (single-flight) - a result younger than the TTL is served without any RPC - a stale result is served immediately while it revalidates in background - a cold fetch is capped at 8s; on timeout/failure the last good result is served instead (only success results are cached) Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(hub): release enumeration cache entries when sessions leave the active set Review follow-up: cached catalogs were kept forever under their session keys, so the per-session entries accumulated for the hub's lifetime. Add SessionEnumerationCache.release(key) (a late in-flight success lands on the detached entry and cannot resurrect the key) and call it from deleteSession and at the end of archiveSession, bounding the cache by the live session count. Two lifecycle tests cover both paths. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(hub): release composite enumeration keys, not the bare session id Review follow-up: cache insertion keys are `${sessionId}|${agent}` composites, so releasing the bare session id matched no entry and the lifecycle cleanup was a no-op. Replace release(key) with releaseSession(sessionId), which prefix-deletes every composite key of the session (session ids cannot contain '|'), and rework the tests to prime composite keys — including one asserting another session's entry survives the release. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(hub): release enumeration entries on merge- and cleanup-path removals Review follow-up: three flows removed sessions through sessionCache directly and bypassed the enumeration release — the resume-replace merge (mergeSessions deletes the old row), and the two pi resume cleanup paths that drop temp/child rows. Release the removed session's entries at each site. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(hub): release enumeration entries for dedup merges via a merge hook sessionCache's internal deduplication (agent-id collisions) also deletes rows through mergeSessionData, but nothing told the enumeration caches. Centralize instead of chasing call sites: SessionCache gains an optional onSessionMergedAway hook fired at the single mergeSessionData deletion point, and SyncEngine wires it to releaseSessionEnumerations. The manual release after the resume-rotation merge is now redundant and is removed. Pinned by tests on both sides of the hook: dedup collapsing an inactive duplicate fires it with the removed and surviving ids; mergeSessionHistory (source row survives) does not. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> |
||
|
|
7b5beee6e7 |
fix(hub): mint a localId for user messages sent without one (#2069)
A REST send without localId was stored with local_id NULL. Shared Codex then gave the turn a random clientUserMessageId and echoed the user message back under it; the hub dedupes only on (session, localId), so the echo became a second row and the message showed twice. Mint a localId in MessageService.sendMessage when the request omits one, so the stored row, the CLI delivery and the agent echo share an identity. Such sends now take the normal queue/ack/cancel path, like typed web sends. Fixes #2068 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
61c7bcc6f8 |
fix(runner): preserve slow Codex sessions across startup deadlines (#2078)
* fix(runner): preserve slow Codex sessions across startup deadlines * fix(runner): clean failed startup worktrees after child exit |
||
|
|
6b3912dc56 |
fix(hub): judge liveness by hub receive time, not runner clock (#1986) (#2089)
machine-alive and session-alive set activeAt from the CLI-supplied timestamp, while expireInactive compares activeAt against the hub clock. A runner whose clock lags the hub by more than the timeout minus the heartbeat interval was expired seconds after every heartbeat, producing the periodic active:false/true flapping. Use the hub receive time for activeAt; client time still drives thinkingAt and runtime-config ordering. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1488be153a |
feat(cli): add hapi set-mode to change a session's permission mode (#2094) (#2095)
* feat(cli): add hapi set-mode to change a session's permission mode (#2094) The CLI could only pick a permission mode at launch; changing it on an existing session required the web UI or a hand-rolled JWT + curl call. `hapi set-mode <session-id-or-prefix> <mode>` resolves the session like ping-peer / inspect-peer, validates the mode against the session flavor's catalog (printing the allowed list on mismatch), and calls the existing POST /api/sessions/:id/permission-mode route. Active sessions switch live; inactive ones keep the mode for the next resume. No MCP tool is added: raising a peer's mode stays behind the agent's normal shell-approval path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: reject Safe Yolo for shared sessions on the permission-mode route Shared (concurrentClients) Codex sessions cannot launch with safe-yolo. The web picker hides it, but the route only checked the flavor catalog, so `hapi set-mode` (or any API client) could store it on an inactive shared session and break its next resume. Guard the route and apply the same filter to the CLI's allowed-mode list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4254942aeb |
fix: show Claude sessions waiting on background tasks (#2100) (#2102)
* fix: show Claude sessions waiting on background tasks (#2100) backgroundTaskCount was always 0 for Claude: the hub parsed the "Command running in background" tool result only from tool_result / assistant records (Claude sends it as a user record), and waited for a <task-notification> user record that current Claude Code never emits. Set the count from Claude's system/background_tasks_changed snapshot, which the CLI already forwards and which lists every running background task (empty once the last one finishes). In the session list, waiting on background work now shows a blue spinner instead of a static blue dot (too close to the "needs input" dot) or the green "running" pulse used for selected and pinned rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): forward background_tasks_changed snapshots to the hub OutgoingMessageQueue only sends chat-visible records, so Claude's background task snapshots never left the CLI. Forward that one hidden subtype, and have the hub apply it as session state without storing a hidden message row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): draw the background spinner with the thinking spinner code Both row spinners now render through one SessionSpinner; background work only switches the color to blue and no longer adds a separate Running label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3eadf65f21 |
perf(hub): cached settings reads (SWR + single-flight) for hot paths (#2122)
Per-request settings reads hit the disk on every call: each flag helper re-reads the whole settings.json, so a 15-session reconnect fan-out on POST/GET /cli/sessions queued ~45 file reads (existsSync + readFile + JSON.parse) on the single hub event loop — during engine long turns those trivial probes measured 1-9s, and page-load bursts stretched the settings GET / voice-credential status endpoints to 2-4s as well. Add a settings read cache (createSettingsReadCache) with stale-while-revalidate semantics and swap the hot readers onto it: - fresh (within TTL, default 5s) -> memory hit, zero disk I/O - stale -> stale snapshot returned immediately plus ONE single-flight background revalidation (a probe storm costs at most one disk read) - cold -> await the shared in-flight read - writes from this process -> write-through refresh from the writeSettingsUnlocked funnel (PUT /api/hub-settings and every writeXxxEnabled helper), so readers see mutations instantly; a generation counter keeps a late-landing revalidation that raced a write (stale inode) from stomping the newer value - corrupt file (parse null) -> evict, next read surfaces the error loudly, matching pre-cache semantics updateSettings / writeSettings still read authoritatively under the lock; only read paths use the cache. External hand-edits of settings.json converge within the TTL. Swapped readers: readSessionSummaryContractEnabled, readSessionSummaryInChatEnabled, GET /api/hub-settings, getTranscriptionCredentialStatus. Tests: 6 unit cases on the factory (cold single-flight, fresh hit, stale serve + single revalidation, revalidation failure keeps snapshot, parse-null eviction, write-through beats late revalidation) plus a real-file integration test pinning write-through visibility and the memory-hit proof. Full hub suite green. via [HAPI](https://hapi.run) Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
7b00df458c |
fix(voice): configurable ElevenLabs max conversation duration (#2017)
* feat(voice): configurable ElevenLabs max conversation duration Auto-created ConvAI agents never set conversation.max_duration_seconds, so ElevenLabs applied its 600s default and cut voice conversations off. Add ELEVENLABS_MAX_DURATION_SECONDS (default 1800, validated 60-7200). The cap is applied when the hub creates an agent and reconciled onto existing hub-managed agents (found by name) once per process. Operator-supplied agents (ELEVENLABS_AGENT_ID / voice map / customAgentId) keep their own cap. Invalid values fail the token request with a clear message instead of an API 422. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(voice): Settings UI for ElevenLabs max conversation duration Extend the hub-owner credentials panel (Settings -> Voice -> manage providers) with a numeric field for ELEVENLABS_MAX_DURATION_SECONDS, reusing the same env-wins-over-Settings overlay that already backs the ElevenLabs API key field. A Settings save takes effect on the very next token request in the same process; the env var still requires a restart. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(voice): reconcile max duration on cached agents, not just once per process Codex review on heavygee/hapi#230 caught this: getOrCreateAgentIdForVoice's agentIdCache short-circuited before ever reaching ensureAgentOverrides, and ensureAgentOverrides's overridesEnsuredAgents gate was a one-time-forever marker that didn't account for the configured duration changing after the first reconciliation. Together, any voice already used once in the hub process would keep its old cap until a restart, contradicting the Settings UI's 'takes effect immediately' claim. Track the last-applied max duration per agent and compare it against the live setting on every call, including agentIdCache hits, so a Settings change reaches an already-cached agent on its very next token request. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(voice): address tiann/hapi#2017 review findings - Fix TS2769 typecheck failure: test assertions casting the credentials response narrowed the type to omit 'editable', which upstream CI's bun typecheck catches and this fork's (currently broken) CI did not. - An invalid ELEVENLABS_MAX_DURATION_SECONDS env value threw out of buildMaxDurationStatus(), which is shared by every provider's GET/PUT credentials status — breaking visibility into OpenAI/Deepgram/ Groq settings too, not just ElevenLabs. The token-issuance hot path (getConfiguredElevenLabsMaxDurationSeconds) still throws there by design; only the status/update endpoints now degrade gracefully. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(voice): interpolate duration range in the Settings hint Nit from tiann/hapi#2017 follow-up review: the hint under the max-duration field rendered the literal '{min}-{max}' placeholders instead of '60-7200'. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
86c88df93b |
fix(runner,hub): cross-platform orphan reap on archive / tracking loss (#1911)
* 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> |
||
|
|
b6849c1d35 |
fix(codex): reject history sync for active sessions (#1895)
* fix(codex): guard transcript sync for active sessions * test(codex): exercise divergent transcript prefix |
||
|
|
b6a27bdc68 |
fix(kimi): discover configured models dynamically (#1873)
* 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. |
||
|
|
8f9d6bd7f8 |
fix(hub): keepalive-only sessions reconcile to an idle lifecycle (#1820) (#1821)
* 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> |
||
|
|
d14683a4b2 | perf(hub): cache prepared statements and memoize per-socket session access (#1851) | ||
|
|
6c69e2f315 |
fix(cli,hub): finalize canceled permission requests and surface stale answers (#1854)
* 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>
|
||
|
|
e0db9c6815 | fix(notifications): distinguish user questions from permission requests | ||
|
|
917baf1201 | fix(codex): sync shared plan proposals and web actions | ||
|
|
051dbbd7da | docs: refresh native app guides and contracts | ||
|
|
1de9613df6 | docs: align documentation with current implementation | ||
|
|
4d92afaf36 |
fix(hub): stop fcmAuth mock.module from poisoning later tests (#1833)
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> |
||
|
|
6f4aacce6b | feat(android): enable default push through official relay | ||
|
|
0c4abcb3d1 |
feat(codex): share sessions across terminal and web
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. |
||
|
|
68422ed6d3 |
fix(web): use conversation content for session reference eligibility (#1800)
* fix(web): base session reference eligibility on conversation content * fix(hub): refresh replacement content after clear abort * fix(hub): refresh content on idempotent clear aborts |
||
|
|
fd2822bab5 |
fix(agy): allow switching existing sessions to newly available models (#1814)
* 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 |
||
|
|
7031d60eb4 |
fix(opencode): expose model-specific reasoning effort options (#1716)
* fix(cli): discover opencode thought_level via set_config_option on model switch * fix(cli): apply and refresh opencode model switches so thought_level stays discoverable * fix(web): track opencode effort options across model switches * feat(cli,hub,web): dynamic opencode effort options in new-session form * test(cli): avoid platform-specific process event narrowing * fix(opencode): address variant discovery review findings * fix(opencode): synchronize effort options with model targets * fix(opencode): roll back rejected model targets * fix(cli): guard opencode variant probe workspace paths * fix(web): clear stale opencode effort on model switch * fix(cli): clear stale opencode effort metadata * fix(web): reset stale effort options on model switch * fix(web): reset opencode effort poll budget * test(web): enforce opencode effort poll budget |
||
|
|
17ee052d9a |
fix(web): preserve the visible chat window during rewind (#1766)
* fix(web): preserve chat window during rewind * fix(web): scope rewind invalidation preservation * fix(web): clear unknown rewind boundaries * fix(web): deduplicate rewind invalidations * fix(web): retain rewind dedupe history |
||
|
|
890b487428 | fix(hub): seed forked session summaries (#1777) | ||
|
|
00a1d24fd0 | Fix concurrent APNs JWT generation and preserve error reasons | ||
|
|
3873e58496 |
fix(web): keep streamed reasoning/text block ids stable across snapshot rows (#1741)
* fix(web): keep streamed reasoning/text block ids stable across snapshot rows
Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.
Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.
Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.
* fix(ios,android): mirror stream-stable block ids in native chat ports
The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.
Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.
* fix(web,ios,android): reject blank stream ids as block identity
Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.
Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.
* fix(ios): use normalized stream id for block construction identity
The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.
Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.
* fix(web): pin blank stream-id identity contract in golden fixtures
Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.
* feat(hub): make title provider max_tokens and timeout env-tunable
Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.
Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.
* docs(hub): document title provider max_tokens/timeout env knobs
Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
|
||
|
|
6bad07614b | fix(hub): keep ambiguous cancels indeterminate (#1750) |