Add permission mode tracking across the local→remote switch so mode changes
made inside the interactive Claude TUI (shift+tab) are inherited when
launching remote. Previously, a mode picked in local TUI was invisible to
remote sessions.
Implementation:
- generateHookSettings: add trackPermissionMode option to register UserPromptSubmit
and PreToolUse hooks (their payloads carry permission_mode; SessionStart's does
not). Export buildHookSettings for testing and make matcher optional.
- New hookPermissionMode.ts: normalizer for hook permission_mode → HAPI mode
('manual' → 'default'; unknown modes like 'dontAsk' → null/ignored).
- runClaude.ts: generate a second, local-TUI-only hook settings file with
trackPermissionMode enabled (remote SDK process keeps the SessionStart-only
file — these hooks block Claude per prompt/tool, and remote state is owned
by hub/RPC). Hook callback inherits mode when session.mode === 'local': updates
currentPermissionMode, syncs session, pushes keepalive, emits permission-mode-
changed event.
- session.ts, loop.ts, claudeLocalLauncher.ts: plumb localHookSettingsPath
(defaults to hookSettingsPath when not provided).
When a batch is parked after a mode switch (pending delivery), the next
attempt that resumes from the parked batch must still seed modeHash/mode
into the relaunch gate. Without this, the gate keeps modeHash=null, so the
NEXT mode switch fails the hash check and gets silently fed into a process
spawned with the old --permission-mode (e.g. 'auto' silently running as
'default'). Fix adds hash to the pending type and seeds modeHash/mode when
delivering a parked batch.
* feat(web): feature-flagged rich composer for inline session @ mentions
Custom segmented contenteditable (not TipTap) inserts caret-local session
atoms from the existing @ picker and serializes to markdown links on send.
Textarea path remains default until flag parity dogfood.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): rich composer mention boundary + #1215 refs
Treat U+FFFC mirror atoms as word boundaries so @ after a session
token still opens autocomplete. Point comments at Fixes#1215.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): peer-stack e2e for rich composer session @ mentions (#1215)
Smoke: flag on, @ picker inserts inline session atom chip (not prose dump).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): preserve newlines in rich composer Enter-newline mode
Chromium splits contenteditable on Enter into block divs; serialize those
as \\n and insert <br> when parent leaves Enter unhandled (Shift+Enter /
enter-inserts-newline).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): show @ badge when rich composer mentions flag is on
Dogfood was invisible: flag-off looks like a normal textarea, and flag-on
had no chrome. Surface a small @ badge when enabled.
* fix(web): rich session composer on by default (not a user setting)
The plan dual-path was an engineering kill-switch, not an opt-in. Default
to the segmented composer; only richMentions=0 disables. Drop the flag
badge and record a peer-stack motion proof covering chips + baseline UX.
* fix(web): make rich composer Shift+Enter create a visible newline
Trailing <br>+empty text node was a silent no-op at EOL. Use
insertLineBreak (ZWSP pad fallback), assert real \\n in peer e2e.
* feat(web): hover tooltips on rich composer session chips
Show full title, status, short id, and path on chip hover via a portal
bubble fed by live useSessions lookup (drafts fall back to title + id).
* fix(web): dismiss rich composer chip tooltips on mouse leave
contenteditable pointerout/relatedTarget was flaky so tips stuck after
leaving the chip. Hit-test on pointermove, clear on prose/input/leave.
* fix(web): address cold-review Blocker/Majors on rich composer
Exclude peer e2e from default Playwright; force plain-text paste; restore
newline hard-stop in findActiveWord; fix root-anchored selection mapping
and nested-block serialize; cover with unit tests.
* chore: drop accidental .cursor files from rich-composer tip
* fix(web): close remaining cold-review gaps on rich composer
Drop absolute peer e2e tooling imports, prove chip→markdown send, and
harden paste/EOL/focus/tooltip/Enter edges before Meta rematerialize.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: absorb soup playwright.config union for clean remat
Keep fork peer-stack timeouts/annotated-video wiring and add testIgnore
for e2e/peer so the next driver rematerialize does not conflict.
Co-authored-by: Cursor <cursoragent@cursor.com>
* revert: drop fork playwright tooling from upstreamable tip
Peer-stack annotated-video + HAPI_PEER wiring stay on fork main / soup.
Product tip only needs testIgnore for e2e/peer (see docs/tooling/peer-stack.md).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fix rich composer Shift+Enter double newline and paste space
Prefer manual newline+pad over execCommand insertLineBreak, and stop
applying autocomplete trailing-space on paste/drop paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pad EOL Shift+Enter after Range.insertNode split
insertNode always leaves an empty text sibling, so !nextSibling never
saw EOL; detect meaningful trailing content and cover with jsdom tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): drop custom onDrop from rich composer
Intercepting drop without caretRangeFromPoint landed text at EOF or
no-oped in-editor moves. Native CE drop is enough for #1215; paste
still forces plain text.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): sidebar-parity tooltips on rich composer session chips
Reuse SessionRowSummary (flavor, thinking/attention, schedule, todos,
relative ago, path) for chip hover so the tip matches the session list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: keep peer-stack e2e off the upstreamable tip
Peer specs and playwright.peer.config stay on fork main per
docs/tooling/peer-stack.md; default config still testIgnore's e2e/peer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: cite sessions with UUID wire + inspect_peer for agent/overseer
Rich composer chips already serialize to [title](/sessions/<id>); flush
before send so the agent prompt never gets title-only chip text. Add
inspect_peer (MCP + hapi inspect-peer) as the read twin of ping_peer so
that same id is immediately usable for overseer/agent peer lookup, with
system-prompt glue from citations to inspect/ping.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): gate inspect_peer behind permission approval
Cross-session history reads need the same prompt path as ping_peer:
keep inspect_peer off Claude --allowedTools and treat it as sensitive
in ACP/OpenCode read-only mode so prompt injection cannot silently
enumerate peer transcripts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: clarify playwright peer testIgnore is upstream-safe
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep session UUIDs on rich composer copy/cut/paste
Copy/cut write wire markdown so chips do not collapse to @title-only
clipboard text; paste reparses session links back into atoms.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): exclude subagent usage from the parent context indicator
The status bar's `ctx N/M` and `cache N` come from latestUsage, which
scans the normalized messages backwards for the most recent usage. That
scan includes sidechain messages, so while a Task subagent runs its
usage — describing the subagent's own, much smaller context — becomes
the parent's numerator, then snaps back when the parent resumes.
The existing `scope_role !== 'child'` guard never fired on any path.
Claude never stamps scope_role (sdkToLogConverter.ts says so outright),
and Codex drops child token_count events in the CLI before they can
reach the web layer, so no producer ever emits 'child'. isSidechain is
the signal that actually survives.
sdkToLogConverter.ts:308-313 already documents this exact reducer
behaviour, but works around only the denominator by forcing the main
session's context_window onto sidechain messages. The numerator was
left unguarded.
* fix(cli): stop stripping context_window from local-session usage
UsageSchema is a plain z.object, so Zod's default strip mode drops every
undeclared key. sessionScanner forwards parsed.data rather than the raw
line, so on the local-JSONL path usage is truncated to the five declared
fields and context_window — injected on the SDK path by
sdkToLogConverter — never survives.
The web status bar then falls back to getContextBudgetTokens, which
subtracts a 10k headroom, so the same model reports a 1.0M denominator
on a remote session and 990k on a local one.
RawMessageSchema right below already carries .passthrough() with a
comment about losing message.model and messageId the same way; the
nested usage object just never got the same treatment.
서브에이전트(sidechain)가 오래 걸리는 도구를 실행할 때 SDK가 주기적으로
내보내는 tool_progress heartbeat 이벤트가 isClaudeChatVisibleMessage()의
기본 통과 분기를 거쳐 raw JSON 그대로 채팅에 노출되던 문제를 고친다.
rate_limit_event 필터링(#423)과 동일한 패턴으로 타입 전체를 deny한다.
Claude Code injects its own user-role turns for skill bodies and compact
continuation summaries. The on-disk transcript flags them `isMeta`, which
claudeLocalLauncher drops before they ever reach the web UI. Over
stream-json the same event is flagged `isSynthetic` instead, and
sdkToLogConverter copied only `message`, dropping the flag entirely.
With no `isMeta` on the converted line, every downstream guard let it
through: OutgoingMessageQueue forwarded it, isExternalUserMessage
classified it as genuine human input (its XML-prefix allowlist does not
match a bare-markdown skill body), and the web UI rendered the full skill
document as a user bubble.
Normalize `isSynthetic` to `isMeta` in the converter so the SDK path
carries the same signal as the transcript path and the existing filters
fire. Fixes skill injections appearing as user messages in remote mode.
* feat(cli): add ping-peer CLI and MCP ping_peer for peer messaging
Promote resume-if-inactive + wait-active + POST message into a first-class
CLI command and session MCP tool so agents stop reinventing JWT+curl.
Fixes#1194
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): do not auto-approve MCP ping_peer
Cross-session messaging can resume a peer and inject a prompt, so keep
permission-mode gating (Codex PR review Major on #1195).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): require approval for ping_peer in read-only mode
Read-only auto-approve treated non-write names as safe; ping_peer can still
resume a peer and inject prompts, so gate it like a write tool.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): keep ping_peer out of Claude --allowedTools
toolNames still registers the MCP tool, but Claude auto-allow must not
pre-approve cross-session resume+inject without a permission prompt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): re-check session active before ping-peer send
List/get can race; POST /messages still 409s if the target flips inactive
before send. Resume+wait again (and re-gate pi) immediately before POST.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): drop unknown SDK message types instead of passing them through
The SDK-to-log converter's switch had a fail-open default that stamped any
unrecognized SDK message with transcript base fields (parentUuid/sessionId/
userType) and forwarded it. Claude Code emits a tool_progress heartbeat every
30s for long-running tools, so a single slow Bash call flooded the chat: the
web normalizer matches no known shape for those records and falls back to
rendering the raw envelope as message text.
Gate the switch on an explicit allowlist instead, bailing before the uuid is
allocated so a dropped event cannot advance sidechain/parent tracking -- the
heartbeats share one parent_tool_use_id and were overwriting the pointer a
subagent's next real message parents to. This matches the local launcher,
which already enforces the same allowlist via RawJSONLinesSchema.safeParse.
The default branch stays as a fail-closed guard so adding a type to the
allowlist without a matching case drops the message rather than leaking it.
* fix(cli): re-check reassembled text for internal event JSON at flush boundary
isInternalEventJson was only applied per incoming chunk. In delta mode
(OpenCode) every chunk is a fragment, so none of them parses as JSON on its
own and the filter never fires; the pieces accumulate and flushText emits the
reassembled envelope verbatim. The dedupe path has the same hole whenever two
chunks share no overlap.
Check again in flushText, which is the first point the complete text exists,
and tolerate surrounding whitespace so an envelope preceded by a newline is
not waved through by the leading-'{' fast path.
Genuine assistant output that happens to be JSON is unaffected: the matcher
still requires the specific { type: 'output', data: { parentUuid, sessionId,
userType } } envelope shape.
* fix(cli): fail closed on unrecognized agent message in converter
convertAgentMessage's exhaustiveness default returned the message object
itself at runtime. The never binding makes the branch unreachable today, but
every caller forwards a non-null result straight into the chat stream, so the
failure mode if it were ever reached is a raw object on screen. Keep the
compile-time check, return null at runtime.
* test(cli): cover command_lifecycle, a second unknown type seen leaking
Observed in the same session after tool_progress. The allowlist already
covered it with no code change, which is the argument for gating on known
types rather than adding a case per offender.
* 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(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.
* 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.
* 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>
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>
Add 'auto' as a first-class HAPI permission mode for claude-flavored sessions,
enforced by Claude's classifier rather than emulated in canCallTool. Includes
mode configuration, CLI respawn on auto transitions, plan-exit targeting, API
extensions, and documentation updates.
* fix(web): dedupe sidebar sessions by flavor resume id
Wire deduplicateSessionsByAgentId into SessionList and resolve cursor
threads via cursorSessionId so resume/archive no longer shows duplicate
inactive rows for the same ACP session.
Fixes#833
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): use SessionSummary.agentSessionId for sidebar dedup
SessionList only receives SessionSummary from the API; native ids like
cursorSessionId are already mapped into metadata.agentSessionId by
toSessionSummary. Drop resolveAgentSessionIdFromMetadata to fix typecheck.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): hide inactive empty session stubs in sidebar
Filter inactive rows with no agentSessionId and no title signal before
grouping sessions, and expose lifecycleState on SessionSummary for future
sidebar rules. Completes the #833 P0 follow-up alongside agent-id dedup.
Fixes#833
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): scope sidebar dedup key by flavor
Prevent cross-flavor collisions when flattened agentSessionId retains a
stale native id. Add regression test and relax claudeRemote CI timeout.
Fixes#833
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers
Move stream-json remote launcher to legacy path and add ACP launcher with
set_config_option model/mode sync, optimistic keepalive on config changes, and
shared catalog caching. Web gets dual base/effort Cursor pickers for session and
new-session flows; hide composer status bar when Cursor sends no usage_update.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,web,shared): Cursor model picker — ACP wires + CLI sku variants
Enrich the web/mobile picker with agent --list-models SKUs grouped under
ACP wire bases, fix session-open base highlight, and keep catalog discovery
safe while the ACP transport holds the CLI lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor-acp): apply ACP default model when web resets to Default
Web sends model: null for Default; push session/set_config_option with the
ACP default[] wire so Cursor backend matches hub state. Regression tests
for setModel(null) and applyModelConfig(null).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(acp): clear stale agent-acp lock when owning process is gone
Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or
crash so listCursorModels can run cold probes again. Regression tests for
guard and catalog discovery.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(cursor): use live pid for ACP lock handler tests
Stale-lock cleanup clears dead pids; handler tests must simulate an
active lock with the current process pid to avoid cold probes/timeouts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(acp): scope agent CLI lock guard to Cursor agent command only
Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active;
that blocked listCursorModels while unrelated backends were running.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): reject Cursor model changes for local sessions
Hub returns 409 when controlledByUser is set, matching Codex. Web hides
model and variant pickers for local Cursor sessions so users do not hit
a dead RPC path. Document pre-push-review in AGENTS.md.
Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): send stable ids for Cursor ask_question replies
Parse and submit question.id and option.id so ACP receives keys like
{ approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
`PermissionHandler` stored its own `permissionMode` field and only updated
it inside `handleModeChange`, which is called when a new batch is pulled
from the queue. The `SetSessionConfig` RPC (web dropdown changes) updates
`runClaude.ts`'s `currentPermissionMode` and the session keepalive
metadata, but never reaches the handler — so switching to Yolo mid-turn
left `canCallTool` checking the stale mode and still prompting for
approval. Closes#735.
Drop the stored field and read live from `session.getPermissionMode()`,
mirroring how the OpenCode permission handler already works. Override
`Session.getPermissionMode()` in `claude/session.ts` to return the
Claude-narrow `PermissionMode`, sound because the matching
`setPermissionMode` setter only accepts that subset.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
The Claude effort selector (New Session config + in-session composer) only
offered auto/medium/high/max, missing `low` and `xhigh` — yet `claude --effort`
actually accepts low/medium/high/xhigh/max. Add the two missing levels in both
places so the selector faithfully mirrors the CLI.
Extract the level list + labels into one shared constant
(@hapi/protocol: shared/src/effort.ts, mirroring CLAUDE_MODEL_PRESETS) so the
two UIs derive from a single source and can't drift again. No backend change:
the effort string is free-form end-to-end through to the --effort flag.
ultracode is intentionally excluded — it is a TUI-only /effort session setting,
not an --effort value (the CLI rejects `--effort ultracode`).
The "Default" model in NewSession sends no --model flag, so Claude CLI
picks its own default (e.g. Opus 4.7 [1m] on Pro accounts). The web
status bar then falls back to 200K - 10K headroom = 190K because the
Claude SDK path never plumbs the real per-model contextWindow through
to the wire-level `modelContextWindow`, unlike the ACP/Codex backends.
Fix the gap in three places:
- cli/src/claude/sdk/types.ts: declare optional `modelUsage` on
SDKResultMessage to surface what Claude CLI already emits
(`modelUsage[<model>].contextWindow`).
- cli/src/claude/utils/sdkToLogConverter.ts: on system.init, capture
the resolved model name (full form with `[1m]` suffix) and derive
an initial contextWindow from the suffix. On every assistant
message, inject the cached contextWindow into `usage.context_window`
when absent. On result, refine the cache with the authoritative
value from `modelUsage`.
- web/src/chat/normalizeAgent.ts: forward `context_window` through
the assistant usage normalization, so the existing reducer path
(reducer.ts:175 → StatusBar.tsx:175) can render the real window.
Closes#719.
The built-in AskUserQuestion tool in claude code 2.x reads
`updatedInput.answers` keyed by the question text and expects each
value to be a single string (multi-select selections joined by
commas), then echoes them verbatim from
`mapToolResultToToolResultBlockParam`. HAPI was filling `answers` as
`Record<questionIndex, string[]>`, so claude's lookup
`answers[question.question]` missed every entry, every question fell
through to `(no option selected)`, the filter dropped them all, and
the tool result content arrived as
"User has answered your questions: . You can now continue with
the user's answers in mind."
— a sentence with no answers. Sessions appeared to hang after the
user clicked an option, because claude had nothing actionable to act
on and never produced another turn.
Walk `input.questions` and rebuild the answers map keyed by the
question text, joining multi-select selections with commas. The
codex `request_user_input` path keeps its existing nested-shape
builder.
Verified end-to-end: re-spawned a HAPI session with the patched
binary, asked claude to use AskUserQuestion, picked options in the
web UI, and the turn resumed normally with answers visible in the
tool result content.
* feat(web): polish chat rendering
Refresh the web chat presentation across user messages, tool cards, code blocks, diffs, reasoning, and Mermaid diagrams.\n\nAdd focused regression coverage for bubble/status behavior, code and diff rendering, clipboard output, Mermaid theming, and message-window updates.
* fix(web): stabilize chat tool rendering
Preserve manual scroll anchors while older messages and tool dialogs update, and align code, diff, and tool result rendering with chat typography.
Add chat font-weight settings, ignore local Playwright CLI artifacts, document the Angular commit-message convention, and cover the scroll, result, code, diff, and settings behavior with focused tests.
Constraint: User requested committing all current workspace diffs with Angular-style commit messaging
Tested: bun run typecheck:web && bun run test:web && git diff --check
Co-authored-by: OmX <omx@oh-my-codex.dev>
* style(tool-card): polish question and permission card styles
Align AskUserQuestion option surfaces and permission action hierarchy with the existing tool card visual language while preserving interaction logic. Extract shared option presentation helpers and theme-driven hover/muted colors to reduce duplication.
Constraint: Frontend style-only polish; preserve existing permission and answer submission behavior
Rejected: Keep screenshot artifacts in the repo | they are local visual review output, not source
Confidence: high
Scope-risk: narrow
Tested: git diff --check; bun run typecheck:web; bun run test:web; bun run build:web
Not-tested: manual cross-browser visual QA beyond local Playwright inspection
Co-authored-by: OmX <omx@oh-my-codex.dev>
* fix(cli): keep Claude remote plan prompts actionable
Handle Claude remote /plan locally so HAPI switches plan permission mode before forwarding any prompt text. This avoids Claude Code treating /plan as an unknown skill and ending with only a ready event.
Constraint: Claude SDK result messages are not conversation log entries, so command handling must happen before the prompt reaches Claude.\nRejected: surfacing SDK result summaries in web chat | would expose transport-level summaries broadly instead of fixing the slash-command path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Claude remote slash commands that alter runtime mode in the CLI special-command parser.\nTested: bun test cli/src/parsers/specialCommands.test.ts; bun typecheck; git diff --check\nNot-tested: Manual GitHub-hosted runner deployment.
* fix(web): polish tool result rendering
* fix(web): preserve collapsed session order
* fix(chat): settle initial thread scroll
* fix(settings): remove chat font weight option
* fix(web): remove font weight bootstrap code
* chore: remove unrelated branch artifacts
* test(web): update consumed message invocation test
* fix(chat): cancel initial scroll settling on manual scroll
---------
Co-authored-by: huhaoyu.hahahu <huhaoyu.hahahu@bytedance.com>
Co-authored-by: OmX <omx@oh-my-codex.dev>
* feat(web): show message metadata (invoke time, duration, model) on click
* fix(cli): preserve model field on assistant messages forwarded to hub
`RawMessageSchema` validates the `message` object in Claude Code session
JSONL lines before the cli forwards each message to the hub. Zod's default
parse mode strips fields that the schema does not declare, so the
`message.model` value (e.g. `claude-sonnet-4-6`) was silently removed
before the message reached the hub. The web normalizer reads
`data.message.model` to label assistant blocks, so without this field
every assistant message fell back to a generic "AI Model" label —
defeating the per-message model attribution this PR adds.
Add `model` to `RawMessageSchema` so it survives parse and reaches the
hub intact.
* fix(web): drop dead model shorthand in result envelope normalize
The `result/success` branch in `normalizeAgentRecord` referenced a `model`
identifier that was never declared in the function scope, breaking
`bun typecheck`. The reducer that consumes the resulting `turn-duration`
event does not look at `model` on the event itself, so the shorthand was
dead code. Remove it to restore typecheck.
* refactor(web): simplify turn-duration matcher with findLastIndex
Replace the imperative reverse-scan loops in the `turn-duration` reducer
branch with `findLastIndex`. The previous fallback also had an awkward
double-loop that mutated the matched block in place; using an index plus
a single immutable update keeps the block reference clean and makes the
match priority (id-prefix > tool-call id > last assistant-like) explicit.
Behaviour is unchanged — existing reducer tests cover both the messageId
match and the fallback paths.
* fix(web): preserve per-message model across mid-session model switches
The metadata footer fell back to `Session.model` from chat context when a
message did not carry its own `model`. That session value mutates when
the user switches models mid-session, so older messages were relabeled
with the latest model — including Codex/local assistant paths
(`AGENT_MESSAGE_PAYLOAD_TYPE`) that don't populate `msg.model`.
Drop the mutable-context fallback: pass `messageModel ?? null` to
`MessageMetadata` and let it omit the model line when no per-message
value is available. This is correct behaviour for messages whose
producer didn't record a model, and avoids ever attributing a message
to a model that didn't generate it.
Also remove the now-unused `useHappyChatContext` import in this file.
Add reducer invariants to lock in the data flow:
- `preserves per-message model across mid-session model switches`
- `leaves model undefined when message lacks per-message model`
* fix(web): keep tool-block reference identity when applying turn-duration
`ensureToolBlock` stores the same `ToolCallBlock` instance in both
`toolBlocksById` and `blocks`. The earlier refactor cloned the matched
block via `blocks[foundIndex] = { ...b, durationMs }`, which left the
map pointing at the stale original. A subsequent permission/result
mutation through `ensureToolBlock` would then update the stale map
object while the rendered `blocks` entry never sees the completion or
result, causing tool cards to miss state transitions.
Mutate the matched block in place instead — same in-place pattern the
reducer used before — and gate the assignment on the kinds that carry a
`durationMs` field so TypeScript narrows correctly.
Add an invariant test that fires a `turn-duration` event at a tool-call
block and asserts the rendered block and `toolBlocksById.get(...)`
remain the same object reference.
* fix(web): do not render service_tier as the model id
`MessageMetadata` previously fell back to `usage.service_tier` as the
"model" when no per-message `model` was available, so messages without
their own model id could surface labels like `Model: standard_only` —
service_tier is tier metadata, not a model.
Render the model line only when a real `model` is present; if a
non-`standard` `service_tier` is the only signal, surface it as a
separate `Tier: <tier>` label so it is not mistaken for the model.
The standard tier is the implicit default and is never rendered alone.
Extract the label-building logic into `buildMessageMetadataLabels` so
it can be unit-tested without a DOM. Add tests covering: model present,
model missing with non-standard tier, default standard tier, model with
non-standard tier appended, and the empty-input case.
* fix(web): metadata toggle ignores clicks on nested interactive controls
The bubble-level click handler that opens the metadata footer wraps
interactive descendants — tool-card buttons, retry buttons, dialog
triggers (Radix `role="button"`), and the Markdown code-copy button.
Clicking any of those flips the metadata footer as a side effect, even
when the descendant is the actual target of the user's intent.
Extract the closest-ancestor check into a small `metadataToggle` helper
and route both `AssistantMessage` and `UserMessage` click paths through
it. The toggle bails out when the click target sits inside any
`button`, `a`, `input`, `textarea`, `select`, or `[role="button"]`
ancestor; plain message-body text still toggles as before.
Add unit tests covering: button target, nested span inside a button,
`role="button"` Radix-style trigger, anchor/input/textarea/select form
controls, plain message-body text (no toggle), and a non-HTMLElement
target.
* fix(cli): preserve messageId on system/turn_duration record
`web/src/chat/normalizeAgent.ts` matches each `turn-duration` event to
the assistant block carrying the same `data.messageId`. Claude code
emits that field on the `system/turn_duration` record, but
`RawJSONLinesSchema`'s system branch did not declare `messageId`, so
Zod stripped it before the cli forwarded the record to the hub. The
matcher then fell back to "the last visible block", which is wrong for
interleaved/tool-heavy turns and silently attaches the duration to the
wrong assistant block.
Add `messageId: z.string().optional()` to the system schema so the id
survives parse and reaches the web reducer. Tests cover the preserved
case, the legacy case without `messageId`, and the previously-fixed
`message.model` case so Zod strip regressions on adjacent fields stay
locked in.
* fix(web): metadata toggle accepts SVG event targets
`isClickOnNestedControl` only walked up via `closest` when the click
target was an `HTMLElement`. The copy / retry / Markdown code-copy
buttons render SVG icons, so clicking the icon makes the event target
an `SVGElement` (not an `HTMLElement`) — the guard returned false and
the bubble-level click flipped the metadata footer anyway.
Widen the type check to `Element`, which is the common super-class of
both `HTMLElement` and `SVGElement` and also exposes `closest`. Plain
text targets and non-Element targets still behave as before.
Add a regression test that mounts an icon-only button (`<button><svg>
<path/></svg></button>`) and asserts both the `<svg>` and `<path>`
targets walk up to the enclosing button.
* refactor(cli): rely on Zod passthrough for jsonl envelopes
`RawMessageSchema` and the `system` branch of `RawJSONLinesSchema` were
declared with Zod's default `strip` mode, so any field the cli did not
explicitly enumerate was silently dropped before the hub forwarded the
record. The metadata pipeline lost `message.model` and
`system/turn_duration.messageId` exactly that way, and each gap took a
separate fix.
Switch both schemas to `.passthrough()` so undeclared fields survive
parse and reach the web reducer verbatim. Future SDK additions no
longer require another schema patch.
Add tests asserting that unknown keys on assistant messages and
unknown keys on system records (alongside the existing `messageId`
case) are preserved end-to-end through the schema.
* refactor(web): clean up dead metadata propagation surface
Several knobs were added to thread metadata through the chat tree but
ended up unused or redundant; consolidate them so the data flow has a
single canonical path.
- Drop the unreachable `data.type === 'result' && data.subtype ===
'success'` branch in `normalizeAgentRecord`. Claude's `result`
records are consumed by `claudeRemote` as session-completion signals
and never forwarded to the hub; the cli `RawJSONLinesSchema`
discriminator does not include `result`, so these records are
rejected before they reach `normalizeAgentRecord` either way.
- Stop threading `invokedAt` through the inner `normalizeAssistantOutput`
/ `normalizeUserOutput` / `normalizeAgentRecord` calls. Every caller
in `normalizeDecryptedMessage` already overwrites it via the outer
spread, so the inner copies were dead writes. Set `invokedAt` only at
the outer boundary.
- Remove the `model?: string | null` field from `HappyChatContextValue`
and the `model` prop on `HappyThread` / `SessionChat`. Its only
consumer (`AssistantMessage` mutable-fallback) was removed when the
per-message model attribution fix landed; the prop has no readers
now.
- Match the existing `as Partial<HappyChatMessageMetadata> | undefined`
cast pattern in `AssistantMessage` and `UserMessage` instead of the
non-`Partial` cast that pretended every field was present even when
`custom` is undefined.
- Rename `AgentEvent.turn-duration.messageId` to `targetMessageId` so a
reader does not confuse the duration's target with the surrounding
envelope id; the wire field on Claude's `system/turn_duration` record
stays `messageId` (vendor name) and is mapped at the normalize
boundary.
No behaviour change. All existing tests pass.
* fix(web): turn-duration matcher and cli-output merge precedence
Two reducer-level metadata-correctness bugs surfaced during a hostile
self-review.
1. Turn-duration matcher silently dropped the duration when
`targetMessageId` resolved to a non-duration-bearing block. The
existing pipeline did `findLastIndex(b => b.id === targetId || ...)`
first; if that hit an `agent-event` or `user-text` block (id-prefix
collision), the kind guard at the assignment site failed and the
duration was never attached. The fallback search ran only when the
first pass returned -1, not when the kind check rejected the match.
Fold the kind filter into every search predicate via a typed
`isDurationTarget` helper so the priority `target-bearing match >
tool-call id > last duration-bearing block` is exhaustive.
2. `mergeCliOutputBlocks` had asymmetric metadata precedence between
the command-name block (`prev`) and the stdout follow-up (`block`):
`invokedAt` and `model` preferred prev, but `durationMs` and `usage`
preferred block. Only the command-name block carries first-class
metadata; the stdout follow-up is a synthetic split. Use prev as the
primary source uniformly and fall back to block only when prev is
missing the field.
Tests cover the fallback path on the matcher and both precedence
scenarios on the merger.
* fix(web): preserve tool-call invokedAt across tool-result update
`ensureToolBlock` is called twice for the same tool: first with the
seed from the assistant's tool-use block, then with the seed from the
matching tool-result message. The second call's `seed.invokedAt` came
from the tool-result message and was unconditionally overwriting the
tool-call's original invokedAt. The rendered "Invoke" timestamp on a
tool card therefore showed when the result was processed, contradicting
the column header.
Guard the assignment so the timestamp survives the second call —
`existing.invokedAt ??= seed.invokedAt` semantics — while still letting
the first call set the value when the tool is created. `durationMs`,
`usage`, and `model` continue to overwrite because their values come
from the result message's usage block and are intentionally newer.
Add a regression test that fires a tool-use followed by a tool-result
with a later invokedAt and asserts the tool block keeps the original.
* fix(web): metadata footer UX, accessibility, and label hardening
Bundle the remaining UI surface fixes for the metadata footer.
- Make the bubble interactive only when there is metadata to disclose.
Without the guard, every non-Claude session bubble (Codex / Cursor /
Gemini, none of which populate `model`/`usage`/`durationMs` in the web
layer) showed a pointer cursor and reacted to clicks even though
`MessageMetadata` rendered nothing — false-positive interactivity.
- Add keyboard support: when the bubble is interactive it now exposes
`role="button"`, `tabIndex=0`, `aria-expanded`, and an `Enter`/`Space`
key handler so screen readers and keyboard-only users can disclose
the footer the same way mouse users do.
- Fix nullish-vs-falsy bugs in the label builder: a 0 ms turn or a 0
unix-epoch invokedAt no longer hides their lines. Use explicit
`!= null` / `>= 0` checks.
- Rename the token total to "billable tokens" so the explicit exclusion
of cache I/O is signalled in the label rather than implied by the
number alone.
- Tag the queued/sending status spans with `role="status"` (and an
accessible label) so they are announced by AT and so the metadata
toggle's `closest('button, ..., [role="status"]')` filter does not
accidentally fire when a user clicks a status icon.
- Add the native `<summary>` element and `[role="status"]` to the
toggle's nested-control selector. Tool cards already render their
expandable bodies as `<details><summary>` — clicking the summary now
expands the disclosure without also flipping the metadata footer.
Tests cover: native `<summary>` target, `role="status"` target, the
billable label, durationMs=0 surfaced, invokedAt=0 surfaced,
invokedAt=null/undefined hidden.
* fix(web): expose cli-output metadata via dedicated toggle button
CliOutputBlock renders the entire card as a Dialog trigger <button>, so
the bubble-level click handler on the cli-output branch never opened the
metadata footer by mouse — every click landed inside that button and
isClickOnNestedControl bailed out. The wrapping div with role="button"
was also a nested-interactive a11y anti-pattern.
Drop the wrapper's role/onClick/tabIndex/keyDown on the cli-output
branch and render an explicit "Show metadata" / "Hide metadata" button
beneath the card. The dialog trigger keeps its full hit area; the
metadata footer is now reachable by both mouse and keyboard.
* fix(web): exclude toggle wrapper from nested-control guard
The bubble-level toggle wrappers in AssistantMessage / UserMessage
carry role="button" for keyboard accessibility. Without excluding
currentTarget, closest('[role="button"]') from any inner click matches
the wrapper itself and the toggle bails out — making the metadata
footer unreachable for mouse users (keyboard Enter/Space still worked,
which is why unit tests and bot review missed it).
Walk currentTarget out of the match: a nested control is one whose
closest matching ancestor is *not* the wrapper itself.
* fix(web): apply nested-control guard on keyboard activation too
The mouse path bailed via isClickOnNestedControl, but the keyboard path
on the metadata-toggle wrapper did not. Pressing Enter or Space on a
focused descendant control (e.g. Markdown code-copy button) bubbled the
keydown up and the wrapper toggled metadata alongside the descendant's
own activation.
Generalize the helper to isNestedInteractiveEvent over both
MouseEvent and KeyboardEvent and call it from onMetadataKeyDown in
AssistantMessage and UserMessage.
extractSDKMetadataAsync() calls query() which sets
CLAUDE_CODE_ENTRYPOINT='sdk-ts' on the current process env.
When claudeLocal() later spawns the claude CLI, the child
inherits this env var, causing Claude Code to treat the
session as SDK-launched. This makes the session invisible
to `claude --resume`.
Strip CLAUDE_CODE_ENTRYPOINT from the child env so the
local spawn uses its own default entrypoint.
Closes#450
* fix(cli): continue execution after plan mode in YOLO/bypassPermissions
In YOLO mode (bypassPermissions), exit_plan_mode was auto-approved like
any other tool, skipping the PLAN_FAKE_RESTART injection that tells the
agent to continue. Combined with isAborted() always returning true for
exit_plan_mode, claudeRemote exited the query loop and stalled waiting
for user input.
Fix: in the bypassPermissions branch of handleToolCall, intercept
exit_plan_mode specifically — inject PLAN_FAKE_RESTART into the message
queue and return deny with PLAN_FAKE_REJECT, matching the behavior of
the normal approval flow.
Closes#172
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(cli): remove unused isPlanTool helper
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>