SLOPSHOPPER

agents

Live subagents, background shells, Monitor watches and cron timers: an AGENTS band above the prompt (main model, each run with its model or command, task and…

newpanebandguardcommandtoast
v0.4.0MITupdated 2026-10-20ZH-L1N/agent-window-mods/agents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Agents[ Collapse ] [ c: code ] [ p: peers ] │ ┃ ╭────────────────╮ ╭────────────────╮ ╭───── ⏺ Read(src/auth.ts) │ ┃ │ Cost │ │ Tokens │ │ Time ⎿ Read 6 lines │ ┃ │ ≈$0.00 │ │ 0 │ │ 0:00 ⏺ Update(src/auth.ts) │ ┃ ╰────────────────╯ ╰────────────────╯ ╰───── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No subagents yet. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-info │ ⎿ agents: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Agents [ Collapse ] [ c: code ] [ p: peers ] [ q: c ╭────────────────╮ ╭────────────────╮ ╭────────────────╮ │ Cost │ │ Tokens │ │ Time │ │ ≈$0.00 │ │ 0 │ │ 0:00 │ ╰────────────────╯ ╰────────────────╯ ╰────────────────╯ No subagents yet.
README

agents

Live subagents, background shells, Monitor watches and cron timers in Claude Code, in two places, a toast when one ends and ALL DONE when the last one does; and a code map of the files the tools touched:

  • The AGENTS band above the prompt, while subagents or background shells run and for 10 s after the last one ends:
   ◎ AGENTS  main fable·5.1 ⟳ │ Explore haiku "find the hooks" ✓ · checker sonnet "check the prices" ⟳ │ skills: plugin-authoring ×2   [ ×2 ] [ ✕ ]

The main loop's model while a turn runs; one entry per run of the current batch (type or name, model, the task cut to 22 characters, ⟳ running, ✓ finished, ✗ error); the skills fired. On a narrow band the task texts go first (oldest first), then all skills but one, then the tail folds into +N more. Under 40 columns it is ● 2 agents ▰▰▰▱▱▱. ×N opens the pane; ✕ hides the band until a new run starts. The row is keyed zm-50-agents and takes its slot in the zehui-mods stack order (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80); other mods' bands follow, and a survey takes the band's place while it is up.

A background shell (a Bash call run in the background, or moved there with ctrl+b or by its timeout) is an entry of its own: sh, the command cut to 22 characters, its elapsed time and ⟳, then ✓ when it completes or ✗ when it fails or is killed, after which it leaves with its batch like a subagent. A command that runs a coding agent's CLI (codex exec, codex "<prompt>", codex review, codex exec resume, opencode run, claude -p) is labelled with the agent and the model -m/--model names, wherever it sits in a pipeline or list (git diff | codex exec "review the diff"), and shows the prompt instead of the command (the command when the prompt comes on stdin):

   ◎ AGENTS  Explore haiku "find the hooks" ⟳ · sh "pytest -q tests/" 0:42 ⟳ · codex gpt-5.4 "review the diff" 2:10 ✓   [ ×3 ] [ ✕ ]

The shell's words (sh, codex gpt-5.4) are grey, never a Claude model's colour. ×N counts shells too, and the short form says ● 3 tasks once a shell is among them.

  • The agents pane, /agents-info (or /agents-info open): cost, tokens and time tiles, then Running, Finished and Error, one card per run with a pixel mascot, a 10-cell ▰▱ bar (context fill, tier colour while running, amber on a slow step, green when finished, red on error) and ctx n% · tokens · ≈$cost · m:ss. While two or more runs are going, a main-loop card tops Running (2 subagents running, or 2 tasks running once shells are among them). Collapse switches to one line per run.

A background shell's card has a grey mascot instead of a coral one (a grey bot with a terminal face; a grey bot in a blue cap for a coding agent's CLI), a grey spinner, the bold title sh · <command> (codex · <prompt>), its status word (running, finished, failed, killed), then background shell · m:ss (with the agent and model first for codex, and exit n once it failed) and the last line of its output, read from the task's output file once a second while the pane is open, and once on opening for a shell that ended while it was closed (colour codes stripped, the line cut to 240 characters; a file over 1 MiB is not read).

Every card, a subagent's or a shell's, starts with a framed button numbered 1 to 9 in pane order (Running, Finished, Error). The buttons are stubs for now: a press, or its digit while the pane has the focus, toasts "messages view comes with E3", as does /agents-info open <n>. The messages view itself comes in a later round (E3).

The mascots move: a one-pixel bob each second while running, a braille spinner, a bright flash on finish and an amber pulse on error; a run waiting on a tool's result for over 20 s sleeps. A shell's mascot bobs while it runs and turns red when it fails. All of it holds still under Claude Code's reduced-motion setting.

Mascot pools (0.4.0): every state has a pool of poses from the mascot pack, and a card shows the pose its run id hashes to (FNV-1a), so it never changes pose within a state while two runs in one state seldom look alike:

StatePoses
running14: repairing-with-tools, writing-software-code, building-colorful-blocks, debugging-system-bugs, reading-data-document, thinking-in-code, examining-green-globe, lab-science-testing, holding-ancient-scroll, visualizing-tree-data, managing-server-rack, processing-logic-symbols, planting-small-trees, user-profile-settings
waiting (over 20 s on a tool)4: sleeping-soundly, thinking-pixel-bubble, in-constant-loop, it-depends-sign
finished7: with-success-checkmark, success-achievement-milestone, happy-pixel-celebrating, rocket-launch-success, api-success-pixel, in-glowing-aura, love-cloud-pixel
error10: confusion-spiral-pixel, dizzy-failed-state, error-state-pixel, failed-process-error, system-error-alert, marking-critical-error, crying-sad-reaction, angry-at-laptop, broken-heart-sad, disconnected-plug-pixel
main loop (hub card)3: artboard-25cla, multi-window-workflow, developer-pair-programming-scene, picked by the session's id (else its first main turn's)

Before 0.4.0 every finished card showed the one checkmark pose (and every error card the one spiral), which is why finished runs all looked the same. Poses are 18 to 30 pixels wide and 10 to 23 tall; the mascot column is as wide as the widest pose, and a card is as tall as its pose (or its text). Shells keep their own grey mascots and codex fuzzies.

Watches and crons (0.3.0)

