SLOPSHOPPER

claudish

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

newpanebandguardtoaststatus
★ 12v2.3.0MITupdated 2026-10-08MadAppGang/magus/plugins/claudish
A shopper browsing a rack in a slop shop
README

Claudish Plugin

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.

Why this plugin exists

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

Requirements

  • The claudish CLI tool must be on $PATH. Install via npm: ``bash npm install -g claudish ``
  • Provider credentials, which claudish resolves itself: from the environment, its own config, the macOS Keychain, or 1Password. claudish --help lists the credential commands.
  • For the session progress monitor: claudish 10.4.0 or later, and 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.

What it provides

Tools exposed via the claudish MCP server:

  • Low-level: run_prompt, list_models, search_models, compare_models
  • Agentic: team, report_error
  • Channel: create_session, send_input, get_output, cancel_session, list_sessions

Tool 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.

Skills

SkillCovers
claudish:claudish-usageWhich 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.

Session progress monitor

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:

  • every create_session session this Claude Code session started;
  • every 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.

What it reports

Every line starts with claudish-monitor:. The monitor writes nothing else to stdout.

LineMeaningNext action
session ID startedthe session existsnote the id
session ID runningstill running; at most one every 5 minutesnothing
session ID needs-input … next: send_input IDit finished a turn and waits for inputsend_input(ID, answer)
session ID needs-input … waited=…a wait that was already answered when the monitor read itnothing
session ID completed … next: get_output IDfinishedget_output(ID)
session ID failed / timeout … next: get_diagnostics IDended badlyget_diagnostics(ID)
session ID cancelledcancellednothing
team ID started … path=Pthe panel existsnote the path
team ID running …still running; at most one every 5 minutesnothing
team ID completed / failed / cancelled … next: team-statusthe run settledteam(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 reportedupgrade claudish, restart Claude Code
notice no-session-identity: …the monitor cannot tell which runs are this session'snone; 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:

LineFields
session startedmodel
session runningmodel elapsed replies tools cost
session needs-inputmodel elapsed turns (+ waited once answered)
session completed, cancelledmodel elapsed turns tools cost
session failed, timeoutmodel elapsed turns tools cost reason exit
team startedslots path
team runningelapsed slots ok failed cancelled running path
team completed, failed, cancelledelapsed 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

How it knows which runs are yours

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.

Limits

  • Needs claudish 10.4.0 or later. An older claudish records no 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.
  • Waking an idle session: measured twice, not a rate. A plugin monitor's line reaches the model as a notification (PMON-3), and in two live interactive sessions (Claude Code 2.1.290 and 2.1.292) every end line arrived after the turn had ended and started a new one. Two sessions are not a rate, and under claude -p no monitor runs, so the shipped instructions never rely on it alone.
  • Needs bun on PATH. Without it Claude Code reports script failed (exit 127) once per session.
  • Not under claude -p, which starts no monitors. Not on Bedrock, Vertex or Foundry, nor with DISABLE_TELEMETRY or CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set.
  • claudish must be launched directly by Claude Code, as this plugin's .mcp.json does. A wrapper process in between hides which window started a run, and its runs are not reported.
  • A monitor that fails at start is not restarted until Claude Code restarts.
  • One Bun process per Claude Code session, roughly 30-40 MB.
  • Without 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.
  • The too-old notice needs an npm or bun install of claudish whose path holds no space. A Homebrew or standalone binary gets none, because its version is not read without running it; neither does an install under a path with a space (such as Application Support), because a ps line cannot be split on it.
  • After upgrading claudish, restart Claude Code: the old server keeps running, and its runs are not reported.
  • 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.
  • A run still in flight when the monitor starts is picked up only when its record directory changed in the 62 minutes before: claudish's longest session timeout, 60 minutes, plus two. No session runs longer than that; a team run that has is not reported.
  • claudish stops appending to a session's waits.jsonl once it holds 1 MiB, about 5,000 waits. Waits after that are not reported.
  • A team run whose end record cannot be written (a full disk, lost permissions, the record directory removed mid-run) is 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.

Monitor and channel mode

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.

Live run status

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.

  • Stop cancels that one slot and nothing else. Press it twice: the first press turns the button into 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.
  • Show opens a tab for that slot, titled by its model: the model's live screen, read-only, in the model's own colours. One tab per model; pressing Show again brings its tab forward.
  • Wake-up. When a model in a run this session started finishes, fails or is stopped — or a 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.

Channel notifications (optional)

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:

  • Claude Code v2.1.80 or later
  • Anthropic auth via claude.ai or Console API key (not Bedrock/Vertex/Foundry)
  • Interactive mode (channels do not register in -p mode)

Used by

This is a runtime dependency of:

  • code-search — semantic code search via mnemex; uses Claudish for multi-model team review
  • dev — universal development assistant; uses Claudish for /dev:research, /team, model orchestration
  • multimodel — /team and /delegate slash commands
  • designer — UI review with multi-model validation

If you are installing Magus, this plugin is auto-installed when any of the above is enabled.

Source 12 files
hooks/status/register.tsx 157 lines
1// 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}
157
hooks/status/claudish-source.ts 508 lines
1// 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}
508
hooks/status/host.ts 113 lines
1// 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}
113
hooks/status/domain.ts 471 lines
1// 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}
471
hooks/status/layout.ts 536 lines
1// 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}
536
hooks/status/poll.ts 420 lines
1// 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}
420
hooks/status/wake.ts 623 lines
1// 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}
623
hooks/status/controls.ts 130 lines
1// 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}
130
hooks/status/band.tsx 70 lines
1// 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}
70
hooks/status/pane.tsx 49 lines
1// 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}
49
hooks/status/run-source.ts 111 lines
1// 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}
111
types/index.d.ts 159 lines
1// 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