Sergio's Tech Code Display: a live, read-only pane with INFO, agents, architect consults, tools and files and a session log, all from real session events…

A live, read-only session dashboard for Claude Code. It opens a pane beside the transcript that shows what the session is doing right now: the main model's INFO, subagents at work, architect consults, the busiest tools and files, and a session log. Every number comes from a real session event, anything inferred says so, and nothing leaves your machine.

<a href="#install">Install</a> · <a href="#what-you-see">What you see</a> · <a href="#use">Use</a> · <a href="#where-it-runs">Where it runs</a> · <a href="#what-it-can-reach">What it can reach</a> · <a href="#configure">Configure</a> · <a href="#troubleshooting">Troubleshooting</a> · <a href="CHANGELOG.md">Changelog</a>
Inside Claude Code 2.1.300 or later:
/plugin marketplace add sergios-tech/code-display
/plugin install st-code-display@sergios-tech
/reload-plugins
/st-code-display
From a terminal:
claude plugin marketplace add sergios-tech/code-display
claude plugin install st-code-display@sergios-tech
Or load it from a clone for one session:
git clone https://github.com/sergios-tech/code-display
claude --plugin-dir ./code-display
If the installer lists options as unset, the defaults apply. You can change them in /config.
Upgrading from st-code. The plugin was called st-code before it became st-code-display. Remove an install of the old name with /plugin uninstall st-code@sergios-tech before you install the new one, and set its options again: they, like the stored state, start fresh under the new name.
The hooks API this plugin is built on is an early-access part of Claude Code and can change between releases. If something breaks after an update, see Troubleshooting.

| Two columns, in a 120-column dock | One column, in the default 66-column dock |
|---|---|
![]() | ![]() |

