Commit Graph
51 Commits
Author SHA1 Message Date
Junmo KimandGitHub c3bed919b8 fix(opencode): verify persisted compaction results (#1357) 2026-08-04 11:15:42 +08:00
Junmo KimandGitHub e35c06b36a feat(agy): add Antigravity as an interactive PTY agent (#1320) 2026-08-04 10:50:03 +08:00
Junmo KimandGitHub f44c9ff3e6 feat(opencode): open a fresh session on clear (#1300)
* test(opencode): specify fresh-session clear

* feat(opencode): open a fresh session on clear

* fix(opencode): release clear latch on cancel

* fix(opencode): retry transient clear handoffs

* fix(opencode): confirm clear archive delivery

* fix(web): preserve superseded session access

* fix(clear): invalidate transferred schedules

* fix(runner): restore live spawn dedupe

* fix(clear): preserve latched scheduled prompts

* fix(runner): quarantine unverified children

* fix(clear): retain handoff retry ownership

* fix(runner): release recovered spawn dedupe

* fix(clear): retain archive retry ownership

* fix(clear): settle rejected immediate prompts

* fix(clear): block reopening replaced sources

* fix(clear): settle prompts when clear is cancelled

* fix(clear): make fresh-session handoff durable

* fix(clear): finalize only after native cleanup

* fix(clear): abort failed native handoffs

* fix(clear): gate recovery on cleanup proof

* fix(clear): retry metadata persistence failures

* fix(clear): preserve handoff ownership through teardown

* fix(clear): abort incomplete cleanup reservations

* fix(clear): require explicit exit before abort

* fix(clear): verify owner exit before recovery

* fix(clear): guard recovery handoff races

* fix(clear): serialize cleanup callbacks

* fix(clear): make callback retries idempotent

* fix(clear): bind callbacks to reservations

* fix(clear): recover pending spawns

* fix(clear): deduplicate held prompts

* fix(clear): validate redirect ownership

* fix(clear): replay prompts in FIFO order

* fix(clear): gate replacement delivery
2026-08-03 18:06:39 +08:00
Junmo KimandGitHub 08fbea5311 feat(cli): bridge OpenCode native compaction via internal REST API (#1252)
* feat(cli): allocate a loopback port for the OpenCode ACP subprocess

opencode does not announce which port it bound when launched with
--port 0 (verified: not present in stdout/stderr even at DEBUG level),
so pick a free loopback port ourselves and pass it explicitly via
--port/--hostname. This makes the ACP subprocess's internal HTTP API
reachable at a known baseUrl for follow-up work (native /compact
bridging).

* feat(cli): add REST bridge for OpenCode native session compaction

opencode's ACP method table has no session/compact RPC, but the
opencode acp subprocess also runs an internal HTTP API. That API's
POST /api/session/:id/compact route is an unimplemented v2 stub
(always 503); the route that actually performs native AI compaction
is the legacy POST /session/:id/summarize, which requires providerID
and modelID in its payload. This adds a small client for that route
plus a helper to split ACP's combined "provider/model" wire id.

Not wired into the slash-command flow yet.

* feat(cli): trigger native OpenCode compaction from /compact

Wires the REST bridge into the OpenCode slash-command flow so /compact
performs real context compaction instead of returning a "not yet
supported" message.

- slashCommands.ts: /compact now resolves to its own `kind: 'compact'`
  (the synchronous 'handled' shape can't carry an async REST round
  trip). /clear is unchanged.
- opencodeRemoteLauncher.ts: registers a compact trigger callback once
  the ACP backend + internal HTTP baseUrl are ready, reading the
  session's current provider/model on every call so it reflects
  inline model switches. A `runExclusive` promise-chain mutex
  serializes the compact trigger against `backend.prompt()` so a
  still-in-flight prompt and a `/compact` sent moments later can't
  race against the same OpenCode session concurrently, in either
  arrival order.
- runOpencode.ts: on /compact, emits "Compaction started" as a session
  event (the same `sendSessionEvent({ type: 'message', ... })` status-
  line channel Claude/Codex already use for compaction and other
  transient state, rather than a chat message), awaits the bridge with
  no artificial timeout (compaction on a reasoning model can take
  90s+), then emits "Compaction completed" or "Compaction failed:
  <reason>" the same way. Falls back to the previous not-yet-supported
  chat message when no trigger is registered (local mode has no ACP
  backend, so this is unreachable there today). If the user cancels
  the message while compaction is queued or in flight, the eventual
  result is suppressed instead of surfacing a stray status message
  for an action the user already considered cancelled (aborting the
  in-flight HTTP call itself is left as follow-up scope).
- help text now notes /compact is remote-sessions only, since local
  mode has no ACP backend to bridge through.

Also fixes a runOpencode.test.ts fixture gap: the mocked
OpencodeSession was missing onThinkingChange, so any exception in that
call path was silently swallowed by the handler's outer catch instead
of failing the test.

* feat(cli): show compaction summary as a reasoning block

After a native OpenCode compaction succeeds, OpenCode's session
history gains a `role: "user"` marker message (a `{type:"compaction"}`
part with no text) followed by a normal assistant message holding the
actual generated summary in a `text` part. Left alone, neither is
visible in HAPI — the bridge only checked success/failure and never
looked at the resulting messages.

- opencodeCompactBridge.ts: `fetchCompactionSummary()` does one more
  GET against the session's message list after a successful compact,
  finds the most recent `type:"compaction"` marker, and extracts the
  following assistant message's text — preferring a match via the
  marker's `parentID` (order-safe) and falling back to simple array
  adjacency, both gated on `role === 'assistant'` so an unrelated
  same-shaped sibling can't be silently misattributed as the summary.
  Concatenates every `text` part in case a summary spans more than
  one. Never throws: any unexpected shape or request failure just
  yields "not found" so the caller can skip showing anything.
- opencodeRemoteLauncher.ts / runOpencode.ts: on a successful compact,
  forward the extracted summary as a `{ type: 'reasoning', text, id }`
  AgentMessage through the same `handleAgentMessage()` /
  `convertAgentMessage()` path OpenCode's own live thought-chunk
  streaming already uses, so it renders in the existing collapsible
  "Reasoning" block — no new UI component or schema field. The
  `role:"user"` marker is never constructed or forwarded at all, so
  there's nothing to filter (unlike Claude's compact summary, which
  is hidden after the fact via an `isCompactSummary` flag).

* fix(cli): disable Bun's hardcoded fetch timeout for OpenCode compaction

Isolated E2E against a real OpenCode session showed compaction always
failing with "Compaction failed: The operation timed out." after
~250s, even though session/prompt-style unlimited waits were intended.
Root cause: Bun's global fetch() hardcodes an internal ~5 minute
timeout that fires independently of any AbortSignal (or its absence) —
see oven-sh/bun#16682. The only documented workaround is passing the
non-standard `timeout: false` fetch option that Bun itself recognizes.

Cast through a local `BunFetchInit = RequestInit & { timeout?: false }`
type alias rather than `Record<string, unknown>`, so the option stays
structurally checked against the rest of the fetch call's shape.

* fix(cli): serialize /compact through the message queue instead of a mutex

HAPI Bot flagged two Major correctness issues on the PR:

- `/compact` bypassed `MessageQueue2` and entered a `runExclusive` mutex
  directly from the user-message handler. If prompt A was in flight and
  prompt B already queued behind it, `/compact` sent afterward could
  still reach the mutex before B did, running compaction ahead of an
  earlier-queued user prompt.
- `compactTriggerRef` (a closure capturing the remote backend) was
  never cleared on a remote→local handoff, so `/compact` typed after
  switching to local mode could call a stale closure targeting an
  already-disposed backend instead of the intended local-mode fallback.

Removes the mutex entirely and routes `/compact` through the same
`messageQueue` regular prompts use (a new `operation?: 'compact'` field
on `OpencodeMode`, dequeued by the launcher's single sequential
consumer loop, which branches to a new `runCompactOperation()` instead
of `backend.prompt()`). Ordering is now enforced by construction (one
queue item in flight at a time) rather than a second bolted-on lock.

Replaces the closure-based `onCompactTriggerReady` callback with a
boolean-flag `onCompactAvailabilityChange`, reset to `false` on every
entry into local mode (cold-start and handoff alike) before the remote
backend is even disposed, so there is no window where the flag says
available but the backend underneath it is gone.

* fix(cli): let /compact's cancel ack fire at dequeue time, not on queue

A follow-up HAPI Bot review caught a Major issue this PR's queue-based
/compact serialization (previous commit) left open: runOpencode.ts
still called session.emitMessagesConsumed manually and synchronously
the instant /compact was pushed onto the queue -- a leftover from
before /compact was routed through the queue at all. That beat
MessageQueue2's automatic dequeue-time ack (onBatchConsumed, wired in
sessionBase.ts, already used by every regular prompt) to the hub, so a
/compact sitting behind other queued prompts was marked "invoked"
immediately and could never actually be cancelled from the UI.

Removes the manual ack; the queue's own dequeue-time ack now covers
/compact exactly like any other queued operation. Adds a deterministic
test for the reported scenario: prompt A generating, /compact queued
behind it, cancelled before A finishes -- the compact REST bridge must
never be called.

* fix(cli): suppress live ACP updates while a compact REST call runs

Found via live use: OpenCode keeps streaming session/update
notifications (thought chunks, etc.) over the ACP transport while
/compact's REST call runs outside prompt(), and
AcpSdkBackend.handleSessionUpdate forwarded them unconditionally to
whatever messageHandler was still installed from the last real prompt
turn -- rendering the compaction summary a second time as a plain
assistant message, alongside the explicit Reasoning block this PR
already sends for the same content.

Adds a narrow, opt-in AcpSdkBackend.suppressUpdatesDuring() (shared
with Gemini, but a pure addition with no behavior change for existing
prompt() callers) that temporarily swaps out the message handler for
the duration of an async callback, restoring it afterward. Wraps the
compact bridge's REST call with it so the live stream produces no
output during that window -- the Reasoning block built from the
explicit GET response becomes the only place the summary appears.

* fix(cli): let Stop/switch-to-local interrupt an in-flight /compact REST call

triggerOpencodeCompact's HTTP request is intentionally unbounded (a
real compaction can take minutes), but the launcher awaited it with no
way to interrupt it once dequeued and running. handleAbort() only
cancels the ACP prompt() turn and resets the queue -- neither touches
this raw HTTP call -- so Stop (and switch-to-local, which routes
through the same handler) stayed blocked until the request settled on
its own: a stale "Compaction completed/failed" could still surface
afterward, queued prompts behind it were delayed, and remote->local
handoff couldn't proceed.

Add a per-call AbortController (compactAbortController) that
handleAbort() aborts before cancelling the prompt, and thread it
through triggerOpencodeCompact's new optional `signal` (kept separate
from the existing timeout:false Bun workaround, which stays
unconditional). An aborted call now folds into the same isCancelled()
check that already suppresses a stale result for the existing
queue-cancel race, so either kind of interruption behaves the same
way.

