MCP runtime for external AI models. Proxies OpenRouter, Ollama and LM Studio, runs blind team voting, manages async sessions, streams channel notifications…

Provides the Claudish MCP runtime for the Magus marketplace. Claudish is an external-model proxy that exposes tools (team, create_session, list_models, etc.) for orchestrating multi-model workflows from Claude Code.
This plugin is a runtime dependency. Other plugins (code-search, dev, multimodel, designer) declare it via the dependencies field in their plugin.json and consume its tools through standard MCP.
Before this plugin, multiple Magus plugins each declared an identical Claudish MCP server entry in their own .mcp.json. Claude Code's plugin loader deduplicates by endpoint (command + args), so only the first plugin's registration survived; others were silently suppressed. This worked by accident as long as all declarations remained byte-identical.
Extracting the runtime into a dedicated plugin makes the dependency explicit, follows Anthropic's documented dependencies-field pattern (Claude Code v2.1.110+), and removes the silent-suppression footgun.
See: Anthropic plugin dependencies docs
claudish CLI tool must be on $PATH. Install via npm: ``bash npm install -g claudish ``claudish --help lists the credential commands.bun on $PATH.The plugin's .mcp.json declares no env entries, on purpose. The server inherits Claude Code's environment, so an exported key reaches it without one. A "OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}" entry made Claude Code refuse to start the server for every user who keeps keys outside the environment.
Tools exposed via the claudish MCP server:
run_prompt, list_models, search_models, compare_modelsteam, report_errorcreate_session, send_input, get_output, cancel_session, list_sessionsTool gating via CLAUDISH_MCP_TOOLS env var: all (default), low-level, agentic, channel.
Plus one plugin monitor, claudish-sessions, which reports the progress of the create_session and team runs this Claude Code session started. See Session progress monitor.
| Skill | Covers |
|---|---|
claudish:claudish-usage | Which MCP tool fits which task, model alias resolution against the live catalog, identity versus routing address, and the preferences-file trust rules |
Read it before any claudish work. It is the single place the resolution procedure lives — every consumer plugin points here rather than restating it, because a second copy of claudish's routing rules guarantees two versions of the truth and no way to tell which one is stale.
Models are run through the MCP tools, never the CLI. The skill teaches team, create_session and run_prompt; the binary's only remaining role is as the runtime the MCP server launches, plus three read-only diagnostics (--help, --version, --models) for investigating that runtime. No workflow shells out.
The skill lives here rather than in multimodel because it documents the claudish runtime: a consumer that depends on claudish alone must find the resolution procedure installed beside it, without also installing multimodel.
monitors/monitors.json declares one Claude Code plugin monitor, claudish-sessions. Claude Code starts it with every interactive session, with no launch flag. Apart from the two notices below, it stays silent until that session starts claudish work. From then on it prints one line per state change of:
create_session session this Claude Code session started;team(mode:"run") run this Claude Code session started.Each line reaches the model as a notification, so a caller acts on the end line instead of polling list_sessions or team(mode:"status"). It does not end its turn relying on that line alone: a plugin monitor's line woke an idle session in the two live sessions measured, and two sessions are not a rate (see Limits), so a caller starts a bounded background wait before it ends a turn with a run in flight. The claudish:claudish-usage skill, "Waiting for a run to end", gives the wait.
Every line starts with claudish-monitor:. The monitor writes nothing else to stdout.
| Line | Meaning | Next action |
|---|---|---|
session ID started | the session exists | note the id |
session ID running | still running; at most one every 5 minutes | nothing |
session ID needs-input … next: send_input ID | it finished a turn and waits for input | send_input(ID, answer) |
session ID needs-input … waited=… | a wait that was already answered when the monitor read it | nothing |
session ID completed … next: get_output ID | finished | get_output(ID) |
session ID failed / timeout … next: get_diagnostics ID | ended badly | get_diagnostics(ID) |
session ID cancelled | cancelled | nothing |
team ID started … path=P | the panel exists | note the path |
team ID running … | still running; at most one every 5 minutes | nothing |
team ID completed / failed / cancelled … next: team-status | the run settled | team(mode:"status", path=P) |
notice claudish-too-old: … | a claudish server in this session is older than 10.4.0; runs started through it are not reported | upgrade claudish, restart Claude Code |
notice no-session-identity: … | the monitor cannot tell which runs are this session's | none; it exits |
needs-input is reported once per wait, and only interactive sessions wait (no prompt, or after a send_input). A session started with a prompt never needs input. A wait still open when its session ends is not reported; the end line is. A writer that dies before recording an end is reported as failed reason=no-terminal-record.
Line grammar:
line = "claudish-monitor:" SP ( session-line / team-line / notice-line )
session-line = "session" SP id SP s-state *( SP field ) [ SP "next:" SP s-hint ]
s-state = "started" / "running" / "needs-input" / "completed" / "failed" / "timeout" / "cancelled"
s-hint = ( "get_output" / "get_diagnostics" / "send_input" ) SP id
team-line = "team" SP id SP t-state *( SP field ) [ SP "next:" SP "team-status" ]
t-state = "started" / "running" / "completed" / "failed" / "cancelled"
field = key "=" value
key = "model" / "elapsed" / "turns" / "replies" / "tools" / "cost" / "reason"
/ "exit" / "waited" / "slots" / "ok" / "failed" / "cancelled" / "running" / "path"
value = 1*200( safe ) ; printable ASCII without space " & < = >
; only path reaches 200; every other value stops at 80
notice-line = "notice" SP ( "claudish-too-old" / "no-session-identity" ) ":" SP text
id = 1*64( ALPHA / DIGIT / "-" / "_" / "." )
Fields, in this order, each omitted when unknown:
| Line | Fields |
|---|---|
session started | model |
session running | model elapsed replies tools cost |
session needs-input | model elapsed turns (+ waited once answered) |
session completed, cancelled | model elapsed turns tools cost |
session failed, timeout | model elapsed turns tools cost reason exit |
team started | slots path |
team running | elapsed slots ok failed cancelled running path |
team completed, failed, cancelled | elapsed slots ok failed cancelled reason path |
elapsed and waited: 7m41s below one hour, 1h05m from one hour.turns is claudish's completed-turn count; replies counts distinct assistant messages in the event log. They measure different things and are named apart on purpose.cost: $0.00 for an exact zero, two decimals from one cent, up to four below it.running on a team running line counts every slot not yet finished, including one not yet started, so ok + failed + cancelled + running is slots. A slot counts as cancelled or failed the same way on both lines.reason on failure lines only: claudish's own reason, or the monitor's verdict no-terminal-record, terminal-record-unreadable or unrecognised-status.path is the team directory, relative to the project when inside it, and percent-encoded (decode it before passing it to team). A path over 200 characters keeps its last part after a ...; the full path is teamPath in <sessionsDir>/<id>/spawn.json.next: team-status means: call team(mode:"status", path=<the decoded path>).Examples (MODEL_ID stands for whatever model the run used):
claudish-monitor: session 1a2b3c4d started model=MODEL_ID
claudish-monitor: session 9f8e7d6c needs-input model=MODEL_ID elapsed=1m12s turns=1 next: send_input 9f8e7d6c
claudish-monitor: session 1a2b3c4d completed model=MODEL_ID elapsed=7m41s turns=5 tools=22 cost=$0.09 next: get_output 1a2b3c4d
claudish-monitor: team team-5e6f7a8b completed elapsed=12m03s slots=4 ok=3 failed=1 cancelled=0 path=ai-docs/sessions/RUN/reviews/panel next: team-status
claudish 10.4.0 and later writes <sessionsDir>/<id>/spawn.json before each run starts. sessionsDir is $CLAUDISH_SESSIONS_DIR when set and not empty, else $HOME/.claudish/sessions, falling back to the OS account home only when HOME is unset or empty. claudish and the monitor apply this one rule, so a sandbox or launcher that changes HOME moves both together. Each applies it to its own environment, and the monitor's is Claude Code's: set CLAUDISH_SESSIONS_DIR where Claude Code starts. Set only in the claudish server's env block, it moves the writer and not the monitor, and no run is reported. The record names the Claude Code process that launched the claudish MCP server (hostPid). The monitor reports a record only when that pid is its own window's Claude Code process (CLAUDE_PID), so two windows never see each other's runs, even when they share a conversation. Runs that ended before the monitor started are never reported.
spawn.json, so its runs are not reported. The monitor prints one claudish-too-old notice when it reads a version below 10.4.0 from one of this session's claudish servers; when no version can be read, it prints none.claude -p no monitor runs, so the shipped instructions never rely on it alone.bun on PATH. Without it Claude Code reports script failed (exit 127) once per session.claude -p, which starts no monitors. Not on Bedrock, Vertex or Foundry, nor with DISABLE_TELEMETRY or CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set..mcp.json does. A wrapper process in between hides which window started a run, and its runs are not reported.ps there is no too-old notice and no check for a reused writer pid, and a run already in flight when the monitor starts is not picked up, not even when it ends.Application Support), because a ps line cannot be split on it.run_prompt writes no session record and is not reported. Neither are run-and-judge and judge, although both start child processes: they hold the tool call open until the run ends and return the verdict in its result. status and cancel start nothing.waits.jsonl once it holds 1 MiB, about 5,000 waits. Waits after that are not reported.running until the window closes, and the wait in the claudish:claudish-usage skill runs to its ceiling./clear and an in-session /resume: measured once, survives both. In one live session (Claude Code 2.1.290) the monitor process stayed the same across /clear and /resume, and a run started after each delivered its start and end lines.They are independent. The monitor is on by default, needs no flag, and reports state changes only. Channel mode (below) is opt-in and pushes claudish's own event stream, including per-tool progress. With both enabled you get both; neither reads the other.
Measured on Claude Code 2.1.286 and 2.1.292 (see Older Claude Code below). While a team(mode="run") run or a create_session session that this Claude Code session started is going, a band above the prompt shows it, one line per model:
◆ claudish · 4 running
01 ▶ running gpt-6.1-sol OpenRouter 12.7k/780 4 tools 1 loops idle 1s Read [ Stop ] [ Show ]
Each line carries the slot, its state, the model and its provider, tokens in and out, tool calls, loops, how long the model has been idle, and what it is doing now. A slot that needs you (waiting for input or for a permission) is highlighted. On a narrow terminal the least important columns drop first; a line never wraps.
confirm?, and a second press within four seconds sends the cancel. Cancelling is not a read, so Claude Code then asks its own permission question before anything stops.create_session session starts waiting for input or a permission — an idle session wakes on its own: Claude gets a short notice naming the slot and how to fetch its result, and you see a toast. Runs another session started never wake this one.One turn per change, with the session progress monitor. Some changes are also reported by the session progress monitor: a create_session session ending or starting to wait, and a team run settling. For those, the band holds its notice for 10 seconds. If the monitor's line reaches the conversation first, the band sends nothing and that line is the one turn. If no line comes, Claude gets the band's notice after the 10 seconds. A monitor line that arrives later than that still reaches Claude, so a rare change can bring a second turn. A slot of a team run that ends while other slots still run is not held: the monitor reports a run only once it settles. If such a notice is still waiting when the run settles (it was held for a turn in progress), it waits with the settle, so the monitor's one line covers the whole run. A run is matched to the monitor's line by the record claudish names it by, never by its directory, so a run started again in the same directory is never covered by the line of the run before it.
What it does not show. A team(mode="run-and-judge") call answers only once the run and its judging have finished, so the band first sees it settled and does not show it, and it cannot be stopped from the band. Runs started before the plugin loaded, or by anything other than this session's own claudish tool calls, are not shown either.
Read-only calls do not prompt. The plugin allows claudish's read-only calls — listing runs, a run's status, a slot's screen, listing sessions, and a session's screen — before Claude Code's permission check, so they never ask you and never wait behind another open question, such as Stop's own. It does so for claudish installed through this plugin and for a claudish server you registered yourself, and it matches each tool by its exact name. This applies to Claude's own calls of those tools too. Starting a run, cancelling one, and every other claudish call still ask as usual.
Your own permission rules still win: Claude Code applies a matching deny rule (the call is blocked) and a matching ask rule (you are asked) whatever the plugin answers. To be asked again for these calls, add an ask rule for them; the band's refresh then asks too.
What it needs. The band comes from the claudish CLI's status answers, which exist from claudish 10.4.0 (mod contract version 1). With an older claudish the band never appears and nothing else changes; run claudish update (or reinstall it) to get it.
Where it stays off. The band is a Claude Code mod, so it is inert wherever Claude Code refuses mods: --bare, safe mode, disableAllHooks, and a folder you have not trusted. claudish's tools keep working in all of these.
Older Claude Code. The band needs Claude Code 2.1.286 or later. On 2.1.250 and 2.1.284 Claude Code shows a one-line notice at startup (claudish: hooks module did not load: … is not an event) and runs without the band; on 2.1.223 nothing is shown. claudish's MCP tools, the read-only allow and the dependency check work on every one of these versions. Update Claude Code to get the band.
On Claude Desktop, the band and the tabs work in the Code tab, but a wake-up's turn stays invisible until you type something (anthropics/claude-code#96336). The toast is the visible signal there.
Claudish emits notifications/claude/channel events during long-running model sessions. To enable them in Claude Code:
claude --dangerously-load-development-channels plugin:claudish@magus
See the Channels reference for what channels do, and Claudish's own CLAUDE.md "Channel Mode" section for implementation details.
Requirements:
-p mode)This is a runtime dependency of:
code-search — semantic code search via mnemex; uses Claudish for multi-model team reviewdev — universal development assistant; uses Claudish for /dev:research, /team, model orchestrationmultimodel — /team and /delegate slash commandsdesigner — UI review with multi-model validationIf you are installing Magus, this plugin is auto-installed when any of the above is enabled.
hooks/status/register.tsx 157 lines1// The composition root of the claudish status mod: every atom and every engine-interface
2// call the mod makes is spelled here, because the engine's static scan follows the
3// interface into no imported function and reads a state reference only where its plugin
4// and key are literals of the file using it. Nothing here decides anything beyond wiring:
5// poll, wake and the adapter take values and the closures bound below.
6
7import { atom, memberOf, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import { CLAUDISH_TOOL, makeClaudishSource } from './claudish-source'
11import { capped, claimVia, rejected, toAccepted, type Host, type Store } from './host'
12import { PANE_ID_PATTERN } from './domain'
13import { bandModel, paneFit, paneModel } from './layout'
14import * as poll from './poll'
15import * as wake from './wake'
16import * as controls from './controls'
17import { bandTree } from './band'
18import { paneTree } from './pane'
19
20// The seven atoms. plugin and key are literals HERE: the scan reads no reference from another file.
21const RUNS = atom({ plugin: 'claudish', key: 'runs' } as const, { epoch: 0, starts: {}, list: [] })
22const FEEDS = atom({ plugin: 'claudish', key: 'feeds' } as const, {})
23const STOP_REQUESTS = atom({ plugin: 'claudish', key: 'stopRequests' } as const, {})
24const PANES = atom({ plugin: 'claudish', key: 'panes' } as const, null) // a family: members by memberOf
25const LEDGER = atom({ plugin: 'claudish', key: 'wakeLedger' } as const, { epoch: 0, entries: {} })
26const TURN = atom({ plugin: 'claudish', key: 'turn' } as const, { isRunning: false, since: 0 })
27const EARLIER = atom({ plugin: 'claudish', key: 'earlier' } as const, { runs: 0, done: 0, failed: 0, stopped: 0 })
28
29// One closure set per atom, spelled per atom: a generic helper taking the atom as a parameter is refused.
30function bindStore($: EngineInterface): Store {
31 return {
32 runs: {
33 read: () => read($, RUNS),
34 update: fn => update($, RUNS, capped(fn)),
35 claim: fn => claimVia(f => update($, RUNS, capped(f)), fn),
36 },
37 feeds: {
38 read: () => read($, FEEDS),
39 update: fn => update($, FEEDS, capped(fn)),
40 claim: fn => claimVia(f => update($, FEEDS, capped(f)), fn),
41 },
42 stopRequests: {
43 read: () => read($, STOP_REQUESTS),
44 update: fn => update($, STOP_REQUESTS, capped(fn)),
45 claim: fn => claimVia(f => update($, STOP_REQUESTS, capped(f)), fn),
46 },
47 ledger: {
48 read: () => read($, LEDGER),
49 update: fn => update($, LEDGER, capped(fn)),
50 claim: fn => claimVia(f => update($, LEDGER, capped(f)), fn),
51 },
52 turn: {
53 read: () => read($, TURN),
54 update: fn => update($, TURN, capped(fn)),
55 claim: fn => claimVia(f => update($, TURN, capped(f)), fn),
56 },
57 earlier: {
58 read: () => read($, EARLIER),
59 update: fn => update($, EARLIER, capped(fn)),
60 claim: fn => claimVia(f => update($, EARLIER, capped(f)), fn),
61 },
62 panes: {
63 // the family member by computed id: only `id` is computed
64 read: id => read($, memberOf(PANES, { requestId: id })),
65 update: (id, fn) => update($, memberOf(PANES, { requestId: id }), capped(fn)),
66 claim: (id, fn) => claimVia(f => update($, memberOf(PANES, { requestId: id }), capped(f)), fn),
67 },
68 }
69}
70
71// The interface itself may not be kept, so each effect is one closure spelling it out.
72function bindHost($: EngineInterface, cwd: string): Host {
73 return {
74 store: bindStore($),
75 cwd,
76 source: makeClaudishSource((server, tool, args) => $.mcp.call(server, tool, args)), // the mod's one MCP closure
77 now: () => $.clock.now(),
78 after: (ms, fn) => $.clock.after(ms, fn),
79 toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs === undefined ? undefined : { timeoutMs }),
80 status: text => $.ui.status(text),
81 submit: text => $.prompt.submit({ text }).then(toAccepted, rejected),
82 debug: text => $.ui.log(text, { to: 'debug' }),
83 panes: () => $.ui.panes(),
84 open: (id, title, columns) => $.ui.open(columns === undefined ? { id, title } : { id, title, columns }),
85 close: id => $.ui.close({ id }),
86 messages: () => $.session.messages(),
87 }
88}
89
90export const register: Register = on => {
91 let host: Host | null = null // closures over the session.start dispatch, rebuilt on every load
92
93 on('session.start', async ($, e, next) => {
94 host = bindHost($, e.cwd)
95 const bound = host
96 await wake.resetVolatile(bound) // stop requests and the turn flag: what a reload strands
97 void wake.recover(bound).then(() => wake.flushDetached(bound), () => wake.flushDetached(bound)) // inflight → delivered | pending, then deliver what is due
98 poll.ensureLoop(bound) // restarts only if state holds something to watch
99 return next(e)
100 })
101
102 on('session.end', ($, e, next) => (host ? poll.onSessionEnd(host, e, next) : next(e)))
103
104 // The main loop's turns: notices are held while one runs and delivered at its end. Never a
105 // guard: should the hook fail, the turn still runs.
106 on('turn.start', ($, e, next) => (host ? wake.onTurnStart(host, e, next) : next(e)))
107 .catch(($, e, next) => next(e))
108 on('turn.complete', ($, e, next) => (host ? wake.onTurnComplete(host, e, next) : next(e)))
109 .catch(($, e, next) => next(e))
110
111 // Observe-only: every row passes on unchanged, and nothing waits on the look. A row carrying
112 // claudish's own monitor lines stands the wake down for the changes they report.
113 on('session.append', ($, e, next) => {
114 if (host) wake.observeRow(host, e)
115 return next(e)
116 }).catch(($, e, next) => next(e))
117
118 // An observer, not a guard: should it fail, the model's call still runs (or keeps the answer it got).
119 on('tool.call', { tool: CLAUDISH_TOOL }, ($, e, next) => (host ? poll.onClaudishCall(host, e, next) : next(e)))
120 .catch(($, e, next) => next(e))
121
122 // The band. Reads use this dispatch's interface, so they subscribe it; presses act through
123 // a Store bound to this same dispatch (the reference's own onPress form).
124 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
125 const bound = host
126 if (!bound || e.props.hasSurvey) return next(e)
127 const model = bandModel({
128 runs: await read($, RUNS),
129 feeds: await read($, FEEDS),
130 stops: await read($, STOP_REQUESTS),
131 ledger: await read($, LEDGER),
132 earlier: await read($, EARLIER),
133 bodyColumns: e.props.bodyColumns,
134 maxRows: e.props.maxRows,
135 })
136 if (model === null) return next(e) // nothing to draw
137 const press = bindStore($)
138 return bandTree($.ui.resolve(e), model, {
139 stop: target => void controls.pressStop(press, bound, target),
140 show: target => void controls.pressShow(press, bound, target).then(() => poll.ensureLoop(bound)),
141 })
142 })
143
144 // A Show tab: its own member (a frame redraws that tab alone) and the runs for its header.
145 on('ui.render', { component: 'Pane', requestId: PANE_ID_PATTERN }, async ($, e, next) => {
146 const view = await read($, memberOf(PANES, e))
147 if (!view) return next(e)
148 return paneTree($.ui.resolve(e), paneModel(view, await read($, RUNS), paneFit(e.props, e.surface)))
149 })
150
151 // A closed tab forgets its view; it never refuses the close, failing or not.
152 on('ui.close', { id: PANE_ID_PATTERN }, async ($, e, next) => {
153 await update($, memberOf(PANES, { requestId: e.id }), () => null)
154 return next(e)
155 }).catch(($, e, next) => next(e))
156}
157hooks/status/claudish-source.ts 508 lines1// THE ADAPTER: claudish mod contract v1 (sections A–E), and the line grammar of claudish's
2// own session monitor, → the mod's domain types.
3//
4// The only module of the mod that names a claudish tool, a mode, an argument, a capability,
5// a response field or a monitor line's words. (The plugin's PreToolUse command hook,
6// hooks/allow-read-verbs.ts, names claudish's read-only tools too; it is not part of the mod.)
7// It calls MCP only through the McpCall it was given (register.tsx builds the mod's one MCP
8// closure), holds no state, and decides nothing about display, cadence, support policy or
9// waking: it reports what the server answered, and what the monitor said.
10
11import type { McpToolResult } from 'claude-code'
12import type { ClaudishColor, ClaudishFrame, ClaudishRunKind, ClaudishRunRef, ClaudishSlot, ClaudishSlotState, ClaudishStyleSpan } from '../../types'
13import { STYLE_SPANS_PER_LINE, feedKey, sanitize } from './domain'
14import type {
15 CallSeen,
16 CaptureResult,
17 FeedAnswer,
18 FetchSlot,
19 PollBatch,
20 PollResult,
21 RecognizedCall,
22 RunSource,
23 MonitorReport,
24 StopResult,
25 WatchedRun,
26} from './run-source'
27
28/** The tool.call matcher: claudish installed through the plugin, or registered directly. */
29export const CLAUDISH_TOOL = /^mcp__(plugin_claudish_claudish|claudish)__([a-z_]+)$/
30
31/** Tool-name segment → the server name the engine's mcp.call takes ("Reaching claudish"). */
32const SERVER_OF: Readonly<Record<string, string>> = {
33 plugin_claudish_claudish: 'plugin:claudish:claudish',
34 claudish: 'claudish',
35}
36
37export type McpCall = (server: string, tool: string, args: Record<string, unknown>) => Promise<McpToolResult>
38
39// ── contract v1 vocabulary ────────────────────────────────────────────────────
40
41const CONTRACT_VERSION = 1
42const SLOT_STATES: ReadonlySet<string> = new Set([
43 'STARTING', 'RUNNING', 'AWAITING_INPUT', 'AWAITING_PERMISSION',
44 'COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT', 'EMPTY',
45])
46const FAILURE_REASONS: ReadonlySet<string> = new Set([
47 'cancelled', 'timeout', 'boot_timeout', 'boot_blocked', 'first_run_dialog', 'agent_rejected',
48 'prompt_not_accepted', 'prompt_not_read', 'child_exited', 'pane_lost', 'blocked', 'api_error',
49 'refused', 'empty_output', 'shape_mismatch',
50])
51const GONE_CODES: ReadonlySet<string> = new Set(['unknown_run', 'unknown_session', 'unknown_slot'])
52const QUESTION_ACTIVITY = 'AskUserQuestion'
53const MODEL_ID = /^[\w.@:/-]{1,80}$/
54const PRECONTRACT_START = 'no run_id: pre-contract claudish'
55
56type Json = Record<string, unknown>
57
58// ── decoding helpers ──────────────────────────────────────────────────────────
59
60function isObject(v: unknown): v is Json {
61 return typeof v === 'object' && v !== null && !Array.isArray(v)
62}
63
64function parseObject(text: string | null | undefined): Json | null {
65 if (typeof text !== 'string') return null
66 try {
67 const v: unknown = JSON.parse(text)
68 return isObject(v) ? v : null
69 } catch {
70 return null
71 }
72}
73
74/** content[0].text of an MCP answer (the contract answers in one text block). */
75function firstText(result: McpToolResult): string | null {
76 const block = Array.isArray(result.content) ? result.content[0] : undefined
77 return isObject(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : null
78}
79
80/** The ContractError code of an error body, or null when the body is not one. */
81function contractErrorCode(text: string | null): string | null {
82 const body = parseObject(text)
83 const err = body && isObject(body.error) ? body.error : null
84 return err && typeof err.code === 'string' && typeof err.message === 'string' ? err.code : null
85}
86
87function short(text: unknown): string {
88 const s = text instanceof Error ? text.message : typeof text === 'string' ? text : String(text)
89 return sanitize(s, 120) || 'no reason given'
90}
91
92function num(v: unknown): number | null {
93 return typeof v === 'number' && Number.isFinite(v) ? v : null
94}
95
96function str(v: unknown, max: number): string | null {
97 return typeof v === 'string' ? sanitize(v, max) : null
98}
99
100/** Server text the mod shows and quotes to the model as it stands: display-safe already, no quote or backslash. */
101function isSafeText(text: string, max: number): boolean {
102 return text.length > 0 && text.length <= max && sanitize(text, max) === text && !/["\\]/.test(text)
103}
104
105/** A run_id, session_id or slot id. */
106const isSafeId = (id: string): boolean => isSafeText(id, 128)
107
108/** run.path: absolute, and display-safe. */
109const isSafePath = (path: string): boolean => path.startsWith('/') && isSafeText(path, 4096)
110
111function lastSegment(path: string): string {
112 const parts = path.split('/').filter(p => p.length > 0)
113 return (parts[parts.length - 1] ?? path).slice(0, 16)
114}
115
116// ── recognizing a start ───────────────────────────────────────────────────────
117
118function recognizeCall(call: CallSeen): RecognizedCall {
119 const m = CLAUDISH_TOOL.exec(call.tool)
120 const server = m ? SERVER_OF[m[1] ?? ''] : undefined
121 if (!m || !server) return { kind: 'other' }
122 const verb = m[2]
123 if (call.answer.isError) return { kind: 'other' }
124
125 if (verb === 'team') {
126 const mode = call.args.mode
127 if (mode !== 'run' && mode !== 'run-and-judge') return { kind: 'other' }
128 const body = parseObject(call.answer.text)
129 if (!body) return { kind: 'unidentified', reason: 'team start answer is not a JSON object' }
130 if (typeof body.run_id !== 'string' || body.run_id.length === 0) {
131 return { kind: 'precontract', server, runKind: 'panel', reason: PRECONTRACT_START }
132 }
133 if (!isSafeId(body.run_id)) return { kind: 'unidentified', reason: 'team start answer has a run_id that is not display-safe' }
134 const run = isObject(body.run) ? body.run : null
135 if (run?.state === 'SETTLED') return { kind: 'other' }
136 const path = run?.path
137 if (typeof path !== 'string' || !path.startsWith('/')) {
138 return { kind: 'unidentified', reason: 'team start answer has no absolute run.path' }
139 }
140 if (!isSafePath(path)) return { kind: 'unidentified', reason: 'team start answer has a run.path that is not display-safe' }
141 // The monitor names the run by its end record, not by run_id: kept so its line matches this start alone.
142 const monitor = typeof body.monitor_record === 'string' && isSafeId(body.monitor_record) ? body.monitor_record : null
143 const ref: ClaudishRunRef = monitor === null
144 ? { kind: 'panel', server, token: body.run_id, address: path }
145 : { kind: 'panel', server, token: body.run_id, address: path, monitor }
146 return { kind: 'start', ref, label: lastSegment(path) }
147 }
148
149 if (verb === 'create_session') {
150 const body = parseObject(call.answer.text)
151 if (!body) return { kind: 'unidentified', reason: 'create_session answer is not a JSON object' }
152 const id = body.session_id
153 if (typeof id !== 'string' || id.length === 0) {
154 return { kind: 'unidentified', reason: 'create_session answer has no session_id' }
155 }
156 if (!isSafeId(id)) return { kind: 'unidentified', reason: 'create_session answer has a session_id that is not display-safe' }
157 const ref: ClaudishRunRef = { kind: 'delegation', server, token: id, address: id }
158 return { kind: 'start', ref, label: `#${id.slice(0, 6)}` }
159 }
160
161 return { kind: 'other' }
162}
163
164// ── one list answer: classification, then rows ───────────────────────────────
165
166type Classified = { answer: FeedAnswer; rows: Json[] }
167
168function classify(result: McpToolResult | Error, rowsKey: 'runs' | 'sessions'): Classified {
169 const none: Json[] = []
170 // 1. the call itself failed
171 if (result instanceof Error) return { answer: { kind: 'unanswered', reason: short(result) }, rows: none }
172 const text = firstText(result)
173 // 2. an error: the contract's error body, or a server that predates the contract
174 if (result.isError === true) {
175 const code = contractErrorCode(text)
176 return code !== null
177 ? { answer: { kind: 'refused', code }, rows: none }
178 : { answer: { kind: 'precontract', reason: `error answer without a contract error body: ${short(text ?? '')}` }, rows: none }
179 }
180 // 3. not a JSON object
181 const body = parseObject(text)
182 if (!body) return { answer: { kind: 'garbled', reason: 'answer is not a JSON object' }, rows: none }
183 // 4. no contract declaration
184 const version = body.contract_version
185 const caps = body.capabilities
186 if (typeof version !== 'number' || !Number.isInteger(version)
187 || !Array.isArray(caps) || !caps.every(c => typeof c === 'string')) {
188 return { answer: { kind: 'precontract', reason: 'answer carries no contract_version and capabilities' }, rows: none }
189 }
190 // 5. a version this mapping does not read, or no list capability
191 if (version !== CONTRACT_VERSION) return { answer: { kind: 'declines', reason: `contract version ${version}` }, rows: none }
192 const has = (c: string) => (caps as string[]).includes(c)
193 if (!has('list')) return { answer: { kind: 'declines', reason: 'no list capability' }, rows: none }
194 // 6. the row array
195 const rows = body[rowsKey]
196 if (!Array.isArray(rows)) return { answer: { kind: 'garbled', reason: `answer has no ${rowsKey} array` }, rows: none }
197 // 7. speaks
198 const capture = has('capture') && has('capture_since_seq')
199 return {
200 answer: { kind: 'speaks', version: 1, can: { cancel: has('cancel'), capture, spans: capture && has('capture_spans') } },
201 rows: rows.filter(isObject),
202 }
203}
204
205function mapSlot(row: Json): ClaudishSlot | null {
206 if (typeof row.slot !== 'string' || !isSafeId(row.slot)) return null
207 const model = typeof row.model === 'string' ? sanitize(row.model, 80) : ''
208 const state: ClaudishSlotState = typeof row.state === 'string' && SLOT_STATES.has(row.state)
209 ? (row.state as ClaudishSlotState)
210 : 'UNKNOWN'
211 const activity = str(row.activity, 60) || null
212 const lastAt = typeof row.last_activity_at === 'string' ? Date.parse(row.last_activity_at) : NaN
213 return {
214 slot: row.slot,
215 model: MODEL_ID.test(model) ? model : 'unknown model',
216 provider: str(row.provider, 40) || null,
217 state,
218 reason: typeof row.reason === 'string' && FAILURE_REASONS.has(row.reason) ? row.reason : null,
219 tokensIn: num(row.tokens_in),
220 tokensOut: num(row.tokens_out),
221 toolCalls: num(row.tool_calls),
222 turnsCompleted: num(row.turns_completed),
223 idleSeconds: num(row.idle_seconds),
224 lastActivityAt: Number.isFinite(lastAt) ? lastAt : null,
225 activity,
226 asked: state === 'AWAITING_INPUT' && row.activity === QUESTION_ACTIVITY,
227 }
228}
229
230/** A watched run's row, matched by its own per-start id, exactly. */
231function pollResult(kind: ClaudishRunKind, rows: readonly Json[], token: string): PollResult {
232 if (kind === 'panel') {
233 const row = rows.find(r => r.run_id === token)
234 if (!row) return { kind: 'missing', reason: null }
235 if (!Array.isArray(row.slots)) return { kind: 'missing', reason: 'run row has no slots array' }
236 const slots: ClaudishSlot[] = []
237 for (const s of row.slots) {
238 const mapped = isObject(s) ? mapSlot(s) : null
239 if (!mapped) return { kind: 'missing', reason: 'a slot row has no slot id' }
240 slots.push(mapped)
241 }
242 return { kind: 'ok', slots }
243 }
244 const row = rows.find(r => r.session_id === token)
245 if (!row) return { kind: 'missing', reason: null }
246 const mapped = mapSlot(row)
247 return mapped ? { kind: 'ok', slots: [mapped] } : { kind: 'missing', reason: 'session row has no slot id' }
248}
249
250// ── stop ──────────────────────────────────────────────────────────────────────
251
252function stopResult(result: McpToolResult | Error, ref: ClaudishRunRef, slot: string): StopResult {
253 if (result instanceof Error) return { kind: 'failed', reason: short(result) }
254 const text = firstText(result)
255 if (result.isError === true) return { kind: 'failed', reason: contractErrorCode(text) ?? short(text ?? '') }
256 const body = parseObject(text)
257 if (!body) return { kind: 'failed', reason: 'cancel answer is not a JSON object' }
258 if (ref.kind === 'delegation') {
259 return typeof body.changed === 'boolean'
260 ? { kind: 'stopped', changed: body.changed }
261 : { kind: 'failed', reason: 'cancel answer has no changed flag' }
262 }
263 const entry = Array.isArray(body.results) ? body.results.find(r => isObject(r) && r.slot === slot) : undefined
264 return isObject(entry) && typeof entry.changed === 'boolean'
265 ? { kind: 'stopped', changed: entry.changed }
266 : { kind: 'failed', reason: `cancel answer has no result for slot ${slot}` }
267}
268
269// ── capture ───────────────────────────────────────────────────────────────────
270
271const SGR_OR_CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g
272const C0 = /[\u0000-\u0008\u000a-\u001f\u007f]/g
273
274function cleanLine(line: string, cols: number): string {
275 let out = ''
276 for (const ch of line.replace(SGR_OR_CSI, '').replace(C0, '')) {
277 if (ch === '\t') out += ' '.repeat(8 - (out.length % 8))
278 else out += ch
279 }
280 return out.slice(0, cols).replace(/\s+$/, '')
281}
282
283function color(c: number): ClaudishColor | null {
284 if (c >= 0 && c <= 255) return { index: c }
285 if (c >= 16_777_216) return { rgb: c & 0xffffff }
286 return null
287}
288
289function sameStyle(a: ClaudishStyleSpan, b: ClaudishStyleSpan): boolean {
290 const eq = (x: ClaudishColor | null, y: ClaudishColor | null) => JSON.stringify(x) === JSON.stringify(y)
291 return a.bold === b.bold && eq(a.fg, b.fg) && eq(a.bg, b.bg)
292}
293
294/** One line's span tuples → sorted, non-overlapping, merged spans, at most STYLE_SPANS_PER_LINE. */
295function decodeLine(raw: unknown, cols: number): ClaudishStyleSpan[] {
296 if (!Array.isArray(raw)) return []
297 const spans: ClaudishStyleSpan[] = []
298 for (const t of raw) {
299 if (!Array.isArray(t) || t.length !== 5 || !t.every(n => Number.isInteger(n))) continue
300 const [col, len, fg, bg, attr] = t as [number, number, number, number, number]
301 if (col < 0 || len < 1 || col >= cols) continue
302 const span: ClaudishStyleSpan = { col, len: Math.min(col + len, cols) - col, fg: color(fg), bg: color(bg), bold: (attr & 1) === 1 }
303 if (span.fg === null && span.bg === null && !span.bold) continue
304 spans.push(span)
305 }
306 spans.sort((a, b) => a.col - b.col)
307 const kept: ClaudishStyleSpan[] = []
308 for (const s of spans) {
309 const prev = kept[kept.length - 1]
310 if (prev && s.col < prev.col + prev.len) continue
311 if (prev && prev.col + prev.len === s.col && sameStyle(prev, s)) {
312 kept[kept.length - 1] = { ...prev, len: prev.len + s.len }
313 continue
314 }
315 kept.push(s)
316 }
317 return kept.slice(0, STYLE_SPANS_PER_LINE)
318}
319
320function decodeSpans(raw: unknown, rows: number, cols: number): ClaudishStyleSpan[][] | undefined {
321 if (!Array.isArray(raw) || raw.length !== rows) return undefined
322 return raw.map(line => decodeLine(line, cols))
323}
324
325function captureResult(result: McpToolResult | Error, withSpans: boolean): CaptureResult {
326 if (result instanceof Error) return { kind: 'unavailable', reason: short(result) }
327 const text = firstText(result)
328 if (result.isError === true) {
329 const code = contractErrorCode(text)
330 if (code !== null && GONE_CODES.has(code)) return { kind: 'gone' }
331 return { kind: 'unavailable', reason: code ?? short(text ?? '') }
332 }
333 const body = parseObject(text)
334 if (!body) return { kind: 'unavailable', reason: 'capture answer is not a JSON object' }
335 const final = body.final === true
336 if (body.unchanged === true) return { kind: 'unchanged', final }
337 const { seq, cols, rows, lines } = body
338 if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0
339 || typeof cols !== 'number' || !Number.isInteger(cols) || cols < 1
340 || typeof rows !== 'number' || !Number.isInteger(rows) || rows < 1
341 || !Array.isArray(lines) || !lines.every(l => typeof l === 'string')) {
342 return { kind: 'unavailable', reason: 'capture answer is malformed' }
343 }
344 if (seq === 0) return { kind: 'unchanged', final }
345 const kept = (lines as string[]).slice(0, rows).map(l => cleanLine(l, cols))
346 while (kept.length < rows) kept.push('')
347 const cur = isObject(body.cursor) ? body.cursor : null
348 const x = cur ? num(cur.x) : null
349 const y = cur ? num(cur.y) : null
350 const frame: ClaudishFrame = {
351 seq,
352 cols,
353 rows,
354 cursor: x !== null && y !== null && x >= 0 && y >= 0 && x < cols && y < rows ? { row: y, col: x } : null,
355 lines: kept,
356 }
357 if (withSpans) {
358 const styles = lines.length === rows ? decodeSpans(body.spans, rows, cols) : undefined
359 if (styles) frame.styles = styles
360 }
361 return { kind: 'frame', frame, final }
362}
363
364// ── wording the wake text borrows ────────────────────────────────────────────
365
366/** A quoted value of the wake text: JSON string syntax, so no quote inside can end it early. */
367const q = (s: string): string => JSON.stringify(s)
368
369function runLine(ref: ClaudishRunRef, label: string): string {
370 return ref.kind === 'panel'
371 ? `Run ${label} (path ${q(ref.address)}, run_id ${q(ref.token)}):`
372 : `Delegation ${label} (session_id ${q(ref.token)}):`
373}
374
375function delegationHint(id: string, s: FetchSlot): string {
376 const sid = `session_id=${q(id)}`
377 switch (s.state) {
378 case 'COMPLETED':
379 return `get_output(${sid})`
380 case 'AWAITING_INPUT':
381 return s.asked
382 ? `Read its answer with get_output(${sid}); reply with send_input(${sid}, …): it is asking a question; send_input declines the question and sends your text as its next prompt`
383 : `Read its answer with get_output(${sid}); reply with send_input(${sid}, …)`
384 case 'AWAITING_PERMISSION':
385 return `it is waiting on a permission dialog; get_output(${sid}) shows it; send_input declines the dialog and sends your text as its next prompt; cancel_session(${sid}) stops it`
386 case 'STARTING':
387 case 'RUNNING':
388 case 'UNKNOWN':
389 return `get_output(${sid}) once it finishes`
390 default:
391 return `get_diagnostics(${sid})`
392 }
393}
394
395function fetchHint(ref: ClaudishRunRef, slots: readonly FetchSlot[]): string {
396 if (ref.kind === 'panel') {
397 return `team(mode="status", path=${q(ref.address)}, run_id=${q(ref.token)}) then read each finished slot's response file`
398 }
399 const s = slots[0] ?? { slot: ref.token, state: 'COMPLETED' as const, asked: false }
400 return delegationHint(ref.token, s)
401}
402
403// ── claudish's session monitor: its line grammar (plugin README, "Session progress monitor") ──
404
405const MONITOR_PREFIX = 'claudish-monitor: '
406const MONITOR_ID = '[A-Za-z0-9._-]{1,64}'
407// value = 1*200 printable ASCII without space " & < = >
408const MONITOR_LINE = new RegExp(
409 `^claudish-monitor: (session|team) (${MONITOR_ID}) (started|running|needs-input|completed|failed|timeout|cancelled)`
410 + `((?: [a-z]+=[!#-%'-;?-~]{1,200})*)`
411 + `(?: next: (?:(?:get_output|get_diagnostics|send_input) ${MONITOR_ID}|team-status))?$`,
412)
413const SESSION_ENDS: ReadonlySet<string> = new Set(['completed', 'failed', 'timeout', 'cancelled'])
414const TEAM_ENDS: ReadonlySet<string> = new Set(['completed', 'failed', 'cancelled'])
415
416/** Each candidate line of a row's text: at a line's start, or right after a tag (`<event>…`), cut at the next tag. */
417function monitorLines(text: string): string[] {
418 const out: string[] = []
419 for (const raw of text.split(/\r?\n/)) {
420 let from = 0
421 for (;;) {
422 const at = raw.indexOf(MONITOR_PREFIX, from)
423 if (at < 0) break
424 from = at + MONITOR_PREFIX.length
425 const before = raw.slice(0, at).trimStart()
426 if (before !== '' && !before.endsWith('>')) continue
427 const end = raw.indexOf('<', at)
428 out.push((end < 0 ? raw.slice(at) : raw.slice(at, end)).trimEnd())
429 }
430 }
431 return out
432}
433
434/** A team line names its run by the start answer's `monitor_record`; its `path` is not an identity (one
435 * directory can hold run after run), so it is not read. */
436function monitorReports(text: string): MonitorReport[] {
437 const out: MonitorReport[] = []
438 for (const line of monitorLines(text)) {
439 const m = MONITOR_LINE.exec(line)
440 if (!m) continue
441 const [, record, id = '', state = ''] = m
442 if (record === 'session') {
443 if (state === 'needs-input') out.push({ kind: 'delegation', token: id, event: 'waiting' })
444 else if (SESSION_ENDS.has(state)) out.push({ kind: 'delegation', token: id, event: 'ended' })
445 continue
446 }
447 if (TEAM_ENDS.has(state)) out.push({ kind: 'panel', record: id, event: 'ended' })
448 }
449 return out
450}
451
452// ── the adapter ───────────────────────────────────────────────────────────────
453
454const settleCall = (p: Promise<McpToolResult>): Promise<McpToolResult | Error> =>
455 p.then(
456 r => r,
457 (e: unknown) => (e instanceof Error ? e : new Error(String(e))),
458 )
459
460/** The one RunSource. register.tsx passes its one MCP closure; tests may pass a stub. */
461export function makeClaudishSource(call: McpCall): RunSource {
462 return {
463 recognizeCall,
464
465 async poll(runs: readonly WatchedRun[]): Promise<PollBatch> {
466 const byFeed = new Map<string, WatchedRun[]>()
467 for (const r of runs) {
468 const key = feedKey(r.ref)
469 byFeed.set(key, [...(byFeed.get(key) ?? []), r])
470 }
471 const feeds = new Map<string, FeedAnswer>()
472 const results = new Map<string, PollResult>()
473 await Promise.all([...byFeed.entries()].map(async ([key, watched]) => {
474 const first = watched[0]
475 if (!first) return
476 const { kind, server } = first.ref
477 const answer = kind === 'panel'
478 ? await settleCall(call(server, 'team', { mode: 'list' }))
479 : await settleCall(call(server, 'list_sessions', { include_completed: true }))
480 const { answer: feed, rows } = classify(answer, kind === 'panel' ? 'runs' : 'sessions')
481 feeds.set(key, feed)
482 if (feed.kind !== 'speaks') return
483 for (const w of watched) results.set(w.id, pollResult(kind, rows, w.ref.token))
484 }))
485 return { feeds, runs: results }
486 },
487
488 async stop(ref: ClaudishRunRef, slot: string): Promise<StopResult> {
489 const answer = ref.kind === 'panel'
490 ? await settleCall(call(ref.server, 'team', { mode: 'cancel', path: ref.address, slot, run_id: ref.token }))
491 : await settleCall(call(ref.server, 'cancel_session', { session_id: ref.token }))
492 return stopResult(answer, ref, slot)
493 },
494
495 async capture(ref: ClaudishRunRef, slot: string, sinceSeq: number, withSpans: boolean): Promise<CaptureResult> {
496 const spans = withSpans ? { spans: true } : {}
497 const answer = ref.kind === 'panel'
498 ? await settleCall(call(ref.server, 'team', { mode: 'capture', path: ref.address, slot, run_id: ref.token, since_seq: sinceSeq, ...spans }))
499 : await settleCall(call(ref.server, 'capture_session', { session_id: ref.token, since_seq: sinceSeq, ...spans }))
500 return captureResult(answer, withSpans)
501 },
502
503 runLine,
504 fetchHint,
505 monitorReports,
506 }
507}
508hooks/status/host.ts 113 lines1// The shapes register.tsx fills with closures over a dispatch's engine interface, and
2// four pure helpers. No engine-interface spelling and no state reference live here: the
3// engine's static scan reads a state reference only where its plugin and key are
4// literals of the file that uses it, and follows the engine interface into no import.
5
6import type { PromptSubmitResult, Timer, UiOpenResult, UiPane } from 'claude-code'
7import type {
8 ClaudishEarlier,
9 ClaudishFeedSupport,
10 ClaudishLedger,
11 ClaudishPaneView,
12 ClaudishRuns,
13 ClaudishStopRequest,
14 ClaudishTurn,
15} from '../../types'
16import type { RunSource } from './run-source'
17
18/** One atom, through closures over one dispatch. */
19export type Cell<T> = {
20 read(): Promise<T>
21 /** resolves what it wrote */
22 update(fn: (v: T) => T): Promise<T>
23 /** one update whose fn also returns a claim; resolves [written, the claim of the pass that wrote] */
24 claim<C>(fn: (v: T) => readonly [T, C]): Promise<readonly [T, C]>
25}
26
27/** One family, member by computed id. */
28export type Member<T> = {
29 read(id: string): Promise<T>
30 update(id: string, fn: (v: T) => T): Promise<T>
31 claim<C>(id: string, fn: (v: T) => readonly [T, C]): Promise<readonly [T, C]>
32}
33
34export type Store = {
35 runs: Cell<ClaudishRuns>
36 feeds: Cell<Record<string, ClaudishFeedSupport>>
37 stopRequests: Cell<Record<string, ClaudishStopRequest>>
38 ledger: Cell<ClaudishLedger>
39 turn: Cell<ClaudishTurn>
40 earlier: Cell<ClaudishEarlier>
41 panes: Member<ClaudishPaneView | null>
42}
43
44export type SubmitOutcome = { accepted: true } | { accepted: false; reason: string }
45
46export type TranscriptRow = { role: string; text: string }
47
48/** Everything timer code may do, bound once per load in session.start. */
49export type Host = {
50 /** bound to the session.start dispatch; what timer code uses */
51 store: Store
52 source: RunSource
53 /** the directory the session started in (session.start's cwd): where a monitor line's relative path starts */
54 cwd: string
55 /** the engine's clock.now */
56 now(): Promise<number>
57 /** the engine's clock.after */
58 after(ms: number, fn: () => void): Timer
59 toast(text: string, timeoutMs?: number): void
60 /** the engine's ui.status: one pinned line per plugin; undefined clears it */
61 status(text: string | undefined): void
62 /** the engine's prompt.submit; a drop and a rejection both read as not accepted */
63 submit(text: string): Promise<SubmitOutcome>
64 /** the engine's ui.log to the debug log */
65 debug(text: string): void
66 panes(): Promise<readonly UiPane[]>
67 /** the engine's ui.open; columns: the body width asked for while docked (a request) */
68 open(id: string, title: string, columns?: number): Promise<UiOpenResult>
69 close(id: string): Promise<void>
70 /** the engine's session.messages; recovery only */
71 messages(): Promise<readonly TranscriptRow[]>
72}
73
74const MAX_PASSES = 8
75
76/** The fn an update retries, throwing once it has been asked more than MAX_PASSES times. */
77export function capped<T>(fn: (v: T) => T): (v: T) => T {
78 let passes = 0
79 return v => {
80 passes += 1
81 if (passes > MAX_PASSES) throw new Error(`state update did not converge in ${MAX_PASSES} passes`)
82 return fn(v)
83 }
84}
85
86/** One update through the given closure, keeping the claim of the pass that wrote: every pass
87 * assigns (never appends to) the claim, so the last pass, whose write landed, wins. */
88export async function claimVia<T, C>(
89 apply: (fn: (v: T) => T) => Promise<T>,
90 fn: (v: T) => readonly [T, C],
91): Promise<readonly [T, C]> {
92 let claimed!: C
93 const written = await apply(v => {
94 const [next, c] = fn(v)
95 claimed = c
96 return next
97 })
98 return [written, claimed] as const
99}
100
101/** A submit's answer: the prompt that entered, or a drop. */
102export function toAccepted(result: PromptSubmitResult | { drop: string }): SubmitOutcome {
103 return 'drop' in result && typeof result.drop === 'string'
104 ? { accepted: false, reason: result.drop }
105 : { accepted: true }
106}
107
108/** A submit that rejected. */
109export function rejected(err: unknown): SubmitOutcome {
110 const reason = err instanceof Error ? err.message : String(err)
111 return { accepted: false, reason: reason.slice(0, 120) || 'rejected' }
112}
113hooks/status/domain.ts 471 lines1// Pure domain functions and constants: state classes, ids and hashes, sanitising, the
2// run merge (LOST rules, wait entries, settlement), stop-request pruning and compaction.
3// No engine interface, no state reference, no claudish vocabulary.
4
5import type { ThemeKey } from 'claude-code'
6import type {
7 ClaudishEarlier,
8 ClaudishFeedSupport,
9 ClaudishLedger,
10 ClaudishRun,
11 ClaudishRunKind,
12 ClaudishRunRef,
13 ClaudishRuns,
14 ClaudishSlot,
15 ClaudishSlotState,
16 ClaudishStopRequest,
17 ClaudishWait,
18} from '../../types'
19
20// ── constants ─────────────────────────────────────────────────────────────────
21
22/** Styled runs kept per captured line, the leftmost; the rest of the line draws plain. */
23export const STYLE_SPANS_PER_LINE = 24
24/** A run missing from its list is LOST only after this many misses spanning MISSING_LOST_MS. */
25export const MISSING_LOST_POLLS = 5
26export const MISSING_LOST_MS = 30_000
27/** A speaking feed that keeps failing ends its runs LOST after this long. */
28export const UNREACHABLE_LOST_MS = 5 * 60_000
29/** A slot in a state outside the closed set ends LOST after this long. */
30export const UNKNOWN_LOST_MS = 10 * 60_000
31/** Past this many runs, the oldest settled, fully delivered runs fold into `earlier`. */
32export const RUNS_CAP = 100
33export const STOP_CONFIRM_MS = 4_000
34export const STOP_SENT_TIMEOUT_MS = 20_000
35/** A tab whose slot ended keeps capturing this long for its final screen. */
36export const FINAL_GRACE_MS = 30_000
37export const QUIET_AFTER_S = 120
38export const NO_SCREEN_NOTE = 'Run ended before a screen was captured'
39export const NO_CAPTURE_NOTE = 'Live screen not available from this claudish version'
40
41/** The only colours the mod's own drawing uses: theme keys the engine resolves for light and dark. */
42export const THEME_KEYS = ['claude', 'permission', 'success', 'error', 'warning', 'inactive', 'subtle'] as const satisfies readonly ThemeKey[]
43export type StatusColor = (typeof THEME_KEYS)[number]
44
45/** The body columns a Show tab asks for when docked: a request, the person's own width wins. */
46export const SHOW_COLUMNS = 120
47/** Surfaces that paint the child's colours in a Show tab; the others draw its screen plain. */
48export const PAINT_SURFACES: readonly string[] = ['terminal', 'desktop']
49/** What one frame line and one styled segment cost, estimated, against the style budget. */
50export const PANE_LINE_COST = 64
51export const PANE_SEGMENT_COST = 128
52/** Past this estimate a frame draws plain: half the engine's 100,000-character tree bound. */
53export const PANE_STYLE_BUDGET = 50_000
54
55export const PANE_ID_PATTERN = /^cl_[0-9a-f]{8}$/
56
57/** While the main loop's turn runs (for at most this long), notices wait for its end. */
58export const WAKE_HOLD_GUARD_MS = 30 * 60_000
59/** A refused notice is retried after attempts × this, up to WAKE_QUICK_ATTEMPTS attempts. */
60export const WAKE_RETRY_MS = 30_000
61export const WAKE_QUICK_ATTEMPTS = 3
62/** A parked notice is retried this often, and at every main-loop turn end. */
63export const PARKED_RETRY_MS = 10 * 60_000
64/** A change claudish's session monitor also reports is held this long for the monitor's line, which then
65 * stands for it; a line seen up to this long before the change was noticed counts too. */
66export const MONITOR_GRACE_MS = 10_000
67
68// ── state classes ─────────────────────────────────────────────────────────────
69
70const TERMINAL: ReadonlySet<ClaudishSlotState> = new Set<ClaudishSlotState>([
71 'COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT', 'EMPTY', 'LOST',
72])
73
74export function isTerminal(state: ClaudishSlotState): boolean {
75 return TERMINAL.has(state)
76}
77
78export function isWaiting(state: ClaudishSlotState): boolean {
79 return state === 'AWAITING_INPUT' || state === 'AWAITING_PERMISSION'
80}
81
82/** §4.2: glyph + word, the theme key, and the header word, per state. */
83export const STATE_LOOK: Readonly<Record<ClaudishSlotState, { glyph: string; word: string; color: StatusColor; group: 'needs you' | 'running' | 'done' | 'failed' | 'stopped' }>> = {
84 STARTING: { glyph: '◌', word: 'starting', color: 'warning', group: 'running' },
85 RUNNING: { glyph: '▶', word: 'running', color: 'warning', group: 'running' },
86 AWAITING_INPUT: { glyph: '◇', word: 'input', color: 'permission', group: 'needs you' },
87 AWAITING_PERMISSION: { glyph: '◇', word: 'permit', color: 'permission', group: 'needs you' },
88 COMPLETED: { glyph: '✓', word: 'done', color: 'success', group: 'done' },
89 FAILED: { glyph: '✕', word: 'failed', color: 'error', group: 'failed' },
90 TIMEOUT: { glyph: '✕', word: 'timeout', color: 'error', group: 'failed' },
91 EMPTY: { glyph: '✕', word: 'empty', color: 'error', group: 'failed' },
92 CANCELLED: { glyph: '■', word: 'stopped', color: 'inactive', group: 'stopped' },
93 LOST: { glyph: '?', word: 'lost', color: 'error', group: 'failed' },
94 UNKNOWN: { glyph: '?', word: 'unknown', color: 'inactive', group: 'running' },
95}
96
97// ── ids and hashes ────────────────────────────────────────────────────────────
98
99/** FNV-1a, 32-bit, over UTF-16 code units. */
100export function fnv1a32(text: string): number {
101 let h = 0x811c9dc5
102 for (let i = 0; i < text.length; i++) {
103 h ^= text.charCodeAt(i)
104 h = Math.imul(h, 0x01000193) >>> 0
105 }
106 return h >>> 0
107}
108
109export function hex8(n: number): string {
110 return (n >>> 0).toString(16).padStart(8, '0')
111}
112
113/** One id per start, because claudish's token is per start. */
114export function runId(ref: ClaudishRunRef): string {
115 return hex8(fnv1a32(`${ref.kind}|${ref.server}|${ref.token}`))
116}
117
118/** A feed is one server × one run kind: one list call per tick. */
119export function feedKey(ref: { server: string; kind: ClaudishRunKind }): string {
120 return `${ref.server}#${ref.kind}`
121}
122
123/** A Show tab's pane id: stable across reloads, one per slot of a run. */
124export function paneId(id: string, slot: string): string {
125 return `cl_${hex8(fnv1a32(`${id}/${slot}`))}`
126}
127
128/** `${runId}/${slot}`: the key of a stop request and of a terminal wake. */
129export function slotKey(id: string, slot: string): string {
130 return `${id}/${slot}`
131}
132
133/** `${runId}/${slot}#w${n}`: the key of a delegation slot's n-th wait entry. */
134export function waitKey(id: string, slot: string, n: number): string {
135 return `${id}/${slot}#w${n}`
136}
137
138/** A wake ledger key taken apart; `wait` is the entry number of a wait key, null for a terminal key. */
139export function parseWakeKey(key: string): { runId: string; slot: string; wait: number | null } {
140 const cut = key.indexOf('/')
141 const rest = key.slice(cut + 1)
142 const m = /^(.*)#w(\d+)$/.exec(rest)
143 return m ? { runId: key.slice(0, cut), slot: m[1] ?? '', wait: Number(m[2]) } : { runId: key.slice(0, cut), slot: rest, wait: null }
144}
145
146/** The label a run is shown and named by: `label`, or `label·N` for the N-th start at its address. */
147export function runLabel(run: ClaudishRun): string {
148 return run.generation > 1 ? `${run.label}·${run.generation}` : run.label
149}
150
151/** A run is drawn while it has a slot, or while its feed speaks the contract. */
152export function isDrawn(run: ClaudishRun, feeds: Readonly<Record<string, ClaudishFeedSupport>>): boolean {
153 return run.slots.length > 0 || feeds[feedKey(run.ref)]?.kind === 'speaks'
154}
155
156// ── text ──────────────────────────────────────────────────────────────────────
157
158// SGR and other CSI sequences, OSC sequences, lone escapes, C0/C1 controls and bidi overrides.
159const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g
160const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
161const CONTROLS = /[\u0000-\u001f\u007f-\u009f--]/g
162
163/** Display-safe text from a server string: no escapes, no controls, one line, at most `max` characters. */
164export function sanitize(text: string, max: number): string {
165 return text.replace(CSI, '').replace(OSC, '').replace(CONTROLS, ' ').replace(/\s+/g, ' ').trim().slice(0, max)
166}
167
168/** Structural equality of plain JSON data. */
169export function sameData(a: unknown, b: unknown): boolean {
170 if (a === b) return true
171 if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false
172 if (Array.isArray(a) !== Array.isArray(b)) return false
173 if (Array.isArray(a)) {
174 const bb = b as unknown[]
175 return a.length === bb.length && a.every((v, i) => sameData(v, bb[i]))
176 }
177 const ao = a as Record<string, unknown>
178 const bo = b as Record<string, unknown>
179 const ak = Object.keys(ao)
180 const bk = Object.keys(bo)
181 return ak.length === bk.length && ak.every(k => Object.prototype.hasOwnProperty.call(bo, k) && sameData(ao[k], bo[k]))
182}
183
184// ── runs ──────────────────────────────────────────────────────────────────────
185
186export function newRun(args: {
187 id: string
188 ref: ClaudishRunRef
189 label: string
190 epoch: number
191 generation: number
192 now: number
193}): ClaudishRun {
194 return {
195 id: args.id,
196 epoch: args.epoch,
197 ref: args.ref,
198 label: args.label,
199 generation: args.generation,
200 slots: [],
201 activityAt: args.now,
202 missedPolls: 0,
203 missingSince: null,
204 unreachableSince: null,
205 unknownSince: {},
206 personStops: [],
207 waits: {},
208 lostReason: null,
209 settledAt: null,
210 }
211}
212
213/** What one tick observed about one run. */
214export type RunObservation =
215 | { kind: 'ok'; slots: readonly ClaudishSlot[] }
216 | { kind: 'missing' }
217 /** its feed spoke the contract but answered badly this tick */
218 | { kind: 'unreachable' }
219 /** its feed withdrew the contract */
220 | { kind: 'withdrawn' }
221
222function lostSlot(s: ClaudishSlot): ClaudishSlot {
223 return { ...s, state: 'LOST', idleSeconds: null, activity: null, asked: false }
224}
225
226/** LOST at run level: every non-terminal slot, or a synthetic `*` slot for a run never listed. */
227export function applyLost(run: ClaudishRun, reason: string): ClaudishRun {
228 const slots: ClaudishSlot[] = run.slots.length === 0
229 ? [{
230 slot: '*', model: run.label, provider: null, state: 'LOST', reason: null,
231 tokensIn: null, tokensOut: null, toolCalls: null, turnsCompleted: null,
232 idleSeconds: null, lastActivityAt: null, activity: null, asked: false,
233 }]
234 : run.slots.map(s => (isTerminal(s.state) ? s : lostSlot(s)))
235 return { ...run, slots, unknownSince: {}, lostReason: run.lostReason ?? reason }
236}
237
238function waitSig(s: ClaudishSlot): string {
239 return `${s.state}|${s.activity ?? ''}|${s.turnsCompleted ?? ''}|${s.toolCalls ?? ''}`
240}
241
242/** Wait entries for a delegation's slots: a new entry whenever a waiting slot's signature changes. */
243function nextWaits(prev: Readonly<Record<string, ClaudishWait>>, slots: readonly ClaudishSlot[]): Record<string, ClaudishWait> {
244 const waits: Record<string, ClaudishWait> = { ...prev }
245 for (const s of slots) {
246 const w = prev[s.slot] ?? { entries: 0, sig: null }
247 if (isWaiting(s.state)) {
248 const sig = waitSig(s)
249 waits[s.slot] = sig === w.sig ? w : { entries: w.entries + 1, sig }
250 } else if (w.sig !== null || prev[s.slot] !== undefined) {
251 waits[s.slot] = { entries: w.entries, sig: null }
252 }
253 }
254 return waits
255}
256
257function isActivity(prev: readonly ClaudishSlot[], next: readonly ClaudishSlot[]): boolean {
258 if (prev.length !== next.length) return true
259 return next.some(n => {
260 const p = prev.find(x => x.slot === n.slot)
261 if (!p) return true
262 return p.state !== n.state
263 || p.tokensIn !== n.tokensIn
264 || p.tokensOut !== n.tokensOut
265 || p.toolCalls !== n.toolCalls
266 || p.turnsCompleted !== n.turnsCompleted
267 || (n.lastActivityAt !== null && (p.lastActivityAt === null || n.lastActivityAt > p.lastActivityAt))
268 })
269}
270
271function settle(run: ClaudishRun, now: number): ClaudishRun {
272 if (run.settledAt !== null) return run
273 const done = run.slots.length > 0 && run.slots.every(s => isTerminal(s.state))
274 return done ? { ...run, settledAt: now } : run
275}
276
277function mergeOk(prev: ClaudishRun, observed: readonly ClaudishSlot[], now: number): ClaudishRun {
278 // LOST is terminal and final in the mod: a slot that reappears keeps it.
279 const seen = observed.map(s => {
280 const p = prev.slots.find(x => x.slot === s.slot)
281 return p?.state === 'LOST' ? p : s
282 })
283 // A slot the answer no longer lists keeps what it last showed, until the missing bound.
284 const kept = prev.slots.filter(p => !seen.some(s => s.slot === p.slot))
285 let slots = [...seen, ...kept]
286
287 // One clock per slot whose state cannot be read: an unknown state word, or a live slot
288 // its still-listed run stopped listing (else it would show running forever).
289 const unknownSince: Record<string, number> = {}
290 let lostReason = prev.lostReason
291 slots = slots.map(s => {
292 const missing = kept.includes(s) && !isTerminal(s.state)
293 if (s.state !== 'UNKNOWN' && !missing) return s
294 const since = prev.unknownSince[s.slot] ?? now
295 if (now - since >= (missing ? MISSING_LOST_MS : UNKNOWN_LOST_MS)) {
296 lostReason = lostReason ?? (missing ? 'slot missing' : 'unknown state')
297 return lostSlot(s)
298 }
299 unknownSince[s.slot] = since
300 return s
301 })
302
303 const run: ClaudishRun = {
304 ...prev,
305 slots,
306 unknownSince,
307 lostReason,
308 missedPolls: 0,
309 missingSince: null,
310 unreachableSince: null,
311 activityAt: isActivity(prev.slots, slots) ? now : prev.activityAt,
312 waits: prev.ref.kind === 'delegation' ? nextWaits(prev.waits, slots) : prev.waits,
313 }
314 return settle(run, now)
315}
316
317/**
318 * The next record of one run from what this tick observed. Returns `prev` itself when
319 * no persisted field changed, so an unchanged tick writes nothing.
320 */
321export function mergeRun(prev: ClaudishRun, observed: RunObservation, now: number): ClaudishRun {
322 if (prev.settledAt !== null) return prev
323 let next: ClaudishRun
324 switch (observed.kind) {
325 case 'ok':
326 next = mergeOk(prev, observed.slots, now)
327 break
328 case 'missing': {
329 const missedPolls = prev.missedPolls + 1
330 const missingSince = prev.missingSince ?? now
331 next = { ...prev, missedPolls, missingSince }
332 if (missedPolls >= MISSING_LOST_POLLS && now - missingSince >= MISSING_LOST_MS) {
333 next = settle(applyLost(next, prev.slots.length === 0 ? 'never listed' : 'vanished'), now)
334 }
335 break
336 }
337 case 'unreachable': {
338 const unreachableSince = prev.unreachableSince ?? now
339 next = { ...prev, unreachableSince }
340 if (now - unreachableSince >= UNREACHABLE_LOST_MS) next = settle(applyLost(next, 'unreachable'), now)
341 break
342 }
343 case 'withdrawn':
344 next = settle(applyLost(prev, 'contract withdrawn'), now)
345 break
346 default:
347 return assertNever(observed)
348 }
349 return sameData(prev, next) ? prev : next
350}
351
352export function assertNever(x: never): never {
353 throw new Error(`unexpected value: ${JSON.stringify(x)}`)
354}
355
356// ── wake units ────────────────────────────────────────────────────────────────
357
358/** held: the change is one claudish's session monitor reports too (wake.ts decides, per run) */
359export type WakeUnit = { key: string; state: ClaudishSlotState; held?: boolean }
360
361function waitEntryState(w: ClaudishWait, n: number): ClaudishSlotState {
362 const state = n === w.entries && w.sig !== null ? w.sig.slice(0, w.sig.indexOf('|')) : ''
363 return state === 'AWAITING_PERMISSION' ? 'AWAITING_PERMISSION' : 'AWAITING_INPUT'
364}
365
366/**
367 * Every wake unit the given (drawn) runs owe: the terminal key of each terminal slot, and
368 * the key of every wait entry 1..entries of each slot whatever it is doing now. The ledger
369 * decides which are new; this only lists them, so an entry a throwing tick never noticed is
370 * listed again on the next.
371 */
372export function wakeUnits(runs: readonly ClaudishRun[]): WakeUnit[] {
373 const out: WakeUnit[] = []
374 for (const run of runs) {
375 for (const [slot, w] of Object.entries(run.waits)) {
376 for (let n = 1; n <= w.entries; n++) out.push({ key: waitKey(run.id, slot, n), state: waitEntryState(w, n) })
377 }
378 for (const s of run.slots) if (isTerminal(s.state)) out.push({ key: slotKey(run.id, s.slot), state: s.state })
379 }
380 return out
381}
382
383/** The person's Stop provenance on one slot, set or removed; the same value when nothing changes. */
384export function withPersonStop(runs: ClaudishRuns, id: string, slot: string, on: boolean): ClaudishRuns {
385 let changed = false
386 const list = runs.list.map(r => {
387 if (r.id !== id || r.personStops.includes(slot) === on) return r
388 changed = true
389 return { ...r, personStops: on ? [...r.personStops, slot] : r.personStops.filter(s => s !== slot) }
390 })
391 return changed ? { ...runs, list } : runs
392}
393
394// ── stop requests ─────────────────────────────────────────────────────────────
395
396/**
397 * Drops each stop request whose run is gone or whose slot is terminal, and each `sent`
398 * one answered more than STOP_SENT_TIMEOUT_MS ago while its slot is still live (those are
399 * returned, so the caller can say so). A `sending` one is never timed out: its cancel has
400 * not answered, and Claude Code's own permission question may still be open.
401 */
402export function pruneStops(
403 stops: Readonly<Record<string, ClaudishStopRequest>>,
404 runs: ClaudishRuns,
405 now: number,
406): readonly [Record<string, ClaudishStopRequest>, string[]] {
407 const next: Record<string, ClaudishStopRequest> = {}
408 const timedOut: string[] = []
409 let changed = false
410 for (const [key, req] of Object.entries(stops)) {
411 const cut = key.indexOf('/')
412 const run = runs.list.find(r => r.id === key.slice(0, cut))
413 const slot = run?.slots.find(s => s.slot === key.slice(cut + 1))
414 if (!run || cut < 0 || (slot && isTerminal(slot.state))) {
415 changed = true
416 continue
417 }
418 if (req.kind === 'sent' && now - req.at > STOP_SENT_TIMEOUT_MS) {
419 timedOut.push(key)
420 changed = true
421 continue
422 }
423 next[key] = req
424 }
425 return [changed ? next : (stops as Record<string, ClaudishStopRequest>), timedOut] as const
426}
427
428// ── compaction ────────────────────────────────────────────────────────────────
429
430export type Compaction = { drop: ReadonlySet<string>; added: ClaudishEarlier }
431
432/**
433 * Past RUNS_CAP runs, the oldest settled runs whose ledger entries are all delivered
434 * fold into the `earlier` counters (`added` is what to add). A run with any undelivered
435 * entry is never compacted. `starts` is never touched, so a later start never reuses a
436 * generation already shown.
437 */
438export function compaction(runs: ClaudishRuns, ledger: ClaudishLedger): Compaction | null {
439 const excess = runs.list.length - RUNS_CAP
440 if (excess <= 0) return null
441 const drop = new Set<string>()
442 const added: ClaudishEarlier = { runs: 0, done: 0, failed: 0, stopped: 0 }
443 for (const run of runs.list) {
444 if (drop.size >= excess) break
445 if (run.settledAt === null) continue
446 const prefix = `${run.id}/`
447 const owed = Object.entries(ledger.entries).some(([k, e]) => k.startsWith(prefix) && e.kind !== 'delivered')
448 if (owed) continue
449 drop.add(run.id)
450 added.runs += 1
451 for (const s of run.slots) {
452 if (s.state === 'COMPLETED') added.done += 1
453 else if (s.state === 'CANCELLED') added.stopped += 1
454 else added.failed += 1
455 }
456 }
457 return drop.size === 0 ? null : { drop, added }
458}
459
460export function withoutRuns(runs: ClaudishRuns, drop: ReadonlySet<string>): ClaudishRuns {
461 return { ...runs, list: runs.list.filter(r => !drop.has(r.id)) }
462}
463
464export function withoutLedgerKeys(ledger: ClaudishLedger, drop: ReadonlySet<string>): ClaudishLedger {
465 const entries: ClaudishLedger['entries'] = {}
466 for (const [k, e] of Object.entries(ledger.entries)) {
467 if (!drop.has(k.slice(0, k.indexOf('/')))) entries[k] = e
468 }
469 return { ...ledger, entries }
470}
471hooks/status/layout.ts 536 lines1// Pure layout: the band's rows, header and column plan (fitted to the band's own width),
2// and a Show tab's viewport, colour spelling and style budget. Values in, values out:
3// no engine interface, no state reference, no claudish vocabulary. The tree builders
4// (band.tsx, pane.tsx) draw exactly what these return.
5
6import type { Color } from 'claude-code'
7import type {
8 ClaudishColor,
9 ClaudishEarlier,
10 ClaudishFeedSupport,
11 ClaudishFrame,
12 ClaudishLedger,
13 ClaudishPaneView,
14 ClaudishRun,
15 ClaudishRunRef,
16 ClaudishRuns,
17 ClaudishSlot,
18 ClaudishSlotState,
19 ClaudishStopRequest,
20 ClaudishStyleSpan,
21} from '../../types'
22import {
23 NO_CAPTURE_NOTE,
24 PAINT_SURFACES,
25 PANE_LINE_COST,
26 PANE_SEGMENT_COST,
27 PANE_STYLE_BUDGET,
28 QUIET_AFTER_S,
29 STATE_LOOK,
30 feedKey,
31 isDrawn,
32 isTerminal,
33 isWaiting,
34 runLabel,
35 slotKey,
36 type StatusColor,
37} from './domain'
38
39// ── shared ────────────────────────────────────────────────────────────────────
40
41/** One run of text in the mod's own drawing: theme keys only. */
42export type Segment = { text: string; color?: StatusColor; dim?: true; bold?: true }
43
44/** What a Stop or Show press acts on: values the render read. */
45export type SlotTarget = { runId: string; epoch: number; ref: ClaudishRunRef; slot: string; model: string }
46
47const DASH = '—'
48
49type Unit = readonly [div: number, suffix: string]
50
51/** The unit a count reads in on its own: plain below 1000, then k, then M. */
52function unitOf(n: number): Unit {
53 const a = Math.abs(n)
54 return a < 1000 ? [1, ''] : a < 999_950 ? [1000, 'k'] : [1_000_000, 'M']
55}
56
57function inUnit(n: number, [div, suffix]: Unit): string {
58 return div === 1 ? String(Math.round(n)) : `${(n / div).toFixed(1)}${suffix}`
59}
60
61/**
62 * The in/out token pair, both halves in ONE unit so the column reads as a pair:
63 * 12_000/400 → 12.0k/0.4k, never 12.0k/400. Plain integers only while BOTH are below
64 * 1000 (850/120); otherwise the larger half picks k or M and the smaller follows it,
65 * unless the shared unit would draw the smaller as 0.0: then it reads in its own unit
66 * (42_900/37 → 42.9k/37, 2_345_678/40_000 → 2.3M/40.0k), so a few real tokens never
67 * read as none. A null half draws —; both null draws — alone.
68 */
69export function tokenPair(tokensIn: number | null, tokensOut: number | null): string {
70 if (tokensIn === null && tokensOut === null) return DASH
71 const shared = unitOf(Math.max(Math.abs(tokensIn ?? 0), Math.abs(tokensOut ?? 0)))
72 const half = (n: number | null) => {
73 if (n === null) return DASH
74 const text = inUnit(n, shared)
75 return shared[0] > 1 && /^-?0\.0[kM]$/.test(text) ? inUnit(n, unitOf(n)) : text
76 }
77 return `${half(tokensIn)}/${half(tokensOut)}`
78}
79
80/** 4 → 4s, 125 → 2m, 7300 → 2h; null → —. */
81export function age(seconds: number | null): string {
82 if (seconds === null) return DASH
83 const s = Math.max(0, Math.floor(seconds))
84 if (s < 60) return `${s}s`
85 if (s < 3600) return `${Math.floor(s / 60)}m`
86 return `${Math.floor(s / 3600)}h`
87}
88
89/** Cut to `width` characters, ending in … when cut. */
90export function cut(text: string, width: number): string {
91 if (width <= 0) return ''
92 const chars = Array.from(text)
93 return chars.length <= width ? text : `${chars.slice(0, Math.max(0, width - 1)).join('')}…`
94}
95
96function pad(text: string, width: number): string {
97 const n = Array.from(text).length
98 return n >= width ? text : text + ' '.repeat(width - n)
99}
100
101/** The state a row draws: a live slot of an unreachable run is `? unknown`, never its stale state. */
102function shownState(run: ClaudishRun, slot: ClaudishSlot): ClaudishSlotState {
103 return run.unreachableSince !== null && !isTerminal(slot.state) ? 'UNKNOWN' : slot.state
104}
105
106/** The activity column: what a live slot does, or why a terminal one ended (not a repeat of its word). */
107function activityOf(run: ClaudishRun, slot: ClaudishSlot, state: ClaudishSlotState): string {
108 if (state === 'UNKNOWN' && run.unreachableSince !== null) return ''
109 if (!isTerminal(state)) return slot.activity ?? ''
110 if (state === 'LOST') return slot.reason ?? run.lostReason ?? ''
111 const reason = slot.reason ?? ''
112 return reason === 'cancelled' || reason === 'timeout' ? '' : reason
113}
114
115function idleOf(slot: ClaudishSlot, state: ClaudishSlotState): Segment {
116 if (isTerminal(state)) return { text: '' }
117 const quiet = state === 'RUNNING' && slot.idleSeconds !== null && slot.idleSeconds >= QUIET_AFTER_S
118 return quiet ? { text: `idle ${age(slot.idleSeconds)} ⚠`, color: 'warning' } : { text: `idle ${age(slot.idleSeconds)}`, color: 'subtle' }
119}
120
121export { isDrawn }
122
123// ── band ──────────────────────────────────────────────────────────────────────
124
125export type StopCell = 'stop' | 'confirm' | 'stopping' | 'none'
126
127export type BandRow = {
128 /** `${runId}/${slot}`, or `${runId}/` for a run not listed yet */
129 key: string
130 attention: boolean
131 id: string
132 glyph: string
133 word: string
134 color: StatusColor
135 /** the header word it counts under */
136 group: (typeof GROUPS)[number]
137 model: string
138 provider: string
139 tokens: string
140 tools: string
141 loops: string
142 idle: Segment
143 activity: string
144 stop: StopCell
145 show: boolean
146 terminal: boolean
147 /** when a terminal row ended (its last activity), for the most-recent-first order; null when unknown */
148 endedAt: number | null
149 target: SlotTarget
150}
151
152/** Cells per column; 0 = dropped. `state` is 10 (glyph and word) or 1 (glyph alone). */
153export type ColumnPlan = {
154 lead: number; id: number; state: number; model: number; provider: number; tokens: number
155 tools: number; loops: number; idle: number; activity: number; stop: number; show: number
156}
157
158export type BandModel = {
159 header: Segment[]
160 /** null: the band is too narrow for any row, so it draws the header alone */
161 plan: ColumnPlan | null
162 rows: BandRow[]
163 /** `+N more: …` when terminal rows were folded for height */
164 more: string | null
165}
166
167export type BandInput = {
168 runs: ClaudishRuns
169 feeds: Readonly<Record<string, ClaudishFeedSupport>>
170 stops: Readonly<Record<string, ClaudishStopRequest>>
171 ledger: ClaudishLedger
172 earlier: ClaudishEarlier
173 bodyColumns: number
174 maxRows: number
175}
176
177export const STOP_CELLS = 12 // `[ confirm? ]`
178export const SHOW_CELLS = 8 // `[ Show ]`
179const STATE_CELLS = 10
180const ID_MIN = 2
181const MODEL_MIN = 8
182const MODEL_MAX = 28
183const PROVIDER_MAX = 18
184const ACTIVITY_MIN = 12
185const LEAD_CELLS = 2
186
187const GROUPS = ['needs you', 'running', 'done', 'failed', 'stopped'] as const
188
189function rowsOf(input: BandInput, drawn: readonly ClaudishRun[]): BandRow[] {
190 const severalRuns = drawn.length > 1
191 const rows: BandRow[] = []
192 for (const run of drawn) {
193 const label = runLabel(run)
194 const support = input.feeds[feedKey(run.ref)]
195 const canCancel = support?.kind === 'speaks' && support.can.cancel
196 if (run.slots.length === 0) {
197 const look = STATE_LOOK.STARTING
198 rows.push({
199 key: `${run.id}/`, attention: false, id: label, glyph: look.glyph, word: look.word, color: look.color, group: look.group,
200 model: '', provider: '', tokens: '', tools: '', loops: '', idle: { text: '' }, activity: '',
201 stop: 'none', show: false, terminal: false, endedAt: null,
202 target: { runId: run.id, epoch: run.epoch, ref: run.ref, slot: '', model: '' },
203 })
204 continue
205 }
206 for (const slot of run.slots) {
207 const state = shownState(run, slot)
208 const look = STATE_LOOK[state]
209 const key = slotKey(run.id, slot.slot)
210 const req = input.stops[key]
211 const terminal = isTerminal(slot.state)
212 const stop: StopCell = terminal || !canCancel ? 'none'
213 : req?.kind === 'armed' ? 'confirm'
214 : req?.kind === 'sending' || req?.kind === 'sent' ? 'stopping'
215 : 'stop'
216 const id = run.ref.kind === 'delegation' ? label
217 : severalRuns ? `${label}/${slot.slot}`
218 : slot.slot
219 rows.push({
220 key,
221 attention: isWaiting(state),
222 id,
223 glyph: look.glyph,
224 word: look.word,
225 color: look.color,
226 group: look.group,
227 model: slot.model,
228 provider: slot.provider ?? '',
229 tokens: tokenPair(slot.tokensIn, slot.tokensOut),
230 tools: slot.toolCalls === null ? DASH : `${slot.toolCalls} tools`,
231 loops: slot.turnsCompleted === null ? DASH : `${slot.turnsCompleted} loops`,
232 idle: idleOf(slot, state),
233 activity: activityOf(run, slot, state),
234 stop,
235 show: slot.slot !== '*',
236 terminal,
237 endedAt: terminal ? slot.lastActivityAt : null,
238 target: { runId: run.id, epoch: run.epoch, ref: run.ref, slot: slot.slot, model: slot.model },
239 })
240 }
241 }
242 // Attention first, then live, then terminal rows, the most recently ended first (by run when unknown).
243 const order = (r: BandRow) => (r.attention ? 0 : r.terminal ? 2 : 1)
244 const runIndex = new Map(drawn.map((r, i) => [r.id, i]))
245 return rows
246 .map((r, i) => ({ r, i }))
247 .sort((a, b) => {
248 const oa = order(a.r)
249 const ob = order(b.r)
250 if (oa !== ob) return oa - ob
251 if (oa === 2) {
252 const ea = a.r.endedAt
253 const eb = b.r.endedAt
254 if (ea !== null && eb !== null && ea !== eb) return eb - ea
255 const ra = runIndex.get(a.r.target.runId) ?? 0
256 const rb = runIndex.get(b.r.target.runId) ?? 0
257 if (ra !== rb) return rb - ra
258 }
259 return a.i - b.i
260 })
261 .map(x => x.r)
262}
263
264function width(texts: readonly string[], max = Infinity): number {
265 return Math.min(max, texts.reduce((m, t) => Math.max(m, Array.from(t).length), 0))
266}
267
268function total(plan: ColumnPlan): number {
269 const cols = Object.values(plan).filter(w => w > 0)
270 return cols.reduce((s, w) => s + w, 0) + Math.max(0, cols.length - 1)
271}
272
273/**
274 * Drops columns in a fixed priority until the row fits `bodyColumns`: activity, tokens, tool
275 * calls, provider, loops; then the model shrinks to 8, idle goes, the id shrinks to 2, the
276 * model goes, and the state shrinks to its glyph. Below that floor, null: header alone.
277 */
278export function fitColumns(bodyColumns: number, rows: readonly BandRow[]): ColumnPlan | null {
279 const content = {
280 id: width(rows.map(r => r.id)),
281 model: width(rows.map(r => r.model), MODEL_MAX),
282 provider: width(rows.map(r => r.provider), PROVIDER_MAX),
283 tokens: width(rows.map(r => r.tokens)),
284 tools: width(rows.map(r => r.tools)),
285 loops: width(rows.map(r => r.loops)),
286 idle: width(rows.map(r => r.idle.text)),
287 activity: width(rows.map(r => r.activity)),
288 }
289 const plan: ColumnPlan = {
290 lead: LEAD_CELLS, id: content.id, state: STATE_CELLS, model: content.model, provider: content.provider,
291 tokens: content.tokens, tools: content.tools, loops: content.loops, idle: content.idle,
292 activity: Math.min(content.activity, ACTIVITY_MIN), stop: STOP_CELLS, show: SHOW_CELLS,
293 }
294 const steps: ((p: ColumnPlan) => void)[] = [
295 p => { p.activity = 0 },
296 p => { p.tokens = 0 },
297 p => { p.tools = 0 },
298 p => { p.provider = 0 },
299 p => { p.loops = 0 },
300 p => { p.model = Math.min(p.model, MODEL_MIN) },
301 p => { p.idle = 0 },
302 p => { p.id = Math.min(p.id, ID_MIN) },
303 p => { p.model = 0 },
304 p => { p.state = 1 },
305 ]
306 for (const step of [null, ...steps]) {
307 step?.(plan)
308 if (total(plan) <= bodyColumns) {
309 // The activity column takes what is left, up to its content.
310 if (plan.activity > 0) plan.activity = Math.min(content.activity, plan.activity + bodyColumns - total(plan))
311 return plan
312 }
313 }
314 return null
315}
316
317function headerOf(rows: readonly BandRow[], input: BandInput): Segment[] {
318 const counts = new Map<string, number>()
319 for (const r of rows) {
320 counts.set(r.group, (counts.get(r.group) ?? 0) + 1)
321 }
322 const out: Segment[] = [{ text: '◆ claudish', color: 'claude', bold: true }]
323 for (const g of GROUPS) {
324 const n = counts.get(g) ?? 0
325 if (n === 0) continue
326 out.push(g === 'needs you' ? { text: ` · ${n} needs you`, color: 'permission' } : { text: ` · ${n} ${g}`, dim: true })
327 }
328 const parked = Object.values(input.ledger.entries).filter(e => e.kind === 'parked').length
329 if (parked > 0) out.push({ text: ` · ${parked} ${parked === 1 ? 'notice' : 'notices'} not delivered`, color: 'error' })
330 if (input.earlier.runs > 0) out.push({ text: ` · +${input.earlier.runs} earlier`, dim: true })
331 return out
332}
333
334/**
335 * The band, or null when there is nothing to draw (no drawn run and nothing compacted).
336 * Rows fit `bodyColumns` (never wrapping); past `maxRows`, terminal rows fold into one line.
337 */
338export function bandModel(input: BandInput): BandModel | null {
339 const drawn = input.runs.list.filter(r => isDrawn(r, input.feeds))
340 if (drawn.length === 0 && input.earlier.runs === 0) return null
341 const all = rowsOf(input, drawn)
342 const header = headerOf(all, input)
343 const plan = fitColumns(input.bodyColumns, all)
344 if (plan === null) return { header, plan: null, rows: [], more: null }
345 if (1 + all.length <= input.maxRows) return { header, plan, rows: all, more: null }
346 const kept = all.filter(r => !r.terminal)
347 const folded = all.filter(r => r.terminal)
348 if (folded.length === 0) return { header, plan, rows: all, more: null }
349 const by = new Map<string, number>()
350 for (const r of folded) {
351 by.set(r.group, (by.get(r.group) ?? 0) + 1)
352 }
353 const parts = GROUPS.filter(g => by.has(g)).map(g => `${by.get(g)} ${g}`)
354 return { header, plan, rows: kept, more: `+${folded.length} more: ${parts.join(' · ')}` }
355}
356
357/** One row's cells as drawn text, each cut and padded to its column. */
358export function rowCells(row: BandRow, plan: ColumnPlan): {
359 lead: string; id: string; state: string; model: string; provider: string; tokens: string
360 tools: string; loops: string; idle: string; activity: string
361} {
362 const fit = (text: string, w: number, right = false) => {
363 if (w <= 0) return ''
364 const c = cut(text, w)
365 return right ? ' '.repeat(Math.max(0, w - Array.from(c).length)) + c : pad(c, w)
366 }
367 return {
368 lead: row.attention ? '▌ ' : ' ',
369 id: fit(row.id, plan.id),
370 state: plan.state >= STATE_CELLS ? fit(`${row.glyph} ${row.word}`, plan.state) : row.glyph,
371 model: fit(row.model, plan.model),
372 provider: fit(row.provider, plan.provider),
373 tokens: fit(row.tokens, plan.tokens, true),
374 tools: fit(row.tools, plan.tools, true),
375 loops: fit(row.loops, plan.loops, true),
376 idle: fit(row.idle.text, plan.idle),
377 activity: fit(row.activity, plan.activity),
378 }
379}
380
381/** The planned width of one row, gaps included. */
382export function planWidth(plan: ColumnPlan): number {
383 return total(plan)
384}
385
386// ── Show tab: fit, viewport, colours, budget ─────────────────────────────────
387
388export type PaneFit = { columns: number; rows: number; paint: boolean }
389
390const FALLBACK_COLUMNS = 80
391const FALLBACK_ROWS = 20
392
393function positive(v: unknown, fallback: number): number {
394 return typeof v === 'number' && Number.isFinite(v) && v >= 1 ? Math.floor(v) : fallback
395}
396
397/** The tab's room: its body width, and its height from the scroll window's rows (never a top-level field). */
398export function paneFit(props: { bodyColumns?: unknown; scroll?: { bodyRows?: unknown } | null }, surface: string): PaneFit {
399 return {
400 columns: positive(props.bodyColumns, FALLBACK_COLUMNS),
401 rows: positive(props.scroll?.bodyRows, FALLBACK_ROWS),
402 paint: PAINT_SURFACES.includes(surface),
403 }
404}
405
406/** A child's colour as the engine takes a raw colour: `#rrggbb`. */
407export function colorSpelling(c: ClaudishColor): Color {
408 const hex = (r: number, g: number, b: number) => `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
409 if ('rgb' in c) return `#${(c.rgb & 0xffffff).toString(16).padStart(6, '0')}`
410 const n = c.index
411 if (n >= 16 && n <= 231) {
412 const k = n - 16
413 const v = (x: number) => (x === 0 ? 0 : 55 + 40 * x)
414 return hex(v(Math.floor(k / 36)), v(Math.floor(k / 6) % 6), v(k % 6))
415 }
416 if (n >= 232 && n <= 255) {
417 const v = 8 + 10 * (n - 232)
418 return hex(v, v, v)
419 }
420 return `#${XTERM_16[Math.max(0, Math.min(15, n))]}`
421}
422
423const XTERM_16 = [
424 '000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5',
425 '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff',
426]
427
428/** One styled run of a frame line, its colours already spelled. */
429export type PaneSegment = { text: string; color?: Color; backgroundColor?: Color; bold?: true }
430/** One drawn frame line: plain strings and styled segments, in order. */
431export type PaneLine = (string | PaneSegment)[]
432
433// A wide or astral character makes a line's cells and characters disagree: draw it plain.
434const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]|[\ud800-\udbff]/
435
436function styledLine(chars: readonly string[], spans: readonly ClaudishStyleSpan[]): PaneLine {
437 const out: PaneLine = []
438 let pos = 0
439 for (const s of spans) {
440 const from = Math.max(s.col, pos)
441 const to = Math.min(s.col + s.len, chars.length)
442 if (from >= to) continue
443 if (from > pos) out.push(chars.slice(pos, from).join(''))
444 const seg: PaneSegment = { text: chars.slice(from, to).join('') }
445 if (s.fg !== null) seg.color = colorSpelling(s.fg)
446 if (s.bg !== null) seg.backgroundColor = colorSpelling(s.bg)
447 if (s.bold) seg.bold = true
448 out.push(seg)
449 pos = to
450 }
451 if (pos < chars.length) out.push(chars.slice(pos).join(''))
452 return out.length > 0 ? out : ['']
453}
454
455/** The estimate the style budget is held to: per line, its cost, its text, and each styled segment. */
456export function paneCost(lines: readonly PaneLine[]): number {
457 let cost = 0
458 for (const line of lines) {
459 cost += PANE_LINE_COST
460 for (const part of line) cost += typeof part === 'string' ? part.length : part.text.length + PANE_SEGMENT_COST
461 }
462 return cost
463}
464
465export type PaneFrameLines = { lines: PaneLine[]; painted: boolean }
466
467/**
468 * The drawn frame: trailing blank lines trimmed, the LAST `rows - 1` lines kept (one row is
469 * the header; Claude Code's prompt and newest output are at the bottom), each cut to
470 * `columns + 1` cells so the engine still truncates with …; the child's colours painted
471 * when the frame carries them, the surface paints, and the estimate fits the budget.
472 */
473export function paneLines(frame: ClaudishFrame | null, fit: PaneFit): PaneFrameLines {
474 if (!frame) return { lines: [], painted: false }
475 let last = frame.lines.length - 1
476 while (last >= 0 && (frame.lines[last] ?? '').trim() === '') last -= 1
477 const keep = Math.max(0, fit.rows - 1)
478 const first = Math.max(0, last + 1 - keep)
479 const limit = fit.columns + 1
480 const picked: { chars: string[]; spans: readonly ClaudishStyleSpan[] }[] = []
481 for (let i = first; i <= last; i++) {
482 const chars = Array.from(frame.lines[i] ?? '').slice(0, limit)
483 picked.push({ chars, spans: frame.styles?.[i] ?? [] })
484 }
485 const plain = (): PaneLine[] => picked.map(p => [p.chars.join('')])
486 if (!fit.paint || !frame.styles) return { lines: plain(), painted: false }
487 const styled = picked.map(p => (p.spans.length === 0 || WIDE.test(p.chars.join('')) ? [p.chars.join('')] : styledLine(p.chars, p.spans)))
488 if (paneCost(styled) > PANE_STYLE_BUDGET) return { lines: plain(), painted: false }
489 return { lines: styled, painted: styled.some(l => l.some(part => typeof part !== 'string')) }
490}
491
492// ── Show tab: what it draws ───────────────────────────────────────────────────
493
494export type PaneBody =
495 | { kind: 'waiting' }
496 | { kind: 'unavailable'; note: string; numbers: string }
497 | { kind: 'frame'; lines: PaneLine[] }
498 | { kind: 'note'; note: string }
499
500export type PaneModel = { header: Segment[]; body: PaneBody }
501
502/** The tab's header (the row's own styling, theme keys only) and its body. */
503export function paneModel(view: ClaudishPaneView, runs: ClaudishRuns, fit: PaneFit): PaneModel {
504 const run = runs.list.find(r => r.id === view.runId)
505 const slot = run?.slots.find(s => s.slot === view.slot)
506 const header: Segment[] = []
507 if (run && slot) {
508 const state = shownState(run, slot)
509 const look = STATE_LOOK[state]
510 header.push({ text: `${look.glyph} ${look.word}`, color: look.color }, { text: ` ${view.model}` })
511 if (slot.provider) header.push({ text: ` · ${slot.provider}`, dim: true })
512 const idle = idleOf(slot, state)
513 if (idle.text) header.push({ text: ` · ${idle.text}`, ...(idle.color ? { color: idle.color } : {}) })
514 const activity = activityOf(run, slot, state)
515 if (activity) header.push({ text: ` · ${activity}`, dim: true })
516 } else {
517 header.push({ text: view.model })
518 }
519 if (view.status === 'ended') header.push({ text: ' · ended', dim: true })
520
521 const numbers = slot
522 ? [slot.tokensIn === null && slot.tokensOut === null ? DASH : `${tokenPair(slot.tokensIn, slot.tokensOut)} tokens`,
523 slot.toolCalls === null ? DASH : `${slot.toolCalls} tools`,
524 slot.turnsCompleted === null ? DASH : `${slot.turnsCompleted} loops`].join(' · ')
525 : ''
526
527 let body: PaneBody
528 if (view.status === 'waiting') body = { kind: 'waiting' }
529 else if (view.status === 'unavailable') {
530 const note = view.note === null || view.note === NO_CAPTURE_NOTE ? NO_CAPTURE_NOTE : `Live screen not available right now: ${view.note}`
531 body = { kind: 'unavailable', note, numbers }
532 } else if (view.frame) body = { kind: 'frame', lines: paneLines(view.frame, fit).lines }
533 else body = { kind: 'note', note: view.note ?? '' }
534 return { header, body }
535}
536hooks/status/poll.ts 420 lines1// The tool.call observer and the poll loop: handshake, cadence, backoff, feed support,
2// the run merge, the hand-off to the wake, stop-request pruning, compaction and captures.
3// Takes a Host only.
4// Names no claudish tool or field: the adapter behind host.source speaks claudish.
5
6import type { Frozen, Next, SessionEndInput, ToolCallInput, ToolCallResult, UiPane } from 'claude-code'
7import type { ClaudishFeedSupport, ClaudishPaneView, ClaudishRun, ClaudishRuns } from '../../types'
8import {
9 FINAL_GRACE_MS,
10 NO_CAPTURE_NOTE,
11 NO_SCREEN_NOTE,
12 PANE_ID_PATTERN,
13 compaction,
14 feedKey,
15 isTerminal,
16 mergeRun,
17 newRun,
18 pruneStops,
19 runId,
20 sameData,
21 withoutLedgerKeys,
22 withoutRuns,
23 type RunObservation,
24} from './domain'
25import type { Host } from './host'
26import type { FeedAnswer, PollBatch } from './run-source'
27import * as wake from './wake'
28
29// ── cadence ───────────────────────────────────────────────────────────────────
30
31export const ACTIVE_MS = 1_000
32export const QUIET_MS = 3_000
33/** No activity change for this long and no shown live tab: the quiet cadence. */
34export const QUIET_AFTER_MS = 30_000
35export const BACKOFF_MAX_MS = 30_000
36/** A feed that never spoke goes silent after this many bad answers. */
37export const PROBE_STRIKES = 3
38/** A speaking feed is withdrawn after this many declarations with no `speaks` between. */
39export const WITHDRAW_DECLARATIONS = 3
40/** A tick that threw is retried, with back-off, this many times in a row before the loop rests. */
41export const TICK_RETRIES = 8
42/** A hidden tab is captured at most this often; an unavailable one, at most this often. */
43export const HIDDEN_CAPTURE_MS = 5_000
44export const UNAVAILABLE_RETRY_MS = 10_000
45
46// ── module state: dies with a hot reload, which session.start restarts ───────
47
48type Phase = 'idle' | 'scheduled' | 'running'
49const loop: { phase: Phase; rerun: boolean; failures: number } = { phase: 'idle', rerun: false, failures: 0 }
50/** pane id → last capture attempt, whatever its answer. Not drawn, so not in state. */
51const lastAttemptAt = new Map<string, number>()
52/** pane id → the first tick that found its slot terminal. */
53const terminalSeenAt = new Map<string, number>()
54/** tick failure messages already logged once. */
55const loggedFailures = new Set<string>()
56
57// ── session boundaries ────────────────────────────────────────────────────────
58
59/** session.end with clear or resume ends the conversation: a new epoch forgets its runs. */
60export async function onSessionEnd(
61 host: Host,
62 e: Frozen<SessionEndInput>,
63 next: Next<'session.end'>,
64): Promise<{ sessionId: string }> {
65 if (e.reason === 'clear' || e.reason === 'resume') {
66 await host.store.runs.update(v => ({ epoch: v.epoch + 1, starts: {}, list: [] }))
67 await host.store.ledger.update(l => ({ epoch: l.epoch + 1, entries: {} }))
68 await host.store.stopRequests.update(() => ({}))
69 await host.store.earlier.update(() => ({ runs: 0, done: 0, failed: 0, stopped: 0 }))
70 await host.store.turn.update(() => ({ isRunning: false, since: 0 }))
71 }
72 return next(e)
73}
74
75// ── the observer ──────────────────────────────────────────────────────────────
76
77const RESERVED = new Set(['tool', 'tool_use_id', 'agentId'])
78
79function argsOf(e: Frozen<ToolCallInput>): Record<string, unknown> {
80 const args: Record<string, unknown> = {}
81 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) args[k] = v
82 return args
83}
84
85/** tool.call on a claudish tool: run it unchanged, then watch a start it answered. */
86export async function onClaudishCall(
87 host: Host,
88 e: Frozen<ToolCallInput>,
89 next: Next<'tool.call'>,
90): Promise<ToolCallResult> {
91 const epoch = (await host.store.runs.read()).epoch
92 const ran = await next(e)
93 try {
94 if (ran.deny !== undefined) return ran
95 const seen = host.source.recognizeCall({
96 tool: String(e.tool),
97 args: argsOf(e),
98 answer: { text: typeof ran.text === 'string' ? ran.text : null, isError: ran.isError === true },
99 })
100 switch (seen.kind) {
101 case 'unidentified':
102 host.debug(`claudish start not watched: ${seen.reason}`)
103 break
104 case 'precontract': {
105 const key = feedKey({ server: seen.server, kind: seen.runKind })
106 const now = await host.now()
107 const [, moved] = await host.store.feeds.claim(f => {
108 const was = f[key]
109 if (was !== undefined && was.kind !== 'probing') return [f, false] as const
110 const support: ClaudishFeedSupport = { kind: 'unsupported', reason: seen.reason, at: now, spoke: false }
111 return [{ ...f, [key]: support }, true] as const
112 })
113 if (moved) host.debug(`claudish ${key}: pre-contract server, not watched (${seen.reason})`)
114 break
115 }
116 case 'start': {
117 const id = runId(seen.ref)
118 const now = await host.now()
119 // A start answer in the contract is the server speaking it now: a feed given up on earlier
120 // (failed probes, a withdrawal, a server since restarted or upgraded) is probed again.
121 const key = feedKey(seen.ref)
122 const [, reopened] = await host.store.feeds.claim(f => {
123 if (f[key]?.kind !== 'unsupported') return [f, false] as const
124 return [{ ...f, [key]: { kind: 'probing', strikes: 0 } }, true] as const
125 })
126 if (reopened) host.debug(`claudish ${key}: a contract start answer, watched again`)
127 await host.store.runs.update(v => {
128 if (v.epoch !== epoch || v.list.some(r => r.id === id)) return v
129 const before = v.starts[seen.ref.address] ?? 0
130 const run = newRun({ id, ref: seen.ref, label: seen.label, epoch, generation: before + 1, now })
131 return { ...v, starts: { ...v.starts, [seen.ref.address]: before + 1 }, list: [...v.list, run] }
132 })
133 ensureLoop(host)
134 break
135 }
136 case 'other':
137 break
138 }
139 } catch (err) {
140 host.debug(`claudish observer failed: ${err instanceof Error ? err.message : String(err)}`)
141 }
142 return ran
143}
144
145// ── feed support policy ───────────────────────────────────────────────────────
146
147type SupportOutcome = { withdrawn: Set<string>; logs: string[] }
148
149/** The core's policy over the adapter's facts, per feed key (architecture §3.4). */
150export function applySupport(
151 feeds: Readonly<Record<string, ClaudishFeedSupport>>,
152 answers: ReadonlyMap<string, FeedAnswer>,
153 now: number,
154): readonly [Record<string, ClaudishFeedSupport>, SupportOutcome] {
155 const next: Record<string, ClaudishFeedSupport> = { ...feeds }
156 const out: SupportOutcome = { withdrawn: new Set(), logs: [] }
157 for (const [key, answer] of answers) {
158 const was: ClaudishFeedSupport = feeds[key] ?? { kind: 'probing', strikes: 0 }
159 if (was.kind === 'unsupported') continue
160 const reason = 'reason' in answer ? answer.reason : 'code' in answer ? `refused: ${answer.code}` : ''
161 if (answer.kind === 'speaks') {
162 next[key] = { kind: 'speaks', version: answer.version, can: answer.can, declined: 0 }
163 continue
164 }
165 const declares = answer.kind === 'precontract' || answer.kind === 'declines'
166 if (was.kind === 'probing') {
167 const strikes = was.strikes + 1
168 if (declares || strikes >= PROBE_STRIKES) {
169 next[key] = { kind: 'unsupported', reason, at: now, spoke: false }
170 out.logs.push(`claudish ${key}: not watched (${answer.kind}: ${reason})`)
171 } else {
172 next[key] = { kind: 'probing', strikes }
173 }
174 continue
175 }
176 // was speaks: a bad answer is a transmission fact; only repeated declarations withdraw it.
177 if (!declares) continue
178 const declined = was.declined + 1
179 if (declined >= WITHDRAW_DECLARATIONS) {
180 next[key] = { kind: 'unsupported', reason: `contract withdrawn (${reason})`, at: now, spoke: true }
181 out.withdrawn.add(key)
182 out.logs.push(`claudish ${key}: contract withdrawn (${reason}); its live slots end LOST`)
183 } else {
184 next[key] = { ...was, declined }
185 }
186 }
187 return [sameData(feeds, next) ? (feeds as Record<string, ClaudishFeedSupport>) : next, out] as const
188}
189
190/** Every run of this tick's merge, from the batch and the feeds as just updated. */
191export function mergeAll(
192 runs: ClaudishRuns,
193 batch: PollBatch,
194 feeds: Readonly<Record<string, ClaudishFeedSupport>>,
195 withdrawn: ReadonlySet<string>,
196 now: number,
197): ClaudishRuns {
198 let changed = false
199 const list = runs.list.map(run => {
200 if (run.settledAt !== null) return run
201 const key = feedKey(run.ref)
202 let observed: RunObservation | null = null
203 if (withdrawn.has(key)) observed = { kind: 'withdrawn' }
204 else if (batch.feeds.has(key) && feeds[key]?.kind === 'speaks') {
205 const result = batch.runs.get(run.id)
206 // A speaking feed answers every run it was asked about: no result means the run started
207 // after this tick's poll went out, so it is not observed until the next tick.
208 if (batch.feeds.get(key)?.kind !== 'speaks') observed = { kind: 'unreachable' }
209 else if (result) observed = result.kind === 'ok' ? { kind: 'ok', slots: result.slots } : { kind: 'missing' }
210 }
211 if (!observed) return run
212 const merged = mergeRun(run, observed, now)
213 if (merged !== run) changed = true
214 return merged
215 })
216 return changed ? { ...runs, list } : runs
217}
218
219// ── the loop ──────────────────────────────────────────────────────────────────
220
221/** From session.start, the observer's start branch, and Show. */
222export function ensureLoop(host: Host): void {
223 switch (loop.phase) {
224 case 'idle':
225 loop.phase = 'scheduled'
226 host.after(0, () => void tick(host))
227 return
228 case 'scheduled':
229 return
230 case 'running':
231 loop.rerun = true
232 return
233 }
234}
235
236function isWatched(run: ClaudishRun, feeds: Readonly<Record<string, ClaudishFeedSupport>>): boolean {
237 return run.settledAt === null && feeds[feedKey(run.ref)]?.kind !== 'unsupported'
238}
239
240function delay(watched: readonly ClaudishRun[], shownLive: boolean, now: number): number {
241 if (loop.failures > 0) return Math.min(ACTIVE_MS * 2 ** loop.failures, BACKOFF_MAX_MS)
242 if (loop.rerun) return ACTIVE_MS // something new arrived while this tick ran (a start, a Show)
243 const lastActivity = watched.reduce((m, r) => Math.max(m, r.activityAt), -Infinity)
244 return shownLive || now - lastActivity < QUIET_AFTER_MS ? ACTIVE_MS : QUIET_MS
245}
246
247const EMPTY_BATCH: PollBatch = { feeds: new Map(), runs: new Map() }
248const BAD_ANSWERS: ReadonlySet<FeedAnswer['kind']> = new Set(['refused', 'unanswered', 'garbled'])
249
250/** One tick: poll, support, merge, prune, compact, capture; then decide the next tick. */
251export async function tick(host: Host): Promise<void> {
252 loop.phase = 'running'
253 loop.rerun = false
254 let open: UiPane[] = []
255 let runsNow: ClaudishRuns | null = null
256 let feedsNow: Record<string, ClaudishFeedSupport> = {}
257 let now = 0
258 let tabs: TabsOutcome = { shownLive: false, capturing: 0 }
259 let threw = false
260 try {
261 now = await host.now()
262 runsNow = await host.store.runs.read()
263 const epoch = runsNow.epoch
264 feedsNow = await host.store.feeds.read()
265 const feeds0 = feedsNow
266 const watched = runsNow.list.filter(r => isWatched(r, feeds0))
267 const batch = watched.length > 0 ? await host.source.poll(watched.map(r => ({ id: r.id, ref: r.ref }))) : EMPTY_BATCH
268
269 const at = now
270 const [feedsWritten, support] = await host.store.feeds.claim(f => applySupport(f, batch.feeds, at))
271 feedsNow = feedsWritten
272 for (const line of support.logs) host.debug(line)
273
274 const before = runsNow
275 const feedsAfter = feedsNow
276 if (mergeAll(runsNow, batch, feedsAfter, support.withdrawn, now) !== runsNow) {
277 runsNow = await host.store.runs.update(v => (v.epoch !== epoch ? v : mergeAll(v, batch, feedsAfter, support.withdrawn, at)))
278 }
279 if (runsNow.epoch !== epoch) return // a /clear or resume landed mid-tick: drop this tick's work
280 for (const run of runsNow.list) {
281 const was = before.list.find(r => r.id === run.id)
282 if (run.unreachableSince !== null && was && was.unreachableSince === null) {
283 host.debug(`claudish run ${run.label}: ${feedKey(run.ref)} is not answering; its rows show unknown`)
284 }
285 }
286 // This tick's terminal transitions and wait entries: the ledger keeps each one owed until delivered.
287 await wake.notice(host, epoch, runsNow, feedsNow)
288
289 const settledRuns = runsNow
290 const [, timedOut] = await host.store.stopRequests.claim(m => pruneStops(m, settledRuns, at))
291 for (const key of timedOut) host.toast(`Stop not confirmed for ${key.slice(key.indexOf('/') + 1)}; press Stop again`)
292
293 runsNow = await compact(host, runsNow, epoch)
294
295 open = (await host.panes()).filter(p => PANE_ID_PATTERN.test(p.id))
296 tabs = await captures(host, open, runsNow, feedsNow, now)
297
298 const polled = [...batch.feeds.values()]
299 loop.failures = polled.length > 0 && polled.every(a => BAD_ANSWERS.has(a.kind)) ? loop.failures + 1 : 0
300 } catch (err) {
301 const message = err instanceof Error ? err.message : String(err)
302 if (!loggedFailures.has(message)) {
303 loggedFailures.add(message)
304 host.debug(`claudish poll tick failed: ${message}`)
305 }
306 loop.failures += 1
307 threw = true
308 } finally {
309 // Every value combined here was awaited above; nothing is awaited between here and the decision.
310 const watchedNow = runsNow ? runsNow.list.filter(r => isWatched(r, feedsNow)) : []
311 const keepGoing = loop.rerun
312 || runsNow === null
313 || watchedNow.length > 0
314 || tabs.capturing > 0 // an open tab whose run can still produce frames
315 || (threw && loop.failures <= TICK_RETRIES) // a throw can land after the last merge, before its wake is owed
316 if (!keepGoing) {
317 loop.phase = 'idle'
318 } else {
319 loop.phase = 'scheduled'
320 host.after(delay(watchedNow, tabs.shownLive, now), () => void tick(host))
321 }
322 }
323}
324
325async function compact(host: Host, runs: ClaudishRuns, epoch: number): Promise<ClaudishRuns> {
326 const plan = compaction(runs, await host.store.ledger.read())
327 if (!plan) return runs
328 const written = await host.store.runs.update(v => (v.epoch !== epoch ? v : withoutRuns(v, plan.drop)))
329 if (written.epoch !== epoch) return written
330 const d = plan.added
331 await host.store.earlier.update(e => ({ runs: e.runs + d.runs, done: e.done + d.done, failed: e.failed + d.failed, stopped: e.stopped + d.stopped }))
332 await host.store.ledger.update(l => (l.epoch !== epoch ? l : withoutLedgerKeys(l, plan.drop)))
333 return written
334}
335
336
337// ── captures: open Show tabs only ─────────────────────────────────────────────
338
339type TabsOutcome = { shownLive: boolean; capturing: number }
340
341/**
342 * Captures every open tab that is due (architecture §3.4, "Captures"). Returns whether
343 * a shown tab is live, and how many open tabs can still produce frames: those keep the
344 * loop alive; an ended tab, or one whose feed offers no capture, costs nothing.
345 */
346async function captures(
347 host: Host,
348 open: readonly UiPane[],
349 runs: ClaudishRuns,
350 feeds: Readonly<Record<string, ClaudishFeedSupport>>,
351 now: number,
352): Promise<TabsOutcome> {
353 const out: TabsOutcome = { shownLive: false, capturing: 0 }
354 // A tab closed before its run ended leaves its bookkeeping behind: drop it with the tab.
355 const openIds = new Set(open.map(p => p.id))
356 for (const id of [...lastAttemptAt.keys()]) if (!openIds.has(id)) lastAttemptAt.delete(id)
357 for (const id of [...terminalSeenAt.keys()]) if (!openIds.has(id)) terminalSeenAt.delete(id)
358 const end = (v: ClaudishPaneView | null): ClaudishPaneView | null =>
359 v ? { ...v, status: 'ended', note: v.frame ? null : NO_SCREEN_NOTE } : v
360 for (const p of open) {
361 const view = await host.store.panes.read(p.id)
362 if (!view || view.status === 'ended') {
363 lastAttemptAt.delete(p.id)
364 terminalSeenAt.delete(p.id)
365 continue
366 }
367 const run = runs.list.find(r => r.id === view.runId)
368 if (!run) {
369 await host.store.panes.update(p.id, end)
370 continue
371 }
372 const support = feeds[feedKey(run.ref)]
373 if (support?.kind !== 'speaks' || !support.can.capture) {
374 if (view.status !== 'unavailable' || view.note !== NO_CAPTURE_NOTE) {
375 await host.store.panes.update(p.id, v => (v ? { ...v, status: 'unavailable', note: NO_CAPTURE_NOTE } : v))
376 }
377 continue
378 }
379 out.capturing += 1
380 if (p.isShown) out.shownLive = true
381 const slot = run.slots.find(s => s.slot === view.slot)
382 if (slot && isTerminal(slot.state) && !terminalSeenAt.has(p.id)) terminalSeenAt.set(p.id, now)
383 const seenAt = terminalSeenAt.get(p.id)
384 const graceOver = seenAt !== undefined && now - seenAt >= FINAL_GRACE_MS
385 const last = lastAttemptAt.get(p.id)
386 const due = last === undefined
387 || (view.status === 'unavailable' ? now - last >= UNAVAILABLE_RETRY_MS : p.isShown || now - last >= HIDDEN_CAPTURE_MS)
388 if (!due) continue
389 lastAttemptAt.set(p.id, now)
390 const r = await host.source.capture(run.ref, view.slot, view.frame?.seq ?? 0, support.can.spans)
391 let ended = false
392 switch (r.kind) {
393 case 'frame':
394 ended = r.final
395 await host.store.panes.update(p.id, v => (v ? { ...v, status: r.final ? 'ended' : 'live', frame: r.frame, note: null } : v))
396 break
397 case 'unchanged':
398 if (r.final || graceOver) {
399 ended = true
400 await host.store.panes.update(p.id, end)
401 }
402 break
403 case 'gone':
404 ended = true
405 await host.store.panes.update(p.id, end)
406 break
407 case 'unavailable':
408 ended = graceOver
409 await host.store.panes.update(p.id, v => (graceOver ? end(v) : v ? { ...v, status: 'unavailable', note: r.reason } : v))
410 break
411 }
412 if (ended) {
413 out.capturing -= 1
414 lastAttemptAt.delete(p.id)
415 terminalSeenAt.delete(p.id)
416 }
417 }
418 return out
419}
420hooks/status/wake.ts 623 lines1// The completion wake-up: every terminal transition of a drawn run this conversation
2// started, and every entry of one of its delegations into a waiting state, is named in
3// exactly one prompt the engine accepted. The ledger (one atom, through host.store) holds
4// delivery: pending → inflight → delivered, or back to pending / parked on a refusal.
5//
6// claudish's own session monitor reports some of the same changes, and its line is a turn of
7// its own. Where it reports, it is the primary: such a change is held for MONITOR_GRACE_MS,
8// and a matching line in a row of the conversation marks it delivered without a prompt.
9// Otherwise the prompt goes as before. A line arriving after the prompt cannot be taken back.
10//
11// Takes a Host only. Names no claudish tool or field: the run line and the fetch line of
12// the text come from the port's runLine and fetchHint, monitor lines from monitorReports.
13
14import type {
15 Frozen,
16 Next,
17 SessionAppendInput,
18 Timer,
19 TurnCompleteInput,
20 TurnCompleteResult,
21 TurnStartInput,
22 TurnStartResult,
23} from 'claude-code'
24import type {
25 ClaudishFeedSupport,
26 ClaudishLedger,
27 ClaudishRun,
28 ClaudishRuns,
29 ClaudishSlotState,
30 ClaudishWakeEntry,
31} from '../../types'
32import {
33 MONITOR_GRACE_MS,
34 PARKED_RETRY_MS,
35 STATE_LOOK,
36 WAKE_HOLD_GUARD_MS,
37 WAKE_QUICK_ATTEMPTS,
38 WAKE_RETRY_MS,
39 fnv1a32,
40 hex8,
41 isDrawn,
42 isTerminal,
43 isWaiting,
44 parseWakeKey,
45 runLabel,
46 wakeUnits,
47 type WakeUnit,
48} from './domain'
49import type { Host, TranscriptRow } from './host'
50import type { FetchSlot, MonitorReport, RunSource } from './run-source'
51
52// ── module state: dies with a hot reload, which session.start rebuilds ───────
53
54/** The one wake timer: cancelled and replaced when an earlier time is asked. */
55let timer: { at: number; t: Timer } | null = null
56let flushSeq = 0
57/** Monitor reports seen in the last MONITOR_GRACE_MS, for changes noticed after their line. */
58let sightings: Sighting[] = []
59const SIGHTINGS_MAX = 200
60
61// ── pure ledger moves ─────────────────────────────────────────────────────────
62
63export type Claimed = { key: string; state: ClaudishSlotState; wasParked: boolean }
64
65/** Adds a pending entry for each unit with none; the created units are the claim. */
66export function createPending(
67 l: ClaudishLedger,
68 epoch: number,
69 units: readonly WakeUnit[],
70 now: number,
71): readonly [ClaudishLedger, WakeUnit[]] {
72 if (l.epoch !== epoch) return [l, []] as const
73 const created = units.filter(u => !(u.key in l.entries))
74 if (created.length === 0) return [l, []] as const
75 const entries = { ...l.entries }
76 for (const u of created) {
77 entries[u.key] = u.held
78 ? { kind: 'pending', state: u.state, at: now, attempts: 0, retryAt: null, heldUntil: now + MONITOR_GRACE_MS }
79 : { kind: 'pending', state: u.state, at: now, attempts: 0, retryAt: null }
80 }
81 return [{ ...l, entries }, created] as const
82}
83
84/** A held entry waits for the monitor's line until its hold ends, at a turn's end too. */
85function isDue(e: ClaudishWakeEntry, now: number, atTurnEnd: boolean): boolean {
86 if (e.kind !== 'pending' && e.kind !== 'parked') return false
87 if (e.kind === 'pending' && e.heldUntil !== undefined && e.heldUntil > now) return false
88 return atTurnEnd || e.retryAt === null || e.retryAt <= now
89}
90
91/** Moves every due pending or parked entry to inflight under `nonce`; they are the claim. While any entry
92 * of a run is still held, the run's due entries wait with it: the monitor's line, if it comes, stands
93 * for them all, and if it does not, one prompt names the run at the hold's end. Taking a held entry
94 * early is what made the monitor's line a second notice for the same change. */
95export function claimDue(
96 l: ClaudishLedger,
97 now: number,
98 atTurnEnd: boolean,
99 nonce: string,
100): readonly [ClaudishLedger, Claimed[]] {
101 const claimed: Claimed[] = []
102 const entries = { ...l.entries }
103 const heldRuns = runHolds(l, now)
104 for (const [key, e] of Object.entries(l.entries)) {
105 if (!isDue(e, now, atTurnEnd) || e.kind === 'delivered' || e.kind === 'inflight' || heldRuns.has(parseWakeKey(key).runId)) continue
106 entries[key] = { kind: 'inflight', state: e.state, at: e.at, attempts: e.attempts, nonce, since: now }
107 claimed.push({ key, state: e.state, wasParked: e.kind === 'parked' })
108 }
109 return claimed.length === 0 ? [l, claimed] as const : [{ ...l, entries }, claimed] as const
110}
111
112/**
113 * The main loop's turn ended: every held entry is held MONITOR_GRACE_MS from now. While a turn
114 * runs, Claude Code queues the monitor's line and hands it over only after the turn, so a hold
115 * that ran out during the turn gave the line no chance (measured: the mod prompted 6 ms after
116 * the turn ended, and the queued line then started a second turn).
117 */
118export function reholdAtTurnEnd(l: ClaudishLedger, now: number): ClaudishLedger {
119 let changed = false
120 const entries = { ...l.entries }
121 for (const [key, e] of Object.entries(l.entries)) {
122 if (e.kind !== 'pending' || e.heldUntil === undefined || e.heldUntil >= now + MONITOR_GRACE_MS) continue
123 entries[key] = { ...e, heldUntil: now + MONITOR_GRACE_MS }
124 changed = true
125 }
126 return changed ? { ...l, entries } : l
127}
128
129/** The submit carrying `nonce` was accepted: its entries are delivered. */
130export function markDelivered(l: ClaudishLedger, nonce: string, now: number): ClaudishLedger {
131 let changed = false
132 const entries = { ...l.entries }
133 for (const [key, e] of Object.entries(l.entries)) {
134 if (e.kind !== 'inflight' || e.nonce !== nonce) continue
135 entries[key] = { kind: 'delivered', at: now, nonce }
136 changed = true
137 }
138 return changed ? { ...l, entries } : l
139}
140
141/** The submit carrying `nonce` was refused: back to pending with a back-off, or parked after three attempts. */
142export function markRefused(l: ClaudishLedger, nonce: string, now: number, reason: string): ClaudishLedger {
143 let changed = false
144 const entries = { ...l.entries }
145 for (const [key, e] of Object.entries(l.entries)) {
146 if (e.kind !== 'inflight' || e.nonce !== nonce) continue
147 const attempts = e.attempts + 1
148 entries[key] = attempts < WAKE_QUICK_ATTEMPTS
149 ? { kind: 'pending', state: e.state, at: e.at, attempts, retryAt: now + WAKE_RETRY_MS * attempts }
150 : { kind: 'parked', state: e.state, at: e.at, attempts, reason, retryAt: now + PARKED_RETRY_MS }
151 changed = true
152 }
153 return changed ? { ...l, entries } : l
154}
155
156// ── the monitor's lines ───────────────────────────────────────────────────────
157
158/** One monitor report and when its row was seen. */
159export type Sighting = { at: number; report: MonitorReport }
160
161/** Does a monitor report stand for this wake key of this run? A delegation's end stands for its
162 * waits too (the monitor reports no wait still open at the end); a panel run's end, matched by the
163 * record id its start answer named, stands for every key of the run once the mod has seen it settle. */
164export function reports(run: ClaudishRun, isWait: boolean, r: MonitorReport): boolean {
165 if (r.kind === 'delegation') {
166 return run.ref.kind === 'delegation' && r.token === run.ref.token && (r.event === 'ended' || isWait)
167 }
168 if (isWait || !monitorCovers(run)) return false
169 return run.ref.kind === 'panel' && r.record === run.ref.monitor
170}
171
172/** Would claudish's monitor report this unit's change? Every unit of a delegation; a panel run's once
173 * it settled, when its start answer named the record the monitor reports it by. */
174export function monitorCovers(run: ClaudishRun): boolean {
175 if (run.ref.kind === 'delegation') return true
176 return run.settledAt !== null && run.ref.monitor !== undefined
177}
178
179/**
180 * Marks delivered (by the monitor) every pending or parked entry a sighting stands for: one
181 * seen after the change was noticed, or at most MONITOR_GRACE_MS before. The marked keys are the claim.
182 */
183export function monitorDeliver(
184 l: ClaudishLedger,
185 runs: ClaudishRuns,
186 seen: readonly Sighting[],
187 now: number,
188): readonly [ClaudishLedger, string[]] {
189 if (l.epoch !== runs.epoch || seen.length === 0) return [l, []] as const
190 const moved: string[] = []
191 const entries = { ...l.entries }
192 for (const [key, e] of Object.entries(l.entries)) {
193 if (e.kind !== 'pending' && e.kind !== 'parked') continue
194 const k = parseWakeKey(key)
195 const run = runs.list.find(r => r.id === k.runId)
196 if (!run || !seen.some(s => s.at >= e.at - MONITOR_GRACE_MS && reports(run, k.wait !== null, s.report))) continue
197 entries[key] = { kind: 'delivered', at: now, nonce: 'monitor', by: 'monitor' }
198 moved.push(key)
199 }
200 return moved.length === 0 ? [l, moved] as const : [{ ...l, entries }, moved] as const
201}
202
203/** The text of a row the monitor's line could arrive in: not the model's, not a tool's, not typed by the person. */
204export function notificationText(e: Frozen<SessionAppendInput>): string | null {
205 if (e.message.type === 'assistant' || e.door === 'response' || e.door === 'tool-result' || e.door === 'tool-message') return null
206 const kind = e.origin.kind
207 if (kind === 'model' || kind === 'tool' || kind === 'composer' || kind === 'bridge') return null
208 const blocks = Array.isArray(e.message.content) ? e.message.content : []
209 const texts: string[] = []
210 for (const b of blocks) {
211 const block = b as { type?: unknown; text?: unknown }
212 if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
213 }
214 return texts.length === 0 ? null : texts.join('\n')
215}
216
217function sightingsAt(now: number): Sighting[] {
218 sightings = sightings.filter(s => s.at >= now - MONITOR_GRACE_MS).slice(-SIGHTINGS_MAX)
219 return sightings
220}
221
222/**
223 * session.append, observe-only: a row of the conversation carrying claudish's monitor lines
224 * stands the wake down for the changes they report. Synchronous up to the parse; never throws.
225 */
226export function observeRow(host: Host, e: Frozen<SessionAppendInput>): void {
227 let found: MonitorReport[]
228 try {
229 const text = notificationText(e)
230 found = text === null ? [] : host.source.monitorReports(text)
231 } catch {
232 return
233 }
234 if (found.length === 0) return
235 standDown(host, found).catch((err: unknown) => {
236 try {
237 host.debug(`claudish wake: monitor line not applied: ${err instanceof Error ? err.message : String(err)}`)
238 } catch {
239 // the environment that saw the row is gone (a reload)
240 }
241 })
242}
243
244async function standDown(host: Host, found: readonly MonitorReport[]): Promise<void> {
245 const now = await host.now()
246 sightings = [...sightingsAt(now), ...found.map(report => ({ at: now, report }))].slice(-SIGHTINGS_MAX)
247 const seen = sightings
248 const runs = await host.store.runs.read()
249 const [after, moved] = await host.store.ledger.claim(l => monitorDeliver(l, runs, seen, now))
250 for (const key of moved) host.debug(`claudish wake: ${key} delivered by claudish's session monitor`)
251 armTimer(host, earliestRetryAt(after, now), now)
252}
253
254/**
255 * After a reload, each inflight entry is settled from the transcript: a user row holding
256 * its nonce means the prompt entered (delivered); otherwise it is owed again (pending,
257 * attempts kept). `stranded`: the nonces the reload caught inflight. An entry claimed
258 * since, by this load's own flush, is that flush's to settle and is left alone.
259 */
260export function reconcileInflight(
261 l: ClaudishLedger,
262 rows: readonly TranscriptRow[],
263 stranded?: ReadonlySet<string>,
264): ClaudishLedger {
265 let changed = false
266 const entries = { ...l.entries }
267 for (const [key, e] of Object.entries(l.entries)) {
268 if (e.kind !== 'inflight' || (stranded !== undefined && !stranded.has(e.nonce))) continue
269 const seen = rows.some(r => r.role === 'user' && typeof r.text === 'string' && r.text.includes(`(ref ${e.nonce})`))
270 entries[key] = seen
271 ? { kind: 'delivered', at: e.since, nonce: e.nonce }
272 : { kind: 'pending', state: e.state, at: e.at, attempts: e.attempts, retryAt: null }
273 changed = true
274 }
275 return changed ? { ...l, entries } : l
276}
277
278/** The latest hold still running per run: claimDue holds the run's due entries until then. */
279function runHolds(l: ClaudishLedger, now: number): Map<string, number> {
280 const holds = new Map<string, number>()
281 for (const [key, e] of Object.entries(l.entries)) {
282 if (e.kind !== 'pending' || e.heldUntil === undefined || e.heldUntil <= now) continue
283 const runId = parseWakeKey(key).runId
284 holds.set(runId, Math.max(holds.get(runId) ?? 0, e.heldUntil))
285 }
286 return holds
287}
288
289/** The earliest time a pending or parked entry can be claimed, or null when none is owed. */
290export function earliestRetryAt(l: ClaudishLedger, now: number): number | null {
291 const holds = runHolds(l, now)
292 let at: number | null = null
293 for (const [key, e] of Object.entries(l.entries)) {
294 if (e.kind !== 'pending' && e.kind !== 'parked') continue
295 const held = holds.get(parseWakeKey(key).runId) ?? -Infinity
296 const due = Math.max(e.retryAt ?? now, held)
297 at = at === null ? due : Math.min(at, due)
298 }
299 return at
300}
301
302// ── wording ───────────────────────────────────────────────────────────────────
303
304type Words = Pick<RunSource, 'runLine' | 'fetchHint'>
305
306/** How a slot is named for the person and the model: its slot id, or a delegation's label. */
307function slotName(run: ClaudishRun, slot: string): string {
308 return run.ref.kind === 'delegation' ? runLabel(run) : slot
309}
310
311function waitWord(state: ClaudishSlotState): string {
312 return state === 'AWAITING_PERMISSION' ? 'permission' : 'input'
313}
314
315/** The toast for one new unit: `03 grok-4.6 done ✓`, `#a1b2c3 haiku-4.5 waiting for input ◇`. */
316export function toastText(runs: readonly ClaudishRun[], unit: WakeUnit): { text: string; timeoutMs: number } {
317 const k = parseWakeKey(unit.key)
318 const run = runs.find(r => r.id === k.runId)
319 const slot = run?.slots.find(s => s.slot === k.slot)
320 const name = run ? (k.slot === '*' ? runLabel(run) : `${slotName(run, k.slot)} ${slot?.model ?? ''}`.trim()) : k.slot
321 if (k.wait !== null) return { text: `${name} waiting for ${waitWord(unit.state)} ◇`, timeoutMs: 4_000 }
322 const look = STATE_LOOK[unit.state]
323 return { text: `${name} ${look.word} ${look.glyph}`, timeoutMs: look.group === 'failed' ? 6_000 : 4_000 }
324}
325
326/** Short names of claimed keys for a toast or the status line: `03 grok-4.6`, `#a1b2c3 haiku-4.5`. */
327function keyLabels(runs: readonly ClaudishRun[], keys: readonly string[]): string {
328 const names: string[] = []
329 for (const key of keys) {
330 const k = parseWakeKey(key)
331 const run = runs.find(r => r.id === k.runId)
332 const model = run?.slots.find(s => s.slot === k.slot)?.model
333 const name = run ? (k.slot === '*' ? runLabel(run) : `${slotName(run, k.slot)}${model ? ` ${model}` : ''}`) : key
334 if (!names.includes(name)) names.push(name)
335 }
336 return names.join(', ')
337}
338
339/** What one terminal state reads as in the text, with the reason claudish gave (closed set only). */
340function terminalPhrase(run: ClaudishRun, slot: string, state: ClaudishSlotState, reason: string | null): string {
341 switch (state) {
342 case 'CANCELLED':
343 return run.personStops.includes(slot) ? 'CANCELLED (stopped by the person)' : 'CANCELLED'
344 case 'TIMEOUT':
345 return 'TIMEOUT'
346 case 'FAILED':
347 case 'EMPTY':
348 return reason ? `${state} (${reason})` : state
349 case 'LOST':
350 return 'no longer reported by claudish, so check its status'
351 default:
352 return state
353 }
354}
355
356function waitingPhrase(state: ClaudishSlotState, asked: boolean): string {
357 if (state === 'AWAITING_PERMISSION') return 'is waiting on a permission dialog'
358 return asked ? 'is asking a question and waiting for an answer' : 'finished its turn and is waiting for input'
359}
360
361type SlotClaim = { slot: string; terminal: ClaudishSlotState | null; wait: { n: number; state: ClaudishSlotState } | null }
362
363function fetchLine(hint: string, states: readonly ClaudishSlotState[]): string {
364 if (/^[A-Z]/.test(hint)) return `${hint}.`
365 return states.every(isTerminal) ? `Fetch the result now: ${hint}.` : `Next: ${hint}.`
366}
367
368/**
369 * The prompt naming every claimed key (pure). Only sanctioned identifiers reach it: the run
370 * line and fetch line from the port, slot ids, sanitised model ids, states, closed-set
371 * reasons, fixed phrases and the nonce. Never activity, provider or screen text.
372 */
373export function composeWake(
374 runs: readonly ClaudishRun[],
375 claimed: readonly { key: string; state: ClaudishSlotState }[],
376 nonce: string,
377 words: Words,
378): string {
379 const byRun = new Map<string, Map<string, SlotClaim>>()
380 const orphans: { key: string; state: ClaudishSlotState }[] = []
381 for (const c of claimed) {
382 const k = parseWakeKey(c.key)
383 if (!runs.some(r => r.id === k.runId)) {
384 orphans.push(c)
385 continue
386 }
387 const slots = byRun.get(k.runId) ?? new Map<string, SlotClaim>()
388 byRun.set(k.runId, slots)
389 const sc = slots.get(k.slot) ?? { slot: k.slot, terminal: null, wait: null }
390 if (k.wait === null) sc.terminal = c.state
391 else if (sc.wait === null || k.wait > sc.wait.n) sc.wait = { n: k.wait, state: c.state }
392 slots.set(k.slot, sc)
393 }
394
395 let finished = 0
396 let waiting = 0
397 const blocks: string[] = []
398 for (const run of runs) {
399 const slots = byRun.get(run.id)
400 if (!slots) continue
401 const lines: string[] = [words.runLine(run.ref, runLabel(run))]
402 const fetch: FetchSlot[] = []
403 for (const sc of slots.values()) {
404 const now = run.slots.find(s => s.slot === sc.slot)
405 const current = now?.state ?? sc.terminal ?? sc.wait?.state ?? 'UNKNOWN'
406 const asked = now?.asked ?? false
407 let phrase: string
408 if (sc.slot === '*') {
409 lines.push(` - run ${runLabel(run)} never appeared in claudish's list, so check its status`)
410 finished += 1
411 fetch.push({ slot: sc.slot, state: 'LOST', asked: false })
412 continue
413 }
414 if (sc.terminal !== null) {
415 finished += 1
416 phrase = terminalPhrase(run, sc.slot, sc.terminal, now?.reason ?? null)
417 if (sc.wait !== null) {
418 const had = `it had been waiting for ${waitWord(sc.wait.state)}`
419 phrase = phrase.endsWith(')') ? `${phrase.slice(0, -1)}; ${had})` : `${phrase} (${had})`
420 }
421 fetch.push({ slot: sc.slot, state: sc.terminal, asked: false })
422 } else {
423 const wait = sc.wait ?? { n: 0, state: 'AWAITING_INPUT' as ClaudishSlotState }
424 waiting += 1
425 phrase = isWaiting(current) ? waitingPhrase(current, asked) : `waited for ${waitWord(wait.state)}; now ${current}`
426 fetch.push({ slot: sc.slot, state: current, asked })
427 }
428 const model = now?.model ?? 'unknown model'
429 lines.push(run.ref.kind === 'delegation' ? ` - ${model}: ${phrase}` : ` - slot ${sc.slot} ${model}: ${phrase}`)
430 }
431 const running = run.ref.kind === 'panel'
432 ? run.slots.filter(s => !isTerminal(s.state) && !slots.has(s.slot)).map(s => `${s.slot} ${s.model}`)
433 : []
434 if (running.length > 0) lines.push(` still running: ${running.join(', ')}`)
435 lines.push(fetchLine(words.fetchHint(run.ref, fetch), fetch.map(f => f.state)))
436 if (running.length > 0) lines.push('Another message like this arrives as each running slot finishes.')
437 else if (run.ref.kind === 'delegation' && fetch.some(f => !isTerminal(f.state))) {
438 lines.push('Another message like this arrives each time it waits again, and when it ends.')
439 }
440 blocks.push(lines.join('\n'))
441 }
442 if (orphans.length > 0) {
443 finished += orphans.length
444 blocks.push(['Runs no longer listed here:', ...orphans.map(o => ` - ${o.key}: ${o.state}`)].join('\n'))
445 }
446
447 const heads: string[] = []
448 if (finished > 0) heads.push(`${finished} ${finished === 1 ? 'slot' : 'slots'} finished`)
449 if (waiting > 0) heads.push(waiting === 1 ? 'a delegated session is waiting for you' : `${waiting} delegated sessions are waiting for you`)
450 const head = heads.join('; ')
451 return [`${head.charAt(0).toUpperCase()}${head.slice(1)}. (ref ${nonce})`, ...blocks].join('\n')
452}
453
454// ── the timer ─────────────────────────────────────────────────────────────────
455
456function cancelTimer(): void {
457 timer?.t.cancel()
458 timer = null
459}
460
461/** Keeps one timer armed for the earliest owed time; none when nothing is owed. */
462function armTimer(host: Host, at: number | null, now: number): void {
463 if (at === null) {
464 cancelTimer()
465 return
466 }
467 if (timer !== null && timer.at <= at) return
468 cancelTimer()
469 const armed = { at, t: host.after(Math.max(0, at - now), () => {
470 if (timer === armed) timer = null
471 flushDetached(host)
472 }) }
473 timer = armed
474}
475
476/** A flush nobody awaits (a timer, a turn's end, a load): a failure is one debug line, never an unhandled rejection. */
477export function flushDetached(host: Host, opts: { atTurnEnd?: boolean } = {}): void {
478 flush(host, opts).catch((err: unknown) => {
479 try {
480 host.debug(`claudish wake: flush failed: ${err instanceof Error ? err.message : String(err)}`)
481 } catch {
482 // the environment that armed it is gone (a reload); the next load's flush owns the ledger
483 }
484 })
485}
486
487// ── session boundaries and turns ──────────────────────────────────────────────
488
489/** session.start: the volatile state a reload strands is reset (a stop request never outlives
490 * the timer that would expire it; a turn flag never holds notices for a turn this load won't see end). */
491export async function resetVolatile(host: Host): Promise<void> {
492 cancelTimer()
493 await host.store.stopRequests.update(m => (Object.keys(m).length === 0 ? m : {}))
494 await host.store.turn.update(t => (t.isRunning ? { isRunning: false, since: 0 } : t))
495}
496
497/** session.start, after the reset: entries a reload caught inflight are settled from the transcript. */
498export async function recover(host: Host): Promise<void> {
499 const l = await host.store.ledger.read()
500 const stranded = new Set(Object.values(l.entries).flatMap(e => (e.kind === 'inflight' ? [e.nonce] : [])))
501 if (stranded.size === 0) return
502 let rows: readonly TranscriptRow[] = []
503 try {
504 rows = await host.messages()
505 } catch (err) {
506 host.debug(`claudish wake: transcript unreadable, re-sending in-flight notices (${err instanceof Error ? err.message : String(err)})`)
507 }
508 await host.store.ledger.update(v => (v.epoch !== l.epoch ? v : reconcileInflight(v, Array.isArray(rows) ? rows : [], stranded)))
509}
510
511/** turn.start is raised by the main loop alone (a subagent's run raises none). */
512export async function onTurnStart(
513 host: Host,
514 e: Frozen<TurnStartInput>,
515 next: Next<'turn.start'>,
516): Promise<TurnStartResult> {
517 const now = await host.now()
518 await host.store.turn.update(() => ({ isRunning: true, since: now }))
519 return next(e)
520}
521
522/** The main loop's turn ended: notices it held, and parked ones, are delivered now. */
523export async function onTurnComplete(
524 host: Host,
525 e: Frozen<TurnCompleteInput>,
526 next: Next<'turn.complete'>,
527): Promise<TurnCompleteResult> {
528 const result = await next(e)
529 if (e.agentId === undefined) {
530 await host.store.turn.update(t => (t.isRunning ? { isRunning: false, since: 0 } : t))
531 const now = await host.now()
532 await host.store.ledger.update(l => reholdAtTurnEnd(l, now))
533 flushDetached(host, { atTurnEnd: true })
534 }
535 return result
536}
537
538// ── notice and flush ──────────────────────────────────────────────────────────
539
540/**
541 * From the poll tick: creates a pending entry for each owed unit of a drawn run that has
542 * none (held when claudish's monitor reports that change too, and delivered at once when its
543 * line was already seen), toasts each one created, and flushes. Writes nothing when every
544 * unit has an entry.
545 */
546export async function notice(
547 host: Host,
548 epoch: number,
549 runs: ClaudishRuns,
550 feeds: Readonly<Record<string, ClaudishFeedSupport>>,
551): Promise<void> {
552 const drawn = runs.list.filter(r => isDrawn(r, feeds))
553 const units = wakeUnits(drawn).map(u => {
554 const run = drawn.find(r => r.id === parseWakeKey(u.key).runId)
555 return run && monitorCovers(run) ? { ...u, held: true } : u
556 })
557 if (units.length === 0) return
558 const before = await host.store.ledger.read()
559 if (before.epoch !== epoch || units.every(u => u.key in before.entries)) return
560 const now = await host.now()
561 const seen = sightingsAt(now)
562 const [, made] = await host.store.ledger.claim(l => {
563 const [withNew, created] = createPending(l, epoch, units, now)
564 if (created.length === 0) return [withNew, { created, moved: [] as string[] }] as const
565 const [written, moved] = monitorDeliver(withNew, runs, seen, now)
566 return [written, { created, moved }] as const
567 })
568 for (const u of made.created) {
569 const t = toastText(runs.list, u)
570 host.toast(t.text, t.timeoutMs)
571 }
572 for (const key of made.moved) host.debug(`claudish wake: ${key} delivered by claudish's session monitor`)
573 if (made.created.length > 0) await flush(host)
574}
575
576/**
577 * Delivers every due entry in one prompt, unless the main loop's turn runs (then at its
578 * end). Called from notice, the main loop's turn.complete (atTurnEnd), session.start and
579 * the wake timer: the complete trigger set. After it, nothing is owed or the timer is armed.
580 */
581export async function flush(host: Host, { atTurnEnd = false }: { atTurnEnd?: boolean } = {}): Promise<void> {
582 const now = await host.now()
583 const turn = await host.store.turn.read()
584 if (turn.isRunning && now - turn.since < WAKE_HOLD_GUARD_MS) {
585 armTimer(host, turn.since + WAKE_HOLD_GUARD_MS, now)
586 return
587 }
588 const before = await host.store.ledger.read()
589 if (!Object.values(before.entries).some(e => isDue(e, now, atTurnEnd))) {
590 armTimer(host, earliestRetryAt(before, now), now)
591 return
592 }
593 flushSeq += 1
594 const nonce = `w${hex8(fnv1a32(`${now}|${flushSeq}`))}` // all 32 bits: matched as `(ref …)` in the transcript
595 const [ledger, claimed] = await host.store.ledger.claim(l => claimDue(l, now, atTurnEnd, nonce))
596 if (claimed.length === 0) {
597 armTimer(host, earliestRetryAt(ledger, now), now)
598 return
599 }
600 const runsNow = await host.store.runs.read()
601 const text = composeWake(runsNow.list, claimed, nonce, host.source)
602 const epochNow = (await host.store.ledger.read()).epoch
603 if (epochNow !== ledger.epoch) return // a /clear or resume landed: those entries are gone
604 const res = await host.submit(text) // no await between the epoch read and this call
605 let after: ClaudishLedger
606 if (res.accepted) {
607 after = await host.store.ledger.update(l => markDelivered(l, nonce, now))
608 const parkedLeft = Object.values(after.entries).some(e => e.kind === 'parked')
609 if (!parkedLeft && claimed.some(c => c.wasParked)) host.status(undefined)
610 } else {
611 after = await host.store.ledger.update(l => markRefused(l, nonce, now, res.reason))
612 host.toast(`Result notice for ${keyLabels(runsNow.list, claimed.map(c => c.key))} not delivered (${res.reason}); retrying`, 6_000)
613 const parked = Object.entries(after.entries).filter(([, e]) => e.kind === 'parked').map(([k]) => k)
614 if (parked.length > 0) {
615 host.status(`${parked.length} result ${parked.length === 1 ? 'notice' : 'notices'} waiting to be delivered: ${keyLabels(runsNow.list, parked)}`)
616 for (const c of claimed) {
617 if (!c.wasParked && parked.includes(c.key)) host.debug(`claudish wake: notice ${c.key} parked after ${WAKE_QUICK_ATTEMPTS} refusals (${res.reason})`)
618 }
619 }
620 }
621 armTimer(host, earliestRetryAt(after, now), now)
622}
623hooks/status/controls.ts 130 lines1// What the band's two buttons do. State goes through the Store it is handed (bound to
2// the render dispatch whose tree held the button); effects through the Host. No engine
3// interface spelled here, and no claudish tool named: Stop reaches claudish through the
4// port's `stop`.
5
6import type { ClaudishPaneView } from '../../types'
7import { PANE_ID_PATTERN, SHOW_COLUMNS, STOP_CONFIRM_MS, assertNever, paneId, slotKey, withPersonStop } from './domain'
8import type { Host, Store } from './host'
9import type { SlotTarget } from './layout'
10import type { StopResult } from './run-source'
11
12type StopMove = 'arm' | 'send' | 'none'
13
14/**
15 * Slot keys whose cancel call has not answered yet. A cancel waits on Claude Code's own
16 * permission dialog first, for as long as the person takes to read it; the poll carries on
17 * meanwhile (its read-only calls are allowed before the permission check, so none queues
18 * behind that dialog). Module state: it dies with a reload, as does the closure awaiting
19 * the call.
20 */
21const cancelling = new Set<string>()
22
23function without<T>(m: Readonly<Record<string, T>>, key: string): Record<string, T> {
24 if (!(key in m)) return m as Record<string, T>
25 const next = { ...m }
26 delete next[key]
27 return next
28}
29
30/**
31 * Stop needs two presses within STOP_CONFIRM_MS, and cancels only that slot. ONE update
32 * decides which press this is and claims the move it makes, so two presses in one tick,
33 * or a press racing the expiry timer, can never both send.
34 */
35export async function pressStop(press: Store, host: Host, target: SlotTarget): Promise<void> {
36 const key = slotKey(target.runId, target.slot)
37 const now = await host.now()
38 const until = now + STOP_CONFIRM_MS
39 const [, move] = await press.stopRequests.claim<StopMove>(m => {
40 const r = m[key]
41 if (r?.kind === 'armed' && now < r.until) return [{ ...m, [key]: { kind: 'sending', at: now } }, 'send']
42 if (r?.kind === 'sending' || r?.kind === 'sent') return [m, 'none']
43 return [{ ...m, [key]: { kind: 'armed', until } }, 'arm'] // the first press, or an expired arm
44 })
45 switch (move) {
46 case 'none':
47 return
48 case 'arm':
49 // A timer: the session-bound Store, since this press's dispatch is long gone by then.
50 host.after(STOP_CONFIRM_MS, () => {
51 void host.store.stopRequests.update(m => {
52 const r = m[key]
53 return r?.kind === 'armed' && r.until === until ? without(m, key) : m
54 })
55 })
56 return
57 case 'send': {
58 // However long the first cancel's dialog stays open, a slot never gets a second one: the
59 // claim above already refuses while the request is `sending`; this holds even when the
60 // atom was reset under a pending call (a /clear, a resume).
61 if (cancelling.has(key)) return
62 cancelling.add(key)
63 try {
64 await sendStop(press, host, target, key)
65 } finally {
66 cancelling.delete(key)
67 }
68 return
69 }
70 default:
71 assertNever(move)
72 }
73}
74
75/** The one cancel of a double press, and its answer: a failure of any kind toasts once and gives Stop back. */
76async function sendStop(press: Store, host: Host, target: SlotTarget, key: string): Promise<void> {
77 let res: StopResult
78 try {
79 // Provenance is written BEFORE the cancel is sent, so no poll can see CANCELLED without it.
80 await press.runs.update(v => (v.epoch !== target.epoch ? v : withPersonStop(v, target.runId, target.slot, true)))
81 res = await host.source.stop(target.ref, target.slot)
82 } catch (err) {
83 res = { kind: 'failed', reason: err instanceof Error ? err.message : String(err) }
84 }
85 // The answer can come long after the press (Claude Code asks its own question first), so
86 // what follows writes through the session-bound Store, as a timer does.
87 const after = host.store
88 if (res.kind === 'failed') {
89 try {
90 await after.runs.update(v => withPersonStop(v, target.runId, target.slot, false)) // roll provenance back
91 await after.stopRequests.update(m => without(m, key))
92 } finally {
93 host.toast(`Stop failed for ${target.slot} ${target.model}: ${res.reason}`)
94 }
95 return
96 }
97 // The confirmation timeout starts now that the cancel answered, never while its dialog was open.
98 const answeredAt = await host.now()
99 await after.stopRequests.update(m => (m[key]?.kind === 'sending' ? { ...m, [key]: { kind: 'sent', at: answeredAt } } : m))
100 if (!res.changed) {
101 // already terminal: this press stopped nothing, so the wake never credits the person
102 await after.runs.update(v => withPersonStop(v, target.runId, target.slot, false))
103 }
104}
105
106function seed(target: SlotTarget): () => ClaudishPaneView {
107 return () => ({ runId: target.runId, slot: target.slot, model: target.model, status: 'waiting', frame: null, note: null })
108}
109
110/**
111 * Show opens the slot's tab (one per slot, its id stable across reloads), titled by the
112 * model, or `model (slot)` when another open tab already shows that model. A pane not
113 * open is seeded `waiting` whatever its member held. If opening an open id did not make it
114 * the shown tab, it is closed and opened again, re-seeded, so the press always shows it.
115 */
116export async function pressShow(press: Store, host: Host, target: SlotTarget): Promise<void> {
117 const id = paneId(target.runId, target.slot)
118 const open = (await host.panes()).filter(p => PANE_ID_PATTERN.test(p.id))
119 const taken = open.some(p => p.id !== id && (p.title === target.model || p.title.startsWith(`${target.model} (`)))
120 const title = taken ? `${target.model} (${target.slot})` : target.model
121 if (!open.some(p => p.id === id)) await press.panes.update(id, seed(target))
122 await host.open(id, title, SHOW_COLUMNS)
123 const now = (await host.panes()).find(p => p.id === id)
124 if (now && !now.isShown) {
125 await host.close(id)
126 await press.panes.update(id, seed(target))
127 await host.open(id, title, SHOW_COLUMNS)
128 }
129}
130hooks/status/band.tsx 70 lines1// The band above the prompt: a pure tree builder. Values and closures in, a tree out;
2// it reads no state, writes none, and calls only the actions it was given, from onPress.
3// Every Text truncates (never wraps), every colour is a theme key.
4
5import type { Elements, RenderElement } from 'claude-code'
6import { STOP_CELLS, SHOW_CELLS, rowCells, type BandModel, type BandRow, type Segment, type SlotTarget } from './layout'
7
8/** The elements the band and the tab draw with: in every surface's table. */
9export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
10
11export type BandActions = {
12 stop(target: SlotTarget): void
13 show(target: SlotTarget): void
14}
15
16/** Segments as one outer Text: one truncation for the whole line, a nested Text per coloured run. */
17export function segmentsText(els: Els, segments: readonly Segment[]): RenderElement {
18 const { Text } = els
19 return (
20 <Text wrap="truncate-end">
21 {segments.map((s, i) => (
22 <Text key={`seg:${i}`} wrap="truncate-end" {...(s.color ? { color: s.color } : {})} {...(s.dim ? { dimColor: true } : {})} {...(s.bold ? { bold: true } : {})}>
23 {s.text}
24 </Text>
25 ))}
26 </Text>
27 ) as RenderElement
28}
29
30function row(els: Els, r: BandRow, model: BandModel, actions: BandActions): RenderElement {
31 const { Box, Text, Button } = els
32 const plan = model.plan!
33 const c = rowCells(r, plan)
34 const t = (text: string, props: Record<string, unknown> = {}) => <Text wrap="truncate-end" {...props}>{text}</Text>
35 const stop = r.stop === 'stop' ? <Button key={`stop:${r.key}`} label="Stop" onPress={() => actions.stop(r.target)} />
36 : r.stop === 'confirm' ? <Button key={`stop:${r.key}`} label="confirm?" variant="primary" onPress={() => actions.stop(r.target)} />
37 : r.stop === 'stopping' ? t('stopping…', { dimColor: true })
38 : null
39 return (
40 <Box key={`row:${r.key}`} flexDirection="row" gap={1}>
41 {t(c.lead, r.attention ? { color: 'permission' } : {})}
42 {plan.id > 0 && t(c.id)}
43 {t(c.state, { color: r.color })}
44 {plan.model > 0 && t(c.model)}
45 {plan.provider > 0 && t(c.provider, { dimColor: true })}
46 {plan.tokens > 0 && t(c.tokens, { dimColor: true })}
47 {plan.tools > 0 && t(c.tools, { dimColor: true })}
48 {plan.loops > 0 && t(c.loops, { dimColor: true })}
49 {plan.idle > 0 && t(c.idle, r.idle.color ? { color: r.idle.color } : {})}
50 {plan.activity > 0 && t(c.activity, { dimColor: true })}
51 <Box width={STOP_CELLS} flexShrink={0}>{stop}</Box>
52 <Box width={SHOW_CELLS} flexShrink={0}>
53 {r.show && <Button key={`show:${r.key}`} label="Show" onPress={() => actions.show(r.target)} />}
54 </Box>
55 </Box>
56 ) as RenderElement
57}
58
59/** The band: the header, one row per slot fitted to the band's width, and a fold line. */
60export function bandTree(els: Els, model: BandModel, actions: BandActions): RenderElement {
61 const { Box, Text } = els
62 return (
63 <Box flexDirection="column">
64 {segmentsText(els, model.header)}
65 {model.plan !== null && model.rows.map(r => row(els, r, model, actions))}
66 {model.more !== null && <Text wrap="truncate-end" dimColor>{` ${model.more}`}</Text>}
67 </Box>
68 ) as RenderElement
69}
70hooks/status/pane.tsx 49 lines1// A Show tab: a pure tree builder over paneModel's output. Box and Text only: the tab
2// takes no action at all (the read-only guarantee is structural; there is no Button and no
3// input). Every frame colour arrives already spelled from layout.ts; the header and notes
4// use theme keys only.
5
6import type { RenderElement } from 'claude-code'
7import { segmentsText, type Els } from './band'
8import type { PaneLine, PaneModel } from './layout'
9
10function frameLine(els: Els, line: PaneLine, row: number): RenderElement {
11 const { Text } = els
12 const blank = line.every(part => (typeof part === 'string' ? part : part.text) === '')
13 if (blank) return (<Text key={`line:${row}`} wrap="truncate-end">{' '}</Text>) as RenderElement
14 return (
15 <Text key={`line:${row}`} wrap="truncate-end">
16 {line.map((part, i) => (typeof part === 'string' ? part : (
17 <Text
18 key={`part:${i}`}
19 {...(part.color !== undefined ? { color: part.color } : {})}
20 {...(part.backgroundColor !== undefined ? { backgroundColor: part.backgroundColor } : {})}
21 {...(part.bold ? { bold: true } : {})}
22 >
23 {part.text}
24 </Text>
25 )))}
26 </Text>
27 ) as RenderElement
28}
29
30/** The tab: a header row, then the child's screen, or why there is none. */
31export function paneTree(els: Els, model: PaneModel): RenderElement {
32 const { Box, Text } = els
33 const body = model.body
34 return (
35 <Box flexDirection="column">
36 {segmentsText(els, model.header)}
37 {body.kind === 'waiting' && <Text wrap="truncate-end" dimColor>Waiting for the first screen…</Text>}
38 {body.kind === 'unavailable' && <Text wrap="truncate-end" dimColor>{body.note}</Text>}
39 {body.kind === 'unavailable' && body.numbers !== '' && <Text wrap="truncate-end" dimColor>{body.numbers}</Text>}
40 {body.kind === 'note' && body.note !== '' && <Text wrap="truncate-end" dimColor>{body.note}</Text>}
41 {body.kind === 'frame' && (
42 <Box key="frame" flexDirection="column">
43 {body.lines.map((line, row) => frameLine(els, line, row))}
44 </Box>
45 )}
46 </Box>
47 ) as RenderElement
48}
49hooks/status/run-source.ts 111 lines1// THE PORT. The mod's core (poll, wake, controls, band, pane) speaks to claudish only
2// through this shape, in the mod's own vocabulary. One adapter implements it
3// (claudish-source.ts); nothing here names a claudish tool, mode, argument, capability
4// or response field. Types only.
5
6import type {
7 ClaudishFeatures,
8 ClaudishFrame,
9 ClaudishRunKind,
10 ClaudishRunRef,
11 ClaudishSlot,
12 ClaudishSlotState,
13} from '../../types'
14
15/** What the model's claudish tool call was, as observed after it ran. Engine envelope already stripped. */
16export type CallSeen = {
17 /** the engine's tool name, e.g. mcp__<server>__<tool> */
18 tool: string
19 /** the tool's own arguments only */
20 args: Readonly<Record<string, unknown>>
21 answer: {
22 /** the result as the model reads it (text blocks joined) */
23 text: string | null
24 isError: boolean
25 }
26}
27
28/** Only run starts matter to the core. Reads, cancels, judges and already-settled answers are 'other'. */
29export type RecognizedCall =
30 | { kind: 'start'; ref: ClaudishRunRef; label: string }
31 /** a start answer that decodes but carries no per-start id: the server predates the contract */
32 | { kind: 'precontract'; server: string; runKind: ClaudishRunKind; reason: string }
33 /** a start answer that does not decode, or lacks its address or per-start id: one debug line */
34 | { kind: 'unidentified'; reason: string }
35 | { kind: 'other' }
36
37/** What one feed (server × run kind) answered this tick. Facts, not policy: poll.ts decides support. */
38export type FeedAnswer =
39 /** the contract version 1, list capability present, answer decodes */
40 | { kind: 'speaks'; version: 1; can: ClaudishFeatures }
41 /** a declaration by the server, not a glitch: no contract version, or a non-contract error body */
42 | { kind: 'precontract'; reason: string }
43 /** the contract declared, but a version other than 1, or no list capability */
44 | { kind: 'declines'; reason: string }
45 /** not an error, but does not decode, or its row array is missing */
46 | { kind: 'garbled'; reason: string }
47 /** an error answer carrying the contract's error body (its code) */
48 | { kind: 'refused'; code: string }
49 /** the call rejected (transport, deny, protocol error) */
50 | { kind: 'unanswered'; reason: string }
51
52export type PollResult =
53 | { kind: 'ok'; slots: ClaudishSlot[] }
54 /** the feed spoke, but no row carries this run's per-start id (ref.token); or its row is too defective to list */
55 | { kind: 'missing'; reason: string | null }
56
57export type PollBatch = {
58 /** key: feedKey(ref) = `${server}#${kind}` */
59 feeds: ReadonlyMap<string, FeedAnswer>
60 /** key: run id; only runs of feeds that answered 'speaks' */
61 runs: ReadonlyMap<string, PollResult>
62}
63
64export type StopResult =
65 /** changed = this call moved the slot to CANCELLED */
66 | { kind: 'stopped'; changed: boolean }
67 | { kind: 'failed'; reason: string }
68
69export type CaptureResult =
70 /** a screen with seq ≥ 1; final = the pane is closed */
71 | { kind: 'frame'; frame: ClaudishFrame; final: boolean }
72 /** nothing new, or seq 0 (no frame yet, or a pane that never spawned) */
73 | { kind: 'unchanged'; final: boolean }
74 /** the run, session or slot is no longer retained */
75 | { kind: 'gone' }
76 /** any other error, a rejection, or an unparseable answer */
77 | { kind: 'unavailable'; reason: string }
78
79/** A watched run as poll() needs it: the mod's run id and its ref. */
80export type WatchedRun = { id: string; ref: ClaudishRunRef }
81
82export type FetchSlot = { slot: string; state: ClaudishSlotState; asked: boolean }
83
84/**
85 * One change claudish's own session monitor reported in a row of this conversation, in the
86 * mod's vocabulary. Only the changes the wake can stand down for: a run's end, and a
87 * delegation's entry into a wait. Each is matched against a run's ref, never trusted further.
88 */
89export type MonitorReport =
90 /** token: the delegation's per-start id (ref.token) */
91 | { kind: 'delegation'; token: string; event: 'ended' | 'waiting' }
92 /** record: the id the line names the run by, matched against ref.monitor exactly (a path can be reused) */
93 | { kind: 'panel'; record: string; event: 'ended' }
94
95export type RunSource = {
96 recognizeCall(call: CallSeen): RecognizedCall
97 /** One list call per feed that has a watched run; answers keyed by feed and by run id. Several runs may
98 * share a ref.address (one path started twice); each is matched by its own ref.token, exactly. */
99 poll(runs: readonly WatchedRun[]): Promise<PollBatch>
100 stop(ref: ClaudishRunRef, slot: string): Promise<StopResult>
101 /** sinceSeq: the last seq drawn, 0 before the first frame. withSpans: ask for the child's colours;
102 * poll.ts passes the feed's can.spans, and nothing else may ask. */
103 capture(ref: ClaudishRunRef, slot: string, sinceSeq: number, withSpans: boolean): Promise<CaptureResult>
104 /** The wake text's line naming one run for the model: its label plus claudish's own address and id. */
105 runLine(ref: ClaudishRunRef, label: string): string
106 /** One sentence telling the model which call fetches these slots' results, or answers a waiting delegation. */
107 fetchHint(ref: ClaudishRunRef, slots: readonly FetchSlot[]): string
108 /** Every monitor report in one row's text, in order. Pure; text that is not the monitor's yields nothing. */
109 monitorReports(text: string): MonitorReport[]
110}
111types/index.d.ts 159 lines1// The claudish status mod's state contract and domain types.
2// Self-contained: no import, no reference. Every name is led by `Claudish`, so it
3// cannot collide when another plugin's type root receives this file.
4// claudish's own wire vocabulary never appears here: hooks/claudish-source.ts maps it.
5
6/** The closed set of slot states, plus the mod's own LOST and UNKNOWN. */
7export type ClaudishSlotState =
8 | 'STARTING' | 'RUNNING' | 'AWAITING_INPUT' | 'AWAITING_PERMISSION' // live
9 | 'COMPLETED' | 'FAILED' | 'CANCELLED' | 'TIMEOUT' | 'EMPTY' // claudish terminal
10 | 'LOST' // the mod's own terminal
11 | 'UNKNOWN' // the mod's own live-ish: a state outside the set, or none
12
13/** panel = a team run; delegation = a delegated session. Never the tool names. */
14export type ClaudishRunKind = 'panel' | 'delegation'
15
16export type ClaudishRunRef = {
17 kind: ClaudishRunKind
18 /** the MCP server name of the process that started it, derived from the starting tool's name */
19 server: string
20 /** claudish's per-start id from the start answer. Opaque */
21 token: string
22 /** panel: the absolute session directory; delegation: the per-start id again. Opaque */
23 address: string
24 /** panel: the id claudish's session monitor names this run by, from the start answer; absent when the
25 * answer carries none (the monitor's line then never stands for this run). Opaque */
26 monitor?: string
27}
28
29export type ClaudishFeatures = { cancel: boolean; capture: boolean; spans: boolean }
30
31export type ClaudishFeedSupport =
32 | { kind: 'probing'; strikes: number }
33 | { kind: 'speaks'; version: number; can: ClaudishFeatures; declined: number }
34 | { kind: 'unsupported'; reason: string; at: number; spoke: boolean }
35
36export type ClaudishSlot = {
37 /** panel: "01"…; delegation: its per-start id; a run that was never listed: "*" */
38 slot: string
39 model: string
40 provider: string | null
41 state: ClaudishSlotState
42 reason: string | null
43 tokensIn: number | null
44 tokensOut: number | null
45 toolCalls: number | null
46 turnsCompleted: number | null
47 /** null in terminal states */
48 idleSeconds: number | null
49 /** epoch ms */
50 lastActivityAt: number | null
51 /** display and wait-entry signature only, never wake text */
52 activity: string | null
53 /** waiting for input on a question (the adapter's comparison); false otherwise */
54 asked: boolean
55}
56
57/** Per slot of a run: the entries into AWAITING_INPUT / AWAITING_PERMISSION seen so far. */
58export type ClaudishWait = {
59 /** 1 after the first entry; +1 per re-entry; never lowered */
60 entries: number
61 /** signature of the current waiting observation; null while the slot is not waiting */
62 sig: string | null
63}
64
65export type ClaudishRun = {
66 /** hex8(fnv1a32(kind|server|token)): one per start */
67 id: string
68 /** the conversation epoch it was started in */
69 epoch: number
70 ref: ClaudishRunRef
71 label: string
72 /** 1 + starts of this epoch already made at the same ref.address */
73 generation: number
74 slots: ClaudishSlot[]
75 /** last poll whose answer showed activity; drives cadence only */
76 activityAt: number
77 missedPolls: number
78 missingSince: number | null
79 unreachableSince: number | null
80 /** slot → when it entered UNKNOWN */
81 unknownSince: Record<string, number>
82 /** slots the person confirmed Stop on; written before the cancel is sent */
83 personStops: string[]
84 /** slot → its wait entries */
85 waits: Record<string, ClaudishWait>
86 lostReason: string | null
87 /** when every slot became terminal; never on an empty slot list */
88 settledAt: number | null
89}
90
91export type ClaudishFrame = {
92 seq: number
93 cols: number
94 rows: number
95 cursor: { row: number; col: number } | null
96 /** exactly `rows` entries, plain text, right-trimmed */
97 lines: string[]
98 /** the child's colours, one array per line (same length as lines); absent = plain */
99 styles?: ClaudishStyleSpan[][]
100}
101
102export type ClaudishColor = { index: number } | { rgb: number }
103
104export type ClaudishStyleSpan = {
105 col: number
106 len: number
107 fg: ClaudishColor | null
108 bg: ClaudishColor | null
109 bold: boolean
110}
111
112export type ClaudishPaneView = {
113 runId: string
114 slot: string
115 model: string
116 status: 'waiting' | 'live' | 'unavailable' | 'ended'
117 frame: ClaudishFrame | null
118 note: string | null
119}
120
121/** armed: the first press, until the confirm window ends. sending: the cancel is out, its answer not
122 * back (Claude Code's own permission question may be open); never timed out. sent: answered at `at`. */
123export type ClaudishStopRequest =
124 | { kind: 'armed'; until: number }
125 | { kind: 'sending'; at: number }
126 | { kind: 'sent'; at: number }
127
128export type ClaudishWakeEntry =
129 /** heldUntil: a change claudish's own session monitor also reports; not sent before it, so the monitor's line can stand for it */
130 | { kind: 'pending'; state: ClaudishSlotState; at: number; attempts: number; retryAt: number | null; heldUntil?: number }
131 | { kind: 'inflight'; state: ClaudishSlotState; at: number; attempts: number; nonce: string; since: number }
132 | { kind: 'parked'; state: ClaudishSlotState; at: number; attempts: number; reason: string; retryAt: number }
133 /** by 'monitor': claudish's own session monitor reported it in a row of this conversation; nothing was sent */
134 | { kind: 'delivered'; at: number; nonce: string; by?: 'monitor' }
135
136export type ClaudishRuns = { epoch: number; starts: Record<string, number>; list: ClaudishRun[] }
137export type ClaudishLedger = { epoch: number; entries: Record<string, ClaudishWakeEntry> }
138export type ClaudishTurn = { isRunning: boolean; since: number }
139export type ClaudishEarlier = { runs: number; done: number; failed: number; stopped: number }
140
141declare module 'claude-code' {
142 interface PluginState {
143 claudish: {
144 /** the epoch lives inside the atom it fences; starts: per ref.address, starts this epoch */
145 runs: ClaudishRuns
146 /** key: `${server}#${kind}`; absent = probing, 0 strikes */
147 feeds: Record<string, ClaudishFeedSupport>
148 /** key: `${runId}/${slot}` */
149 stopRequests: Record<string, ClaudishStopRequest>
150 /** member id = pane id = Pane requestId */
151 panes: StateFamily<ClaudishPaneView | null>
152 /** key: `${runId}/${slot}` (terminal) or `${runId}/${slot}#w${n}` (the n-th wait entry) */
153 wakeLedger: ClaudishLedger
154 turn: ClaudishTurn
155 earlier: ClaudishEarlier
156 }
157 }
158}
159