Files
hermes-agent/ui-opentui/src/logic/store.ts
T

988 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>
/** 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<string> }>
}
readonly skills: { readonly total: number; readonly categories: ReadonlyArray<{ name: string; count: number }> }
readonly mcp: { readonly servers: ReadonlyArray<string> }
}
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<SessionInfo> {
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<SessionInfo> {
const patch: Partial<SessionInfo> = {}
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<unknown> | 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 `<For>` 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
// `<For>` 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<StoreState>({
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<string>()
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 `<For>` 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<string, unknown>
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 <For>. 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<typeof createSessionStore>