Cursor ACP (and other remotes) flagged the glued-on SKILL_LOOKUP_INSTRUCTION
as prompt injection. Keep discovery on the skill_lookup MCP tool description
and on system prompts (OpenCode/Grok); do not taint user messages.
Fixes#1095
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): cap and back off consecutive remote launch failures
claudeRemoteLauncher's respawn loop retried claudeRemote() immediately
on every throw with no backoff or limit. A deterministic launch
failure (bad auth, invalid model/args, spawn failure) respawned in a
tight loop instead of giving up, hammering the same failure forever.
Track whether onReady() fired at least once per attempt to tell an
immediate/deterministic failure apart from a failure after real
progress, back off between immediate-failure retries, and after 3
consecutive immediate failures drop the message that keeps triggering
them and reset the streak, instead of respawning forever. The session
keeps running so a later, unrelated message still gets its own budget.
The streak reset on a non-throwing attempt is itself gated on having
reached onReady, not applied unconditionally -- otherwise a message
that keeps getting parked and re-picked-up on alternating attempts
(e.g. an isolated command hitting the same deterministic failure)
would reset the streak every other attempt and the cap would never
fire.
* fix(cli): restore queued message when remote launch fails before delivery
MessageQueue2.collectBatch() acks a message (fires onBatchConsumed,
which the hub uses to mark it consumed) at dequeue time, before the
message ever reaches the SDK. If claudeRemote() then throws before
onReady -- e.g. the process dies right after picking up the message --
the catch block only logged and retried, so the message vanished: the
hub already thinks it was delivered, but the CLI never acted on it.
Track the message returned from nextMessage() (whether freshly
dequeued or held in `pending` across a mode change) as in-flight until
the next onReady confirms it was handled, and restore it to the front
of the queue (preserving isolation via unshiftIsolated when needed) if
the attempt throws and will be retried. Restoring happens even if the
throw races with a user-initiated switch/exit, so a message is not
silently dropped by that unrelated shutdown either.
When the immediate-failure cap from the previous commit is reached,
the in-flight message is dropped instead of restored: unshifting it
back would just feed it into another immediate failure on the very
next attempt, storming again. This mirrors
cursorLegacyRemoteLauncher's existing drop-and-reset policy on its own
consecutive-failure cap.
* fix(cli): preserve localId when restoring a failed message batch
MessageQueue2.collectBatch() already collects each queue item's
localId (it fires onBatchConsumed with the full list to ack them), but
only exposed the joined `message` string to callers, discarding the
per-item localIds and their original boundaries in the process.
When claudeRemoteLauncher restores a dequeued-but-undelivered batch
after a launch failure, it re-added the joined string as a single new
queue item with no localId, orphaning the retried prompt from the hub
row(s) it originated from (and from cancel-by-localId).
Expose the pre-join `items` breakdown (message + localId per item)
alongside the existing joined `message` field on
collectBatch()/waitForMessagesAndGetAsString() -- purely additive, so
the other callers of waitForMessagesAndGetAsString() (grok, kimi,
opencode, cursor, codex, runAgentSession) are unaffected. On restore,
unshift each original item individually in reverse order, so the
localId and relative order of a multi-message batch are both
preserved instead of just the first item's.
* fix(cli): reset immediate-failure streak on a delivered non-onReady success
claudeRemote.ts's /clear handling delivers the queued message to the
SDK, then calls onSessionReset()/onCompletionEvent() and returns
successfully without ever calling onReady(). The success-path streak
reset only cleared on reachedReadyThisAttempt, so a successful /clear
between two unrelated immediate launch failures did not reset the
streak: an unrelated message's very next failure could hit the
3-in-a-row cap after just 1 failure, and the resulting banner would
misreport "3 times in a row".
Track whether nextMessage() actually handed a message to the SDK this
attempt (deliveredMessageThisAttempt), separately from whether the
attempt reached onReady, and reset the streak on either signal. The
livelock-prone case this guards against (a message parked into
`pending` and the attempt returning without ever delivering anything)
leaves both flags false, so it still does not reset the streak.
* feat(shared,cli): whitelist away_summary so auto recap reaches the hub
Claude Code's local TUI writes an automatic away-summary recap to the
session transcript on window blur/focus (5min+ idle), but
VISIBLE_CLAUDE_SYSTEM_SUBTYPES dropped it before it ever reached the
hub. Add it to the whitelist so the local launcher forwards it like
the other system subtypes, and cover the forwarding + Zod passthrough
of the recap `content` field with tests.
* feat(web): render Claude Code's automatic away recap in the chat
Once away_summary reaches the hub (previous commit), the web chat
still dropped it silently: normalizeAgent had no branch for the
subtype, so it fell through to `return null`. Add a `recap` AgentEvent,
a normalizeAgent branch mirroring the existing turn_duration/compact
subtype branches, and a presentation entry that prefixes the text with
`recap:` so it reads distinctly from the manual /recap assistant
bubble (which already renders as a normal message). No new render
component needed: it flows through the existing generic system-event
row (SystemMessage.tsx + getEventPresentation) that every other system
subtype already uses.
* fix(web): drop inaccurate manual-/recap comparison from recap comments
* perf(claude): scan transcripts incrementally
The claude session scanner re-read the entire transcript JSONL on every
scan, so the cost of each poll grew with the length of the conversation.
Track a byte offset per file instead and parse only the bytes appended
since the previous scan.
A trailing partial line — a write still in progress — is held back until
its newline arrives. A file that shrank resets the cursor to 0; the base
scanner's uuid dedup absorbs the re-sent events. A read that fails
returns no events and leaves the cursor where it was, so a transient
error is retried on the next scan rather than skipping content.
The codex scanner received this in #1031; this extends the same
improvement to the claude scanner. readSessionLog is exported for tests,
mirroring readTranscriptRange there.
* fix(claude): forward a complete final record with no trailing newline
The incremental reader consumed only through the last newline, so a final
JSONL record flushed without its terminating newline — at shutdown or on
import — was held back as if it were a partial write and never forwarded
until a later append supplied the newline. The previous whole-file reader
parsed such a record.
Consume a trailing segment when it already parses as a complete JSON value,
and keep holding back a genuinely partial line (which parses as incomplete).
* fix(opencode): stop treating empty tool input as final args
OpenCode emits input/rawInput as {} on tool start (and sometimes again
during permission), then fills real arguments on running/completed.
Treat empty objects as unusable so ACP and local hooks keep waiting for
real args, never clobber them, and ignore non-tool parts as fake results.
* fix(web): add exec timing fields to ToolCard test fixture
ChatToolCall now requires execStartedAt/execCompletedAt; update the
fixture so typecheck passes.
* fix(opencode): recover late tool-call after empty execute.before
Skip empty before under name-only queue pairing, emit tool-call on after when
still missing, and reject content JSON {} on ACP initial tool_call.
* fix(claude): consume the one-time --resume flag only once it is used or discarded
Reopening a remote Claude session and hitting a relaunch trigger (e.g.
/compact, or a mode/model/effort change) before the first turn is ever
processed loses the original --resume session id and starts a brand new
Claude session with no prior context. With /compact this also shows up as
the compaction ending immediately with "Not enough messages to compact.",
because the session it lands in is empty.
claudeRemoteLauncher called session.consumeOneTimeFlags() right after every
claudeRemote() call, including calls that returned before spawning Claude
(nextMessage() resolves null when the relaunch trigger arrives before any
turn was handled, so the message is parked as pending). That retired the
one-time --resume flag before the SDK ever had a chance to use it, so the
next launch started fresh instead of resuming.
Tie the flag's lifetime to the invariant it needs: retire it only once it
has been used, or once the context it points at has been explicitly
discarded. It is now consumed from onSessionFound (Claude reported a
session id back) and from onSessionReset (/clear dropped the context, and
/clear likewise returns before spawning Claude, so without this the flag
would outlive the reset and the next launch would resume the very session
the user just cleared). Attempts that reach neither outcome never touched
the anchor, so the flag survives for the next launch.
* fix(claude): report the actual /compact outcome instead of always success
When Claude cannot compact a session it says so on a system/status message
carrying compact_result: "failed" and a compact_error reason (for example
"Not enough messages to compact."), which arrives shortly before the result
message. The completion event was emitted from the result message alone, so
a compaction that did not happen was still surfaced to the user as
"Compaction completed".
Record the reported outcome when the status message arrives and use it when
the result message is handled, mirroring the wording the Codex launcher
already uses for the same situation ("Compaction failed: <reason>").
Only an explicitly reported failure is recorded: a status shape without
compact_result, or one reporting anything other than "failed", leaves the
existing success path untouched, so an unrecognised or unseen status can
never invent a failure.
hapi kimi local mode spawned the kimi TUI with no transcript sync, so
terminal conversations never reached the hub and the web UI stayed empty.
After the kimi-code rewrite (data moved from ~/.kimi to ~/.kimi-code),
model resolution also broke: hapi read the gone ~/.kimi/config.toml and
fell back to the invalid hardcoded default kimi-k2, and the KIMI_MODEL /
KIMI_PROJECT_DIR env vars it set no longer exist upstream.
Local sync (mirrors the codex transcript scanner):
- kimiWireLocator: derive the kimi-code workspace id
(wd_<slug>_<sha256(cwd).12>, ported verbatim from upstream workdir-slug),
poll for the session dir created by the just-spawned process, and watch
its agents/main/wire.jsonl. Pre-existing sessions are snapshotted and
excluded (awaited before spawn) so a retry cannot bind to a stale
session; multiple fresh candidates are refused as ambiguous.
- kimiWireScanner: incrementally read wire.jsonl and convert events into
hapi messages (user prompts/steers, assistant text/thinking, tool
call/result incl. is_error, step.end usage with cached input summed
into inputTokens).
- kimiLocalLauncher: attach locator+scanner, report kimiSessionId on
discovery (enables web resume and local<->remote handoff).
Model handling:
- config.ts: read <KIMI_CODE_HOME|~/.kimi-code>/config.toml (legacy
~/.kimi fallback); drop the hardcoded kimi-k2 default and the dead
KIMI_MODEL env source - when nothing is configured, omit --model so
kimi-code uses its own default_model.
- kimiBackend/kimiLocal: stop setting KIMI_MODEL and KIMI_PROJECT_DIR
(both unused by new kimi-code).
- kimiRemoteLauncher: apply the resolved model over ACP after session
creation (session/set_model, falling back to the advertised model
config option), and display the agent-reported current model instead
of the env guess.
Verified against live kimi-code 0.26.0: ACP initialize/session-new/
prompt probes, locator discovery of a running session, and converter
robustness over a real 800-line wire.jsonl.
* test: reproduce issue #786
* fix: load extra headers from settings (closes#786)
* test: cover extra header precedence and redaction
* fix: redact persisted extra headers in diagnostics
* test: cover runner extra header identity
* fix: restart runner when extra headers change
Legacy rows and inserts left sessions.active_at NULL while SessionSchema
required a number, so CLI GET /cli/sessions/:id failed Zod and resume
surfaced HTTP 500. Persist active_at on insert, harden hub read coerce,
and nullish-transform activeAt in SessionSchema (output stays number).
Fixes#1025
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): add darwin vm_stat memory percent parser
Add readDarwinMemoryUsedPercent, a pure parser that computes macOS used
memory as App Memory + Wired + Compressed (anonymous + wired-down +
occupied-by-compressor pages), matching Activity Monitor's "Memory Used"
figure. Page size is parsed from the vm_stat header rather than hardcoded,
since it differs between Apple Silicon (16KB) and Intel (4KB) Macs.
Not wired up yet; covered by unit tests, including a verbatim vm_stat
capture from a 16GB Mac mini where the pre-fix total - freemem() path
reported 99% (counting reclaimable cache as used) while App+Wired+
Compressed is 79% — the number a user sees in Activity Monitor.
* fix(cli): wire darwin memory percent into computeMemoryPercent
Add a platform() === 'darwin' branch that shells out to vm_stat
(1s timeout, guarded by try/catch) and feeds its output to
readDarwinMemoryUsedPercent. On any failure or undefined result it
falls through to the existing total - freemem() fallback, matching
the Linux branch's structure.
This fixes the Machine capacity tooltip showing a stuck ~99% "High
pressure" warning on macOS runners: os.freemem() there counts
reclaimable file cache as used, so it reports near-total usage.
Summing only App Memory + Wired + Compressed reports the same figure
Activity Monitor shows.
* refactor(claude): thread session's selected model into SDKToLogConverter
Adds an optional selectedModel field to the converter's context, wired
from session.getModel() in the launcher, so a later commit can seed the
turn-1 contextWindow estimate for presets whose system/init model
arrives without the "[1m]" suffix. No behavior change yet.
* fix(claude): key contextWindow cache by model to stop 1M/200k flicker
The remote launcher re-emits system/init on every turn for the same
converter instance. Its init-time estimate only checked whether the
model string ended in "[1m]", but current claude CLI versions strip
that suffix from system/init for some 1M presets (fable[1m] arrives as
"claude-fable-5"), so the estimate guessed 200k for them. The one
authoritative value is result.modelUsage[<model>].contextWindow, which
arrives after the heuristic has already injected 200k into that turn's
assistant message and then gets clobbered back to 200k by the very
next turn's init - producing the observed 200k<->1M oscillation in the
web status bar.
Cache the authoritative contextWindow per model id instead of a single
session-wide number, and only let system/init seed a heuristic guess
for a model that has no cached value yet, so a same-model re-init no
longer downgrades an already-learned value.
Two observed facts about the CLI's model ids drive the design:
system/init.model and the result.modelUsage keys always agree with
each other within a session (both bare for plain/fable[1m], both
suffixed for opus[1m]/sonnet[1m]), while each per-turn assistant
message reports its model bare and thus can't distinguish a 200k plain
preset from its 1M "[1m]" variant on tiers where they share a base id.
So the cache is keyed on the raw id (init/result agree, no
normalization) and assistant injection looks the value up via
resolvedModel (the last init id) rather than the lossy message.model.
Keying raw keeps a plain preset and its [1m] variant on distinct
entries; looking up via resolvedModel also means sidechain (Task
subagent) messages carry the main session window rather than the
subagent's own, since the web status bar picks the most recent usage
message without filtering sidechains and would otherwise flicker to
the subagent's smaller window while it runs.
For presets whose init model arrives bare even though they are 1M
(fable[1m]), the originally-selected preset - which preserves the
"[1m]" suffix - seeds the turn-1 estimate, kept live across mid-session
model switches via updateSelectedModel() (called from the launcher on
every turn) so it never goes stale.
* fix(web): recognize [1m] suffix on full Claude model ids in budget fallback
getContextBudgetTokens already special-cased "[1m]" for short preset
values (e.g. "opus[1m]") but fell through to the default 200k budget
for full model ids (e.g. "claude-opus-4-8[1m]"), which is what the CLI
now reports once context_window isn't available and this fallback is
consulted. Check the suffix on that branch too so it stays a correct
last-resort even without a session-provided context_window.
* refactor(web): merge duplicate Claude context-budget branches
isClaudeModelPreset(trimmedModel) and the startsWith('claude-') branch
below it had become byte-for-byte identical bodies after the [1m]
suffix check was added to both. Merge them into one condition; no
behavior change.
* fix(claude): distinguish fable from fable[1m] when the CLI reports both bare
The per-model contextWindow cache keyed on the raw system/init model id,
on the assumption that a 1M preset and its plain form always land on
distinct ids. That holds for opus[1m]/sonnet[1m] (the CLI reports the
"[1m]" suffix on their init and result ids) but not for fable: the CLI
reports both "fable" and "fable[1m]" with the same bare id
"claude-fable-5". So the "seed only if not already cached" guard would
skip re-seeding when switching fable[1m] -> fable, leaving the stale 1M
in place until fable's own result arrived - the same switch flicker this
change set out to remove, just for fable specifically.
Fold the selected preset's "[1m]" back into the cache key
(computeContextWindowKey): when the init model arrives bare but the
session selected an "[1m]" preset, key the entry as "<id>[1m]" so the 1M
and plain variants stay distinct; ids the CLI already suffixed are left
as-is. Seeding, lookups, and the current-model result entry all use this
resolved key. Subagent result entries (e.g. haiku) keep their own raw id
so the session's "[1m]" is never folded onto a model that isn't the
selected one.
* feat(gemini): remove launchable Gemini CLI agent, keep sessions readable
Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped
serving requests 2026-06-18). This removes the ability to launch/create
Gemini CLI sessions while keeping existing stored Gemini sessions fully
readable in the web UI.
Removed (no longer launchable):
- cli/src/gemini/ runtime (runGemini, loop, local/remote launchers,
session, ACP backend, config, scanner) + GeminiDisplay ink view
- `hapi gemini` command + registry entry + usage line
- runner spawn branch & buildCliArgs mapping now reject gemini with a
clear error; resume dispatch throws a clear "no longer supported" error
- gemini dropped from the new-session agent selector via new
CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults
Kept (read path — existing sessions still validate, load, render):
- `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS
- AgentFlavorIcon badge, model-option labels, ACP message normalization,
metadata.geminiSessionId, hub session dedup/resume-id
Note: the Gemini *Live voice* backend is a separate feature and is
untouched.
Adds read-guarantee tests (stored gemini validates; excluded from
creatable). typecheck + full suite green.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(gemini): reject gemini resume before handoff (#953 review)
HAPI Bot [Major]: `hapi resume <active-gemini-session>` called
handoffSessionToLocal() — which tells the running remote agent to exit —
before reaching the gemini-unsupported throw in dispatchLocalResume, so
it could stop the live/readable session and then fail locally.
Move the gemini guard into resumeCommand.run before the handoff, so an
active Gemini session is left running/readable instead of being stopped.
Keep the dispatch-layer guard as defense-in-depth. Adds a regression test
asserting handoffSessionToLocal is not called for an active gemini target.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(gemini): harden against stale gemini input (#953 review)
Two [Minor] follow-ups from HAPI Bot:
- newSessionFormDraft: coerce a restored browse draft's agent to a
creatable flavor, so a pre-removal 'gemini' draft cannot submit
agent:'gemini' even though the selector no longer offers it.
- buildCliArgs: reject 'gemini' explicitly instead of silently falling
through to the 'claude' command if the exported helper is reused
outside the guarded spawnSession path.
Updated the buildCliArgs precedence test to a creatable agent and added
a test asserting buildCliArgs('gemini') throws.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(gemini): reset dependent draft fields when coercing stale agent (#953 review)
Follow-up [Minor]: coercing a stale gemini draft's agent to claude left
model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' }
draft restored as claude *with* a Gemini model, which handleCreate() then
sent to the runner. Now reset model / cursorSelectedBase / effort /
modelReasoningEffort to defaults whenever the agent is coerced.
Adds a regression test.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review)
HAPI Bot [Major]: after removing geminiCommand from the registry,
resolveCommand() treats `gemini` as an unknown subcommand and falls
through to the default Claude command (forwarding "gemini" as an arg),
so `hapi gemini` silently started Claude instead of reporting the sunset.
Add an explicit tombstone `gemini` command that prints the sunset error
and exits 1.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(web): assert AgentSelector hides the sunset Gemini agent (#953)
Render regression test confirming the new-session AgentSelector offers
exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(web,hub,cli): show machine load in session sidebar
Runners attach OS health snapshots to machine-alive heartbeats; the hub
caches them and the web session list renders load or CPU between the
machine label and session count.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web,cli): show CPU and RAM pressure in machine health badge
Sidebar label now combines CPU and RAM percentages for overload
signaling; load stays in the tooltip on Unix. Prime CPU sampling so
the first heartbeat includes usage, not just memory.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): visual machine health meters with tooltip
Replace bare CPU/RAM text with labeled mini bar gauges, chip
border tint by severity, and a HoverTooltip explaining capacity
and overload guidance.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): widen machine health tooltip with horizontal layout
Allow a generous popover width and lay CPU/RAM/load out side by side
so the capacity tooltip reads wider and less tall than the chip.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): anchor machine health tooltip to row left edge
Wide tooltip was align=end on the chip, so it grew left off-screen.
Use row-span positioning on the machine tile button instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): machine host card with OS label and inline health
Turn the session sidebar machine row into a bordered host panel with OS
metadata and side-by-side CPU/RAM meters embedded in the tile instead
of a flat label line matching project rows.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep machine host tile single-row height
Collapse the machine header back to one py-1.5 row with OS and compact
inline health beside the name, and restore the original project indent
without the extra nested rail or second header line.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): show CPU core count in machine health tooltip
When the runner reports cpuCount, the tooltip reads "CPU across all 6
cores" instead of the generic all-cores label.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs: add machine health sidebar screenshots
Dogfood captures for the session sidebar machine tile and capacity
tooltip, for upstream PR review.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): clear machine-alive priming timeout on disconnect
Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so
disconnect/shutdown during the delay cannot leave a stray interval alive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop dogfood screenshots from upstream PR diff
Review evidence lives in the PR discussion only; no need to ship PNGs in
the repo long-term.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): truncate long machine OS/host metadata in sidebar row
Bound the metadata span so a long hostname cannot push the health chip
or session count off-screen in narrow sidebars.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): reveal machine health tooltip on keyboard row focus
Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine
header button so keyboard users can read the health tooltip like session rows.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): use MemAvailable for Linux RAM pressure on Bun
Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which
made sidebar RAM read ~99% while btop showed ~40% used. Parse
/proc/meminfo MemAvailable instead so used percent matches operator tools.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web,cli): show machine uptime in sidebar tiles and tooltip
Collect os.uptime() as uptimeSeconds on keepalive and render compact
up 1h 54m in the machine meta row plus an Uptime line in the health tooltip.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): anchor machine health tooltip to chip not row
align=row positioned the tooltip below the full machine header button,
so the collapsible project panel painted over it on hover. Use align=end
with a min-width panel so mouse and keyboard tooltips stay visible.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
asStrOrDef/asOpt* helpers now treat missing keys as undefined so
get_available_models/get_commands parsing works again. safeParse success
checked explicitly.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli): four hub-restart-cascade cleanup bugs (#913#914#916#919)
These four contained bugs were uncovered by a 2026-06-15 hub-restart
incident where `hapi-restart-hub` SIGTERMed 23 cursor ACP sessions.
Each fix lands independently of the architectural #915 (hub-restart
cascade-archive) and the hypothesis-pending #917 (reopen creates dead
session); audit-trail correctness and idempotency wins stand on their
own.
Fresh ACP sessions could be SIGTERMed during the async `update-metadata`
ACK round-trip, stranding the on-disk ACP store with no DB handle. Add
`ApiSessionClient.flushMetadata()` and await it after `onSessionFoundWithProtocol`
on the fresh-session branch. Resume-path pre-registration (PR #834) is
unchanged.
Hub-restart-cascade SIGTERMs went through the same path as web-UI
Archive clicks, both writing archiveReason='User terminated'. New
default is 'Hub restart'; the KillSession RPC handler (the
authoritative user-archive signal) now explicitly stamps
'User terminated' before cleanupAndExit. SIGINT (local-terminal Ctrl-C)
keeps the 'User terminated' label too.
`rpcGateway.killSession` threw a generic Error when no target socket
was registered, and the archive route surfaced that as 500. Add typed
`RpcTargetMissingError`, narrow on it in `syncEngine.archiveSession`,
fall back to a hub-side `markSessionArchivedFromHub` write so
lifecycleState still flips to 'archived'. Drop the requireActive
guard on the route and 2xx-noop for already-archived rows.
without refresh, producing forever-409 on rename/reopen until an
unrelated event triggered a cache refresh. `renameSession`,
`clearSessionArchiveMetadata`, `restoreSessionArchiveMetadata` now
retry-with-refresh (5 attempts, then throw) mirroring the existing
good pattern in `mergeSessions`.
Refs tiann/hapi#913
Refs tiann/hapi#914
Refs tiann/hapi#916
Refs tiann/hapi#919
AI disclosure: implementation by Claude Sonnet 4.5 (Cursor agent peer)
under operator supervision. Issue triage by a sibling discovery agent.
Per CONTRIBUTING.md AI-assisted contributions policy.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): runner-spawned children use 'Stopped by runner' as default archive reason
Addresses bot review of #923: with the #914 default-archiveReason flip to
'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`,
webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all
mislabel as 'Hub restart' which is also inaccurate audit-trail noise.
Smallest defensible change: parameterise the lifecycle default via
HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set
'Stopped by runner' on spawn. Terminal-launched sessions (no runner
parent, no env var) still default to 'Hub restart' since hub-restart
cascade documented at #915 is the most plausible SIGTERM source for
those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT
Ctrl-C, markCrash uncaught exception) still win.
Two new unit tests cover the env-var default and the override
precedence.
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): markSessionArchivedFromHub surfaces persistence failures as 5xx
Addresses second-round bot review of #923 (Major): `markSessionArchivedFromHub`
silently returned on DB write errors and on exhausted version-retry
attempts, which would let `/archive` claim 200 OK while the row stayed
unarchived. That regresses the #916 acceptance criterion that non-RPC
errors during archive must still propagate as 5xx.
Both fall-through paths now throw, matching the contract of the
sibling writers in this file (renameSession, mergeSessions). The
sessionModel test suite gains two cases that spy on
`store.sessions.updateSessionMetadata` to force `error` and
`version-mismatch` shapes and asserts the helper throws. The existing
route test at `hub/src/web/routes/sessions.test.ts:1015` already
covers the route-level 500 propagation for any error thrown out of
`archiveSession`, so no new route test is needed.
Imports `spyOn` from `bun:test` to match this test file's runtime
(the rest of the hub package uses bun:test, not vitest).
Refs tiann/hapi#916.
Co-authored-by: Cursor <cursoragent@cursor.com>
* revert(cli): drop HAPI_DEFAULT_ARCHIVE_REASON env override
Reverts `1c8972a3`. Bot review round 3 surfaced that the env-on-spawn
approach (the bot's own round-1 suggestion shape) mislabels
hub-restart-cascade SIGTERMs against runner-spawned children: systemd
killcgroup on `hapi-runner.service` stop sends SIGTERM to all
runner-children directly, and those would archive as 'Stopped by runner'
instead of 'Hub restart'.
The two suggestions are mutually incompatible without adding an IPC
channel (stdio: 'ipc' on spawn) so the runner can stamp
setArchiveReason via childProcess.send() before SIGTERMing. That is a
refactor, not a smallest-defensible change.
Going back to the simple shape: SIGTERM default is 'Hub restart' for
everyone, runner-internal stop paths share that label. The
audit-trail-correctness criterion from the #914 issue is met
(SIGTERM no longer falsely labels as 'User terminated'). Finer
attribution between cascade vs runner-stop is deferred as a follow-up.
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): clean completions get 'Session completed', not 'Hub restart'
Addresses bot review round 4 of #923 (Major): every agent runner
(runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode)
calls setSessionEndReason('completed') on the natural exit path
without touching archiveReason. With the SIGTERM default flipped to
'Hub restart', clean completions were now archived as restart
cascades.
Fix: setSessionEndReason flips archiveReason to 'Session completed'
when it transitions to 'completed' AND no caller has already overridden
the archive reason. This covers all six agent runners with a single
setter change (no per-runner edits).
Two new tests cover the natural-completion default and the override
precedence (explicit setArchiveReason still wins).
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): restore inactive-session guard on /archive except split-brain
Addresses post-rebase bot review Major on #923: dropping requireActive
entirely let normal inactive non-archived rows (completed stubs, UI
Delete/Reopen targets) fall through to archiveSession, which could stamp
archivedBy=hub on sessions that were never active.
Restore the 409 for inactive rows unless metadata.lifecycleState is
still 'running' (hub-restart split-brain cleanup case from #916).
Two route tests cover the guard and the exception.
Refs tiann/hapi#916.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): merge runnerLifecycle tests after upstream rebase
Post-rebase fix: Session completed tests referenced makeFakeSession
which was renamed to createMockApiSessionWithMetadataCapture when
merging upstream hasExplicitSessionEndReason tests with #914 archive
reason coverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): pass lifecycle object to KillSession handler in Pi runner
Upstream #862 (Pi agent) landed after this branch was cut. runPi.ts
still registered the legacy bare cleanupAndExit callback, so web
Archive for Pi sessions would persist archiveReason: Hub restart
instead of User terminated. One-line fix matching the other six
agent runners.
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: reproduce issue #864
Assert Cursor error paths emit agent error payloads and web UI renders
them as warning-styled events instead of neutral session messages.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): surface agent errors with warning styling in web UI (closes#864)
Route Cursor stderr, init, prompt, and legacy exit failures through
sendAgentMessage({ type: 'error' }) and teach the web chat layer to
render error events with a warning icon instead of neutral info text.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): surface dangling-symlink errors instead of misleading EEXIST
When a session's workspace path is a symbolic link to a directory that no
longer exists, the runner previously failed with a kernel-level error:
Unable to create directory at '/path'. System error: EEXIST: file
already exists, mkdir '/path'.
The cause: `fs.access` follows symlinks and throws ENOENT when the target
is missing, then the fallback `fs.mkdir(dir, { recursive: true })` cannot
tolerate the existing non-directory entry (the dangling symlink itself)
and surfaces EEXIST verbatim. Users had no way to tell that the symlink
target was the actual problem.
Replace the inline `fs.access` + `fs.mkdir` pair with a small
`validateWorkspaceDirectory` helper that uses `fs.lstat` so symlinks are
inspected without being followed, then explicitly handles:
- missing path -> approval flow / mkdir as before
- existing directory -> ok
- regular file at the workspace path -> "non-directory file" error
- symlink to existing directory -> ok
- symlink to a non-directory -> "not a directory" error
- dangling symlink -> diagnostic naming both the symlink path and the
missing target, with recovery options (recreate the target, remove
the symlink, archive the session)
The mkdir error switch is preserved (EACCES, ENOTDIR, ENOSPC, EROFS) and
extended with an EEXIST race-recheck that lstat's the path again so the
kernel error code never leaks to the user.
Adds focused unit tests for both the fs-touching paths (real tmpdir +
symlinks) and the pure errno-to-message mapper.
Closes#890
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): drop copy-pasteable rm command from dangling-symlink hint
The dangling-symlink recovery message embedded the user-controlled
workspace path inside a literal `rm '...'` shell command shape. A path
containing a single quote would break the quoting and turn the
diagnostic into a shell-injection / accidental-delete vector when the
user copy-pasted the suggested command.
Describe the recovery action in prose instead ("remove the dangling
symlink at '<path>'") so the path is no longer presented as a
copy-pasteable command. Adds a regression test that exercises a path
containing a single quote and asserts the message never contains the
literal `rm ...` shape.
Codex review on PR #892.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runner): preserve ENOTDIR diagnostic on lstat parent-path failure
When the workspace path sits under a regular-file parent, fs.lstat throws
ENOTDIR before mkdir runs. Route that through describeMkdirError so the
user still sees the historic "file already exists at this path" message
instead of the generic inspect-workspace-path text.
Codex follow-up review on PR #892 (post-rebase).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
OutgoingMessageQueue.scheduleProcessing() defers socket.emit() via
setTimeout(fn,0) — a macrotask. The Claude SDK's nextMessage() callback
runs in a microtask chain, which executes before that macrotask fires.
This means messages-consumed for turn N+1 can be sent to the hub before
the queued agent messages from turn N have been emitted. The hub stamps
invokedAt on the N+1 user message at receive time, and then stores the
late-arriving agent messages with created_at > invokedAt_N+1. Since
compareMessages sorts by invokedAt ?? createdAt ascending, those agent
messages sort permanently below the N+1 user message.
Fix: await messageQueue.flush() at the top of nextMessage() so all
pending outgoing agent messages are sent through the socket before
messages-consumed is dispatched.
Closes#908
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(cli): stateful MCP HTTP transport for display_image
MCP SDK 1.29+ rejects stateless StreamableHTTP reuse across separate POSTs
(initialize, notifications/initialized, tools/call), so display_image 500'd
on the second request. Generate per-session IDs instead.
Add hapi-display-image.mjs to call the live session CLI's MCP via hostPid so
generated-image bytes stay in the owning process.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): multi-session MCP transport + hapiMcpUrl metadata
Route streamable HTTP by mcp-session-id so agent bridge and
hapi-display-image can each initialize without "already initialized".
Publish metadata.hapiMcpUrl at MCP start; helper uses that instead of
guessing loopback ports (hook server collision).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scripts): preserve namespaced CLI_API_TOKEN in display-image helper
Do not append :default; namespace is already encoded in the stored token.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scripts): read settings only when CLI_API_TOKEN unset
Env-only auth must not require ~/.hapi/settings.json to exist.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: verify PID belongs to hapi before treating runner as alive
After OS upgrade, stale runner.state.json PID can be reused by unrelated
processes. The old kill(pid, 0) check passes for any process, causing
start-sync to loop with 'Runner already running' indefinitely.
Now uses ps/wmic to confirm the process command line contains 'hapi'
before considering the runner alive. Falls back to alive-only check if
ps/wmic fails.
* fix: precise runner process detection and wmic fallback
* fix: add fallback for ps failure in non-Windows branch