SLOPSHOPPER

Sergio's Tech · Code Display

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…

newpaneguardcommandtimeragents
v0.6.1MITupdated 2026-10-25sergios-tech/code-display
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · st-code-display
│ ┃ Sergio's Tech · Code Display ✕ › fix the failing auth test and add an audit log call │ ┃ ▌SERGIO'S TECH · CODE DISPLAY │ ┃ ⏺ Read(src/auth.ts) │ ┃ ┌─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ ◐ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Update(src/auth.ts) │ ┃ │ 5h ▰▰▰▱▱▱▱▱▱▱ 31% ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ $ spend $0.42 ⎿ 3 pass, 1 fail │ ┃ └─────────────────────────────────────────── │ ┃ INFO ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ○ idle │ ┃ ├─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ┌─────────────────────────────────────────── › /st-code-display │ ┃ │ tool calls ⎿ st-code-display: Dashboard opened. Focus it with ctrl+x tab; 1-6 │ ┃ │ ▸ Bash 4 │ ┃ │ ✎ Write 2 │ ┃ │ › Read 1 │ ┃ │ › Grep 1 │ ┃ │ │ ┃ │ file reads ed │ ┃ │ src/auth.ts 1 │ ┃ │ src/cache.ts 0 │ ┃ │ src/audit.ts 0 │ ┃ │ app/src 1 │ ┃ └─────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sergio's Tech · Code Display
▌SERGIO'S TECH · CODE DISPLAY ○ ┌──────────────────────────────────────────────────────┐ │ ◐ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ 5h ▰▰▰▱▱▱▱▱▱▱ 31% │ │ │ │ $ spend $0.42 │ └──────────────────────────────────────────────────────┘ INFO ○ idle ├──────────────────────────────────────────────────────┤ ┌──────────────────────────────────────────────────────┐ │ tool calls err avg* │ │ ▸ Bash 4 ✗ 1 20ms │ │ ✎ Write 2 · 20ms │ │ › Read 1 · 20ms │ │ › Grep 1 · 20ms │ │ │ │ file reads edits │ │ src/auth.ts 1 ✎ 1 │ │ src/cache.ts 0 ✎ 1 │ │ src/audit.ts 0 ✎ 1 │ │ app/src 1 · │ └──────────────────────────────────────────────────────┘ TOOLS & FILES 9 calls ├──────────────────────────────────────────────────────┤ ┌──────────────────────────────────────────────────────┐ │ 0:00 main Edit → src/auth.ts │ │ 0:00 main Write → src/audit.ts │ │ 0:00 main Write → src/cache.ts │ │ 0:00 main Bash → bun test ✗ │ │ │ └──────────────────────────────────────────────────────┘ LOG
README

Sergio's Tech · Code Display

Release License: MIT Claude Code 2.1.300+ plugin

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.

The pane, docked at 66 columns, through a real run with three subagents and an architect: under the SERGIO'S TECH · CODE DISPLAY header, INFO's frame turns green and reads working in orange, three agents appear in AGENTS with a bouncing bar while they run and a full green bar with a check once done, the header reads OPUS 5.5 · ARCHITECT ADVISING while the ARCHITECT panel counts how long the consult has run on its timeline, then ARCHITECT ON CALL with the 11-second consult marked before a plan and its verdict to keep normalize in tally/utils.py, TOOLS & FILES counts the calls and the files the agents read and edited, and the LOG, running to the pane's bottom edge, fills with the prompt at 0:24 into the session, spawns, edits, the architect's advice and completions while the spend climbs to $1.08

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

Install

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.

What you see

A fullscreen session 160 columns wide: on the left, the transcript reports the three docstring subagents and the architect finishing, lists each function's new docstring, notes that python3 -m unittest passes the 5 tests and ends with the architect's verdict to keep normalize in utils.py; on the right, the docked pane shows the SERGIO'S TECH · CODE DISPLAY header with ○ OPUS 5.5 · ARCHITECT ON CALL over INFO at $1.08 spent, AGENTS with three finished agents, ARCHITECT with one 11-second consult and its verdict, TOOLS & FILES with 17 calls, and the LOG, timed from the session's start (0:46 to 0:50), running down to the pane's bottom edge, in one column

Two columns, in a 120-column dockOne column, in the default 66-column dock
The wide layout under the SERGIO'S TECH · CODE DISPLAY header: INFO and TOOLS & FILES with 19 calls stacked on the left, AGENTS with three finished agents and ARCHITECT on call after one 11-second consult, with the verdict to keep it in utils.py, on the right, and the LOG across the full width underneath down to the pane's bottom edge, from 0:30 to 0:50 into the session, listing a spawn, the edits to tally/parser.py, tally/utils.py and tally/cli.py, the architect's advice and the agents finishingThe compact layout close up: the SERGIO'S TECH · CODE DISPLAY header with ○ OPUS 5.5 · ARCHITECT ON CALL, then INFO, AGENTS with three finished agents, ARCHITECT on call with its consult on the timeline and its verdict, TOOLS & FILES and LOG stacked in one column, with gauges at 6% context and at 2% and 59% of the two limits, the reads and edits of each file, and the LOG, timed from the session's start, reaching the pane's bottom edge

