/** * Session/message store — the SOLID side (spec v4 §1, §7). Plain `createStore` * + an `apply(event)` reducer, à la opencode `context/sync-v2.tsx`. NOT Effect. * The boundary calls `apply` with already-decoded `GatewayEvent`s via * GatewayService.subscribe. * * Phase 2b: an assistant turn is ONE ordered `parts[]` of a discriminated union * (text / reasoning / tool), so tool calls render INLINE between text blocks * instead of dumped as separate rows below (§7 — the "dump-below" bug). Tools are * matched start↔complete by `tool_id`; `tool.complete` updates that part IN PLACE. * User/system rows stay flat `text` (no parts). Carried from Phase 1: streaming * concat (prefer `payload.text`), skin→theme, LRU dedup, hydrate-while-buffering. */ import { Option } from 'effect' import { createStore, produce } from 'solid-js/store' import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts' import { decodeCatalog, decodeSessionInfoPatch, type CatalogDecoded, type SessionInfoPatchDecoded } from '../boundary/schema/SessionInfo.ts' import { diffStats, type DiffStats } from './diff.ts' import { envOutputLinesSet } from './env.ts' import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts' import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts' /** A tool call inside an assistant turn (matched start↔complete by `id`=tool_id). */ export interface ToolPartState { type: 'tool' id: string name: string state: 'running' | 'complete' /** Envelope-stripped output (multi-line → block render; the view caps it). */ resultText?: string /** Short one-line status when there's no substantial output. */ summary?: string error?: string lineCount?: number /** One-line primary-arg preview from gateway `context` (always sent; redaction-safe). */ argsPreview?: string /** Full args (pretty JSON) for the expanded view — `args_text` (redacted) or stringified `args`. */ argsText?: string /** Structured args from `tool.complete` (always sent) — the per-tool renderers read these. */ args?: Record /** Tool wall-clock seconds (gateway `duration_s`), shown dim in the header. */ duration?: number /** Local Date.now() stamped on `tool.start` — drives the live elapsed tick * while running (Epic 2.5). `duration` (gateway truth) wins once settled. */ startedAt?: number /** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */ omittedNote?: string /** FULL raw unified diff from file-edit tools (gateway `diff_unified`, 512KB-capped). */ diffUnified?: string /** `+N −M` line counts derived from diffUnified (collapsed header summary). */ diffStats?: DiffStats } /** One ordered piece of an assistant turn (§7). */ export type Part = | { type: 'text'; id: string; text: string } | { type: 'reasoning'; id: string; text: string } | ToolPartState export interface Message { readonly role: 'user' | 'assistant' | 'system' /** Flat body for user/system rows (and settled/resumed assistant rows). */ text: string /** Ordered parts for a live assistant turn; absent for user/system. */ parts?: Part[] streaming?: boolean } /** * A BLOCKING interactive request from the agent (spec §8 #6 — unhandled = deadlock). * Each is answered via the matching `*.respond` RPC; Esc/Ctrl+C sends deny/empty. */ export type ActivePrompt = | { kind: 'clarify'; question: string; choices: string[] | null; requestId: string } | { kind: 'approval'; command: string; description: string } | { kind: 'sudo'; requestId: string } | { kind: 'secret'; envVar: string; prompt: string; requestId: string } // local (non-gateway) Y/N confirm — e.g. /clear, /new (spec §2a) | { kind: 'confirm'; message: string; onConfirm: () => void } /** A full-screen scrollable text viewer (long slash output: /status, /logs, …). */ export interface PagerState { title: string text: string } /** One row in the session switcher (from `session.list`). */ export interface SessionItem { id: string title: string preview: string messageCount: number } /** A row in the generic picker overlay (model picker, skills hub, …). */ export interface PickerItem { label: string description?: string value: string /** Group header this row renders under (e.g. the provider's display name). * Rows without a group render headerless (flat list). */ group?: string /** Extra fuzzy-search haystacks beyond label/group/description (e.g. the * provider slug, so `oai` finds openai models). */ haystacks?: string[] /** Marks the currently-active row (rendered with a ✓). */ current?: boolean /** Unavailable row (e.g. an unconfigured provider's `no API key — set …` * hint): hidden by default, revealed dimmed + NON-selectable by the * picker's Ctrl+U toggle; ↑↓ traversal skips it (picker v2.1). */ unavailable?: boolean } /** An open generic picker overlay: a titled list whose pick runs `onPick(value)`. */ export interface PickerState { title: string items: PickerItem[] onPick: (value: string) => void } /** A slash-completion candidate (from `complete.slash`). */ export interface CompletionItem { text: string display: string meta: string } /** A delegated subagent, tracked from the `subagent.*` event stream (agents dashboard). */ export interface SubagentInfo { id: string goal: string status: string depth: number model?: string parentId?: string summary?: string lastTool?: string /** Live activity trace (item 15) — tool/progress/summary lines, newest last. */ trace?: string[] /** Latest thinking text (transient; not appended to the trace to avoid flooding). */ thought?: string } /** Cap on a subagent's retained trace lines. */ const SUBAGENT_TRACE_LIMIT = 200 /** * Live session chrome (the status bar — item 14). Sourced from the `session.info` * event (and the `session.create`/`resume` result's `info`), refreshed whenever * the gateway's agent/config state changes. `running` is the turn-active flag the * Ctrl-C interrupt (item 11) reads; we also flip it locally on message.start/ * complete so the bar reacts instantly even if a `session.info` lags. */ export interface SessionInfo { model?: string effort?: string fast?: boolean cwd?: string branch?: string running?: boolean contextUsed?: number contextMax?: number contextPercent?: number compressions?: number /** Estimated session cost in USD (`usage.cost_usd` — only when the gateway's * pricing estimate succeeds; absent otherwise). */ costUsd?: number /** Commits behind the remote (`update_behind`) — null/absent until the async * update check resolves; >0 drives the transient update notice in the bar. */ updateBehind?: number /** The recommended update command (`update_command`), paired with updateBehind. */ updateCommand?: string /** Active profile name (`profile_name`); the bar badges it when non-default. */ profileName?: string /** Count of configured MCP servers (`mcp_servers.length`) for the bar's `N mcp`. */ mcpServers?: number /** Epoch ms when this TUI session started (set once at store creation; never * patched from the wire) — drives the status-bar session duration. */ startedAt?: number } /** Startup catalog (tools/skills/MCP) for the home-screen panel (item 9 / banner parity). */ export interface Catalog { readonly tools: { readonly total: number readonly toolsets: ReadonlyArray<{ name: string; count: number; enabled: boolean; tools: ReadonlyArray }> } readonly skills: { readonly total: number; readonly categories: ReadonlyArray<{ name: string; count: number }> } readonly mcp: { readonly servers: ReadonlyArray } } export interface StoreState { ready: boolean messages: Message[] /** Count of oldest messages trimmed from the DISPLAY by the rolling cap (live * overflow + resume slice). Drives the "N earlier messages" truncation notice; * 0 when nothing's been dropped. NOT context loss — the model's history lives on * the gateway (see MESSAGE_CAP); this only bounds in-TUI scrollback. */ dropped: number theme: Theme /** The active blocking prompt (composer is hidden while set); undefined when none. */ prompt: ActivePrompt | undefined /** The open pager overlay (replaces the transcript while set); undefined when none. */ pager: PagerState | undefined /** The open session switcher (replaces the composer while set); undefined when none. */ switcher: SessionItem[] | undefined /** The open generic picker (model/skills/…); undefined when none. */ picker: PickerState | undefined /** Whether the Esc+Esc session prompt-history viewer is open (Epic 5). */ promptHistory: boolean /** Live completion candidates (slash-name/args or file/@-mention) shown above the composer. */ completions: CompletionItem[] | undefined /** Char offset in the input where an accepted completion should start replacing * (gateway `replace_from` for slash args; the path-token start for @-mentions). */ completionFrom: number /** Delegated subagents (from `subagent.*`), shown in the agents dashboard. */ subagents: SubagentInfo[] /** Whether the agents dashboard overlay is open (/agents). */ dashboard: boolean /** Subagent id the dashboard should preselect on open (tray Enter — Epic 2.7). */ dashboardAgent: string | undefined /** Transient busy indicator (the kaomoji face/verb from `thinking.delta`/`status.update`); * shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */ status: string | undefined /** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */ info: SessionInfo /** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" — item 11); * takes visual priority over the busy `status` face. Undefined when none. */ hint: string | undefined /** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */ catalog: Catalog | undefined /** Cached `/model` picker rows (mapped `model.options`). Prefetched at session * bootstrap and refreshed after a switch, so `/model` opens INSTANTLY from * memory instead of awaiting the slow RPC (it does network calls: pricing * fetch + Nous tier check) on every open (Epic 7). */ modelItems: PickerItem[] | undefined /** The current session id (shown in the home panel; updated on create/resume). */ sessionId: string | undefined } const LRU_LIMIT = 1000 /** Read a string field off an unknown payload record (no `any`, no cast). */ function readStr(payload: { readonly [k: string]: unknown }, key: string): string | undefined { const v = payload[key] return typeof v === 'string' ? v : undefined } /** Read a number field off an unknown payload record. */ function readNum(payload: { readonly [k: string]: unknown }, key: string): number { const v = payload[key] return typeof v === 'number' ? v : 0 } /** Read an optional number (undefined when absent) — distinguishes "0" from "missing". */ function readOptNum(payload: { readonly [k: string]: unknown }, key: string): number | undefined { const v = payload[key] return typeof v === 'number' ? v : undefined } /** Render a raw tool `result` for display: strings as-is, anything else pretty * JSON — both then go through the same envelope-strip pipeline as result_text. */ function stringifyResult(v: unknown): string | undefined { if (typeof v === 'string') return v if (v === null || v === undefined) return undefined try { return JSON.stringify(v, null, 2) } catch { return undefined } } /** * Fold a `session.info` / `session.create.info` payload into a partial SessionInfo. * The loose wire JSON is decoded ONCE via `SessionInfoPatchSchema` (decode-at- * boundary); context/usage numbers are read from the nested `usage` object first, * falling back to the top level (the gateway shapes vary by RPC vs event). A * malformed payload decodes to `Option.none` → an empty patch (never crashes). * Only present fields are included so a partial patch can't clobber prior chrome. */ function readInfoPatch(payload: { readonly [k: string]: unknown }): Partial { const decoded = decodeSessionInfoPatch(payload) if (Option.isNone(decoded)) return {} return infoPatchFrom(decoded.value) } /** Build the SessionInfo patch from a decoded session.info payload. */ function infoPatchFrom(d: SessionInfoPatchDecoded): Partial { const patch: Partial = {} if (d.model) patch.model = d.model if (d.reasoning_effort) patch.effort = d.reasoning_effort if (d.fast !== undefined) patch.fast = d.fast if (d.cwd) patch.cwd = d.cwd if (d.branch) patch.branch = d.branch if (d.running !== undefined) patch.running = d.running // prefer the nested usage.context_* numbers, else the top-level fallback. const used = d.usage?.context_used ?? d.context_used if (used !== undefined) patch.contextUsed = used const max = d.usage?.context_max ?? d.context_max if (max !== undefined) patch.contextMax = max const pct = d.usage?.context_percent ?? d.context_percent if (pct !== undefined) patch.contextPercent = pct const comp = d.usage?.compressions ?? d.compressions if (comp !== undefined) patch.compressions = comp if (d.usage?.cost_usd !== undefined) patch.costUsd = d.usage.cost_usd // null = "update check not resolved yet" — leave the prior value alone. if (typeof d.update_behind === 'number') patch.updateBehind = d.update_behind if (d.update_command) patch.updateCommand = d.update_command if (d.profile_name) patch.profileName = d.profile_name if (d.mcp_servers) patch.mcpServers = d.mcp_servers.length return patch } /** Keep only the string elements of a decoded (unknown-element) array. */ function onlyStrings(items: ReadonlyArray | undefined): string[] { return (items ?? []).filter((s): s is string => typeof s === 'string') } /** Build the typed Catalog from a decoded startup.catalog result (item 9). An * absent `enabled` flag means on; nameless toolsets/categories are dropped and * non-string tool/server names are filtered (defensive — wire arrays are loose). */ function catalogFrom(d: CatalogDecoded): Catalog { return { mcp: { servers: onlyStrings(d.mcp?.servers) }, skills: { total: d.skills?.total ?? 0, categories: (d.skills?.categories ?? []) .map(c => ({ count: c.count ?? 0, name: c.name ?? '' })) .filter(c => c.name) }, tools: { total: d.tools?.total ?? 0, toolsets: (d.tools?.toolsets ?? []) .map(t => ({ count: t.count ?? 0, enabled: t.enabled !== false, name: t.name ?? '', tools: onlyStrings(t.tools) })) .filter(t => t.name) } } } /** The subagent status implied by an event type (an explicit payload `status` wins). */ function subagentStatusFor(type: string): string { if (type === 'subagent.complete') return 'complete' if (type === 'subagent.thinking') return 'thinking' if (type === 'subagent.tool') return 'tool' if (type === 'subagent.progress') return 'working' return 'running' } export function createSessionStore() { // Rolling cap on retained transcript rows. OpenTUI lays out via Yoga (WASM), whose // linear memory is grow-only — every live `` row is a Yoga-node subtree, so an // uncapped `messages[]` ratchets the high-water mark up over a long session and never // gives it back. Capping the array in place (see `capMessages`) makes Solid's keyed // `` UNMOUNT exactly the evicted oldest rows → `Renderable.destroy()` → // `yogaNode.free()`, returning those nodes to the WASM allocator's free list. // // Default 3000 (≈1500 turns of scrollback): the highest cap whose steady-state RSS // stays within a sane TUI budget on the realistic-fixture bench (~20.4 renderables/ // msg, ~0.65 MB/msg → ~2 GB at 3000 — and that ceiling is only reached by marathon // 3000+-message sessions; typical sessions cost a fraction). opencode caps at 100; // we trade memory for far more in-TUI scrollback (the dashboard holds the rest). // Read once per store from `HERMES_TUI_MAX_MESSAGES`. Turns trimmed beyond the cap // aren't lost — they live on the gateway and are recoverable via `/resume`. const MESSAGE_CAP = (() => { const raw = Number.parseInt(process.env.HERMES_TUI_MAX_MESSAGES ?? '', 10) return Number.isFinite(raw) && raw > 0 ? raw : 3000 })() const [state, setState] = createStore({ ready: false, messages: [], dropped: 0, theme: DEFAULT_THEME, prompt: undefined, pager: undefined, switcher: undefined, picker: undefined, promptHistory: false, completions: undefined, completionFrom: 0, subagents: [], dashboard: false, dashboardAgent: undefined, status: undefined, // startedAt is set ONCE here (store creation ≈ session start) — the status // bar's session-duration segment ticks from it; wire patches never carry it. info: { startedAt: Date.now() }, hint: undefined, catalog: undefined, modelItems: undefined, sessionId: undefined }) // Monotonic part id (stable `key` per part so a new tool part below a streaming // text part doesn't remount/re-tokenize it). let partSeq = 0 const nextId = () => `p${++partSeq}` // LRU id-dedup: events that carry a stable id are applied at most once. const applied = new Set() function duplicate(id: string | undefined): boolean { if (!id) return false if (applied.has(id)) return true applied.add(id) if (applied.size > LRU_LIMIT) { const oldest = applied.values().next() if (!oldest.done) applied.delete(oldest.value) } return false } // Hydrate-while-buffering (resume): while a snapshot is loading, live events // queue here and replay after the snapshot is reconciled (opencode sync-v2). let buffering: GatewayEvent[] | null = null // Anti-flood for `gateway.stderr`: a crashing child can emit a torrent of // stderr lines, so we do NOT push each to the transcript. Instead we keep a // small ring of the most-recent lines and only surface a TAIL of it when a // failure event (start_timeout / exited) actually needs the diagnostic // context — so a healthy-but-chatty gateway never spams the chat. const STDERR_RING_LIMIT = 20 const STDERR_TAIL = 5 const stderrRing: string[] = [] function stderrTail(): string { return stderrRing.slice(-STDERR_TAIL).join('\n') } function setSkin(skin: GatewaySkinDecoded | undefined): void { setState('theme', themeFromSkin(skin)) } // Trim the transcript to MESSAGE_CAP, dropping the OLDEST rows IN PLACE via // `splice` (NOT a `slice`-reassign). A keyed `` keeps rows by item // REFERENCE, so splicing the head unmounts only the evicted rows (freeing their // Yoga nodes) while the survivors keep their refs and are not remounted. A live // streaming assistant turn is always the LAST row, so head-trimming never drops it. function capMessages(draft: StoreState): void { const overflow = draft.messages.length - MESSAGE_CAP if (overflow > 0) { draft.messages.splice(0, overflow) draft.dropped += overflow } } // ── parts helpers (operate on a draft message inside produce) ─────────── function appendPart(m: Message, type: 'text' | 'reasoning', text: string): void { const parts = (m.parts ??= []) const last = parts[parts.length - 1] if (last && last.type === type) last.text += text else parts.push({ type, id: nextId(), text }) } /** The live (last) assistant message, optionally only when still streaming. */ function liveAssistant(draft: StoreState, streamingOnly = false): Message | undefined { const last = draft.messages[draft.messages.length - 1] if (last && last.role === 'assistant' && (!streamingOnly || last.streaming)) return last return undefined } /** Ensure there's an open assistant turn to attach parts to (tool/reasoning). */ function ensureAssistant(draft: StoreState): Message { const live = liveAssistant(draft, true) if (live) return live const created: Message = { role: 'assistant', text: '', parts: [], streaming: true } draft.messages.push(created) return created } /** Find a tool part by id in the CURRENT (last) assistant turn — a tool.complete * always pairs with a tool.start in the live turn, so scoping there avoids * matching a same-id tool in an older/resumed turn (and is O(parts), not O(all)). */ function findToolPart(draft: StoreState, id: string): ToolPartState | undefined { const parts = liveAssistant(draft)?.parts if (!parts) return undefined for (let j = parts.length - 1; j >= 0; j--) { const p = parts[j] if (p && p.type === 'tool' && p.id === id) return p } return undefined } /** Push a user message (composer submit). */ function pushUser(text: string) { setState( produce(draft => { draft.messages.push({ role: 'user', text }) capMessages(draft) }) ) } /** Push a system line (slash output, errors, notices). */ function pushSystem(text: string) { // slash/notice text is often ANSI-colored for the Ink TUI; strip codes so // they don't render as literal `[1;38m…` glyphs in the native engine (item 8). const clean = stripAnsi(text) setState( produce(draft => { draft.messages.push({ role: 'system', text: clean }) capMessages(draft) }) ) } /** Clear the transcript (e.g. /clear, /new) and any tracked subagents. */ function clearTranscript() { setState('messages', []) setState('subagents', []) setState('dropped', 0) // Drop the dedup history too — a fresh transcript should re-process any id. applied.clear() } /** Open / close the agents dashboard overlay (/agents). The optional `agentId` * preselects that subagent's row (the tray's Enter — Epic 2.7). */ function openDashboard(agentId?: string) { setState('dashboardAgent', agentId) setState('dashboard', true) } function closeDashboard() { setState('dashboard', false) setState('dashboardAgent', undefined) } /** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */ function setConfirm(message: string, onConfirm: () => void) { setState('prompt', { kind: 'confirm', message, onConfirm }) } /** Open the pager overlay (long slash output: /status, /logs, …). */ function openPager(title: string, text: string) { setState('pager', { title, text: stripAnsi(text) }) } /** Close the pager overlay. */ function closePager() { setState('pager', undefined) } /** Open the session switcher with the given session rows (/sessions, /resume). */ function openSwitcher(sessions: SessionItem[]) { setState('switcher', sessions) } /** Close the session switcher. */ function closeSwitcher() { setState('switcher', undefined) } /** Open the generic picker (model picker, skills hub, …). */ function openPicker(picker: PickerState) { setState('picker', picker) } /** Close the generic picker. */ function closePicker() { setState('picker', undefined) } /** Open / close the Esc+Esc session prompt-history viewer (Epic 5). */ function openPromptHistory() { setState('promptHistory', true) } function closePromptHistory() { setState('promptHistory', false) } /** Cache the mapped `/model` picker rows (instant open — Epic 7). */ function setModelItems(items: PickerItem[]) { setState('modelItems', items) } /** Set / clear the transient composer hint ("Ctrl+C again to quit" — item 11). */ function setHint(text: string | undefined): void { setState('hint', text) } /** Merge a session-info patch into the chrome state (status bar — item 14). */ function applyInfo(raw: { readonly [k: string]: unknown }): void { const patch = readInfoPatch(raw) if (Object.keys(patch).length) setState('info', prev => ({ ...prev, ...patch })) } /** Set / clear the live completion candidates (composer dropdown). `from` is the * input char offset an accepted item replaces from (slash-arg / @-mention splice). */ function setCompletions(items: CompletionItem[], from = 0) { setState('completions', items.length ? items : undefined) setState('completionFrom', items.length ? Math.max(0, from) : 0) } function clearCompletions() { setState('completions', undefined) setState('completionFrom', 0) } /** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */ function apply(event: GatewayEvent): void { if (buffering) { buffering.push(event) return } applyNow(event) } function applyNow(event: GatewayEvent): void { switch (event.type) { case 'gateway.ready': setState('ready', true) // Clear any transient status: on a recovery-respawn ready this drops the // lingering 'gateway recovering (attempt N)…' line; no-op on first connect. setState('status', undefined) setSkin(event.payload?.skin) break case 'skin.changed': setSkin(event.payload) break case 'session.info': applyInfo(event.payload) break case 'message.start': setState('status', undefined) setState('info', prev => ({ ...prev, running: true })) setState( produce(draft => { draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true }) capMessages(draft) }) ) break case 'message.delta': { // prefer `text` over `rendered` (gotcha §8 #4 — rendered is incremental Rich-ANSI). const text = event.payload?.text ?? '' if (!text) break setState( produce(draft => { const live = liveAssistant(draft, true) if (live) appendPart(live, 'text', text) }) ) break } case 'message.complete': setState( produce(draft => { // complete-only gateways may send `message.complete{text}` with no prior // start/delta → create the turn so the final text isn't dropped. const finalText = event.payload?.text const live = liveAssistant(draft, true) ?? (finalText ? ensureAssistant(draft) : undefined) if (!live) return // If no deltas arrived (complete-only gateways), seed the full text once. const hasText = (live.parts ?? []).some(p => p.type === 'text' && p.text.length > 0) if (finalText && !hasText) appendPart(live, 'text', finalText) live.streaming = false }) ) setState('status', undefined) setState('info', prev => ({ ...prev, running: false })) // message.complete carries the latest usage/context — refresh the bar. if (event.payload) applyInfo(event.payload) break // thinking.delta / status.update are the TRANSIENT busy indicator (kaomoji // face/verb) — route them to the status line, NOT the transcript (gotcha: Ink // shows these as a FaceTicker, not message content). case 'thinking.delta': case 'status.update': { const text = event.payload?.text ?? '' if (text) setState('status', text) break } // reasoning.delta is the model's actual reasoning — a (dim) transcript part. case 'reasoning.delta': { const text = event.payload?.text ?? '' if (!text) break setState( produce(draft => { appendPart(ensureAssistant(draft), 'reasoning', text) }) ) break } case 'tool.start': { const id = readStr(event.payload, 'tool_id') if (!id) break const name = readStr(event.payload, 'name') ?? 'tool' // `context` = build_tool_preview's primary-arg line (always sent); `args_text` // = redacted full-arg JSON (verbose mode only). Surfacing these is item 2. const argsPreview = readStr(event.payload, 'context') const argsText = readStr(event.payload, 'args_text') setState( produce(draft => { const live = ensureAssistant(draft) const part: ToolPartState = { type: 'tool', id, name, state: 'running', startedAt: Date.now() } if (argsPreview) part.argsPreview = argsPreview if (argsText) part.argsText = argsText ;(live.parts ??= []).push(part) }) ) break } case 'tool.complete': { const id = readStr(event.payload, 'tool_id') if (!id) break const name = readStr(event.payload, 'name') // explicit payload error wins; else derive from the `{"error": …}` result // convention (the only failure signal the live gateway actually ships). const error = readStr(event.payload, 'error') const summary = readStr(event.payload, 'summary') // `result_text` is verbose-gated, but the raw `result` is ALWAYS sent — // when the verbose text is absent, derive the display body from `result` // (so e.g. bash output still renders in non-verbose sessions). Then peel // the gateway's "[showing verbose tail; omitted …]" label (item 2) before // envelope-stripping, so the body is clean and the note renders tidily. let { body: rawBody, omittedNote } = stripOmittedNote( readStr(event.payload, 'result_text') ?? stringifyResult(event.payload['result']) ?? summary ?? '' ) // HERMES_TUI_TOOL_OUTPUT_LINES set → the user explicitly controls how much // output the expanded view shows, but a gateway-capped `result_text` // (omittedNote) is only a TAIL — substituting the always-full raw `result` // is the only way flag=0 (unlimited) can actually show everything. Same // display pipeline (envelope strip) — and the same raw-result redaction // tradeoff — as the existing non-verbose stringifyResult fallback above. // The omitted note no longer applies to the full body; "+N more lines" // (view-side) stays honest for caps below the full length. File-edit JSON // results stay parseable, so fileTool's diffOutputPlan still suppresses // the diff echo. The redacted-argsText precedence is untouched. if (omittedNote && envOutputLinesSet(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)) { const full = stringifyResult(event.payload['result']) if (full !== undefined) { rawBody = full omittedNote = undefined } } const resultText = stripToolEnvelope(rawBody) const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0 // `args` (full dict) is always sent; stringify as the expanded-view args // when verbose `args_text` wasn't captured on start. `duration_s` → header. const argsObj = event.payload['args'] const duration = readOptNum(event.payload, 'duration_s') // FULL raw unified diff (file-edit tools; gateway caps at 512KB). Stats // are computed once here, not per render. const diffUnified = readStr(event.payload, 'diff_unified') setState( produce(draft => { let part = findToolPart(draft, id) if (!part) { // complete without a matching start — append a settled tool part. part = { type: 'tool', id, name: name ?? 'tool', state: 'running' } ;(ensureAssistant(draft).parts ??= []).push(part) } part.state = 'complete' part.lineCount = lineCount if (name) part.name = name if (resultText) part.resultText = resultText if (summary) part.summary = summary if (error) part.error = error if (duration !== undefined) part.duration = duration if (omittedNote) part.omittedNote = omittedNote if (diffUnified) { part.diffUnified = diffUnified part.diffStats = diffStats(diffUnified) } // argsPreview (from tool.start `context`) is intentionally NOT overwritten. if (argsObj && typeof argsObj === 'object') { // structured args feed the per-tool renderers (labeled fields, bash command). if (!Array.isArray(argsObj)) part.args = argsObj as Record if (!part.argsText) { try { part.argsText = JSON.stringify(argsObj, null, 2) } catch { /* unstringifiable args — leave unset */ } } } }) ) break } // ── blocking prompts (spec §8 #6 — unhandled = the agent deadlocks) ── case 'clarify.request': setState('prompt', { kind: 'clarify', question: event.payload.question ?? '', // decoded choices are readonly — copy to the store's mutable string[] choices: event.payload.choices ? [...event.payload.choices] : null, requestId: event.payload.request_id }) break case 'approval.request': setState('prompt', { kind: 'approval', command: event.payload.command, description: event.payload.description }) break case 'sudo.request': setState('prompt', { kind: 'sudo', requestId: event.payload.request_id }) break case 'secret.request': setState('prompt', { kind: 'secret', envVar: event.payload.env_var, prompt: event.payload.prompt, requestId: event.payload.request_id }) break // ── subagents (agents dashboard) — track the delegation tree by id ── case 'subagent.spawn_requested': case 'subagent.start': case 'subagent.thinking': case 'subagent.tool': case 'subagent.progress': case 'subagent.complete': { const id = readStr(event.payload, 'subagent_id') if (!id) break setState( produce(draft => { let sa = draft.subagents.find(s => s.id === id) if (!sa) { sa = { depth: readNum(event.payload, 'depth'), goal: '', id, status: 'running' } draft.subagents.push(sa) } const goal = readStr(event.payload, 'goal') if (goal) sa.goal = goal const model = readStr(event.payload, 'model') if (model) sa.model = model const parent = readStr(event.payload, 'parent_id') if (parent) sa.parentId = parent const summary = readStr(event.payload, 'summary') if (summary) sa.summary = summary const tool = readStr(event.payload, 'tool_name') if (tool) sa.lastTool = tool sa.status = readStr(event.payload, 'status') ?? subagentStatusFor(event.type) // Live trace (item 15): a concise per-subagent activity log. Thinking // deltas update a transient `thought` (not appended — they'd flood). const text = readStr(event.payload, 'text') const trace = (sa.trace ??= []) if (event.type === 'subagent.start') trace.push(`▶ ${goal ?? sa.goal ?? 'started'}`) else if (event.type === 'subagent.tool' && tool) trace.push(`⚡ ${tool}${text ? ` — ${text}` : ''}`) else if (event.type === 'subagent.progress' && text) trace.push(text) else if (event.type === 'subagent.complete') trace.push(`✓ ${summary ?? 'done'}`) else if (event.type === 'subagent.thinking' && text) sa.thought = text if (trace.length > SUBAGENT_TRACE_LIMIT) trace.splice(0, trace.length - SUBAGENT_TRACE_LIMIT) }) ) break } // ── gateway lifecycle / transport errors (auto-heal foundations) ── // The child exited mid-turn. THE key bug fix: clear the frozen `running` // spinner (no message.complete will ever arrive for the lost reply), tell // the user their in-flight reply was lost, and show a recovering status. case 'gateway.exited': { setState('info', prev => ({ ...prev, running: false })) // Neutral status: we don't ALWAYS recover (budget exhaustion). The // "recovering…" wording now comes from the gateway.recovering case, // which fires only when a respawn is actually scheduled. setState('status', 'gateway exited') const reason = event.payload?.reason const base = 'gateway exited — recovering your session (any in-flight reply was lost)' pushSystem(reason ? `${base}: ${reason}` : base) break } // A respawn+resume attempt is in flight — reflect the attempt in the status. case 'gateway.recovering': { const attempt = event.payload?.attempt setState('status', attempt ? `gateway recovering (attempt ${attempt})…` : 'gateway recovering…') break } // Collect stderr into a bounded ring (NOT the transcript) — see stderrRing. case 'gateway.stderr': { stderrRing.push(event.payload.line) if (stderrRing.length > STDERR_RING_LIMIT) stderrRing.splice(0, stderrRing.length - STDERR_RING_LIMIT) break } // The gateway never reached `gateway.ready` — surface the failure with any // stderr tail (payload is a loose Record; read defensively). case 'gateway.start_timeout': { const detail = readStr(event.payload, 'stderr') ?? readStr(event.payload, 'message') ?? stderrTail() pushSystem(detail ? `gateway failed to start:\n${detail}` : 'gateway failed to start') break } case 'gateway.protocol_error': { const preview = event.payload?.preview pushSystem(preview ? `gateway protocol error: ${preview}` : 'gateway protocol error') break } case 'error': { const message = event.payload?.message pushSystem(message ? `error: ${message}` : 'error') break } // Other event types (chrome) are reduced in later phases; unhandled members // are intentionally ignored here. } } /** Clear the active blocking prompt (after it's answered/cancelled). */ function clearPrompt(): void { setState('prompt', undefined) } // ── resume hydrate (opencode sync-v2): buffer live events while the snapshot // loads, then replace history + replay the buffer in order. Split into begin/ // commit so the buffer can span an async `session.resume` RPC. /** Start buffering live events (call BEFORE the async resume RPC). Idempotent. */ function beginBuffer(): void { if (!buffering) buffering = [] } /** Replace history with the resume snapshot, then replay events buffered meanwhile. */ function commitSnapshot(snapshot: Message[]): void { // Slice to the cap BEFORE the first setState, not after. Yoga (WASM) layout // memory is grow-only, so even a TRANSIENT mount of an over-cap resume // snapshot would permanently ratchet the high-water mark — a set-then-trim // briefly hands the full fetched history to . Pre-slicing guarantees // resuming ANY session mounts at most MESSAGE_CAP rows. (Events buffered // across the resume RPC, replayed below, self-cap via capMessages per push.) const capped = snapshot.length > MESSAGE_CAP ? snapshot.slice(-MESSAGE_CAP) : snapshot setState('messages', capped) // A resume is a fresh view → SET (not accumulate) the dropped count to what the // snapshot slice hid, so the notice reflects this session. Live pushes add to it. setState('dropped', snapshot.length - capped.length) const pending = buffering ?? [] buffering = null for (const event of pending) applyNow(event) } /** Synchronous convenience: buffer → load → commit (used by tests). */ function hydrate(loadSnapshot: () => Message[]): void { beginBuffer() commitSnapshot(loadSnapshot()) } /** * Map the loose `startup.catalog` response into the typed Catalog (item 9). * Decoded ONCE via `CatalogSchema` (decode-at-boundary); garbage decodes to * `Option.none` → the catalog is left unset rather than crashing the panel. */ function setCatalog(raw: unknown): void { const decoded = decodeCatalog(raw) if (Option.isNone(decoded)) return setState('catalog', catalogFrom(decoded.value)) } function setSessionId(sid: string | undefined): void { setState('sessionId', sid) } return { state, apply, pushUser, pushSystem, setCatalog, setSessionId, clearTranscript, setConfirm, openPager, closePager, openSwitcher, closeSwitcher, openPicker, closePicker, openPromptHistory, closePromptHistory, setModelItems, setCompletions, clearCompletions, applyInfo, setHint, openDashboard, closeDashboard, hydrate, beginBuffer, commitSnapshot, duplicate, clearPrompt } as const } export type SessionStore = ReturnType