Claude Code writes failed API calls ("Prompt is too long", usage limits,
logged out) as synthetic assistant records whose usage carries
`service_tier: null`. The schema only accepted undefined, so the local
session scanner dropped every one and the web session just looked stalled.
- Accept null for the usage fields the Messages API types as nullable.
- Keep the top-level `error`/`isApiErrorMessage` on assistant records in
local mode, and `error` in the SDK converter for remote mode.
- Render assistant records carrying `error` as the existing error event,
so the all-zero synthetic usage no longer resets the context gauge.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: show Claude sessions waiting on background tasks (#2100)
backgroundTaskCount was always 0 for Claude: the hub parsed the
"Command running in background" tool result only from tool_result /
assistant records (Claude sends it as a user record), and waited for a
<task-notification> user record that current Claude Code never emits.
Set the count from Claude's system/background_tasks_changed snapshot,
which the CLI already forwards and which lists every running background
task (empty once the last one finishes).
In the session list, waiting on background work now shows a blue
spinner instead of a static blue dot (too close to the "needs input"
dot) or the green "running" pulse used for selected and pinned rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): forward background_tasks_changed snapshots to the hub
OutgoingMessageQueue only sends chat-visible records, so Claude's
background task snapshots never left the CLI. Forward that one hidden
subtype, and have the hub apply it as session state without storing a
hidden message row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): draw the background spinner with the thinking spinner code
Both row spinners now render through one SessionSpinner; background work
only switches the color to blue and no longer adds a separate Running label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Claude Code injects its own user-role turns for skill bodies and compact
continuation summaries. The on-disk transcript flags them `isMeta`, which
claudeLocalLauncher drops before they ever reach the web UI. Over
stream-json the same event is flagged `isSynthetic` instead, and
sdkToLogConverter copied only `message`, dropping the flag entirely.
With no `isMeta` on the converted line, every downstream guard let it
through: OutgoingMessageQueue forwarded it, isExternalUserMessage
classified it as genuine human input (its XML-prefix allowlist does not
match a bare-markdown skill body), and the web UI rendered the full skill
document as a user bubble.
Normalize `isSynthetic` to `isMeta` in the converter so the SDK path
carries the same signal as the transcript path and the existing filters
fire. Fixes skill injections appearing as user messages in remote mode.
* fix(cli): drop unknown SDK message types instead of passing them through
The SDK-to-log converter's switch had a fail-open default that stamped any
unrecognized SDK message with transcript base fields (parentUuid/sessionId/
userType) and forwarded it. Claude Code emits a tool_progress heartbeat every
30s for long-running tools, so a single slow Bash call flooded the chat: the
web normalizer matches no known shape for those records and falls back to
rendering the raw envelope as message text.
Gate the switch on an explicit allowlist instead, bailing before the uuid is
allocated so a dropped event cannot advance sidechain/parent tracking -- the
heartbeats share one parent_tool_use_id and were overwriting the pointer a
subagent's next real message parents to. This matches the local launcher,
which already enforces the same allowlist via RawJSONLinesSchema.safeParse.
The default branch stays as a fail-closed guard so adding a type to the
allowlist without a matching case drops the message rather than leaking it.
* fix(cli): re-check reassembled text for internal event JSON at flush boundary
isInternalEventJson was only applied per incoming chunk. In delta mode
(OpenCode) every chunk is a fragment, so none of them parses as JSON on its
own and the filter never fires; the pieces accumulate and flushText emits the
reassembled envelope verbatim. The dedupe path has the same hole whenever two
chunks share no overlap.
Check again in flushText, which is the first point the complete text exists,
and tolerate surrounding whitespace so an envelope preceded by a newline is
not waved through by the leading-'{' fast path.
Genuine assistant output that happens to be JSON is unaffected: the matcher
still requires the specific { type: 'output', data: { parentUuid, sessionId,
userType } } envelope shape.
* fix(cli): fail closed on unrecognized agent message in converter
convertAgentMessage's exhaustiveness default returned the message object
itself at runtime. The never binding makes the branch unreachable today, but
every caller forwards a non-null result straight into the chat stream, so the
failure mode if it were ever reached is a raw object on screen. Keep the
compile-time check, return null at runtime.
* test(cli): cover command_lifecycle, a second unknown type seen leaking
Observed in the same session after tool_progress. The allowlist already
covered it with no code change, which is the argument for gating on known
types rather than adding a case per offender.
* 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.
The "Default" model in NewSession sends no --model flag, so Claude CLI
picks its own default (e.g. Opus 4.7 [1m] on Pro accounts). The web
status bar then falls back to 200K - 10K headroom = 190K because the
Claude SDK path never plumbs the real per-model contextWindow through
to the wire-level `modelContextWindow`, unlike the ACP/Codex backends.
Fix the gap in three places:
- cli/src/claude/sdk/types.ts: declare optional `modelUsage` on
SDKResultMessage to surface what Claude CLI already emits
(`modelUsage[<model>].contextWindow`).
- cli/src/claude/utils/sdkToLogConverter.ts: on system.init, capture
the resolved model name (full form with `[1m]` suffix) and derive
an initial contextWindow from the suffix. On every assistant
message, inject the cached contextWindow into `usage.context_window`
when absent. On result, refine the cache with the authoritative
value from `modelUsage`.
- web/src/chat/normalizeAgent.ts: forward `context_window` through
the assistant usage normalization, so the existing reducer path
(reducer.ts:175 → StatusBar.tsx:175) can render the real window.
Closes#719.
Extract permission and model mode validation into reusable functions utilizing
@hapi/protocol schemas. Use isPermissionModeAllowedForFlavor and
isModelModeAllowedForFlavor to validate modes based on session flavor (claude/codex).
Replace inline type definitions with shared types from @hapi/protocol.