* fix(cli): structurally close remaining /compact abort/lifecycle races

Two more narrow races surfaced in review, both symptoms of ad hoc
per-site fixes rather than a shared mechanism:

1. The signal threaded through triggerOpencodeCompact (the POST) did
   not also reach fetchCompactionSummary (the GET runCompactOperation
   makes right after it), so Stop/switch-to-local could still block on
   that call alone. Introduce OpencodeCompactCallOpts with `signal`
   required (not optional) so every HTTP step opencodeCompactBridge.ts
   makes on behalf of one /compact operation is forced by the compiler
   to accept it, not left to remembering to wire it in per call site.

2. /compact availability (runOpencode.ts's compactSupported flag) was
   only reset to false on the *next* local-mode entry (loop.ts's
   runLocal callback), leaving a window between "switch/exit was
   requested" and "local mode actually started" where a /compact
   arriving mid-transition could still queue, and -- since local mode
   hands straight back to remote when it finds a non-empty queue --
   run anyway despite the user having already left remote mode. Add an
   onLeavingRemote() hook to RemoteLauncherBase (no-op default, so the
   other six flavors built on it are unaffected) called synchronously
   as the very first action of requestExit() -- closing the race at
   its source -- and again unconditionally in start()'s finally block
   as a backstop for exit paths that never call requestExit() at all
   (an exception thrown from runMainLoop, for instance).
   OpencodeRemoteLauncher overrides it to flip availability false;
   loop.ts's local-entry reset is removed as redundant now that
   leaving remote is what's authoritative.

* fix(cli): create compactAbortController before the inline model/effort switch

A hostile-review whole-feature sweep of the /compact abort/lifecycle
surface (round 5) found that the dequeue loop applied the per-batch
inline model/effort switch (real async ACP round-trips that yield to
the event loop) *before* branching into runCompactOperation(), which
is where compactAbortController used to get created. An abort firing
during that switch hit a still-null controller (a no-op), and by the
time the switch resolved and runCompactOperation() created a fresh
one, the abort was forgotten -- the compact's unbounded REST call
would then run to completion despite the user having already pressed
Stop/switch/exit.

Move controller creation to the top of the loop iteration, as soon as
the batch is known to be a compact operation, and pass it into
runCompactOperation() rather than having that function create its
own. Also: soften an overstated doc comment about what the required
`signal` field actually guarantees, and add a regression test locking
in that two sequential compact operations each get an independent
controller (no leak or cross-clearing).

* fix(cli): drain quietly before restoring the handler in suppressUpdatesDuring

A 5th PR-review round found that suppressUpdatesDuring restored the
suppressed messageHandler the instant its callback settled, but
aborting the client-side HTTP call (e.g. OpenCode's compact bridge
aborting via compactAbortController) does not necessarily stop the
agent from continuing that operation server-side -- session/update is
a separate notification channel from the HTTP request's lifecycle.
A late straggler notification from a still-running server-side
operation could leak straight into the restored handler (or into a
new one prompt() installs right after).

Reuse the same quiet-drain prompt() already runs before installing a
new handler for the next turn (waitForSessionUpdateQuiet with the
PRE_PROMPT_* constants) -- same class of race, same validated
mechanism, just on the way back in instead of the way out. No new
state or Stop-vs-switch branching: the wait is internal to
suppressUpdatesDuring and the handler stays suppressed throughout it.

* fix(cli): queue /compact during remote-mode initialization instead of rejecting it

compactSupported alone conflated two different situations: a
genuinely local-mode session (compact fundamentally can't run) versus
a session that's already in remote mode but hasn't finished ACP
initialize + session load/new yet (onCompactAvailabilityChange(true)
hasn't fired yet, but will shortly). A regular prompt sent in that
same startup window queues normally and just waits its turn; /compact
sent in the identical window instead got an immediate
not-yet-supported reply.

Check sessionWrapperRef.current?.mode (the actual OpencodeSession
instance's mode, synced synchronously by onModeChange before either
launcher starts) alongside compactSupported: only genuinely local mode
now gets the not-yet-supported reply. A session already in remote mode
but still initializing queues /compact exactly like a prompt and lets
it settle into its real FIFO position once the launcher is ready.

* fix(cli): don't let the remote-init /compact queuing fix reopen the teardown race

A hostile-review whole-feature sweep found that gating /compact
queuing on sessionWrapperRef.current?.mode alone (26344118) fixed the
startup window but silently reopened the exact race
OpencodeRemoteLauncher's onLeavingRemote() exists to close: mode stays
'remote' for the entire teardown window too (it only flips back to
'local' once runMainLoop() fully unwinds), so a /compact arriving
after switch/exit was requested -- while compactSupported has already
gone false -- queued anyway instead of getting rejected, and could
still run once local mode bounced back to remote to drain a non-empty
queue.

Add compactTeardownInProgress, true from the moment
onCompactAvailabilityChange(false) fires (which -- since the old
reset-on-local-entry was removed -- only ever means "leaving remote",
never "not ready yet") until the session next re-enters remote mode.
Wrap the onModeChange callback opencodeLoop already receives to reset
it back to false on that re-entry. The existing "stops queuing /compact
once availability is reset to false" test didn't catch this because
its mock never set mode to 'remote' during the simulated teardown,
unlike real production timing -- fixed to match.

* fix(cli): keep the dequeue loop blocked on a compact until it really finishes on plain Stop

A 6th PR-review round rejected the quiet-drain mitigation from the
previous round (bounded at ~1.2s) and asked for the originally
proposed fix instead: quiet-drain cannot guarantee a multi-minute
server-side compaction has actually finished, since session/update is
a separate notification channel from the aborted HTTP request's
lifecycle. Unconditionally aborting compactAbortController on any
abort (the previous fix) let the dequeue loop move on to the next
queued prompt while the agent could still be compacting the same
OpenCode session server-side -- breaking the core invariant this
feature's whole queue-based redesign depends on: compact and a prompt
must never touch the same session concurrently.

handleAbort() now takes a leavingRemote parameter. Plain Stop
(leavingRemote=false, the default) only sets compactResultSuppressed
-- the eventual result gets hidden, but compactAbortController is left
alone, so runCompactOperation()'s own awaits keep blocking the dequeue
loop until the real HTTP response arrives, i.e. until the server
actually finishes. Switch-to-local/exit (leavingRemote=true) still
abort the controller for real, since cleanup() disconnects the whole
ACP subprocess right after regardless -- there's no shared session left
to protect there, and the responsiveness fixed in an earlier round
still matters for that path.

The quiet-drain from the previous round (AcpSdkBackend.suppressUpdatesDuring)
is left in place -- it still helps for a compaction that finishes
quickly and for trailing session/update stragglers right after a real
completion, it's just no longer the thing plain Stop relies on for
correctness.

* fix(cli): close 3 gaps a hostile-review pass expected the next bot round to flag

Pre-emptively addresses findings a hostile-review final pass on
65729bb7 judged likely for the next external bot round, since a
communication round-trip costs more than fixing them now:

1. No dedicated test existed for the Stop/switch-to-local RPC-overlap
   message-ordering fix (handleAbort() re-reading compactAbortController's
   abort state instead of a stale snapshot). The test harness has no way
   to observe MessageBuffer/Ink content, so the decision logic that
   picks the status message and whether to clear `thinking` is extracted
   into a pure, exported selectAbortStatusMessage() function and unit
   tested directly against all four (hasCompactInFlight, leavingRemote,
   compactAborted) combinations, including the exact overlap case.

2. No test verified compactResultSuppressed doesn't leak between two
   sequential compact operations. Added a regression test: a Stop-suppressed
   compact #1 finishing for real must not silence a normally-completed
   compact #2 queued after it.

3. The "Stop requested — waiting..." status message didn't tell the user
   how to actually leave the session (switch-to-local/exit) while a
   compaction is deliberately left running. Appended that guidance.

* fix(cli): skip a compact cancelled before its request ever went out

A plain Stop landing during the inline model/effort switch that
precedes a compact batch only set compactResultSuppressed; it never
stopped the dequeue loop from calling runCompactOperation()
unconditionally once that switch resolved, so a cancelled-before-start
compact would still fire a brand new REST request and block the loop
for however long that call takes.

Skip starting the operation when compactResultSuppressed is set and
the controller was never actually aborted (plain Stop leaves the
signal alone by design). Deliberately excludes the
compactAbortController.signal.aborted case (switch/exit) and
isLocalIdCancelled: both must keep falling through to
runCompactOperation() as before, per Round 5's and the
isLocalIdCancelled suite's existing coverage.

* fix(cli): also skip a compact cancelled-before-start via isLocalIdCancelled

Round 7's pre-start skip check only covered compactResultSuppressed
(plain Stop), deliberately leaving isLocalIdCancelled out so as not
to disturb the "Compaction started is never suppressed" tests that
predated that check. But isLocalIdCancelled's backing Set can only
ever be populated during the brief ack-vs-hub-DB-write race before a
queued item's REST call is sent, never while it's actually running
(see runOpencode.ts's cancelledBeforeEnqueue doc comment) — so a true
result here unconditionally means the same "cancelled before it ever
went out" situation Round 7 already handles for plain Stop, and
deserves the same treatment: skip starting the operation instead of
sending "Compaction started" for a request already known to be
discarded.

Updates the two tests that had encoded the old "started is never
suppressed" behavior for this specific signal to their corrected
expectation, and removes a no-longer-consumed mockImplementationOnce
that would otherwise have leaked its failure response into the next
test that actually calls the REST bridge.

* fix(cli): don't resurrect /compact availability after a startup-time switch/exit

onCompactAvailabilityChange(true) fired unconditionally right after
newSession/loadSession resolved, with no way to know a terminal
switch-to-local/exit had already run during that pending ACP round
trip (RemoteLauncherBase.requestExit() sets shouldExit synchronously
before awaiting its handler, so the flag is already accurate at that
point). runOpencode.ts's compactSupported/compactTeardownInProgress
gate treats compactSupported flipping true as reason enough to ignore
compactTeardownInProgress entirely, so this belated true could let a
/compact arriving right after slip into the queue mid-teardown.

Guard the call with the same shouldExit flag requestExit() already
set. The race can only be reached via the terminal UI's onExit/
onSwitchToLocal callbacks (wired up before runMainLoop starts, well
before the RPC 'abort'/'switch' handlers exist), so the regression
test forces isTTY and captures ink's render() props to invoke them
directly.

* fix(cli): clear the hub's queued-thinking grace when a compact is skipped pre-start

The cancelledBeforeStart skip path never calls session.onThinkingChange(true)
(the whole point of skipping), but also never told the hub the queued
item was done. The hub's 15s queued-thinking grace (markMessageQueued,
sessionCache.ts) keeps thinking pinned true regardless of keepalives
until a messages-consumed ack with clearQueuedThinkingGrace arrives,
so the web UI spinner could sit stuck for the full grace window.

Applies the same pattern already used by runOpencode.ts's synchronous
slash.kind === 'handled' path (e.g. /model): an emitMessagesConsumed
ack with clearQueuedThinkingGrace, then an immediate thinking=false
keepalive. This is additive to, not a replacement for, the queue's own
unflagged onBatchConsumed ack — a second ack for an already-invoked
localId is a no-op on the hub's first-write-wins protocol, and
clearQueuedThinkingGrace is keyed by session, not localId, so both are
idempotent.
2026-08-01 17:20:07 +08:00
Junmo KimandGitHub 084d3462cf fix(web): keep line numbers clear when code wraps (#1260)
* fix(web): reserve code gutter padding

* fix(web): expose diff wrap controls

* test(ci): run terminal wrap regression
2026-08-01 17:11:40 +08:00
Junmo KimandGitHub 770439730a fix(web): keep abort next to Send by default (#988)
* refactor(web): pin abort button to end of composer left cluster

Abort's position shifted with conditional siblings (terminal/switch/
schedule), making the destructive action's location unpredictable.
Move it to the last child of the left button group so it's always
immediately left of Send — pure JSX reorder, no markup/props/handler
changes.

* fix(web): keep abort last in default toolbar

Keep the product default predictable without changing saved custom order or the registry used to append missing items.
2026-07-31 18:17:34 +01:00
Junmo KimandGitHub 61740164fb fix(hub): preserve invocation activity timestamps (#1249) 2026-07-30 23:29:00 +08:00
Junmo KimandGitHub 659913c0c9 fix(shared): hide tool_progress heartbeat events from chat delivery (#1094)
서브에이전트(sidechain)가 오래 걸리는 도구를 실행할 때 SDK가 주기적으로
내보내는 tool_progress heartbeat 이벤트가 isClaudeChatVisibleMessage()의
기본 통과 분기를 거쳐 raw JSON 그대로 채팅에 노출되던 문제를 고친다.
rate_limit_event 필터링(#423)과 동일한 패턴으로 타입 전체를 deny한다.
2026-07-29 20:18:09 +08:00
Junmo KimandGitHub e52443f4cf feat(web): global word-wrap toggle for code and diff views (#985)
* refactor(web): add per-line shiki line-splitting helper

* feat(web): add global word-wrap toggle for code, markdown, and diff views

* feat(web): add a shaded gutter background behind line numbers

* fix(web): make DiffView compact rows follow the global wrap setting
2026-07-27 19:09:59 +08:00
Junmo KimandGitHub bb5275a333 fix: recover Codex resume ID from stored messages (#1180)
* refactor(hub): generalize recovered ID helpers

* fix(hub): recover Codex resume ID from messages

* fix(web): allow Codex message recovery
2026-07-27 12:59:57 +08:00
Junmo KimandGitHub deb5d63695 fix(cli,web): group orphaned subagent trace by parentToolUseId (#1175) 2026-07-26 23:01:46 +08:00
Junmo KimandGitHub 60d7489a59 fix(hub): archive active sessions with stale metadata (#1170) 2026-07-26 15:03:24 +08:00
Junmo KimandGitHub 223be6d60f fix(cli): don't lose the queued message when a remote launch fails (#1058)
* 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.
2026-07-19 14:16:40 +08:00
Junmo KimandGitHub 289c9f2218 feat(cli,web): show Claude Code's away recap in local-mode chat (#1089)
* 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
2026-07-19 12:24:32 +08:00
Junmo KimandGitHub 95eee432d4 perf(claude): scan transcripts incrementally (#1081)
* 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).
2026-07-19 12:23:09 +08:00
Junmo KimandGitHub 2c559373c9 fix(claude): preserve resume anchor and report real /compact outcome (#1056)
* 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.
2026-07-18 12:25:35 +08:00
Junmo KimandGitHub d5aeeb57a4 fix(web): add missing exec timestamps to ToolCard test fixture (#1057)
The ChatToolCall fixture in ToolCard.test.ts does not set execStartedAt
and execCompletedAt, which became required fields, so bun typecheck fails
on main with TS2739.

The fixture was added while those fields did not exist yet, and the change
that introduced them landed a minute earlier, so each side type-checked
against its own base and the gap only appears once both are on main.

Set both to null, matching the sibling fixtures in ToolGroupCard.test.ts
and groupedPresentation.test.ts, since these cases do not exercise tool
execution duration.
2026-07-17 11:46:15 +08:00
Junmo KimandGitHub adb6f41858 fix(web): extend hover-reveal info affordance to touch devices (#1046)
MessageInfoPopover's trigger button carried the same
happy-message-actions-desktop-only class as the other hover-reveal
actions from 4c76668a, so it never rendered on touch-only viewports
(no hover: hover match). Switch it to the always-visible flex pattern
the sibling copy button already uses, matching desktop's hover-reveal
opacity animation on the parent row.

Also widen the action row's desktop-only-row guard to stay reachable
when a tool-only response (no copyable text) still carries model/
duration metadata from its first tool block, so the info popover isn't
hidden behind an empty row on mobile.
2026-07-16 12:33:51 +08:00
Junmo KimandGitHub 89df3fd5f2 feat(web): show subagent's executed model in Task/Agent card header (#1045)
* feat(web): show subagent's executed model in Task/Agent card header

Task/Agent trace cards previously gave no indication of which model a
subagent actually ran under, even though the model can differ from the
calling session's (e.g. main session on opus, subagent on haiku) and
can even change mid-run when --fallback-model kicks in under overload.

The data already reaches the frontend: each child block produced from
a subagent's own sidechain carries the model of the assistant message
it came from. Derive it in getSubagentModel() from the tool call's own
children (not the parent ToolCallBlock.model, which reflects the
calling session and would misattribute the model), collecting distinct
raw values in first-seen order and joining them the same way
aggregateResponseGroups already does for top-level multi-turn message
metadata.

Full SDK model ids (e.g. claude-sonnet-4-5-20250929) are long and not
great for a compact label, so formatSubagentModelLabel() extends this
repo's existing "friendly label, else raw fallback" idiom
(getClaudeModelLabel(model) ?? model in claudeModelOptions.ts, which
only covers the short preset aliases) with a narrow second fallback
that extracts just the name and version from the SDK id shape and
drops the date suffix (-> "Sonnet 4.5"). Anything else is left as-is.

Renders the result as a small chip in the header's existing right-side
meta cluster (next to ElapsedView/status icon), always visible without
opening the detail dialog.

* fix(web): cap subagent model badge width to avoid squeezing the card header

Addresses HAPI Bot review on #1045: formatSubagentModelLabel() returns
unrecognized model ids (Gemini, Codex, future formats) unchanged, and
those can be long. Bound the chip with max-w + truncate so it can't
push the title/status area off narrow cards, with a title attribute
so the full value is still reachable on hover.
2026-07-16 12:33:22 +08:00
Junmo KimandGitHub 2ce6d3ef3a feat(web): show tool call duration in the detail dialog (#1036)
* refactor(web): export formatDuration for reuse

* feat(web): show tool call duration in the detail dialog

Show a completed tool's execution duration at the top of its detail
dialog. The value is derived from the Claude entry's own timestamps
(the execution machine's wall clock) rather than the hub's
message-receive time, and is used only when both the tool_use and
tool_result entries carry a real timestamp — otherwise it falls back to
the hub receive times on both sides, so the two clocks are never mixed.
Running/pending tools show nothing, the running-state live timer is
unchanged, and clock skew is guarded against. Reuses the existing
formatDuration formatter. No schema changes.

* fix(web): backfill hub startedAt on reorder so duration isn't 0.0s

When a tool_result entry is reduced before its tool_use, the tool block
is created from the result, so the hub startedAt is the result receive
time. The tool_use path only lowered the exec start, not the hub
startedAt, so a timestamp-less pair (no exec duration available) fell
back to startedAt === completedAt and the detail dialog showed 0.0s.
Lower the hub startedAt to the earlier tool_use receive time as well.
2026-07-16 12:32:00 +08:00
Junmo KimandGitHub 43e7b6bef7 fix(cli): compute macOS machine-health memory from vm_stat (#990)
* 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.
2026-07-11 10:40:06 +08:00
Junmo KimandGitHub e45fde51e9 fix(claude): stop 1M/200k context-window flicker in the status bar (#992)
* 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.
2026-07-11 10:39:36 +08:00
Junmo KimandGitHub f51e06e8f3 fix(web): stop pinning resolved permission cards to the bottom of the chat (#974)
agentState keeps an answered permission request in completedRequests. When its
tool_use message is not in the loaded window, the permission-only synthesis
appended a card to the end of the timeline — and there is no chronological
re-sort, so the card stays pinned above the composer as a stale "answered"
card that never moves to its place in history. With several answered asks this
piles up at the bottom of the chat.

Synthesize a card only for a *pending* request (the case that needs an
answerable card when its message hasn't loaded). A resolved request is history
and renders only via its own message when that message is in the window.
2026-06-29 11:40:32 +08:00
Junmo KimandGitHub d1c2051f28 fix(web): aggregate per-response metadata so multi-turn cards show total usage (#637)
* refactor(web): extend MessageMetadata to accept aggregated turnCount

Add an optional `turnCount` prop to MessageMetadata so the same builder
can render an aggregated response-group footer when the caller has
already summed usage and dedup-joined model ids. The label set switches
to `Models` / `Total` / `N turns` only when `turnCount >= 2`, leaving
single-turn footers byte-identical with the existing
`Invoke · Model · Usage` output.

Also expose `turnCount?: number` on `HappyChatMessageMetadata` so a
later commit can inject the aggregated metadata through the library's
ThreadMessageLike payload without widening the type at the same time.

No call site passes `turnCount` yet, so this commit is behavior-neutral
on all existing surfaces (proof-of-invariance test included).

* feat(web): aggregate per-response metadata so multi-turn cards show total usage

The `@assistant-ui/react` converter joins adjacent assistant messages
into one card but only preserves `metadata.custom` from the first
block, so multi-turn responses currently show the first turn's usage
and model only.

Compute response-group aggregates in `useHappyRuntime` and inject the
sum on each group's first visible block, where the library will keep
them. Per group: usage tokens are summed across distinct turns,
model ids are dedup-joined in first-seen order, and the invoke time
is the first turn's so the footer keeps showing when the response
started (regression-guarded by unit test). `durationMs` is explicitly
cleared on aggregated blocks because the first turn's value would
otherwise leak through the join.

Turn identity prefers the CLI-stamped `localId`. When that is null
(claude code spawn sessions today emit `localId=null` on every chunk)
the aggregator falls back to a fingerprint built from `model` plus the
shared `usage` totals — every block emitted within one Claude SDK
message carries an identical usage object, so the fingerprint dedups
those chunks without merging distinct turns whose token counts
naturally differ. Tool-result chunks with no model or usage are
skipped so they cannot inflate the turn count.

Single-turn responses get no aggregate entry, so their footers stay
byte-identical with the existing behavior.

Test plan
- `assistant-runtime.test.ts` covers the six grouping scenarios spelled
  out in the design note (localId-based + null-localId fingerprint
  fallback) plus two defensive cases for tool_result chunks and cache
  token preservation.

* fix(web): preserve explicit zero sums and count tool-group turns in response aggregator

Two correctness gaps in aggregateResponseGroups:

- addUsage folded `0 + 0` through `|| undefined`, dropping an
  explicit-zero cache token sum from the aggregated metadata.
  Replace the falsy fold with sumOptional(): undefined only when
  both operands are absent, otherwise (a ?? 0) + (b ?? 0).
- turnSourceFromBlock returned null for tool-group blocks, so a
  card whose visible-first block is a tool-group dropped its
  turn entirely. Read the first underlying tool-call instead;
  degrade to null only when the group somehow holds zero tools.

Unit tests cover both regressions: tool-group as the first visible
block in a response group, explicit-zero cache sums preserved, and
the empty-tool-group degrade-to-null path.

* fix(web): dedup response-group turns by adjacency rather than set membership

The fingerprint fallback (used when localId is null) compared each
turn key against a Set of every key seen in the group. A response
group whose first and third turns happened to carry the same
(model, usage) fingerprint would collapse the third turn into the
first, under-counting the visible turn count.

Switch to ordering-based dedup: each block's turn key only collides
with the immediately previous turn. Adjacent blocks within one SDK
message still collapse (their usage object is identical), but
non-adjacent fingerprint matches across separate turns stay
distinct. Behavior under localId-stamped flows is unchanged because
distinct turns always carry distinct localIds.

Unit test covers a three-turn group whose first and third turns
share a fingerprint with a different middle turn between them.

* fix(web): aggregate every tool-call in a tool-group and dedup by createdAt fingerprint

`buildVisibleChatBlocks` merges adjacent eligible tool-calls into a single
`tool-group` without checking that they share a turn. Reading only the
first underlying tool would drop every later tool turn from the aggregate,
so each tool-call in the group now contributes its own turn source.

The fingerprint fallback (used when the CLI does not stamp `localId`)
gains `createdAt` as a third axis. The reducer copies `msg.createdAt`
onto every derived ChatBlock, so blocks from one SDK message still
collapse to one turn, while two adjacent turns that happen to coincide
on `(model, usage)` no longer dedup against each other. Same wall-clock
millisecond collisions remain theoretically possible but are bounded by
the hub stamp resolution.

Helper layer consolidates: `turnSourceFromBlock` (single-or-null) is
gone, replaced by `turnSourcesFromBlock` returning the array directly.
Test renames clarify the contract — the existing tool-group test now
documents the same-turn collapse case — and one new test pins the
fingerprint coincidence case.

* fix(web): make tool-only response cards expose aggregate metadata

`aggregateResponseGroups` keys aggregate metadata onto a response group's
first visible block, which can be a `tool-group` when the assistant turn
starts with tools. The `toolOnly` render branch did not wire the click
toggle that the default/codex branches use, so the new Models/Total/N-turns
footer stayed unreachable for those cards.

Wrap the toolOnly content with the same cursor-pointer div used in the
sibling branches (toggleMetadata, onMetadataKeyDown, role=button,
aria-expanded). Carry `min-w-0` on the wrapper so long tool labels keep
clipping under the existing `overflow-x-hidden` on MessagePrimitive.Root.

The shared `isNestedInteractiveEvent` guard prevents the wrapper toggle
from firing when nested tool buttons or disclosures are clicked.
2026-05-18 10:40:51 +08:00
Junmo KimandGitHub 6e32b20524 feat(web): linkify custom URI schemes with a confirm prompt (#633)
* feat(web): add UriConfirmDialog component

Add a Radix Dialog-based confirmation modal for custom URI scheme
navigation. Follows the RenameSessionDialog pattern.

- UriConfirmDialog: shows URI, scheme label, Cancel/Open/Always-allow buttons
- i18n keys: dialog.uri.{title,description,open,alwaysAllow}

* feat(web): autolink non-https URI schemes in markdown

Add a remark plugin that converts raw `scheme://...` text nodes into
link nodes for non-http(s) schemes. GFM already handles http/https;
this plugin handles the remainder (obsidian://, vscode://, slack://, etc.).

- No scheme allowlist: every `scheme://` pattern is converted; the
  sanitize layer (urlTransform) and onClick layer (classifyScheme) handle
  blocking/confirmation downstream.
- Runs before remarkStripCjkAutolink so the CJK-strip plugin sees the
  new link nodes and can trim trailing CJK punctuation from them.
- Trailing punctuation (.,;!?) stripped from matched URIs.
- Unit tests: conversion, partial-match, escape, explicit link bypass,
  code-block bypass, trailing-punct trimming.

* feat(web): linkify custom URI schemes via markdown <a> handler

Wire up 4-layer URI security policy in the markdown renderer:

1. URL sanitize (deny-only): urlTransform strips javascript:/data:/vbscript:/file:
   using classifyScheme as single source of truth (handles percent-encoding,
   case-insensitive, whitespace-prefix bypass patterns).

2. onClick intercept: custom <A> component classifies each href —
   - IANA safe (https/http/irc/ircs/mailto/xmpp): navigate directly.
   - Deny (javascript/data/vbscript/file): preventDefault silently.
   - Custom (obsidian/vscode/slack/…): preventDefault + open UriConfirmDialog.

3. UriConfirmProvider: one dialog lifted to each markdown root (MarkdownText,
   Reasoning, MarkdownRenderer). Shared isAllowed state across all <a> tags in
   the subtree — "Always allow" click updates every link in one React commit.

4. Intra-tab cross-provider sync (P7e.1): module-level schemeListeners Set so
   sibling UriConfirmProviders (MarkdownText + Reasoning in AssistantMessage)
   receive allowed-scheme updates synchronously without waiting for the window
   storage event (which only fires in other tabs). Cross-tab sync continues via
   the existing window storage event listener.

5. "Always allow" persisted to localStorage (hapi-allowed-schemes). Custom
   schemes once allowed navigate directly on subsequent clicks, no dialog gate.
   href="#" in DOM for unallowed custom schemes prevents middle-click bypass.
   Deny-scheme href="" prevents any navigation even if localStorage tampered.

Security: classifyScheme decodes percent-encoding before scheme extraction,
blocking %6Aavascript:, jav%61script:, javascript%3A (single-encoded colon)
and double-encoded variants. DENY_SCHEMES checked after localStorage lookup so
tampered allowed-list cannot promote deny schemes.

Tests: classifyScheme 6-axis security bypass, denyOnlyTransform, localStorage
roundtrip, cross-tab storage event, <A> click handler cases.

* fix(web): block control-char-spliced deny schemes in classifyScheme

Browsers silently strip ASCII control characters (\t, \n, \r) and
whitespace from URL scheme names during navigation. A scheme like
`java\nscript:alert(1)` was navigated as `javascript:` while our
literal string comparison classified it as 'custom', allowing it
past the deny list and into window.open().

Introduce normalizedScheme() that:
- applies 2 rounds of decodeURIComponent so double-encoded schemes
  (javascript%253A → javascript%3A → javascript:) are fully unwrapped
  before comparison
- strips [\x00-\x1F\x7F\s] from the extracted scheme name, matching
  the browser's own normalization

classifyScheme() now delegates to normalizedScheme() so both the
denyOnlyTransform (urlTransform) path and the <A> onClick path benefit
from the same normalization.

Tests added for \n / \t / \r / space spliced into scheme, and verify
that double-encoded colon is now caught via scheme-match (not just
the no-colon fallback).

* fix(web): preserve relative markdown links from being blocked

Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) were
silently preventDefault'd in <A>'s onClick handler. denyOnlyTransform
correctly passed them through (no colon → not a scheme URL), but the
click handler called classifyScheme(href) which returned 'deny' for
any input with no valid scheme separator — then the deny branch fired.

Add hasScheme(href): checks whether the first ':' appears before any
path/query/fragment boundary ('/', '?', '#'). When hasScheme is false
the href is treated as 'iana' so the browser or SPA router can navigate
normally with no dialog and no preventDefault.

Also wrap renderA() with <I18nProvider> so the UriConfirmDialog that
UriConfirmProvider may render does not throw outside its translation
context during tests.

Fixes a regression that broke all relative-path markdown links once the
custom-URI-scheme onClick handler was added.

* test(web): cover percent-encoded scheme control char + protocol-relative href

Round-5 internal hostile review noted two coverage gaps on the bot-fixup commits:

- `java%0Ascript:alert(1)` (percent-encoded newline in the scheme name) takes the
  same decode→strip code path as the literal `java\nscript:` case but was only
  tested literally. Add an explicit test so a future refactor that drops the
  decode-then-strip ordering would be caught.
- Protocol-relative URLs (`//host/path`) have no colon, so `hasScheme` returns
  false and `<A>` treats them as scheme-less — browsers then navigate them as
  the current origin's protocol. Existing relative-href tests covered absolute
  paths, hashes, queries, and colon-in-path, but not the protocol-relative
  variant. Add one assertion.

Also extend the `hasScheme` JSDoc to note that protocol-relative URLs are
intentionally treated as scheme-less.

* fix(web): preserve balanced parens/brackets in autolinked URIs

The trailing-punctuation strip used to drop every `)` / `]` from the end
of a matched URI, even when the URL body had an unmatched opener. So a
URI like `obsidian://open?file=Note(1)` was rendered with href
`obsidian://open?file=Note(1` plus a separate `)` text node, opening a
broken deep link.

Match the GFM autolink-literal behaviour: when the trailing character is
`)` or `]`, keep it iff the URL body has more opening counterparts than
closers (so the trailing closer balances an earlier opener and belongs
to the URL). Other trailing punctuation (`.,;!?:>'"`) and unmatched
closers still strip as before.

Add tests for the balanced cases (`Note(1)`, `Note[1]`, nested
`(a(b)c)`), the "balanced URL followed by a period" case, and a
regression test that an unmatched `).` after a URL is still stripped.
2026-05-18 10:40:32 +08:00
Junmo KimandGitHub 5512890a4c fix(web): bound scroll restoration cache by collapsing keys to pathname (#632) 2026-05-18 09:09:54 +08:00
Junmo KimandGitHub b2a30c2e39 feat(hub,web): support scheduling messages for future delivery (#590) 2026-05-18 09:09:17 +08:00
Junmo KimandGitHub e17d7e5995 fix(web): align Agent tool dialog with TUI ctrl+o expand (#585) 2026-05-07 08:28:03 +08:00
Junmo KimandGitHub 8185f0287e feat(web,hub): cancel queued messages (#568) 2026-05-06 13:32:45 +08:00
Junmo KimandGitHub de69027926 fix(acp): hoist Gemini edit/write content into Claude-shaped input (#575) 2026-05-06 13:31:25 +08:00
Junmo KimandGitHub 136badb86e fix(gemini): surface tool_call input on Gemini ACP cards (#562)
* fix(acp): derive tool_call input from kind+title fallback

Gemini 2.5 Flash and 3 Flash Preview omit rawInput entirely on
tool_call events while emitting prose (non-JSON) thoughts. Neither
the existing rawInput path nor JSON-thought hoisting fires, so the
UI shows "Input: null" alongside a perfectly readable title like
"README.md" or "ls -la /tmp".

Add a conservative fallback that maps known kinds to a minimal
input shape:

  read     -> { file_path: title }
  execute  -> { command: title }
  search   -> { pattern: title }
  think    -> null  (topic-update prose has no clean arg mapping)
  unknown  -> null  (no guessing on shapes we have not verified)

Priority: rawInput > hoisted JSON thought > kind+title derive.

Lock the new behaviour with synthetic unit tests (8 cases) and a
real-Gemini fixture suite captured from gemini-3-flash-preview
and gemini-2.5-flash via ACP stdio (4 fixtures, 33/27/13/4 raw
sessionUpdate events). The fixtures double as regression guards
against future ACP handler changes.

* fix(web): suppress duplicate subtitle when equal to tool title

Gemini ACP emits a tool_call whose title field is a human-readable
summary (often the verbatim shell command or file path). Combined with
the kind+title input fallback, an unknown-tool card ends up with the
same string in both the title and subtitle slots — e.g. title
"cat /tmp/hello.txt" over subtitle "cat /tmp/hello.txt".

Add a guard in getToolPresentation's unknown-tool branch: emit
subtitle only when it differs from toolName. The known-tool and
mcp__* branches are unaffected.

* test(acp): align Gemini fixtures to current model set

- Drop gemini-2.5-flash fixtures: the captures came from a model that
  is not part of the PR's evidence model set, and re-running the
  capture is gated on quota that is not currently available.
- Refresh gemini-3-flash-preview read_file / run_shell fixtures with
  a fresh live capture so they reflect the latest ACP shape (e.g.
  a `kind: think` tool_call expressing reasoning when the model emits
  no agent_thought_chunk).
- Update fixture-replay expectations: read_file no longer requires
  reasoning chunks (zero are emitted on this path) and now requires
  >= 2 tool_calls (think + read).

* feat(web): promote semantic title for Gemini ACP tool cards

When the unknown-tool ToolCard would render the same string as both
the title and the subtitle, promote a semantic label to the title
slot so the card reads like a sentence:

  cat /tmp/hello.txt   →   Run shell  / cat /tmp/hello.txt
  README.md            →   Read file  / README.md
  *.ts                 →   Search     / *.ts

This is a web-only ergonomic change; the underlying ACP message
shape (tool_name = title, input = derived from kind+title) is
unchanged. Builds on the dedup guard so the title-equals-subtitle
case is now handled by promotion rather than by hiding the subtitle.

* fix(acp): derive tool_call.input for kind=edit from locations[0].path

Gemini's write_file and replace tools both surface as ACP tool_call
with kind="edit" and rawInput omitted. The path lives on locations[0]
from the very first event; the title is prose like "Writing to foo.txt"
or "foo.txt: old => new", which is not safely usable as a file_path.

Extend the kind+title fallback to read locations[0].path when kind is
"edit", and synthesize { file_path } from it. Title fallback is
intentionally not used here so we never feed prose into file_path.

Lock the behaviour in with two new fixtures captured live from
gemini-3-flash-preview (write_file and replace) plus two synthetic
unit tests covering the locations-present and locations-empty paths.

* test(acp): add gemini-3.1-pro-preview fixtures for regression coverage

Captured 4 raw ACP `sessionUpdate` sequences from a live
`gemini-3.1-pro-preview` session via the same isolated hub +
runner + spawn pattern used for the existing flash captures
(read_file 31 events / run_shell 83 events / write_file 4 events /
edit_file 11 events).

The pro tier reuses the same kind/title shape as flash:
`rawInput` is omitted on every tool_call across read / execute /
edit kinds, so the kind+title (and locations[0].path for edit)
fallback is exactly what derives the modal Input. Locking these
fixtures in guards against future regressions on a second model.

The fixture-based regression test gains 4 entries (read / shell /
write / edit) mirroring the flash matrix; assertions are unchanged.
ACP handler suite: 53 -> 57 pass.
2026-05-05 18:20:54 +08:00
Junmo KimandGitHub d9d7ed6699 feat(web): show message metadata (invoke time, duration, model) on click (#555)
* 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.
2026-05-03 12:51:22 +08:00
Junmo KimandGitHub 9ee014098a feat(opencode): support model selection and mid-session model change (#558)
* refactor(opencode): declare ModelChange capability and add model field to OpencodeMode

Mark opencode flavor as supporting model change by adding Capabilities.ModelChange
to FLAVOR_CAPS.opencode. Add optional `model` field to OpencodeMode so the
set-session-config handler can carry a model alongside the existing permissionMode.

Pure structural change: no behavior change yet. The mid-session model change RPC
and UI wiring follow in subsequent commits, gated by this capability.

* feat(acp): branch setModel by flavor, capture session models metadata, expose getSessionModelsMetadata on AgentBackend interface

Adds an optional `flavor` argument to `AcpSdkBackend.setModel` so it can
dispatch the right `session/*` RPC for each agent flavor without changing
the call site Gemini already uses. Both Gemini and OpenCode wire to
`session/set_model`; the OpenCode response only carries `_meta.opencode`,
so the backend updates the cached `currentModelId` optimistically while
preserving the previously captured `availableModels`.

Captures `availableModels` and `currentModelId` from `session/new` /
`session/load` / `session/set_model` responses into per-session metadata,
exposed as `getSessionModelsMetadata(sessionId)` on the `AgentBackend`
interface so the hub can forward the snapshot to the web client.

* feat(opencode): accept model in set-session-config RPC and forward to launcher

Mirror the Gemini set-session-config handler so the web UI can change the
OpenCode model mid-session. Validates incoming model strings, persists null
("Default") for keepalive metadata, and pushes a keepAlive immediately so
the hub UI reflects the change without waiting for the next 2s tick.

Forward the model through opencodeLoop and into queued OpencodeMode entries
so the launcher can detect a per-batch model change. Add a setModel helper
on OpencodeSession to store the chosen model on the shared session base.

Wire --model up the runner path: parse --model <value> in commands/opencode.ts
and stop excluding opencode in buildCliArgs so the runner spawns OpenCode
with the user-selected initial model.

* feat(opencode): switch model mid-session via ACP RPC

Mirror the Gemini pattern from PR #543: when a user picks a different model
between turns, call backend.setModel with flavor='opencode' so the ACP backend
sends session/set_session_config_option (configId='model') to the running
OpenCode CLI. The next turn then runs against the new model.

The first batch on a fresh session seeds currentBackendModel without firing the
RPC — the OpenCode CLI was launched with that model via --model and there is
nothing to switch yet. If the running build does not implement the RPC we learn
that from the first method-not-found response, latch inline switching off, and
notify the user once. Other errors fall back to the previous model and surface
a one-line failure message.

* feat(hub): expose model selection and discovery for OpenCode sessions

Generalize the /sessions/:id/model guard via supportsModelChange so any flavor
that advertises the ModelChange capability becomes accepted automatically. This
piggybacks on the capability SSOT introduced in PR #400 and turns OpenCode on
without listing flavors inline.

Add a /sessions/:id/opencode-models endpoint that mirrors the existing
codex-models pattern. The endpoint forwards a per-session listOpencodeModels
RPC to the running OpenCode launcher, which returns the availableModels and
currentModelId metadata captured from the ACP session/new and
session/set_session_config_option responses. The web UI consumes this to
render the model dropdown without round-tripping ACP itself.

* feat(web): render OpenCode model dropdown in the chat composer

Mirror the Codex pattern in SessionChat: query /sessions/:id/opencode-models
via a new useOpencodeModels hook and feed the result into the composer's
availableModelOptions. The AssistantChat model dropdown now lists the user's
ollama / mlx / OpenCode Zen models with the same provider/model label that the
ACP server reports, so the picker matches the OpenCode TUI.

Stop falling back to the Claude composer model list when the flavor is
opencode and no custom options are supplied — that fallback briefly surfaced
unrelated Claude models in OpenCode sessions before the RPC response landed.
The NewSession flow keeps an empty MODEL_OPTIONS.opencode for now: model
discovery requires an active OpenCode ACP session, so the dropdown becomes
available once the session boots and stays empty (and hidden) at creation
time.

* feat(cli,hub): add cwd-based OpenCode model discovery RPC

Adds a short-lived `opencode acp` probe that runs `initialize` +
`session/new` against a target cwd to read the `availableModels` /
`currentModelId` snapshot, then tears the subprocess down. Results are
cached for 60s per cwd and concurrent probes coalesce into a single
spawn.

Exposes the probe through:
- `listOpencodeModelsForCwd` JSON-RPC handler on the CLI
- `RpcGateway.listOpencodeModelsForCwd` / `SyncEngine.listOpencodeModelsForCwd`
- `GET /api/machines/:id/opencode-models?cwd=...` on the hub

This lets the web NewSession form discover OpenCode models for a chosen
directory before any session is spawned.

* feat(web): add OpenCode model selector to NewSession with loading and default highlight

Adds a `OpencodeModelSelector` panel that the NewSession form swaps in
when the OpenCode flavor is selected. The panel:

- queries the new `GET /api/machines/:id/opencode-models?cwd=...` endpoint
  via `useOpencodeModelsForCwd` (TanStack Query, 60s staleTime, no retry),
- shows a labelled spinner + skeleton rows while discovering,
- renders an inline error with a Retry button when probing fails,
- renders an empty-state message when the directory yields no models,
- highlights the OpenCode-reported `currentModelId` with a "Default" badge
  and auto-selects it (or the first option) so the form has a sensible
  value if the user hits Enter without scrolling.

Selection is reset whenever the agent / machine / directory changes so a
new probe can establish a fresh default. Directory input is wrapped in
`useDeferredValue` so per-keystroke edits do not spawn a fresh
`opencode acp` probe. The chosen model is forwarded on session spawn
via the existing OpenCode `model` parameter.

Adds en + zh-CN locale strings for the loading / failure / empty / retry
/ default-badge labels.

* fix(cli): guard /machines/:id/opencode-models handler with workspace root check

The machine-scoped `listOpencodeModelsForCwd` RPC handler was registered by
`registerCommonHandlers` without any workspace-root check, so a web client
could pass an arbitrary `cwd` and have the runner spawn an `opencode acp`
subprocess plus a `session/new` against that path. That broke runner
isolation, since peer machine-scoped handlers (`list-directory`,
`spawn-happy-session`) already enforce the configured workspace root.

Re-register the handler in `ApiMachineClient` so it reuses the existing
`resolveForWorkspaceCheck` (realpath-based, with missing-tail walking) and
`isWithinWorkspaceRoot` helpers before delegating to the lower-level probe.
The resolved cwd is forwarded down so symlinked-but-contained paths still
work, while traversal attempts are rejected with the same error shape the
peer handlers use. Added unit tests around the new dispatch path. Addresses
HAPI Bot review on PR #558.

* fix(web): gate opencode model discovery on cwd existence

The new-session form previously enabled `useOpencodeModelsForCwd` as
soon as the OpenCode agent, machine, and any non-empty directory string
were present. Because that hook calls `/machines/:id/opencode-models`
and the CLI handler starts an `opencode acp` probe for that cwd, normal
typing through partial paths could launch expensive 30s OpenCode
subprocesses for non-existent directories before the path-existence
result had validated the final cwd.

Reorder NewSession so `useMachinePathsExists` runs before
`useOpencodeModelsForCwd`, then gate the discovery hook on the
directory having been positively confirmed to exist
(`pathExistence[deferredDirectory] === true`). The decision is
factored into a small pure helper `shouldEnableOpencodeModelDiscovery`
so the contract can be unit-tested without provider scaffolding.

* fix(web): keep current opencode model on shortcut without dynamic options

`getNextModelForFlavor` is invoked by the global Ctrl/Cmd+M shortcut in
`HappyComposer`, which is now active for OpenCode sessions because the
agent backend declares the `ModelChange` capability. When the dynamic
OpenCode model list has not yet been loaded — e.g. the user presses the
shortcut before `/opencode-models` returns — the function received an
`undefined`/empty `customOptions` and fell through to the Claude preset
cycler, which would emit `sonnet`/`opus` for an OpenCode session. The
following turn then attempted `session/set_model` with a Claude model id
that no OpenCode provider can serve.

Add an `opencode` branch that returns the (normalized) current model
unchanged when no dynamic options are available, mirroring the existing
empty-list policy of `getModelOptionsForFlavor`. Unit tests cover the
undefined / empty / null-current-model variants and lock the
no-Claude-fallback contract. Addresses HAPI Bot review on PR #558.
2026-05-03 12:50:22 +08:00
Junmo KimandGitHub 7d55bc1456 feat(web): float queued messages above composer until invocation (#542)
* refactor: add invoked_at column and propagate via messages-consumed

- Bump hub schema to V8: add `invoked_at INTEGER` to messages table
- Add `migrateFromV7ToV8` (idempotent ALTER TABLE ADD COLUMN)
- Add migration chain entries for V4/V5/V6/V7 → V8
- Expose `StoredMessage.invokedAt: number | null` and `markMessagesInvoked`
- Record server-side `Date.now()` in hub on `messages-consumed` socket event
- Propagate `invokedAt` through SSE (`messages-consumed` payload)
- Update `markMessagesConsumed` in web store to accept and store `invokedAt`
- Preserve optimistic `invokedAt` in `mergeMessages` (server echo path)
- Add migration unit tests (fresh V8, V7→V8 ALTER, markMessagesInvoked)

* feat(web): float queued messages above composer until invocation

Show queued (uninvoked) user messages in a dedicated floating bar above
the composer instead of inline in the thread timeline. Once the CLI acks
the batch via messages-consumed, the bar disappears and the messages
appear in the thread at their invocation position (invokedAt ordering).

- Add QueuedMessagesBar component: subscribes to message-window-store,
  filters user messages with invokedAt==null, shows clock icon + text
  preview; disappears when all messages are invoked
- Filter queued messages from thread (visibleMessages), sort by
  invokedAt ?? createdAt so invoked messages land at the right position
- Extend markMessagesConsumed to update server-loaded messages (status
  undefined) in addition to optimistic (status 'queued'), enabling
  multi-device and post-refresh scenarios
- Remove opacity-60 from UserMessage: queued messages no longer appear
  in the thread so the dimming branch is unreachable
- Include invokedAt in getMessagesPage/getMessagesAfter API responses
  so the web client can restore floating-bar state after page refresh
- Add invokedAt field to DecryptedMessageSchema for shared protocol type

* fix(hub,web): make sort use invokedAt and V8 backfill idempotent

- compareMessages: prioritize invokedAt/createdAt over seq so invoked
  messages land at their invocation position rather than their
  send-time seq position
- migrateFromV7ToV8: move backfill outside the ALTER guard so it
  re-runs if a previous attempt crashed between ALTER and UPDATE
  before the user_version bump (idempotent WHERE invoked_at IS NULL)

* fix(hub,web): cover localId-less messages and live-ack invokedAt

- addMessage: messages without a localId have no ack path
  (markMessagesInvoked matches by localId). Treat them as
  already-invoked at insert time so they land in the thread instead of
  sitting in the queued floating bar forever.
- markMessagesConsumed: apply the ack even when the message is already
  'sent' optimistically, so the live window receives invokedAt instead
  of waiting until a full refetch.

* fix(hub): propagate invokedAt in live message-received SSE payload

The SSE `message-received` event omitted `invokedAt` while REST
pagination included it, so localId-less CLI/local user messages arrived
on the live wire as queued (`invokedAt == null`) and stayed in the
floating bar until a full refetch replaced them with the stored row.

* fix(hub): propagate invokedAt in CLI socket message-received handler

The CLI socket 'message' handler fans out to web via a separate
`onWebappEvent` publisher; the previous fix only touched the
`MessageService` publisher. Aligns the live SSE payload shape with
the REST/page-load shape so localId-less CLI/local user messages with
`invokedAt = createdAt` (set in addMessage) reach web filters with the
field already populated, instead of being misclassified as queued
until a full refetch.

* fix(hub,web): add byPosition pagination to fix long-session queued message loss

Pagination used seq-based windows, so queued messages with low seq but late
invokedAt fell outside the visible window on refresh. Fix by adding a V8
byPosition mode that orders by COALESCE(invoked_at, created_at) DESC, seq DESC
with a composite cursor, while keeping the V7 seq path fully intact for
backward compatibility.

- hub/store/index: add idx_messages_session_position (createSchema + V7→V8 migration)
- hub/store/messages: add getMessagesByPosition with composite cursor SQL
- hub/store/messageStore: delegate getMessagesByPosition
- hub/sync/messageService: add getMessagesPageByPosition with nextBeforeAt response
- hub/sync/syncEngine: expose getMessagesPageByPosition
- hub/web/routes/messages: byPosition=1 query param dispatches to V8 path
- web/types/api: MessagesResponse.page gains optional nextBeforeAt
- web/api/client: getMessages gains byPosition + beforeAt options
- web/lib/message-window-store: fetchLatestMessages/fetchOlderMessages use V8
  composite cursor; fallback to seq cursor when hub returns no nextBeforeAt
- hub/store/migration-v8.test: 7 new tests covering position sort, composite
  cursor pagination, long-session scenario, V7 compat, and index existence

* fix(hub,web): re-sort on consume and use position cursor for next fetch

- markMessagesConsumed: re-merge with empty list to re-sort by position
  key after invokedAt is set. A queued user message becomes visible
  with the consume event; without re-sort it stays at its send-time
  array slot until the next fetch overwrites it.
- getMessagesPageByPosition: pick the cursor from stored[0] (oldest in
  position order) instead of scanning for minimum seq. With the page
  already in ascending position order, scanning for min seq could land
  on a low-seq, late-invoked row that is actually the newest in the
  page, causing the next older fetch to overlap.

* fix(web): trust invokedAt as the only invocation signal and pin cursor pair

- visibleMessages predicate (SessionChat + QueuedMessagesBar): drop the
  status === 'sent' check. status='sent' only means the REST write
  returned, not that the CLI consumed the message; an optimistic 'sent'
  with no invokedAt is still queued. invokedAt is the single source of
  truth for invocation.
- byPosition cursor: track oldestPositionSeq alongside oldestPositionAt
  so the server's cursor pair travels through the next older fetch
  unchanged. Recomputing beforeSeq from the local window's minimum seq
  could combine it with a server beforeAt that referred to a different
  row, causing the SQL cursor to skip or overlap.

* fix(hub): include uninvoked local messages in latest page

Long sessions can push a queued user message (invokedAt = null, sort key
= createdAt) outside the latest position-ordered page once the agent
emits more than `limit` later rows. A refresh or secondary client then
never receives the row, the floating bar stays empty, and the later
`messages-consumed` event only carries localIds — there is no way to
materialize the missing row at invocation time.

Pin uninvoked local user messages to every latest-page response
out-of-band. The pagination cursor still anchors to the position-ordered
page rows, so older-page fetches are unaffected.

* fix(web): preserve queued messages across trimVisible

The visible-window trim drops the oldest entries beyond
VISIBLE_WINDOW_SIZE, but a queued user message (invokedAt = null) sorts
by send time and is the oldest item. Once a long agent stream pushes
it past the window the row is gone from the client store, and the
`messages-consumed` SSE carries only localIds — there is no way to
restore or reposition the dropped row without a full refetch.

Pull queued rows out before slicing the regular budget, then merge
them back in. Queued rows are bounded by composer/CLI queue depth and
do not meaningfully grow the window.

* fix(web): use strict null for queued check and fall back invokedAt

- Optimistic message sets invokedAt: null explicitly so the strict-null
  queued check matches the local opt-in. Pre-V8 hub responses that
  omit the field (`undefined`) are treated as already-invoked and
  stay in the thread instead of being misclassified as queued.
- markMessagesConsumed: when the consume SyncEvent omits invokedAt
  (older hub) fall back to client time, otherwise a message that
  receives an ack with no server timestamp stays queued forever under
  the new strict-null filter. The persisted server value is still
  authoritative on next fetch.

* fix: comprehensive invokedAt propagation hardening (review feedback batch)

Bot review surfaced 11 propagation bugs incrementally; this batch fixes
9 additional adjacent issues found by hostile-review to break the
incremental discovery cycle:

- legacy DB (user_version=0 with HAPI tables): step ladder runs V1→V8
  before createSchema so pre-existing tables get all later columns/indexes
- step ladder includes V1/V2/V3 entries; previously V1-V3 DBs threw
- mergeSessionMessages collision branch forces invoked_at = created_at
  so unmergeable rows can't strand in the floating bar
- session-end auto-invokes still-queued user messages and broadcasts
  messages-consumed; the floating bar no longer pins ghost rows after
  the CLI is gone
- trimPending preserves queued rows symmetrically with trimVisible
- markMessagesInvoked is first-write-wins; duplicate acks are no-ops
  rather than re-stamping invoked_at and reordering the thread
- markMessagesConsumed migrates just-acked pending entries into the
  visible thread so non-at-bottom users see their own messages without
  scrolling
- mergeMessages dedup window compares by position key (invokedAt ?? createdAt)
  instead of createdAt only, so late-invoked optimistic copies don't
  duplicate the server echo
- isQueuedForInvocation centralized in lib/messages.ts (single
  predicate used by SessionChat, QueuedMessagesBar, and the store)

* fix(web): mirror hub's first-write-wins on markMessagesConsumed

The hub's markMessagesInvoked is first-write-wins, but the web store
was still overwriting any non-null invokedAt with the latest
messages-consumed timestamp. A duplicate ack (CLI re-emit) would leave
the SQLite row at the original timestamp while live clients moved
the message to the duplicate ack time, diverging until refetch.
Mirror the guard: only set invokedAt when it is null.

* fix: in-scope hostile-review polish

Web:
- fetchLatestMessages: persist the V8 composite cursor pair on the
  non-at-bottom branch too. Without this, a refresh while scrolled
  up dropped the cursor and the next loadMore fell back to V7 seq
  mode against a V8 hub — same asymmetric class of bug commit
  30df6b2 fixed for the at-bottom path.
- markMessagesConsumed: tighten the loose-null check on invokedAt
  to strict null, consistent with isQueuedForInvocation and the
  rest of the file. The idSet filter already shields V7-stamped
  rows from this path, but the strict-null contract should not
  vary by call site.
- messages: drop the upsertMessagesInCache export. It has no
  callers (verified with grep) and is the only user of the
  InfiniteData / MessagesResponse imports, so the imports go
  with it.

Hub tests:
- migration-v8.test.ts: add a session-end auto-invoke test
  (getUninvokedLocalMessages + markMessagesInvoked clears every
  queued row and stamps them all with the same invokedAt) and
  two byPosition union tests covering (1) a low-position queued
  row pushed out of the latest page is still surfaced via the
  uninvoked set, and (2) pageRows[0] is the oldest row in the
  page so the web client can safely anchor the next-older
  cursor on it.

* fix(hub,web): bot-13 polish — atomic SSE on DB success and attachment chip text

- sessionHandlers messages-consumed: emit messages-consumed only after
  markMessagesInvoked succeeds. Otherwise a transient SQLite failure
  would broadcast an invokedAt that was never persisted; live clients
  would hide the queued rows while a refresh / secondary client would
  see them as queued again, diverging the state.
- QueuedMessagesBar: fall back to attachment filenames when the
  message text is empty. The composer / POST /messages allow
  attachment-only sends; without the fallback those queued messages
  rendered as blank chips until invocation.
2026-04-29 17:23:01 +08:00
Junmo KimandGitHub 0160da4bb5 fix(gemini): switch model mid-session via ACP RPC (#543)
When a user selects a different Gemini model from the Web UI mid-session,
the running `gemini --experimental-acp` process kept using the model it
was launched with. The Web UI reflected the new selection, but the next
response was still produced by the original model.

Changes:
- AcpSdkBackend: add `setModel` wrapping the `session/set_model` RPC.
  Errors propagate as standard rejections, matching every other
  `sendRequest` call in this file.
- geminiRemoteLauncher: detect model changes between turns and call
  `backend.setModel` on the live ACP session — no process restart, no
  MCP reload. If the running gemini-cli build returns method-not-found,
  the launcher learns once, surfaces a single advisory message, then
  silently honors the previous model for the rest of the session.
- AgentSessionBase.pushKeepAlive: small helper used by runGemini to
  broadcast new config to the hub immediately after `set-session-config`.
- Both layers serialize the switch — the launcher attempts `setModel`
  only between batches, and `AcpSdkBackend.setModel` defensively awaits
  `waitForResponseComplete()` before issuing the RPC.

Tests:
- New `geminiRemoteLauncher.test.ts` covers: setModel called between
  turns when the model differs; not called when unchanged; the
  method-not-found capability latch; transient errors continue with the
  previous model; setModel is serialized after the prior prompt.
- `runGemini.test.ts` asserts pushKeepAlive fires from the
  `set-session-config` handler.
2026-04-29 09:22:50 +08:00
Junmo KimandGitHub 04fbc0d37f fix(hub,web): apply selected permission mode when resuming inactive sessions (#540)
Previously, toggling the permission mode on an inactive session had no
effect on resume: the /permission-mode endpoint rejected inactive sessions
(HTTP 409), so the cache was never updated, and the spawned CLI always
received the stored default value.

- Remove the `requireActive` guard from POST /sessions/:id/permission-mode
  so inactive sessions can have their in-memory permission mode updated.
- In `SyncEngine.applySessionConfig`, skip the RPC call for inactive
  sessions and update the in-memory cache directly; the value is then
  available when the session is resumed.
- Accept an optional `{ permissionMode }` body in POST /sessions/:id/resume
  and forward it to `resumeSession` (takes precedence over the cached value),
  with flavor-compatibility validation.
- Extend `SyncEngine.resumeSession` with an optional `opts` argument so
  callers can supply a permission mode override at resume time.
- Update the web client (`api.resumeSession`) and `router.tsx` to pass
  `session.permissionMode` in the resume request body.
2026-04-28 17:42:05 +08:00
Junmo KimandGitHub 52ec08b6cb feat(web): show subagent task trace in tool dialog (#539)
* refactor(web): extract shared task tool helpers

* feat(web): show subagent task trace in tool dialog

Task tool modals previously showed only Input and Result. This adds a
Trace section between them that surfaces the child tool calls already
wired through the reducer into block.children.

- TraceSection collapses by default when completed, expands when
  running or error so the relevant state is visible on open
- Each child row toggles an inline expand (Input/Result) to avoid
  nested Dialogs
- Header summarises call count, token total and duration via
  readSummaryFields() typed parser, falling back gracefully when any
  value is absent
- formatTaskChildLabel / TaskStateIcon imported from shared helpers.tsx
  (extracted in prior refactor commit) — no local duplicates
- Task name guard: getTaskTraceChildren returns null for non-Task blocks
- children prop renamed to items in TraceSectionInner / TraceChildList
  (react/no-children-prop anti-pattern removed)
- i18n: tool.trace and tool.trace.callsSuffix keys added for en and
  zh-CN; useTranslation hooked up to header label and calls suffix
- 15 unit tests: getTaskTraceChildren (guard, filter, non-Task null),
  getTraceSummaryText (3 branches), TraceSection (open/close/toggle/
  summary/empty)

* feat(web): include input view in trace row expand

Expanded child rows in the Task trace section now render both an Input
section and a Result section, matching the pattern used in the parent
ToolCard dialog. Tools with a registered FullInputView use it; all
others fall back to a JSON CodeBlock. Closes bot review on PR #539.
2026-04-28 10:55:10 +08:00
Junmo KimandGitHub b712ee67a5 fix(web): fall back to getRandomValues when crypto.randomUUID is unavailable (#523)
crypto.randomUUID is only exposed in secure contexts (HTTPS or
localhost). When the web app is served over HTTP on a LAN IP the
attachment adapter, toast provider, message localId helper, file
attachment metadata and terminal id creation all call
crypto.randomUUID() synchronously and throw TypeError, so the UI
silently does nothing (e.g. the file picker opens and closes with no
chip).

Add a small web/src/lib/randomId helper that tries crypto.randomUUID
first, then falls back to crypto.getRandomValues-derived UUID v4,
and finally to a Date.now/Math.random string for very old
environments. Route all five call sites through it. Output format is
identical for secure contexts and UUID v4 for the getRandomValues
path, so existing DB/SSE/RPC consumers see the same shape.
2026-04-24 13:59:19 +08:00
Junmo KimandGitHub 82703b85fb fix(acp): normalize tool_call_update content for agents without rawOutput (#521) 2026-04-24 08:13:34 +08:00
Junmo KimandGitHub 96d766d7f1 feat(acp): forward agent_thought_chunk as reasoning message (#520)
* refactor(agent): extend AgentMessage and CodexMessage unions with reasoning variant

Add a reasoning variant to the shared AgentMessage union that flows
out of the ACP backend, and pass it through to CodexMessage so the
existing web reducer (which already renders { type: 'reasoning' }
parts as collapsible blocks) can consume ACP-sourced thoughts
identically to Codex.

No behavior change yet: the ACP handler still drops thought chunks,
and the remote launchers receive the new variant as a no-op. The
behavior is wired up in the following commit.

* feat(acp): forward agent_thought_chunk as reasoning message

Route ACP thought chunks to the session as reasoning AgentMessages so
OpenCode and Gemini thinking output reaches the web UI's Reasoning
block, matching the existing Codex behavior.

Thought chunks are emitted inline without flushing the pending text
buffer — text and thought live on independent interleave lanes, so
splitting a live text segment on every thought arrival would be
wrong. The inline-emit ordering is documented alongside the test
that depends on it.

extractTextContent is not reused for thought content: its
assistant-audience filter is correct for regular message chunks but
would silently drop thoughts annotated with a non-assistant audience,
which have no meaningful audience to filter against. A direct text
block shape check handles the narrower need.

In the remote launchers, reasoning is surfaced to the local terminal
buffer as a truncated system-role hint prefixed with [Thinking],
matching how the Codex flavor already displays reasoning chunks
in-terminal without mixing them into the assistant reply stream.
2026-04-23 20:45:20 +08:00
Junmo KimandGitHub 3405b56ff2 fix(cli): preserve intra-turn order between text and tool updates in ACP (#505) 2026-04-21 21:10:08 +08:00
Junmo KimandGitHub 32755f9056 feat(web): show queued status for messages pending inference (#492) 2026-04-20 19:49:26 +08:00
Junmo KimandGitHub e6eaff83c5 fix(hub,cli): forward permissionMode on session resume (#460)
* feat(hub,cli): forward permissionMode on session resume

When a session is resumed, the cached permissionMode is now forwarded
through the Hub → Runner → CLI pipeline via a new --permission-mode
flag. Previously the mode was lost on resume, resetting to 'default'.

Each CLI flavor validates the flag value against its own allowed
permission modes (e.g. CLAUDE_PERMISSION_MODES) and rejects unknown
values. The existing --yolo flag is preserved as a shorthand.

* refactor(cli): extract buildCliArgs from startRunner

Extract the CLI argument construction logic into a standalone
exported function so it can be unit-tested independently.
No behavior change.

* test(cli): add buildCliArgs unit tests for --permission-mode

Verify that the runner correctly forwards valid permission modes
via --permission-mode, rejects invalid values, and falls back to
--yolo when no permission mode is set.

* fix(cli): let --permission-mode take precedence over --yolo

When both flags are present, --permission-mode was silently
overwritten by a later --yolo. Guard legacy flag branches with
a hasExplicitPermissionMode check so the explicit flag wins.
2026-04-15 11:13:21 +08:00
Junmo KimandGitHub c32378b3ba feat(web): persist composer draft across session switches (#438)
* feat(web): persist composer draft across session switches

Switching between sessions now preserves the text typed in the
composer. Drafts are stored per-session in sessionStorage and
restored when the user navigates back.

- Add composer-drafts utility (sessionStorage, in-memory cache)
- Restore draft on HappyComposer mount, save on unmount
- Clear draft on message send
- Evict oldest drafts when exceeding 50 entries
- Add unit tests for composer-drafts

Fixes #231

* fix(web): add key prop to HappyComposer for explicit remount on session switch

* fix(web): move clearDraft to SessionChat after send validation

Prevents draft loss when Codex rejects an unsupported slash command.

* fix(web): remove explicit clearDraft, rely on unmount save

Successful sends clear the composer text, so the unmount save
naturally persists an empty string which deletes the draft entry.
This avoids clearing the draft when the send is blocked or fails.

* fix(web): clear draft on successful send via onSuccess callback

Move draft clearing to the send-success path so drafts are only
removed after the message is actually accepted by the server.

* fix(web): pass session ID to onSuccess to clear correct draft

The previous version used the current route's sessionId, which could
clear the wrong draft if the user switched sessions before the send
completed.

* test(web): add useSendMessage onSuccess callback tests

Verify that onSuccess receives the correct session ID (including
resolved IDs), and is not called on send failure or block.

* refactor(web): extract useComposerDraft hook with unit tests

Extract the draft save/restore logic from HappyComposer into a
dedicated useComposerDraft hook. Adds 6 unit tests covering:
- mount: restores saved draft via rAF
- mount: skips restore if composer already has text
- mount: skips restore if no saved draft
- unmount: saves current text after rAF has fired
- unmount: skips save before rAF (draftReady guard)
- no-op when sessionId is undefined

* fix(web): clear both route and resolved session drafts after send

When resolveSessionId swaps the session (e.g. inactive → resumed),
the sent ID differs from the route's session ID. Extract
clearDraftsAfterSend so both are cleared and unit-testable.

* fix(web): refresh eviction order when updating an existing draft

Delete the key before re-inserting so Object.keys() reflects the
most recent write, preventing a recently edited draft from being
evicted first.
2026-04-12 11:04:52 +08:00
Junmo KimandGitHub 2eae161139 fix: filter rate_limit_event from Claude Remote/Local chat paths (#423) 2026-04-09 20:16:34 +08:00
Junmo KimandGitHub 2f61852a9e refactor: extract local agent spawn helper and unify process tree cleanup (#410) 2026-04-07 09:50:40 +08:00
Junmo KimandGitHub ea09663cdc refactor: organize model definitions and flavor capabilities into dedicated modules (#400) 2026-04-05 22:49:29 +08:00
Junmo KimandGitHub 00ba610ab0 feat: display rate limit warnings instead of raw JSON (#388)
* refactor(web): extract normalizeTimestamp helper in presentation

Extract the shared seconds-vs-milliseconds normalization logic into
a private `normalizeTimestamp()` helper. No behavior change —
`formatUnixTimestamp()` produces identical output.

* refactor(web): return AgentEvent from parseClaudeUsageLimit

Change return type from `number | null` to `AgentEvent | null` so
the caller doesn't need to construct the event object. No behavior
change — the same `limit-reached` event is produced.

* feat(cli): convert rate_limit_event to standardized text format

Parse undocumented Claude `rate_limit_event` JSON in the CLI adapter
layer (AcpMessageHandler) before it reaches the web.

Converted text format (pipe-delimited):
  - "Claude AI usage limit warning|{ts}|{pct}|{rateLimitType}"
  - "Claude AI usage limit reached|{ts}|{rateLimitType}"

Status handling:
  - `allowed_warning` → warning text with utilization and limit type
  - `rejected` → reached text with limit type
  - `allowed` → silently suppressed (noise)
  - unknown statuses → passed through as-is (forward-compatible)

* feat(web): display rate limit warnings with limit type

Parse standardized pipe-delimited text from the CLI adapter into
`limit-warning` and `limit-reached` events, displaying the rate
limit type (5-hour, 7-day) when available.

- `limit-warning`: "⚠️ Usage limit 90% (5-hour) · resets 2:00 PM"
- `limit-reached`: " Usage limit reached (5-hour) until 4/2/2026"
- Backward compatible: `limit-reached` without limitType still works

The `reached` regex uses `(?:\|([^|]*))?$` to optionally match the
limitType field, maintaining compatibility with the existing format.

* refactor(cli): move rate limit parsing out of flushText

Remove rate limit detection from flushText() back to plain buffer
flush. The next commit will re-add parsing at the chunk level
(handleUpdate) where it can intercept before buffer merging.

Includes failing tests that demonstrate the mixed-chunk bug:
when a rate_limit_event chunk arrives in the same turn as normal
text, the JSON leaks into the merged buffer.

* fix(cli): intercept rate_limit_event at chunk level, not flush

Move rate limit detection from flushText() to the agentMessageChunk
handler so it fires before the chunk enters the shared text buffer.

Previously, a rate_limit_event chunk arriving in the same turn as
normal text would merge into bufferedText and leak as raw JSON.
Now the chunk is intercepted individually, the existing buffer is
flushed first (preserving prior text), and the converted message
is emitted separately.

* fix(cli): skip flush when suppressing allowed rate_limit_event

Only flush the text buffer when the parsed event will actually be
displayed. Suppressed events (e.g. status: 'allowed') now return
immediately without flushing, preventing a text → allowed → text
sequence from splitting one answer into two agent-text blocks.

* fix(web): include limitType in limit-reached reconcile key

Without this, reprocessing a message from the old format (no
limitType) to the new typed format reuses the stale block and
the (5-hour)/(7-day) suffix never appears.
2026-04-03 08:25:22 +08:00
Junmo KimandGitHub 4eb88c5d7e feat(gemini): support mid-session model change (#379) 2026-04-01 11:15:11 +08:00
Junmo KimandGitHub 3a073dc4a9 fix(gemini): wire --resume flag through to Gemini backend (#378) 2026-03-31 10:46:21 +08:00
Junmo KimandGitHub d76b1a6ac0 refactor: introduce model-agnostic agent interfaces (#323) 2026-03-20 08:45:32 +08:00