The header is one unfilled row on black. ▌SERGIO'S TECH · CODE DISPLAY sits on the left. On the right are the state dot and the main model in its family colour (Opus red, Sonnet orange, Haiku green, Fable gold, any other model grey), for example ● OPUS 5.5 or ○ OPUS 5.5. Once an architect has been seen, · ARCHITECT ADVISING or · ARCHITECT ON CALL joins it. The dot is green and pulses while a turn, a subagent or a workflow's agent is running, and the header carries no state word: INFO's frame reads ● working or ○ idle for the main loop. A frame turns green while its panel holds running work: INFO while the main loop works, AGENTS while an agent or a loop runs, ARCHITECT while the architect advises. When nothing runs at all, neither a turn nor an agent nor a loop, every frame and the ○ of the header and the ○ IDLE of the inline summary turn dark red (#7f0000, a step under the Sergio's Tech themes' #800000).
Panels with nothing to show take no room, so a fresh session shows the header, INFO and LOG. The wide layout is the exception: it shows AGENTS beside INFO from the start, reading no agents yet. In one column AGENTS appears with the first agent, or with the first model loop that no agent claims, such as a workflow's agents; until an agent has a row, it reads only loops running while one runs.
| Panel | Shows | From |
|---|---|---|
| INFO | Three groups split by dividers. Model: the effort gauge (◆ effort ▮▮▮▮ xhigh), mode auto and req 7. Usage: a context gauge with tokens/window and compactions (↺ 1), then each of the first two rate-limit windows on its own row with its reset countdown (↻ 3h51m). Spend: the session cost and the last main-loop request's ttft and tok/s. The effort gauge's lit cells turn red at xhigh and max; the word stays green. The context gauge is green, orange from 30% and red from 50%. Every rate-limit window is green, orange from 80% and red from 90%, judged by the printed figure. The frame meta reads ● working in orange while the main loop runs and ○ idle in dim. mode bypass shows in coral. A group with no data is left out, and with none at all the panel reads waiting for the first request. | turn.step, classic.UserPromptSubmit, session.measure, session.compact, $.session.usage() |
| AGENTS | One row per agent, running agents first and the newest first among them. Each row has a status glyph, its hotkey digit and title, the model when it differs from the main one, a status bar and a clock. The bar is a 3-cell block that bounces along the track while the agent runs, and fills green, coral or grey once it is done, failed or stopped; it shows that an agent is alive, not how far along it is. A request cut at max_tokens shows as failed. Six rows show, hotkeys 1–6, under a +N earlier row. The frame meta reads N running · N total. An opened agent shows inside the frame after a divider. The last row is loops 2 seen · 1 active with one dot per model loop that no agent claims (workflow agents, compactions, forks): ● active, ✓ done, ○ idle. | agent.spawn, turn.step, tool.call, turn.complete |
| ARCHITECT | ● advising or ○ on call. Consults on a one-row timeline (◆ where one started, ━ while it ran). How long the open consult has run (consulting for 42s), or when the last one ended and how long it took. The inferred moment of the last consult. A » line with the first line of the advice. The frame is green while it advises. | agent.offer, agent.spawn of a matching type, matching server tools in session.append, turn.complete, the SubagentHandback call or hand-back turn |
| TOOLS & FILES | A table of the busiest tools (4 rows, 6 in two columns): a glyph for the tool's family (› read, ✎ edit, ▸ run, ● agent, · other), the name (an MCP tool as server:tool), calls, errors (✗ 3) and the average time per call under avg*. After a divider, the 5 paths touched most, each with reads and edits (✎ 2). Paths show their last two segments, and a file that only subagents touched is dimmed. | tool.call, $.tool.check() |
| LOG | Prompts, spawns, completions, consults and advice, edits, errors, denials, compactions and max_tokens hits. Each line has a time, how long into the session it was logged (0:42, 12:04, then 1:02:04 past an hour; --:-- when unknown), right-aligned to the widest shown, an actor (opus for the main loop, named by its model, then you, an agent such as Explore 1, or architect) and its text, as much of it as the pane's width holds, with … where it is cut. Only the mark after the text carries a state: ✗ in coral, · denied in gold. Docked, it takes every row the other panels leave and pads with blank rows, so its frame ends on the pane's bottom edge. Inline it takes up to 8, and 4 to 8 when the pane's height is unknown. A pane too short for 2 hides it rather than cutting it off at the bottom edge. | all of the above |
A denial is a call refused by a permission rule or hook. It writes one log line that ends with a gold · denied (Bash → rm -rf build · denied). Declining an approval prompt is not a denial: that call counts as a failed call (✗) in TOOLS & FILES and the log. Neither is a refusal by a PreToolUse hook, which counts as failed too (What is inferred, not measured says why).
| Command | Does | |||
|---|---|---|---|---|
/st-code-display (or /st-code-display open) | Opens the pane. | |||
/st-code-display close | Closes it. | |||
/st-code-display reset | Clears agents and loops, consults, tools and files, the denial count, the log and any /st-code-display layout choice. The model, permission mode, session cost, rate limits and compaction count stay. The context gauge returns with the next reading. | |||
| `/st-code-display layout auto\ | compact\ | wide\ | mini` | Picks the layout for this session, overriding the layout option. |
/clear resets the pane the same way as /st-code-display reset.
Focus the pane with ctrl+x tab, then:
| Key | Does |
|---|---|
1 to 6 | Opens that row's agent inside the AGENTS frame: the full task, its type, model, status and steps, its last 3 tool calls and the start of its answer. Press the key again to close it. The digits follow the drawn order, so they shift when an agent finishes. |
While you view a subagent's transcript, its row is marked ▶ and the log shows only that agent's lines (LOG · THIS AGENT).
auto, draws a summary of at most 8 rows on black, without frames. The rows are the state and a digest of INFO's figures, up to six agents, running ones first, and a last row for the agents left out and the loops. Its figures take the colours INFO's gauges use. It takes no keys, so its agent rows carry no digit.Two columns. Under auto, the wide layout starts at a dock of 110 columns. The host caps the dock at 45% of the terminal's width and opens it at 66 columns, so you need both of these:
/st-code-display layout wide draws two columns from a dock of 82 columns: a terminal at least 183 columns wide, with the dock widened. Below 82 it stays in one column. /st-code-display layout compact keeps one column at any width. /st-code-display layout mini draws the 8-row summary even when docked. /st-code-display layout auto goes back to choosing by width.
Opening by itself. With openOnStart, the plugin opens the pane when a session starts. The host only places a pane opened that way in a terminal at least 144 columns wide, or 110 once you have opened the pane yourself and not closed it by hand. Below that, run /st-code-display, which opens it at any width.
Colours. The palette is fixed: the Signal palette on a pure black fill, made for black or dark terminals. It doesn't follow your terminal or host theme, and there's no light variant. Green is the working colour, gauges go green to orange to red, agents are orange and frames are red, turned down to dark red while nothing runs. Every text colour reaches at least 4.5:1 contrast on black, and so does its 256-colour fallback, with one exception: the resting ○ of the header and the inline summary's ○ IDLE in dark red (1.9:1), which the ○ shape repeats. Frame lines and the empty cells of gauges and bars never carry text. A state never relies on colour alone: ● running, ✓ done, ✗ failed, ■ stopped, ↺ compaction, and a refused call says denied.
The plugin only watches. Apart from its own command and its own pane, every hook passes its event on unchanged: it never denies, rewrites or blocks a tool call, a prompt or a subagent. What each hook does lists them one by one.
| It sees | Through |
|---|---|
| Every tool call's name and input, whether it failed, and how long it took | tool.call |
| Each call's permission verdict, asked just before the call runs, used only to tell an approval prompt or a refusal apart | $.tool.check() |
| The agent types offered to the model, used to remember which ones are the architect | agent.offer |
| Subagent spawns, their model requests and token usage, and their final answers | agent.spawn, turn.step, turn.complete |
| The main loop's requests: model, effort, timing and output tokens | turn.step |
| The opening text of each main-loop turn (your prompt, first 400 characters, for the log) and the permission mode | turn.start, classic.UserPromptSubmit |
| Context, cost and rate-limit readings, and compactions | session.measure, session.compact, $.session.usage() |
| The main loop's replies, read only for server-side advisor calls (whose content stays encrypted) | session.append |
| An architect agent's hand-back report | the SubagentHandback tool call |
What it keeps, in session state: a tool call as its name plus a command, a path cut to its last two segments, a pattern, a URL or a description; the first 400 characters of each log line, your prompts included; agent tasks and the start of their answers; the first line of the architect's advice. Credentials are masked before anything is stored. The lists are capped: 24 agents, 60 other loops, 60 log lines, 40 consults, 40 tools and 200 files.
What it never does: it makes no network requests, runs no processes, reads and writes no files, calls no model and stores nothing across sessions. claude plugin validate . prints every hook it registers and every host call it makes.
The plugin hooks 14 events, with 15 hooks, in the order claude plugin validate . lists them (tool.call is hooked twice). Each row says when the hook runs, what it reads, and what it keeps. What it keeps goes into session state, capped and with credentials masked, as described in What it can reach; the one exception is marked in its row. Nothing is kept outside the session.
| Hook | Runs | Reads and keeps | Event |
|---|---|---|---|
session.start | When a session starts, and again after /reload-plugins. | Registers /st-code-display, brings stored state up to the current schema, notes the session's start time and takes the host's first context, cost and rate-limit reading. With openOnStart it asks the host to open the pane. Keeps the start time and that reading. | Passed on unchanged. |
session.end | When a session ends. | Reads only the reason. After /clear it clears the pane as /st-code-display reset does. Keeps nothing. | Passed on unchanged. |
command.run | Only for /st-code-display. Other commands never reach it. | Reads the arguments: open, close, reset or layout. Opens or closes the pane, clears its state on reset, and keeps the layout you pick. | Answered: the command's own reply text. It is the only command the plugin answers. |
classic.UserPromptSubmit | When you submit a prompt. | Reads only the permission mode, not the prompt. Keeps the mode. | Passed on unchanged. |
turn.start | When the main loop or a subagent starts a turn. | In the main loop it reads the opening text and keeps its first 400 characters as a log line, marks the loop as running and starts a fresh tally for the turn. When the text is an architect's hand-back, it keeps the first line of the advice instead. A subagent's turn is left alone. | Passed on unchanged. |
turn.step | For each model request, as its response streams. | In the main loop it reads the model, the effort and the kind of each streamed chunk, never its content, and reads the clock up to three times. It keeps the model, the effort, the request count and, once the response ends, ttft and tok/s. For a subagent it reads the model, token usage and stop reason and keeps them on the agent's row, with a log line for max_tokens. | Every streamed chunk and the final result are passed through unchanged. |
turn.complete | When a turn ends. | In the main loop it marks the loop idle and clears the turn's tally. For a subagent it reads the end reason and the answer. It keeps the status, a log line and the first 400 characters of the answer. For an architect it keeps the first line of the advice. | Passed on unchanged. |
agent.offer | When the host lists an agent type the model may call. | Reads the type's name, and its description with matchDescriptions. Keeps the name of each type that matches architectPattern, the newest 20. | Passed on unchanged. |
agent.spawn | When a subagent is spawned. | Reads the type, name and description, and the id and model the host gave it. Keeps a row for the agent and a log line. For an architect it keeps only the id, as a consult. A refused spawn, and a workflow script's agent, is left alone here. | Passed on unchanged. |
session.append | When a message is added to the conversation. | Reads the main loop's replies, only for server-side advisor calls and their results. Keeps the ids it has seen and the consult's times. Other messages, and every subagent's, are ignored. | Passed on unchanged. |
tool.call for SubagentHandback | When a subagent hands its report back. | Reads the report only when the sender is a known architect. Keeps the first line as the advice, and a log line. | Passed on unchanged. Other calls pass this hook by. |
tool.call for every call | Around each tool call, in every loop. | First asks the engine for the call's permission verdict with $.tool.check, a query that opens no dialog and runs nothing, and keeps the verdict in memory only, until the call ends, to tell an approval prompt or a refusal from a failed tool. Then reads the clock before and after, the tool's name and input, and whether the call failed or was refused. Keeps the tool's name and a short description of its input, counts, errors and average time, the files touched, the subagent's last three calls and a log line for each edit, failure or refusal. | The call and its result are passed on unchanged. It never denies or rewrites a call. |
session.measure | When the host measures the session. | Reads the context, cost and rate-limit readings. Keeps the latest. | Passed on unchanged. |
session.compact | When the conversation is compacted. | Reads the trigger, and whether the compaction was vetoed. Keeps the count, the time and a log line. A vetoed compaction, a precompute and a subagent's compaction are not counted. | Passed on unchanged. |
ui.render | Each time the pane is drawn, for its own pane only. | Reads the stored state, the clock, and the pane's size and surface. Keeps which agent your digit key has opened. | Answered: the drawing of the plugin's own pane. |
No hook decides anything.
tool.call asks the engine for a call's verdict with $.tool.check before the call runs, and only reads it, to mark an asked or refused call.tool.call never denies or rewrites a call. It reads the result after the call has run.turn.step passes every streamed chunk through unchanged./st-code-display's own reply text and the drawing of its own pane.The host calls it makes, as claude plugin validate . prints them:
| Call | Used for |
|---|---|
$.state.get, $.state.set | Reading and writing the state above. It lasts for the session; the plugin keeps nothing across sessions. |
$.clock.now | The times of log lines and clocks, and the timing of a request or a tool call. |
$.clock.after | One timer, set when a model loop that no agent claims steps, to redraw the pane when that loop stops counting as running. |
$.command.register | Registering /st-code-display at session start. |
$.session.usage | The host's first context, cost and rate-limit reading at session start. |
$.tool.check | Asking the engine for a tool call's permission verdict just before the call runs, to tell an approval prompt or a refusal from a failed tool. The query opens no dialog and runs nothing. |
$.ui.open, $.ui.close | Opening the pane at session start and on /st-code-display, and closing it on /st-code-display close. |
$.ui.invalidate | Asking the host to draw the pane again. |
$.ui.resolve | Getting the host's drawing elements for the pane. |
(inferred), and moments: false turns them off.advice returned, but cannot show what the tool said. The » advice line comes only from an architect agent's answer or hand-back.ctx is the latest request's whole input (uncached, cache read and cache write). It is labelled ctx, not cost, behooks/register.tsx 769 lines1// The one hooks module: the plugin's atoms, every helper that reads or writes them or calls `$`,
2// and each hook, written in the `on(...)` call that registers it, so the host's scan reads all of
3// them off this file. What the hooks work out is pure and lives in domain/, format/ and view/.
4import { atom, read, update } from 'claude-code'
5import type { AgentSpawnInput, ApiContentBlock, EngineInterface, EventResult, Register, RenderElement, SessionUsage, Timer } from 'claude-code'
6
7import type { AgentCard, Architect, Layout, LogKind, LogLine, LogMark, Moment, Speed, ToolNote } from '../types'
8import { parseConfig } from './config'
9import type { Config } from './config'
10import { recordCall, timedMs } from './domain/activity'
11import type { Call } from './domain/activity'
12import { LOOP_ACTIVE_MS, addCard, applyStep, finishCard, finishLoop, newCard, noteTool, stepLoop, whoOf } from './domain/agents'
13import {
14 IDS_MAX,
15 SEEN_MAX,
16 adviceLine,
17 endConsult,
18 handbackOf,
19 isAdvisorUse,
20 isServerResult,
21 momentOf,
22 startConsult,
23 viaOf,
24 whoOf as architectWho,
25} from './domain/architect'
26import { LINE_MAX, appendLog, promptLine } from './domain/log'
27import { EDIT_TOOLS, afterCall } from './domain/turn'
28import { stepSpeed, usageFrom } from './domain/vitals'
29import { describeInput, redact } from './format/redact'
30import { fmtDuration, shorten } from './format/text'
31import {
32 DEFAULT_ACTIVITY,
33 DEFAULT_ARCHITECT,
34 DEFAULT_MAIN,
35 DEFAULT_ROSTER,
36 DEFAULT_SESSION,
37 DEFAULT_TURN,
38 DEFAULT_USAGE,
39 DEFAULT_VIEW,
40 LAYOUTS,
41 SCHEMA_VERSION,
42 normalizeActivity,
43 normalizeArchitect,
44 normalizeAgents,
45 normalizeLog,
46 normalizeLoops,
47 normalizeMain,
48 normalizeRoster,
49 normalizeSession,
50 normalizeTurn,
51 normalizeUsage,
52 normalizeView,
53} from './state/schema'
54import type { Snapshot } from './state/schema'
55import type { Draw } from './view/draw'
56import { header } from './view/header'
57import { miniPane } from './view/mini'
58import { buildViewModel } from './view/model'
59import { COLORS } from './view/palette'
60import type { Expand } from './view/panels/agents'
61import { body } from './view/pane'
62import { pictures } from './view/picture'
63
64// ---------------------------------------------------------------- atoms
65//
66// One atom per key of PluginState['st-code-display'], each starting at its default. They are declared here,
67// beside every read and write, because the host's scan only lists calls on atoms declared in the
68// calling file; the plugin and key are literals for the same scan.
69
70export const meta = atom({ plugin: 'st-code-display', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
71export const main = atom({ plugin: 'st-code-display', key: 'main' } as const, DEFAULT_MAIN)
72export const usage = atom({ plugin: 'st-code-display', key: 'usage' } as const, DEFAULT_USAGE)
73export const turn = atom({ plugin: 'st-code-display', key: 'turn' } as const, DEFAULT_TURN)
74export const agents = atom({ plugin: 'st-code-display', key: 'agents' } as const, [])
75export const loops = atom({ plugin: 'st-code-display', key: 'loops' } as const, [])
76export const architect = atom({ plugin: 'st-code-display', key: 'architect' } as const, DEFAULT_ARCHITECT)
77export const activity = atom({ plugin: 'st-code-display', key: 'activity' } as const, DEFAULT_ACTIVITY)
78export const log = atom({ plugin: 'st-code-display', key: 'log' } as const, [])
79export const view = atom({ plugin: 'st-code-display', key: 'view' } as const, DEFAULT_VIEW)
80export const roster = atom({ plugin: 'st-code-display', key: 'roster' } as const, DEFAULT_ROSTER)
81export const session = atom({ plugin: 'st-code-display', key: 'session' } as const, DEFAULT_SESSION)
82
83// ---------------------------------------------------------------- state
84//
85// The only place that reads or bulk-writes stored state: every value leaves here normalized. The
86// tests drive the atoms and some of these helpers directly, so those are exported.
87
88/** Every key read once, so a frame is drawn from one consistent set of values. */
89export async function loadSnapshot($: EngineInterface): Promise<Snapshot> {
90 const [m, u, t, a, l, ar, ac, lg, v, ro, se] = await Promise.all([
91 read($, main),
92 read($, usage),
93 read($, turn),
94 read($, agents),
95 read($, loops),
96 read($, architect),
97 read($, activity),
98 read($, log),
99 read($, view),
100 read($, roster),
101 read($, session),
102 ])
103 return {
104 main: normalizeMain(m),
105 usage: normalizeUsage(u),
106 turn: normalizeTurn(t),
107 agents: normalizeAgents(a),
108 loops: normalizeLoops(l),
109 architect: normalizeArchitect(ar),
110 activity: normalizeActivity(ac),
111 log: normalizeLog(lg),
112 view: normalizeView(v),
113 roster: normalizeRoster(ro),
114 session: normalizeSession(se),
115 }
116}
117
118/** A stored shape older than this build's: rewrite every key in the current shape. */
119export async function migrate($: EngineInterface): Promise<void> {
120 const stored = await read($, meta)
121 const version = typeof stored?.schemaVersion === 'number' ? stored.schemaVersion : 0
122 if (version >= SCHEMA_VERSION) return
123 await update($, main, normalizeMain)
124 await update($, usage, normalizeUsage)
125 await update($, turn, normalizeTurn)
126 await update($, agents, normalizeAgents)
127 await update($, loops, normalizeLoops)
128 await update($, architect, normalizeArchitect)
129 await update($, activity, normalizeActivity)
130 await update($, log, normalizeLog)
131 await update($, view, normalizeView)
132 await update($, roster, normalizeRoster)
133 await update($, session, normalizeSession)
134 await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
135}
136
137/**
138 * `/st-code-display reset` and `/clear`: the session is the same, so what it spent stays; the context
139 * gauge waits for the next measurement instead of showing the pre-clear fill.
140 * The architect types offered stay too, as the agent listing is not sent again, and so does
141 * the session's start time, which the log's times count from.
142 */
143export async function resetAll($: EngineInterface): Promise<void> {
144 const keptMain = normalizeMain(await read($, main))
145 await update($, main, () => ({ ...DEFAULT_MAIN, model: keptMain.model, mode: keptMain.mode }))
146 await update($, usage, u => ({ ...normalizeUsage(u), pct: null, tokens: null, window: 0, lastCompactAt: null }))
147 await update($, turn, () => DEFAULT_TURN)
148 await update($, agents, () => [])
149 await update($, loops, () => [])
150 await update($, architect, () => DEFAULT_ARCHITECT)
151 await update($, activity, () => DEFAULT_ACTIVITY)
152 await update($, log, () => [])
153 await update($, view, () => DEFAULT_VIEW)
154}
155
156/** The session's start, noted once: a reload runs `session.start` again, and `/clear` and `/st-code-display reset` keep the same session. */
157async function noteSessionStart($: EngineInterface): Promise<void> {
158 const now = await $.clock.now()
159 await update($, session, x => {
160 const s = normalizeSession(x)
161 return s.startedAt === null ? { startedAt: now } : s
162 })
163}
164
165/** One line into the session log, credentials masked before its text is cut to LINE_MAX, the log kept to its newest lines; `mark` is drawn after the text, never cut. */
166async function say($: EngineInterface, who: string, text: string, kind: LogKind = 'info', agentId: string | null = null, mark: LogMark | null = null): Promise<void> {
167 const line: LogLine = { at: await $.clock.now(), who, text: shorten(redact(text), LINE_MAX), kind, agentId, mark }
168 await update($, log, list => appendLog(normalizeLog(list), line))
169}
170
171/** A `session.measure` reading, or the host's own first reading at the session's start, stored over the last one. */
172async function applyMeasure($: EngineInterface, reading: Parameters<typeof usageFrom>[0]): Promise<void> {
173 await update($, usage, x => usageFrom(reading, normalizeUsage(x)))
174}
175
176/** One compaction of the main conversation, counted and timed. */
177async function noteCompaction($: EngineInterface): Promise<void> {
178 const now = await $.clock.now()
179 await update($, usage, x => {
180 const u = normalizeUsage(x)
181 return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
182 })
183}
184
185/** The permission mode the main loop runs in, when the event names one. */
186async function noteMode($: EngineInterface, mode: string | undefined): Promise<void> {
187 if (typeof mode !== 'string' || mode === '') return
188 await update($, main, m => ({ ...normalizeMain(m), mode }))
189}
190
191/** The main loop starts a turn: a fresh tally, and the loop is running. */
192async function beginTurn($: EngineInterface): Promise<void> {
193 await update($, turn, () => DEFAULT_TURN)
194 await update($, main, m => ({ ...normalizeMain(m), isRunning: true }))
195}
196
197/** The main loop starts a model request: its model and effort are known now, and it counts. */
198async function beginRequest($: EngineInterface, model: string, effort: string | number | undefined): Promise<void> {
199 await update($, main, m => {
200 const x = normalizeMain(m)
201 return {
202 ...x,
203 model: typeof model === 'string' && model !== '' ? model : x.model,
204 effort: effort === undefined ? x.effort : String(effort),
205 steps: x.steps + 1,
206 }
207 })
208}
209
210/** The speed of the main loop's last request. */
211async function storeSpeed($: EngineInterface, speed: Speed): Promise<void> {
212 await update($, main, m => ({ ...normalizeMain(m), speed }))
213}
214
215/** The main loop's turn ended: the tally is cleared and the loop is idle. */
216async function endTurn($: EngineInterface): Promise<void> {
217 await update($, turn, () => DEFAULT_TURN)
218 await update($, main, m => ({ ...normalizeMain(m), isRunning: false }))
219}
220
221/**
222 * One tool call added to the turn's tally (edits and the main loop's error streak); a call
223 * that changes none of them writes nothing, so it costs no redraw.
224 */
225async function noteTurnCall($: EngineInterface, c: Parameters<typeof afterCall>[1]): Promise<void> {
226 const t = normalizeTurn(await read($, turn))
227 const after = afterCall(t, c)
228 if (after.edits === t.edits && after.errorStreak === t.errorStreak) return
229 await update($, turn, x => afterCall(normalizeTurn(x), c))
230}
231
232/** One finished tool call in the session's tool and file tallies. */
233async function recordTool($: EngineInterface, c: Call): Promise<void> {
234 await update($, activity, a => recordCall(normalizeActivity(a), c))
235}
236
237/** `/st-code-display layout`: the person's layout choice, which wins over the configured one. */
238async function setLayout($: EngineInterface, layout: Layout): Promise<void> {
239 await update($, view, v => ({ ...normalizeView(v), layout }))
240}
241
242/** Architect types remembered from the agent listing, the newest kept. */
243const ROSTER_MAX = 20
244
245/** An offered agent type that matches the architect pattern, remembered for when it is spawned. */
246export async function noteArchitectType($: EngineInterface, type: string): Promise<void> {
247 if (normalizeRoster(await read($, roster)).architectTypes.includes(type)) return
248 await update($, roster, r => {
249 const x = normalizeRoster(r)
250 return x.architectTypes.includes(type) ? x : { architectTypes: [...x.architectTypes, type].slice(-ROSTER_MAX) }
251 })
252}
253
254/** Whether a type was offered as the architect's, by its name or, with `matchDescriptions`, its description. */
255async function isOfferedArchitect($: EngineInterface, type: string): Promise<boolean> {
256 return normalizeRoster(await read($, roster)).architectTypes.includes(type)
257}
258
259/** The agent cards as stored now. */
260async function readAgents($: EngineInterface): Promise<AgentCard[]> {
261 return normalizeAgents(await read($, agents))
262}
263
264/** The architect as stored now. */
265async function readArchitect($: EngineInterface): Promise<Architect> {
266 return normalizeArchitect(await read($, architect))
267}
268
269/** Whether a loop is a spawned architect's. */
270async function isArchitectId($: EngineInterface, id: string): Promise<boolean> {
271 return normalizeArchitect(await read($, architect)).ids.includes(id)
272}
273
274/** A spawned architect: its id is kept, so its loop is neither a card nor among the other loops. */
275export async function addArchitectId($: EngineInterface, id: string): Promise<void> {
276 await update($, architect, a => {
277 const x = normalizeArchitect(a)
278 return x.ids.includes(id) ? x : { ...x, ids: [...x.ids, id].slice(-IDS_MAX) }
279 })
280 if (normalizeLoops(await read($, loops)).some(l => l.id === id)) {
281 await update($, loops, l => normalizeLoops(l).filter(x => x.id !== id))
282 }
283}
284
285/** A server tool call remembered the first time it is read; true then, false for one read before. */
286export async function markSeen($: EngineInterface, id: string): Promise<boolean> {
287 if (normalizeArchitect(await read($, architect)).seen.includes(id)) return false
288 let isNew = false
289 await update($, architect, a => {
290 const x = normalizeArchitect(a)
291 isNew = !x.seen.includes(id)
292 return isNew ? { ...x, seen: [...x.seen, id].slice(-SEEN_MAX) } : x
293 })
294 return isNew
295}
296
297/**
298 * A consult opened now, at the moment the main turn's tally suggests. The moment, or null when a
299 * consult of that id was already opened.
300 */
301async function openConsult($: EngineInterface, id: string, via: string): Promise<Moment | null> {
302 const [t, a, now] = await Promise.all([read($, turn), read($, architect), $.clock.now()])
303 if (normalizeArchitect(a).consults.some(c => c.id === id)) return null
304 const moment = momentOf(normalizeTurn(t))
305 await update($, architect, x => startConsult(normalizeArchitect(x), { id, at: now, moment, via }))
306 return moment
307}
308
309/** The open consult of `id` (else the latest open one) ended now, its advice the last when it gave some. */
310async function closeConsult($: EngineInterface, advice: string | null, id?: string): Promise<void> {
311 const now = await $.clock.now()
312 await update($, architect, x => endConsult(normalizeArchitect(x), now, advice, id))
313}
314
315/** Advice that arrived outside a consult's end; true when it differs from the last. */
316async function storeAdvice($: EngineInterface, advice: string): Promise<boolean> {
317 if (advice === '' || normalizeArchitect(await read($, architect)).lastAdvice === advice) return false
318 await update($, architect, a => ({ ...normalizeArchitect(a), lastAdvice: advice }))
319 return true
320}
321
322/** A spawned subagent's card, newest last; its id no longer counts among the other loops. */
323export async function addAgent($: EngineInterface, card: AgentCard): Promise<void> {
324 await update($, agents, list => addCard(normalizeAgents(list), card))
325 if (normalizeLoops(await read($, loops)).some(l => l.id === card.id)) {
326 await update($, loops, l => normalizeLoops(l).filter(x => x.id !== card.id))
327 }
328}
329
330/** One finished model request of a subagent, as `applyStep` takes it. */
331type AgentStep = Parameters<typeof applyStep>[1]
332
333/**
334 * A subagent's model request ended: its card counts it, or, for a loop no card claims and that is
335 * not the architect's, the other loops do. The card after the step, or null when there is none.
336 */
337export async function noteAgentStep($: EngineInterface, id: string, step: AgentStep): Promise<AgentCard | null> {
338 const card = normalizeAgents(await read($, agents)).find(c => c.id === id)
339 if (card) {
340 await update($, agents, list => normalizeAgents(list).map(c => (c.id === id ? applyStep(c, step) : c)))
341 return applyStep(card, step)
342 }
343 if (normalizeArchitect(await read($, architect)).ids.includes(id)) return null
344 const now = await $.clock.now()
345 await update($, loops, l => stepLoop(normalizeLoops(l), id, now))
346 return null
347}
348
349/**
350 * A subagent's run ended: its card takes the status and the start of the answer, or its loop is
351 * marked done. The finished card, or null when the id has none.
352 */
353export async function finishAgent($: EngineInterface, id: string, reason: string, answer: string): Promise<AgentCard | null> {
354 const now = await $.clock.now()
355 const card = normalizeAgents(await read($, agents)).find(c => c.id === id)
356 if (card) {
357 await update($, agents, list => normalizeAgents(list).map(c => (c.id === id ? finishCard(c, reason, answer, now) : c)))
358 return finishCard(card, reason, answer, now)
359 }
360 if (normalizeLoops(await read($, loops)).some(l => l.id === id)) {
361 await update($, loops, l => finishLoop(normalizeLoops(l), id, now))
362 }
363 return null
364}
365
366/** A subagent's tool call among its card's last three; the card it was noted on, or null when the loop has none. */
367async function noteAgentTool($: EngineInterface, id: string, note: ToolNote): Promise<AgentCard | null> {
368 const card = normalizeAgents(await read($, agents)).find(c => c.id === id)
369 if (!card) return null
370 await update($, agents, list => normalizeAgents(list).map(c => (c.id === id ? noteTool(c, note) : c)))
371 return noteTool(card, note)
372}
373
374/** A card's hotkey: its expanded block opens, or closes when it was the one open. */
375async function toggleExpanded($: EngineInterface, id: string): Promise<void> {
376 await update($, view, v => {
377 const x = normalizeView(v)
378 return { ...x, expanded: x.expanded === id ? null : id }
379 })
380}
381
382// ---------------------------------------------------------------- architect
383//
384// The on-call architect. A consult opens when an architect agent is spawned or a server-side
385// advisor tool shows in the main loop's replies, and closes when that agent's run ends or the
386// tool's result arrives. Its advice comes from the run's answer, from the agent's hand-back tool
387// call, or from the hand-back turn it opens in the main loop.
388
389/** A consult opened: logged at the moment it is inferred at, or plainly with the moments switched off. */
390async function beginConsult($: EngineInterface, cfg: Config, id: string, via: string): Promise<void> {
391 const moment = await openConsult($, id, via)
392 if (moment === null) return
393 await say($, architectWho(cfg), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
394}
395
396/**
397 * The consult `id` (else the latest open one) ended with `report`, its advice its first line. One
398 * already closed is left as it is, and so is advice the log already has.
399 */
400async function finishConsult($: EngineInterface, cfg: Config, report: string | null, id?: string): Promise<void> {
401 const a = await readArchitect($)
402 const isOpen = a.consults.some(c => c.endAt === null && (id === undefined || c.id === id))
403 const advice = report ? adviceLine(report) : ''
404 const isNew = advice !== '' && advice !== a.lastAdvice
405 if (!isOpen && !isNew) return
406 await closeConsult($, advice || null, id)
407 await say($, architectWho(cfg), isNew ? `advice: ${advice}` : 'advice returned', 'consult')
408}
409
410/** A spawned architect agent: followed as a consult under its id, never as a card or a loop. */
411async function architectSpawned($: EngineInterface, cfg: Config, id: string, type: string): Promise<void> {
412 await addArchitectId($, id)
413 await beginConsult($, cfg, id, viaOf(type))
414}
415
416/**
417 * A report handed back by agent `from`: when it is an architect's, its first line is the advice
418 * (logged once however many ways it arrives). False when `from` is no architect.
419 */
420async function handedBack($: EngineInterface, cfg: Config, from: string, report: string): Promise<boolean> {
421 if (!(await isArchitectId($, from))) return false
422 const advice = adviceLine(report)
423 if (await storeAdvice($, advice)) await say($, architectWho(cfg), `advice: ${advice}`, 'consult')
424 return true
425}
426
427/** One row of the model's reply, in order: an advisor call opens a consult, and its result, here or in a later row, closes it. */
428async function readServerTools($: EngineInterface, cfg: Config, content: readonly ApiContentBlock[]): Promise<void> {
429 for (const b of content) {
430 if (isAdvisorUse(cfg, b)) {
431 if (await markSeen($, b.id)) await beginConsult($, cfg, b.id, `${b.name} tool`)
432 } else if (isServerResult(b)) {
433 await finishConsult($, cfg, null, b.tool_use_id)
434 }
435 }
436}
437
438/**
439 * Whether a spawn is the on-call architect, which is followed as a consult instead of as a card:
440 * its type matches the architect pattern, or was remembered from the agent listing as matching.
441 */
442async function isArchitectSpawn($: EngineInterface, cfg: Config, e: AgentSpawnInput): Promise<boolean> {
443 return cfg.architect.test(e.subagentType) || (await isOfferedArchitect($, e.subagentType))
444}
445
446// ---------------------------------------------------------------- hooks
447
448/** The pane's title, as the host frames it. */
449const TITLE = "Sergio's Tech · Code Display"
450/** `/st-code-display`'s answer to arguments it does not take. */
451const USAGE = 'Usage: /st-code-display [open|close|reset|layout auto|compact|wide|mini]'
452
453/** The keys a `tool.call` input carries for the engine beside the tool's own arguments. */
454const CALL_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent'])
455
456/** A call's own arguments, as its permission check reads them: its input without the engine's keys. */
457const toolArgs = (input: object): Record<string, unknown> => Object.fromEntries(Object.entries(input).filter(([k]) => !CALL_KEYS.has(k)))
458
459/** The redraw due when the loops last heard from stop counting as running. */
460let quiet: Timer | null = null
461
462export const register: Register = (on, options) => {
463 const cfg = parseConfig(options)
464
465 // The session's start and end: bring stored state up to date, take a first reading, open the
466 // pane, and start over on `/clear`.
467 on('session.start', async ($, e, next) => {
468 // `/st-code-display` is declared here, as a plugin may hook `session.start` only once.
469 await $.command.register({
470 name: 'st-code-display',
471 description: "Sergio's Tech Code Display: open, close, reset, or set the layout",
472 argumentHint: '[open|close|reset|layout auto|compact|wide|mini]',
473 })
474 await migrate($)
475 await noteSessionStart($)
476 // The host's own reading of the context, cost and rate limits, stored as the first measurement.
477 // A host without usage (headless, an SDK host, a session not yet bound), or one that throws,
478 // just starts without it.
479 let reading: SessionUsage | null = null
480 try {
481 reading = (await $.session.usage()) ?? null
482 } catch {
483 reading = null
484 }
485 if (reading) await applyMeasure($, reading)
486 // Opening can wait on the host's layout; the session does not wait for it. Columns apply when
487 // docked beside the transcript, rows when seated inline above the prompt.
488 if (cfg.openOnStart) void $.ui.open({ id: 'st-code-display', title: TITLE, columns: 66, rows: 8 }).catch(() => undefined)
489 return next(e)
490 })
491
492 on('session.end', async ($, e, next) => {
493 // A `/clear` keeps the process and the pane but not the conversation they described.
494 if (e.reason === 'clear') await resetAll($)
495 return next(e)
496 })
497
498 // The `/st-code-display` command: open, close, reset, or choose the layout.
499 on('command.run', { command: 'st-code-display' }, async ($, e, next) => {
500 const [first = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
501 const verb = first === '' ? 'open' : first
502
503 if (verb === 'close') {
504 await $.ui.close({ id: 'st-code-display' })
505 return { text: 'Dashboard closed.' }
506 }
507 if (verb === 'reset') {
508 await resetAll($)
509 return { text: 'Dashboard reset.' }
510 }
511 if (verb === 'layout') {
512 const layout = LAYOUTS.find(l => l === arg)
513 if (!layout) return { text: USAGE }
514 await setLayout($, layout)
515 const opened = await $.ui.open({ id: 'st-code-display', title: TITLE, columns: 66, rows: 8 })
516 return { text: opened.isPlaced ? `Dashboard layout: ${layout}.` : `Layout set to ${layout}; the dashboard is not shown yet: ${opened.reason}` }
517 }
518 if (verb === 'open') {
519 const opened = await $.ui.open({ id: 'st-code-display', title: TITLE, columns: 66, rows: 8 })
520 if (!opened.isPlaced) return { text: `The dashboard is not shown yet: ${opened.reason}` }
521 return { text: 'Dashboard opened. Focus it with ctrl+x tab; 1-6 expand agents.' }
522 }
523 return { text: USAGE }
524 })
525
526 // Turns in every loop. The main loop's opening text goes into the log (or, for an architect's
527 // hand-back, its advice), its requests are timed as they stream, and its end clears the turn's
528 // tally. A subagent's requests fill its card (or count among the other loops when no card claims
529 // it and it is not the architect's), and its end settles the card, or the architect's consult,
530 // and is logged.
531
532 // The permission mode rides on the person's prompt; read it and pass the event on.
533 on('classic.UserPromptSubmit', async ($, e, next) => {
534 await noteMode($, e.permission_mode)
535 return next(e)
536 })
537
538 on('turn.start', async ($, e, next) => {
539 // A subagent's first prompt is the spawn's, which the agent log lines will cover.
540 if (!e.agentId) {
541 await beginTurn($)
542 if (typeof e.text === 'string' && e.text.trim() !== '') {
543 // A background architect's report reaches the main loop as the text opening this turn. Its
544 // hand-back tool call normally carries the advice first; this is the fallback.
545 const back = handbackOf(e.text)
546 if (!back || !(await handedBack($, cfg, back.from, back.body))) {
547 const cards = await readAgents($)
548 const p = promptLine(e.text, id => cards.find(c => c.id === id))
549 await say($, p.who, p.text, 'info', p.agentId ?? null)
550 }
551 }
552 }
553 return next(e)
554 })
555
556 on('turn.step', async function* ($, e, next) {
557 if (e.agentId) {
558 const id = e.agentId
559 const result = yield* next(e)
560 // The model that answered, as the API reports it, else the one the request named. An
561 // architect's requests belong to its consult: they make neither a card nor a loop.
562 const card = await noteAgentStep($, id, { model: result.usage?.model || e.model, usage: result.usage, stopReason: result.stopReason })
563 if (card && result.stopReason === 'max_tokens') await say($, whoOf(card), 'hit max_tokens', 'error', id, '✗')
564 // A loop no card claims counts as running until LOOP_ACTIVE_MS after its last step, and no event
565 // need come when it falls quiet, so the pane is drawn again then; a later step moves the redraw on.
566 if (!card) {
567 quiet?.cancel()
568 quiet = $.clock.after(LOOP_ACTIVE_MS, () => $.ui.invalidate('ui.render'))
569 }
570 return result
571 }
572 // The main loop's model and effort are known when its request starts, so a long first request does not read `—`.
573 await beginRequest($, e.model, e.effort)
574 // Time the response as it passes: its first chunk (the engine's envelope as a rule), the first
575 // piece that is not the envelope, then the stop chunk. The rate runs from the first chunk, since
576 // thinking that streams no text comes before the first piece and its tokens count. At most three
577 // clock reads in the stream, and nothing stored until it has ended.
578 const sentAt = await $.clock.now()
579 let startAt: number | null = null
580 let firstAt: number | null = null
581 let stopAt: number | null = null
582 const s = next(e)
583 for await (const c of s) {
584 const isFirstPiece = firstAt === null && c.kind !== 'engine' && c.kind !== 'stop'
585 if (startAt === null || isFirstPiece || c.kind === 'stop') {
586 const now = await $.clock.now()
587 startAt ??= now
588 if (isFirstPiece) firstAt = now
589 if (c.kind === 'stop') stopAt = now
590 }
591 yield c
592 }
593 const result = await s.result
594 const speed = result.usage && stopAt !== null ? stepSpeed(sentAt, firstAt, stopAt, result.usage.output_tokens, startAt) : null
595 if (speed) await storeSpeed($, speed)
596 return result
597 })
598
599 on('turn.complete', async ($, e, next) => {
600 const done = await next(e)
601 if (!e.agentId) {
602 await endTurn($)
603 return done
604 }
605 const id = e.agentId
606 // The architect's run ending closes its consult, and its answer is the advice.
607 if (await isArchitectId($, id)) {
608 await finishConsult($, cfg, e.answer, id)
609 return done
610 }
611 const card = await finishAgent($, id, e.reason, e.answer)
612 if (card) {
613 const took = card.spawnedAt > 0 && card.endedAt !== null ? ` · ${fmtDuration(card.endedAt - card.spawnedAt)}` : ''
614 if (card.status === 'done') await say($, whoOf(card), `done${took}`, 'done', id)
615 else if (card.status === 'failed') await say($, whoOf(card), card.status, 'error', id, '✗')
616 else await say($, whoOf(card), card.status, 'info', id)
617 }
618 return done
619 })
620
621 // Subagents as they start: which offered agent types are the architect's, a consult for an
622 // architect's spawn and a card for every other. Their steps and ends are followed by turn.step
623 // and turn.complete, beside the main loop's.
624
625 // The agent listing names each type the model may call; architect types are remembered by name.
626 on('agent.offer', async ($, e, next) => {
627 const offered = await next(e)
628 if (cfg.architect.test(e.agent) || (cfg.matchDescriptions && cfg.architect.test(e.description))) await noteArchitectType($, e.agent)
629 return offered
630 })
631
632 on('agent.spawn', async ($, e, next) => {
633 const started = await next(e)
634 // A refused spawn started nothing, and one without an id has no loop to follow.
635 if (started.deny !== undefined || !started.agentId) return started
636 // A workflow script's agents belong to its run: they show among the other loops as they step.
637 if (e.workflow !== undefined) return started
638 if (await isArchitectSpawn($, cfg, e)) {
639 await architectSpawned($, cfg, started.agentId, e.subagentType)
640 return started
641 }
642 const card = newCard({ id: started.agentId, type: e.name ?? e.subagentType, model: started.model, description: e.description, at: await $.clock.now() })
643 await addAgent($, card)
644 await say($, whoOf(card), `spawned · ${card.type}`, 'info', card.id)
645 return started
646 })
647
648 // A server-side advisor never reaches tool.call: it shows only as blocks of the main loop's replies.
649 on('session.append', async ($, e, next) => {
650 if (!e.agentId && e.message.type === 'assistant' && Array.isArray(e.message.content)) await readServerTools($, cfg, e.message.content)
651 return next(e)
652 })
653
654 // A background agent hands its report back through this tool. No built-in list names it, so it is
655 // matched by pattern, and every other call passes this hook by.
656 on('tool.call', { tool: /^SubagentHandback$/ }, async ($, e, next) => {
657 const ran = await next(e)
658 const message = (e as { message?: unknown }).message
659 if (ran.deny === undefined && e.agentId && typeof message === 'string') await handedBack($, cfg, e.agentId, message)
660 return ran
661 })
662
663 // Tool calls in every loop: counted per tool and per file for TOOLS & FILES, timed when nothing
664 // but the tool ran inside them, added to the turn's tally and to the calling subagent's card, and
665 // logged when they edit, fail or are refused.
666 on('tool.call', async ($, e, next) => {
667 // A hand-back is the SubagentHandback hook's to read, and is no tool work.
668 if (String(e.tool) === 'SubagentHandback') return next(e)
669 // The engine's verdict on the call, asked before the clock is read, so the query is not timed. A
670 // refusal at the call's own check reaches the call as an error result rather than a deny, so the
671 // verdict is the only way to tell it from a tool that failed. A query that fails leaves the
672 // verdict unknown, and the call runs all the same.
673 let verdict: EventResult<'tool.check'>['decision'] | null = null
674 try {
675 verdict = (await $.tool.check({ tool: e.tool, input: toolArgs(e) })).decision
676 } catch {
677 verdict = null
678 }
679 const sentAt = await $.clock.now()
680 const ran = await next(e)
681 const at = await $.clock.now()
682 const isAsked = verdict === 'ask'
683 const isDenied = verdict === 'deny' || ran.deny !== undefined
684 const didRun = !isDenied
685 const hasFailed = didRun && ran.isError === true
686 const inSubagent = Boolean(e.agentId)
687 await recordTool($, { tool: e.tool, input: e, hasFailed, inSubagent, didRun, ms: timedMs(didRun, isAsked, e.tool, at - sentAt), at })
688 // An answered Agent call is its subagent's to report: the card, its turns and its end.
689 if (e.tool === 'Agent' && !isDenied) return ran
690
691 const isEdit = didRun && !hasFailed && EDIT_TOOLS.has(e.tool)
692 await noteTurnCall($, { inSubagent, hasFailed, isEdit })
693 // A call as the log and the cards name it, `Edit → src/main.ts`; each keeps as much as it holds.
694 const text = describeInput(e.tool, e)
695 const card = e.agentId ? await noteAgentTool($, e.agentId, { tool: e.tool, text, isError: hasFailed || isDenied }) : null
696 const who = card ? whoOf(card) : e.agentId ? 'agent' : 'main'
697 const agentId = e.agentId ?? null
698 // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
699 // The marker is stored apart from the text, so a narrow pane cuts the command and keeps it.
700 if (isDenied) {
701 await say($, who, text, 'error', agentId, '· denied')
702 } else if (hasFailed) {
703 await say($, who, text, 'error', agentId, '✗')
704 } else if (isEdit) {
705 await say($, who, text, 'info', agentId)
706 }
707 return ran
708 })
709
710 // The host's measurements of the session: context, cost and rate limits, and compactions.
711 on('session.measure', async ($, e, next) => {
712 await applyMeasure($, e)
713 return next(e)
714 })
715
716 on('session.compact', async ($, e, next) => {
717 const done = await next(e)
718 // A vetoed compaction changed nothing, a precompute installs nothing, and a subagent's is its own.
719 if (!('skip' in done && done.skip !== undefined) && !e.agentId && e.trigger !== 'precompute') {
720 await noteCompaction($)
721 await say($, 'main', `context compacted (${e.trigger})`)
722 }
723 return done
724 })
725
726 // The pane's drawing: one state snapshot, one view model, then the header and the panels in one
727 // column, in two with the log under both, or the inline summary instead of all of them. Off the
728 // terminal each of those blocks is drawn as a picture of its cells.
729 on('ui.render', { component: 'Pane', requestId: 'st-code-display' }, async ($, e, next) => {
730 const els = $.ui.resolve(e)
731 const [s, now] = await Promise.all([loadSnapshot($), $.clock.now()])
732 const isPicture = e.surface !== 'terminal'
733 const vm = buildViewModel(s, {
734 cfg,
735 now,
736 bodyColumns: e.props.bodyColumns,
737 placement: e.props.placement,
738 viewedAgentId: e.props.view?.agentId ?? null,
739 bodyRows: e.props.scroll?.bodyRows ?? e.viewport?.rows,
740 isPicture,
741 })
742 // VS Code's webview takes no client modules, whatever its element table holds.
743 const hasClient = 'Client' in els && e.surface !== 'vscode'
744 const d: Draw = { els, C: COLORS, hasClient, motion: cfg.motion, surface: e.surface, isPicture, now, isIdle: !vm.header.isActive }
745 // An agent's hotkey opens its details inside the AGENTS frame, or closes them.
746 const expand: Expand = id => () => toggleExpanded($, id)
747 const { Box } = els
748 // The OLED black is filled in rather than left to the host's pane colour. Docked, it fills every
749 // body row, not only the rows the panels take: the dark-ansi theme paints the dock bright black.
750 if (!d.isPicture) {
751 const fillRows = e.props.placement === 'dock' ? e.props.scroll?.bodyRows : undefined
752 if (vm.mini) return miniPane(d, vm.mini, vm.width, fillRows)
753 return (
754 <Box flexDirection="column" width={vm.width} minHeight={fillRows} backgroundColor={COLORS.fill}>
755 {header(d, vm.header, vm.width)}
756 {body(d, vm, expand)}
757 </Box>
758 )
759 }
760 // Off the terminal each block is one picture of its cells, beside the hidden hotkey Buttons.
761 const blocks = vm.mini ? [miniPane(d, vm.mini, vm.width)] : [header(d, vm.header, vm.width), ...body(d, vm, expand)]
762 return (
763 <Box flexDirection="column" width={vm.width} backgroundColor={COLORS.fill}>
764 {pictures(d, blocks.filter((b): b is RenderElement => b !== null), vm.width)}
765 </Box>
766 )
767 })
768}
769hooks/config.ts 62 lines1// The plugin's `/config` values, read leniently: anything malformed falls back to its default.
2import type { Layout } from '../types'
3
4export type Panel = 'info' | 'agents' | 'architect' | 'tools' | 'log'
5
6export const PANELS: readonly Panel[] = ['info', 'agents', 'architect', 'tools', 'log']
7
8export type Config = {
9 architect: RegExp
10 matchDescriptions: boolean
11 architectLabel: string
12 panels: Panel[]
13 layout: Layout
14 motion: boolean
15 moments: boolean
16 openOnStart: boolean
17}
18
19const DEFAULT_PATTERN = 'advisor|architect'
20
21// A pattern that does not compile must not take the plugin down: fall back to the default one.
22const safeRegExp = (source: string): RegExp => {
23 try {
24 return new RegExp(source, 'i')
25 } catch {
26 return new RegExp(DEFAULT_PATTERN, 'i')
27 }
28}
29
30const TRUE_WORDS = ['true', 'on', 'yes']
31const FALSE_WORDS = ['false', 'off', 'no']
32
33export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
34 const text = (k: string, d: string): string => {
35 const v = o[k]
36 return typeof v === 'string' && v.trim() !== '' ? v : d
37 }
38 // The settings screen may hand a switch over as a boolean or as a word.
39 const flag = (k: string, d: boolean): boolean => {
40 const v = o[k]
41 if (typeof v === 'boolean') return v
42 if (typeof v !== 'string') return d
43 const w = v.trim().toLowerCase()
44 return TRUE_WORDS.includes(w) ? true : FALSE_WORDS.includes(w) ? false : d
45 }
46 const listed = o.panels
47 const names = (Array.isArray(listed) ? listed : typeof listed === 'string' ? listed.split(',') : [])
48 .map(s => (typeof s === 'string' ? s.trim() : ''))
49 .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
50 const layout = text('layout', 'auto').trim().toLowerCase()
51 return {
52 architect: safeRegExp(text('architectPattern', DEFAULT_PATTERN)),
53 matchDescriptions: flag('matchDescriptions', false),
54 architectLabel: text('architectLabel', 'ARCHITECT'),
55 panels: names.length > 0 ? [...new Set(names)] : [...PANELS],
56 layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
57 motion: flag('motion', true),
58 moments: flag('moments', true),
59 openOnStart: flag('openOnStart', true),
60 }
61}
62hooks/domain/activity.ts 144 lines1// Tools and files: what one tool call adds to the session's tallies, which tools and files lead,
2// and when a call's time may join its tool's average. Pure.
3import type { Activity, FileRow, ToolRow } from '../../types'
4import { redact } from '../format/redact'
5import { fmtDuration, shorten } from '../format/text'
6import { EDIT_TOOLS } from './turn'
7
8/** Distinct tools kept; when full, the one with the fewest calls makes room. */
9export const TOOLS_MAX = 40
10/** Distinct files kept; when full, the one touched least makes room. */
11export const FILES_MAX = 200
12
13/** The tools that read a file or a folder, named by `file_path` or `path`. */
14const READ_TOOLS = new Set(['Read', 'Grep', 'Glob'])
15/** A stored path is cut to this many characters, so a pathological name cannot bloat the list. */
16const PATH_MAX = 120
17
18const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
19const str = (v: unknown): string => (typeof v === 'string' ? v : '')
20
21/** A path's last two segments, `/` and `\` alike, masked first so a cut never leaves half a token. */
22const trimPath = (p: string): string => shorten(redact(p).split(/[\\/]+/).filter(Boolean).slice(-2).join('/'), PATH_MAX)
23
24/** The file a call reads or edits, its path trimmed and masked; null for a call on no file. */
25export const fileOf = (tool: string, input: unknown): { path: string; kind: 'read' | 'edit' } | null => {
26 const i = isRecord(input) ? input : {}
27 const isRead = READ_TOOLS.has(tool)
28 const raw = isRead
29 ? str(i.file_path) || str(i.path)
30 : tool === 'NotebookEdit'
31 ? str(i.notebook_path) || str(i.file_path)
32 : EDIT_TOOLS.has(tool)
33 ? str(i.file_path)
34 : ''
35 const path = raw ? trimPath(raw) : ''
36 return path ? { path, kind: isRead ? 'read' : 'edit' } : null
37}
38
39/**
40 * One finished tool call. `ms` is null when the call is not timed; `didRun` is false for a refused
41 * call, which still counts as a call but touches no file.
42 */
43export type Call = { tool: string; input: unknown; hasFailed: boolean; inSubagent: boolean; ms: number | null; at: number; didRun?: boolean }
44
45/** The entry to drop: the lowest `weight`, a tie going to the oldest `age`, then to the first in the list. */
46const victim = <T>(list: T[], weight: (x: T) => number, age: (x: T) => number): number =>
47 list.reduce((best, x, i) => {
48 const b = list[best]
49 if (b === undefined) return i
50 return weight(x) < weight(b) || (weight(x) === weight(b) && age(x) < age(b)) ? i : best
51 }, 0)
52
53/** `list` with room for one more entry, dropping `victim`'s pick while it is full. */
54const makeRoom = <T>(list: T[], max: number, weight: (x: T) => number, age: (x: T) => number): T[] => {
55 const out = [...list]
56 while (out.length >= max && out.length > 0) out.splice(victim(out, weight, age), 1)
57 return out
58}
59
60const touches = (f: FileRow): number => f.reads + f.edits
61
62/** The tallies after one call: its tool's row, and the row of the file it read or edited. */
63export const recordCall = (a: Activity, c: Call): Activity => {
64 const timed = c.ms !== null && Number.isFinite(c.ms) ? Math.max(0, c.ms) : null
65 const toolRow = (r: ToolRow | undefined): ToolRow => ({
66 tool: c.tool,
67 calls: (r?.calls ?? 0) + 1,
68 errors: (r?.errors ?? 0) + (c.hasFailed ? 1 : 0),
69 timedCalls: (r?.timedCalls ?? 0) + (timed === null ? 0 : 1),
70 timedMs: (r?.timedMs ?? 0) + (timed ?? 0),
71 })
72 const t = a.tools.findIndex(r => r.tool === c.tool)
73 // Tool rows keep no time of their own, so a tie in calls goes to the first in the list.
74 const tools = t >= 0 ? a.tools.map((r, i) => (i === t ? toolRow(r) : r)) : [...makeRoom(a.tools, TOOLS_MAX, r => r.calls, () => 0), toolRow(undefined)]
75
76 // A refused or failed call did not get to the file.
77 const touched = c.didRun === false || c.hasFailed ? null : fileOf(c.tool, c.input)
78 const calls = a.calls + 1
79 if (!touched) return { tools, files: a.files, calls }
80 const fileRow = (r: FileRow | undefined): FileRow => ({
81 path: touched.path,
82 reads: (r?.reads ?? 0) + (touched.kind === 'read' ? 1 : 0),
83 edits: (r?.edits ?? 0) + (touched.kind === 'edit' ? 1 : 0),
84 byMain: (r?.byMain ?? false) || !c.inSubagent,
85 lastAt: c.at,
86 })
87 const f = a.files.findIndex(r => r.path === touched.path)
88 const files = f >= 0 ? a.files.map((r, i) => (i === f ? fileRow(r) : r)) : [...makeRoom(a.files, FILES_MAX, touches, r => r.lastAt), fileRow(undefined)]
89 return { tools, files, calls }
90}
91
92/** The `n` tools with the most calls, most first; a tie keeps the list's order. */
93export const topTools = (a: Activity, n: number): ToolRow[] => [...a.tools].sort((x, y) => y.calls - x.calls).slice(0, Math.max(0, n))
94
95/** The `n` files touched most, most first; a tie goes to the one touched last. */
96export const hotFiles = (a: Activity, n: number): FileRow[] =>
97 [...a.files].sort((x, y) => touches(y) - touches(x) || y.lastAt - x.lastAt).slice(0, Math.max(0, n))
98
99/** Every call this session, counted as it came, so tool rows evicted since still count. */
100export const totalCalls = (a: Activity): number => a.calls
101
102/** A tool's average time over its timed calls; null while none was timed. */
103export const avgMs = (r: ToolRow): number | null => (r.timedCalls > 0 ? r.timedMs / r.timedCalls : null)
104
105/**
106 * A call's time for its tool's average, or null when it is not timed. The clock is read around the
107 * whole call, so a call that waited on an approval prompt, one refused, and `Agent` (which runs a
108 * foreground subagent to its end) would measure something other than the tool.
109 */
110export const timedMs = (didRun: boolean, wasAsked: boolean, tool: string, elapsed: number): number | null =>
111 didRun && !wasAsked && tool !== 'Agent' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : null
112
113/** An average time in the table's column: `40ms` under 100 ms, `1.5s` under a minute, `1m05s` past it, `—` when untimed. */
114export const avgShort = (ms: number | null): string => {
115 if (ms === null || !Number.isFinite(ms)) return '—'
116 if (ms < 100) return `${Math.round(ms)}ms`
117 return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : fmtDuration(ms)
118}
119
120/** `avg* 0.4s`, `avg* 45ms`, `avg* 1m05s`, or `avg* —` with nothing timed; the `*` says it is inferred. */
121export const avgLabel = (ms: number | null): string => `avg* ${avgShort(ms)}`
122
123/** The kind of work a tool does, which picks its glyph in TOOLS & FILES. */
124export type ToolFamily = 'read' | 'edit' | 'run' | 'agent' | 'other'
125
126const FAMILY_TOOLS: Record<Exclude<ToolFamily, 'other'>, ReadonlySet<string>> = {
127 read: new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch']),
128 edit: new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']),
129 run: new Set(['Bash', 'BashOutput', 'KillShell', 'PowerShell', 'Monitor']),
130 agent: new Set(['Agent', 'Task']),
131}
132
133/** The family of a tool; anything not named above is `other`. */
134export const toolFamily = (tool: string): ToolFamily => {
135 for (const f of ['read', 'edit', 'run', 'agent'] as const) if (FAMILY_TOOLS[f].has(tool)) return f
136 return 'other'
137}
138
139/** One cell per family: read `›`, edit `✎`, run `▸`, agent `●`, other `·`. */
140export const FAMILY_GLYPH: Record<ToolFamily, string> = { read: '›', edit: '✎', run: '▸', agent: '●', other: '·' }
141
142/** A tool as the table names it: `mcp__server__tool` reads `server:tool`. */
143export const toolName = (tool: string): string => (tool.startsWith('mcp__') ? tool.split('__').slice(1).join(':') : tool)
144hooks/domain/agents.ts 184 lines1// Subagent cards and the other model loops: what a spawn, a step, a tool call and an end do to
2// them, the order of the AGENTS list, the shared time axis of the desktop's picture, and how a
3// title fits a row. Pure.
4import type { AgentCard, AgentStatus, Loop, ToolNote } from '../../types'
5import { redact } from '../format/redact'
6import { modelFamily, prettyModel, shorten } from '../format/text'
7
8/** Cards kept; the oldest goes first. */
9export const AGENTS_MAX = 24
10/** Other loops kept; the one heard from longest ago goes first. */
11export const LOOPS_MAX = 60
12/** A loop that stepped this recently, and has not ended, counts as active. */
13export const LOOP_ACTIVE_MS = 15_000
14
15/** Tool calls a card remembers, newest last. */
16const TOOLS_KEPT = 3
17const DESCRIPTION_MAX = 400
18const ANSWER_MAX = 400
19const NOTE_MAX = 120
20/** Rows the AGENTS list draws, one per hotkey 1 to 6. */
21export const LIST_SHOWN = 6
22
23/** The state glyph beside every status, so a status is never told by colour alone. */
24export const AGENT_GLYPH: Record<AgentStatus, string> = { running: '●', done: '✓', failed: '✗', stopped: '■' }
25
26/** The agent failed, or ended after a request was cut at `max_tokens`. A running one is still just running. */
27export const isCut = (c: AgentCard): boolean => c.status === 'failed' || (c.status !== 'running' && c.lastStop === 'max_tokens')
28
29/** The glyph of a row: `✗` for a cut agent, else its status glyph. */
30export const agentGlyph = (c: AgentCard): string => (isCut(c) ? '✗' : AGENT_GLYPH[c.status])
31
32// ---------------------------------------------------------------- terminal cells
33
34/** Code points a terminal draws two cells wide: CJK, Hangul, full-width forms and emoji. */
35const WIDE: readonly [number, number][] = [
36 [0x1100, 0x115f],
37 [0x2e80, 0xa4cf],
38 [0xac00, 0xd7a3],
39 [0xf900, 0xfaff],
40 [0xfe30, 0xfe4f],
41 [0xff00, 0xff60],
42 [0xffe0, 0xffe6],
43 [0x1f000, 0x1faff],
44 [0x20000, 0x3fffd],
45]
46
47const cellsOf = (ch: string): number => {
48 const cp = ch.codePointAt(0) ?? 0
49 if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || cp === 0xfe0e || cp === 0xfe0f) return 0
50 return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
51}
52
53/** One line: newlines, tabs and other control characters become single spaces. */
54const flat = (s: unknown): string => (typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f\s]+/g, ' ').trim() : '')
55
56/** Cells `s` takes on a terminal. */
57export const cellCount = (s: string): number => Array.from(s).reduce((n, ch) => n + cellsOf(ch), 0)
58
59/** `s` on one row of at most `n` terminal cells, ending in `…` when it had to be cut. */
60export const fitCells = (s: string, n: number): string => {
61 const one = flat(s)
62 if (n <= 0) return ''
63 if (cellCount(one) <= n) return one
64 let out = ''
65 let used = 0
66 for (const ch of one) {
67 const w = cellsOf(ch)
68 if (used + w > n - 1) break
69 out += ch
70 used += w
71 }
72 return `${out.trimEnd()}…`
73}
74
75// ---------------------------------------------------------------- cards
76
77/** A card for a subagent that has just started. Its task is masked for credentials before it is kept. */
78export const newCard = (p: { id: string; type: string; model: string; description: string; at: number }): AgentCard => ({
79 id: p.id,
80 type: flat(p.type) || 'agent',
81 model: typeof p.model === 'string' ? p.model : '',
82 description: shorten(redact(typeof p.description === 'string' ? p.description : ''), DESCRIPTION_MAX),
83 status: 'running',
84 spawnedAt: p.at,
85 endedAt: null,
86 ctx: 0,
87 out: 0,
88 steps: 0,
89 lastStop: null,
90 tools: [],
91 answer: '',
92})
93
94const count = (v: number | undefined): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
95
96/**
97 * The card after one model request: `ctx` is that request's whole input (uncached, cache read and
98 * cache written), `out` adds up across requests. A request with no usage still counts as a step.
99 */
100export const applyStep = (
101 c: AgentCard,
102 s: {
103 model: string
104 usage: { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
105 stopReason: string | null
106 },
107): AgentCard => {
108 const u = s.usage
109 return {
110 ...c,
111 model: typeof s.model === 'string' && s.model !== '' ? s.model : c.model,
112 steps: c.steps + 1,
113 ctx: u ? count(u.input_tokens) + count(u.cache_read_input_tokens) + count(u.cache_creation_input_tokens) : c.ctx,
114 out: c.out + (u ? count(u.output_tokens) : 0),
115 lastStop: s.stopReason ?? c.lastStop,
116 }
117}
118
119/** The card with one more tool call remembered, the last three kept, its text masked and cut. */
120export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({
121 ...c,
122 tools: [...c.tools, { ...n, text: shorten(redact(n.text), NOTE_MAX) }].slice(-TOOLS_KEPT),
123})
124
125/** The card once its run ended: `answer` is done, `aborted` is stopped, any other reason failed. */
126export const finishCard = (c: AgentCard, reason: string, answer: string, at: number): AgentCard => ({
127 ...c,
128 status: reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed',
129 endedAt: at,
130 answer: shorten(redact(typeof answer === 'string' ? answer : ''), ANSWER_MAX),
131})
132
133/** `list` with `c` last, replacing any card of its id, kept to the newest AGENTS_MAX. */
134export const addCard = (list: AgentCard[], c: AgentCard): AgentCard[] => [...list.filter(x => x.id !== c.id), c].slice(-AGENTS_MAX)
135
136/** What a card is called: its task on one line, or its type when it was given none. */
137export const cardTitle = (c: AgentCard): string => flat(c.description) || c.type || 'agent'
138
139/** A card as the log names it, within the log's source column. */
140export const whoOf = (c: AgentCard): string => fitCells(cardTitle(c), 11)
141
142/** The cards in list order: running ones newest first, then the rest newest first; newest is later in the stored list. */
143export const listOrder = (cards: AgentCard[]): AgentCard[] => [...cards.filter(c => c.status === 'running').reverse(), ...cards.filter(c => c.status !== 'running').reverse()]
144
145/** The model's family name (`Haiku`) when the card runs on another model than `mainModel`, else empty. */
146export const modelTag = (c: AgentCard, mainModel: string): string => (c.model && prettyModel(c.model) !== mainModel ? modelFamily(c.model) : '')
147
148/**
149 * Each card's lane on one shared axis, `width` cells from the earliest known spawn to now: the
150 * cells before it started, its bar (at least one cell) and the cells after it ended.
151 */
152export const lanes = (cards: AgentCard[], now: number, width: number): { id: string; before: number; bar: number; after: number }[] => {
153 const w = Math.max(1, Math.floor(Number.isFinite(width) ? width : 1))
154 const known = cards.map(c => c.spawnedAt).filter(t => t > 0)
155 const start = known.length > 0 ? Math.min(...known) : now
156 const end = Math.max(now, start, ...cards.map(c => c.endedAt ?? 0))
157 const span = Math.max(1, end - start)
158 const cell = (t: number) => Math.round(((Math.min(Math.max(t, start), end) - start) / span) * w)
159 return cards.map(c => {
160 const before = Math.min(w - 1, cell(c.spawnedAt > 0 ? c.spawnedAt : start))
161 const bar = Math.max(1, Math.min(w - before, cell(c.endedAt ?? now) - before))
162 return { id: c.id, before, bar, after: w - before - bar }
163 })
164}
165
166// ---------------------------------------------------------------- other loops
167
168/** One more model request in a loop no card claims; a loop first heard from is added. */
169export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
170 if (loops.some(l => l.id === id)) return loops.map(l => (l.id === id ? { ...l, steps: l.steps + 1, lastAt: at, isDone: false } : l))
171 const list = [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
172 while (list.length > LOOPS_MAX) {
173 // Full: the loop heard from longest ago makes room, the first of a tie.
174 const oldest = list.reduce((o, l, i) => (l.lastAt < (list[o]?.lastAt ?? Number.POSITIVE_INFINITY) ? i : o), 0)
175 list.splice(oldest, 1)
176 }
177 return list
178}
179
180/** A loop's run ended. */
181export const finishLoop = (loops: Loop[], id: string, at: number): Loop[] => loops.map(l => (l.id === id ? { ...l, isDone: true, lastAt: at } : l))
182
183export const isLoopActive = (l: Loop, now: number): boolean => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
184hooks/domain/architect.ts 115 lines1// The on-call architect: the consults it is asked for, the moment each one is inferred at, the
2// one-row timeline they share, the reply blocks a server-side advisor shows in, and the advice a
3// report leaves. Pure.
4import type { ApiContentBlock } from 'claude-code'
5
6import type { Architect, Moment, Turn } from '../../types'
7import type { Config } from '../config'
8import { redact } from '../format/redact'
9import { fmtDuration, shorten } from '../format/text'
10
11/** Consults kept; the oldest goes first. */
12export const CONSULTS_MAX = 40
13/** Architect agent ids kept. */
14export const IDS_MAX = 40
15/** Server tool calls remembered, so a row read twice opens no second consult. */
16export const SEEN_MAX = 60
17const ADVICE_MAX = 160
18
19/** The moments a consult is inferred at, in the order the panel lists them. */
20export const MOMENTS: readonly Moment[] = ['before a plan', 'error repeats', 'before done']
21
22/** The log's name for the architect: its label, as a source column reads. */
23export const whoOf = (cfg: Config): string => cfg.architectLabel.toLowerCase()
24
25/** An agent type as a consult names it: `fable-advisor:fable-advisor` is `fable-advisor`. */
26export const viaOf = (type: string): string => type.split(':').pop() || 'agent'
27
28/** When in the turn a consult was asked: errors repeating, nothing edited yet, or after edits. */
29export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
30 t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
31
32/** A consult opened; one per id however often it is reported, the newest CONSULTS_MAX kept. */
33export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
34 a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-CONSULTS_MAX) }
35
36/** The open consult of `id` (any id when none is given, the latest first) ended at `at`; advice, when there is some, is the last. */
37export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
38 const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
39 return {
40 ...a,
41 consults: open ? a.consults.map(c => (c === open ? { ...c, endAt: at } : c)) : a.consults,
42 lastAdvice: advice ? advice : a.lastAdvice,
43 }
44}
45
46export const isAdvising = (a: Architect): boolean => a.consults.some(c => c.endAt === null)
47
48/** Whether an architect has been seen at all; the panel, the header words and the status field show from then. */
49export const hasArchitect = (a: Architect): boolean => a.consults.length > 0 || a.ids.length > 0
50
51/**
52 * Every consult on one row of `width` cells, from the first consult to now: `◆` where one started,
53 * `━` while it ran, `─` for the rest. Starts are drawn last, so a later run never hides one.
54 */
55export const consultTimeline = (a: Architect, now: number, width: number): string => {
56 const w = Math.max(0, Math.floor(Number.isFinite(width) ? width : 0))
57 if (a.consults.length === 0 || w < 4) return '─'.repeat(w)
58 const first = Math.min(...a.consults.map(c => c.at))
59 const span = Math.max(1, now - first)
60 const cell = (t: number) => Math.max(0, Math.min(w - 1, Math.floor(((t - first) / span) * (w - 1))))
61 const cells = Array.from({ length: w }, () => '─')
62 for (const c of a.consults) for (let i = cell(c.at) + 1; i <= cell(c.endAt ?? now); i++) cells[i] = '━'
63 for (const c of a.consults) cells[cell(c.at)] = '◆'
64 return cells.join('')
65}
66
67/** Elapsed time as the panel says how long ago: `42s`, `2m`, `1h05m`. */
68const agoOf = (ms: number): string => (ms >= 60_000 && ms < 3_600_000 ? `${Math.floor(ms / 60_000)}m` : fmtDuration(ms))
69
70/** `consulting for 42s` while one is open, `last 2m ago · took 14s` after, or `not consulted yet`. */
71export const consultLine = (a: Architect, now: number): string => {
72 const open = [...a.consults].reverse().find(c => c.endAt === null)
73 if (open) return `consulting for ${agoOf(now - open.at)}`
74 const last = a.consults[a.consults.length - 1]
75 if (!last) return 'not consulted yet'
76 const end = last.endAt ?? last.at
77 return `last ${agoOf(now - end)} ago · took ${fmtDuration(end - last.at)}`
78}
79
80/** A server-side advisor's call in a reply: a server tool use with an id, named as the architect pattern matches. */
81export const isAdvisorUse = (cfg: Config, b: ApiContentBlock): b is ApiContentBlock & { id: string; name: string } =>
82 b.type === 'server_tool_use' && typeof b.id === 'string' && b.id !== '' && typeof b.name === 'string' && cfg.architect.test(b.name)
83
84/** A server tool's result in a reply, naming the call it answers. */
85export const isServerResult = (b: ApiContentBlock): b is ApiContentBlock & { tool_use_id: string } =>
86 typeof b.type === 'string' && b.type.endsWith('_tool_result') && typeof b.tool_use_id === 'string'
87
88/** The first line with words in it, past the engine's bracketed notes and tags. */
89const firstLine = (s: string): string =>
90 s
91 .split('\n')
92 .map(l => l.trim())
93 .find(l => l !== '' && !l.startsWith('[') && !l.startsWith('<')) ?? ''
94
95/**
96 * A background agent's report as it opens a main-loop turn, `<agent-message from="id">` then the
97 * engine's preamble and the report: whose it is and the report's first line, or null for any other text.
98 */
99export const handbackOf = (text: string): { from: string; body: string } | null => {
100 if (typeof text !== 'string') return null
101 const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
102 if (!from) return null
103 // The preamble ends at `The report follows:`; without one the report starts after the tag.
104 const rest = text.split(/The report follows:\s*\n/)[1] ?? text.replace(/^\s*<agent-message[^>]*>/, '')
105 const body = firstLine(rest)
106 return body ? { from, body } : null
107}
108
109/**
110 * The advice a report leaves: its first real line without markdown emphasis or a leading heading,
111 * quote or bullet, credentials masked before it is cut to 160.
112 */
113export const adviceLine = (report: string): string =>
114 typeof report === 'string' ? shorten(redact(firstLine(report).replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, '')), ADVICE_MAX) : ''
115hooks/domain/log.ts 69 lines1// The session log: what a line says and how many fit. Pure, so it is tested without an engine.
2import type { AgentCard, LogLine, Session } from '../../types'
3import { redact } from '../format/redact'
4import { plural, shorten } from '../format/text'
5import { whoOf } from './agents'
6
7/** The log keeps this many lines; older ones are dropped as new ones arrive. */
8export const LOG_MAX = 60
9
10/** A line keeps this many characters of its text; the panel cuts it again to the pane's width. */
11export const LINE_MAX = 400
12
13/** `list` with `line` last, kept to the newest LOG_MAX. */
14export const appendLog = (list: LogLine[], line: LogLine): LogLine[] => [...list, line].slice(-LOG_MAX)
15
16/**
17 * When the session started, which the log's times count from: the stored start, else (a state
18 * stored before it was kept) the earliest line logged; null with neither.
19 */
20export const sessionStart = (session: Session, lines: LogLine[]): number | null => {
21 if (session.startedAt !== null) return session.startedAt
22 const ats = lines.map(l => l.at).filter(at => at > 0)
23 return ats.length > 0 ? Math.min(...ats) : null
24}
25
26/** A paste in a prompt as the host wraps it. Only its own id closes it, so a wrapper escaped inside it (`<\pasted_content`) does not. */
27const PASTE = /<pasted_content id="([^"]*)">\n?([\s\S]*?)\n?<\/pasted_content id="\1">/g
28
29/** `text` with each paste as `[pasted N lines]`: the person's words stay, what they pasted does not. */
30const collapsePastes = (text: string): string => text.replace(PASTE, (_all, _id, body: string) => `[pasted ${plural(body.split('\n').length, 'line')}]`)
31
32/**
33 * What a turn's opening text was, for the log: the person's words, each paste in them as a marker,
34 * or for a turn the engine opened with a tagged message (a subagent's hand-back, a task
35 * notification), that message's kind. A message from an agent `cardOf` knows is logged under that
36 * agent's title and id; any other names the start of its sender's id. Credentials are masked before
37 * the text is cut, so a cut never leaves half a token showing.
38 */
39export const promptLine = (text: string, cardOf: (id: string) => AgentCard | undefined = () => undefined): { who: string; text: string; agentId?: string } => {
40 // A prompt that is only a paste opens with the paste's wrapper, which is not an engine tag.
41 const said = collapsePastes(text)
42 const tag = /^\s*<([a-z][\w-]*)/i.exec(said)?.[1]
43 if (!tag) return { who: 'you', text: shorten(redact(said), LINE_MAX) }
44 const kind = tag.replace(/[-_]/g, ' ')
45 const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
46 const card = from ? cardOf(from) : undefined
47 if (card) return { who: whoOf(card), text: shorten(kind, LINE_MAX), agentId: card.id }
48 return { who: 'engine', text: shorten(`${kind}${from ? ` from ${from.slice(0, 8)}` : ''}`, LINE_MAX) }
49}
50
51/** The rows a pane that reports no height is taken to have. */
52const ASSUMED_ROWS = 40
53
54/**
55 * The rows the log's body takes. Docked with a known height it is every row the other panels leave
56 * of `bodyRows` after the `used` rows and the log's own frame and gap (3), uncapped, so the log's
57 * bottom border lands on the pane's last row; with room for fewer than 2 it is 0 and the log is
58 * not drawn, so its frame never runs past the pane's bottom edge. Inline, where `bodyRows` may be
59 * the whole terminal, it is at most 8, so an inline pane never stretches to the terminal's height.
60 * A pane of unknown height is taken to have 40 rows, and its log keeps between 4 and 8.
61 */
62export const logRows = (bodyRows: number | undefined, used: number, isDocked: boolean): number => {
63 const isKnown = bodyRows !== undefined && Number.isFinite(bodyRows)
64 const room = Number.isFinite(used) ? Math.floor((isKnown ? bodyRows : ASSUMED_ROWS) - used - 3) : 4
65 if (!isKnown) return Math.max(4, Math.min(8, room))
66 if (room < 2) return 0
67 return isDocked ? room : Math.min(8, room)
68}
69hooks/domain/turn.ts 15 lines1// The turn: what one tool call adds to its tally. Pure.
2import type { Turn } from '../../types'
3
4/** The tools that change files. */
5export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
6
7/**
8 * The turn after one tool call. The error streak counts from the main loop only, as a subagent's
9 * failure is its own; edits count from every loop, so delegated work still counts.
10 */
11export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
12 errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
13 edits: t.edits + (c.isEdit ? 1 : 0),
14})
15hooks/domain/vitals.ts 65 lines1// The main model's vitals: how fast a request ran, the effort scale, and the usage figures a host
2// reports. Pure, so every host shape (no cost, no limits, no context percent) is tested without an engine.
3import type { Limit, Speed, Usage } from '../../types'
4import { parseIso } from '../format/text'
5
6/** Effort words on the four-step scale the gauge draws; `max` shares the top step with `xhigh`. */
7export const EFFORT_LEVEL: Record<string, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }
8
9/** Steps of the effort gauge for a stored effort word; 0 for a word the scale does not know. */
10export const effortLevel = (effort: string): number => (Object.hasOwn(EFFORT_LEVEL, effort) ? (EFFORT_LEVEL[effort] ?? 0) : 0)
11
12const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
13
14/** The shortest window a rate is read over: a quicker one times pieces arriving together, not the model's pace. */
15const MIN_DECODE_MS = 200
16/** Faster than any model streams: a window this quick for its tokens timed their delivery, not their making. */
17const MAX_TOK_PER_S = 1000
18
19/**
20 * A request's speed from four clock readings: the time to the first piece of the answer, and the
21 * output tokens per second from the response's start to the stop. The start is its first chunk of
22 * any kind (the envelope), since thinking that streams no text comes before the first piece yet
23 * counts among the output tokens; with no start read, the first piece is the start. Unknown without
24 * a first piece; the rate is unknown without output tokens, and when the window is too short, or too
25 * quick for its tokens, to be the model's pace: the response came in one burst and cannot be timed.
26 */
27export const stepSpeed = (sentAt: number, firstAt: number | null, stopAt: number, outTokens: number | null, startAt: number | null = firstAt): Speed | null => {
28 if (firstAt === null || !finite(sentAt) || !finite(firstAt) || !finite(stopAt)) return null
29 const decodeMs = stopAt - (finite(startAt) ? Math.min(startAt, firstAt) : firstAt)
30 const rate = finite(outTokens) && outTokens > 0 && decodeMs >= MIN_DECODE_MS ? (outTokens * 1000) / decodeMs : null
31 return { ttftMs: Math.max(0, firstAt - sentAt), tokPerS: rate !== null && rate <= MAX_TOK_PER_S ? rate : null }
32}
33
34/** A rate-limit reading as the pane keeps it, its ISO reset time turned into milliseconds since the epoch. */
35export const limitOf = (r: { kind: string; percentUsed: number; resetsAt?: string }): Limit => {
36 const at = typeof r.resetsAt === 'string' && r.resetsAt !== '' ? parseIso(r.resetsAt) : Number.NaN
37 return { kind: typeof r.kind === 'string' ? r.kind : '', pct: finite(r.percentUsed) ? r.percentUsed : 0, resetsAt: Number.isFinite(at) ? at : null }
38}
39
40/** A host reports a handful of windows at most; the pane keeps a bounded few. */
41const LIMITS_MAX = 8
42
43type Reading = {
44 context: { percent?: number | null; tokens?: number | null; window: number }
45 cost?: { usd?: number } | null
46 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
47}
48
49/**
50 * The stored usage after a host reading. Whatever the host leaves out (no context percent, no cost,
51 * no windows) is stored as unknown, never as zero; the compaction count and time carry over.
52 */
53export const usageFrom = (reading: Reading, prev: Usage): Usage => {
54 const limits = Array.isArray(reading.rateLimits) ? reading.rateLimits : []
55 return {
56 pct: finite(reading.context?.percent) ? reading.context.percent : null,
57 tokens: finite(reading.context?.tokens) ? reading.context.tokens : null,
58 window: finite(reading.context?.window) ? reading.context.window : prev.window,
59 costUsd: finite(reading.cost?.usd) ? reading.cost.usd : null,
60 limits: limits.slice(0, LIMITS_MAX).map(limitOf),
61 compactions: prev.compactions,
62 lastCompactAt: prev.lastCompactAt,
63 }
64}
65hooks/format/redact.ts 38 lines1// Credential masking. Everything the pane stores about a tool call passes through here first.
2
3const SECRETS: [RegExp, string][] = [
4 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?[^\s"']+/gi, '$1$2•••'],
5 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
6 [/\b(?:sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
7 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s"']+)/gi, '$1•••'],
8 [/(--(?:token|password|api-key|secret)[= ])[^\s"']+/gi, '$1•••'],
9 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)[^\s"']+/g, '$1•••'],
10 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
11]
12
13/** `s` with credentials masked: auth headers, bearer and vendor tokens, key/secret assignments, URL passwords. */
14export const redact = (s: string): string => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
15
16const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
17
18/**
19 * One line saying what a call was about, `<tool> → <command|path|pattern|url|description>`, redacted.
20 * A path keeps only its last two segments, split on `/` or `\`.
21 */
22export const describeInput = (tool: string, input: unknown): string => {
23 const i = isRecord(input) ? input : {}
24 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
25 const what =
26 typeof i.command === 'string'
27 ? i.command
28 : path ||
29 (typeof i.pattern === 'string'
30 ? i.pattern
31 : typeof i.url === 'string'
32 ? i.url
33 : typeof i.description === 'string'
34 ? i.description
35 : '')
36 return redact(what ? `${tool} → ${what}` : tool)
37}
38hooks/format/text.ts 166 lines1import type { Speed } from '../../types'
2
3// Pure text and number formatting for the pane. Nothing here touches state or the host.
4
5const cap = (w: string) => w.charAt(0).toUpperCase() + w.slice(1)
6
7/** Code points of `s`, so a cut never splits an emoji's surrogate pair. */
8const points = (s: string): string[] => Array.from(s)
9
10/** One line of text collapsed to single spaces, with control characters treated as spaces. */
11const oneLine = (s: unknown): string =>
12 typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f\s]+/g, ' ').trim() : ''
13
14/**
15 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` is `Opus 5.5 1M`,
16 * `claude-sonnet-4-5@20250929` is `Sonnet 4.5`, `claude-3-5-haiku-20241022` is
17 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters; an empty id is `—`.
18 */
19export const prettyModel = (id: string): string => {
20 if (!id) return '—'
21 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
22 const named = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?!\d)/i.exec(id)
23 if (named?.[1]) return `${cap(named[1].toLowerCase())} ${named[2]}${named[3] ? `.${named[3]}` : ''}${big}`
24 const legacy = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
25 if (legacy?.[3]) return `${cap(legacy[3].toLowerCase())} ${legacy[1]}${legacy[2] ? `.${legacy[2]}` : ''}${big}`
26 return shorten(id, 22)
27}
28
29/** The family word of a model id, the first word of `prettyModel`: `claude-haiku-4-5` is `Haiku`; empty for an empty id. */
30export const modelFamily = (id: string): string => (id ? (prettyModel(id).split(' ')[0] ?? '') : '')
31
32/** One row whatever the input: whitespace and newlines collapse, and anything past `n` ends in `…`. */
33export const shorten = (s: string, n: number): string => {
34 const one = oneLine(s)
35 if (n <= 0) return ''
36 const chars = points(one)
37 return chars.length > n ? `${chars.slice(0, n - 1).join('').trimEnd()}…` : one
38}
39
40export const kTokens = (n: number): string =>
41 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
42
43const pad2 = (n: number) => String(n).padStart(2, '0')
44
45/** An elapsed time in whole seconds, as the running clock counts them: `42s`, `1m42s`, `1h05m`. */
46export const fmtDuration = (ms: number): string => {
47 const s = Math.max(0, Math.floor(ms / 1000))
48 if (s < 60) return `${s}s`
49 const m = Math.floor(s / 60)
50 return m < 60 ? `${m}m${pad2(s % 60)}s` : `${Math.floor(m / 60)}h${pad2(m % 60)}m`
51}
52
53/** A running clock as the live cards draw it: `m:ss`, or `XhYY` past an hour. */
54export const fmtTimer = (ms: number): string => {
55 const s = Math.max(0, Math.floor(ms / 1000))
56 const m = Math.floor(s / 60)
57 return m < 60 ? `${m}:${pad2(s % 60)}` : `${Math.floor(m / 60)}h${pad2(m % 60)}`
58}
59
60/** How far into the session a moment is, as the LOG reads it: `m:ss`, or `h:mm:ss` past an hour. */
61export const fmtSession = (ms: number): string => {
62 const s = Math.max(0, Math.floor(ms / 1000))
63 const m = Math.floor(s / 60)
64 return m < 60 ? `${m}:${pad2(s % 60)}` : `${Math.floor(m / 60)}:${pad2(m % 60)}:${pad2(s % 60)}`
65}
66
67const ISO_TIME = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.(\d+))?(?:(Z)|([+-])(\d\d):(\d\d))$/
68
69/**
70 * An ISO 8601 time, `2026-10-23T12:00:00Z`, `…00.5+02:00`, as milliseconds since the epoch, worked out
71 * by plain arithmetic. The offset is required (`Z` or `±hh:mm`); NaN for anything else, or a field out of range.
72 */
73export const parseIso = (s: unknown): number => {
74 const m = typeof s === 'string' ? ISO_TIME.exec(s) : null
75 if (!m) return Number.NaN
76 const [year, month, day, hour, min, sec] = m.slice(1, 7).map(Number) as [number, number, number, number, number, number]
77 const ms = Number((m[7] ?? '').padEnd(3, '0').slice(0, 3))
78 const offH = Number(m[10] ?? 0)
79 const offM = Number(m[11] ?? 0)
80 const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
81 const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0
82 if (day < 1 || day > monthDays || hour > 23 || min > 59 || sec > 59 || offH > 23 || offM > 59) return Number.NaN
83 // Days since 1970-01-01 from the civil date, counting years from March so the leap day ends the year.
84 const y = month <= 2 ? year - 1 : year
85 const era = Math.floor(y / 400)
86 const yoe = y - era * 400
87 const doy = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1
88 const days = era * 146_097 + yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy - 719_468
89 const offset = (m[9] === '-' ? -1 : 1) * (offH * 60 + offM)
90 return ((days * 24 + hour) * 60 + min - offset) * 60_000 + sec * 1000 + ms
91}
92
93/** `1 error`, `2 errors`. */
94export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
95
96export const fmtUsd = (n: number): string => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
97
98/** `ttft 0.8s · 42 tok/s`; `— tok/s` when the rate could not be timed. */
99export const fmtSpeed = (s: Speed): string => `ttft ${(s.ttftMs / 1000).toFixed(1)}s · ${s.tokPerS !== null ? Math.round(s.tokPerS) : '—'} tok/s`
100
101/** Time left until a window resets, its two largest units: `25m`, `3h10m`, `4d2h`; empty when unknown or past. */
102export const fmtResetIn = (resetsAt: number | null, now: number): string => {
103 if (resetsAt === null || resetsAt === undefined || !(resetsAt > now)) return ''
104 const m = Math.max(1, Math.floor((resetsAt - now) / 60_000))
105 const d = Math.floor(m / 1440)
106 const h = Math.floor((m % 1440) / 60)
107 if (d > 0) return `${d}d${h}h`
108 return h > 0 ? `${h}h${pad2(m % 60)}m` : `${m}m`
109}
110
111/** A rate-limit window's short name: `five_hour` is `5h`, `seven_day_opus` is `7d opus`. */
112export const limitLabel = (kind: string): string =>
113 kind
114 .replace(/five[_ -]?hours?/i, '5h')
115 .replace(/seven[_ -]?days?/i, '7d')
116 .replace(/[_-]+/g, ' ')
117 .trim()
118
119/** A gauge of `width` cells: `on` is the filled run of ▰, `off` the empty run of ▱. */
120export const gauge = (pct: number, width: number): { on: string; off: string } => {
121 const w = Math.max(0, Math.floor(width))
122 const full = Number.isFinite(pct) ? Math.max(0, Math.min(w, Math.round((pct / 100) * w))) : 0
123 return { on: '▰'.repeat(full), off: '▱'.repeat(w - full) }
124}
125
126/**
127 * `s` broken at word boundaries into at most `maxRows` rows of at most `width` cells. A word longer than
128 * a row is cut across rows. When words remain after the last allowed row, that row takes the words that
129 * fit in `width - 1` and ends with `…`.
130 */
131export const wrapText = (s: string, width: number, maxRows: number): string[] => {
132 const w = Math.floor(width)
133 if (w <= 0 || maxRows <= 0) return []
134 const words = oneLine(s).split(' ').filter(Boolean)
135 const rows: string[] = []
136 let i = 0
137 while (i < words.length) {
138 if (rows.length === maxRows - 1) {
139 // The last allowed row: everything left if it fits, else the words that fit beside a closing `…`.
140 const rest = words.slice(i)
141 const all = rest.join(' ')
142 if (points(all).length <= w) return [...rows, all]
143 let take = ''
144 for (const r of rest) {
145 const next = take ? `${take} ${r}` : r
146 if (points(next).length > w - 1) break
147 take = next
148 }
149 return [...rows, `${take || points(rest[0] ?? '').slice(0, w - 1).join('')}…`]
150 }
151 const head = words[i] ?? ''
152 if (points(head).length > w) {
153 // A word wider than a row: this row takes its head, and the tail starts the next row.
154 const chars = points(head)
155 rows.push(chars.slice(0, w).join(''))
156 words[i] = chars.slice(w).join('')
157 continue
158 }
159 let line = head
160 i++
161 while (i < words.length && points(`${line} ${words[i] ?? ''}`).length <= w) line += ` ${words[i++]}`
162 rows.push(line)
163 }
164 return rows
165}
166hooks/state/schema.ts 220 lines1// Stored shapes, their defaults and the lenient reads. Stored state can be older than this build,
2// half written, or plain junk after an update mid-session, so nothing read back is trusted: each
3// normalizer rebuilds a value of the declared shape and never throws.
4import type {
5 Activity,
6 AgentCard,
7 AgentStatus,
8 Architect,
9 Consult,
10 FileRow,
11 Layout,
12 Limit,
13 LogKind,
14 LogLine,
15 LogMark,
16 Loop,
17 Main,
18 Moment,
19 Roster,
20 Session,
21 ToolNote,
22 ToolRow,
23 Turn,
24 Usage,
25 View,
26} from '../../types'
27
28export const SCHEMA_VERSION = 2
29
30// ---------------------------------------------------------------- defaults
31
32export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false, speed: null }
33export const DEFAULT_USAGE: Usage = { pct: null, tokens: null, window: 0, costUsd: null, limits: [], compactions: 0, lastCompactAt: null }
34export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0 }
35export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
36export const DEFAULT_ACTIVITY: Activity = { tools: [], files: [], calls: 0 }
37export const DEFAULT_VIEW: View = { expanded: null, layout: null }
38export const DEFAULT_ROSTER: Roster = { architectTypes: [] }
39export const DEFAULT_SESSION: Session = { startedAt: null }
40
41/** Every stored key but `meta`, each normalized: the one set of values a frame is drawn from. */
42export type Snapshot = {
43 main: Main
44 usage: Usage
45 turn: Turn
46 agents: AgentCard[]
47 loops: Loop[]
48 architect: Architect
49 activity: Activity
50 log: LogLine[]
51 view: View
52 roster: Roster
53 session: Session
54}
55
56// ---------------------------------------------------------------- primitives
57
58const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
59
60const num = (v: unknown, d = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : d)
61const numOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
62const str = (v: unknown, d = ''): string => (typeof v === 'string' ? v : d)
63const strOrNull = (v: unknown): string | null => (typeof v === 'string' ? v : null)
64const bool = (v: unknown, d = false): boolean => (typeof v === 'boolean' ? v : d)
65const oneOf = <T extends string>(v: unknown, allowed: readonly T[], d: T): T => (allowed.includes(v as T) ? (v as T) : d)
66
67/** A copy no caller can use to change a shared default. */
68const fresh = <T>(v: T): T => {
69 if (Array.isArray(v)) return v.map(fresh) as T
70 if (isObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fresh(x)])) as T
71 return v
72}
73
74/**
75 * The stored object over its defaults. Only the default's own keys survive, and a stored value
76 * must have the default's type (a finite number for a number, and so on); a default of `null`
77 * stands for "number, string or null", which the per-key normalizers narrow further.
78 */
79export const normalize = <T extends object>(def: T, stored: unknown): T => {
80 const out: Record<string, unknown> = fresh(def) as Record<string, unknown>
81 if (!isObject(stored)) return out as T
82 for (const [k, d] of Object.entries(def)) {
83 const s = stored[k]
84 if (d === null) out[k] = typeof s === 'string' || (typeof s === 'number' && Number.isFinite(s)) ? s : null
85 else if (Array.isArray(d)) out[k] = Array.isArray(s) ? s : fresh(d)
86 else if (typeof d === 'number') out[k] = num(s, d)
87 else if (typeof d === 'string') out[k] = str(s, d)
88 else if (typeof d === 'boolean') out[k] = bool(s, d)
89 else if (isObject(d)) out[k] = isObject(s) ? s : fresh(d)
90 }
91 return out as T
92}
93
94/** A stored list, or empty when what is stored is not a list. */
95export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
96
97const objects = (stored: unknown): Record<string, unknown>[] => listOf<unknown>(stored).filter(isObject)
98const strings = (stored: unknown): string[] => listOf<unknown>(stored).filter((s): s is string => typeof s === 'string')
99
100// ---------------------------------------------------------------- per-key normalizers
101
102const MOMENTS: readonly Moment[] = ['before a plan', 'error repeats', 'before done']
103/** The layouts a person can choose, as `/st-code-display layout` takes them and the view key stores them. */
104export const LAYOUTS: readonly Layout[] = ['auto', 'compact', 'wide', 'mini']
105const STATUSES: readonly AgentStatus[] = ['running', 'done', 'stopped', 'failed']
106const KINDS: readonly LogKind[] = ['info', 'error', 'consult', 'done']
107const MARKS: readonly LogMark[] = ['✗', '· denied']
108
109export const normalizeMain = (stored: unknown): Main => {
110 const m = normalize(DEFAULT_MAIN, stored)
111 const s = isObject(stored) && isObject(stored.speed) ? stored.speed : null
112 const ttftMs = s ? numOrNull(s.ttftMs) : null
113 return { ...m, speed: s && ttftMs !== null ? { ttftMs, tokPerS: numOrNull(s.tokPerS) } : null }
114}
115
116// Only the two counts survive; a turn stored by the 0.1.0 build carries more, and the rest is dropped.
117export const normalizeTurn = (stored: unknown): Turn => normalize(DEFAULT_TURN, stored)
118
119export const normalizeUsage = (stored: unknown): Usage => {
120 const o = isObject(stored) ? stored : {}
121 return {
122 pct: numOrNull(o.pct),
123 tokens: numOrNull(o.tokens),
124 window: num(o.window),
125 costUsd: numOrNull(o.costUsd),
126 limits: objects(o.limits).map((l): Limit => ({ kind: str(l.kind), pct: num(l.pct), resetsAt: numOrNull(l.resetsAt) })),
127 compactions: num(o.compactions),
128 lastCompactAt: numOrNull(o.lastCompactAt),
129 }
130}
131
132const normalizeNote = (stored: Record<string, unknown>): ToolNote => ({
133 tool: str(stored.tool),
134 text: str(stored.text),
135 isError: bool(stored.isError),
136})
137
138export const normalizeCard = (stored: unknown): AgentCard => {
139 const o = isObject(stored) ? stored : {}
140 return {
141 id: str(o.id),
142 type: str(o.type, 'agent'),
143 model: str(o.model),
144 description: str(o.description),
145 status: oneOf(o.status, STATUSES, 'running'),
146 spawnedAt: num(o.spawnedAt),
147 endedAt: numOrNull(o.endedAt),
148 ctx: num(o.ctx),
149 out: num(o.out),
150 steps: num(o.steps),
151 lastStop: strOrNull(o.lastStop),
152 tools: objects(o.tools).map(normalizeNote),
153 answer: str(o.answer),
154 }
155}
156
157/** A stored card list: entries that are not objects are dropped rather than read as blank cards. */
158export const normalizeAgents = (stored: unknown): AgentCard[] => objects(stored).map(normalizeCard)
159
160export const normalizeLoops = (stored: unknown): Loop[] =>
161 objects(stored).map(l => ({ id: str(l.id), steps: num(l.steps), firstAt: num(l.firstAt), lastAt: num(l.lastAt), isDone: bool(l.isDone) }))
162
163/**
164 * A stored line's text and marker. A failed or refused call's line stored before the marker had a
165 * field of its own ends in it, so the marker is taken off the end of such a line's text.
166 */
167const markOf = (l: Record<string, unknown>): { text: string; mark: LogMark | null } => {
168 const text = String(l.text ?? '')
169 if (MARKS.includes(l.mark as LogMark)) return { text, mark: l.mark as LogMark }
170 const old = l.mark === undefined && l.kind === 'error' ? MARKS.find(m => text.endsWith(` ${m}`)) : undefined
171 return old ? { text: text.slice(0, -(old.length + 1)), mark: old } : { text, mark: null }
172}
173
174export const normalizeLog = (stored: unknown): LogLine[] =>
175 objects(stored).map(l => ({
176 at: num(l.at),
177 who: String(l.who ?? ''),
178 ...markOf(l),
179 agentId: strOrNull(l.agentId),
180 kind: oneOf(l.kind, KINDS, 'info'),
181 }))
182
183const normalizeConsult = (c: Record<string, unknown>): Consult => ({
184 id: str(c.id),
185 at: num(c.at),
186 endAt: numOrNull(c.endAt),
187 moment: oneOf(c.moment, MOMENTS, 'before a plan'),
188 via: str(c.via),
189})
190
191export const normalizeArchitect = (stored: unknown): Architect => {
192 const o = isObject(stored) ? stored : {}
193 return { consults: objects(o.consults).map(normalizeConsult), ids: strings(o.ids), seen: strings(o.seen), lastAdvice: str(o.lastAdvice) }
194}
195
196export const normalizeActivity = (stored: unknown): Activity => {
197 const o = isObject(stored) ? stored : {}
198 const tools = objects(o.tools)
199 .filter(t => typeof t.tool === 'string' && t.tool !== '')
200 .map((t): ToolRow => ({ tool: str(t.tool), calls: num(t.calls), errors: num(t.errors), timedCalls: num(t.timedCalls), timedMs: num(t.timedMs) }))
201 return {
202 tools,
203 files: objects(o.files)
204 .filter(f => typeof f.path === 'string' && f.path !== '')
205 .map((f): FileRow => ({ path: str(f.path), reads: num(f.reads), edits: num(f.edits), byMain: bool(f.byMain), lastAt: num(f.lastAt) })),
206 // The running total outlives evicted rows, but never reads as fewer calls than the rows kept.
207 calls: Math.max(num(o.calls), tools.reduce((n, t) => n + t.calls, 0)),
208 }
209}
210
211export const normalizeView = (stored: unknown): View => {
212 const o = isObject(stored) ? stored : {}
213 return { expanded: strOrNull(o.expanded), layout: LAYOUTS.includes(o.layout as Layout) ? (o.layout as Layout) : null }
214}
215
216export const normalizeRoster = (stored: unknown): Roster => ({ architectTypes: strings(isObject(stored) ? stored.architectTypes : []) })
217
218/** The session's start time; a state stored before this key existed has none, and reads as unset. */
219export const normalizeSession = (stored: unknown): Session => ({ startedAt: numOrNull(isObject(stored) ? stored.startedAt : null) })
220hooks/view/draw.tsx 66 lines1// What every renderer draws with: the surface's element table and the facts about that surface.
2import type { EngineInterface, RenderElement } from 'claude-code'
3
4import { fmtTimer } from '../format/text'
5import { COLORS } from './palette'
6
7/** The element table `$.ui.resolve(e)` hands out, for whichever surface asked. */
8export type Els = ReturnType<EngineInterface['ui']['resolve']>
9
10export type Draw = {
11 els: Els
12 C: typeof COLORS
13 /** The surface can run a `Client` module: the terminal and the desktop app. */
14 hasClient: boolean
15 /** The surface shows the pane as pictures of its cells (every surface but the terminal), which animate themselves. */
16 isPicture: boolean
17 /** The person's `motion` setting; the header pulse honours it, clocks tick regardless. */
18 motion: boolean
19 surface: string
20 now: number
21 /** Nothing runs, neither a turn nor a subagent nor another loop: the frames are drawn in `idle`. */
22 isIdle: boolean
23}
24
25/** The surface animates what it draws, by client modules or inside a picture, and the person has not turned motion off. */
26export const canAnimate = (d: Draw): boolean => d.motion && ((d.hasClient && 'Client' in d.els) || d.isPicture)
27
28/**
29 * A region an animated client module draws: the surface's own `Client` when its table has one, else a
30 * plain node that only a picture reads (it stands in for the module). `kind` picks the module here,
31 * so each path stays a literal in the source, as the host requires.
32 */
33export const clientNode = (d: Draw, key: string, kind: 'pulse' | 'sweep', width: number, props: Record<string, string | number | boolean>): RenderElement => {
34 const { els } = d
35 if ('Client' in els) {
36 return kind === 'pulse' ? (
37 <els.Client key={key} module="../client/pulse.tsx" width={width} height={1} props={props} />
38 ) : (
39 <els.Client key={key} module="../client/sweep.tsx" width={width} height={1} props={props} />
40 )
41 }
42 return (
43 kind === 'pulse'
44 ? { type: 'Client', props: { key, module: '../client/pulse.tsx', width, height: 1, props }, children: [] }
45 : { type: 'Client', props: { key, module: '../client/sweep.tsx', width, height: 1, props }, children: [] }
46 ) as unknown as RenderElement
47}
48
49/**
50 * A clock from `since` to `endAt`, or to now while `endAt` is null. A start of 0 is unknown (state
51 * saved before it was recorded), so it reads `—` rather than decades. Where the surface runs client
52 * modules the clock ticks by itself, so the pane need not redraw each second; elsewhere it is the
53 * value at this draw.
54 */
55export const clock = (d: Draw, key: string, since: number, endAt: number | null, color: string): RenderElement => {
56 const { els } = d
57 const { Text } = els
58 if (!(since > 0)) return <Text key={key} color={color}>—</Text>
59 if (d.hasClient && 'Client' in els) return <els.Client key={key} module="../client/elapsed.tsx" props={{ since, now: d.now, endAt, color }} />
60 return (
61 <Text key={key} color={color}>
62 {fmtTimer((endAt ?? d.now) - since)}
63 </Text>
64 )
65}
66