A Monitor call is a watch and a CronCreate call a cron, each an entry of the band and a row of the pane under a Watching head (no mascot, no number):

 ◎ AGENTS  Explore haiku "find the hooks" ⟳ · watch deploy a15f4e9 ⏳ 12m · cron 20:30 in 41m   [ ×3 ] [ ✕ ] ☕
  • A watch reads watch <description> <target> ⏳ <time left>: the target is the herdr pane (w1:p6), commit (a15f4e9) or run id its command names, if any; the time left runs to its deadline (a persistent watch shows its elapsed time instead). Its event notifications set the line under its pane row; it ends on its end notification (✓ watch "deploy" ended after 12:00).
  • A cron reads cron <next fire, local> in <time left>, its prompt in the pane. A one-shot cron ends when its prompt fires; a recurring one moves to its next fire time; CronDelete ends either, with no toast.
  • Stale greying: a notification for a watch whose target another watch saw done within the last minute (or a new event for a watch already ended) turns its entry grey for 10 s instead of toasting again. A wait on that target that ends later (a second round, a re-armed Monitor) toasts as usual.
  • Caffeinate hold (caffeinate option, on by default): while any subagent, shell or watch runs, one caffeinate -i -t 600 child (through $.process.spawn) holds idle sleep, spawned again once its 600 s run out while work still runs, and stopped as soon as the last one ends; ☕ sits at the end of the AGENTS row while it is held. A cron waiting to fire holds nothing. A child that ends within a minute (no caffeinate on the machine) is retried a minute later, not every second.
  • ALL DONE: when the last subagent, shell or watch in flight ends, one toast ★ all background work done, and with the sound option on a rising chime (assets/done.wav, the peers mod's). Both need toasts on.

Code view (0.3.0)

The pane's second view: c (or /agents-info code) shows it, w (or /agents-info who) goes back to the cards, p (or /agents-info peers) shows the Peers view (0.4.0), q (or /agents-info close) closes the pane. The keys work while the pane has the focus (ctrl+x tab).

Code map (11 files) (2 changed) · turn 4 ● editing kit.ts
[ w: who ] [ a: session ] [ d: diff ] [ q: close ]

╭────────────────────╮   ╭────────────────────╮   ╭────────────────────╮
│ 1 band.tsx       ✓ │──▶│ 2 register.tsx   ✓ │──▶│ 3 kit.ts         ● │
│ +2 −2 · RE · 4m    │   │ +9 −1 · E · 2m @1  │   │ +12 −3 · RE · 5s   │
│ Padded chip        │   │ Prune ended runs…  │   │ pruneEnded, keep…  │
╰────────────────────╯   ╰────────────────────╯   ╰────────────────────╯
 ALSO READ   token-weather.mjs  TIMELINE.md  mascots.ts
subagents [ 1 ] savvy-careful
  • What is read: the calls of Read, Edit, Write, MultiEdit, NotebookEdit, Grep and Glob, and the paths a Bash command names (cat reads, sed -i edits, > and >> write, git add reads, git checkout -- <file> and git restore rewrite, cp reads its sources and writes its target, mv writes its target), in the main loop and in subagents (by agentId, @n in the node is the subagent's card number). Relative paths resolve against the session's directory. A heredoc's body and a quoted > (grep -c '>' f) name no file; a [ in an edited or read path (app/[id]/page.tsx) is literal. A Bash command's exit code never marks its > target ✗.
  • Node: round border 22 wide, three lines: its number (for d), name and state glyph; +plus −minus · tools · time since the last touch (and the turn tags t3 in session scope); one line from the edit itself (the Edit's first changed line, the Write's first line, kept to 80 characters). States: ◌ read blue, ● editing amber for 30 s after an edit, ✓ changed green, ✗ failed red when the edit tool returned an error.
  • Counts: after each edit that did not fail, git diff --numstat -- <file> runs in the file's folder ($.process.run, 5 s timeout), only when git rev-parse --is-inside-work-tree says the folder is in a repo (asked once per folder), and only for a file under the session's directory: git never runs in a folder elsewhere (a cloned repo's .git/config stays unread), so those nodes carry no counts. A new file git does not know yet counts the lines written.
  • Layout: edited files in edit order, ──▶ between consecutive ones, as many to a row as the pane width holds (a wrapped row starts with the arrow); read-only files in the ALSO READ chip row; the subagents that touched the files with their numbered buttons (stubs until E3, as in the Who view). Under 60 columns a tree list by folder instead.
  • Scope: this main-loop turn by default (the turn before while this one touched nothing yet); a (or /agents-info scope) toggles the whole session, the nodes then tagged with their turns.
  • Diff: d then a number (or /agents-info diff <n>) runs /diff on that node's file through $.command.run, from the 1 s tick rather than the press. While d is armed the digits belong to the diff pick, not the subagents row.

In the Who view the clear button is now x: clear done (c opens the Code view).

Peers view (0.4.0)

The pane's third view: p (or /agents-info peers) shows it from the Who or Code view; w, c and q work as before, and r reads herdr again. It draws what the peers mod's board draws, read by this mod itself (the code is ported, not imported across mod folders):

Peers · 4 in w1 · 1 blocked · read every 30 s
[ w: who ] [ c: code ] [ r: refresh ] [ q: close ]

hub · this pane  ● working 12m  w1:p2  claude  "Orchestrate the review"
req skin · ⏳ 21m left · no result.md
   ▲ blocked 4m  w1:p7  codex  "Compare skills"
   ● working 4m  w1:p9  claude  "Skin, second half"
req rev-r4 · round 4/5 · result.md ✓ completed
   ● working 12m  w1:p6  claude  "Review round 4"
other peers
○ idle 3m  w1:p8  claude  "Not ours"
  • Rows: one per agent of this herdr workspace (HERDR_WORKSPACE_ID), this pane left out: the state's glyph and word (▲ blocked, ● working, ○ idle, ✓ done, ? unknown) with the time in that state, the pane id, the agent and the title. Blocked rows are amber and come first. The time counts from the first read that saw the pane in that state (none on the first read); a read more than a minute old is not carried over.
  • Hub row first: this pane's own row (hub · this pane) when this session sent HERDR-ORCH requests; else, when a brief from another pane made this session a spoke, that pane's row (hub).
  • Spokes grouped under their request id: each request this session sent heads a group, req <id> with its round (round 4/5), its deadline countdown (⏳ 21m left, overdue 4m; gone once a result file is there) and its result file (result.md ✓ completed, ✗ failed, ▲ blocked, result.md (no status), no result.md), then its spokes. A group with a blocked spoke comes first, then the newest. A spoke is drawn under the newest request that names it. Peers no request names follow under other peers.
  • Requests: read from the envelope headers in this session's prompts (prompt.submit), the peers mod's rule (POLICY.md section 2): [HERDR-ORCH v0] req=<id> from=<agent>@<pane> type=<type> at a line's start or after herdr agent prompt <pane> "; the spoke is the pane of that herdr agent prompt line (so a fan-out of one request to several panes groups them all), else to=<pane>, else the first herdr agent prompt pane in the text that is not the sender's, else the sender of a report, else the first other pane id in the prompt. A brief or follow-up that another pane sent to this session takes its target from its header line only; when that names no other pane it is no request of ours, and a brief's sender becomes the hub. Round, ceiling and deadline as the peers mod reads them (30 minutes for a brief that names none). The newest 40 requests are kept.
  • Reads: herdr agent list (5 s limit) through $.process.run, only while the pane is open in this view: every 30 s from the existing 1 s tick, and at once on p, r or /agents-info peers. The result files (~/.local/state/herdr-orch/<req>/result.md, 64 KiB at most) through $.fs in the same read, and only when the file, with every symbolic link followed, lands at its own place in that folder. A failed read keeps the last rows; with no good read yet the head says why (herdr agent list failed (exit 1)).
  • Outside herdr (no HERDR_ENV=1 with a workspace and pane id) the view says The Peers view needs a herdr pane and nothing runs.

Toasts: one toast when a subagent or a background shell ends, never for the main loop: ✓ Explore "survey the mods" finished in 0:31, ✗ sh "pytest -q tests/" exit 1 after 0:42 (or killed after, or failed after when no exit code is known). The toasts option turns them off.

Install

At the prompt of a terminal session:

/plugin install agents@zehui-mods

From the local marketplace folder (~/.claude/mods, added with claude plugin marketplace add ~/.claude/mods):

claude plugin install agents@zehui-mods --scope user

The install is a copy of the mod at that moment (under ~/.claude/plugins/cache/zehui-mods/agents/<version>/), so /reload-plugins reloads that copy, not this folder. After a version bump, refresh the copy and restart the session:

claude plugin marketplace update zehui-mods
claude plugin update agents@zehui-mods --scope user

Options

OptionValuesDefaultWhat it does
bandon, offonoff hides the AGENTS band; the pane stays.
toastson, offonoff drops the toast for a subagent or shell that ends.
keepEnded0, 5, 10, 25, 5010How many ended runs (finished or failed) the pane keeps, newest first; older ones are dropped as runs end. The pane's x: clear done button and /agents-info clear drop them all.
caffeinateon, offonon holds idle sleep (caffeinate -i -t 600, re-armed) while any subagent, shell or watch runs; ☕ on the AGENTS row while held.
soundoff, onoffon plays the rising chime with the ALL DONE toast (needs toasts on).
summariesedit, haiku, offeditThe code map node's third line: from the edit itself (edit); haiku is reserved and drawn as edit for now (no model call in this mod); off shows the file's folder.

Notes

  • Costs are estimates from the token counts the engine reports and a price table in hooks/kit.ts (from the claude-api skill bundled with Claude Code 2.1.303). Cache writes are priced at the 5-minute rate (1.25x input), since the engine reports one write count. A model the table does not know shows ≈ ?.
  • A shell ends on the task notification the engine writes into the conversation (<task-notification>, status completed, failed or killed). A shell started by a synchronous subagent, which the engine stops with the subagent's final answer, ends (as killed) when that subagent ends.
  • The shell mascots are ours, not from the pack: four grey candidates drawn on 2026-10-19 (A shell-terminal-face, B codex-hex-badge, C shell-window, D codex-blue-cap, in SHELL_POSES in hooks/mascots.ts). A is wired for plain shells and D for a coding agent's CLI; change SHELL_POSE or CODEX_POSE there to pick B or C instead.
  • The Code view's d + number passes the file to /diff as its argument; the plugin API does not say whether /diff narrows to it, so it may open the whole working-tree diff instead.
  • The watch, cron, notification and CronCreate result formats were read from Zehui's own session transcripts (Claude Code 2.1.30x): a Monitor's events are <task-notification> rows with an <event> and no <status>; its end is one with <status>; a cron's fire is a row of origin scheduled-trigger holding its prompt.
  • Processes: git (the code map's counts), caffeinate (the hold) and, from 0.4.0, herdr agent list (the Peers view, only while it is shown); nothing else, no network and no model.
  • Terminal only for the band; the pane is plain text on the desktop app. English only.
Source 12 files
hooks/register.tsx 905 lines
1// The agents mod: one run record per subagent and per background shell, the
2// /agents-info pane and the AGENTS band above the prompt. This file keeps the
3// books and raises the finish toasts; skin.tsx draws the pane and band.tsx the band.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
6
7import type { AgentRun, CodeTouch } from '../types'
8import { drawBand } from './band'
9import { EDITING_MS, EDIT_TOOLS, FILE_TOOLS, NUMSTAT_TIMEOUT_MS, addTouch, bashTouches, dirOf, parseNumstat, toolTouches } from './codemap'
10import type { Touch } from './codemap'
11import {
12  ALL_DONE,
13  CAFFEINATE,
14  SOUND_DONE,
15  closePane,
16  diffQueue,
17  isCaffeinateOn,
18  isInFlight,
19  isSoundOn,
20  setView,
21  LINGER_MS,
22  MAX_RUNS,
23  keepEndedOf,
24  pruneEnded,
25  MESSAGES_STUB,
26  PANE,
27  PULSE_MS,
28  TAIL_MAX_BYTES,
29  batchOf,
30  clip,
31  costOf,
32  isBandOn,
33  isShell,
34  isToastsOn,
35  openPane,
36  toastText,
37  togglePane,
38  tokensOf,
39  windowOf,
40} from './kit'
41import type { Usage } from './kit'
42import { labelOf, lastLineOf, notesOf, outputFileOf, textOfContent } from './shells'
43import type { TaskNote } from './shells'
44import { nodePath } from './code'
45import { NEEDS_HERDR, herdrIds, noteEnvelopes, pollPeers, tickPeers } from './peers'
46import { drawPane } from './skin'
47import { DONE_RECENT_MS, cronNext, eventsOf, targetOf } from './watch'
48
49const runs = atom({ plugin: 'agents', key: 'runs' } as const, [])
50const skills = atom({ plugin: 'agents', key: 'skills' } as const, {})
51const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
52const now = atom({ plugin: 'agents', key: 'now' } as const, 0)
53const dismissedAt = atom({ plugin: 'agents', key: 'dismissedAt' } as const, 0)
54const isStill = atom({ plugin: 'agents', key: 'isStill' } as const, false)
55const tails = atom({ plugin: 'agents', key: 'tails' } as const, {})
56const isCaffeinated = atom({ plugin: 'agents', key: 'isCaffeinated' } as const, false)
57const code = atom({ plugin: 'agents', key: 'code' } as const, [])
58const turn = atom({ plugin: 'agents', key: 'turn' } as const, 0)
59const codeScope = atom({ plugin: 'agents', key: 'codeScope' } as const, 'turn')
60const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
61
62// A shell's command and task text as kept in its run record (drawings clip further).
63const COMMAND_MAX = 2_000
64const DESCRIPTION_MAX = 200
65
66// A caffeinate hold that ends sooner than this after its start is retried only this much later.
67const AWAKE_RETRY_MS = 60_000
68
69// The tick re-reads prefersReducedMotion every this many slow ticks.
70const MOTION_EVERY = 10
71
72// What the Agent call said that its spawn does not carry, by tool_use_id.
73const pending = new Map<string, { effort?: string }>()
74
75// Timers and the tick's memory: a reload drops them with the environment, and
76// session.start (raised again for a fresh load) starts them over.
77let slow: Timer | null = null
78let fast: Timer | null = null
79let wasActive = false
80let ticks = 0
81// Background task ids already ended, so a notification seen twice ends and toasts once.
82const endedTasks = new Set<string>()
83// Each shell's output file as last read (size and mtime), so an unchanged file is not read again.
84const tailSeen = new Map<string, string>()
85// Whether a subagent, shell or watch was in flight at the last tick: ALL DONE when it goes false.
86let wasInFlight = false
87// The caffeinate hold's child while it runs (spawned and stopped by the tick only).
88let awake: ReturnType<EngineInterface['process']['spawn']> | null = null
89// A hold that ended within a minute of its start (no caffeinate here, or it failed): not re-armed before this time.
90let awakeRetryAt = 0
91// The session's working directory: relative paths in the code map resolve against it.
92let cwd = ''
93// Folders already asked whether they sit in a git work tree.
94const repoDirs = new Map<string, boolean>()
95// Watch events already seen (task id and line): the queue record and the row of one event count once.
96const seenEvents = new Set<string>()
97
98const updateRun = ($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) =>
99  update($, runs, list => (list.some(a => a.agentId === agentId) ? list.map(a => (a.agentId === agentId ? change(a) : a)) : list))
100
101async function readMotion($: EngineInterface): Promise<void> {
102  try {
103    const settings = (await $.settings.read()) as Record<string, unknown>
104    const still = settings.prefersReducedMotion === true
105    if ((await read($, isStill)) !== still) await update($, isStill, () => still)
106  } catch {
107    // No settings: keep what was read last.
108  }
109}
110
111async function readModel($: EngineInterface): Promise<string> {
112  try {
113    return await $.session.model()
114  } catch {
115    return ''
116  }
117}
118
119async function readSessionId($: EngineInterface): Promise<string> {
120  try {
121    return await $.session.id()
122  } catch {
123    return ''
124  }
125}
126
127/** One toast for a run that ended, unless the `toasts` option is off; never for the main loop. */
128function toastEnd($: EngineInterface, run: AgentRun, options: PluginOptions): void {
129  if (!isToastsOn(options.toasts)) return
130  try {
131    $.ui.toast(toastText(run))
132  } catch {
133    // A toast is a courtesy.
134  }
135}
136
137/**
138 * True when another watch on `run`'s target ended done within DONE_RECENT_MS
139 * of `at`: a notification of this one greys its entry instead of toasting again.
140 */
141const isTargetDone = (list: readonly AgentRun[], run: AgentRun, at: number): boolean =>
142  run.kind === 'watch' &&
143  run.target !== undefined &&
144  list.some(
145    o =>
146      o.kind === 'watch' &&
147      o.id !== run.id &&
148      o.target === run.target &&
149      o.status === 'finished' &&
150      o.endedAt !== undefined &&
151      at - o.endedAt <= DONE_RECENT_MS,
152  )
153
154/**
155 * Ends a running shell or watch as a notification (or its subagent's end)
156 * reports, and toasts it once; a watch whose target is already done ends grey,
157 * without a toast.
158 */
159async function endShell($: EngineInterface, note: TaskNote, options: PluginOptions): Promise<void> {
160  // Claimed before the first await, so a second row for the task (its queue
161  // record) finds it taken; released unless the shell is ended, so that row retries.
162  if (endedTasks.has(note.id)) return
163  endedTasks.add(note.id)
164  if (endedTasks.size > 256) endedTasks.delete(endedTasks.values().next().value ?? '')
165  let isEnded = false
166  try {
167    const list = await read($, runs)
168    const run = list.find(a => (isShell(a) || a.kind === 'watch') && a.taskId === note.id && a.status === 'running')
169    if (!run) return
170    const at = await $.clock.now()
171    const isDone = note.status === 'completed'
172    const isStaleEnd = isTargetDone(list, run, at)
173    const ended: AgentRun = {
174      ...run,
175      ...(isStaleEnd ? { staleAt: at } : {}),
176      status: isDone ? 'finished' : 'error',
177      endedAt: at,
178      ...(isDone ? {} : { endWord: note.status === 'killed' ? 'killed' : 'failed' }),
179      ...(note.exitCode !== undefined ? { exitCode: note.exitCode } : {}),
180      ...(note.outputFile && !run.outputFile ? { outputFile: note.outputFile } : {}),
181    }
182    await update($, runs, l => l.map(a => (a.id === run.id ? ended : a)))
183    isEnded = true
184    await update($, now, () => at)
185    if (!isStaleEnd) toastEnd($, ended, options)
186    await pruneAfterEnd($, options)
187  } finally {
188    if (!isEnded) endedTasks.delete(note.id)
189  }
190}
191
192/**
193 * Each shell's last output line, read through $.fs.read while the pane is open:
194 * for the shells running or ended within the linger, a file read only when its
195 * size or mtime moved, and never one over TAIL_MAX_BYTES.
196 */
197async function pollTails($: EngineInterface, list: readonly AgentRun[], at: number): Promise<void> {
198  const shells = list.filter(
199    a => isShell(a) && a.outputFile && (a.status === 'running' || (a.endedAt !== undefined && at - a.endedAt <= LINGER_MS)),
200  )
201  for (const a of shells) {
202    const file = a.outputFile ?? ''
203    try {
204      const stat = await $.fs.stat(file)
205      const seen = `${stat.size}:${stat.mtimeMs}`
206      if (tailSeen.get(a.id) === seen) continue
207      tailSeen.set(a.id, seen)
208      if (tailSeen.size > 64) tailSeen.delete(tailSeen.keys().next().value ?? '')
209      if (stat.size > TAIL_MAX_BYTES) continue
210      const line = lastLineOf(await $.fs.read(file))
211      if (line === undefined || (await read($, tails))[a.id] === line) continue
212      const ids = new Set(list.map(r => r.id))
213      await update($, tails, t => ({ ...Object.fromEntries(Object.entries(t).filter(([id]) => ids.has(id))), [a.id]: line }))
214    } catch {
215      // No file yet, or none to read: the card keeps the line it had.
216    }
217  }
218}
219
220/**
221 * A watch's event notification: its last event line, and for a watch already
222 * ended (or whose target another watch already saw done) a grey entry for 10 s.
223 */
224async function noteEvent($: EngineInterface, id: string, event: string): Promise<void> {
225  const seen = `${id}\n${event}`
226  if (seenEvents.has(seen)) return
227  seenEvents.add(seen)
228  if (seenEvents.size > 256) seenEvents.delete(seenEvents.values().next().value ?? '')
229  const list = await read($, runs)
230  const run = list.find(a => a.kind === 'watch' && a.taskId === id)
231  if (!run) return
232  const at = await $.clock.now()
233  const isStale = run.status !== 'running' || isTargetDone(list, run, at)
234  await update($, runs, l =>
235    l.map(a => (a.id === run.id ? { ...a, ...(event ? { lastEvent: event } : {}), ...(isStale ? { staleAt: at } : {}) } : a)),
236  )
237  if (isStale) await update($, now, () => at)
238}
239
240/** A cron's stored prompt fired: a one-shot cron ends ✓, a recurring one moves to its next fire time. */
241async function cronFired($: EngineInterface, text: string): Promise<void> {
242  const body = text.trim()
243  if (!body) return
244  const list = await read($, runs)
245  const run = list.find(a => a.kind === 'cron' && a.status === 'running' && a.description && body.startsWith(a.description.replace(/…$/, '').slice(0, 120)))
246  if (!run) return
247  const at = await $.clock.now()
248  const fired: AgentRun = run.isRecurring
249    ? { ...run, nextAt: run.cron ? cronNext(run.cron, at) : undefined }
250    : { ...run, status: 'finished', endedAt: at, nextAt: undefined }
251  await update($, runs, l => l.map(a => (a.id === run.id ? fired : a)))
252  await update($, now, () => at)
253}
254
255/**
256 * Lines added and removed for an edited file, from `git diff --numstat -- <file>`
257 * run in its folder (only inside a git work tree, 5 s at most); a new file
258 * git does not know yet counts the lines written. Best effort, never awaited by a tool call.
259 */
260async function readNumstat($: EngineInterface, path: string, turnOf: number, newLines?: number): Promise<void> {
261  try {
262    const dir = dirOf(path)
263    let isRepo = repoDirs.get(dir)
264    if (isRepo === undefined) {
265      const probe = await $.process.run(['git', 'rev-parse', '--is-inside-work-tree'], { cwd: dir, timeoutMs: NUMSTAT_TIMEOUT_MS })
266      isRepo = probe.exitCode === 0 && probe.stdout.trim() === 'true'
267      repoDirs.set(dir, isRepo)
268      if (repoDirs.size > 64) repoDirs.delete(repoDirs.keys().next().value ?? '')
269    }
270    if (!isRepo) return
271    const out = await $.process.run(['git', 'diff', '--numstat', '--', path], { cwd: dir, timeoutMs: NUMSTAT_TIMEOUT_MS })
272    const counts = parseNumstat(out.stdout) ?? (newLines !== undefined ? { plus: newLines, minus: 0 } : undefined)
273    if (!counts) return
274    // The file's record for the turn the edit was made in.
275    await update($, code, list => list.map(c => (c.path === path && c.turn === turnOf ? { ...c, ...counts } : c)))
276  } catch {
277    // No git, a timeout, or no state: the node keeps no counts.
278  }
279}
280
281/** Records what one tool call touched in the code map, then reads the numstat of each file it edited. */
282async function recordTouches($: EngineInterface, touches: readonly Touch[], by: string, isError: boolean, newLines?: number): Promise<void> {
283  if (touches.length === 0) return
284  const at = await $.clock.now()
285  const t = await read($, turn)
286  await update($, code, list => touches.reduce<CodeTouch[]>((l, x) => addTouch(l, { ...x, turn: t, at, by, isError }), [...list]))
287  if (isError) return
288  // git runs only under the session's folder: a repo elsewhere (its .git/config) is never entered.
289  const root = cwd.replace(/\/+$/, '') + '/'
290  for (const x of touches) if (x.isEdit && cwd !== '' && x.path.startsWith(root)) void readNumstat($, x.path, t, x.tool === 'Write' ? newLines : undefined)
291}
292
293/**
294 * The caffeinate hold: while `want`, one `caffeinate -i -t 600` child runs
295 * (spawned again once it ends while work is still in flight); stopped as soon
296 * as the in-flight set empties. Spawned and stopped from the tick only.
297 */
298async function holdAwake($: EngineInterface, want: boolean, at: number): Promise<void> {
299  if (want && awake === null && at >= awakeRetryAt) {
300    const child = $.process.spawn({ argv: CAFFEINATE })
301    awake = child
302    await update($, isCaffeinated, () => true)
303    void (async () => {
304      try {
305        for await (const _piece of child) {
306          // caffeinate writes nothing; the loop is the child's life.
307        }
308      } catch {
309        // It could not start, or it was stopped.
310      }
311      if (awake === child) {
312        awake = null
313        // Ended within a minute (it could not run): wait a minute before the next try.
314        const end = await $.clock.now().catch(() => at)
315        if (end - at < AWAKE_RETRY_MS) awakeRetryAt = end + AWAKE_RETRY_MS
316        await update($, isCaffeinated, () => false).catch(() => {})
317      }
318    })()
319  } else if (!want && awake !== null) {
320    const child = awake
321    awake = null
322    void child.return({ code: null, signal: 'SIGTERM' }).catch(() => {})
323    await update($, isCaffeinated, () => false)
324  }
325}
326
327/** ALL DONE: one toast, and the chime when the `sound` option is on, both only with `toasts` on. */
328function allDone($: EngineInterface, options: PluginOptions): void {
329  if (!isToastsOn(options.toasts)) return
330  try {
331    $.ui.toast(ALL_DONE)
332  } catch {
333    // A toast is a courtesy.
334  }
335  if (isSoundOn(options.sound)) void $.audio.play({ asset: SOUND_DONE }).catch(() => {})
336}
337
338/** The /diff runs the Code view asked for, one by one through $.command.run. */
339async function runDiffs($: EngineInterface): Promise<void> {
340  while (diffQueue.length > 0) {
341    const path = diffQueue.shift() ?? ''
342    try {
343      await $.command.run({ command: 'diff', args: path })
344    } catch {
345      // No /diff to run: nothing more to do.
346    }
347  }
348}
349
350/**
351 * True while the code map changes on its own: a node turns from editing to
352 * changed 30 s after its edit, and the ages count while the Code view is open.
353 */
354async function isCodeMoving($: EngineInterface, at: number): Promise<boolean> {
355  if ((await read($, code)).some(c => c.lastEditAt !== undefined && at - c.lastEditAt <= EDITING_MS + 1_000)) return true
356  if ((await read($, view)) !== 'code') return false
357  try {
358    return (await $.ui.panes()).some(p => p.id === PANE)
359  } catch {
360    return false
361  }
362}
363
364const stopFast = () => {
365  fast?.cancel()
366  fast = null
367}
368
369/**
370 * Once a second: while any run runs, the band shows (or lingers), or a finish
371 * flash or error pulse is still on, move `now` so the clocks and spinners redraw;
372 * one last move once all that is over, which hides the band and clears its
373 * skills. A 100 ms tick runs beside it while the pane is open or the band shows,
374 * unless motion is reduced, so the spinner and the flashes move smoothly.
375 */
376/** Drops ended runs past the `keepEnded` option, newest kept; called right after a run ends. */
377async function pruneAfterEnd($: EngineInterface, options: PluginOptions): Promise<void> {
378  const keep = keepEndedOf(options.keepEnded)
379  await update($, runs, list => pruneEnded(list, keep))
380}
381
382async function tick($: EngineInterface, options: PluginOptions): Promise<void> {
383  // Not awaited: a /diff that holds the screen must not hold the tick.
384  if (diffQueue.length > 0) void runDiffs($)
385  const list = await read($, runs)
386  const at = await $.clock.now()
387  // The Peers view reads herdr every 30 s, only while the pane shows it; not awaited, so a slow herdr never holds the tick.
388  void tickPeers($, at).catch(() => {})
389  // ALL DONE and the caffeinate hold follow the in-flight set: subagents, shells and watches.
390  const inFlight = list.some(isInFlight)
391  if (wasInFlight && !inFlight) allDone($, options)
392  wasInFlight = inFlight
393  await holdAwake($, inFlight && isCaffeinateOn(options.caffeinate), at)
394  const isBand = isBandOn(options.band) && batchOf(list, at, await read($, dismissedAt)).length > 0
395  const isLive = inFlight
396  const isFlashing = list.some(a => a.endedAt !== undefined && at - a.endedAt < PULSE_MS)
397  if (!isLive && !isBand && !isFlashing) {
398    stopFast()
399    if (await isCodeMoving($, at)) {
400      await update($, now, () => at)
401      return
402    }
403    if (wasActive) {
404      wasActive = false
405      await update($, now, () => at)
406      await update($, skills, () => ({}))
407    }
408    return
409  }
410  wasActive = true
411  if (ticks++ % MOTION_EVERY === 0) await readMotion($)
412  await update($, now, () => at)
413  let isPaneOpen = false
414  try {
415    isPaneOpen = (await $.ui.panes()).some(p => p.id === PANE)
416  } catch {
417    // No surface to ask: the band alone decides.
418  }
419  if (isPaneOpen) await pollTails($, list, at)
420  // The 100 ms tick turns spinners: not for a band that shows only a cron waiting to fire.
421  const wantsFast = !(await read($, isStill)) && (isPaneOpen || isBand) && (isLive || isFlashing || isPaneOpen)
422  if (!wantsFast) stopFast()
423  else if (fast === null) {
424    fast = $.clock.every(100, () => {
425      void $.clock
426        .now()
427        .then(t => update($, now, () => t))
428        .catch(() => {})
429    })
430  }
431}
432
433export const register: Register = (on, options) => {
434  on('session.start', async ($, e, next) => {
435    const started = await next(e)
436    cwd = typeof e.cwd === 'string' ? e.cwd : ''
437    // A reload ended the last module's caffeinate child with it: no ☕ until the tick holds again.
438    await update($, isCaffeinated, () => false)
439    await $.command.register({
440      name: 'agents-info',
441      description:
442        'Show or hide the agents pane: subagents, shells, watches and crons (`who`), the files touched (`code`, `scope`, `diff <n>`), the herdr peers (`peers`); `clear` drops the ended ones, `close` closes it',
443    })
444    const model = await readModel($)
445    if (model) await update($, main, m => ({ ...m, model }))
446    // The hub card's pose is picked by the session's id: one pose for the session.
447    const sessionId = await readSessionId($)
448    if (sessionId) await update($, main, m => ({ ...m, id: sessionId }))
449    await readMotion($)
450    // A reload re-runs session.start: trim the ended runs then too, not only when a run ends.
451    await pruneAfterEnd($, options)
452    slow?.cancel()
453    stopFast()
454    slow = $.clock.every(1000, () => {
455      void tick($, options).catch(() => {})
456    })
457    return started
458  })
459
460  on('command.run', { command: 'agents-info' }, async ($, e) => {
461    const [verb, card] = e.args.trim().split(/\s+/)
462    // `/agents-info code` and `/agents-info who`: the pane in that view, the twins of `c` and `w`.
463    if (verb === 'code' || verb === 'who') {
464      await openPane($, verb)
465      return { text: `Agents pane: ${verb === 'code' ? 'Code' : 'Who'} view.` }
466    }
467    // `/agents-info peers`: the pane in the Peers view, herdr read now; the twin of `p` and of `r`.
468    if (verb === 'peers') {
469      await openPane($, 'peers')
470      await pollPeers($)
471      return { text: (await herdrIds($)) ? 'Agents pane: Peers view (read every 30 s while shown; r reads now).' : NEEDS_HERDR }
472    }
473    // `/agents-info close`: the twin of `q`.
474    if (verb === 'close') return { text: (await closePane($)) ? 'Agents pane closed.' : 'The agents pane is not open.' }
475    // `/agents-info scope`: the code map's turn or session scope, the twin of `a`.
476    if (verb === 'scope') {
477      await update($, codeScope, s => (s === 'turn' ? 'session' : 'turn'))
478      return { text: `Code map scope: ${(await read($, codeScope)) === 'session' ? 'the whole session' : 'this turn'}.` }
479    }
480    // `/agents-info diff <n>`: /diff on the code map's node n, the twin of `d` + n.
481    if (verb === 'diff') {
482      const path = card ? await nodePath($, Number(card)) : undefined
483      if (!path) return { text: card ? `No file ${card} in the code map.` : 'Name the node: /agents-info diff <n>.' }
484      diffQueue.push(path)
485      return { text: `Diff of ${path} coming up.` }
486    }
487    // `/agents-info clear`: drop every finished or failed run (subagents and shells); running ones stay.
488    if (verb === 'clear') {
489      const before = (await read($, runs)).length
490      await update($, runs, list => pruneEnded(list, 0))
491      const after = (await read($, runs)).length
492      return { text: `Cleared ${before - after} ended run${before - after === 1 ? '' : 's'}.` }
493    }
494    // `/agents-info open <n>`: the numbered card's messages, the twin of its button; a stub until E3.
495    if (verb === 'open' && card) return { text: `Card ${card}: ${MESSAGES_STUB}.` }
496    // `/agents-info open` opens the pane, or brings it forward, without toggling it.
497    if (verb === 'open') {
498      await openPane($)
499      return { text: 'Agents pane opened.' }
500    }
501    return { text: (await togglePane($)) ? 'Agents pane opened.' : 'Agents pane closed.' }
502  })
503
504  // The Agent call carries the effort its spawn does not; the spawn reads it by tool_use_id.
505  on('tool.call', { tool: 'Agent' }, ($, e, next) => {
506    if (e.tool === 'Agent' && e.tool_use_id) {
507      pending.set(e.tool_use_id, { effort: e.effort })
508      if (pending.size > 64) pending.delete(pending.keys().next().value ?? '')
509    }
510    return next(e)
511  }).catch(($, e, next) => (next.called ? undefined : next(e)))
512
513  // A Bash call that resolves with a background task id (run_in_background, or
514  // moved there by ctrl+b or its timeout) starts a shell run; its notification ends it.
515  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
516    let startedAt = 0
517    try {
518      startedAt = await $.clock.now()
519    } catch {
520      // Stamped after the call instead.
521    }
522    const result = await next(e)
523    try {
524      if (e.tool !== 'Bash' || result.deny !== undefined) return result
525      // The files the command names, for the code map (foreground or background alike).
526      // A non-zero exit says nothing about its `>` target (`grep x f > hits.txt` wrote hits.txt): never drawn ✗.
527      if (typeof e.command === 'string') await recordTouches($, bashTouches(e.command, cwd), e.agentId ?? 'main', false)
528    } catch {
529      // The code map is best effort.
530    }
531    try {
532      if (e.tool !== 'Bash' || result.deny !== undefined) return result
533      const out = result.result as { backgroundTaskId?: unknown; backgroundEndsWithFinalResponse?: unknown } | undefined
534      const taskId = typeof out?.backgroundTaskId === 'string' ? out.backgroundTaskId : ''
535      if (!taskId) return result
536      const command = typeof e.command === 'string' ? e.command : ''
537      const at = startedAt || (await $.clock.now())
538      const parsed = labelOf(command)
539      const outputFile = outputFileOf(result.text)
540      const id = `sh-${taskId}`
541      const run: AgentRun = {
542        id,
543        kind: 'shell',
544        taskId,
545        // Clipped as recorded (labelOf read the whole command): a long heredoc is not held twice per run.
546        command: clip(command, COMMAND_MAX),
547        type: parsed.label ?? 'sh',
548        ...(parsed.label ? { label: parsed.label } : {}),
549        description: clip(parsed.task, DESCRIPTION_MAX),
550        model: parsed.model ?? '',
551        ...(outputFile ? { outputFile } : {}),
552        ...(e.agentId ? { parentAgentId: e.agentId } : {}),
553        ...(out?.backgroundEndsWithFinalResponse === true ? { endsWithParent: true } : {}),
554        status: 'running',
555        startedAt: at,
556        contextTokens: 0,
557        contextMax: 0,
558        tokens: 0,
559        costUsd: 0,
560        steps: 0,
561      }
562      await update($, runs, list => [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS))
563      await update($, now, () => at)
564    } catch {
565      // The books are best effort: the command goes on whatever happens here.
566    }
567    return result
568  }).catch(($, e, next) => (next.called ? undefined : next(e)))
569
570  // A background task's notification row ends its shell: ✓ completed, ✗ failed or killed.
571  on('session.append', async ($, e, next) => {
572    const stored = await next(e)
573    const kind = (e.origin as { kind?: string }).kind
574    if (kind === 'scheduled-trigger') {
575      try {
576        await cronFired($, textOfContent(e.message.content))
577      } catch {
578        // Best effort.
579      }
580      return stored
581    }
582    if (kind !== 'task-notification') return stored
583    try {
584      const text = textOfContent(e.message.content)
585      for (const ev of eventsOf(text)) await noteEvent($, ev.id, ev.event)
586      for (const note of notesOf(text)) await endShell($, note, options)
587    } catch {
588      // Best effort: the row is kept whatever happens here.
589    }
590    return stored
591  }).catch(($, e, next) => (next.called ? undefined : next(e)))
592
593  // A Monitor call starts a watch: its description, the target its command
594  // names and its deadline; its task notification ends it, as a shell's does.
595  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
596    const result = await next(e)
597    try {
598      if (e.tool !== 'Monitor' || result.deny !== undefined || result.isError) return result
599      const out = result.result as { taskId?: unknown; timeoutMs?: unknown; persistent?: unknown } | undefined
600      const taskId = typeof out?.taskId === 'string' ? out.taskId : ''
601      if (!taskId) return result
602      const at = await $.clock.now()
603      const command = typeof e.command === 'string' ? e.command : ''
604      const timeoutMs = typeof out?.timeoutMs === 'number' ? out.timeoutMs : 0
605      const target = targetOf(command)
606      const id = `w-${taskId}`
607      const run: AgentRun = {
608        id,
609        kind: 'watch',
610        taskId,
611        type: 'watch',
612        command: clip(command, COMMAND_MAX),
613        description: clip(typeof e.description === 'string' ? e.description : '', DESCRIPTION_MAX),
614        model: '',
615        ...(target ? { target } : {}),
616        ...(out?.persistent !== true && timeoutMs > 0 ? { nextAt: at + timeoutMs } : {}),
617        ...(e.agentId ? { parentAgentId: e.agentId } : {}),
618        status: 'running',
619        startedAt: at,
620        contextTokens: 0,
621        contextMax: 0,
622        tokens: 0,
623        costUsd: 0,
624        steps: 0,
625      }
626      await update($, runs, list => [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS))
627      await update($, now, () => at)
628    } catch {
629      // Best effort: the watch runs whatever happens here.
630    }
631    return result
632  }).catch(($, e, next) => (next.called ? undefined : next(e)))
633
634  // A CronCreate call starts a cron entry (next fire time, schedule, prompt); it
635  // ends when it fires once (a one-shot) or when CronDelete removes it.
636  on('tool.call', { tool: 'CronCreate' }, async ($, e, next) => {
637    const result = await next(e)
638    try {
639      if (e.tool !== 'CronCreate' || result.deny !== undefined || result.isError) return result
640      const out = result.result as { id?: unknown; humanSchedule?: unknown; recurring?: unknown; nextFireAt?: unknown } | undefined
641      const job = typeof out?.id === 'string' ? out.id : ''
642      if (!job) return result
643      const at = await $.clock.now()
644      const expr = typeof e.cron === 'string' ? e.cron : ''
645      const nextAt = typeof out?.nextFireAt === 'number' ? out.nextFireAt : cronNext(expr, at)
646      const id = `cron-${job}`
647      const run: AgentRun = {
648        id,
649        kind: 'cron',
650        type: 'cron',
651        target: job,
652        cron: expr,
653        schedule: typeof out?.humanSchedule === 'string' ? out.humanSchedule : expr,
654        isRecurring: out?.recurring !== false && e.recurring !== false,
655        description: clip(typeof e.prompt === 'string' ? e.prompt.trim() : '', DESCRIPTION_MAX),
656        model: '',
657        ...(nextAt !== undefined ? { nextAt } : {}),
658        status: 'running',
659        startedAt: at,
660        contextTokens: 0,
661        contextMax: 0,
662        tokens: 0,
663        costUsd: 0,
664        steps: 0,
665      }
666      await update($, runs, list => [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS))
667      await update($, now, () => at)
668    } catch {
669      // Best effort.
670    }
671    return result
672  }).catch(($, e, next) => (next.called ? undefined : next(e)))
673
674  on('tool.call', { tool: 'CronDelete' }, async ($, e, next) => {
675    const result = await next(e)
676    try {
677      if (e.tool !== 'CronDelete' || result.deny !== undefined || result.isError) return result
678      const id = `cron-${typeof e.id === 'string' ? e.id : ''}`
679      const at = await $.clock.now()
680      await update($, runs, list =>
681        list.map(a => (a.id === id && a.status === 'running' ? { ...a, status: 'finished' as const, endedAt: at, nextAt: undefined } : a)),
682      )
683      await update($, now, () => at)
684      await pruneAfterEnd($, options)
685    } catch {
686      // Best effort.
687    }
688    return result
689  }).catch(($, e, next) => (next.called ? undefined : next(e)))
690
691  // The code map: each file tool's call (main loop and subagents) records the file it touched.
692  // One hook for them all (MultiEdit, Grep and Glob are matched by name though this engine may not ship them).
693  const fileTools = new RegExp(`^(?:${FILE_TOOLS.join('|')})$`)
694  on('tool.call', { tool: fileTools }, async ($, e, next) => {
695    const result = await next(e)
696    const tool = e.tool
697    try {
698      // A refused call touched nothing; a failed read neither (a failed edit is drawn ✗).
699      if (result.deny !== undefined || (result.isError === true && !EDIT_TOOLS.has(tool))) return result
700      const input = e as unknown as Record<string, unknown>
701      const touches = toolTouches(tool, input, cwd)
702      const content = typeof input.content === 'string' ? input.content : ''
703      const created = (result.result as { type?: unknown } | undefined)?.type === 'create'
704      // A new file's lines, for its + count while git does not know it yet.
705      const newLines = tool === 'Write' && created ? (content === '' ? 0 : content.replace(/\n$/, '').split('\n').length) : undefined
706      await recordTouches($, touches, e.agentId ?? 'main', result.isError === true && EDIT_TOOLS.has(tool), newLines)
707    } catch {
708      // The code map is best effort: the call goes on whatever happens here.
709    }
710    return result
711  }).catch(($, e, next) => (next.called ? undefined : next(e)))
712
713  on('agent.spawn', async ($, e, next) => {
714    const started = await next(e)
715    if (started.deny !== undefined) return started
716    // Only a run with a local loop is kept: one with no agentId (a workflow's
717    // remote agent) never gets a step or an end here, so it would run for good.
718    const agentId = started.agentId
719    if (!agentId) {
720      pending.delete(e.tool_use_id)
721      return started
722    }
723    try {
724      const at = await $.clock.now()
725      const id = agentId
726      const call = pending.get(e.tool_use_id)
727      pending.delete(e.tool_use_id)
728      await update($, runs, list => {
729        const prev = list.find(a => a.id === id)
730        const run: AgentRun = {
731          id,
732          agentId,
733          type: e.subagentType || prev?.type || 'agent',
734          name: e.name ?? prev?.name,
735          description: e.description || prev?.description || '',
736          model: started.model,
737          effort: call?.effort ?? prev?.effort,
738          status: 'running',
739          startedAt: at,
740          contextTokens: prev?.contextTokens ?? 0,
741          contextMax: windowOf(started.model),
742          tokens: prev?.tokens ?? 0,
743          costUsd: prev?.costUsd ?? 0,
744          isUnpriced: prev?.isUnpriced,
745          steps: prev?.steps ?? 0,
746        }
747        return [...list.filter(a => a.id !== id), run].slice(-MAX_RUNS)
748      })
749      await update($, now, () => at)
750    } catch {
751      // The books are best effort: the spawn goes on whatever happens here.
752    }
753    return started
754  }).catch(($, e, next) => (next.called ? undefined : next(e)))
755
756  // The main loop's turn: its model and whether it runs. A woken subagent runs again.
757  on('turn.start', async ($, e, next) => {
758    const started = await next(e)
759    try {
760      const at = await $.clock.now()
761      if (e.agentId === undefined) {
762        const model = await readModel($)
763        // No session id read: the first main turn's id picks the hub pose instead, kept after.
764        await update($, main, m => ({ ...m, model: model || m.model, isRunning: true, startedAt: at, id: m.id || e.turnId }))
765        // A new main-loop turn: the code map's turn scope moves on (the turn before kept once).
766        await update($, turn, n => n + 1)
767      } else {
768        const agentId = e.agentId
769        if ((await read($, runs)).some(a => a.agentId === agentId && a.status !== 'running')) {
770          await updateRun($, agentId, a => ({ ...a, status: 'running', endedAt: undefined }))
771        }
772      }
773    } catch {
774      // Best effort.
775    }
776    return started
777  })
778
779  // Each model request of a subagent: one step, its context, tokens and cost.
780  on('turn.step', async function* ($, e, next) {
781    const agentId = e.agentId
782    const effort = typeof e.effort === 'string' ? e.effort : typeof e.effort === 'number' ? String(e.effort) : undefined
783    if (agentId) {
784      try {
785        const at = await $.clock.now()
786        await updateRun($, agentId, a => {
787          const cycle = a.stepStartedAt !== undefined ? Math.max(0, at - a.stepStartedAt) : undefined
788          return {
789            ...a,
790            effort: effort ?? a.effort,
791            stepStartedAt: at,
792            awaitingSince: undefined,
793            stepMsTotal: (a.stepMsTotal ?? 0) + (cycle ?? 0),
794            stepCycles: (a.stepCycles ?? 0) + (cycle === undefined ? 0 : 1),
795          }
796        })
797      } catch {
798        // Best effort.
799      }
800    }
801    const result = yield* next(e)
802    if (!agentId) return result
803    try {
804      const at = await $.clock.now()
805      const usage: Usage | null = result.usage
806      const model = result.usage?.model || e.model
807      await updateRun($, agentId, a => {
808        const cost = usage ? costOf(model, usage) : 0
809        return {
810          ...a,
811          model,
812          steps: a.steps + 1,
813          awaitingSince: result.toolUses.length > 0 ? at : undefined,
814          ...(usage
815            ? {
816                contextTokens: tokensOf(usage),
817                contextMax: windowOf(model),
818                tokens: a.tokens + tokensOf(usage),
819                costUsd: a.costUsd + (cost ?? 0),
820                isUnpriced: a.isUnpriced || cost === null || undefined,
821              }
822            : {}),
823        }
824      })
825    } catch {
826      // Best effort.
827    }
828    return result
829  })
830
831  on('turn.complete', async ($, e, next) => {
832    try {
833      const at = await $.clock.now()
834      const agentId = e.agentId
835      if (agentId === undefined) {
836        await update($, main, m => ({ ...m, isRunning: false }))
837      } else {
838        const before = (await read($, runs)).find(a => a.agentId === agentId)
839        await updateRun($, agentId, a => {
840          // A run whose steps went unseen still gets the turn's own sum.
841          const usage = a.steps === 0 ? e.usage : undefined
842          const cost = usage ? costOf(usage.model || a.model, usage) : 0
843          return {
844            ...a,
845            status: e.reason === 'answer' ? 'finished' : 'error',
846            endedAt: at,
847            stepStartedAt: undefined,
848            awaitingSince: undefined,
849            ...(usage
850              ? {
851                  model: usage.model || a.model,
852                  tokens: tokensOf(usage),
853                  costUsd: cost ?? 0,
854                  isUnpriced: cost === null || undefined,
855                }
856              : {}),
857          }
858        })
859        await update($, now, () => at)
860        if (before?.status === 'running') {
861          const ended = (await read($, runs)).find(a => a.agentId === agentId)
862          if (ended) toastEnd($, ended, options)
863        }
864        // Its shells that end with its final answer get no notification: they end here.
865        for (const sh of (await read($, runs)).filter(a => a.parentAgentId === agentId && a.endsWithParent && a.status === 'running')) {
866          if (sh.taskId) await endShell($, { id: sh.taskId, status: 'killed' }, options)
867        }
868        await pruneAfterEnd($, options)
869      }
870    } catch {
871      // Best effort.
872    }
873    return next(e)
874  })
875
876  // The Peers view's requests: the HERDR-ORCH envelope headers in this session's
877  // prompts (typed, pasted, a peer's message), kept once the prompt entered. Nothing is changed.
878  on('prompt.submit', async ($, e, next) => {
879    const r = await next(e)
880    try {
881      if (!e.command && r.drop === undefined) await noteEnvelopes($, e.text)
882    } catch {
883      // A failed note keeps the requests as they were.
884    }
885    return r
886  }).catch(($, e, next) => (next.called ? undefined : next(e)))
887
888  // Skills fired, for the band: counted once per expansion while its batch is
889  // live (a skill fired with no band up belongs to no batch), cleared when the band hides.
890  on('skill.prompt', async ($, e, next) => {
891    const result = await next(e)
892    try {
893      const at = await $.clock.now()
894      if (batchOf(await read($, runs), at, await read($, dismissedAt)).length === 0) return result
895      await update($, skills, s => ({ ...s, [e.skill]: (Object.hasOwn(s, e.skill) ? (s[e.skill] ?? 0) : 0) + 1 }))
896    } catch {
897      // Best effort.
898    }
899    return result
900  })
901
902  on('ui.render', { component: 'Pane', requestId: 'agents' }, ($, e) => drawPane($, e, options))
903  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawBand($, e, () => next(e), options))
904}
905
hooks/band.tsx 232 lines
1// The AGENTS band above the prompt, in the shape of the "Claude Code Mods"
2// deck (slide 15): a pink chip, the main loop's model while a turn runs, one
3// entry per run of the current batch (a subagent, or a background shell with
4// its elapsed time), the skills fired, then ×N (opens the pane) and ✕ (hides
5// the band until a new run starts). Its row sits in the zehui-mods stack at
6// AGENTS 50 (stackRow); other mods' bands follow.
7import { atom, read } from 'claude-code'
8import type { EngineInterface, PluginOptions, RenderElement, RenderInput } from 'claude-code'
9
10import type { AgentRun } from '../types'
11import {
12  MODEL_COLOR,
13  batchOf,
14  clip,
15  dismissBand,
16  elapsed,
17  familyOf,
18  fmtTime,
19  isBandOn,
20  isShell,
21  isStale,
22  isWatching,
23  modelTag,
24  openPane,
25  readRuns,
26  shellWords,
27  stackKey,
28  stackRow,
29  watchTime,
30} from './kit'
31
32const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
33const skills = atom({ plugin: 'agents', key: 'skills' } as const, {})
34const dismissedAt = atom({ plugin: 'agents', key: 'dismissedAt' } as const, 0)
35const isCaffeinated = atom({ plugin: 'agents', key: 'isCaffeinated' } as const, false)
36
37export const CHIP = ' ◎ AGENTS  ' // padded to the widest zehui-mods chip (HANDOFF) so the colour bars line up
38const CHIP_FG = '#0b0b0f'
39const CHIP_BG = '#f472b6'
40const RUNNING = '#f59e0b'
41const DONE = '#22c55e'
42const FAILED = '#ef4444'
43const SKILL = '#a3e635'
44/** A shell's words (`sh`, `codex gpt-5.4`): grey, so they never read as a Claude model. */
45export const SHELL_WORD = '#9a9a96'
46/** A stale entry's grey: a notification for a target already done. */
47export const STALE_GREY = '#6b7280'
48/** The caffeinate hold's mark at the end of the row. */
49export const CAFFEINE = '☕'
50/** The task text's cut. */
51export const TASK_MAX = 22
52/** Under this many columns the band takes its short form. */
53export const SHORT_BELOW = 40
54export const BAND_BAR = 6
55
56/** One run of text in the band's line: its words and how they are drawn. */
57type Seg = { text: string; color?: string; isDim?: boolean; isBold?: boolean; bg?: string }
58
59const glyphOf = (a: AgentRun): Seg =>
60  a.status === 'finished' ? { text: '✓', color: DONE } : a.status === 'error' ? { text: '✗', color: FAILED } : { text: '⟳', color: RUNNING }
61
62const modelSeg = (model: string, text: string): Seg => {
63  const color = MODEL_COLOR[familyOf(model)]
64  return color ? { text, color } : { text, isDim: true }
65}
66
67/** The words an entry starts with, the part the narrowest band cuts. */
68const labelOf = (a: AgentRun): string => (isWatching(a) ? (a.kind ?? 'watch') : isShell(a) ? shellWords(a) : a.name || a.type)
69
70/**
71 * One entry: type or name (cut to `labelMax`), model word, the task in quotes
72 * (unless dropped), the status glyph. A shell's: its grey words (`sh`, or the
73 * agent and model), the command or prompt in quotes, its elapsed time, the glyph.
74 */
75const entrySegs = (a: AgentRun, isTask: boolean, at: number, labelMax = Number.POSITIVE_INFINITY): Seg[] => {
76  if (isWatching(a)) {
77    // `watch deploy a15f4e9 ⏳ 12m`, `cron 20:30 in 41m`; ✓ or ✗ once ended; all grey while stale.
78    const segs: Seg[] = [{ text: clip(labelOf(a), labelMax), color: SHELL_WORD }]
79    if (a.kind === 'watch' && isTask && a.description) segs.push({ text: ' ' + clip(a.description, TASK_MAX), isDim: true })
80    if (a.kind === 'watch' && a.target) segs.push({ text: ' ' + a.target, isDim: true })
81    if (a.status === 'running') segs.push({ text: ' ' + watchTime(a, at) })
82    else segs.push({ text: ' ' }, glyphOf(a))
83    return isStale(a, at) ? segs.map(s => ({ text: s.text, color: STALE_GREY })) : segs
84  }
85  const task: Seg[] = isTask && a.description ? [{ text: ` "${clip(a.description, TASK_MAX)}"`, isDim: true }] : []
86  if (isShell(a)) {
87    return [{ text: clip(labelOf(a), labelMax), color: SHELL_WORD }, ...task, { text: ` ${fmtTime(elapsed(a, at))} ` }, glyphOf(a)]
88  }
89  return [{ text: clip(labelOf(a), labelMax) + ' ' }, modelSeg(a.model, familyOf(a.model) || 'model'), ...task, { text: ' ' }, glyphOf(a)]
90}
91
92const widthOf = (segs: readonly Seg[]): number => segs.reduce((w, s) => w + [...s.text].length, 0)
93
94export type BandParts = {
95  batch: readonly AgentRun[]
96  mainModel: string | null
97  skills: readonly [name: string, count: number][]
98  /** The time the shells' elapsed times are drawn at. */
99  at?: number
100}
101
102/**
103 * The full line fitted to `room` columns: task texts are dropped oldest first,
104 * then the skills down to one, then the tail of the entries folds into
105 * `+N more`, then the main segment shrinks to `main ⟳` and goes; last, the
106 * kept skill's name and the first entry's label are cut with `…`. The first
107 * entry and one skill always stay (at a very narrow room the line may still
108 * overrun, and the Text's truncate-end cuts its tail).
109 */
110export function fitLine(p: BandParts, room: number): Seg[] {
111  const n = p.batch.length
112  const hasTask = p.batch.map(() => true)
113  let skillCount = p.skills.length
114  let shown = n
115  let mainForm: 'full' | 'short' | 'none' = 'full'
116  let skillMax = Number.POSITIVE_INFINITY
117  let labelMax = Number.POSITIVE_INFINITY
118  const build = (): Seg[] => {
119    const segs: Seg[] = [{ text: CHIP, color: CHIP_FG, bg: CHIP_BG, isBold: true }, { text: ' ' }]
120    if (p.mainModel !== null && mainForm !== 'none') {
121      segs.push({ text: 'main ' })
122      if (mainForm === 'full') segs.push(modelSeg(p.mainModel, modelTag(p.mainModel)), { text: ' ' })
123      segs.push({ text: '⟳', color: RUNNING }, { text: ' │ ', isDim: true })
124    }
125    p.batch.slice(0, shown).forEach((a, i) => {
126      if (i) segs.push({ text: ' · ', isDim: true })
127      segs.push(...entrySegs(a, hasTask[i] ?? false, p.at ?? 0, i === 0 ? labelMax : undefined))
128    })
129    if (shown < n) segs.push({ text: ` +${n - shown} more`, isDim: true })
130    if (skillCount > 0) {
131      const list = p.skills
132        .slice(0, skillCount)
133        .map(([name, count]) => `${clip(name, skillMax)} ×${count}`)
134        .join(', ')
135      segs.push({ text: ' │ ', isDim: true }, { text: `skills: ${list}`, color: SKILL })
136    }
137    return segs
138  }
139  const over = (): number => widthOf(build()) - room
140  for (let i = 0; i < n && over() > 0; i++) hasTask[i] = false
141  if (over() > 0) skillCount = Math.min(skillCount, 1)
142  while (shown > 1 && over() > 0) shown--
143  if (p.mainModel !== null && over() > 0) mainForm = 'short'
144  if (p.mainModel !== null && over() > 0) mainForm = 'none'
145  const skill = p.skills[0]?.[0]
146  if (skillCount > 0 && skill !== undefined && over() > 0) skillMax = Math.max(2, skill.length - over())
147  const first = p.batch[0]
148  if (first && over() > 0) labelMax = Math.max(2, labelOf(first).length - over())
149  return build()
150}
151
152/** The short form's bar: `width` cells, filled by the share of the batch that ended. */
153export const shortBar = (batch: readonly AgentRun[], width = BAND_BAR): [filled: string, empty: string] => {
154  const ended = batch.filter(a => a.status !== 'running').length
155  const n = batch.length ? Math.round((ended / batch.length) * width) : 0
156  return ['▰'.repeat(n), '▱'.repeat(width - n)]
157}
158
159export async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>, pass: () => Promise<RenderElement>, options: PluginOptions) {
160  if (e.props.hasSurvey || !isBandOn(options.band) || e.surface !== 'terminal') return pass()
161  const { list, at } = await readRuns($)
162  const batch = batchOf(list, at, await read($, dismissedAt))
163  if (batch.length === 0) return pass()
164  const ordered = [...batch].sort((a, b) => a.startedAt - b.startedAt)
165  const m = await read($, main)
166  const fired = Object.entries(await read($, skills))
167  const isAwake = await read($, isCaffeinated)
168
169  // Other mods' bands beneath this row; stackRow puts this row in its slot
170  // among the zehui-mods rows and never nests the engine's own placeholder.
171  const below = await pass().catch(() => null)
172
173  const { Box, Text, Button } = $.ui.resolve(e)
174  const n = ordered.length
175  const crew = `×${n}`
176  const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
177  // Framed buttons: `[ ×N ]` and `[ ✕ ]`, each after a one-column gap; ☕ (two cells) after them while held.
178  const buttonsW = crew.length + 4 + 1 + 5 + 1 + (isAwake ? 3 : 0)
179  const isLive = ordered.some(a => a.status === 'running')
180
181  let line: JSX.Element
182  if (cols < SHORT_BELOW) {
183    const [filled, empty] = shortBar(ordered)
184    // Shells count too: with any in the batch, the word is `tasks`.
185    const noun = ordered.some(a => isShell(a) || isWatching(a)) ? (n === 1 ? 'task' : 'tasks') : n === 1 ? 'agent' : 'agents'
186    line = (
187      <Text wrap="truncate-end">
188        <Text color={isLive ? RUNNING : DONE}>{`● ${n} ${noun} `}</Text>
189        <Text color={DONE}>{filled}</Text>
190        <Text dimColor>{empty}</Text>
191      </Text>
192    )
193  } else {
194    const segs = fitLine({ batch: ordered, mainModel: m.isRunning ? m.model || 'main' : null, skills: fired, at }, cols - buttonsW)
195    line = (
196      <Text wrap="truncate-end">
197        {segs.map((s, i) => (
198          <Text
199            key={`seg-${i}`}
200            {...(s.color ? { color: s.color } : {})}
201            {...(s.bg ? { backgroundColor: s.bg } : {})}
202            {...(s.isBold ? { bold: true } : {})}
203            {...(s.isDim ? { dimColor: true } : {})}
204          >
205            {s.text}
206          </Text>
207        ))}
208      </Text>
209    )
210  }
211
212  const row = (
213    <Box key={stackKey('agents')} flexDirection="row" gap={1}>
214      <Box key="agents-line" flexGrow={1} flexShrink={1}>
215        {line}
216      </Box>
217      <Box flexShrink={0}>
218        <Button key="agents-crew" label={crew} onPress={() => void openPane($)} />
219      </Box>
220      <Box flexShrink={0}>
221        <Button key="agents-dismiss" label="✕" role="dismiss" onPress={() => void dismissBand($)} />
222      </Box>
223      {isAwake ? (
224        <Box key="agents-awake" flexShrink={0}>
225          <Text>{CAFFEINE}</Text>
226        </Box>
227      ) : null}
228    </Box>
229  )
230  return stackRow(row, below)
231}
232
hooks/codemap.ts 319 lines
1// The code map's books: which files the tools touched (Read, Edit, Write,
2// MultiEdit, NotebookEdit, Grep, Glob, and the paths a Bash command names),
3// one record per file and main-loop turn, the one-line summaries taken from
4// the edits themselves, `git diff --numstat` read back, and the scopes the
5// Code view draws (this turn, the turn before, or the whole session).
6// Plain data work, no engine calls; register.tsx keeps the books, code.tsx draws.
7import type { CodeScope, CodeState, CodeTouch } from '../types'
8import { lexWords } from './shells'
9
10/** A node is drawn `editing` (amber) this long after an edit. */
11export const EDITING_MS = 30_000
12/** A node's width in cells, its round border included. */
13export const NODE_W = 22
14/** The arrow between consecutive edited files. */
15export const ARROW = '──▶'
16/** Under this many columns the Code view is a tree list. */
17export const TREE_BELOW = 60
18/** Records kept: the oldest turns' go first (the current and previous turn's always stay). */
19export const CODE_MAX = 400
20/** The tools whose calls the map reads, besides Bash. */
21export const FILE_TOOLS = ['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Grep', 'Glob'] as const
22/** The tools that edit the file they name. */
23export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
24/** The `git diff --numstat` call's limit. */
25export const NUMSTAT_TIMEOUT_MS = 5_000
26
27/** The `summaries` option: `edit` (default), `haiku` (drawn as `edit`: see the README) or `off`. */
28export type Summaries = 'edit' | 'haiku' | 'off'
29export const summariesOf = (option: unknown): Summaries => (option === 'off' ? 'off' : option === 'haiku' ? 'haiku' : 'edit')
30
31/** One touch a tool call made: the file, read or edit, and by which tool. */
32export type Touch = { path: string; isEdit: boolean; tool: string; summary?: string }
33
34/** `/a/b/../c` → `/a/c`; a relative path joined to `cwd` first. */
35export function resolvePath(path: string, cwd: string): string {
36  const joined = path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`
37  const out: string[] = []
38  for (const part of joined.split('/')) {
39    if (part === '' || part === '.') continue
40    if (part === '..') out.pop()
41    else out.push(part)
42  }
43  return '/' + out.join('/')
44}
45
46/** A summary's length cap when recorded (a minified line must not bloat the state). */
47export const SUMMARY_MAX = 80
48const capped = (s: string | undefined): string | undefined => (s !== undefined && s.length > SUMMARY_MAX ? s.slice(0, SUMMARY_MAX - 1) + '…' : s)
49
50const firstLine = (text: string): string | undefined => capped(text.split('\n').map(l => l.trim()).find(l => l !== ''))
51
52/** The Edit's first changed line: the first line of the new text not in the old, else its first line. */
53export function editSummary(oldText: string, newText: string): string | undefined {
54  const old = new Set(oldText.split('\n').map(l => l.trim()))
55  return capped(
56    newText
57      .split('\n')
58      .map(l => l.trim())
59      .find(l => l !== '' && !old.has(l)),
60  ) ?? firstLine(newText)
61}
62
63const str = (v: unknown): string => (typeof v === 'string' ? v : '')
64
65/** What one file tool's call touched (Bash aside); empty for a call that names no path. */
66export function toolTouches(tool: string, input: Record<string, unknown>, cwd: string): Touch[] {
67  const isSearch = tool === 'Grep' || tool === 'Glob'
68  const path = str(input.file_path) || str(input.notebook_path) || (isSearch ? str(input.path) : '')
69  // Only a search's path can be a glob; an edited or read file's path is literal (`app/[id]/page.tsx`).
70  if (!path || (isSearch && /[*?[]/.test(path))) return []
71  const abs = resolvePath(path, cwd)
72  if (tool === 'Edit') return [{ path: abs, isEdit: true, tool, summary: editSummary(str(input.old_string), str(input.new_string)) }]
73  if (tool === 'MultiEdit') {
74    const first = Array.isArray(input.edits) ? (input.edits[0] as Record<string, unknown> | undefined) : undefined
75    return [{ path: abs, isEdit: true, tool, summary: first ? editSummary(str(first.old_string), str(first.new_string)) : undefined }]
76  }
77  if (tool === 'Write') return [{ path: abs, isEdit: true, tool, summary: firstLine(str(input.content)) }]
78  if (tool === 'NotebookEdit') return [{ path: abs, isEdit: true, tool, summary: firstLine(str(input.new_source)) }]
79  return [{ path: abs, isEdit: false, tool }]
80}
81
82const OPERATORS = new Set(['&&', '||', ';', '|', '&'])
83const base = (w: string): string => w.slice(w.lastIndexOf('/') + 1)
84/** A word that can be a file the map shows: no flag, glob, variable or device. */
85const isFile = (w: string): boolean => w !== '' && w !== '.' && w !== '-' && !w.startsWith('-') && !/[*?$`{}]/.test(w) && !w.startsWith('/dev/')
86
87/**
88 * The command with each heredoc's body dropped, up to its closing tag line:
89 * the line that opens it stays (`cat > notes.md <<'EOF'` still writes notes.md).
90 * `<<<` here-strings are left alone.
91 */
92export function withoutHeredocs(command: string): string {
93  const out: string[] = []
94  const tags: string[] = []
95  for (const line of command.split('\n')) {
96    if (tags.length > 0) {
97      if (line.replace(/^\t+/, '').trimEnd() === tags[0]) tags.shift()
98      continue
99    }
100    out.push(line)
101    for (const m of line.matchAll(/(?<![<\w])<<-?\s*(['"]?)([A-Za-z_][\w.-]*)\1/g)) tags.push(m[2] ?? '')
102  }
103  return out.join('\n')
104}
105
106/**
107 * The files a Bash command reads or writes, as far as its words say: `cat`
108 * reads, `sed -i` edits, `>` and `>>` write their target, `git add` reads,
109 * `git checkout -- <file>` and `git restore` rewrite, `cp` reads its sources
110 * and writes its target, `mv` writes its target. Anything else touches nothing.
111 */
112export function bashTouches(command: string, cwd: string): Touch[] {
113  const out: Touch[] = []
114  const add = (w: string, isEdit: boolean, tool: string) => {
115    if (isFile(w)) out.push({ path: resolvePath(w, cwd), isEdit, tool })
116  }
117  const segments: string[][] = [[]]
118  const words = lexWords(withoutHeredocs(command))
119  for (let k = 0; k < words.length; k++) {
120    const { text: w, isQuotedStart } = words[k] ?? { text: '', isQuotedStart: false }
121    if (OPERATORS.has(w) && !isQuotedStart) {
122      segments.push([])
123      continue
124    }
125    // A quoted `>` (`grep -c '>' f`) is an argument, not a redirect.
126    if (isQuotedStart) {
127      segments[segments.length - 1]?.push(w)
128      continue
129    }
130    // `>`/`>>` (stdout, or `1>`) write their file; `2>`, `<` and `>&` do not count.
131    const redirect = /^(1?)(>>?)(.*)$/.exec(w)
132    if (redirect && !w.startsWith('>&') && !/^\d*>&/.test(w)) {
133      const target = redirect[3] || words[++k]?.text || ''
134      add(target, true, 'Bash')
135      continue
136    }
137    if (/^\d*(?:[<>]{1,2}|&>{1,2})/.test(w)) {
138      if (/^\d*(?:[<>]{1,2}|&>{1,2})$/.test(w)) k++
139      continue
140    }
141    segments[segments.length - 1]?.push(w)
142  }
143  for (const seg of segments) {
144    let i = 0
145    while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(seg[i] ?? '') || seg[i] === 'sudo' || seg[i] === 'command') i++
146    const head = base(seg[i] ?? '')
147    const args = seg.slice(i + 1)
148    const files = args.filter(isFile)
149    if (head === 'cat') files.forEach(f => add(f, false, 'Bash'))
150    else if (head === 'sed' && args.some(a => /^-i|^--in-place|^-[a-zA-Z]*i/.test(a))) {
151      // The script is the first positional unless -e or -f gave it.
152      const hasScript = args.some(a => a === '-e' || a === '-f' || a.startsWith('--expression') || a.startsWith('--file'))
153      const pos: string[] = []
154      for (let j = 0; j < args.length; j++) {
155        const a = args[j] ?? ''
156        // BSD sed's `-i ''` (or `-i .bak`): the suffix word is not the script.
157        if (a === '-i' && (args[j + 1] === '' || args[j + 1]?.startsWith('.'))) j++
158        else if (a === '-e' || a === '-f') j++
159        else if (!a.startsWith('-')) pos.push(a)
160      }
161      pos.slice(hasScript ? 0 : 1).forEach(f => add(f, true, 'Bash'))
162    } else if (head === 'git') {
163      const sub = args.find(a => !a.startsWith('-'))
164      const rest = args.slice(args.indexOf(sub ?? '') + 1)
165      if (sub === 'add') rest.filter(isFile).forEach(f => add(f, false, 'git add'))
166      else if (sub === 'checkout') {
167        const dash = rest.indexOf('--')
168        if (dash >= 0) rest.slice(dash + 1).forEach(f => add(f, true, 'git checkout'))
169      } else if (sub === 'restore') rest.filter(isFile).forEach(f => add(f, true, 'git restore'))
170    } else if (head === 'cp' || head === 'mv') {
171      if (files.length < 2) continue
172      const target = files[files.length - 1] ?? ''
173      if (head === 'cp') files.slice(0, -1).forEach(f => add(f, false, 'Bash'))
174      add(target, true, 'Bash')
175    }
176  }
177  return out
178}
179
180/** Lines added and removed in `git diff --numstat` output; undefined when it names none (or only binaries). */
181export function parseNumstat(stdout: string): { plus: number; minus: number } | undefined {
182  let plus = 0
183  let minus = 0
184  let isAny = false
185  for (const line of stdout.split('\n')) {
186    const m = /^(\d+)\t(\d+)\t/.exec(line)
187    if (!m) continue
188    plus += Number(m[1])
189    minus += Number(m[2])
190    isAny = true
191  }
192  return isAny ? { plus, minus } : undefined
193}
194
195/** The parent folder of a path. */
196export const dirOf = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/'
197
198/**
199 * The records with one touch added: merged into the file's record for this
200 * turn (tools, who, last touch; for an edit its state, summary and edit
201 * times), or a new record. The oldest turns' records go past CODE_MAX.
202 */
203export function addTouch(list: readonly CodeTouch[], t: Touch & { turn: number; at: number; by: string; isError?: boolean }): CodeTouch[] {
204  const prev = list.find(c => c.path === t.path && c.turn === t.turn)
205  const rec: CodeTouch = prev
206    ? { ...prev, tools: [...prev.tools], by: [...prev.by] }
207    : { path: t.path, turn: t.turn, lastAt: t.at, state: 'read', tools: [], by: [] }
208  rec.lastAt = t.at
209  if (!rec.tools.includes(t.tool)) rec.tools.push(t.tool)
210  if (!rec.by.includes(t.by)) rec.by.push(t.by)
211  if (t.isEdit) {
212    rec.editedAt = rec.editedAt ?? t.at
213    rec.lastEditAt = t.at
214    rec.state = t.isError ? 'failed' : 'changed'
215    if (!t.isError && t.summary) rec.summary = t.summary
216  }
217  const next = prev ? list.map(c => (c === prev ? rec : c)) : [...list, rec]
218  if (next.length <= CODE_MAX) return next
219  const keepFrom = t.turn - 1
220  const old = next.filter(c => c.turn < keepFrom).sort((a, b) => a.turn - b.turn || a.lastAt - b.lastAt)
221  const drop = new Set(old.slice(0, next.length - CODE_MAX))
222  return next.filter(c => !drop.has(c))
223}
224
225/** A file as the map draws it: its records in the scope merged. */
226export type MapFile = {
227  path: string
228  editedAt?: number
229  lastEditAt?: number
230  lastAt: number
231  state: CodeState
232  plus?: number
233  minus?: number
234  tools: string[]
235  summary?: string
236  by: string[]
237  /** The main-loop turns it was touched in. */
238  turns: number[]
239}
240
241/** The map's scope: its files, the turn it shows (session: the current one), and whether it is the turn before. */
242export type ScopeView = { files: MapFile[]; turn: number; isPrevious: boolean; scope: CodeScope }
243
244/**
245 * The files the map shows: this turn's (the turn before's while this one
246 * touched nothing yet), or every turn's merged by path for the session. Edited
247 * files come first in edit order, then the read-only ones by last touch.
248 */
249export function scopeOf(list: readonly CodeTouch[], turn: number, scope: CodeScope): ScopeView {
250  let records = scope === 'session' ? [...list] : list.filter(c => c.turn === turn)
251  let isPrevious = false
252  if (scope === 'turn' && records.length === 0) {
253    const before = list.filter(c => c.turn < turn)
254    const last = before.length ? Math.max(...before.map(c => c.turn)) : undefined
255    if (last !== undefined) {
256      records = before.filter(c => c.turn === last)
257      isPrevious = true
258    }
259  }
260  const byPath = new Map<string, MapFile>()
261  for (const c of [...records].sort((a, b) => a.turn - b.turn || a.lastAt - b.lastAt)) {
262    const f = byPath.get(c.path)
263    if (!f) {
264      byPath.set(c.path, { ...c, tools: [...c.tools], by: [...c.by], turns: [c.turn] })
265      continue
266    }
267    f.lastAt = Math.max(f.lastAt, c.lastAt)
268    f.tools.push(...c.tools.filter(x => !f.tools.includes(x)))
269    f.by.push(...c.by.filter(x => !f.by.includes(x)))
270    if (!f.turns.includes(c.turn)) f.turns.push(c.turn)
271    if (c.editedAt !== undefined) {
272      f.editedAt = f.editedAt ?? c.editedAt
273      f.lastEditAt = c.lastEditAt
274      f.state = c.state
275      f.summary = c.summary ?? f.summary
276      if (c.plus !== undefined) {
277        f.plus = c.plus
278        f.minus = c.minus
279      }
280    }
281  }
282  const all = [...byPath.values()]
283  const edited = all.filter(f => f.editedAt !== undefined).sort((a, b) => (a.editedAt ?? 0) - (b.editedAt ?? 0))
284  const read = all.filter(f => f.editedAt === undefined).sort((a, b) => b.lastAt - a.lastAt)
285  return { files: [...edited, ...read], turn: isPrevious ? Math.max(0, ...records.map(c => c.turn)) : turn, isPrevious, scope }
286}
287
288/** What a node draws for its state now: `editing` for 30 s after an edit that did not fail. */
289export type ShownState = CodeState | 'editing'
290export const shownState = (f: Pick<MapFile, 'state' | 'lastEditAt'>, at: number): ShownState =>
291  f.state !== 'failed' && f.lastEditAt !== undefined && at - f.lastEditAt < EDITING_MS ? 'editing' : f.state
292
293/** How many nodes (each with its arrow) fit across `cols`; at least one. */
294export const nodesPerRow = (cols: number): number => Math.max(1, Math.floor((cols + ARROW.length) / (NODE_W + ARROW.length)))
295
296/** Each file's short name: its base name, with its parent folder when another file in view shares the base name. */
297export function namesOf(paths: readonly string[]): Map<string, string> {
298  const count = new Map<string, number>()
299  for (const p of paths) count.set(base(p), (count.get(base(p)) ?? 0) + 1)
300  return new Map(
301    paths.map(p => {
302      const b = base(p)
303      if ((count.get(b) ?? 0) < 2) return [p, b]
304      const dir = dirOf(p)
305      return [p, `${base(dir)}/${b}`]
306    }),
307  )
308}
309
310/** A path shown relative to `cwd` when under it. */
311export const relPath = (path: string, cwd: string): string => {
312  const root = cwd.replace(/\/+$/, '') + '/'
313  return cwd && path.startsWith(root) ? path.slice(root.length) : path
314}
315
316/** The tools as initials, in first-use order (`ER`), Bash as `B`, git's as `G`. */
317export const toolMarks = (tools: readonly string[]): string =>
318  [...new Set(tools.map(t => (t.startsWith('git') ? 'G' : t === 'MultiEdit' ? 'M' : t === 'NotebookEdit' ? 'N' : t.charAt(0))))].join('')
319
hooks/kit.ts 484 lines
1// Shared by register.tsx, skin.tsx and band.tsx: the state, the price table,
2// the tier colours and the small formatters.
3// The state atoms are declared again in each file that reads or writes them:
4// the engine reads a state source only from a const of the calling file.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface } from 'claude-code'
7
8import type { AgentRun, MainLoop, PaneView } from '../types'
9import { SHELL_POSE, codexPose, donePose, errorPose, runningPose, waitPose } from './mascots'
10import type { Pose } from './mascots'
11import { lastLineOf } from './shells'
12import { STALE_MS, fmtClock, fmtLeft } from './watch'
13import type { RenderElement, RenderNode } from 'claude-code'
14
15const runs = atom({ plugin: 'agents', key: 'runs' } as const, [])
16const skills = atom({ plugin: 'agents', key: 'skills' } as const, {})
17const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
18const now = atom({ plugin: 'agents', key: 'now' } as const, 0)
19const dismissedAt = atom({ plugin: 'agents', key: 'dismissedAt' } as const, 0)
20const isCompact = atom({ plugin: 'agents', key: 'isCompact' } as const, false)
21const isStill = atom({ plugin: 'agents', key: 'isStill' } as const, false)
22const tails = atom({ plugin: 'agents', key: 'tails' } as const, {})
23const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
24const isDiffArmed = atom({ plugin: 'agents', key: 'isDiffArmed' } as const, false)
25
26export const PANE = 'agents'
27export const PANE_TITLE = 'Agents'
28/** The band stays up this long after the last run of its batch ends. */
29export const LINGER_MS = 10_000
30/** A run awaiting a tool's result this long sleeps. */
31export const WAIT_MS = 20_000
32/** The bar's bright flash on finish, and the amber pulse on error. */
33export const FLASH_MS = 600
34export const PULSE_MS = 2_000
35/** Runs kept in state; the oldest beyond this are dropped. */
36export const MAX_RUNS = 200
37/** Ended runs (finished or failed) the pane keeps by default; the `keepEnded` option. */
38export const KEEP_ENDED = 10
39/** The `keepEnded` option as a count: a whole number, else the default. */
40export const keepEndedOf = (option: unknown): number => {
41  const n = Number(option)
42  return Number.isInteger(n) && n >= 0 ? n : KEEP_ENDED
43}
44/** The list with only the newest `keep` ended runs (by end time); running runs always stay. */
45export const pruneEnded = (list: readonly AgentRun[], keep: number): AgentRun[] => {
46  const ended = list.filter(a => a.status !== 'running').sort((x, y) => (y.endedAt ?? 0) - (x.endedAt ?? 0))
47  const drop = new Set(ended.slice(keep).map(a => a.id))
48  return drop.size === 0 ? [...list] : list.filter(a => !drop.has(a.id))
49}
50/** A shell's output file over this size is not read for its last line. */
51export const TAIL_MAX_BYTES = 1_048_576
52/** What a numbered card button says until the messages view (E3) lands. */
53export const MESSAGES_STUB = 'messages view comes with E3'
54/** The ALL DONE toast, when the last subagent, shell or watch in flight ends. */
55export const ALL_DONE = '★ all background work done'
56/** The rising chime played with it (copied from the peers mod). */
57export const SOUND_DONE = 'assets/done.wav'
58/** The caffeinate hold's command: idle sleep held, for 600 s at most (the tick re-arms it while work runs). */
59export const CAFFEINATE = ['caffeinate', '-i', '-t', '600'] as const
60/** The `caffeinate` option: anything but `off` is on (default on). */
61export const isCaffeinateOn = (option: unknown): boolean => option !== 'off'
62/** The `sound` option: only `on` is on (default off). */
63export const isSoundOn = (option: unknown): boolean => option === 'on'
64
65/**
66 * Files the Code view's `d` + number (or `/agents-info diff <n>`) asked /diff
67 * for; the 1 s tick runs them through $.command.run, never the press itself.
68 * A reload drops the queue with the environment.
69 */
70export const diffQueue: string[] = []
71
72// --- tiers: the savvy-flow tier colours (savvy-progress register.tsx, Zehui's copy)
73
74export const TIER_COLOR: Record<string, string> = {
75  fable: '#7F77DD',
76  heavy: '#D85A30',
77  careful: '#BA7517',
78  medium: '#378ADD',
79  light: '#1D9E75',
80  other: '#888780',
81}
82
83/**
84 * A savvy-flow worker's tier comes from its type (`savvy-careful`); any other
85 * run's from its model and effort, the way the savvy tiers are defined
86 * (fable: Fable; heavy: Opus xhigh; careful: Opus high; medium: Opus medium;
87 * light: Opus low), with Sonnet as medium and Haiku as light.
88 */
89export const tierOf = (a: Pick<AgentRun, 'type' | 'model' | 'effort'>): string => {
90  const bare = a.type.replace(/^[^:]*:/, '')
91  const savvy = /^savvy-(\w+)$/i.exec(bare)?.[1]?.toLowerCase()
92  if (savvy && Object.hasOwn(TIER_COLOR, savvy)) return savvy
93  const family = familyOf(a.model)
94  if (family === 'fable' || family === 'mythos') return 'fable'
95  if (family === 'opus') {
96    if (a.effort === 'xhigh' || a.effort === 'max') return 'heavy'
97    if (a.effort === 'high') return 'careful'
98    if (a.effort === 'low') return 'light'
99    return 'medium'
100  }
101  if (family === 'sonnet') return 'medium'
102  if (family === 'haiku') return 'light'
103  return 'other'
104}
105
106export const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
107
108// --- models
109
110/** The deck's model colours (the "Claude Code Mods" deck, slide 15). */
111export const MODEL_COLOR: Record<string, string> = {
112  fable: '#d16bff',
113  mythos: '#d16bff',
114  opus: '#ff8c1a',
115  sonnet: '#3d9bff',
116  haiku: '#19c3a6',
117}
118
119const MODEL_RE = /(fable|mythos|opus|sonnet|haiku)(?:-(\d+)(?:-(\d{1,2})(?!\d))?)?/i
120
121/** `opus` for `claude-opus-5-5` (or the alias `opus`); '' for an unknown model. */
122export const familyOf = (model: string): string => MODEL_RE.exec(model)?.[1]?.toLowerCase() ?? ''
123
124/** `5.5` for `claude-opus-5-5`; '' when the id names no version. */
125export const versionOf = (model: string): string => {
126  const m = MODEL_RE.exec(model)
127  return m?.[2] ? `${m[2]}${m[3] ? '.' + m[3] : ''}` : ''
128}
129
130/** `Opus 5.5`: the card's model name; the id itself (less `claude-`) when unknown. */
131export const modelName = (model: string): string => {
132  const family = familyOf(model)
133  if (!family) return model.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
134  const v = versionOf(model)
135  return `${family.charAt(0).toUpperCase()}${family.slice(1)}${v ? ' ' + v : ''}`
136}
137
138/** `fable·5.1`: the band's word for the main loop's model. */
139export const modelTag = (model: string): string => {
140  const family = familyOf(model)
141  if (!family) return modelName(model)
142  const v = versionOf(model)
143  return v ? `${family}·${v}` : family
144}
145
146// USD per million tokens: input, output, cache read, cache write. From the
147// claude-api skill bundled with Claude Code 2.1.303 (model table cached
148// 2026-10-06; cache reads as listed there; cache writes at the 5-minute rate,
149// 1.25x input, since the engine reports one write count with no TTL split).
150type Price = readonly [input: number, output: number, cacheRead: number, cacheWrite: number]
151
152const PRICES: Record<string, Price> = {
153  'fable-5.1': [10, 50, 0.25, 12.5],
154  'mythos-5.1': [10, 50, 0.25, 12.5],
155  'fable-5': [10, 50, 1, 12.5],
156  'mythos-5': [10, 50, 1, 12.5],
157  'opus-5.5': [4, 20, 0.2, 5],
158  'opus-5': [5, 25, 0.5, 6.25],
159  'opus-4.8': [5, 25, 0.5, 6.25],
160  'opus-4.7': [5, 25, 0.5, 6.25],
161  'opus-4.6': [5, 25, 0.5, 6.25],
162  'sonnet-5.5': [2, 10, 0.2, 2.5],
163  'sonnet-5': [2, 10, 0.2, 2.5],
164  'sonnet-4.6': [3, 15, 0.3, 3.75],
165  'haiku-5.5': [0.1, 0.5, 0.01, 0.125],
166  'haiku-4.5': [1, 5, 0.1, 1.25],
167}
168// Haiku 5.5 is priced by prompt length: a request whose prompt (input and cache
169// reads and writes) is over 100K tokens pays these instead.
170const HAIKU_55_LONG: Price = [0.5, 2.5, 0.05, 0.625]
171const HAIKU_55_LONG_PROMPT = 100_000
172
173export type Usage = {
174  input_tokens: number
175  output_tokens: number
176  cache_read_input_tokens: number
177  cache_creation_input_tokens: number
178}
179
180export const promptOf = (u: Usage): number =>
181  (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
182
183export const tokensOf = (u: Usage): number => promptOf(u) + (u.output_tokens || 0)
184
185/** One request's cost in USD, or null for a model the table does not know. */
186export const costOf = (model: string, u: Usage): number | null => {
187  const key = `${familyOf(model)}-${versionOf(model)}`
188  const base = PRICES[key]
189  if (!base) return null
190  const [i, o, r, w] = key === 'haiku-5.5' && promptOf(u) > HAIKU_55_LONG_PROMPT ? HAIKU_55_LONG : base
191  return (
192    ((u.input_tokens || 0) * i + (u.output_tokens || 0) * o + (u.cache_read_input_tokens || 0) * r + (u.cache_creation_input_tokens || 0) * w) / 1e6
193  )
194}
195
196/** Haiku 4.5 and the Claude 3 models have a 200K window; the rest 1M. */
197export const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
198
199// --- formatting
200
201export const fmtTokens = (n: number): string =>
202  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
203
204export const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
205
206/** `≈$0.09`, or `≈ ?` for a run on a model with no price. */
207export const costText = (a: Pick<AgentRun, 'costUsd' | 'isUnpriced'>): string => (a.isUnpriced ? '≈ ?' : '≈' + fmtCost(a.costUsd))
208
209export const fmtTime = (ms: number): string => {
210  const s = Math.max(0, Math.round(ms / 1000))
211  const h = Math.floor(s / 3600)
212  const m = Math.floor((s % 3600) / 60)
213  const ss = String(s % 60).padStart(2, '0')
214  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
215}
216
217export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
218
219export const ctxPct = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
220
221/** Cuts `s` to `max` characters, the last an ellipsis. */
222export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
223
224// --- run state
225
226export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
227/** The braille spinner's frame at `at` (one step per 100 ms); `●` when motion is reduced. */
228export const spinnerAt = (at: number, still: boolean): string => (still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●'))
229
230export const isWaiting = (a: AgentRun, at: number): boolean =>
231  a.status === 'running' && a.awaitingSince !== undefined && at - a.awaitingSince > WAIT_MS
232
233/** True for a background shell's run. */
234export const isShell = (a: Pick<AgentRun, 'kind'>): boolean => a.kind === 'shell'
235
236/** True for a Monitor watcher's or a CronCreate timer's run: the pane lists them under Watching. */
237export const isWatching = (a: Pick<AgentRun, 'kind'>): boolean => a.kind === 'watch' || a.kind === 'cron'
238
239/** In flight: a subagent, shell or watch still running (a cron waiting to fire is not). */
240export const isInFlight = (a: Pick<AgentRun, 'kind' | 'status'>): boolean => a.status === 'running' && a.kind !== 'cron'
241
242/** True while a stale notification greys the entry. */
243export const isStale = (a: Pick<AgentRun, 'staleAt'>, at: number): boolean => a.staleAt !== undefined && at - a.staleAt < STALE_MS
244
245/**
246 * A watch's or cron's time words: `⏳ 12m` left before a watch's deadline
247 * (its elapsed time when persistent), `20:30 in 41m` for a cron's next fire
248 * (its schedule when that is unknown).
249 */
250export const watchTime = (a: AgentRun, at: number): string => {
251  if (a.kind === 'cron') return a.nextAt !== undefined ? `${fmtClock(a.nextAt)} in ${fmtLeft(a.nextAt - at)}` : (a.schedule ?? '')
252  return a.nextAt !== undefined ? `⏳ ${fmtLeft(a.nextAt - at)}` : `⏳ ${fmtTime(elapsed(a, at))}`
253}
254
255/**
256 * The pose a run's card shows now: a pick from its state's pool by its run id,
257 * so it never changes pose within a state. A shell keeps the terminal window
258 * (a codex fuzzy for a coding agent's CLI).
259 */
260export const poseOf = (a: AgentRun, at: number): Pose =>
261  isShell(a)
262    ? a.label
263      ? codexPose(a.id)
264      : SHELL_POSE
265    : a.status === 'finished'
266      ? donePose(a.id)
267      : a.status === 'error'
268        ? errorPose(a.id)
269        : isWaiting(a, at)
270          ? waitPose(a.id)
271          : runningPose(a.id)
272
273/** The id the main loop's hub pose is picked by: the session's id, else its first main turn's, else `main`. */
274export const mainPoseId = (m: Pick<MainLoop, 'id'>): string => m.id || 'main'
275
276/**
277 * Each card's number, 1 up, in pane order: Running, Finished, then Error
278 * (watches and crons are not numbered). The Code view's subagents row uses the same.
279 */
280export const cardNumbers = (list: readonly AgentRun[]): Map<string, number> => {
281  const cards = list.filter(a => !isWatching(a))
282  const ordered = [...cards.filter(a => a.status === 'running'), ...cards.filter(a => a.status === 'finished'), ...cards.filter(a => a.status === 'error')]
283  return new Map(ordered.map((a, i) => [a.id, i + 1]))
284}
285
286/** A shell's words before its task: `sh`, or the agent and its model (`codex gpt-5.4`). */
287export const shellWords = (a: Pick<AgentRun, 'label' | 'model'>): string =>
288  a.label ? `${a.label}${a.model ? ' ' + clip(a.model, 16) : ''}` : 'sh'
289
290/** The toast's cut of a task text. */
291export const TOAST_TASK_MAX = 40
292
293/**
294 * The toast for a run that ended: `✓ Explore "survey the mods" finished in
295 * 0:31`; `✗ sh "pytest -q tests/" exit 1 after 0:42`, `killed after`, or
296 * `failed after` when no exit code is known.
297 */
298export const toastText = (a: AgentRun): string => {
299  if (isWatching(a)) {
300    const what = a.description ? ` "${clip(a.description, TOAST_TASK_MAX)}"` : ''
301    const span = fmtTime(elapsed(a, a.endedAt ?? a.startedAt))
302    return a.status === 'finished' ? `✓ ${a.kind}${what} ended after ${span}` : `✗ ${a.kind}${what} ${a.endWord ?? 'failed'} after ${span}`
303  }
304  const who = isShell(a) ? shellWords(a) : a.name || a.type
305  const task = a.description ? ` "${clip(a.description, TOAST_TASK_MAX)}"` : ''
306  const t = fmtTime(elapsed(a, a.endedAt ?? a.startedAt))
307  if (a.status === 'finished') return `✓ ${who}${task} finished in ${t}`
308  const how = a.endWord === 'killed' ? 'killed' : a.exitCode !== undefined ? `exit ${a.exitCode}` : 'failed'
309  return `✗ ${who}${task} ${how} after ${t}`
310}
311
312/** The `toasts` option: anything but `off` is on. */
313export const isToastsOn = (option: unknown): boolean => option !== 'off'
314
315/** True while the step in flight has taken twice the average of the run's finished steps. */
316export const isSlowStep = (a: AgentRun, at: number): boolean => {
317  const cycles = a.stepCycles ?? 0
318  if (a.status !== 'running' || cycles < 2 || a.stepStartedAt === undefined) return false
319  return at - a.stepStartedAt > (2 * (a.stepMsTotal ?? 0)) / cycles
320}
321
322const isLive = (a: AgentRun, at: number): boolean =>
323  a.status === 'running' || (a.endedAt !== undefined && at - a.endedAt <= LINGER_MS) || isStale(a, at)
324
325/**
326 * The band's batch: the live runs (running, or ended within LINGER_MS) plus the
327 * runs that ended at or after the earliest live start, one level only. Empty
328 * when nothing is live, or when no run of it started after `dismissed`.
329 */
330export function batchOf(list: readonly AgentRun[], at: number, dismissed = 0): AgentRun[] {
331  const live = list.filter(a => isLive(a, at))
332  if (live.length === 0) return []
333  // A watch or cron can wait for hours: it anchors the batch only when nothing else is live.
334  const anchors = live.filter(a => !isWatching(a))
335  const from = Math.min(...(anchors.length ? anchors : live).map(a => a.startedAt))
336  const batch = list.filter(a => isLive(a, at) || (a.endedAt ?? Number.POSITIVE_INFINITY) >= from)
337  return batch.some(a => a.startedAt > dismissed) ? batch : []
338}
339
340/** The time a drawing is drawn at: the tick's, or a later event's. */
341export const drawTime = (list: readonly AgentRun[], tick: number): number =>
342  Math.max(tick, 0, ...list.map(a => Math.max(a.startedAt, a.endedAt ?? 0)))
343
344/** The `band` option: anything but `off` is on. */
345export const isBandOn = (option: unknown): boolean => option !== 'off'
346
347/**
348 * The last output line of each ended shell the pane has none for (one that
349 * ended while the pane was closed, past the tick's linger reads), read once
350 * as the pane opens; never a file over TAIL_MAX_BYTES. Best effort.
351 */
352async function readEndedTails($: EngineInterface): Promise<void> {
353  try {
354    const list = await read($, runs)
355    const have = await read($, tails)
356    const found: Record<string, string> = {}
357    for (const a of list) {
358      if (!isShell(a) || !a.outputFile || a.status === 'running' || have[a.id] !== undefined) continue
359      try {
360        if ((await $.fs.stat(a.outputFile)).size > TAIL_MAX_BYTES) continue
361        const line = lastLineOf(await $.fs.read(a.outputFile))
362        if (line !== undefined) found[a.id] = line
363      } catch {
364        // No file any more: the card keeps its '…'.
365      }
366    }
367    if (Object.keys(found).length === 0) return
368    const ids = new Set(list.map(r => r.id))
369    await update($, tails, t => ({ ...Object.fromEntries(Object.entries(t).filter(([id]) => ids.has(id))), ...found }))
370  } catch {
371    // No state or files to read: the cards keep what they had.
372  }
373}
374
375/**
376 * Opens the pane (in `to`'s view when given), the tick's time brought up to
377 * date first, then fills the ended shells' last lines.
378 */
379export async function openPane($: EngineInterface, to?: PaneView): Promise<void> {
380  const at = await $.clock.now()
381  await update($, now, () => at)
382  if (to !== undefined) await setView($, to)
383  await $.ui.open({ id: PANE, title: PANE_TITLE })
384  await readEndedTails($)
385}
386
387/** Switches the pane's view (`w` Who, `c` Code); the diff pick is dropped. */
388export async function setView($: EngineInterface, to: PaneView): Promise<void> {
389  await update($, view, () => to)
390  await update($, isDiffArmed, () => false)
391}
392
393/** Closes the pane (`q`, `/agents-info close`); false when it was not open. */
394export async function closePane($: EngineInterface): Promise<boolean> {
395  if (!(await $.ui.panes()).some(p => p.id === PANE)) return false
396  await $.ui.close({ id: PANE })
397  return true
398}
399
400/** Toggles the pane; true when it is open afterwards. */
401export async function togglePane($: EngineInterface): Promise<boolean> {
402  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
403  if (isOpen) {
404    await $.ui.close({ id: PANE })
405    return false
406  }
407  await openPane($)
408  return true
409}
410
411/** Hides the band until a new run starts, and clears its skills. */
412export async function dismissBand($: EngineInterface): Promise<void> {
413  const at = await $.clock.now()
414  await update($, dismissedAt, () => at)
415  await update($, skills, () => ({}))
416}
417
418/** The shells' last output lines, by run id (subscribing the drawing to them). */
419export async function readTails($: EngineInterface): Promise<Readonly<Record<string, string>>> {
420  return read($, tails)
421}
422
423/** The runs and the tick's time, read together for a drawing (subscribing it to both). */
424export async function readRuns($: EngineInterface): Promise<{ list: readonly AgentRun[]; at: number }> {
425  const list = await read($, runs)
426  return { list, at: drawTime(list, await read($, now)) }
427}
428
429// Stack order of the zehui-mods band rows (plan 2026-10-20-boards-codemap-recap,
430// section 0): SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70,
431// NEXT 80. Copied unchanged from the canonical
432// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx; change it
433// there first, then in every band mod.
434
435/** The rows each zehui-mods band draws, by stack order. */
436export const STACK = { sys: 10, plan: 20, handoff: 30, cache: 40, agents: 50, goal: 60, here: 70, next: 80 } as const
437
438/** A row's key: `zm-<order>-<name>`. */
439export const stackKey = (name: keyof typeof STACK): string => `zm-${STACK[name]}-${name}`
440
441type Loose = { type?: unknown; props?: Record<string, unknown> | null; part?: unknown; children?: unknown }
442
443/** The order of a zehui-mods row (`zm-<order>-<name>`), or null for any other node. */
444export const stackOrderOf = (node: unknown): number | null => {
445  const key = node && typeof node === 'object' ? (node as Loose).props?.key : undefined
446  const m = typeof key === 'string' ? /^zm-(\d+)-/.exec(key) : null
447  return m ? Number(m[1]) : null
448}
449
450/**
451 * The column a band hook beneath built: a Box whose only prop is
452 * `flexDirection: 'column'` and whose direct children hold a zm row. A Box with
453 * other props (a border, a padding) is another mod's own drawing, kept whole.
454 */
455const isStack = (node: Loose): node is Loose & { children: unknown[] } =>
456  node.type === 'Box' &&
457  node.props?.flexDirection === 'column' &&
458  Object.keys(node.props).every(k => k === 'flexDirection') &&
459  Array.isArray(node.children) &&
460  node.children.some(c => stackOrderOf(c) !== null)
461
462/**
463 * `row` put in its slot among the zm rows of `below` (what `next(e)` drew):
464 * zm rows sorted by their number, then every other child in its original order.
465 * Nothing beneath (null, a failed next, the engine placeholder): the row alone.
466 */
467export function stackRow(row: RenderElement, below: unknown): RenderElement {
468  const tree = below && typeof below === 'object' && (below as Loose).type !== 'engine' ? (below as Loose) : null
469  if (!tree) return row
470  let items: unknown[] = [tree]
471  if (isStack(tree)) {
472    items = [...tree.children]
473    // The column's mark (whose drawing started there) moves to the one row
474    // that carries none, its own; the engine lets a mark move, never multiply.
475    const bare = items.filter(c => c && typeof c === 'object' && !(c as Loose).part)
476    if (tree.part && bare.length === 1) items = items.map(c => (c === bare[0] ? { ...(c as object), part: tree.part } : c))
477  }
478  const rows = items.filter(c => stackOrderOf(c) !== null)
479  const others = items.filter(c => stackOrderOf(c) === null)
480  // A stable sort: two rows of one number keep their order, this row after.
481  const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0))
482  return { type: 'Box', props: { flexDirection: 'column' }, children: [...sorted, ...others] as RenderNode[] }
483}
484
hooks/shells.ts 281 lines
1// Background shells: what a command runs (a coding agent's CLI or a plain
2// shell), the task notification that ends one, and the last line of its output.
3// Plain string work, no engine calls; register.tsx keeps the books.
4
5/** What a shell's command is labelled with: the coding agent and its model, and the task text. */
6export type ShellLabel = {
7  /** `codex`, `opencode` or `claude`; absent for a plain shell. */
8  label?: string
9  /** The model named by `-m`/`--model` (codex also `-c model=`), when given. */
10  model?: string
11  /** The agent's prompt when one is found; else the command, its whitespace folded. */
12  task: string
13}
14
15/** One `<task-notification>` as the engine writes it into the conversation. */
16export type TaskNote = {
17  id: string
18  /** `completed`, `failed`, `killed`, or another word its producer wrote. */
19  status: string
20  toolUseId?: string
21  outputFile?: string
22  /** The exit code its summary names, when it does. */
23  exitCode?: number
24}
25
26const fold = (s: string): string => s.replace(/\s+/g, ' ').trim()
27
28/** One lexed word: its text, and whether its first character was quoted or escaped (so `'>'` is no redirect). */
29export type Word = { text: string; isQuotedStart: boolean }
30
31/** A command's words and control operators, quotes honoured and marked (a small shell lexer, no expansion). */
32export function lexWords(command: string): Word[] {
33  const out: Word[] = []
34  let word: string | null = null
35  let isQuotedStart = false
36  const add = (s: string, isQuoted: boolean) => {
37    if (word === null) isQuotedStart = isQuoted
38    word = (word ?? '') + s
39  }
40  const push = () => {
41    if (word !== null) out.push({ text: word, isQuotedStart })
42    word = null
43  }
44  for (let i = 0; i < command.length; i++) {
45    const c = command.charAt(i)
46    if (c === "'") {
47      const end = command.indexOf("'", i + 1)
48      const stop = end < 0 ? command.length : end
49      add(command.slice(i + 1, stop), true)
50      i = stop
51    } else if (c === '"') {
52      let s = ''
53      let j = i + 1
54      for (; j < command.length && command.charAt(j) !== '"'; j++) {
55        if (command.charAt(j) === '\\' && j + 1 < command.length) j++
56        s += command.charAt(j)
57      }
58      add(s, true)
59      i = j
60    } else if (c === '\\' && i + 1 < command.length) {
61      add(command.charAt(++i), true)
62    } else if (c === '&' && (command.charAt(i - 1) === '>' || command.charAt(i - 1) === '<' || command.charAt(i + 1) === '>')) {
63      // Part of a redirection (`2>&1`, `&>`), not an operator.
64      add(c, false)
65    } else if (c === '&' || c === '|' || c === ';' || c === '\n') {
66      push()
67      const two = command.slice(i, i + 2)
68      if (two === '&&' || two === '||') {
69        out.push({ text: two, isQuotedStart: false })
70        i++
71      } else out.push({ text: c === '\n' ? ';' : c, isQuotedStart: false })
72    } else if (/\s/.test(c)) push()
73    else add(c, false)
74  }
75  push()
76  return out
77}
78
79/** A command's words and control operators, quotes honoured (a small shell lexer, no expansion). */
80export const wordsOf = (command: string): string[] => lexWords(command).map(w => w.text)
81
82const OPERATORS = new Set(['&&', '||', ';', '|', '&'])
83/** Words that run the next word as the command. */
84const WRAPPERS = new Set(['nohup', 'time', 'command', 'exec', 'caffeinate'])
85/** Commands that only set up the one after them. */
86const SETUP = new Set(['cd', 'pushd', 'export', 'source', '.', 'set', 'unset'])
87
88/**
89 * The words of each command that runs, in order (a pipeline's and a list's
90 * segments alike): setup segments, assignments and wrappers skipped.
91 */
92function runSegments(words: readonly string[]): string[][] {
93  const segments: string[][] = [[]]
94  for (let k = 0; k < words.length; k++) {
95    const w = words[k] ?? ''
96    if (OPERATORS.has(w)) segments.push([])
97    else if (/^\d*(?:[<>]|&>)/.test(w)) {
98      // A redirection: `>`, `2>` and `&>` take the next word as their file; `2>&1` and `>out` hold it.
99      if (/^\d*(?:[<>]{1,2}|&>{1,2})$/.test(w)) k++
100    } else segments[segments.length - 1]?.push(w)
101  }
102  const out: string[][] = []
103  for (const seg of segments) {
104    let i = 0
105    for (;;) {
106      const w = seg[i]
107      if (w === undefined) break
108      if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i++
109      else if (WRAPPERS.has(w)) {
110        i++
111        while (seg[i]?.startsWith('-')) i++
112      } else if (w === 'env') {
113        i++
114        while (seg[i]?.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(seg[i] ?? '')) i++
115      } else if (w === 'timeout' || w === 'gtimeout') {
116        i++
117        while (seg[i]?.startsWith('-')) i += /^-(k|s)$|^--(kill-after|signal)$/.test(seg[i] ?? '') ? 2 : 1
118        i++ // the duration
119      } else break
120    }
121    const rest = seg.slice(i)
122    const head = rest[0]
123    if (head === undefined || SETUP.has(head)) continue
124    out.push(rest)
125  }
126  return out
127}
128
129const base = (w: string): string => w.slice(w.lastIndexOf('/') + 1)
130
131// Flags that take a value (the next word), per CLI; any other flag stands alone.
132const CODEX_VALUE = new Set([
133  '-m', '--model', '-c', '--config', '-C', '--cd', '-s', '--sandbox', '-a', '--ask-for-approval', '-p', '--profile',
134  '-i', '--image', '--add-dir', '-o', '--output-last-message', '--output-schema', '--color', '--enable', '--disable', '--local-provider',
135])
136const OPENCODE_VALUE = new Set(['-m', '--model', '-s', '--session', '--agent', '-f', '--file', '--format', '--port', '--attach', '--title', '--command', '--variant'])
137const CLAUDE_VALUE = new Set([
138  '--model', '--output-format', '--input-format', '--permission-mode', '--max-turns', '--allowedTools', '--allowed-tools',
139  '--disallowedTools', '--disallowed-tools', '--append-system-prompt', '--system-prompt', '--add-dir', '--settings', '--mcp-config',
140  '--agents', '--agent', '--session-id', '-r', '--resume', '--fallback-model', '--effort', '--setting-sources', '--plugin-dir',
141  '--betas', '--max-budget-usd', '--json-schema', '--tools', '-n', '--name',
142])
143/** codex subcommands that run no prompt. */
144const CODEX_IDLE = new Set([
145  'login', 'logout', 'mcp', 'mcp-server', 'app-server', 'completion', 'sandbox', 'debug', 'apply', 'a', 'cloud', 'features', 'help',
146  'responses-api-proxy', 'stdio-to-uds', 'generate-ts',
147])
148const CODEX_PROMPT_SUBS = new Set(['exec', 'e', 'review'])
149
150/** The words that are not flags or flag values, in order. */
151function positionals(words: readonly string[], valued: ReadonlySet<string>): string[] {
152  const out: string[] = []
153  for (let i = 0; i < words.length; i++) {
154    const w = words[i] ?? ''
155    if (w === '--') {
156      out.push(...words.slice(i + 1))
157      break
158    }
159    if (w.startsWith('-') && w !== '-') {
160      if (!w.includes('=') && valued.has(w)) i++
161      continue
162    }
163    out.push(w)
164  }
165  return out
166}
167
168/** The value of `-m`/`--model` (or `--model=x`), when given. */
169function modelFlag(words: readonly string[], names: readonly string[]): string | undefined {
170  for (let i = 0; i < words.length; i++) {
171    const w = words[i] ?? ''
172    for (const n of names) {
173      if (w === n && words[i + 1] !== undefined) return words[i + 1]
174      if (w.startsWith(n + '=')) return w.slice(n.length + 1)
175    }
176  }
177  return undefined
178}
179
180const promptOf = (p: string | undefined): string | undefined => (p && p !== '-' ? fold(p) || undefined : undefined)
181
182/** The agent one segment runs (its task, or the whole command when it names none); undefined for any other command. */
183function agentOf(words: readonly string[], command: string): ShellLabel | undefined {
184  const head = base(words[0] ?? '')
185  const args = words.slice(1)
186  if (head === 'codex') {
187    if (args.some(w => w === '--version' || w === '-V' || w === '--help' || w === '-h')) return undefined
188    const pos = positionals(args, CODEX_VALUE)
189    const sub = pos[0]
190    if (sub !== undefined && CODEX_IDLE.has(sub)) return undefined
191    const configModel = args.map((w, i) => (w === '-c' || w === '--config' ? args[i + 1] : undefined)).find(v => v?.startsWith('model='))
192    const model = modelFlag(args, ['-m', '--model']) ?? configModel?.slice('model='.length).replace(/^["']|["']$/g, '')
193    // `codex exec resume [SESSION_ID] [PROMPT]`: with `--last` the first word after `resume` is the prompt.
194    const resumed = pos[1] === 'resume' ? (args.includes('--last') ? pos[2] : pos[3]) : pos[1]
195    const prompt = sub !== undefined && CODEX_PROMPT_SUBS.has(sub) ? resumed : sub === 'resume' || sub === 'fork' ? undefined : sub
196    return { label: 'codex', ...(model ? { model } : {}), task: promptOf(prompt) ?? fold(command) }
197  }
198  if (head === 'opencode' && args[0] === 'run') {
199    const model = modelFlag(args, ['-m', '--model'])
200    const prompt = positionals(args.slice(1), OPENCODE_VALUE).join(' ')
201    return { label: 'opencode', ...(model ? { model } : {}), task: promptOf(prompt) ?? fold(command) }
202  }
203  if (head === 'claude' && args.some(w => w === '-p' || w === '--print')) {
204    const model = modelFlag(args, ['--model'])
205    return { label: 'claude', ...(model ? { model } : {}), task: promptOf(positionals(args, CLAUDE_VALUE)[0]) ?? fold(command) }
206  }
207  return undefined
208}
209
210/**
211 * What a background command runs: `codex exec`, `codex <prompt>` (or another
212 * codex subcommand that runs a prompt), `opencode run` or `claude -p` is that
213 * agent, with the model `-m`/`--model` names, wherever it sits in a pipeline
214 * or list (`git diff | codex exec "review"`); anything else is a plain shell.
215 */
216export function labelOf(command: string): ShellLabel {
217  for (const words of runSegments(wordsOf(command))) {
218    const agent = agentOf(words, command)
219    if (agent) return agent
220  }
221  return { task: fold(command) }
222}
223
224/** The output file a background Bash result names (`Output is being written to: <path>.`). */
225export function outputFileOf(text: string | undefined): string | undefined {
226  const m = /Output is being written to: (\S+)/.exec(text ?? '')
227  return m?.[1]?.replace(/\.$/, '') || undefined
228}
229
230const tag = (body: string, name: string): string | undefined => {
231  const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(body)
232  return m?.[1]?.trim() || undefined
233}
234
235/** Every `<task-notification>` in a row's text. */
236export function notesOf(text: string): TaskNote[] {
237  const out: TaskNote[] = []
238  for (const m of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
239    const body = m[1] ?? ''
240    const id = tag(body, 'task-id')
241    const status = tag(body, 'status')
242    if (!id || !status) continue
243    const code = /exit code (-?\d+)/.exec(tag(body, 'summary') ?? '')?.[1]
244    const toolUseId = tag(body, 'tool-use-id')
245    const outputFile = tag(body, 'output-file')
246    out.push({
247      id,
248      status,
249      ...(toolUseId ? { toolUseId } : {}),
250      ...(outputFile ? { outputFile } : {}),
251      ...(code !== undefined ? { exitCode: Number(code) } : {}),
252    })
253  }
254  return out
255}
256
257/** The text of a row's content: its text blocks joined (or the string itself). */
258export function textOfContent(content: unknown): string {
259  if (typeof content === 'string') return content
260  if (!Array.isArray(content)) return ''
261  return content
262    .map(b => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : ''))
263    .join('\n')
264}
265
266/** The longest last output line kept: wider than any pane, so a one-line dump is not held whole. */
267export const LINE_MAX = 240
268
269/** The last line of output with anything on it: escapes stripped, the last `\r` rewrite kept, clipped to LINE_MAX. */
270export function lastLineOf(output: string): string | undefined {
271  const lines = output.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').split('\n')
272  for (let i = lines.length - 1; i >= 0; i--) {
273    const parts = (lines[i] ?? '').split('\r').map(s => s.trimEnd())
274    const shown = [...parts].reverse().find(s => s.trim() !== '')
275    if (shown === undefined) continue
276    const line = fold(shown)
277    return line.length > LINE_MAX ? line.slice(0, LINE_MAX - 1) + '…' : line
278  }
279  return undefined
280}
281
hooks/code.tsx 313 lines
1// The agents pane's Code view: the files this turn (or the session) touched,
2// drawn as a map of round-bordered nodes 22 wide in edit order with ──▶
3// between them, wrapping by the pane's width; the read-only files in an
4// ALSO READ chip row; the subagents that touched them with their numbered
5// buttons. Under 60 columns a tree list instead; plain text off the terminal.
6// Keys while the pane has the focus: w Who, a turn/session, d then a number
7// /diff on that file (run from the tick), p Peers, q close.
8import { atom, read, update } from 'claude-code'
9import type { Elements, EngineInterface, PluginOptions, RenderInput } from 'claude-code'
10
11import type { AgentRun, CodeScope } from '../types'
12import { ARROW, NODE_W, TREE_BELOW, namesOf, nodesPerRow, scopeOf, shownState, summariesOf, toolMarks } from './codemap'
13import type { MapFile, ScopeView, ShownState } from './codemap'
14import { MESSAGES_STUB, cardNumbers, clip, closePane, diffQueue, setView } from './kit'
15import { showPeers } from './peers'
16import { fmtLeft } from './watch'
17
18const code = atom({ plugin: 'agents', key: 'code' } as const, [])
19const turn = atom({ plugin: 'agents', key: 'turn' } as const, 0)
20const codeScope = atom({ plugin: 'agents', key: 'codeScope' } as const, 'turn')
21const isDiffArmed = atom({ plugin: 'agents', key: 'isDiffArmed' } as const, false)
22
23/** The state colours: read blue, editing amber, changed green, failed red. */
24export const STATE_COLOR: Record<ShownState, string> = {
25  read: '#60a5fa',
26  editing: '#f59e0b',
27  changed: '#22c55e',
28  failed: '#ef4444',
29}
30/** The state glyphs, as the plan words them: `◌ read`, `● editing`, `✓ changed`, `✗ failed`. */
31export const STATE_GLYPH: Record<ShownState, string> = { read: '◌', editing: '●', changed: '✓', failed: '✗' }
32const CHIP_FG = '#0b0b0f'
33const ALSO_BG = '#9ca3af'
34/** Nodes numbered for `d` + number: one digit each. */
35export const MAX_DIFF = 9
36
37/** The edited files in scope, in the map's order: node n is the n-th. */
38const editedOf = (v: ScopeView): MapFile[] => v.files.filter(f => f.editedAt !== undefined)
39
40async function readScope($: EngineInterface): Promise<ScopeView> {
41  return scopeOf(await read($, code), await read($, turn), await read($, codeScope))
42}
43
44/** The path of the code map's node `n` (1 up) in the current scope, or undefined. */
45export async function nodePath($: EngineInterface, n: number): Promise<string | undefined> {
46  if (!Number.isInteger(n) || n < 1) return undefined
47  return editedOf(await readScope($))[n - 1]?.path
48}
49
50/** Queues /diff for a node's file (the tick runs it) and drops the pick. */
51async function pickDiff($: EngineInterface, path: string): Promise<void> {
52  diffQueue.push(path)
53  await update($, isDiffArmed, () => false)
54}
55
56export type CodeData = {
57  list: readonly AgentRun[]
58  at: number
59  cols: number
60  options: PluginOptions
61}
62
63/** `+12 −3`, or '' with no counts. */
64const countsOf = (f: MapFile): string => (f.plus !== undefined ? `+${f.plus} −${f.minus ?? 0}` : '')
65
66/** A folder's last two parts (`agents/hooks`), for the tree list and the plain view. */
67const shortDir = (path: string): string => path.split('/').slice(-3, -1).join('/')
68
69/** The words after the counts: tools, time since the last touch, turn tags (session), subagent numbers. */
70function metaOf(f: MapFile, at: number, scope: CodeScope, numbers: ReadonlyMap<string, number>): string {
71  const subs = f.by.filter(b => b !== 'main').map(b => numbers.get(b))
72  return [
73    countsOf(f),
74    toolMarks(f.tools),
75    fmtLeft(at - f.lastAt),
76    ...(scope === 'session' ? [f.turns.map(t => `t${t}`).join(',')] : []),
77    ...(subs.length ? [subs.map(n => (n ? `@${n}` : '@')).join('')] : []),
78  ]
79    .filter(Boolean)
80    .join(' · ')
81}
82
83/** The header's words: `Code map (11 files) (2 changed)`, and the file being edited. */
84function headOf(v: ScopeView, at: number): { counts: string; editing?: string; scope: string } {
85  const changed = v.files.filter(f => f.state === 'changed').length
86  const editing = [...v.files].filter(f => shownState(f, at) === 'editing').sort((a, b) => (b.lastEditAt ?? 0) - (a.lastEditAt ?? 0))[0]
87  const names = namesOf(v.files.map(f => f.path))
88  return {
89    counts: `(${v.files.length} file${v.files.length === 1 ? '' : 's'}) (${changed} changed)`,
90    ...(editing ? { editing: names.get(editing.path) ?? editing.path } : {}),
91    scope: v.scope === 'session' ? 'session' : v.isPrevious ? `turn ${v.turn} (previous)` : `turn ${v.turn}`,
92  }
93}
94
95export async function drawCode($: EngineInterface, e: RenderInput<'Pane'>, d: CodeData) {
96  const v = await readScope($)
97  const isArmed = await read($, isDiffArmed)
98  if (e.surface === 'terminal') return codeTerminal($, $.ui.resolve(e), v, isArmed, d)
99  return codePlain($.ui.resolve(e), v, d)
100}
101
102/** The two elements every surface's table has, the plain view's whole need. */
103type Shared = { Box: Elements['mobile']['Box']; Text: Elements['mobile']['Text'] }
104
105// --- plain text, for the desktop and the other remote surfaces
106
107function codePlain(ui: Shared, v: ScopeView, d: CodeData) {
108  const { Box, Text } = ui
109  const head = headOf(v, d.at)
110  const names = namesOf(v.files.map(f => f.path))
111  const edited = editedOf(v)
112  const read = v.files.filter(f => f.editedAt === undefined)
113  return (
114    <Box flexDirection="column">
115      <Text bold>{`Code map ${head.counts} · ${head.scope}${head.editing ? ` · editing ${head.editing}` : ''}`}</Text>
116      {v.files.length === 0 && <Text dimColor>No files touched yet.</Text>}
117      {edited.map((f, i) => (
118        <Text key={`plain-${f.path}`}>
119          {`${i + 1} ${STATE_GLYPH[shownState(f, d.at)]} ${names.get(f.path) ?? f.path} ${countsOf(f)}${f.summary ? ' · ' + clip(f.summary, 60) : ''}`}
120        </Text>
121      ))}
122      {read.length > 0 && <Text dimColor>{`Also read: ${read.map(f => names.get(f.path) ?? f.path).join(', ')}`}</Text>}
123    </Box>
124  )
125}
126
127// --- the terminal
128
129function codeTerminal($: EngineInterface, ui: Elements['terminal'], v: ScopeView, isArmed: boolean, d: CodeData) {
130  const { Box, Text, Button } = ui
131  const { at, cols } = d
132  const summaries = summariesOf(d.options.summaries)
133  const head = headOf(v, at)
134  const names = namesOf(v.files.map(f => f.path))
135  const edited = editedOf(v)
136  const alsoRead = v.files.filter(f => f.editedAt === undefined)
137  const numbers = cardNumbers(d.list)
138  const isTree = cols < TREE_BELOW
139
140  const header = (
141    <Box key="code-head">
142      <Text wrap="truncate-end">
143        <Text bold>Code map</Text>
144        <Text dimColor>{` ${head.counts} · ${head.scope}`}</Text>
145        {head.editing ? <Text color={STATE_COLOR.editing}>{` ● editing ${head.editing}`}</Text> : null}
146      </Text>
147    </Box>
148  )
149
150  // The view's keys: framed buttons, each with its /agents-info twin.
151  const keys = (
152    <Box key="code-keys" flexDirection="row" flexWrap="wrap" gap={1}>
153      <Button key="view-who" label="w: who" hotkey="w" onPress={() => void setView($, 'who')} />
154      <Button
155        key="code-scope"
156        label={v.scope === 'session' ? 'a: this turn' : 'a: session'}
157        hotkey="a"
158        onPress={() => void update($, codeScope, s => (s === 'turn' ? 'session' : 'turn'))}
159      />
160      {edited.length > 0 ? (
161        <Button key="code-diff" label={isArmed ? 'd: cancel' : 'd: diff'} hotkey="d" onPress={() => void update($, isDiffArmed, x => !x)} />
162      ) : null}
163      <Button key="view-peers" label="p: peers" hotkey="p" onPress={() => void showPeers($).catch(() => {})} />
164      <Button key="pane-close" label="q: close" hotkey="q" onPress={() => void closePane($)} />
165    </Box>
166  )
167
168  // After `d`: one framed button per numbered node; its digit runs /diff on that file.
169  const picks = isArmed ? (
170    <Box key="diff-picks" flexDirection="row" flexWrap="wrap" gap={1}>
171      <Text color={STATE_COLOR.editing}>diff which file?</Text>
172      {edited.slice(0, MAX_DIFF).map((f, i) => (
173        <Button key={`diff-${i + 1}`} label={String(i + 1)} hotkey={String(i + 1)} onPress={() => void pickDiff($, f.path)} />
174      ))}
175    </Box>
176  ) : null
177
178  const node = (f: MapFile, i: number) => {
179    const state = shownState(f, at)
180    const line3 = summaries === 'off' || !f.summary ? shortDir(f.path) : f.summary
181    return (
182      <Box key={`node-${i + 1}`} width={NODE_W} flexShrink={0} flexDirection="column" borderStyle="round" borderColor={STATE_COLOR[state]} paddingX={1}>
183        <Box flexDirection="row" justifyContent="space-between">
184          <Box flexShrink={1}>
185            <Text wrap="truncate-end">
186              {i < MAX_DIFF ? <Text dimColor>{`${i + 1} `}</Text> : null}
187              <Text bold>{names.get(f.path) ?? f.path}</Text>
188            </Text>
189          </Box>
190          <Box flexShrink={0}>
191            <Text color={STATE_COLOR[state]}>{' ' + STATE_GLYPH[state]}</Text>
192          </Box>
193        </Box>
194        <Text dimColor wrap="truncate-end">
195          {metaOf(f, at, v.scope, numbers) || ' '}
196        </Text>
197        <Text dimColor={state !== 'failed'} {...(state === 'failed' ? { color: STATE_COLOR.failed } : {})} wrap="truncate-end">
198          {state === 'failed' ? 'edit failed' : line3}
199        </Text>
200      </Box>
201    )
202  }
203  const arrow = (key: string) => (
204    <Box key={key} flexShrink={0} marginTop={2}>
205      <Text dimColor>{ARROW}</Text>
206    </Box>
207  )
208
209  // Nodes in edit order, `perRow` to a row; a wrapped row starts with the arrow from the row above.
210  const fitsOne = edited.length <= nodesPerRow(cols)
211  const perRow = fitsOne ? Math.max(1, edited.length) : Math.max(1, Math.floor(cols / (NODE_W + ARROW.length)))
212  const rows: MapFile[][] = []
213  for (let i = 0; i < edited.length; i += perRow) rows.push(edited.slice(i, i + perRow))
214  const map = (
215    <Box key="code-map" flexDirection="column">
216      {rows.map((row, r) => (
217        <Box key={`code-row-${r}`} flexDirection="row">
218          {row.flatMap((f, j) => {
219            const i = r * perRow + j
220            return [...(i > 0 ? [arrow(`arrow-${i}`)] : []), node(f, i)]
221          })}
222        </Box>
223      ))}
224    </Box>
225  )
226
227  // Under 60 columns: the edited files as a tree by folder.
228  const tree = () => {
229    const groups = new Map<string, { f: MapFile; i: number }[]>()
230    edited.forEach((f, i) => {
231      const dir = shortDir(f.path)
232      groups.set(dir, [...(groups.get(dir) ?? []), { f, i }])
233    })
234    return (
235      <Box key="code-tree" flexDirection="column">
236        {[...groups.entries()].map(([dir, items]) => (
237          <Box key={`tree-${dir}`} flexDirection="column">
238            <Text dimColor wrap="truncate-end">
239              {dir || '/'}
240            </Text>
241            {items.map(({ f, i }, k) => {
242              const state = shownState(f, at)
243              return (
244                <Box key={`tree-${i + 1}`}>
245                  <Text wrap="truncate-end">
246                    <Text dimColor>{`${k === items.length - 1 ? '└' : '├'} ${i < MAX_DIFF ? i + 1 : ' '} `}</Text>
247                    <Text color={STATE_COLOR[state]}>{STATE_GLYPH[state] + ' '}</Text>
248                    <Text bold>{names.get(f.path) ?? f.path}</Text>
249                    <Text dimColor>{countsOf(f) ? ' ' + countsOf(f) : ''}</Text>
250                  </Text>
251                </Box>
252              )
253            })}
254          </Box>
255        ))}
256      </Box>
257    )
258  }
259
260  const also =
261    alsoRead.length > 0 ? (
262      <Box key="code-also">
263        <Text wrap="wrap">
264          <Text bold color={CHIP_FG} backgroundColor={ALSO_BG}>
265            {' ALSO READ '}
266          </Text>
267          {alsoRead.map((f, i) => (
268            <Text key={`also-${i}`} dimColor>{`  ${names.get(f.path) ?? f.path}`}</Text>
269          ))}
270        </Text>
271      </Box>
272    ) : null
273
274  // The subagents that touched these files, with the Who view's numbered buttons (stubs until E3).
275  const subIds = new Set(v.files.flatMap(f => f.by).filter(b => b !== 'main'))
276  const subs = d.list.filter(a => a.agentId !== undefined && subIds.has(a.agentId) && (numbers.get(a.id) ?? 0) > 0)
277  const subRow =
278    subs.length > 0 ? (
279      <Box key="code-subs" flexDirection="row" flexWrap="wrap" gap={1}>
280        <Text dimColor>subagents</Text>
281        {subs.map(a => {
282          const n = numbers.get(a.id) ?? 0
283          const isKeyed = !isArmed && n <= MAX_DIFF
284          return (
285            <Box key={`sub-${a.id}`} flexDirection="row" gap={1} flexShrink={0}>
286              <Button
287                key={`sub-num-${a.id}`}
288                label={String(n)}
289                {...(isKeyed ? { hotkey: String(n) } : {})}
290                onPress={() => $.ui.toast(MESSAGES_STUB)}
291              />
292              <Text>{clip(a.name || a.type, 16)}</Text>
293            </Box>
294          )
295        })}
296      </Box>
297    ) : null
298
299  return (
300    <Box flexDirection="column">
301      {header}
302      {keys}
303      {picks}
304      <Box flexDirection="column" marginTop={1}>
305        {v.files.length === 0 ? <Text dimColor>No files touched yet.</Text> : null}
306        {edited.length > 0 ? (isTree ? tree() : map) : null}
307        {also}
308        {subRow}
309      </Box>
310    </Box>
311  )
312}
313
hooks/peers.ts 504 lines
1// The agents pane's Peers view (0.4.0): the other agents of this herdr
2// workspace, as the peers mod's board shows them, with this session's
3// HERDR-ORCH spokes grouped under their request ids, the hub's row first.
4//
5// Ported, not imported: the peers mod's reading code (peers/hooks/kit.ts
6// `parsePeers`, peers/hooks/orch.ts envelope rule, request records, result
7// file check and row words), written again here, since one mod never imports
8// from another's folder. Ours (Zehui Lin, MIT), no third-party code.
9//
10// The envelope header is herdr-setup POLICY.md section 2:
11//   [HERDR-ORCH v0] req=<id> from=<agent-name>@<pane-id> type=<type>
12// and the result file is section 4: ~/.local/state/herdr-orch/<req-id>/result.md,
13// valid iff its first two lines are exactly `req: <id>` and `status: <status>`
14// and a non-blank line follows.
15//
16// herdr is read with `$.process.run(['herdr', 'agent', 'list'])` only while the
17// pane shows this view (every 30 s from the 1 s tick, `r` or
18// `/agents-info peers` at once), and the result files with $.fs.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface } from 'claude-code'
21
22import type { PeerBoard, PeerRequest, PeerRow, PeerStatus } from '../types'
23import { PANE, setView } from './kit'
24
25const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
26const peerBoard = atom({ plugin: 'agents', key: 'peerBoard' } as const, null)
27const peerRequests = atom({ plugin: 'agents', key: 'peerRequests' } as const, {})
28const peerHub = atom({ plugin: 'agents', key: 'peerHub' } as const, '')
29
30/** The view's read of herdr: every this many ms while shown. */
31export const PEERS_TICK_MS = 30_000
32/** `herdr agent list`'s limit. */
33export const HERDR_TIMEOUT_MS = 5_000
34/** What the view and `/agents-info peers` say outside a herdr pane. */
35export const NEEDS_HERDR = 'The Peers view needs a herdr pane (HERDR_ENV=1 with a workspace and pane id).'
36/** The envelope types POLICY.md lists. */
37export const ENVELOPE_TYPES = ['request', 'handoff', 'progress', 'blocked', 'needs-approval', 'completed', 'failed'] as const
38/** Not in POLICY.md's list, but the hubs send it: only an update to a request already kept. */
39export const FOLLOWUP = 'followup'
40/** POLICY.md section 4: a brief that names no deadline has 30 minutes. */
41export const DEFAULT_DEADLINE_MS = 30 * 60_000
42/** The requests kept; the oldest beyond this are dropped. */
43export const MAX_REQUESTS = 40
44/** A result file over this size is taken as present but not read. */
45export const RESULT_MAX_BYTES = 65_536
46/** The folder of the result files, under the home folder. */
47export const ORCH_DIR = '.local/state/herdr-orch'
48/** A title longer than this is cut, with an ellipsis. */
49export const TITLE_MAX = 48
50const RESULT_STATUSES = ['completed', 'failed', 'blocked']
51const STATUSES: readonly PeerStatus[] = ['idle', 'working', 'blocked', 'done', 'unknown']
52
53// --- herdr agent list
54
55/** One agent of `herdr agent list`. */
56export type Agent = { pane: string; agent: string; status: PeerStatus; title: string }
57
58const asStatus = (v: unknown): PeerStatus => (STATUSES.includes(v as PeerStatus) ? (v as PeerStatus) : 'unknown')
59const asText = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
60
61/**
62 * The agents of `workspace` in `herdr agent list`'s JSON (`.result.agents[]`),
63 * this pane among them, in herdr's order. Null when the output is not that
64 * JSON, so a bad read never looks like every peer leaving.
65 */
66export function parseAgents(stdout: string, workspace: string): Agent[] | null {
67  let agents: unknown
68  try {
69    agents = (JSON.parse(stdout) as { result?: { agents?: unknown } }).result?.agents
70  } catch {
71    return null
72  }
73  if (!Array.isArray(agents)) return null
74  const found: Agent[] = []
75  for (const a of agents) {
76    if (!a || typeof a !== 'object') continue
77    const r = a as Record<string, unknown>
78    const pane = asText(r.pane_id)
79    if (!pane || asText(r.workspace_id) !== workspace) continue
80    found.push({ pane, agent: asText(r.agent) || 'agent', status: asStatus(r.agent_status), title: asText(r.terminal_title_stripped).trim() })
81  }
82  return found
83}
84
85/**
86 * Each agent's row at a read at `at`: its time in its state carried from the
87 * last read when that read is recent (within two ticks; an older one, as after
88 * the view was left, may have missed changes) and saw it in the same state,
89 * else from now (then no time is drawn yet).
90 */
91export function rowsOf(agents: readonly Agent[], last: PeerBoard | null, at: number): PeerRow[] {
92  const recent = last && last.isHerdr && at - last.at <= 2 * PEERS_TICK_MS ? last : null
93  const before = recent ? [...recent.rows, ...(recent.self ? [recent.self] : [])] : []
94  return agents.map(a => {
95    const b = before.find(r => r.pane === a.pane)
96    return { ...a, since: b && b.status === a.status ? b.since : at }
97  })
98}
99
100// --- HERDR-ORCH envelopes and requests
101
102// At a line's start, or after `herdr agent prompt <pane>` and an opening quote
103// there (the hub's own Bash line, pasted or typed); never mid-sentence.
104const HEADER = /^[^\S\n]*(?:herdr agent prompt\s+(\S+)\s+)?["'`]?\[HERDR-ORCH v0\] req=(\S+) from=(\S+)@(\S+) type=(\S+)(.*)$/gm
105/** A herdr pane id: `w1:p6`, `wZ:p1`. */
106const PANE_ID = /\bw[A-Za-z0-9]+:p\d+\b/g
107
108/** One envelope header found in a prompt, with what the prompt around it says. */
109export type Envelope = {
110  req: string
111  fromAgent: string
112  fromPane: string
113  type: string
114  /** The spoke the envelope concerns; '' when the text does not say. */
115  target: string
116  round: number | null
117  ceiling: number | null
118  /** The deadline the text names, in milliseconds; null when none. */
119  deadlineMs: number | null
120}
121
122/** A request id safe to use as one folder name: letters, digits, `.`, `_`, `-`, never `.` or `..`. */
123export const isSafeReq = (req: string): boolean => /^[A-Za-z0-9._-]{1,128}$/.test(req) && req !== '.' && req !== '..'
124
125const num = (s: string | undefined): number | null => {
126  const n = s === undefined ? NaN : Number(s)
127  return Number.isInteger(n) && n > 0 && n < 1_000 ? n : null
128}
129
130/** `round 4/5`, `round 4 of 5`, `round=4`, else a `-r4` req suffix; the ceiling from the same or `ceiling 5`, `max rounds 5`. */
131export function roundOf(text: string, req: string): { round: number | null; ceiling: number | null } {
132  const pair = /\bround[ =:]*(\d+)\s*(?:\/|of)\s*(\d+)/i.exec(text)
133  const alone = /\bround[ =:]*(\d+)\b/i.exec(text)
134  const suffix = /-r(\d+)$/i.exec(req)
135  const cap = /\b(?:ceiling|max[ _-]?rounds)[ =:]*(\d+)/i.exec(text)
136  return { round: num(pair?.[1]) ?? num(alone?.[1]) ?? num(suffix?.[1]), ceiling: num(pair?.[2]) ?? num(cap?.[1]) }
137}
138
139/** `deadline: 45 minutes`, `Deadline 2 h`, `DEADLINE: 90 min` in milliseconds; null when the text names none. */
140export function deadlineOf(text: string): number | null {
141  const m = /\bdeadline\b[^0-9\n]{0,12}(\d+)\s*(minutes?|mins?|m|hours?|hrs?|h)\b/i.exec(text)
142  const n = num(m?.[1])
143  if (!m || n === null) return null
144  return /^h/i.test(m[2] ?? '') ? n * 3_600_000 : n * 60_000
145}
146
147const isBrief = (type: string): boolean => type === 'request' || type === 'handoff'
148/** A hub-to-spoke envelope: a brief or a follow-up; every other type is a spoke's report. */
149const isHubToSpoke = (type: string): boolean => isBrief(type) || type === FOLLOWUP
150
151/**
152 * The spoke an envelope concerns: the pane of the `herdr agent prompt <pane>`
153 * its header line starts with, a `to=<pane>` on that line, or (not for an
154 * incoming brief or follow-up, whose body often says how to wake its hub) a
155 * `herdr agent prompt <pane>` elsewhere in the text other than the sender's; else the sender, when that is not
156 * this pane (a spoke's report reaching this session); else the first other
157 * pane id in the text. '' when none. null when this pane is the spoke: a
158 * brief or follow-up from another pane naming no other target (sent to this
159 * pane), or a report this pane sends (its own, to its hub).
160 */
161function targetOf(text: string, line: string, rest: string, type: string, fromPane: string, selfPane: string): string | null {
162  const isIncoming = fromPane !== selfPane
163  if (!isIncoming && !isHubToSpoke(type)) return null
164  // The pane of the header's own `herdr agent prompt` line first (a fan-out names one per line), then `to=`, then the text's first.
165  const own = /^w[A-Za-z0-9]+:p\d+$/.test(line) ? line : undefined
166  const isToThisPane = isIncoming && isHubToSpoke(type)
167  const elsewhere = isToThisPane
168    ? undefined
169    : [...text.matchAll(/\bherdr agent prompt\s+(w[A-Za-z0-9]+:p\d+)\b/g)].map(m => m[1]).find(p => p !== fromPane)
170  const to = own ?? /\bto=(w[A-Za-z0-9]+:p\d+)\b/.exec(rest)?.[1] ?? elsewhere
171  if (to && to !== selfPane && to !== fromPane) return to
172  if (isToThisPane) return null
173  if (isIncoming && /^w[A-Za-z0-9]+:p\d+$/.test(fromPane)) return fromPane
174  const others = [...text.matchAll(PANE_ID)].map(m => m[0]).filter(p => p !== selfPane && p !== fromPane)
175  return others[0] ?? ''
176}
177
178/**
179 * Every envelope header in a prompt's text, in order, with the target, round
180 * and deadline the text gives; an envelope that makes this pane the spoke is
181 * left out of `envelopes`, and its sender (a brief's) is the `hub`: this
182 * session's hub, drawn first when this session sent no requests of its own.
183 */
184export function parseEnvelopes(text: string, selfPane: string): { envelopes: Envelope[]; hub: string } {
185  const envelopes: Envelope[] = []
186  let hub = ''
187  for (const m of text.matchAll(HEADER)) {
188    const [, line = '', req = '', fromAgent = '', fromPane = '', rawType = '', rest = ''] = m
189    // A one-line header in quotes ends its type with the closing quote.
190    const type = rawType.replace(/["'`]+$/, '')
191    if (!isSafeReq(req) || (!(ENVELOPE_TYPES as readonly string[]).includes(type) && type !== FOLLOWUP)) continue
192    const target = targetOf(text, line, rest, type, fromPane, selfPane)
193    if (target === null) {
194      if (fromPane !== selfPane && isBrief(type) && /^w[A-Za-z0-9]+:p\d+$/.test(fromPane)) hub = fromPane
195      continue
196    }
197    envelopes.push({ req, fromAgent, fromPane, type, target, ...roundOf(text, req), deadlineMs: deadlineOf(text) })
198  }
199  return { envelopes, hub }
200}
201
202/**
203 * The requests after these envelopes, submitted at `at`: a new request starts
204 * its deadline clock now (the text's deadline, else 30 minutes for a request
205 * or handoff); a known one keeps its start, adds the spoke pane it names, and
206 * takes a round or deadline the new envelope names, and its type. A follow-up
207 * only updates a known request, and a deadline it names counts from the
208 * follow-up. The newest MAX_REQUESTS are kept.
209 */
210export function recordEnvelopes(prev: Readonly<Record<string, PeerRequest>>, envs: readonly Envelope[], at: number): Record<string, PeerRequest> {
211  const next: Record<string, PeerRequest> = { ...prev }
212  for (const env of envs) {
213    const old = next[env.req]
214    const isFollowup = env.type === FOLLOWUP
215    if (isFollowup && !old) continue
216    const deadlineMs = env.deadlineMs ?? (isBrief(env.type) && !old?.deadlineAt ? DEFAULT_DEADLINE_MS : null)
217    const deadlineFrom = isFollowup ? at : (old?.sentAt ?? at)
218    const panes = old?.panes ?? []
219    next[env.req] = {
220      req: env.req,
221      panes: env.target && !panes.includes(env.target) ? [...panes, env.target] : [...panes],
222      type: env.type,
223      round: env.round ?? old?.round ?? null,
224      ceiling: env.ceiling ?? old?.ceiling ?? null,
225      sentAt: old?.sentAt ?? at,
226      deadlineAt: deadlineMs !== null ? deadlineFrom + deadlineMs : (old?.deadlineAt ?? null),
227      result: old?.result ?? null,
228    }
229  }
230  const kept = Object.values(next)
231    .sort((a, b) => b.sentAt - a.sentAt)
232    .slice(0, MAX_REQUESTS)
233  return Object.fromEntries(kept.map(r => [r.req, r]))
234}
235
236/** The folder of the result files: `<home>/.local/state/herdr-orch`. */
237export const orchRoot = (home: string): string => `${home.replace(/\/+$/, '')}/${ORCH_DIR}`
238/** The result file of a request: `<home>/.local/state/herdr-orch/<req>/result.md`. */
239export const resultPath = (home: string, req: string): string => `${orchRoot(home)}/${req}/result.md`
240
241/**
242 * A result file's status, POLICY.md section 4: the first line exactly
243 * `req: <req>`, the second exactly `status: <status>`, then a non-blank line;
244 * '' when it is not valid so, as while the spoke is still writing it.
245 */
246export function resultStatus(text: string, req: string): string {
247  const [first = '', second = '', ...body] = text.split(/\r?\n/)
248  if (first !== `req: ${req}`) return ''
249  const status = /^status: (\S+)$/.exec(second)?.[1] ?? ''
250  if (!body.some(line => line.trim() !== '')) return ''
251  return RESULT_STATUSES.includes(status) ? status : ''
252}
253
254/** The newest request of each spoke pane: a spoke is drawn under that one only. */
255export function requestByPane(requests: Readonly<Record<string, PeerRequest>>): Map<string, PeerRequest> {
256  const byPane = new Map<string, PeerRequest>()
257  for (const r of Object.values(requests)) {
258    for (const pane of r.panes) {
259      const had = byPane.get(pane)
260      if (!had || r.sentAt > had.sentAt) byPane.set(pane, r)
261    }
262  }
263  return byPane
264}
265
266// --- the view's layout
267
268/** Blocked rows first, the rest in herdr's order. */
269export const blockedFirst = (rows: readonly PeerRow[]): PeerRow[] => [
270  ...rows.filter(r => r.status === 'blocked'),
271  ...rows.filter(r => r.status !== 'blocked'),
272]
273
274/** One request and the spokes drawn under it. */
275export type PeerGroup = { request: PeerRequest; rows: PeerRow[] }
276
277/** What the view draws: the hub's row (and whether it is this pane), the request groups, the other peers. */
278export type PeerLayout = { hub: PeerRow | null; isSelfHub: boolean; groups: PeerGroup[]; others: PeerRow[] }
279
280/**
281 * The view's layout of a board: the hub's row first (this pane when this
282 * session sent requests, else the pane whose brief made this session a
283 * spoke), then each request with its spokes (the groups holding a blocked
284 * spoke first, then the newest first; blocked spokes first within), then the
285 * peers no request names (blocked first). A request no shown peer belongs to
286 * is left out.
287 */
288export function layoutOf(board: PeerBoard, requests: Readonly<Record<string, PeerRequest>>, hubPane: string): PeerLayout {
289  const isSelfHub = Object.values(requests).some(r => r.panes.length > 0)
290  const hub = isSelfHub ? board.self : (board.rows.find(r => r.pane === hubPane) ?? null)
291  const peers = board.rows.filter(r => r.pane !== hub?.pane)
292  const byPane = requestByPane(requests)
293  const groups = new Map<string, PeerGroup>()
294  const others: PeerRow[] = []
295  for (const row of peers) {
296    const request = byPane.get(row.pane)
297    if (!request) {
298      others.push(row)
299      continue
300    }
301    const g = groups.get(request.req) ?? { request, rows: [] }
302    g.rows.push(row)
303    groups.set(request.req, g)
304  }
305  const isBlocked = (g: PeerGroup) => g.rows.some(r => r.status === 'blocked')
306  const ordered = [...groups.values()]
307    .map(g => ({ ...g, rows: blockedFirst(g.rows) }))
308    .sort((a, b) => Number(isBlocked(b)) - Number(isBlocked(a)) || b.request.sentAt - a.request.sentAt)
309  return { hub, isSelfHub, groups: ordered, others: blockedFirst(others) }
310}
311
312// --- words
313
314/** `45s`, `12m`, `1h 05m`. */
315export function durationText(ms: number): string {
316  const s = Math.max(0, Math.floor(ms / 1000))
317  if (s < 60) return `${s}s`
318  const m = Math.floor(s / 60)
319  if (m < 60) return `${m}m`
320  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
321}
322
323/** Each state's glyph and colour; blocked is amber. */
324export const STATE_GLYPH: Record<PeerStatus, string> = { blocked: '▲', working: '●', idle: '○', done: '✓', unknown: '?' }
325export const STATE_COLOR: Record<PeerStatus, string> = {
326  blocked: '#f59e0b',
327  working: '#60a5fa',
328  idle: '#9ca3af',
329  done: '#22c55e',
330  unknown: '#9ca3af',
331}
332
333/** `▲ blocked 4m`: the glyph, the word and the time in the state (left out until a later read sees it so). */
334export const stateText = (r: PeerRow, at: number): string =>
335  `${STATE_GLYPH[r.status]} ${r.status}${r.since < at ? ' ' + durationText(at - r.since) : ''}`
336
337/** `"title"`, cut to TITLE_MAX characters; '' when none. */
338export function titleText(title: string): string {
339  if (!title) return ''
340  const chars = [...title]
341  return `"${chars.length > TITLE_MAX ? chars.slice(0, TITLE_MAX - 1).join('') + '…' : title}"`
342}
343
344/** `⏳ 26m left`, `overdue 4m`; '' when no deadline is known. */
345export function deadlineText(r: PeerRequest, at: number): string {
346  if (r.deadlineAt === null) return ''
347  const left = r.deadlineAt - at
348  return left > 0 ? `⏳ ${durationText(left)} left` : `overdue ${durationText(-left)}`
349}
350
351/** `result.md ✓ completed`, `result.md ✗ failed`, `result.md ▲ blocked`, `result.md (no status)`, `no result.md`. */
352export function resultText(result: string | null): string {
353  if (result === null) return 'no result.md'
354  if (result === '') return 'result.md (no status)'
355  return `result.md ${result === 'completed' ? '✓' : result === 'failed' ? '✗' : '▲'} ${result}`
356}
357
358/** A group's head: `req apr-alp-r4 · round 4/5 · ⏳ 26m left · no result.md`. */
359export function requestText(r: PeerRequest, at: number): string {
360  const round = r.round !== null ? `round ${r.round}${r.ceiling !== null ? '/' + r.ceiling : ''}` : ''
361  // A result file settles the request: its countdown no longer applies.
362  const deadline = r.result ? '' : deadlineText(r, at)
363  return [`req ${r.req}`, round, deadline, resultText(r.result)].filter(Boolean).join(' · ')
364}
365
366/** The view's head: `Peers · 3 in w1 · 1 blocked · read every 30 s`. */
367export function peersHead(board: PeerBoard | null): string {
368  if (!board) return 'Peers · reading herdr…'
369  if (!board.isHerdr) return 'Peers'
370  if (board.error) return `Peers · ${board.error}`
371  const n = board.rows.length
372  const blocked = board.rows.filter(r => r.status === 'blocked').length
373  return ['Peers', `${n} in ${board.workspace}`, ...(blocked > 0 ? [`${blocked} blocked`] : []), 'read every 30 s'].join(' · ')
374}
375
376// --- reading herdr and the result files (state lives here, so the atoms are declared above)
377
378type HerdrIds = { workspace: string; pane: string }
379
380/** This pane's herdr workspace and pane, or null outside herdr. */
381export async function herdrIds($: EngineInterface): Promise<HerdrIds | null> {
382  try {
383    if ((await $.env.get('HERDR_ENV')) !== '1') return null
384    const workspace = await $.env.get('HERDR_WORKSPACE_ID')
385    const pane = await $.env.get('HERDR_PANE_ID')
386    return workspace && pane ? { workspace, pane } : null
387  } catch {
388    return null
389  }
390}
391
392// One read at a time: a slow herdr never stacks reads.
393let isPolling = false
394// When herdr was last read for the view ($.clock time); the tick reads again 30 s after.
395let polledAt = 0
396
397/** The result file of each request a shown spoke belongs to, read with $.fs; only changed records are written. */
398async function readResults($: EngineInterface, board: PeerBoard): Promise<void> {
399  const home = await $.env.get('HOME')
400  if (!home) return
401  const shown = new Set(board.rows.map(r => r.pane))
402  // The folder where every link is followed: a result file is read only when it lands at its own place there.
403  const root = (await $.fs.stat(orchRoot(home), { resolve: true }).catch(() => undefined))?.realPath?.replace(/\/+$/, '')
404  const found: Record<string, string | null> = {}
405  for (const r of Object.values(await read($, peerRequests))) {
406    if (!r.panes.some(p => shown.has(p)) || !isSafeReq(r.req)) continue
407    const path = resultPath(home, r.req)
408    try {
409      if (root === undefined) throw new Error('no result folder')
410      const stat = await $.fs.stat(path, { resolve: true })
411      const isInside = stat.realPath === `${root}/${r.req}/result.md`
412      found[r.req] = isInside && stat.kind === 'file' && stat.size <= RESULT_MAX_BYTES ? resultStatus(await $.fs.read(path), r.req) : ''
413    } catch {
414      // No result file yet.
415      found[r.req] = null
416    }
417  }
418  const list = await read($, peerRequests)
419  if (!Object.entries(found).some(([req, v]) => list[req] !== undefined && list[req].result !== v)) return
420  await update($, peerRequests, l =>
421    Object.fromEntries(Object.entries(l).map(([req, r]) => [req, Object.hasOwn(found, req) ? { ...r, result: found[req] ?? null } : r])),
422  )
423}
424
425/**
426 * One read for the view: `herdr agent list` (5 s at most), this workspace's
427 * agents kept as the board (this pane apart, for the hub row), then the
428 * result files of the requests shown. Outside herdr the board says so and
429 * nothing runs. A failed read keeps the last good board; with none yet, the
430 * board says the read failed. Best effort.
431 */
432export async function pollPeers($: EngineInterface): Promise<void> {
433  if (isPolling) return
434  isPolling = true
435  try {
436    const at = await $.clock.now()
437    polledAt = at
438    const ids = await herdrIds($)
439    if (!ids) {
440      await update($, peerBoard, () => ({ at, workspace: '', isHerdr: false, self: null, rows: [] }))
441      return
442    }
443    const out = await $.process.run(['herdr', 'agent', 'list'], { timeoutMs: HERDR_TIMEOUT_MS }).catch(() => null)
444    const agents = out?.exitCode === 0 ? parseAgents(out.stdout, ids.workspace) : null
445    if (!agents) {
446      const error = !out ? 'herdr agent list did not run' : out.exitCode !== 0 ? `herdr agent list failed (exit ${out.exitCode})` : 'herdr agent list gave no agent list'
447      const last = await read($, peerBoard)
448      if (!last || !last.isHerdr || last.error) await update($, peerBoard, () => ({ at, workspace: ids.workspace, isHerdr: true, self: null, rows: [], error }))
449      return
450    }
451    const rows = rowsOf(agents, await read($, peerBoard), at)
452    const board: PeerBoard = {
453      at,
454      workspace: ids.workspace,
455      isHerdr: true,
456      self: rows.find(r => r.pane === ids.pane) ?? null,
457      rows: rows.filter(r => r.pane !== ids.pane),
458    }
459    await update($, peerBoard, () => board)
460    await readResults($, board)
461  } catch {
462    // herdr gave no answer: the view keeps its last read.
463  } finally {
464    isPolling = false
465  }
466}
467
468/** True while the agents pane is open. */
469async function isPaneOpen($: EngineInterface): Promise<boolean> {
470  try {
471    return (await $.ui.panes()).some(p => p.id === PANE)
472  } catch {
473    return false
474  }
475}
476
477/**
478 * From the 1 s tick: reads herdr when 30 s passed since the last read, and
479 * only while the pane is open in the Peers view. Never awaited by the tick.
480 */
481export async function tickPeers($: EngineInterface, at: number): Promise<void> {
482  if (at - polledAt < PEERS_TICK_MS || isPolling) return
483  if ((await read($, view)) !== 'peers' || !(await isPaneOpen($))) return
484  await pollPeers($)
485}
486
487/** `p`: the Peers view, read now. */
488export async function showPeers($: EngineInterface): Promise<void> {
489  await setView($, 'peers')
490  await pollPeers($)
491}
492
493/** Keeps the HERDR-ORCH requests (and the hub) in a prompt this session submitted. */
494export async function noteEnvelopes($: EngineInterface, text: string): Promise<void> {
495  if (!text.includes('[HERDR-ORCH v0]')) return
496  const ids = await herdrIds($)
497  if (!ids) return
498  const { envelopes, hub } = parseEnvelopes(text, ids.pane)
499  if (hub) await update($, peerHub, () => hub)
500  if (envelopes.length === 0) return
501  const at = await $.clock.now()
502  await update($, peerRequests, l => recordEnvelopes(l, envelopes, at))
503}
504
hooks/skin.tsx 517 lines
1// The agents pane (/agents-info): stat tiles, ruled section heads and one card
2// per run, a half-block Raster mascot at the left of each (a grey one for a
3// background shell) and a numbered button (1 to 9, a stub until the messages
4// view of E3). Adapted from Zehui's terminal skin for savvy-progress
5// (terminal-skin.tsx, 2026-10-19), with text ▰▱ bars in place of Raster bars.
6// Other surfaces get plain text.
7import { atom, read, update } from 'claude-code'
8import type { Elements, EngineInterface, PluginOptions, RenderInput } from 'claude-code'
9
10import type { AgentRun, MainLoop } from '../types'
11import { drawCode } from './code'
12import { showPeers } from './peers'
13import { drawPeers } from './peerview'
14import {
15  FLASH_MS,
16  cardNumbers,
17  closePane,
18  isStale,
19  isWatching,
20  setView,
21  watchTime,
22  MESSAGES_STUB,
23  PULSE_MS,
24  SPINNER,
25  colorOf,
26  costText,
27  ctxPct,
28  elapsed,
29  fmtCost,
30  fmtTime,
31  fmtTokens,
32  isShell,
33  isSlowStep,
34  isWaiting,
35  modelName,
36  poseOf,
37  readRuns,
38  readTails,
39  shellWords,
40  tierOf,
41  pruneEnded,
42  mainPoseId,
43} from './kit'
44import { boxOf, hubPose, mascotCells, mascotWidth } from './mascots'
45import type { Pose } from './mascots'
46
47const main = atom({ plugin: 'agents', key: 'main' } as const, { model: '', isRunning: false, startedAt: 0 })
48const isCompact = atom({ plugin: 'agents', key: 'isCompact' } as const, false)
49const isStill = atom({ plugin: 'agents', key: 'isStill' } as const, false)
50const runs = atom({ plugin: 'agents', key: 'runs' } as const, [])
51const view = atom({ plugin: 'agents', key: 'view' } as const, 'who')
52
53export const CARD_BAR = 10
54const AMBER = '#f59e0b'
55const GREEN = '#22c55e'
56const FLASH = '#86efac'
57const RED = '#ef4444'
58const AMBER_RGB = 0xf59e0b
59const RED_RGB = 0xef4444
60/** A shell card's spinner and words: grey, never a tier's colour. */
61const SHELL_GREY = '#9a9a96'
62/** Cards numbered 1 to this; the rest get a blank of the button's width. */
63export const MAX_NUMBERED = 9
64/** A framed button's width, `[ 1 ]`. */
65const NUM_W = 5
66/** Narrower than this, the cards draw without mascots. */
67export const MASCOT_MIN_COLS = 56
68
69/** `filled` of `width` cells: at least one while the run is live. */
70export const barFill = (a: AgentRun): number =>
71  a.status === 'finished' ? CARD_BAR : Math.max(1, Math.min(CARD_BAR, Math.round(ctxPct(a) / 10)))
72
73/** The bar's colour: tier while running (amber on a slow step), green, a bright flash on finish, red on error. */
74export const barColor = (a: AgentRun, at: number, still: boolean): string => {
75  if (a.status === 'error') return RED
76  if (a.status === 'finished') return !still && a.endedAt !== undefined && at - a.endedAt < FLASH_MS ? FLASH : GREEN
77  return isSlowStep(a, at) ? AMBER : colorOf(tierOf(a))
78}
79
80/** The status glyph: the spinner in the tier colour while running, ✓ green, ✗ red. */
81const glyphOf = (a: AgentRun, at: number, still: boolean): [glyph: string, color: string] =>
82  a.status === 'finished'
83    ? ['✓', GREEN]
84    : a.status === 'error'
85      ? ['✗', RED]
86      : [still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●'), colorOf(tierOf(a))]
87
88const statusWord = (a: AgentRun, at: number): string =>
89  a.status === 'finished'
90    ? 'finished'
91    : a.status === 'error'
92      ? isShell(a)
93        ? (a.endWord ?? 'failed')
94        : 'error'
95      : isWaiting(a, at)
96        ? 'waiting'
97        : 'running'
98
99/** A card's bold title: the task; a shell's `sh · <command>` (`codex · <prompt>`). */
100const titleOf = (a: AgentRun): string => (isShell(a) ? `${a.label ?? 'sh'} · ${a.description}` : a.description || a.name || a.type)
101
102/** A card's dim detail: context, tokens, cost and time; a shell's `background shell · <elapsed>`. */
103const detailOf = (a: AgentRun, at: number): string =>
104  isShell(a)
105    ? `${a.label ? shellWords(a) + ' · ' : ''}background shell · ${fmtTime(elapsed(a, at))}${a.exitCode !== undefined && a.exitCode !== 0 ? ` · exit ${a.exitCode}` : ''}`
106    : `ctx ${ctxPct(a)}% · ${fmtTokens(a.tokens)} · ${costText(a)} · ${fmtTime(elapsed(a, at))}`
107
108type Totals = { cost: string; tokens: string; time: string }
109
110const totalsOf = (all: readonly AgentRun[], at: number): Totals => {
111  // Watches and crons cost nothing and can wait for hours: the tiles count the cards only.
112  const list = all.filter(a => !isWatching(a))
113  const cost = list.reduce((s, a) => s + a.costUsd, 0)
114  const isUnpriced = list.some(a => a.isUnpriced)
115  const from = Math.min(...list.map(a => a.startedAt))
116  const to = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
117  return {
118    cost: '≈' + fmtCost(cost) + (isUnpriced ? '+?' : ''),
119    tokens: fmtTokens(list.reduce((s, a) => s + a.tokens, 0)),
120    time: list.length ? fmtTime(to - from) : '0:00',
121  }
122}
123
124type PaneData = {
125  list: readonly AgentRun[]
126  at: number
127  main: MainLoop
128  isCompact: boolean
129  still: boolean
130  cols: number
131  /** Each shell's last output line, by run id. */
132  tails: Readonly<Record<string, string>>
133}
134
135export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, options: PluginOptions = {}) {
136  const { list, at } = await readRuns($)
137  // The Code view (`c`, `/agents-info code`) draws the code map instead of the cards.
138  if ((await read($, view)) === 'code') return drawCode($, e, { list, at, cols: e.props.bodyColumns > 0 ? e.props.bodyColumns : 40, options })
139  // The Peers view (`p`, `/agents-info peers`) draws this herdr workspace's agents instead.
140  if ((await read($, view)) === 'peers') return drawPeers($, e)
141  const d: PaneData = {
142    list,
143    at,
144    main: await read($, main),
145    isCompact: await read($, isCompact),
146    still: await read($, isStill),
147    cols: e.props.bodyColumns > 0 ? e.props.bodyColumns : 40,
148    tails: await readTails($),
149  }
150  if (e.surface === 'terminal') return paneTerminal($, $.ui.resolve(e), d)
151  return panePlain($.ui.resolve(e), d)
152}
153
154/** The two elements every surface's table has, the plain pane's whole need. */
155type Shared = { Box: Elements['mobile']['Box']; Text: Elements['mobile']['Text'] }
156
157/** A watch's or cron's line: `watch deploy a15f4e9 · ⏳ 12m`, `cron 20:30 in 41m · "<prompt>"`. */
158const watchWords = (a: AgentRun, at: number): string =>
159  a.kind === 'cron'
160    ? `cron ${a.status === 'running' ? watchTime(a, at) : (a.schedule ?? '')}${a.description ? ` · "${a.description}"` : ''}`
161    : `watch ${a.description || 'monitor'}${a.target ? ' ' + a.target : ''}${a.status === 'running' ? ' · ' + watchTime(a, at) : ''}`
162
163// --- plain text, for the desktop and the other remote surfaces
164
165function panePlain(ui: Shared, d: PaneData) {
166  const { Box, Text } = ui
167  const t = totalsOf(d.list, d.at)
168  return (
169    <Box flexDirection="column">
170      <Text bold>Agents</Text>
171      <Text dimColor>{`Cost ${t.cost} · Tokens ${t.tokens} · Time ${t.time}`}</Text>
172      {d.list.length === 0 && <Text dimColor>No subagents yet.</Text>}
173      {d.list.map(a => (
174        <Text key={`row-${a.id}`}>
175          {isWatching(a)
176            ? `${a.status === 'finished' ? '✓' : a.status === 'error' ? '✗' : '◷'} ${watchWords(a, d.at)}`
177            : `${a.status === 'finished' ? '✓' : a.status === 'error' ? '✗' : '●'} ${titleOf(a)} · ${isShell(a) ? '' : modelName(a.model) + ' · '}${detailOf(a, d.at)}`}
178        </Text>
179      ))}
180    </Box>
181  )
182}
183
184// --- the terminal
185
186function paneTerminal($: EngineInterface, ui: Elements['terminal'], d: PaneData) {
187  const { Box, Text, Button, Raster } = ui
188  const { list, at, still } = d
189  const cols = d.cols
190  const cards = list.filter(a => !isWatching(a))
191  const watching = list.filter(isWatching)
192  const running = cards.filter(a => a.status === 'running')
193  const finished = cards.filter(a => a.status === 'finished')
194  const failed = cards.filter(a => a.status === 'error')
195  const isHub = running.length >= 2
196  const isMascots = !d.isCompact && cols >= MASCOT_MIN_COLS
197  const mascotCol = mascotWidth()
198  const t = totalsOf(list, at)
199  // Every card's number, in pane order: Running, Finished, Error.
200  const numberOf = cardNumbers(list)
201
202  // The card's numbered button: a stub until the messages view (E3) lands. Hotkeys work while the pane has focus.
203  const numButton = (a: AgentRun) => {
204    const n = numberOf.get(a.id) ?? 0
205    if (n < 1 || n > MAX_NUMBERED) return <Box key={`num-${a.id}`} flexShrink={0} width={NUM_W} />
206    return (
207      <Box key={`num-box-${a.id}`} flexShrink={0}>
208        <Button key={`num-${a.id}`} label={String(n)} hotkey={String(n)} onPress={() => $.ui.toast(MESSAGES_STUB)} />
209      </Box>
210    )
211  }
212
213  const toggle = (
214    <Button key="compact" label={d.isCompact ? 'Expand' : 'Collapse'} onPress={() => void update($, isCompact, v => !v)} />
215  )
216  // `x: clear done` drops every ended run; the twin of `/agents-info clear`. Drawn only when there is something to clear.
217  const ended = list.filter(a => a.status !== 'running').length
218  const clear =
219    ended > 0 ? (
220      <Box key="clear-box" flexShrink={0}>
221        <Button key="clear" label="x: clear done" hotkey="x" onPress={() => void update($, runs, l => pruneEnded(l, 0))} />
222      </Box>
223    ) : null
224  // The view keys: `c` the code map (`/agents-info code`), `p` the herdr peers (`/agents-info peers`), `q` close (`/agents-info close`).
225  const viewKeys = [
226    <Box key="view-code-box" flexShrink={0}>
227      <Button key="view-code" label="c: code" hotkey="c" onPress={() => void setView($, 'code')} />
228    </Box>,
229    <Box key="view-peers-box" flexShrink={0}>
230      <Button key="view-peers" label="p: peers" hotkey="p" onPress={() => void showPeers($).catch(() => {})} />
231    </Box>,
232    <Box key="pane-close-box" flexShrink={0}>
233      <Button key="pane-close" label="q: close" hotkey="q" onPress={() => void closePane($)} />
234    </Box>,
235  ]
236
237  // Three round tiles, a one-column gap between; one line when the longest label or value does not fit.
238  const tiles: [label: string, value: string][] = [
239    ['Cost', t.cost],
240    ['Tokens', t.tokens],
241    ['Time', t.time],
242  ]
243  const tileW = Math.floor((Math.max(24, cols) - 2) / 3)
244  const longest = Math.max(...tiles.flat().map(s => s.length))
245  const stats =
246    tileW - 4 < longest || d.isCompact ? (
247      <Text key="stats" wrap="truncate-end">
248        {tiles.map(([k, v], i) => (
249          <Text key={`stat-${i}`}>
250            {i ? <Text dimColor>{' · '}</Text> : null}
251            <Text dimColor>{k + ' '}</Text>
252            <Text bold>{v}</Text>
253          </Text>
254        ))}
255      </Text>
256    ) : (
257      <Box key="stats" flexDirection="row" gap={1}>
258        {tiles.map(([k, v], i) => (
259          <Box key={`tile-${i}`} width={tileW} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
260            <Text dimColor wrap="truncate-end">
261              {k}
262            </Text>
263            <Text bold wrap="truncate-end">
264              {v}
265            </Text>
266          </Box>
267        ))}
268      </Box>
269    )
270
271  // A section head with a thin rule to the body's right edge.
272  const head = (key: string, text: string) => (
273    <Text key={key} dimColor wrap="truncate-end">
274      {`${text} ${'─'.repeat(Math.max(0, cols - text.length - 1))}`}
275    </Text>
276  )
277
278  const mascot = (key: string, pose: Pose, isBobbed: boolean, tint?: number) => {
279    const box = boxOf(pose)
280    return (
281      <Box flexShrink={0} width={mascotCol}>
282        <Raster key={`mascot-${key}`} columns={box.columns} rows={box.rows} cells={mascotCells(pose, { isBobbed, tint })} />
283      </Box>
284    )
285  }
286  const isOddSecond = Math.floor(at / 1000) % 2 === 1
287
288  const bar = (a: AgentRun) => {
289    const n = barFill(a)
290    return (
291      <Box key={`bar-${a.id}`} flexShrink={0} paddingLeft={2}>
292        <Text>
293          <Text color={barColor(a, at, still)}>{'▰'.repeat(n)}</Text>
294          <Text dimColor>{'▱'.repeat(CARD_BAR - n)}</Text>
295        </Text>
296      </Box>
297    )
298  }
299
300  // A background shell's card: its grey mascot, ✓ ✗ or a grey spinner, `sh · <command>`,
301  // `background shell · <elapsed>` and the last line of its output.
302  const shellCard = (a: AgentRun) => {
303    const [glyph, glyphColor] = a.status === 'running' ? [still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●'), SHELL_GREY] : glyphOf(a, at, still)
304    const word = statusWord(a, at)
305    const wordColor = a.status === 'finished' ? GREEN : a.status === 'error' ? RED : SHELL_GREY
306    const tail = d.tails[a.id]
307    if (d.isCompact) {
308      return (
309        <Box key={a.id} flexDirection="row" gap={1}>
310          {numButton(a)}
311          <Box flexGrow={1} flexShrink={1}>
312            <Text wrap="truncate-end">
313              <Text color={glyphColor}>{glyph + ' '}</Text>
314              <Text bold>{titleOf(a)}</Text>
315              <Text dimColor>{` · ${detailOf(a, at)}`}</Text>
316            </Text>
317          </Box>
318        </Box>
319      )
320    }
321    const rows = (
322      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
323        <Box flexDirection="row" gap={1}>
324          {numButton(a)}
325          <Box flexShrink={0}>
326            <Text color={glyphColor}>{glyph}</Text>
327          </Box>
328          <Box flexGrow={1} flexShrink={1}>
329            <Text bold wrap="truncate-end">
330              {titleOf(a)}
331            </Text>
332          </Box>
333          <Box flexShrink={0}>
334            <Text color={wordColor}>{word}</Text>
335          </Box>
336        </Box>
337        <Box key={`detail-${a.id}`}>
338          <Text dimColor wrap="truncate-end">
339            {'  ' + detailOf(a, at)}
340          </Text>
341        </Box>
342        <Box key={`tail-${a.id}`}>
343          <Text dimColor wrap="truncate-end">
344            {'  ' + (tail ?? '…')}
345          </Text>
346        </Box>
347      </Box>
348    )
349    return (
350      <Box key={a.id} flexDirection="row" gap={1} marginBottom={1}>
351        {isMascots &&
352          mascot(a.id, poseOf(a, at), a.status === 'running' && !still && isOddSecond, a.status === 'error' ? RED_RGB : undefined)}
353        {rows}
354      </Box>
355    )
356  }
357
358  const card = (a: AgentRun) => {
359    if (isShell(a)) return shellCard(a)
360    const tier = tierOf(a)
361    const [glyph, glyphColor] = glyphOf(a, at, still)
362    const word = statusWord(a, at)
363    const wordColor = a.status === 'finished' ? GREEN : a.status === 'error' ? RED : word === 'waiting' ? AMBER : colorOf(tier)
364    const meta = [a.type, modelName(a.model), ...(a.effort ? [a.effort] : [])]
365    if (d.isCompact) {
366      return (
367        <Box key={a.id} flexDirection="row" gap={1}>
368          {numButton(a)}
369          <Box flexGrow={1} flexShrink={1}>
370            <Text wrap="truncate-end">
371              <Text color={glyphColor}>{glyph + ' '}</Text>
372              <Text bold>{titleOf(a)}</Text>
373              <Text dimColor>{` · ${modelName(a.model)} · ${detailOf(a, at)}`}</Text>
374            </Text>
375          </Box>
376        </Box>
377      )
378    }
379    const isPulse = a.status === 'error' && !still && a.endedAt !== undefined && at - a.endedAt < PULSE_MS && Math.floor(at / 250) % 2 === 0
380    const rows = (
381      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
382        <Box flexDirection="row" gap={1}>
383          {numButton(a)}
384          <Box flexShrink={0}>
385            <Text color={glyphColor}>{glyph}</Text>
386          </Box>
387          <Box flexGrow={1} flexShrink={1}>
388            <Text bold wrap="truncate-end">
389              {titleOf(a)}
390            </Text>
391          </Box>
392          <Box flexShrink={0}>
393            <Text color={wordColor}>{word}</Text>
394          </Box>
395        </Box>
396        <Text wrap="truncate-end">
397          {'  '}
398          <Text color={colorOf(tier)}>{tier}</Text>
399          <Text dimColor>{' · ' + meta.join(' · ')}</Text>
400        </Text>
401        <Box flexDirection="row">
402          {bar(a)}
403          <Box flexShrink={1}>
404            <Text dimColor wrap="truncate-end">
405              {' ' + detailOf(a, at)}
406            </Text>
407          </Box>
408        </Box>
409      </Box>
410    )
411    return (
412      <Box key={a.id} flexDirection="row" gap={1} marginBottom={1}>
413        {isMascots && mascot(a.id, poseOf(a, at), a.status === 'running' && !still && isOddSecond && !isWaiting(a, at), isPulse ? AMBER_RGB : undefined)}
414        {rows}
415      </Box>
416    )
417  }
418
419  // A watch's or cron's row under Watching, no mascot: its glyph, words and time; a watch's last event beneath.
420  const watchRow = (a: AgentRun) => {
421    const [glyph, glyphColor] = a.status === 'running' ? ['◷', AMBER] : glyphOf(a, at, still)
422    const isGrey = isStale(a, at)
423    return (
424      <Box key={a.id} flexDirection="column">
425        <Text wrap="truncate-end" {...(isGrey ? { dimColor: true } : {})}>
426          <Text {...(isGrey ? {} : { color: glyphColor })}>{glyph + ' '}</Text>
427          <Text bold={!isGrey}>{watchWords(a, at)}</Text>
428        </Text>
429        {a.lastEvent ? (
430          <Text dimColor wrap="truncate-end">
431            {'  ' + a.lastEvent}
432          </Text>
433        ) : null}
434      </Box>
435    )
436  }
437
438  // The main loop's card, at the top of Running while two or more subagents run.
439  // `N subagents running`, or `N tasks running` once shells are among them.
440  const runningText = `${running.length} ${running.some(isShell) ? 'tasks' : 'subagents'} running`
441  const hubCard = () => {
442    const m = d.main
443    const tier = tierOf({ type: 'main', model: m.model, effort: undefined })
444    const glyph = m.isRunning ? (still ? '●' : (SPINNER[Math.floor(at / 100) % SPINNER.length] ?? '●')) : '·'
445    const rows = (
446      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
447        <Box flexDirection="row" gap={1}>
448          <Box flexShrink={0}>
449            <Text color={colorOf(tier)}>{glyph}</Text>
450          </Box>
451          <Box flexGrow={1} flexShrink={1}>
452            <Text bold wrap="truncate-end">
453              main loop
454            </Text>
455          </Box>
456          <Box flexShrink={0}>
457            <Text color={colorOf(tier)}>{m.isRunning ? 'running' : 'idle'}</Text>
458          </Box>
459        </Box>
460        <Text wrap="truncate-end">
461          {'  '}
462          <Text color={colorOf(tier)}>{tier}</Text>
463          <Text dimColor>{` · main · ${modelName(m.model || 'unknown')}`}</Text>
464        </Text>
465        <Text dimColor wrap="truncate-end">
466          {`  ${runningText}${m.isRunning && m.startedAt ? ` · turn ${fmtTime(at - m.startedAt)}` : ''}`}
467        </Text>
468      </Box>
469    )
470    if (d.isCompact) {
471      return (
472        <Text key="main-loop" wrap="truncate-end">
473          <Text color={colorOf(tier)}>{glyph + ' '}</Text>
474          <Text bold>main loop</Text>
475          <Text dimColor>{` · ${modelName(m.model || 'unknown')} · ${runningText}`}</Text>
476        </Text>
477      )
478    }
479    return (
480      <Box key="main-loop" flexDirection="row" gap={1} marginBottom={1}>
481        {isMascots && mascot('main', hubPose(mainPoseId(m)), m.isRunning && !still && isOddSecond)}
482        {rows}
483      </Box>
484    )
485  }
486
487  return (
488    <Box flexDirection="column">
489      <Box flexDirection="row" justifyContent="space-between">
490        <Box flexShrink={1}>
491          <Text bold wrap="truncate-end">
492            Agents
493          </Text>
494        </Box>
495        <Box flexShrink={1} flexDirection="row" flexWrap="wrap" justifyContent="flex-end" gap={1}>
496          {clear}
497          {toggle}
498          {viewKeys}
499        </Box>
500      </Box>
501      {stats}
502      <Box flexDirection="column" marginTop={1}>
503        {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
504        {running.length > 0 && head('h-run', `Running · ${running.length}`)}
505        {isHub && hubCard()}
506        {running.map(card)}
507        {finished.length > 0 && head('h-done', `Finished · ${finished.length}`)}
508        {finished.map(card)}
509        {failed.length > 0 && head('h-error', `Error · ${failed.length}`)}
510        {failed.map(card)}
511        {watching.length > 0 && head('h-watch', `Watching · ${watching.filter(a => a.status === 'running').length}`)}
512        {watching.map(watchRow)}
513      </Box>
514    </Box>
515  )
516}
517
hooks/watch.ts 129 lines
1// Monitor watchers and CronCreate timers: what a watch's command targets,
2// a cron expression's next fire time, the time-left words, and the event
3// notifications a watch sends. Plain string and date work, no engine calls;
4// register.tsx keeps the books.
5import { wordsOf } from './shells'
6
7/** How long an entry stays grey after a stale notification. */
8export const STALE_MS = 10_000
9/**
10 * How recently another watch on the same target must have ended done for a
11 * notification to count as stale; a later wait on that target (a second
12 * round, a re-armed Monitor) is fresh and toasts.
13 */
14export const DONE_RECENT_MS = 60_000
15
16/**
17 * What a watch's command watches, for the band and for spotting a second
18 * watch on a target already done: a herdr pane (`w1:p6`), then a commit or
19 * run id (seven or more hex characters with a digit among them, or a number
20 * of five or more digits). Undefined when the command names none.
21 */
22export function targetOf(command: string): string | undefined {
23  const pane = /\bw\d+[A-Za-z]?:p\w+\b/.exec(command)?.[0]
24  if (pane) return pane
25  for (const w of wordsOf(command)) {
26    const word = w.replace(/^[#@]/, '')
27    if (/^[0-9a-f]{7,40}$/i.test(word) && /\d/.test(word) && /[a-f]/i.test(word)) return word.slice(0, 7)
28    if (/^\d{5,}$/.test(word)) return word
29  }
30  return undefined
31}
32
33/** `45s`, `12m`, `1h05m`, `2d`: a span of time in the band's words. */
34export function fmtLeft(ms: number): string {
35  const s = Math.max(0, Math.round(ms / 1000))
36  if (s < 60) return `${s}s`
37  const m = Math.round(s / 60)
38  if (m < 60) return `${m}m`
39  const h = Math.floor(m / 60)
40  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
41  return `${Math.floor(h / 24)}d`
42}
43
44/** `20:30`: a time of day, local, as the cron entry reads it. */
45export const fmtClock = (at: number): string => {
46  const d = new Date(at)
47  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
48}
49
50/** One cron field as the set of values it allows, or null when it cannot be read. */
51function fieldOf(text: string, min: number, max: number): Set<number> | null {
52  const out = new Set<number>()
53  for (const part of text.split(',')) {
54    const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part)
55    if (!m) return null
56    const step = m[2] ? Number(m[2]) : 1
57    if (step < 1) return null
58    let lo = min
59    let hi = max
60    if (m[1] !== '*') {
61      const [a, b] = (m[1] ?? '').split('-').map(Number)
62      lo = a ?? min
63      hi = b ?? (m[2] ? max : lo)
64    }
65    if (lo < min || hi > max || lo > hi) return null
66    for (let v = lo; v <= hi; v += step) out.add(v)
67  }
68  return out
69}
70
71/**
72 * The next time after `from` a five-field cron expression (`M H DoM Mon DoW`,
73 * local time; `*`, lists, ranges and steps) matches, within eight days (a
74 * recurring job expires after seven); undefined when it cannot be read or does
75 * not match in that span. Day of month and day of week combine as cron does:
76 * either matches when both are restricted.
77 */
78export function cronNext(expr: string, from: number): number | undefined {
79  const parts = expr.trim().split(/\s+/)
80  if (parts.length !== 5) return undefined
81  const [mi, ho, dom, mon, dow] = parts as [string, string, string, string, string]
82  const minutes = fieldOf(mi, 0, 59)
83  const hours = fieldOf(ho, 0, 23)
84  const days = fieldOf(dom, 1, 31)
85  const months = fieldOf(mon, 1, 12)
86  // Sunday is 0 or 7.
87  const weekdays = fieldOf(dow, 0, 7)
88  if (weekdays?.has(7)) weekdays.add(0)
89  if (!minutes || !hours || !days || !months || !weekdays) return undefined
90  const isDomAny = dom === '*'
91  const isDowAny = dow === '*'
92  const d = new Date(from)
93  d.setSeconds(0, 0)
94  d.setMinutes(d.getMinutes() + 1)
95  for (let i = 0; i < 8 * 24 * 60; i++) {
96    const dayOk =
97      isDomAny && isDowAny
98        ? true
99        : isDomAny
100          ? weekdays.has(d.getDay())
101          : isDowAny
102            ? days.has(d.getDate())
103            : days.has(d.getDate()) || weekdays.has(d.getDay())
104    if (months.has(d.getMonth() + 1) && dayOk && hours.has(d.getHours()) && minutes.has(d.getMinutes())) return d.getTime()
105    d.setMinutes(d.getMinutes() + 1)
106  }
107  return undefined
108}
109
110/** A watch's event notification: its task id and the first line of its event. */
111export type WatchEvent = { id: string; event: string }
112
113/**
114 * Every event notification in a row's text: a `<task-notification>` with an
115 * `<event>` and no `<status>` (a Monitor's stdout line, not its end).
116 */
117export function eventsOf(text: string): WatchEvent[] {
118  const out: WatchEvent[] = []
119  for (const m of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
120    const body = m[1] ?? ''
121    if (/<status>/.test(body)) continue
122    const id = /<task-id>([\s\S]*?)<\/task-id>/.exec(body)?.[1]?.trim()
123    const event = /<event>([\s\S]*?)<\/event>/.exec(body)?.[1]?.trim()
124    if (!id || event === undefined) continue
125    out.push({ id, event: (event.split('\n').find(l => l.trim() !== '') ?? '').trim().slice(0, 240) })
126  }
127  return out
128}
129
hooks/mascots.ts 1260 lines
1// Pixel art from the "Claude Mascot Pack" by getillustrations.com
2// (https://getillustrations.com/illustration-pack/claude-mascot-pack),
3// "Commercial and personal use license", pack downloaded 2026-10-19; the pack
4// itself is not redistributed, only the pixels of the poses used (POSES).
5//
6// The grey shell and codex mascots (SHELL_POSES, at the end) are not from the
7// pack: drawn by Zehui/Claude 2026-10-19 in the pack's proportions.
8//
9// Each pose is drawn as a half-block Raster: one column per pixel, one row per
10// two pixels (top pixel the foreground of ▀, bottom pixel its background).
11//
12// Every state has a pool of poses (0.4.0): a run keeps the pose its id hashes
13// to for as long as it stays in that state, so a card never changes pose
14// within a state, and two runs that finish together seldom look the same.
15
16/** Raster colour for the terminal's default (bit 24 alone). */
17const DEFAULT = 0x01000000
18const UPPER = 0x2580 // ▀
19const LOWER = 0x2584 // ▄
20const SPACE = 0x20
21// An empty cell still names a colour for its glyph, never the terminal's default foreground.
22const BLANK_FG = 0x000000
23
24/** The characters a pack pose's rows use, each the index of a colour in that pose's palette. */
25export const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
26
27/** A pose from the pack. */
28export type PackPose = keyof typeof POSES
29/** A shell or codex mascot of our own. */
30export type ShellPose = keyof typeof SHELL_POSES
31export type Pose = PackPose | ShellPose
32
33/** Running poses. */
34export const RUNNING_POOL: readonly PackPose[] = [
35  'repairing-with-tools',
36  'writing-software-code',
37  'building-colorful-blocks',
38  'debugging-system-bugs',
39  'reading-data-document',
40  'thinking-in-code',
41  'examining-green-globe',
42  'lab-science-testing',
43  'holding-ancient-scroll',
44  'visualizing-tree-data',
45  'managing-server-rack',
46  'processing-logic-symbols',
47  'planting-small-trees',
48  'user-profile-settings',
49]
50/** A run waiting on a tool's result (or the person) for a while. */
51export const WAIT_POOL: readonly PackPose[] = ['sleeping-soundly', 'thinking-pixel-bubble', 'in-constant-loop', 'it-depends-sign']
52/** A finished run. */
53export const DONE_POOL: readonly PackPose[] = [
54  'with-success-checkmark',
55  'success-achievement-milestone',
56  'happy-pixel-celebrating',
57  'rocket-launch-success',
58  'api-success-pixel',
59  'in-glowing-aura',
60  'love-cloud-pixel',
61]
62/** A run that ended in an error. */
63export const ERROR_POOL: readonly PackPose[] = [
64  'confusion-spiral-pixel',
65  'dizzy-failed-state',
66  'error-state-pixel',
67  'failed-process-error',
68  'system-error-alert',
69  'marking-critical-error',
70  'crying-sad-reaction',
71  'angry-at-laptop',
72  'broken-heart-sad',
73  'disconnected-plug-pixel',
74]
75/** The main loop's card, while two or more subagents run. */
76export const HUB_POOL: readonly PackPose[] = ['artboard-25cla', 'multi-window-workflow', 'developer-pair-programming-scene']
77
78/**
79 * The background shell's card mascot: candidate C (`shell-window`), Zehui's
80 * pick on 2026-10-19; A (`shell-terminal-face`) is the other choice.
81 */
82export const SHELL_POSE: ShellPose = 'shell-window'
83/**
84 * A shell running a coding agent's CLI (codex, opencode, claude -p) shows one
85 * of the five Codex fuzzies, chosen by a hash of the run id (see codexPose);
86 * `codex-blue-cap` and `codex-hex-badge` are the earlier grey-bot candidates.
87 */
88export const CODEX_POOL: readonly ShellPose[] = [
89  'fuzzy-pink-headphones',
90  'fuzzy-blue-beret',
91  'fuzzy-yellow-triangle',
92  'fuzzy-green-square',
93  'fuzzy-purple-heart',
94]
95export const CODEX_POSE: ShellPose = CODEX_POOL[0] ?? 'fuzzy-blue-beret'
96
97const isShellPose = (pose: Pose): pose is ShellPose => Object.hasOwn(SHELL_POSES, pose)
98
99const poseRows = (pose: Pose): readonly string[] => (isShellPose(pose) ? SHELL_POSES[pose] : POSES[pose].rows)
100
101/** A row character's colour in `pose`, or undefined for none. */
102const colourOf = (pose: Pose, ch: string): number | undefined => {
103  if (isShellPose(pose)) return Object.hasOwn(SHELL_PALETTE, ch) ? SHELL_PALETTE[ch] : undefined
104  const palette: readonly number[] = POSES[pose].palette
105  const i = ALPHABET.indexOf(ch)
106  return i >= 0 ? palette[i] : undefined
107}
108
109/** A pose's pixel width and height. */
110export const pixelsOf = (pose: Pose): { w: number; h: number } => {
111  const rows = poseRows(pose)
112  return { w: rows[0]?.length ?? 0, h: rows.length }
113}
114
115/**
116 * The Raster box a pose takes: one column per pixel, and rows for its height
117 * plus the one-pixel bob, so a bobbing mascot never changes its card's height.
118 * The card is as tall as its pose (or its text, when that is taller).
119 */
120export const boxOf = (pose: Pose): { columns: number; rows: number } => {
121  const { w, h } = pixelsOf(pose)
122  return { columns: w, rows: Math.ceil((h + 1) / 2) }
123}
124
125/**
126 * The widest pose: the mascot column of every card, so the cards' text lines up.
127 * A function, since the pixel data is declared below the code.
128 */
129export const mascotWidth = (): number =>
130  Math.max(...([...Object.keys(POSES), ...Object.keys(SHELL_POSES)] as Pose[]).map(p => pixelsOf(p).w))
131
132/** FNV-1a of a run id, for stable picks. */
133const hashOf = (id: string): number => {
134  let h = 0x811c9dc5
135  for (let i = 0; i < id.length; i++) {
136    h ^= id.charCodeAt(i)
137    h = Math.imul(h, 0x01000193)
138  }
139  return h >>> 0
140}
141
142/** A stable pick from `pool` for an id. */
143const pickOf = <P extends Pose>(pool: readonly P[], id: string, fallback: P): P => pool[hashOf(id) % pool.length] ?? fallback
144
145/** A stable pick from the running pool for a run id. */
146export const runningPose = (id: string): Pose => pickOf(RUNNING_POOL, id, 'thinking-in-code')
147
148/** A stable pick from the waiting pool for a run id. */
149export const waitPose = (id: string): Pose => pickOf(WAIT_POOL, id, 'sleeping-soundly')
150
151/** A stable pick from the finished pool for a run id. */
152export const donePose = (id: string): Pose => pickOf(DONE_POOL, id, 'with-success-checkmark')
153
154/** A stable pick from the error pool for a run id. */
155export const errorPose = (id: string): Pose => pickOf(ERROR_POOL, id, 'confusion-spiral-pixel')
156
157/** The main loop's pose: a stable pick from the hub pool for the session's id (or its main turn's). */
158export const hubPose = (id: string): Pose => pickOf(HUB_POOL, id, 'artboard-25cla')
159
160/** A stable pick from the codex fuzzies for a shell's run id. */
161export const codexPose = (id: string): ShellPose => pickOf(CODEX_POOL, id, CODEX_POSE)
162
163/** Blends a colour toward `tint` by `k` (0..1). */
164const blend = (c: number, tint: number, k: number): number => {
165  const mix = (shift: number) => Math.round(((c >> shift) & 0xff) * (1 - k) + ((tint >> shift) & 0xff) * k)
166  return (mix(16) << 16) | (mix(8) << 8) | mix(0)
167}
168
169export type MascotOptions = {
170  /** Shifts the pose down one pixel row (the bob on odd seconds). */
171  isBobbed?: boolean
172  /** Blends every pixel halfway toward this 0xRRGGBB colour (the error pulse). */
173  tint?: number
174}
175
176const memo = new Map<string, string>()
177const MEMO_MAX = 128
178
179/**
180 * A pose's Raster cells, row-major base64 u32 triplets [codePoint, fg, bg], in
181 * the box `boxOf(pose)` gives. Both pixels set: ▀ with the top as foreground
182 * and the bottom as background; only the top: ▀; only the bottom: ▄; neither:
183 * a space. The background of a half-filled cell is the terminal's default.
184 */
185export function mascotCells(pose: Pose, options: MascotOptions = {}): string {
186  const shift = options.isBobbed ? 1 : 0
187  const key = `${pose}|${shift}|${options.tint ?? ''}`
188  const hit = memo.get(key)
189  if (hit !== undefined) return hit
190  const rows = poseRows(pose)
191  const { columns, rows: cellRows } = boxOf(pose)
192  const pixel = (x: number, y: number): number | null => {
193    const ch = rows[y - shift]?.[x]
194    if (ch === undefined || ch === '.') return null
195    const c = colourOf(pose, ch)
196    if (c === undefined) return null
197    return options.tint === undefined ? c : blend(c, options.tint, 0.5)
198  }
199  const words = new Uint32Array(columns * cellRows * 3)
200  for (let r = 0; r < cellRows; r++) {
201    for (let x = 0; x < columns; x++) {
202      const top = pixel(x, r * 2)
203      const bottom = pixel(x, r * 2 + 1)
204      const i = (r * columns + x) * 3
205      if (top !== null) {
206        words[i] = UPPER
207        words[i + 1] = top
208        words[i + 2] = bottom ?? DEFAULT
209      } else if (bottom !== null) {
210        words[i] = LOWER
211        words[i + 1] = bottom
212        words[i + 2] = DEFAULT
213      } else {
214        words[i] = SPACE
215        words[i + 1] = BLANK_FG
216        words[i + 2] = DEFAULT
217      }
218    }
219  }
220  // The hooks environment has the web's Uint8Array.prototype.toBase64; the es2023 lib does not type it.
221  const cells = (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
222  if (memo.size >= MEMO_MAX) memo.clear()
223  memo.set(key, cells)
224  return cells
225}
226
227// --- pixel data (generated from the pack's 1x PNGs with tools/pixgrid.py, grid count 46; see the header)
228
229/**
230 * Each pack pose: its own palette (0xRRGGBB, most used first) and its pixel
231 * rows, top to bottom; '.' is empty, any other character indexes the pose's
232 * palette (0-9, a-z, A-Z). One palette per pose keeps every colour exact.
233 */
234export const POSES = {
235  'repairing-with-tools': {
236    palette: [0xd97757, 0xf4e84d, 0x7296aa, 0x5b39c9, 0xffffff, 0x2f2f38],
237    rows: [
238      '...........11...........',
239      '.......1111111111.......',
240      '......111111111111......',
241      '.....11111111111111.....',
242      '.....11111111111111.....',
243      '.....11111111111111...2.',
244      '....1111111111111111..2.',
245      '2..2.00000000000000...2.',
246      '2222.00000000000000...2.',
247      '.22..00000000000000...2.',
248      '.22..00000000000000...2.',
249      '.22..00050000005000..333',
250      '.22..00000400400000...3.',
251      '.2000000000440000000003.',
252      '.2000000000000000000003.',
253      '22000000000000000000003.',
254      '2..2.0..0......0..0..333',
255      '....0..0........0..0....',
256    ],
257  },
258  'writing-software-code': {
259    palette: [0xd97757, 0x3bf497, 0x2c8c5e, 0x15586d, 0xffffff, 0x2f2f38],
260    rows: [
261      '..........222......2........',
262      '.......111.......2...2......',
263      '............................',
264      '.......00000000000000.2.....',
265      '111....00000001110000.......',
266      '...333.00001110000000.......',
267      '.......000000000000001...1..',
268      '.......00005000005001001..1.',
269      '....00000000000000010001...1',
270      '....00000000044400001001..1.',
271      '....002200000000000001...1..',
272      '....11.00000000000000.......',
273      '.......0..0......0..0.......',
274      '......0..0......0..0........',
275    ],
276  },
277  'building-colorful-blocks': {
278    palette: [0xd97757, 0xda64dd, 0x6d61e0, 0xf9f950, 0x3f6a99, 0x39e5bc, 0x2ee565, 0xff6f48, 0x5ab1ff, 0xb726ed, 0x2f2f38, 0xffffff],
279    rows: [
280      '...00000000000000...',
281      '...00000000000000...',
282      '...00000000000000...',
283      '...0000a0000a0000...',
284      '...000a0a00a0a000...',
285      '...00000b00b00000...',
286      '000000000bb000000000',
287      '00000000000000000000',
288      '00000000000000000000',
289      '...0..0......0..0...',
290      '...0..0......0..0...',
291      '..1111222255553333..',
292      '..1111222255553333..',
293      '..1111222255553333..',
294      '44446666777788889999',
295      '44446666777788889999',
296      '44446666777788889999',
297      '..2222333311114444..',
298      '..2222333311114444..',
299      '..2222333311114444..',
300    ],
301  },
302  'debugging-system-bugs': {
303    palette: [0xd97757, 0x456ec9, 0x25cc50, 0x8a46c6, 0xcaf419, 0x1414c9, 0x2f2f38],
304    rows: [
305      '..1.........33.......',
306      '1111.......3333......',
307      '11111......3333......',
308      '155511......33.......',
309      '115111.......3.......',
310      '111100000000000000...',
311      '....0000000000002222.',
312      '....0000000000002222.',
313      '....0000000000002222.',
314      '....0000000006002222.',
315      '...0000000000000002.2',
316      '000000000000000000000',
317      '000000440000000000000',
318      '...004444....00.00...',
319      '...0.4444.....0......',
320      '......44.............',
321    ],
322  },
323  'reading-data-document': {
324    palette: [0xd97757, 0xdbe00b, 0xa50e97, 0xbcbc2b, 0x598dd8, 0xffffff, 0x2f2f38],
325    rows: [
326      '..00000000000000..',
327      '..00000000000000..',
328      '..00222000022200..',
329      '..02464222246420..',
330      '..00222000022200..',
331      '.00000005500000000',
332      '.00000005500000000',
333      '.11111100000000000',
334      '001333100000000000',
335      '00113331....0..0..',
336      '...111111.........',
337      '....111111........',
338    ],
339  },
340  'thinking-in-code': {
341    palette: [0xd97757, 0x27ff69, 0x2f2f38, 0xffffff],
342    rows: [
343      '...1..............1...',
344      '...1..............1...',
345      '..1..........1.....1..',
346      '.1..........1.......1.',
347      '1..........1.........1',
348      '11..00000100000000..11',
349      '..1.00001000000000.1..',
350      '...1000000000000001...',
351      '...1000000000000001...',
352      '....00020000002000....',
353      '....00000300300000....',
354      '.00000000000000000000.',
355      '.00000000000000000000.',
356      '....0..0......0..0....',
357      '....0..0......0..0....',
358      '....0..0......0..0....',
359    ],
360  },
361  'examining-green-globe': {
362    palette: [0xd97757, 0xaaff15, 0x17aaa3, 0x3b3bcc, 0x68e0bb, 0xffffff, 0x2f2f38],
363    rows: [
364      '..111.................',
365      '..111.................',
366      '.12221................',
367      '1242421...............',
368      '1422241.00000000000000',
369      '1122211.00000000600000',
370      '..111.3.00000000000000',
371      '.......300000550000000',
372      '.......300000550000000',
373      '.....00030000000000000',
374      '.....00003000000000000',
375      '.....00000030000000000',
376      '........0.0.3000...0.0',
377      '........0.0..300...0.0',
378    ],
379  },
380  'lab-science-testing': {
381    palette: [0xd97757, 0xb5b3ba, 0x8557ce, 0x1327e2, 0x351daa, 0x11ea82, 0x3340c1, 0xffffff, 0x2f2f38],
382    rows: [
383      '.........................5',
384      '.111....................5.',
385      '..1..................5.5..',
386      '..1...................5...',
387      '..2.......................',
388      '.222.................11111',
389      '26262.................111.',
390      '626260..............00333.',
391      '...000..............00343.',
392      '...0000000000000000000434.',
393      '......00000000000000..343.',
394      '......00008000080000..434.',
395      '......00000700700000...4..',
396      '......00000077000000......',
397      '......00000000000000......',
398      '......00000000000000......',
399      '......00000000000000......',
400      '......00000000000000......',
401      '.....0...0......0...0.....',
402      '.....0...0......0...0.....',
403    ],
404  },
405  'holding-ancient-scroll': {
406    palette: [0xd97757, 0xffd255, 0xeab839, 0xf9f2de, 0x2f2f38],
407    rows: [
408      '....333.................',
409      '...3...3................',
410      '..3....000..............',
411      '1131221100..............',
412      '.132332100..............',
413      '..132211..000000000000..',
414      '..111111..000000000000..',
415      '..122221..044004000000..',
416      '.1111111..000000000000..',
417      '.1222211..000330000000..',
418      '11111111..000000000000..',
419      '1111111...00000000000000',
420      '..........00000000000000',
421      '..........00000000000000',
422      '..........0...0......0.0',
423      '.........0....0.....0..0',
424    ],
425  },
426  'visualizing-tree-data': {
427    palette: [0xd97757, 0x6240e2, 0x30ef4b, 0xffffff],
428    rows: [
429      '...............2........',
430      '...............1..11..1.',
431      '..............211.11.1.2',
432      '...............1..111.1.',
433      '.000000000000.....11....',
434      '.0000000000000....11....',
435      '.0000000000000....11....',
436      '.0000000000000...1111...',
437      '.0000000003000..1.11.1..',
438      '.0000000000000...1..1...',
439      '00000000000000001.......',
440      '0000000000000000........',
441      '0000000000000000........',
442      '0.00.....00.00..........',
443      '0........0...0..........',
444    ],
445  },
446  'managing-server-rack': {
447    palette: [0xd97757, 0x6f6ad3, 0x4139aa, 0x4c4ce0, 0xffffff, 0x2f2f38, 0x82ef2e],
448    rows: [
449      '..............000...........',
450      '..............0003333333222.',
451      '0000000000000000111111111222',
452      '00000000500050..111111111222',
453      '00000000040400...3333333222.',
454      '00000000004000..111111111222',
455      '00000000004000..111111111222',
456      '00000000000000..111161111222',
457      '0000000000000000011111111222',
458      '0000000000000000011111111222',
459      '0..0......0..0..111161111222',
460      '0..0......0..0..111111111222',
461    ],
462  },
463  'processing-logic-symbols': {
464    palette: [0xd97757, 0xd322a5, 0x2f2f38, 0xe83c00, 0x38e540, 0x7d3ec6, 0x5a39e2, 0x359cf9],
465    rows: [
466      '...1......................',
467      '..111................3.3..',
468      '..111................3.3..',
469      '...100..............003...',
470      '...000..............0303..',
471      '...000..............000...',
472      '......00000000000000......',
473      '44....00000000000000...666',
474      '4400..00020000002000..006.',
475      '4400..00020000002000..006.',
476      '.000..00202000020200..000.',
477      '.000..00000000000000..000.',
478      '..7...00000000000000..5.5.',
479      '..700.00000000000000.050..',
480      '.7700.00000000000000.0555.',
481      '..000.0.0........0.0.000..',
482      '..000.0.0........0.0.000..',
483      '.......0.0......0.0.......',
484    ],
485  },
486  'planting-small-trees': {
487    palette: [0xd97757, 0x4f4aa8, 0x7c7cce, 0xffeb99, 0x6de26d, 0xefd760, 0x13ce1b, 0x828293, 0xffffff],
488    rows: [
489      '.....00000000000000..',
490      '.....00000000000000..',
491      '.....00000000000000..',
492      '.....00000000000000..',
493      '.12..00000000000000..',
494      '2122.00000000000000..',
495      '121104600080000000000',
496      '.12006400000000000000',
497      '.7000470000000000000.',
498      '35335350......0...0..',
499    ],
500  },
501  'user-profile-settings': {
502    palette: [0xd97757, 0xdbd235, 0x42c642, 0xf2e970, 0x7667e8, 0xffffff, 0xd89336, 0x6251b7, 0x2f2f38],
503    rows: [
504      '.2222222222...............',
505      '22522552222...............',
506      '22222222222...............',
507      '1111111111100000000000000.',
508      '1444666661100000000000000.',
509      '1474111111100000000000000.',
510      '1444333333100080000008000.',
511      '1777111111100000005000000.',
512      '1111333331100000550000000.',
513      '.1111111110000000000000000',
514      '.........00000000000000000',
515      '.........00000000000000000',
516      '...........0...0......0.0.',
517      '...........0...0......0.0.',
518    ],
519  },
520  'sleeping-soundly': {
521    palette: [0xd97757, 0x5b1fdd, 0x1a9643, 0x2f2f38, 0xffe05c, 0xff6136, 0x70ed20, 0xffffff],
522    rows: [
523      '......1111..........',
524      '........1....44.....',
525      '.......1......44....',
526      '.2222.1111..........',
527      '...2.........55.....',
528      '..2...........55....',
529      '.2222......66.......',
530      '...00000000066000...',
531      '...00000000000000...',
532      '...00000000000000...',
533      '...00303000030300...',
534      '...00030000003000...',
535      '...00000000000000...',
536      '00000000070000000000',
537      '00000000000000000000',
538      '00000000000000000000',
539      '...0..0......0..0...',
540      '....0..0......0..0..',
541    ],
542  },
543  'thinking-pixel-bubble': {
544    palette: [0xd97757, 0x3cd694, 0x2f2f38, 0x3d45d3, 0xffffff],
545    rows: [
546      '..................111111..',
547      '.................11111111.',
548      '................1111111111',
549      '................1131131111',
550      '................1111111111',
551      '.................11111111.',
552      '..................111111..',
553      '................1.........',
554      '................11........',
555      '..000000000000000.........',
556      '..00000000000001000.......',
557      '..00000000000000000.......',
558      '..00022200002220000.......',
559      '..000000000000000.........',
560      '..000000040000000.........',
561      '00000000000000000.........',
562      '00000000000000000.........',
563      '00000000000000000.........',
564      '..0..00.....00..0.........',
565      '..0..00.....00..0.........',
566    ],
567  },
568  'in-constant-loop': {
569    palette: [0xd97757, 0x9c17e2, 0x2f2f38, 0xffffff],
570    rows: [
571      '............1...............',
572      '.............1..............',
573      '.........111111...11........',
574      '........1....1......1.1.....',
575      '.......1....1........11.....',
576      '......1.............111.....',
577      '............................',
578      '..1....00000000000000...1...',
579      '.111...00000000000000....1..',
580      '1.1.1..00000000000000....1..',
581      '..1....00202000020200....1..',
582      '..1.00000020000002000000.1..',
583      '..1.00000000000000000000.1..',
584      '..1.00000000033000000001.1.1',
585      '..1....00000000000000...111.',
586      '...1...00000000000000....1..',
587      '......0..0.......0...0......',
588      '.....111..0.....0...01......',
589      '.....11........1....1.......',
590      '.....1.1......1....1........',
591      '........11...111111.........',
592      '..............1.............',
593      '...............1............',
594    ],
595  },
596  'it-depends-sign': {
597    palette: [0xd97757, 0xef509c, 0xaded51, 0xffffff, 0x8c7670],
598    rows: [
599      '1111111111111111111111......',
600      '1112112211111111111111......',
601      '1111111111111111111111......',
602      '1111111111111111111111......',
603      '1121122122122122212211......',
604      '1122121122121121212121......',
605      '1121122111122111212211......',
606      '.11111111141111111111.......',
607      '..........4.................',
608      '.........0000000000000000...',
609      '........00000000000000000...',
610      '........00000000000000000...',
611      '.........0000000000000000...',
612      '..........400000333300000...',
613      '...........00000000000000...',
614      '...........00000000000000000',
615      '...........00000000000000000',
616      '...........00000000000000000',
617      '...........00000000000000000',
618      '...........0..0......0..0...',
619      '...........0............0...',
620    ],
621  },
622  'with-success-checkmark': {
623    palette: [0xd97757, 0x15aa51, 0x3aedb2, 0x2f2f38, 0xffffff],
624    rows: [
625      '.......111111.......',
626      '...2..11111111..2...',
627      '.2...1111111111...2.',
628      '.....1111111211.....',
629      '2....1111112111....2',
630      '.....1112121111.....',
631      '000..1111211111..000',
632      '000...11111111...000',
633      '000....111111....000',
634      '...00000000000000...',
635      '...00000000000000...',
636      '...00030000003000...',
637      '...00303000030300...',
638      '...00000400400000...',
639      '...00000044000000...',
640      '...00000000000000...',
641      '...00000000000000...',
642      '...00000000000000...',
643      '...0..0......0..0...',
644      '..0...0......0...0..',
645    ],
646  },
647  'success-achievement-milestone': {
648    palette: [0xd97757, 0xfcfc56, 0x64e2a6, 0x2f2f38, 0xffffff],
649    rows: [
650      '........2.........1.....',
651      '........2...............',
652      '........2...............',
653      '.......2..........1.....',
654      '...2...2........1.1.1...',
655      '....2.2..........212....',
656      '...0000000000000021211.1',
657      '...00000000000001.1.1...',
658      '...00030000003000.1.....',
659      '...00030000003000.1.....',
660      '...00303000030300.......',
661      '...00000000000000.1.....',
662      '00000000044000000000....',
663      '00000000000000000000....',
664      '...00000000000000.......',
665      '...0..0......0..0.......',
666      '...0..0......0..0.......',
667      '...0..0......0..0.......',
668    ],
669  },
670  'happy-pixel-celebrating': {
671    palette: [0xd97757, 0x50ffb4, 0x873be0, 0xffff39, 0xaa399f, 0x2f2f38, 0x1fa028, 0xffffff, 0xf4552a],
672    rows: [
673      '......1............2......',
674      '...001.1..........2.200...',
675      '...0001............2000...',
676      '...000..............000...',
677      '..........................',
678      '......................3...',
679      '...4.................3.3..',
680      '.0404.00000000000000..300.',
681      '.004..00000000000000..000.',
682      '.000..00000000000000..000.',
683      '......00000000000000......',
684      '......00550000005500......',
685      '.6....00000700700000....8.',
686      '6.600.00000077000000.008.8',
687      '.6000.00000000000000.0008.',
688      '..000.00000000000000.000..',
689      '......0..0......0..0......',
690      '.......0..0....0..0.......',
691    ],
692  },
693  'rocket-launch-success': {
694    palette: [0xd97757, 0x96f916, 0xffbd03, 0xea3f54, 0x1944f7, 0xffffff, 0x3564f9, 0x2f2f38, 0x7697ff],
695    rows: [
696      '.......................466',
697      '......................1146',
698      '.................2...31814',
699      '................2...11311.',
700      '...................13113..',
701      '................3341131...',
702      '...............3..4411....',
703      '............2....2044..2..',
704      '...........2...0022.3.2...',
705      '.00000000000000020..3.....',
706      '.00000000000000000.3......',
707      '.00007000000700...........',
708      '.00000050050000...2.......',
709      '.00000005500000..2........',
710      '.00000000000000...........',
711      '.00000000000000000........',
712      '.00000000000000000........',
713      '.00000000000000000........',
714      '.0..0......0..0...........',
715      '0..0........0..0..........',
716    ],
717  },
718  'api-success-pixel': {
719    palette: [0xd97757, 0xc6c1e0, 0x4f37db, 0x23d8b5, 0xffffff, 0x2f2f38],
720    rows: [
721      '.22222222222................',
722      '1111111111111.......3333333.',
723      '1112112221211......111111111',
724      '1121212121111......111111311',
725      '1122212221211......111113111',
726      '1121212111211000000113131111',
727      '1111111111111000000111311111',
728      '.11111111111000000001111111.',
729      '.......00000000000000.......',
730      '.......00050000005000.......',
731      '.......00000000000000.......',
732      '....00000000444400000000....',
733      '....00000000000000000000....',
734      '....00000000000000000000....',
735      '.......0..0......0..0.......',
736      '.......0..0......0..0.......',
737    ],
738  },
739  'in-glowing-aura': {
740    palette: [0xd97757, 0xdddddd, 0x2f2f38, 0xffffff],
741    rows: [
742      '...1.....11.....1...',
743      '..111....11....111..',
744      '...111...11...111...',
745      '...111...11...111...',
746      '....1..........1....',
747      '....................',
748      '...00000000000000...',
749      '11100000000000000111',
750      '11100202000020200111',
751      '...00020000002000...',
752      '...00020000002000...',
753      '...00000300300000...',
754      '00000000033000000000',
755      '00000000000000000000',
756      '...0..0......0..0...',
757      '....1..0....0..1....',
758      '...111........111...',
759      '...111........111...',
760      '..111....11....111..',
761      '...1.....11.....1...',
762      '.........11.........',
763    ],
764  },
765  'love-cloud-pixel': {
766    palette: [0xf1ff6c, 0xd97757, 0xdd3e68, 0xffcf6e, 0xffffff, 0x2f2f38, 0xed6992],
767    rows: [
768      '..2.222.................',
769      '.2226622................',
770      '.2222222................',
771      '..22222.................',
772      '..2222...00000000000000.',
773      '...2....000000000000000.',
774      '....1111111111111100000.',
775      '....1111111111111100000.',
776      '....1111111111111100000.',
777      '....1115111111511100000.',
778      '..111111511111111111110.',
779      '..111111141114111111110.',
780      '..111111114441111111110.',
781      '.0001111111111111100000.',
782      '000011111111111111000000',
783      '000133313331133133000000',
784      '000100010001100100000000',
785      '000000000000000000000000',
786      '.0000000000000000000000.',
787      '..000000000000000000000.',
788    ],
789  },
790  'confusion-spiral-pixel': {
791    palette: [0xd97757, 0x602aad, 0x674bef, 0x2f2f38, 0xffffff],
792    rows: [
793      '....1.21212121..1...',
794      '.....1..2121..2.....',
795      '....2..1....2.1.2...',
796      '....2..1....2.1.2...',
797      '....1.2..12.1.2.....',
798      '....2.1.21.12.1.2...',
799      '....1.2...2...2.1...',
800      '....21.1212121.12...',
801      '......2.......2.....',
802      '...01001212121001...',
803      '...01001212121001...',
804      '...00000000000000...',
805      '...00030000003000...',
806      '...00303000030300...',
807      '00000000044000000000',
808      '00000000000000000000',
809      '00000000000000000000',
810      '00000000000000000000',
811      '...00000000000000...',
812      '...0..0......0..0...',
813      '....0..0....0..0....',
814    ],
815  },
816  'dizzy-failed-state': {
817    palette: [0xd97757, 0xb51f8a, 0xe058c0, 0xffffff, 0x2f2f38, 0xf75b2b],
818    rows: [
819      '..111.................',
820      '.11311................',
821      '1111111...............',
822      '13111311....5.........',
823      '11131131.....5....2..2',
824      '11311111...........2..',
825      '1111111.............2.',
826      '.11111....22......2..2',
827      '.....00002222000000...',
828      '.....00000000000000...',
829      '.....00000000000000...',
830      '.....00000000000000...',
831      '.....00400000004000...',
832      '.....00443000304000...',
833      '..0000000033300000000.',
834      '.000000000000000000000',
835      '.000000000000000000000',
836      '..0000000000000000000.',
837      '.....0..0......0.00...',
838    ],
839  },
840  'error-state-pixel': {
841    palette: [0xd97757, 0xff97d0, 0x5a9bcc, 0x2f2f38, 0xffffff, 0x8659ce],
842    rows: [
843      '...........1...1............',
844      '...........1...1............',
845      '............1.1.....22222...',
846      '.............1.....2111112..',
847      '............1.1...211111112.',
848      '.000000000000001.21114141112',
849      '.00030000003000..21111411112',
850      '.00303000030300..21114141112',
851      '.00303000030300..21114141112',
852      '.00030000003000...214111412.',
853      '.00000044000000...52111112..',
854      '000000000000000005..22222...',
855      '000000000000000500..........',
856      '.0..0......0..0.............',
857      '0..0......0..0..............',
858      '0..0......0..0..............',
859    ],
860  },
861  'failed-process-error': {
862    palette: [0xd97757, 0x1620d8, 0x5ccc71, 0xcc5eb0, 0xffffff, 0x0c9379, 0x2f2f38],
863    rows: [
864      '...22222....................',
865      '..2111112...................',
866      '.211111112..................',
867      '21131313112..00000000000000.',
868      '21113331112..00000000000000.',
869      '21313331312..00000000000000.',
870      '21113331112..00060000060000.',
871      '.213131312...00000000000000.',
872      '..21111125...00000444000000.',
873      '...22222..500000040004000000',
874      '..........050000000000000000',
875      '..........005000000000000000',
876      '.............0..0......0..0.',
877      '..............0..0......0..0',
878    ],
879  },
880  'system-error-alert': {
881    palette: [0xd97757, 0xffc0ff, 0xe842e0, 0xfc277e, 0xffffff, 0xec8fef, 0xe5d942, 0x2f2f38],
882    rows: [
883      '.22222222222222...............',
884      '2262626222222222..............',
885      '2222222222222222..............',
886      '1111131111111111..............',
887      '1111333111111111..............',
888      '1113343311111111..............',
889      '1113343311113111..............',
890      '11333333311131110000000000000.',
891      '11333433311111110000000000000.',
892      '13333333331131110700070000000.',
893      '11111111111111110000000000000.',
894      '11155555111111110044400000000.',
895      '11111111111111110000000000000.',
896      '11111111111110000000000000000.',
897      '.............00000000000000000',
898      '.............00000000000000000',
899      '................0..0......0..0',
900      '................0..0......0..0',
901    ],
902  },
903  'marking-critical-error': {
904    palette: [0xd97757, 0xad2aad, 0xff7b8e, 0x2f2f38, 0xffffff],
905    rows: [
906      '.......111111.......',
907      '...2..11111111..2...',
908      '.2...1121111211...2.',
909      '.....1112112111.....',
910      '..2..1111221111..2..',
911      '.....1112112111.....',
912      '.....1121111211.....',
913      '......11111111......',
914      '.......111111.......',
915      '...00000000000000...',
916      '...00303000030300...',
917      '...00030000003000...',
918      '...00303044030300...',
919      '...00000400400000...',
920      '...00000000000000...',
921      '00000000000000000000',
922      '00000000000000000000',
923      '00000000000000000000',
924      '...0..0......0..0...',
925      '..0....0....0....0..',
926    ],
927  },
928  'crying-sad-reaction': {
929    palette: [0xd97757, 0x3eadff, 0x83d0ff, 0xffffff, 0x2f2f38],
930    rows: [
931      '.....2..........2.....',
932      '..120020000000020021..',
933      '.1..20010000001002..1.',
934      '1...00000000000000...1',
935      '....01040000004010....',
936      '..001110003300011100..',
937      '..001110003300011100..',
938      '..012111000000111210..',
939      '..021211000000112120..',
940      '..112111100001111211..',
941      '...11111.0..0.11111...',
942      '....111........111....',
943    ],
944  },
945  'angry-at-laptop': {
946    palette: [0xd97757, 0xffc431, 0x5b30d1, 0xff6833, 0xaf32ce, 0x2f2f38, 0xffffff],
947    rows: [
948      '................1........',
949      '.................1..3....',
950      '................11.313...',
951      '.........1.....111..3....',
952      '........131..1.11........',
953      '.........1..1..131.......',
954      '............111311.1..1..',
955      '000.........1133311..1...',
956      '000..........11311..111.1',
957      '00000000000000111...1131.',
958      '...00000000000000....111.',
959      '...00000000000000........',
960      '...00000000000000..222222',
961      '...00005000005000.222422.',
962      '...00000500050000.224222.',
963      '...00000006000000222222..',
964      '...000000000000444444....',
965      '...00000000000000000.....',
966      '...0..0......0..0........',
967      '....0..0......0..0.......',
968    ],
969  },
970  'broken-heart-sad': {
971    palette: [0xd97757, 0xe03890, 0x2f2f38, 0x993add, 0x50a8ff, 0xffffff],
972    rows: [
973      '..13.11.............',
974      '.113.111............',
975      '.1113.11............',
976      '..13.11.............',
977      '...3.1..............',
978      '...00000000000000...',
979      '...00000000000000...',
980      '...00000000000000...',
981      '...00202000020200...',
982      '...04020000002040...',
983      '...40000055000004...',
984      '00000000500500000000',
985      '00000000000000000000',
986      '00000000000000000000',
987      '...0..0......0..0...',
988      '..0..0......0..0....',
989    ],
990  },
991  'disconnected-plug-pixel': {
992    palette: [0xd97757, 0x6e31f4, 0x3381f2, 0x2f2f38, 0xffffff, 0xffd939],
993    rows: [
994      '......2225.1112.....',
995      '.....1222..11121....',
996      '....1.2225.1212.1...',
997      '....1.2225.1212.1...',
998      '....1..22..111...1..',
999      '...1..............1.',
1000      '...1..............1.',
1001      '000..............000',
1002      '000..............000',
1003      '1..00000000000000..1',
1004      '1..00000000000000..1',
1005      '1..00000000000000..1',
1006      '.1.00030000003000.1.',
1007      '...00030000003000...',
1008      '...00000044000000...',
1009      '...00000400400000...',
1010      '...00000000000000...',
1011      '...00000000000000...',
1012      '...00000000000000...',
1013      '..0...0......0...0..',
1014      '..0...0......0...0..',
1015    ],
1016  },
1017  'artboard-25cla': {
1018    palette: [0xd97757, 0x2f2f38, 0xffffff],
1019    rows: [
1020      '.......00000000000000.......',
1021      '.......00000000000000.......',
1022      '.......00000000000000.......',
1023      '.......00000000000000.......',
1024      '.......00010000001000.......',
1025      '.......00000200200000.......',
1026      '....00000000022000000000....',
1027      '....00000000000000000000....',
1028      '....00000000000000000000....',
1029      '.......0..0......0..0.......',
1030      '.00000.0..0......0..0.00000.',
1031      '.01010................01010.',
1032      '0000000..00000..0000.0000000',
1033      '0.0.0.0..01010..0101.0.0.0.0',
1034      '........0000000.0000........',
1035      '........0.0.0.0.0..0........',
1036    ],
1037  },
1038  'multi-window-workflow': {
1039    palette: [0xd97757, 0xc5e2dd, 0xe3c0ff, 0xffd89f, 0xc15bb9, 0x51e5c8, 0x4735ef, 0xf4a321, 0xffffff, 0x9d33e2, 0x2f2f38],
1040    rows: [
1041      '...................44444444.',
1042      '............999...2222222222',
1043      '...........22222..2222222222',
1044      '...........22222..2222222222',
1045      '.5555555...22222..2222222222',
1046      '111111111.........2222222222',
1047      '111111111...................',
1048      '111111111000000000000.......',
1049      '111111111000000000000.......',
1050      '111111111000000000000.......',
1051      '111111111000000000000666666.',
1052      '.......000a000000a0011111111',
1053      '.......000008008000011111111',
1054      '....000000000880000011111111',
1055      '....000000000000000011111111',
1056      '....77777700000000000000....',
1057      '...33333333......0..0.......',
1058      '...33333333......0..0.......',
1059      '...33333333.................',
1060      '...33333333.................',
1061    ],
1062  },
1063  'developer-pair-programming-scene': {
1064    palette: [0xd97757, 0x27d834, 0x7af4d4, 0x9736a8, 0x3b35aa, 0xefa262, 0xffffff],
1065    rows: [
1066      '...................44.....',
1067      '....................4544..',
1068      '.........1..11.......555..',
1069      '........1...11..1....55...',
1070      '........1...1...1....55...',
1071      '........1..1....1....111..',
1072      '.........1.1........11111.',
1073      '...................1111111',
1074      '...0000000000000...1111111',
1075      '...00000000000000..5111111',
1076      '...00000000000000..5511115',
1077      '...00000000000000..5511115',
1078      '...00000000000222222222225',
1079      '...00000000000022222222233',
1080      '00000000060000022222222233',
1081      '0000000000000000222222233.',
1082      '000000000000000004444443..',
1083      '..00.00.....00.00.333.33..',
1084      '...0..0......0..0.333.33..',
1085      '..................333.33..',
1086      '..................333.33..',
1087      '..................444444..',
1088    ],
1089  },
1090} as const
1091
1092// --- shell and codex mascots: drawn by Zehui/Claude 2026-10-19, not from the pack
1093
1094/** The shell mascots' colours, as 0xRRGGBB, by the letter their rows use. */
1095export const SHELL_PALETTE: Readonly<Record<string, number>> = {
1096  g: 0x8a8f98,
1097  G: 0x5b606a,
1098  k: 0x1a1c22,
1099  w: 0xf5f5f4,
1100  n: 0x19c3a6,
1101  o: 0xd97757,
1102  y: 0xffe05c,
1103  b: 0x3d9bff,
1104  p: 0xd16bff,
1105  // the codex fuzzies' colours (drawn by us after Codex's own mascots, not copied)
1106  r: 0xff7ab6,
1107  R: 0xd94f8e,
1108  B: 0x1f5fd6,
1109  Y: 0xd99c0a,
1110  l: 0x7ad12a,
1111  L: 0x55a318,
1112  v: 0xb04cff,
1113  V: 0x862fd1,
1114}
1115
1116/** Each shell mascot's pixel rows, top to bottom: '.' is empty, a letter a SHELL_PALETTE key. */
1117export const SHELL_POSES = {
1118  // A. grey bot with a terminal face (">_" in mint): background shells
1119  'shell-terminal-face': [
1120    '.......GGGGGG.......',
1121    '.......G....G.......',
1122    '...GGGGGGGGGGGGGG...',
1123    '...GggggggggggggG...',
1124    '...GgkkkkkkkkkkgG...',
1125    '...Gknn.kkkkkkkkgG..',
1126    '...GkknnkkkkkkkkgG..',
1127    '...GkknnkknnnnkkgG..',
1128    '...GknnkkkkkkkkkgG..',
1129    '...GgkkkkkkkkkkgG...',
1130    '...GggggggggggggG...',
1131    'GGGGggggggggggggGGGG',
1132    'GGGGggggggggggggGGGG',
1133    '...Gg.gg....gg.gG...',
1134    '...Gg.gg....gg.gG...',
1135  ],
1136  // B. grey bot with a white hexagon badge on the chest: codex
1137  'codex-hex-badge': [
1138    '.......GGGGGG.......',
1139    '.......G....G.......',
1140    '...GGGGGGGGGGGGGG...',
1141    '...GggggggggggggG...',
1142    '...GgkkkkkkkkkkgG...',
1143    '...GgkwwkkkkwwkgG...',
1144    '...GgkwwkkkkwwkgG...',
1145    '...GgkkkkkkkkkkgG...',
1146    '...GggggggggggggG...',
1147    '...Ggggg.ww.ggggG...',
1148    '...Gggg.w..w.gggG...',
1149    'GGGGggg.w..w.gggGGGG',
1150    'GGGGgggg.ww.ggggGGGG',
1151    '...Gg.gg....gg.gG...',
1152    '...Gg.gg....gg.gG...',
1153  ],
1154  // C. a terminal window with a cursor, no character: background shells
1155  'shell-window': [
1156    'GGGGGGGGGGGGGGGGGGGG',
1157    'GoGyGnGGGGGGGGGGGGGG',
1158    'GGGGGGGGGGGGGGGGGGGG',
1159    'Gkkkkkkkkkkkkkkkkk.G',
1160    'Gknkkkkkkkkkkkkkkk.G',
1161    'Gkknkkkkkkkkkkkkkk.G',
1162    'Gknkkkkkkkkkkkkkkk.G',
1163    'Gkkkknnnnnnkkkkkkk.G',
1164    'Gkkkkkkkkkkkkkkkkk.G',
1165    'Gkkkkkkkkkkkkkkkkk.G',
1166    'Gkkkkkkkkkkkkkkkkk.G',
1167    'Gkkkkkkkkkkkkkkkkk.G',
1168    'GGGGGGGGGGGGGGGGGGGG',
1169  ],
1170  // D. grey bot wearing a small blue cap with a "C": codex
1171  'codex-blue-cap': [
1172    '......bbbbbbb.......',
1173    '.....bbwwbbbbb......',
1174    '.....bbwbbbbbb......',
1175    '.....bbwwbbbbbbb....',
1176    '...GGGGGGGGGGGGGG...',
1177    '...GggggggggggggG...',
1178    '...GgkkkkkkkkkkgG...',
1179    '...GgkwwkkkkwwkgG...',
1180    '...GgkkkkkkkkkkgG...',
1181    '...GggggggggggggG...',
1182    '...GggggggggggggG...',
1183    'GGGGggggggggggggGGGG',
1184    'GGGGggggggggggggGGGG',
1185    '...Gg.gg....gg.gG...',
1186    '...Gg.gg....gg.gG...',
1187  ],
1188  // Codex fuzzies, five shapes: a codex shell picks one by a hash of its id
1189  'fuzzy-pink-headphones': [
1190    '......rrrrrrrr......',
1191    '....rrrrrrrrrrrr....',
1192    '...rrrrrrrrrrrrrr...',
1193    '..kkrrrrrrrrrrrkk...',
1194    '.kkkrrrrrrrrrrrrkkk.',
1195    '.kkkrrkkrrrrkkrrkkk.',
1196    '.kkkrrrkkrrrrkkrkkk.',
1197    '.kkkrrrrrrrrrrrkkkk.',
1198    '..kkrrrrrrrrrrrkk...',
1199    '...rrrrrrrrrrrrrr...',
1200    '....rrrrrrrrrrrr....',
hooks/peerview.tsx 144 lines
1// The agents pane's Peers view (`p`, `/agents-info peers`): the head and its
2// framed keys, the hub's row first, then this session's HERDR-ORCH requests,
3// each with its spokes beneath, then the other peers of the herdr workspace.
4// A row is the state's glyph and word with the time in it, the pane id, the
5// agent and the title; blocked rows come first and are amber. Box, Text and
6// Button only; the plain surfaces get the same rows as text.
7import { atom, read } from 'claude-code'
8import type { Elements, EngineInterface, RenderInput } from 'claude-code'
9
10import type { PeerBoard, PeerRow } from '../types'
11import { closePane, setView } from './kit'
12import { NEEDS_HERDR, STATE_COLOR, layoutOf, peersHead, pollPeers, requestText, stateText, titleText } from './peers'
13import type { PeerLayout } from './peers'
14
15const peerBoard = atom({ plugin: 'agents', key: 'peerBoard' } as const, null)
16const peerRequests = atom({ plugin: 'agents', key: 'peerRequests' } as const, {})
17const peerHub = atom({ plugin: 'agents', key: 'peerHub' } as const, '')
18
19const AMBER = STATE_COLOR.blocked
20
21/** One row as plain words: `▲ blocked 4m  w1:p7  codex  "title"`. */
22const rowWords = (r: PeerRow, at: number): string => [stateText(r, at), r.pane, r.agent, titleText(r.title)].filter(Boolean).join('  ')
23
24export async function drawPeers($: EngineInterface, e: RenderInput<'Pane'>) {
25  const board = await read($, peerBoard)
26  const layout = board?.isHerdr ? layoutOf(board, await read($, peerRequests), await read($, peerHub)) : null
27  if (e.surface === 'terminal') return peersTerminal($, $.ui.resolve(e), board, layout)
28  return peersPlain($.ui.resolve(e), board, layout)
29}
30
31/** The two elements every surface's table has, the plain view's whole need. */
32type Shared = { Box: Elements['mobile']['Box']; Text: Elements['mobile']['Text'] }
33
34function peersPlain(ui: Shared, board: PeerBoard | null, layout: PeerLayout | null) {
35  const { Box, Text } = ui
36  const at = board?.at ?? 0
37  return (
38    <Box flexDirection="column">
39      <Text bold>{peersHead(board)}</Text>
40      {board && !board.isHerdr ? <Text dimColor>{NEEDS_HERDR}</Text> : null}
41      {layout?.hub ? <Text key="plain-hub">{`${layout.isSelfHub ? 'hub · this pane' : 'hub'}  ${rowWords(layout.hub, at)}`}</Text> : null}
42      {layout?.groups.map(g => (
43        <Box key={`plain-req-${g.request.req}`} flexDirection="column">
44          <Text dimColor>{requestText(g.request, at)}</Text>
45          {g.rows.map(r => (
46            <Text key={`plain-${r.pane}`}>{'  ' + rowWords(r, at)}</Text>
47          ))}
48        </Box>
49      ))}
50      {layout?.others.map(r => (
51        <Text key={`plain-${r.pane}`}>{rowWords(r, at)}</Text>
52      ))}
53    </Box>
54  )
55}
56
57function peersTerminal($: EngineInterface, ui: Elements['terminal'], board: PeerBoard | null, layout: PeerLayout | null) {
58  const { Box, Text, Button } = ui
59  // The view moves on its reads: times and countdowns are as of the last one.
60  const at = board?.at ?? 0
61
62  // One peer's row; `lead` is the words before it (`hub`, or an indent under a request).
63  const row = (r: PeerRow, lead: string, key: string) => {
64    const isBlocked = r.status === 'blocked'
65    const amber = isBlocked ? { color: AMBER } : {}
66    return (
67      <Box key={key} flexDirection="row" gap={1}>
68        {lead ? (
69          <Box key={`lead-${r.pane}`} flexShrink={0}>
70            <Text bold={lead.trim() !== ''}>{lead}</Text>
71          </Box>
72        ) : null}
73        <Box key={`state-${r.pane}`} flexShrink={0}>
74          <Text color={STATE_COLOR[r.status]} bold={isBlocked}>
75            {stateText(r, at)}
76          </Text>
77        </Box>
78        <Box key={`pane-${r.pane}`} flexShrink={0}>
79          <Text {...amber}>{r.pane}</Text>
80        </Box>
81        <Box key={`agent-${r.pane}`} flexShrink={0}>
82          <Text dimColor={!isBlocked} {...amber}>
83            {r.agent}
84          </Text>
85        </Box>
86        {r.title ? (
87          <Box key={`title-${r.pane}`} flexGrow={1} flexShrink={1}>
88            <Text wrap="truncate-end" {...amber}>
89              {titleText(r.title)}
90            </Text>
91          </Box>
92        ) : null}
93      </Box>
94    )
95  }
96
97  // The view's keys: framed buttons, each with its /agents-info twin.
98  const keys = (
99    <Box key="peers-keys" flexDirection="row" flexWrap="wrap" gap={1}>
100      <Button key="view-who" label="w: who" hotkey="w" onPress={() => void setView($, 'who')} />
101      <Button key="view-code" label="c: code" hotkey="c" onPress={() => void setView($, 'code')} />
102      <Button key="peers-refresh" label="r: refresh" hotkey="r" onPress={() => void pollPeers($).catch(() => {})} />
103      <Button key="pane-close" label="q: close" hotkey="q" onPress={() => void closePane($)} />
104    </Box>
105  )
106
107  const isEmpty = layout !== null && !layout.hub && layout.groups.length === 0 && layout.others.length === 0
108  return (
109    <Box flexDirection="column">
110      <Box key="peers-head">
111        <Text bold wrap="truncate-end">
112          {peersHead(board)}
113        </Text>
114      </Box>
115      {keys}
116      <Box flexDirection="column" marginTop={1}>
117        {board && !board.isHerdr ? (
118          <Text key="peers-needs-herdr" dimColor>
119            {NEEDS_HERDR}
120          </Text>
121        ) : null}
122        {isEmpty ? <Text dimColor>{`No other agents in herdr workspace ${board?.workspace ?? ''}.`}</Text> : null}
123        {layout?.hub ? row(layout.hub, layout.isSelfHub ? 'hub · this pane' : 'hub', 'peer-hub') : null}
124        {layout?.groups.map(g => (
125          <Box key={`req-${g.request.req}`} flexDirection="column">
126            <Box key={`req-head-${g.request.req}`}>
127              <Text dimColor wrap="truncate-end">
128                {requestText(g.request, at)}
129              </Text>
130            </Box>
131            {g.rows.map(r => row(r, ' ', `peer-${r.pane}`))}
132          </Box>
133        ))}
134        {layout && layout.others.length > 0 && (layout.hub || layout.groups.length > 0) ? (
135          <Text key="peers-others" dimColor>
136            other peers
137          </Text>
138        ) : null}
139        {layout?.others.map(r => row(r, '', `peer-${r.pane}`))}
140      </Box>
141    </Box>
142  )
143}
144
types/index.d.ts 224 lines
1export type AgentStatus = 'running' | 'finished' | 'error'
2
3/**
4 * A subagent's run, a background shell's (a Bash call moved to the background),
5 * a Monitor watcher's (`watch`) or a CronCreate timer's (`cron`).
6 */
7export type RunKind = 'agent' | 'shell' | 'watch' | 'cron'
8
9/** One run, a subagent's or a background shell's, as register.tsx records it. */
10export type AgentRun = {
11  id: string
12  /** Absent on runs recorded before shells were: an agent. */
13  kind?: RunKind
14  agentId?: string
15  /**
16   * The agent type (`Explore`, `general-purpose`, a plugin's agent); for a
17   * shell, `sh` or the coding agent its command runs (`codex`, `opencode`, `claude`).
18   */
19  type: string
20  /** A shell's command as the Bash call gave it, clipped to 2,000 characters. */
21  command?: string
22  /** A shell's background task id, as the Bash result and its notification name it. */
23  taskId?: string
24  /** The coding agent a shell's command runs (`codex`); absent for a plain shell. */
25  label?: string
26  /** The file the shell's output is written to, from the Bash result. */
27  outputFile?: string
28  /** The subagent whose Bash call started the shell; absent for main. */
29  parentAgentId?: string
30  /** True when the shell ends with its subagent's final answer (no notification follows). */
31  endsWithParent?: boolean
32  /** A failed shell's exit code, when its notification says. */
33  exitCode?: number
34  /** How a shell that did not complete ended: `failed` or `killed`. */
35  endWord?: 'failed' | 'killed'
36  /** The Agent call's `name`, when it gave one. */
37  name?: string
38  /** The Agent call's short description of the task. */
39  description: string
40  /** The model id the run answers on. */
41  model: string
42  effort?: string
43  status: AgentStatus
44  startedAt: number
45  endedAt?: number
46  /** The context of the run's last request: input, cache read and write, output. */
47  contextTokens: number
48  contextMax: number
49  /**
50   * A watch's target (the pane, commit or run id its command names), or a
51   * cron's job id; absent when the command names none.
52   */
53  target?: string
54  /** When a watch times out, or a cron fires next; absent for a persistent watch or an unknown fire time. */
55  nextAt?: number
56  /** A cron's schedule as the engine words it (`11 18 26 9 *`). */
57  schedule?: string
58  /** A cron's five-field expression, for its next fire time once it fired. */
59  cron?: string
60  /** True for a recurring cron; a one-shot one ends when it fires. */
61  isRecurring?: boolean
62  /** A watch's last event line, from its event notifications. */
63  lastEvent?: string
64  /** When a notification for a target already done came in: the entry greys for 10 s instead of toasting again. */
65  staleAt?: number
66  /** Every token the run's requests counted. */
67  tokens: number
68  costUsd: number
69  /** True once a request came from a model the price table does not know. */
70  isUnpriced?: boolean
71  /** Model requests seen. */
72  steps: number
73  /** When the request in flight started. */
74  stepStartedAt?: number
75  /** The summed length of the finished step cycles (request to next request). */
76  stepMsTotal?: number
77  /** How many step cycles that sum covers. */
78  stepCycles?: number
79  /** When the last request ended asking for tools; cleared when the next starts. */
80  awaitingSince?: number
81}
82
83/** A file's state in the code map: read only, changed by an edit, or its last edit failed. */
84export type CodeState = 'read' | 'changed' | 'failed'
85
86/** One file as one main-loop turn touched it, for the code map (the agents pane's Code view). */
87export type CodeTouch = {
88  /** The path as the tool named it, a relative one resolved against the session's directory. */
89  path: string
90  /** The main-loop turn (the `turn` counter) it was touched in. */
91  turn: number
92  /** The first edit in this turn: the edit order the map lays nodes in; absent while only read. */
93  editedAt?: number
94  /** The last edit, successful or not: `editing` for 30 s after it. */
95  lastEditAt?: number
96  /** The last touch of any kind. */
97  lastAt: number
98  state: CodeState
99  /** Lines added and removed, as `git diff --numstat` reports them (or a new file's lines). */
100  plus?: number
101  minus?: number
102  /** The tools used on it, in first-use order (`Read`, `Edit`, `Bash`). */
103  tools: string[]
104  /** One line from the edit itself: the Edit's first changed line or the Write's first line. */
105  summary?: string
106  /** Who touched it: `main`, or a subagent's agentId. */
107  by: string[]
108}
109
110/** The agents pane's view: the run cards (Who), the code map (Code) or the herdr peers (Peers). */
111export type PaneView = 'who' | 'code' | 'peers'
112
113/** A herdr agent's state as `herdr agent list` reports it (`agent_status`). */
114export type PeerStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'
115
116/** One agent of this herdr workspace as the Peers view's last read saw it. */
117export type PeerRow = {
118  /** The herdr pane id (`w1:p6`). */
119  pane: string
120  /** The agent herdr names (`claude`, `codex`). */
121  agent: string
122  status: PeerStatus
123  /** The pane's terminal title without its spinner; '' when none. */
124  title: string
125  /**
126   * When a read first saw the pane in this state (`$.clock.now()` ms), carried
127   * from the read before when that one is recent; the read's own time otherwise.
128   */
129  since: number
130}
131
132/** The Peers view's last read of herdr. */
133export type PeerBoard = {
134  /** When herdr was read, `$.clock.now()` milliseconds. */
135  at: number
136  /** The herdr workspace read; '' outside herdr. */
137  workspace: string
138  /** False outside a herdr pane: the view says it needs one, and nothing is read. */
139  isHerdr: boolean
140  /** This pane's own row (the hub row when this session sent requests); null when herdr does not list it. */
141  self: PeerRow | null
142  /** The other agents of the workspace, in herdr's order. */
143  rows: PeerRow[]
144  /** Why the read failed, set only while no read has succeeded yet (the rows are then empty). */
145  error?: string
146}
147
148/**
149 * A HERDR-ORCH request this session sent or took part in, from the envelope
150 * headers (`[HERDR-ORCH v0] req=<id> from=<agent>@<pane> type=<type>`) in its prompts.
151 */
152export type PeerRequest = {
153  /** The request id (`req=`), also the folder under `~/.local/state/herdr-orch/`. */
154  req: string
155  /** The spoke panes the envelopes named, in the order first named. */
156  panes: string[]
157  /** The type of the last envelope seen. */
158  type: string
159  /** The round, from `round 4/5`, `round 4 of 5`, `round 4` or a `-r4` req suffix; null when none. */
160  round: number | null
161  /** The round ceiling, from `round 4/5`, `ceiling 5`, `max rounds 5`; null when none. */
162  ceiling: number | null
163  /** When the first envelope of the request was submitted here, `$.clock.now()` milliseconds. */
164  sentAt: number
165  /** When the deadline passes; null when unknown. */
166  deadlineAt: number | null
167  /** The result file as last read: null when missing or not read, '' without a valid status, else its status. */
168  result: string | null
169}
170
171/** The code map's scope: this main-loop turn, or the whole session with turn tags. */
172export type CodeScope = 'turn' | 'session'
173
174/** The main loop: its model and whether a turn of it runs. */
175export type MainLoop = {
176  model: string
177  isRunning: boolean
178  startedAt: number
179  /**
180   * The session's id (`$.session.id()`), else the first main-loop turn's id:
181   * the hub card's pose is picked by it, so it keeps one pose for the session.
182   */
183  id?: string
184}
185
186declare module 'claude-code' {
187  interface PluginState {
188    agents: {
189      runs: AgentRun[]
190      /** Skills fired since the band last hid: name to count. */
191      skills: Record<string, number>
192      main: MainLoop
193      /** The tick's last time. */
194      now: number
195      /** When the person last dismissed the band. */
196      dismissedAt: number
197      /** The pane's one-line-per-agent view. */
198      isCompact: boolean
199      /** Claude Code's prefersReducedMotion setting, as last read. */
200      isStill: boolean
201      /** Each shell's last output line, by run id, read while the pane is open. */
202      tails: Record<string, string>
203      /** True while the caffeinate hold runs (the band's ☕). */
204      isCaffeinated: boolean
205      /** The pane's view: `who` (the cards) or `code` (the code map). */
206      view: PaneView
207      /** The files touched, one record per file and main-loop turn. */
208      code: CodeTouch[]
209      /** The main-loop turn counter: one more at each main turn start. */
210      turn: number
211      /** The code map's scope. */
212      codeScope: CodeScope
213      /** True after `d` in the Code view: the next digit runs /diff on that node's file. */
214      isDiffArmed: boolean
215      /** The Peers view's last read of herdr; null until the view first reads. */
216      peerBoard: PeerBoard | null
217      /** The HERDR-ORCH requests seen in this session's prompts, by request id (the newest 40). */
218      peerRequests: Record<string, PeerRequest>
219      /** The pane whose brief made this session a spoke (its hub); '' when none. */
220      peerHub: string
221    }
222  }
223}
224