A 100-column session with the summary inline above the prompt, in four rows: the model with its IDLE state and ARCHITECT ON CALL, the effort, a context gauge at 6%, the 7-day limit and the session cost; then three finished subagents, each with a check, a full green bar and its clock

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.

PanelShowsFrom
INFOThree 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()
AGENTSOne 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 & FILESA 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()
LOGPrompts, 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).

Use

CommandDoes
/st-code-display (or /st-code-display open)Opens the pane.
/st-code-display closeCloses it.
/st-code-display resetClears 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:

KeyDoes
1 to 6Opens 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).

Where it runs

  • Fullscreen terminal, 110 columns or wider: the pane docks beside the transcript. The dock opens 66 columns wide, which draws one column.
  • Main screen, or a terminal under 110 columns: the pane sits inline above the prompt and, under 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.
  • Desktop app and VS Code: the same design, drawn as pictures. Each block (the header, each panel or the two columns together, and the log) is one SVG laid out cell for cell as the terminal would show it, on black. The header pulse and the AGENTS sweep animate on both, and both show each clock as it stood at the last redraw. The agents' time axis stays under AGENTS. The digit keys still open an agent, but a picture's text can't be selected and its rows can't be clicked.

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:

  • A terminal at least 245 columns wide.
  • A dock widened to 110 columns or more. Drag it wider; the host remembers the width you set.

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

What it can reach

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 seesThrough
Every tool call's name and input, whether it failed, and how long it tooktool.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 architectagent.offer
Subagent spawns, their model requests and token usage, and their final answersagent.spawn, turn.step, turn.complete
The main loop's requests: model, effort, timing and output tokensturn.step
The opening text of each main-loop turn (your prompt, first 400 characters, for the log) and the permission modeturn.start, classic.UserPromptSubmit
Context, cost and rate-limit readings, and compactionssession.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 reportthe 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.

What each hook does

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.

HookRunsReads and keepsEvent
session.startWhen 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.endWhen 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.runOnly 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.UserPromptSubmitWhen you submit a prompt.Reads only the permission mode, not the prompt. Keeps the mode.Passed on unchanged.
turn.startWhen 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.stepFor 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.completeWhen 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.offerWhen 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.spawnWhen 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.appendWhen 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 SubagentHandbackWhen 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 callAround 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.measureWhen the host measures the session.Reads the context, cost and rate-limit readings. Keeps the latest.Passed on unchanged.
session.compactWhen 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.renderEach 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.

  • No hook takes part in a permission check. 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.
  • The only answers the plugin gives are /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:

CallUsed for
$.state.get, $.state.setReading and writing the state above. It lasts for the session; the plugin keeps nothing across sessions.
$.clock.nowThe times of log lines and clocks, and the timing of a request or a tool call.
$.clock.afterOne timer, set when a model loop that no agent claims steps, to redraw the pane when that loop stops counting as running.
$.command.registerRegistering /st-code-display at session start.
$.session.usageThe host's first context, cost and rate-limit reading at session start.
$.tool.checkAsking 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.closeOpening the pane at session start and on /st-code-display, and closing it on /st-code-display close.
$.ui.invalidateAsking the host to draw the pane again.
$.ui.resolveGetting the host's drawing elements for the pane.

What is inferred, not measured

  • Architect moments. "Before a plan" means nothing has been edited yet this turn. "Error repeats" means 2 or more main-loop errors in a row. "Before done" means edits were made this turn. The panel labels them (inferred), and moments: false turns them off.
  • Server-side advice is encrypted. When the architect is a server tool, the pane counts and times the consult and logs advice returned, but cannot show what the tool said. The » advice line comes only from an architect agent's answer or hand-back.
  • Per-agent ctx is the latest request's whole input (uncached, cache read and cache write). It is labelled ctx, not cost, be
Source 31 files
hooks/register.tsx 769 lines
1// 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}
769
hooks/config.ts 62 lines
1// 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}
62
hooks/domain/activity.ts 144 lines
1// 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)
144
hooks/domain/agents.ts 184 lines
1// 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
184
hooks/domain/architect.ts 115 lines
1// 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) : ''
115
hooks/domain/log.ts 69 lines
1// 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}
69
hooks/domain/turn.ts 15 lines
1// 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})
15
hooks/domain/vitals.ts 65 lines
1// 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}
65
hooks/format/redact.ts 38 lines
1// 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}
38
hooks/format/text.ts 166 lines
1import 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}
166
hooks/state/schema.ts 220 lines
1// 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) })
220
hooks/view/draw.tsx 66 lines
1// 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