A powerline-style band under the prompt (usage limits and clock, repo, git, model, context, wipnote work items and tracks, subagents by work item with stalls…

A Claude Code mod (function hooks) that turns the line under the prompt into an orchestration band: where you are, what is being worked on, how the subagents are getting on with it, and the project's wipnote delegate runs. It also draws wipnote-ref fences in replies as work-item cards. It replaces the former wipnote-cards mod.
⏵⏵ auto mode on · (shift+tab to cycle) · esc to interrupt · ← for agents ← Claude Code's own hint line, unchanged
5h 42% resets 9:40 PM 7d 83% resets Fri 3:00 PM Tue 20 · 7:40 PM ← usage limits, then the clock
ionna-rca main !3 ⇡1 ✻ Opus 5.5 ctx 33% Raw-data RCA agents [1/46] → Routine setup script ◉ 3 agents · ✔ 12 ✘ 1
⚠ Explore no tool call for 6m12s Survey workbench types ↳ $ npm test ← needs a look
⇄ w1 codex · interactive ● live Working on it. ← delegate runs
⇄ Fix the tests ccx · oneshot ✘ ended exit 2 ccx: failed in 3s
▸ Routine setup script 2/5 steps ● 2 running ✔ 1 done ← agents by work item
| Row | Content | Source |
|---|---|---|
| Hint | Claude Code's hint line exactly as it draws it (the mode pill stays beside it) | PromptHint props |
| Limits | each rate-limit window (5h, 7d, spend): % used, green/amber/red at 50/80, local reset time; then the clock: weekday, day of the month, local time (without a limits row, in compact or before limits are known, the clock is the last segment) | $.session.usage().rateLimits, timeZone |
| Segments | repo/worktree name · git branch, dirty count, ahead/behind · model · context % · per track: title [done/total] · per work item: → title n/m (steps), coloured by status · live agent count and the session's finished tally (✔ done ✘ failed) | git, $.session.*, wipnote |
| Needs a look | a running agent quiet for 5 min: on one call that long (◷ one call running for …) or with no tool call at all (⚠ no tool call for …), with its last call; and a failed or stopped agent for 10 min (✘) | tool.call, $.agent.list() |
| Delegate runs | one row per wipnote delegate run, one-shot or interactive (never one per pane or split): its name (interactive) or label, harness and mode, ● live or ended (✔ ended, ✘ ended exit 2, ■ ended (stop)), and the first line of its last.md. Live runs first, then runs that ended in the last hour, newest first | .wipnote/delegate/runs/, .wipnote/delegations-ledger.html |
| Work items | one row per work item the agents' briefs name: title, steps, and how many of its agents are running, waiting, done, failed (finished ones counted 90 s) | agent.spawn, $.agent.list(), wipnote |
No row per agent. Claude Code's own agent panel under the prompt already lists every subagent with its time, tokens and current activity, and opens one with Enter (or from /tasks). The band only adds what that panel does not show: agents grouped by work item, a stall warning, failures that stay past the panel's 30 s, and a session tally.
Segments are powerline-style with rounded caps and wrap between segments to the pane width (re-laid out on every resize); a segment wider than the pane is cut with …. Every line is one truncated Text, so nothing wraps mid-row.
wipnote who --json comes first. It is read at session start, after /resume, /branch or /clear, and after each turn (a swap is seen by classic.SessionStart, and also by a changed session id after session.end and on the 1 s tick, since a managed plugin may continue classic.* events past this one; each swap rehydrates once); when who fails, the last claim stays. Then come the items started by shell commands any loop runs: wipnote <feature|bug|spike|track> start <id> adds, … complete <id> removes. A subagent's work item is the first feat-/bug-/spk-/trk- id in its brief.wipnote <kind> show <id> --format json per item (title, status, steps, part_of track) and wipnote track show <trk> per track (title and (n/m done), which only the text form carries). Up to 12 items..wipnote/delegate/runs/ and reads each open run's folder (label, start, end, exit, last.md; see internal/delegate/runstore), so a run folder Go writes shows within 2 s. A run has ended once its folder has an exit file or its ledger row is closed (a stopped worker has no exit code). Mode, harness and name come from the run's row in .wipnote/delegations-ledger.html, read again only when that file changes; a folder with no row shows its label alone. The project is found as wipnote's CLI finds it: WIPNOTE_PROJECT_DIR, else the main checkout of a git worktree, else the nearest folder with a .wipnote/ up from the session's directory. Files only, so it works outside herdr.wipnote-ref)While the mod is loaded, a one-line rule (prompt.md, which wipnote claude appends to the system prompt when it loads the mods) asks the model to end a report on a work item with a fenced block whose info string is wipnote-ref and whose only line is the id. The mod draws that block as a card: title, coloured status, step bar. The saved message keeps only the short block (about 10 tokens), and without the mod it still reads as an id.
wipnote <kind> add-step|complete-step|start|complete <id> on a card already drawn (so the bar moves mid-turn), after each turn (open or failed cards, stalest first, up to 10), and on start or resume for fences already in the transcript. A card is fetched again after 60 s.not in this checkout (pull to update), wipnote unavailable or details unavailable.The former wipnote-cards status line (wipnote · <id> · <status> · n/m steps) is gone: the band shows the same fields.
/orchestra full | compact | off | above | below — full band (with the needs-a-look and work-item rows), one segment line, hidden; draw under the prompt (default) or in the band above it.
.claude-plugin/plugin.json manifest (name, types contract, timeZone option)
hooks/hooks.json { "modules": ["./register.tsx"] }
hooks/register.tsx hooks: session.start timer, session claim, agent.spawn, tool.call, session.end and classic.* (permission mode, resume), ui.render
hooks/swap.ts conversation swaps (/clear, /resume, /branch): one tracker so each swap rehydrates once
hooks/refcards.tsx wipnote-ref cards: fetch on reply and turn, the model rule, the AssistantMessage drawing
hooks/refs.ts pure helpers for cards: fence parsing, show argv, card parsing, TTL, refresh order
hooks/band.tsx the band's tree (pure: data + room → elements)
hooks/model.ts pure helpers: parsing (git, wipnote, hint), agents (by work item, needs a look, tally), run rows, layout (wrap, fit), formatting (time, limits)
hooks/runs.ts delegate runs, read-only: run folders, the delegations ledger, the project lookup, which runs show
types/index.d.ts $.state contract (PluginState['wipnote-orchestra'])
tests/*.test.ts(x) claude plugin test suites (pure helpers + mounted band on terminal and desktop)
tests/memfs.ts an in-memory $.fs for the run tests, logging every fs op
tests/fixtures/runs.ts run folders and ledger rows written by the Go run store; generated, do not edit
wipnote claude loads it (with every mod in mods/) by default; WIPNOTE_NO_MODS=1 skips the mods. On its own:
claude --plugin-dir mods/wipnote-orchestra
Mods draw only where Claude Code itself draws: a local terminal or a local desktop session. A cloud session viewed in the Claude app has no drawing surface, so the band does not show there.
claude plugin validate mods/wipnote-orchestra
claude plugin test mods/wipnote-orchestra # 53 tests
tsc -p mods/wipnote-orchestra # after a load lays .claude-plugin/types/
tests/fixtures/runs.ts is written by the Go run store, ledger and renderers, and go test ./internal/delegate/render -run TestOrchestraFixture fails when it drifts from what they write. To regenerate it after a run-folder or ledger change:
WIPNOTE_UPDATE_FIXTURES=1 go test ./internal/delegate/render -run TestOrchestraFixture
Built on Claude Code 2.1.292 and checked on 2.1.310. The function-hooks API is early access and changes between releases. .claude-plugin/types/ is generated on each load and is not committed.
It is not yet part of the generated plugin tree; shipping mods needs build-ports support. The mod stays at mods/wipnote-orchestra, and it is tracked and shipped as part of the wipnote delegate feature: its run rows replace the codex-herdr-pane mod's Claude Code run pane.
| Option | Default | Effect |
|---|---|---|
timeZone | America/New_York | IANA zone for the clock segment and the usage-limit reset times. An unknown zone falls back to the default. |
Set it in /config, or in settings under pluginConfigs["wipnote-orchestra"].options (wipnote-orchestra@inline for a --plugin-dir load).
permission_mode (classic hook events) and a main-loop agent.spawn's permissionMode. With no mode learned (a plugin above continued every classic event), a (shift+tab …) hint, which only shows beside a pill, takes the auto pill's width, and any other hint none. It is learned on the next prompt or tool call, so after shift+tab between two non-default modes the indent can lag by a cell or two.\u escapes in code, never literal: a byte-level edit tool once double-encoded the file and the engine refused the tree.TERM=xterm, no COLORTERM); set COLORTERM=truecolor.wipnote-orchestra: the atom({ plugin: 'wipnote-orchestra', … }) refs in hooks/register.tsx and the PluginState key in types/index.d.ts change together with the manifest's name. The slash command stays /orchestra.hooks/register.tsx 450 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Mode, PromptInfo, Tally, WorkCard } from '../types'
5import type { BandData } from './band'
6import { drawBand } from './band'
7import { hydrate, refreshAfterTurn, refreshChanged, registerRefs, storeCard, wipnoteJSON } from './refcards'
8import { parseWho } from './refs'
9import { findProject, newRunCache, readRuns } from './runs'
10import type { SwapState } from './swap'
11import { swapClassic, swapSeen, swapStarted } from './swap'
12import {
13 STALL_MS,
14 keepFor,
15 applyWipnote,
16 baseName,
17 modelLabel,
18 describeCall,
19 isLive,
20 parseGitStatus,
21 parseItemJson,
22 parseTrackText,
23 showArgv,
24 pillIndent,
25 shortDir,
26 workItemIn,
27 zoneOf,
28} from './model'
29
30const prompt = atom({ plugin: 'wipnote-orchestra', key: 'prompt' } as const, null)
31const agents = atom({ plugin: 'wipnote-orchestra', key: 'agents' } as const, [])
32const items = atom({ plugin: 'wipnote-orchestra', key: 'items' } as const, [])
33const cards = atom({ plugin: 'wipnote-orchestra', key: 'cards' } as const, [])
34const mainTool = atom({ plugin: 'wipnote-orchestra', key: 'mainTool' } as const, null)
35const now = atom({ plugin: 'wipnote-orchestra', key: 'now' } as const, 0)
36const mode = atom({ plugin: 'wipnote-orchestra', key: 'mode' } as const, 'full')
37const placement = atom({ plugin: 'wipnote-orchestra', key: 'placement' } as const, 'below')
38const permissionMode = atom({ plugin: 'wipnote-orchestra', key: 'permissionMode' } as const, null)
39const claim = atom({ plugin: 'wipnote-orchestra', key: 'claim' } as const, null)
40const tally = atom({ plugin: 'wipnote-orchestra', key: 'tally' } as const, { done: 0, failed: 0 })
41const runs = atom({ plugin: 'wipnote-orchestra', key: 'runs' } as const, [])
42
43const KEEP_DONE_MS = 90_000
44
45type Hint = { workItem?: string; model?: string }
46
47async function refreshPrompt($: EngineInterface) {
48 const [cwd, home, usage, git, top, model] = await Promise.all([
49 $.session.cwd(),
50 $.env.get('HOME'),
51 $.session.usage(),
52 $.process
53 .run(['git', 'status', '--porcelain=v1', '--branch'], { timeoutMs: 5000 })
54 .catch(() => undefined),
55 $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).catch(() => undefined),
56 $.session.model().catch(() => undefined),
57 ])
58 const root = top?.exitCode === 0 ? top.stdout.trim() : ''
59 const info: PromptInfo = {
60 dir: root !== '' ? baseName(root) : shortDir(cwd, home),
61 dirty: 0,
62 ahead: 0,
63 behind: 0,
64 ...(git?.exitCode === 0 ? parseGitStatus(git.stdout) : {}),
65 model: model !== undefined ? modelLabel(model) : undefined,
66 ctxPercent: usage.context.percent,
67 limits: usage.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
68 }
69 await update($, prompt, () => info)
70}
71
72/**
73 * Syncs the rows with `$.agent.list()`. A row the list has shown and no longer shows ended (the
74 * engine dropped its task) and counts as done. A row only tool calls made (an agent the list never
75 * showed, such as a workflow's) is closed uncounted once it has been quiet for STALL_MS with no
76 * call running. Each agent is counted in the tally once.
77 */
78async function reconcileAgents($: EngineInterface, at: number, hints: ReadonlyMap<string, Hint>) {
79 const listed = await $.agent.list()
80 const ended: Tally = { done: 0, failed: 0 }
81 await update($, agents, rows => {
82 ended.done = 0
83 ended.failed = 0
84 const close = (row: AgentRow, status: string): AgentRow => {
85 if (row.counted === true) return { ...row, status, endedAt: row.endedAt ?? at }
86 if (status === 'failed' || status === 'killed') ended.failed += 1
87 else ended.done += 1
88 return { ...row, status, endedAt: at, counted: true }
89 }
90 const byId = new Map(rows.map(row => [row.id, row]))
91 for (const info of listed) {
92 const row = byId.get(info.id)
93 const wasLive = row === undefined || isLive(row.status)
94 const next: AgentRow = {
95 tools: 0,
96 startedAt: at,
97 ...row,
98 id: info.id,
99 type: info.name ?? info.type,
100 description: info.description,
101 workItem: row?.workItem ?? hints.get(info.description)?.workItem,
102 model: row?.model ?? hints.get(info.description)?.model,
103 status: info.status,
104 listed: true,
105 }
106 byId.set(info.id, wasLive && !isLive(info.status) ? close(next, info.status) : next)
107 }
108 const seen = new Set(listed.map(info => info.id))
109 for (const row of byId.values()) {
110 if (seen.has(row.id) || !isLive(row.status)) continue
111 if (row.listed === true) byId.set(row.id, close(row, 'completed'))
112 else if ((row.inFlight ?? 0) === 0 && at - (row.lastAt ?? row.startedAt) >= STALL_MS) {
113 byId.set(row.id, { ...row, status: 'completed', endedAt: at, counted: true })
114 }
115 }
116 return [...byId.values()].slice(-100)
117 })
118 if (ended.done + ended.failed > 0) {
119 await update($, tally, t => ({ done: t.done + ended.done, failed: t.failed + ended.failed }))
120 }
121}
122
123
124/** Titles, status and track progress for the work items in view: the session's own and the agents'. */
125async function refreshCards($: EngineInterface) {
126 const [work, rows] = await Promise.all([activeItems($), read($, agents)])
127 const ids = [...new Set([...work, ...rows.map(row => row.workItem).filter((id): id is string => id !== undefined)])]
128 const found: WorkCard[] = []
129 for (const id of ids.slice(0, 12)) {
130 const argv = showArgv(id)
131 if (argv === undefined) continue
132 const ran = await $.process.run(argv, { timeoutMs: 5000 }).catch(() => undefined)
133 const item = ran?.exitCode === 0 ? parseItemJson(ran.stdout) : undefined
134 if (item === undefined || ran === undefined) continue
135 found.push({ id, ...item })
136 await storeCard($, id, ran.stdout)
137 }
138 const tracks = new Map<string, ReturnType<typeof parseTrackText>>()
139 for (const trackId of new Set(found.map(card => card.trackId).filter((id): id is string => id !== undefined))) {
140 const ran = await $.process.run(['wipnote', 'track', 'show', trackId], { timeoutMs: 5000 }).catch(() => undefined)
141 tracks.set(trackId, ran?.exitCode === 0 ? parseTrackText(ran.stdout) : undefined)
142 }
143 const next = found.map(card => {
144 const track = card.trackId === undefined ? undefined : tracks.get(card.trackId)
145 return track === undefined ? card : { ...card, trackTitle: track.title, done: track.done, total: track.total }
146 })
147 await update($, cards, () => next)
148}
149
150/**
151 * The session's claimed work item (`wipnote who`). When `who` cannot be read
152 * the last claim stays: an unknown claim is not "no claim".
153 */
154async function refreshClaim($: EngineInterface) {
155 const who = await wipnoteJSON($, ['wipnote', 'who', '--json'])
156 const id = who.ok ? parseWho(who.stdout) : null
157 if (id !== null) await update($, claim, () => (id === '' ? null : id))
158}
159
160/** The work items in view: the session's claim first, then the ones shell commands started. */
161async function activeItems($: EngineInterface): Promise<string[]> {
162 const [claimed, started] = await Promise.all([read($, claim), read($, items)])
163 return claimed === null ? started : [claimed, ...started.filter(id => id !== claimed)]
164}
165
166/** Records the permission mode a main-loop classic event carries (a subagent's is its own). */
167async function noteMode($: EngineInterface, e: { permission_mode?: string; agent_id?: string }) {
168 const mode = e.permission_mode
169 if (mode !== undefined && e.agent_id === undefined) await update($, permissionMode, () => mode)
170}
171
172/**
173 * The project's delegate runs, followed read-only: every call reads the open runs again (a run
174 * folder Go writes shows within one tick) and writes the atom only when a row changed. The
175 * project is looked up again while none is found and after /resume, /branch or /clear.
176 */
177const runCache = newRunCache()
178let runsProject: string | null | undefined
179let isReadingRuns = false
180
181async function refreshRuns($: EngineInterface, tick: number) {
182 if (isReadingRuns) return
183 isReadingRuns = true
184 try {
185 if (runsProject === undefined || (runsProject === null && tick % 10 === 0)) runsProject = await findProject($)
186 const next = runsProject === null ? [] : await readRuns($, runsProject, runCache)
187 const last = await read($, runs)
188 if (JSON.stringify(last) !== JSON.stringify(next)) await update($, runs, () => next)
189 } finally {
190 isReadingRuns = false
191 }
192}
193
194/** A swapped-in conversation: its project's runs, its claim, the cards its transcript references. */
195function rehydrate($: EngineInterface) {
196 runsProject = undefined
197 void refreshClaim($).catch(() => undefined)
198 void hydrate($).catch(() => undefined)
199}
200
201async function sessionId($: EngineInterface): Promise<string | undefined> {
202 return $.session.id().catch(() => undefined)
203}
204
205/** Rehydrates once when the session id moved since it was last seen (swap.ts). */
206async function checkSwap($: EngineInterface, swap: SwapState) {
207 if (swapSeen(swap, await sessionId($))) rehydrate($)
208}
209
210async function bandData($: EngineInterface, zone: string): Promise<BandData> {
211 const [info, rows, ended, work, known, doing, delegated, at] = await Promise.all([
212 read($, prompt),
213 read($, agents),
214 read($, tally),
215 activeItems($),
216 read($, cards),
217 read($, mainTool),
218 read($, runs),
219 read($, now),
220 ])
221 return { info, rows, tally: ended, keepMs: KEEP_DONE_MS, work, cards: known, doing, runs: delegated, at, zone }
222}
223
224export const register: Register = (on, options) => {
225 const zone = zoneOf(options.timeZone)
226 const pending = new Map<string, Hint>()
227 const swap: SwapState = {}
228
229 on('session.start', async ($, e, next) => {
230 await $.command.register({
231 name: 'orchestra',
232 description: 'Orchestra band: full (agents by work item, stalled or failed ones), compact (one line), off; above or below the prompt',
233 argumentHint: 'full|compact|off|above|below',
234 })
235 await refreshPrompt($).catch(() => undefined)
236 swapStarted(swap, await sessionId($))
237 void refreshClaim($).catch(() => undefined)
238 void hydrate($).catch(() => undefined)
239 const started = await $.clock.now()
240 await update($, now, () => started)
241 runsProject = undefined
242 void refreshRuns($, 0).catch(() => undefined)
243 let tick = 0
244 $.clock.every(1000, () => {
245 tick += 1
246 void checkSwap($, swap).catch(() => undefined) // a swap whose classic.SessionStart was skipped
247 void (async () => {
248 const at = await $.clock.now()
249 const rows = await read($, agents)
250 const isBusy = rows.some(row => isLive(row.status) || at - (row.endedAt ?? 0) < keepFor(row.status, KEEP_DONE_MS))
251 if (isBusy || tick % 30 === 0) {
252 await update($, now, () => at)
253 }
254 if (tick % 2 === 0) await reconcileAgents($, at, pending)
255 if (tick % 5 === 0) await refreshPrompt($)
256 if (tick % 5 === 1) await refreshCards($)
257 })().catch(() => undefined)
258 void refreshRuns($, tick).catch(() => undefined)
259 })
260 return next(e)
261 })
262
263 on('command.run', { command: 'orchestra' }, async ($, e) => {
264 const arg = e.args.trim()
265 if (arg === 'above' || arg === 'below') {
266 await update($, placement, () => arg)
267 return { text: `Orchestra band: ${arg} the prompt.` }
268 }
269 const current = await read($, mode)
270 const wanted: Mode =
271 arg === 'full' || arg === 'compact' || arg === 'off'
272 ? arg
273 : current === 'off'
274 ? 'full'
275 : 'off'
276 await update($, mode, () => wanted)
277 return { text: `Orchestra band: ${wanted}.` }
278 })
279
280 on('agent.spawn', async ($, e, next) => {
281 const hint: Hint = {
282 workItem: workItemIn(e.prompt),
283 model: e.model !== undefined ? modelLabel(e.model) : undefined,
284 }
285 pending.set(e.description, hint)
286 // A main-loop spawn carries the main loop's mode: a source no classic.* skip can hide.
287 await noteMode($, { permission_mode: e.permissionMode, agent_id: e.parentAgentId })
288 const at = await $.clock.now()
289 const ran = await next(e)
290 const id = ran.agentId
291 if (id !== undefined) {
292 const model = modelLabel(ran.model)
293 await update($, agents, rows => {
294 const row = rows.find(one => one.id === id)
295 const base: AgentRow = row ?? {
296 id,
297 type: e.subagentType,
298 description: e.description,
299 status: 'running',
300 startedAt: at,
301 tools: 0,
302 }
303 return [
304 ...rows.filter(one => one.id !== id),
305 { ...base, workItem: base.workItem ?? hint.workItem, model },
306 ]
307 })
308 pending.delete(e.description)
309 }
310 return ran
311 }).catch(($, e, next) => next(e))
312
313 on('tool.call', async ($, e, next) => {
314 const input = e as unknown as Record<string, unknown>
315 const doing = describeCall(String(e.tool), input)
316 if (e.agentId === undefined) {
317 await update($, mainTool, () => doing)
318 } else {
319 const id = e.agentId
320 const at = await $.clock.now()
321 await update($, agents, rows => {
322 const row = rows.find(one => one.id === id)
323 const base: AgentRow = row ?? {
324 id,
325 type: 'agent',
326 description: '',
327 status: 'running',
328 startedAt: at,
329 tools: 0,
330 }
331 const hint = pending.get(base.description)
332 const workItem = base.workItem ?? hint?.workItem
333 const model = base.model ?? hint?.model
334 return [
335 ...rows.filter(one => one.id !== id),
336 { ...base, workItem, model, tools: base.tools + 1, lastTool: doing, lastAt: at, inFlight: (base.inFlight ?? 0) + 1 },
337 ]
338 })
339 }
340 if (e.tool === 'Bash') {
341 const command = e.command
342 await update($, items, list => applyWipnote(list, command))
343 }
344 try {
345 const r = await next(e)
346 if (e.tool === 'Bash') void refreshChanged($, e.command).catch(() => undefined)
347 return r
348 } finally {
349 // The call ended, answered or thrown: one fewer running for its agent
350 const id = e.agentId
351 if (id !== undefined) {
352 try {
353 const at = await $.clock.now()
354 await update($, agents, rows =>
355 rows.map(row => (row.id === id ? { ...row, lastAt: at, inFlight: Math.max(0, (row.inFlight ?? 1) - 1) } : row)),
356 )
357 } catch {
358 /* the next call or the list sets it again */
359 }
360 }
361 }
362 }).catch(($, e, next) => next(e))
363
364 // The mode pill Claude Code draws left of the band (the band shifts left past it, pillIndent):
365 // the hint's props do not carry it, so every main-loop event that does keeps it current. A
366 // shift+tab change shows from the next of them (a prompt, a tool call, the turn's end).
367 on('classic.UserPromptSubmit', async ($, e, next) => {
368 await noteMode($, e)
369 return next(e)
370 })
371
372 on('classic.PostToolUse', async ($, e, next) => {
373 await noteMode($, e)
374 return next(e)
375 })
376
377 on('classic.Stop', async ($, e, next) => {
378 await noteMode($, e)
379 return next(e)
380 })
381
382 on('classic.Notification', async ($, e, next) => {
383 await noteMode($, e)
384 return next(e)
385 })
386
387 on('turn.complete', async ($, e, next) => {
388 await update($, mainTool, () => null)
389 await refreshPrompt($).catch(() => undefined)
390 void refreshClaim($).catch(() => undefined)
391 refreshAfterTurn($)
392 return next(e)
393 })
394
395 // /resume, /branch and /clear swap the conversation, but session.start does not fire again.
396 // The settings SessionStart does, unless a plugin above continues it past this one, so the
397 // swap is also caught by the changed session id: here, at session.end and on the tick.
398 on('session.end', async ($, e, next) => {
399 const r = await next(e)
400 if (e.reason === 'clear' || e.reason === 'resume') await checkSwap($, swap)
401 return r
402 })
403
404 on('classic.SessionStart', async ($, e, next) => {
405 await noteMode($, e) // a launch in a mode (wipnote claude's auto) shows its pill before any prompt
406 if (e.source !== 'startup' && swapClassic(swap, await sessionId($))) rehydrate($)
407 return next(e)
408 })
409
410 registerRefs(on)
411
412 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
413 const [shown, where] = await Promise.all([read($, mode), read($, placement)])
414 if (e.props.hasSurvey || shown === 'off' || where !== 'above') return next(e)
415 const tree = drawBand($.ui.resolve(e), await bandData($, zone), {
416 width: e.props.bodyColumns,
417 maxRows: e.props.maxRows,
418 isWorking: e.props.isWorking,
419 isCompact: shown === 'compact',
420 })
421 return tree ?? next(e)
422 })
423
424 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
425 const [shown, where] = await Promise.all([read($, mode), read($, placement)])
426 if (shown === 'off' || where !== 'below') return next(e)
427 const els = $.ui.resolve(e)
428 const tree = drawBand(els, await bandData($, zone), {
429 width: Math.max(20, (e.viewport?.columns ?? 100) - 2),
430 maxRows: Math.max(2, Math.floor((e.viewport?.rows ?? 30) / 4) - 1),
431 isWorking: e.props.isWorking,
432 isCompact: shown === 'compact',
433 })
434 if (tree === null) return next(e)
435 // Row one is the engine's own hint line, as it draws it; the band takes the rows under it.
436 const { Box, Text } = els
437 const indent = pillIndent(await read($, permissionMode), e.props.hint)
438 return (
439 <Box flexDirection="column">
440 <Text key="hint" dimColor wrap="truncate-end">
441 {e.props.hint}
442 </Text>
443 <Box key="band" marginLeft={-indent}>
444 {tree}
445 </Box>
446 </Box>
447 )
448 })
449}
450hooks/band.tsx 163 lines1import type { EngineInterface } from 'claude-code'
2
3import type { AgentRow, PromptInfo, RunRow, Tally, WorkCard } from '../types'
4import type { Segment, Span } from './model'
5import { visibleRuns } from './runs'
6import {
7 attentionSpans,
8 clip,
9 clockText,
10 fit,
11 groupByItem,
12 groupSpans,
13 isLive,
14 limitSpans,
15 lineSpans,
16 needsAttention,
17 runSpans,
18 tallyText,
19 wrapSegments,
20} from './model'
21
22type Elements = ReturnType<EngineInterface['ui']['resolve']>
23
24export type BandData = {
25 info: PromptInfo | null
26 /** Every agent the session has followed (the last 100). */
27 rows: AgentRow[]
28 tally: Tally
29 /** How long a finished agent stays counted in its work item's row. */
30 keepMs: number
31 work: string[]
32 cards: WorkCard[]
33 doing: string | null
34 /** The project's delegate runs (.wipnote/delegate/runs/), live and ended. */
35 runs: RunRow[]
36 at: number
37 zone: string
38}
39
40export type BandRoom = {
41 width: number
42 maxRows: number
43 isWorking: boolean
44 isCompact: boolean
45}
46
47/** A work item segment's colour by its status; an unknown or unloaded status keeps the in-progress colour. */
48const ITEM_BG: Record<string, string> = { done: '#5f8700', blocked: '#af0000', todo: '#5f5f87' }
49
50/**
51 * The session's work items as their track's progress (` Raw-data RCA agents [1/46] `, once per track) and
52 * their own title and steps (` → Routine setup script 2/5 `), coloured by status; an item whose details
53 * have not loaded shows its id.
54 */
55export function workSegments(work: readonly string[], cards: readonly WorkCard[]): Segment[] {
56 const out: Segment[] = []
57 const tracks = new Set<string>()
58 for (const id of work) {
59 const card = cards.find(one => one.id === id)
60 if (card?.trackId !== undefined && card.trackTitle !== undefined && !tracks.has(card.trackId)) {
61 tracks.add(card.trackId)
62 const progress = card.total !== undefined ? ` [${card.done ?? 0}/${card.total}]` : ''
63 out.push({ text: ` ${clip(card.trackTitle, 32)}${progress} `, bg: '#5f5faf', fg: '#ffffff' })
64 }
65 const steps = card?.stepsTotal !== undefined ? ` ${card.stepsDone ?? 0}/${card.stepsTotal}` : ''
66 out.push({ text: ` → ${clip(card?.title ?? id, 40)}${steps} `, bg: ITEM_BG[card?.status ?? ''] ?? '#875fd7', fg: '#ffffff' })
67 }
68 return out
69}
70
71function segmentsOf(
72 info: PromptInfo | null,
73 work: readonly string[],
74 cards: readonly WorkCard[],
75 live: number,
76 tally: string,
77 clock: string,
78): Segment[] {
79 const out: Segment[] = []
80 if (info !== null) {
81 out.push({ text: ` ${info.dir} `, bg: '#0087af', fg: '#ffffff' })
82 if (info.branch !== undefined) {
83 const marks = [
84 info.dirty > 0 ? `!${info.dirty}` : '',
85 info.ahead > 0 ? `⇡${info.ahead}` : '',
86 info.behind > 0 ? `⇣${info.behind}` : '',
87 ].filter(Boolean)
88 out.push({
89 text: ` \uE0A0 ${info.branch}${marks.length > 0 ? ` ${marks.join(' ')}` : ''} `,
90 bg: info.dirty > 0 ? '#d7af00' : '#5faf00',
91 fg: '#000000',
92 })
93 }
94 if (info.model !== undefined) {
95 out.push({ text: ` ✻ ${info.model} `, bg: '#d75f00', fg: '#ffffff' })
96 }
97 if (info.ctxPercent !== undefined) {
98 const pct = Math.round(info.ctxPercent)
99 out.push({ text: ` ctx ${pct}% `, bg: pct >= 80 ? '#d70000' : '#444444', fg: '#ffffff' })
100 }
101 }
102 out.push(...workSegments(work, cards))
103 const agents = [live > 0 ? `◉ ${live} agent${live === 1 ? '' : 's'}` : '', tally].filter(Boolean).join(' · ')
104 if (agents !== '') out.push({ text: ` ${agents} `, bg: '#005f87', fg: '#ffffff' })
105 if (clock !== '') out.push({ text: ` \uF017 ${clock} `, bg: '#303030', fg: '#d0d0d0' })
106 return out
107}
108
109/**
110 * The band's tree, or null with nothing to show; drawn above or below the prompt alike.
111 * Claude Code's own agent panel lists every agent (and opens one with Enter), so the band adds
112 * what it does not show: agents grouped by work item, the ones that need a look, and the
113 * project's delegate runs (one row per run).
114 */
115export function drawBand({ Box, Text }: Elements, data: BandData, room: BandRoom) {
116 const live = data.rows.filter(row => isLive(row.status)).length
117 // The clock ends the usage-limits row; without that row (compact, or no limits known) it is a segment.
118 const limits = room.isCompact ? [] : limitSpans(data.info?.limits ?? [], data.at, data.zone)
119 const clock = data.at > 0 ? clockText(data.at, data.zone) : ''
120 const segs = segmentsOf(data.info, data.work, data.cards, live, tallyText(data.tally), limits.length > 0 ? '' : clock)
121 if (segs.length === 0) return null
122 if (limits.length > 0 && clock !== '') limits.push({ text: ' ' }, { text: `\uF017 ${clock}`, color: '#d0d0d0' })
123
124 const draw = (key: string, spans: readonly Span[]) => (
125 <Text key={key} wrap="truncate-end">
126 {fit(spans, room.width).map((s, i) => (
127 <Text key={`${key}-${i}`} color={s.color} backgroundColor={s.bg} bold={s.bold} dimColor={s.dim} italic={s.italic}>
128 {s.text}
129 </Text>
130 ))}
131 </Text>
132 )
133 const lines = wrapSegments(segs, room.width).map(lineSpans)
134 const last = lines.at(-1)
135 if (last !== undefined && room.isWorking && data.doing !== null) last.push({ text: ` ▶ ${data.doing}`, dim: true })
136 const bar = [...(limits.length > 0 ? [limits] : []), ...lines].map((spans, i) => draw(`line-${i}`, spans))
137 if (room.isCompact) return <Box flexDirection="column">{bar}</Box>
138
139 const card = (id: string) => data.cards.find(one => one.id === id)
140 const flags = needsAttention(data.rows, data.at).map(flag => ({
141 key: `x-${flag.row.id}`,
142 spans: attentionSpans(flag, data.at),
143 }))
144 const groups = groupByItem(data.rows, data.at, data.keepMs).map(group => {
145 const it = card(group.item)
146 const steps = it?.stepsTotal !== undefined ? `${it.stepsDone ?? 0}/${it.stepsTotal} steps` : ''
147 return { key: `g-${group.item}`, spans: groupSpans(group, clip(it?.title ?? group.item, 40), steps) }
148 })
149 const runs = visibleRuns(data.runs, data.at).map(run => ({ key: `r-${run.id}`, spans: runSpans(run) }))
150 const rows = [...flags, ...runs, ...groups]
151 // Rows past the room left: the last one that fits says how many more there are
152 const fits = Math.max(0, room.maxRows - 1 - bar.length)
153 const shown = rows.length > fits ? rows.slice(0, Math.max(0, fits - 1)) : rows
154 const hidden = rows.length - shown.length
155 return (
156 <Box flexDirection="column">
157 {bar}
158 {shown.map(row => draw(row.key, row.spans))}
159 {hidden > 0 && fits > 0 && <Text dimColor>{` … ${hidden} more`}</Text>}
160 </Box>
161 )
162}
163hooks/refcards.tsx 200 lines1// wipnote-ref cards, for few model tokens: the model ends a report with a
2// ```wipnote-ref fence holding only an id, and this draws it as a card from
3// `wipnote ... show --format json`. The stored message keeps the short fence,
4// so later turns read ~10 tokens, and without the mod the fence still reads as
5// an id.
6//
7// The one-line rule that asks for the fence lives in ../prompt.md. `wipnote
8// claude` appends it to the system prompt when it loads this mod, so the model
9// never writes fences nobody draws. It is not a prompt.compose hook: a managed
10// policy plugin that sends that event past the user tier skips every mod's.
11//
12// A render hook never writes state, so cards are fetched from events: when a
13// reply is stored (session.append), after each turn (turn.complete), and on
14// start or resume (hydrate). The render hook only reads the refs atom.
15
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { Card, CardEntry, FailKind } from '../types'
20import { Latest, changedRefs, classifyExit, failureText, needsFetch, parseCard, pickRefresh, refsInMessages, showArgv, splitRefs, stepBar, stepCount } from './refs'
21import type { CliResult } from './refs'
22
23type On = Parameters<Register>[0]
24
25const refs = atom({ plugin: 'wipnote-orchestra', key: 'refs' } as const, {})
26const CLI_TIMEOUT_MS = 15_000
27const MAX_REFRESH = 10
28const SCAN_MESSAGES = 200
29
30const STATUS_COLOR: Record<string, string> = {
31 done: 'success', 'in-progress': 'warning', blocked: 'error', todo: 'subtle',
32}
33
34/** Runs a read-only wipnote command as this session; a spawn failure or timeout is 'unavailable'. */
35export async function wipnoteJSON($: EngineInterface, argv: string[]): Promise<CliResult> {
36 try {
37 const sessionId = await $.session.id()
38 const r = await $.process.run(argv, {
39 timeoutMs: CLI_TIMEOUT_MS,
40 env: { CLAUDE_CODE_SESSION_ID: sessionId },
41 })
42 return classifyExit(r.exitCode, r.stdout, r.stderr)
43 } catch {
44 return { ok: false, kind: 'unavailable' }
45 }
46}
47
48async function fetchCard($: EngineInterface, id: string): Promise<{ card: Card } | { fail: FailKind }> {
49 const r = await wipnoteJSON($, showArgv(id))
50 if (!r.ok) return { fail: r.kind }
51 try {
52 return { card: parseCard(id, r.stdout) }
53 } catch {
54 return { fail: 'error' }
55 }
56}
57
58function setEntry($: EngineInterface, id: string, entry: CardEntry): Promise<unknown> {
59 return update($, refs, all => ({ ...all, [id]: entry }))
60}
61
62// Overlapping lookups finish in any order. Each one takes a ticket, and only
63// the newest ticket may write, so an older, slower lookup never wins.
64const latest = new Latest()
65
66/**
67 * Stores a card from `show` output the band already fetched, so an active
68 * item costs one lookup, not one for the band and one for its card. It takes
69 * a ticket like loadCard, so it never overwrites a newer lookup.
70 */
71export async function storeCard($: EngineInterface, id: string, stdout: string): Promise<void> {
72 const ticket = latest.take(`card:${id}`)
73 let card: Card
74 try {
75 card = parseCard(id, stdout)
76 } catch {
77 return
78 }
79 if (latest.isNewest(`card:${id}`, ticket)) await setEntry($, id, { state: 'loaded', card, at: await $.clock.now() })
80}
81
82/** Loads one card into the atom; the write redraws every message that reads it. */
83async function loadCard($: EngineInterface, id: string): Promise<void> {
84 const ticket = latest.take(`card:${id}`)
85 await setEntry($, id, { state: 'loading', at: await $.clock.now() })
86 const r = await fetchCard($, id)
87 if (!latest.isNewest(`card:${id}`, ticket)) return
88 const at = await $.clock.now()
89 await setEntry($, id, 'card' in r ? { state: 'loaded', card: r.card, at } : { state: 'failed', reason: r.fail, at })
90}
91
92/** Starts a fetch for every id that is missing or stale. */
93async function loadStale($: EngineInterface, ids: string[]): Promise<void> {
94 const all = await read($, refs)
95 const now = await $.clock.now()
96 for (const id of ids) if (needsFetch(all[id], now)) void loadCard($, id)
97}
98
99/**
100 * After a turn, re-fetches the cards that can still change: any not done, and
101 * any that failed (the user may have pulled). A done item rarely changes, so
102 * it waits for the TTL instead of costing a process every turn.
103 */
104async function refreshOpenCards($: EngineInterface): Promise<void> {
105 const all = await read($, refs)
106 for (const id of pickRefresh(Object.entries(all), MAX_REFRESH)) void loadCard($, id)
107}
108
109/**
110 * After a Bash call ends, re-fetches the cards it changed (a ticked step, a
111 * start or complete), so a card moves mid-turn instead of at the turn's end.
112 * Only cards already drawn are fetched; others load when a reply names them.
113 */
114export async function refreshChanged($: EngineInterface, command: string): Promise<void> {
115 const ids = changedRefs(command)
116 if (ids.length === 0) return
117 const all = await read($, refs)
118 // The CLI takes a unique id prefix (feat-65bcc2), so match drawn ids by prefix.
119 for (const id of Object.keys(all)) if (ids.some(one => id.startsWith(one))) void loadCard($, id)
120}
121
122/**
123 * Loads the cards the transcript already references. The refs atom starts
124 * empty in a new or resumed session, and session.append sees only new
125 * replies, so saved references are read back from the transcript.
126 */
127export async function hydrate($: EngineInterface): Promise<void> {
128 const messages = await $.session.messages()
129 if (!Array.isArray(messages)) return
130 const ids = refsInMessages(messages.slice(-SCAN_MESSAGES), MAX_REFRESH)
131 if (ids.length > 0) await loadStale($, ids)
132}
133
134/** After a turn: the open cards (stalest first) and any the transcript references but the atom lacks. */
135export function refreshAfterTurn($: EngineInterface): void {
136 void refreshOpenCards($).catch(() => undefined)
137 void hydrate($).catch(() => undefined)
138}
139
140/**
141 * The wipnote-ref hooks: fetch on reply, the model rule, and the card drawing.
142 * register.tsx owns session.start, classic.SessionStart and turn.complete (an
143 * event takes one unmatched hook per mod) and calls hydrate and refreshAfterTurn.
144 */
145export function registerRefs(on: On): void {
146 // A stored reply with a wipnote-ref fence starts the card fetches; the
147 // render hook below only reads the result.
148 on('session.append', { door: 'response' }, async ($, e, next) => {
149 const r = await next(e)
150 const text = e.message.content
151 .map(b => (b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
152 .join('\n')
153 const ids = splitRefs(text).flatMap(s => (s.kind === 'refs' ? s.ids : []))
154 if (ids.length > 0) void loadStale($, ids)
155 return r
156 })
157
158 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
159 const segments = splitRefs(e.props.text)
160 if (!segments.some(s => s.kind === 'refs')) return next(e)
161
162 const { Box, Text, Markdown } = $.ui.resolve(e)
163 const all = await read($, refs)
164
165 return (
166 <Box flexDirection="column">
167 {segments.map((s, i) =>
168 s.kind === 'text' ? (
169 <Markdown key={`t${i}`} text={s.text} />
170 ) : (
171 <Box key={`r${i}`} flexDirection="column">
172 {s.ids.map(id => {
173 const entry = all[id]
174 if (!entry) return <Text key={id} dimColor>{id}</Text>
175 if (entry.state === 'loading') return <Text key={id} dimColor>⧗ {id}</Text>
176 if (entry.state === 'failed') {
177 return <Text key={id} dimColor>{failureText(id, entry.reason)}</Text>
178 }
179 const c = entry.card
180 return (
181 <Box key={id} flexDirection="column" borderStyle="round" paddingX={1}>
182 <Text>
183 <Text bold>{c.title || c.id}</Text>
184 <Text dimColor> {c.id}</Text>
185 </Text>
186 <Text>
187 <Text color={STATUS_COLOR[c.status] ?? 'subtle'}>● {c.status}</Text>
188 {c.steps.length > 0 ? <Text dimColor> {stepBar(c.steps)} {stepCount(c.steps)}</Text> : null}
189 </Text>
190 </Box>
191 )
192 })}
193 </Box>
194 ),
195 )}
196 </Box>
197 )
198 })
199}
200hooks/refs.ts 181 lines1// Pure helpers for wipnote-ref cards: fence parsing, CLI argv and text formats.
2// No `$` here, so the tests run them directly.
3
4import type { Card, CardEntry, FailKind, Step } from '../types'
5
6/** A work-item id: kind prefix, a dash, 6+ hex digits (feat-1a2b3c4d). */
7const ID = /\b(feat|bug|spk|trk|plan)-[0-9a-f]{6,}\b/g
8
9/** A closed ```wipnote-ref fence: ids on the info line or in the body. */
10const FENCE = /```wipnote-ref([^\n`]*)\n?([\s\S]*?)```/g
11
12export type Segment = { kind: 'text'; text: string } | { kind: 'refs'; ids: string[] }
13
14/**
15 * Splits assistant markdown into text runs and wipnote-ref fences. An open
16 * (still streaming) fence stays text, so a half-written block draws as typed.
17 */
18export function splitRefs(markdown: string): Segment[] {
19 const out: Segment[] = []
20 let last = 0
21 for (const m of markdown.matchAll(FENCE)) {
22 const ids = [...new Set(`${m[1]} ${m[2]}`.match(ID) ?? [])]
23 if (ids.length === 0) continue
24 if (m.index > last) out.push({ kind: 'text', text: markdown.slice(last, m.index) })
25 out.push({ kind: 'refs', ids })
26 last = m.index + m[0].length
27 }
28 if (out.length === 0) return [{ kind: 'text', text: markdown }]
29 if (last < markdown.length) out.push({ kind: 'text', text: markdown.slice(last) })
30 return out
31}
32
33const KIND_COMMAND: Record<string, string> = {
34 feat: 'feature', bug: 'bug', spk: 'spike', trk: 'track', plan: 'plan',
35}
36
37/** The read-only CLI call that reports one work item as JSON. */
38export function showArgv(id: string): string[] {
39 const kind = KIND_COMMAND[id.split('-')[0] ?? ''] ?? 'feature'
40 return ['wipnote', kind, 'show', id, '--format', 'json']
41}
42
43/** Parses `wipnote <kind> show --format json` output into a Card. */
44export function parseCard(id: string, stdout: string): Card {
45 const raw = JSON.parse(stdout) as Record<string, unknown>
46 const steps = Array.isArray(raw.steps)
47 ? (raw.steps as Record<string, unknown>[]).map((s): Step => ({
48 description: String(s.description ?? ''),
49 completed: s.completed === true,
50 }))
51 : []
52 return {
53 id: String(raw.id ?? id),
54 title: String(raw.title ?? ''),
55 status: String(raw.status ?? 'unknown'),
56 type: String(raw.type ?? ''),
57 steps,
58 }
59}
60
61/** "3/5 steps", or "" for an item with no steps. */
62export function stepCount(steps: Step[]): string {
63 if (steps.length === 0) return ''
64 return `${steps.filter(s => s.completed).length}/${steps.length} steps`
65}
66
67/** A ten-cell text bar for step progress: ███████░░░. */
68export function stepBar(steps: Step[]): string {
69 if (steps.length === 0) return ''
70 const done = Math.round((10 * steps.filter(s => s.completed).length) / steps.length)
71 return '█'.repeat(done) + '░'.repeat(10 - done)
72}
73
74/** A shell call that changes a work item's steps or status. */
75const CHANGE = /\bwipnote\s+(?:feature|bug|spike|track|plan)\s+(?:add-step|complete-step|complete|start)\s+((?:feat|bug|spk|trk|plan)-[0-9a-f]{6,})\b/g
76
77/**
78 * The ids a shell command changes with `wipnote <kind> add-step|complete-step|
79 * complete|start <id>`, deduped, so their cards refresh as soon as it ran.
80 */
81export function changedRefs(command: string): string[] {
82 return [...new Set([...command.matchAll(CHANGE)].map(m => m[1] as string))]
83}
84
85/** How long a loaded or failed card is shown before it is fetched again. */
86export const CARD_TTL_MS = 60_000
87
88/**
89 * Whether a card needs a (re)fetch: missing, or loaded/failed longer than the
90 * TTL ago. A loading entry is left alone unless it is stuck past the TTL.
91 */
92export function needsFetch(entry: { state: string; at: number } | undefined, now: number): boolean {
93 if (!entry) return true
94 return now - entry.at > CARD_TTL_MS
95}
96
97/** What a wipnote call returned: its output, or why it failed. */
98export type CliResult = { ok: true; stdout: string } | { ok: false; kind: FailKind }
99
100/**
101 * Classifies a finished wipnote process. A nonzero exit that says "not found"
102 * is a missing item (pulling may help); any other nonzero exit is an error.
103 * A process that could not run or timed out is reported by the caller as
104 * 'unavailable'.
105 */
106export function classifyExit(exitCode: number, stdout: string, stderr: string): CliResult {
107 if (exitCode === 0) return { ok: true, stdout }
108 return { ok: false, kind: /not found/i.test(`${stderr}\n${stdout}`) ? 'missing' : 'error' }
109}
110
111/** The one-line text drawn for a card whose lookup failed. */
112export function failureText(id: string, kind: FailKind): string {
113 if (kind === 'missing') return `${id} · not in this checkout (pull to update)`
114 if (kind === 'unavailable') return `${id} · wipnote unavailable`
115 return `${id} · details unavailable`
116}
117
118/**
119 * The work item `wipnote who --json` reports: the id, '' for no claim, or
120 * null when the output cannot be read (the caller keeps its last claim).
121 */
122export function parseWho(stdout: string): string | null {
123 try {
124 const raw = JSON.parse(stdout) as { work_item?: unknown }
125 return typeof raw.work_item === 'string' ? raw.work_item : ''
126 } catch {
127 return null
128 }
129}
130
131/**
132 * The cards to refresh after a turn: open (not done) or failed ones, stalest
133 * first, at most `max`. Ordering by age rotates the cap across every card, so
134 * none is starved.
135 */
136export function pickRefresh(entries: [string, CardEntry][], max: number): string[] {
137 return entries
138 .filter(([, e]) => e.state === 'failed' || (e.state === 'loaded' && e.card.status !== 'done'))
139 .sort(([, a], [, b]) => a.at - b.at)
140 .slice(0, max)
141 .map(([id]) => id)
142}
143
144/**
145 * The ids referenced by wipnote-ref fences in the assistant messages, newest
146 * last, deduped, at most `max` (the newest ones).
147 */
148export function refsInMessages(messages: { role: string; text: string }[], max: number): string[] {
149 const ids: string[] = []
150 for (const m of messages) {
151 if (m.role !== 'assistant') continue
152 for (const s of splitRefs(m.text)) {
153 if (s.kind !== 'refs') continue
154 for (const id of s.ids) {
155 const at = ids.indexOf(id)
156 if (at >= 0) ids.splice(at, 1)
157 ids.push(id)
158 }
159 }
160 }
161 return ids.slice(-max)
162}
163
164/**
165 * Hands out increasing tickets per key, so of several overlapping async calls
166 * only the newest one acts on its result.
167 */
168export class Latest {
169 private readonly last = new Map<string, number>()
170
171 take(key: string): number {
172 const ticket = (this.last.get(key) ?? 0) + 1
173 this.last.set(key, ticket)
174 return ticket
175 }
176
177 isNewest(key: string, ticket: number): boolean {
178 return this.last.get(key) === ticket
179 }
180}
181hooks/runs.ts 204 lines1import type { EngineInterface } from 'claude-code'
2
3import type { RunRow } from '../types'
4
5/**
6 * Delegate runs (wipnote delegate, plan-28b4a7f6): the run folders under
7 * .wipnote/delegate/runs/ (internal/delegate/runstore) joined to their rows in
8 * .wipnote/delegations-ledger.html (core/delegationledger), which holds the
9 * mode, harness and name a run folder does not. Read-only: this file only
10 * lists, stats and reads files (and asks git where the checkout is); it never
11 * writes, creates or removes anything.
12 */
13
14export const RUNS_DIR = '.wipnote/delegate/runs'
15export const LEDGER_FILE = '.wipnote/delegations-ledger.html'
16
17/** An ended run stays in the band this long after it ended; a live one always shows. */
18export const RUN_KEEP_MS = 60 * 60_000
19
20const RUN_ID = /^dlg-[0-9a-f]{12,64}$/
21
22/** What the ledger says of one run. */
23export type LedgerRow = {
24 mode: string
25 harness: string
26 name: string
27 label: string
28 closed: boolean
29 endedAt?: number
30 exit?: number
31 endReason: string
32}
33
34const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
35
36/** Undoes the HTML escaping the ledger writer applies to attribute values. */
37export function decodeEntities(text: string): string {
38 return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, ref: string) => {
39 const named = NAMED[ref.toLowerCase()]
40 if (named !== undefined) return named
41 const isHex = /^#x/i.test(ref)
42 const code = isHex ? parseInt(ref.slice(2), 16) : ref.startsWith('#') ? Number(ref.slice(1)) : NaN
43 return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole
44 })
45}
46
47function attrsOf(tag: string): Map<string, string> {
48 const attrs = new Map<string, string>()
49 for (const [, name = '', value = ''] of tag.matchAll(/\s([a-z][a-z0-9-]*)="([^"]*)"/g)) attrs.set(name, decodeEntities(value))
50 return attrs
51}
52
53/**
54 * The ledger's rows by run id. Each row is one `<tr data-run-id=…>` line whose
55 * attributes carry every column; a row with no valid run id, mode or harness
56 * (a torn or foreign line) is skipped.
57 */
58export function parseLedger(html: string): Map<string, LedgerRow> {
59 const rows = new Map<string, LedgerRow>()
60 for (const [tag] of html.matchAll(/<tr\s[^>]*data-run-id="[^"]*"[^>]*>/g)) {
61 const a = attrsOf(tag)
62 const get = (name: string) => (a.get(`data-${name}`) ?? '').trim()
63 const [id, mode, harness, exit, endReason] = ['run-id', 'mode', 'harness', 'exit', 'end-reason'].map(get) as string[]
64 if (!RUN_ID.test(id ?? '') || mode === '' || harness === '') continue
65 const ended = Date.parse(get('ended'))
66 rows.set(id ?? '', {
67 mode: mode ?? '',
68 harness: harness ?? '',
69 name: get('name'),
70 label: get('label'),
71 closed: !Number.isNaN(ended) || endReason !== '',
72 endedAt: Number.isNaN(ended) ? undefined : ended,
73 exit: /^-?\d+$/.test(exit ?? '') ? Number(exit) : undefined,
74 endReason: endReason ?? '',
75 })
76 }
77 return rows
78}
79
80/** The first non-blank line of last.md, without a heading's `#`s or a quote's `>`. */
81export function firstLine(md: string): string {
82 const line = md.split('\n').find(one => one.trim() !== '') ?? ''
83 return line.trim().replace(/^(?:#{1,6}\s+|>\s*)+/, '').trim()
84}
85
86/** What one run folder holds. */
87export type FolderFacts = {
88 label: string
89 startedAt?: number
90 endedAt?: number
91 /** The exit file's code (an unreadable one reads as 1, as Go reads it); undefined while there is none. */
92 exit?: number
93 answer: string
94}
95
96const unixMs = (text: string) => (/^\d+$/.test(text.trim()) && Number(text) > 0 ? Number(text.trim()) * 1000 : undefined)
97
98/** The band's row for one run: its folder's files, and its ledger row when there is one. */
99export function rowOf(id: string, facts: FolderFacts, ledger?: LedgerRow): RunRow {
100 const ended = facts.exit !== undefined || ledger?.closed === true
101 const exit = facts.exit ?? ledger?.exit
102 const reason = facts.exit === undefined && ledger?.endReason !== 'exit' ? ledger?.endReason : undefined
103 const endedAt = facts.endedAt ?? ledger?.endedAt
104 return {
105 id,
106 title: ledger?.name || ledger?.label || facts.label || id,
107 harness: ledger?.harness ?? '',
108 mode: ledger?.mode ?? '',
109 state: ended ? 'ended' : 'live',
110 ...(ended && exit !== undefined ? { exit } : {}),
111 ...(ended && reason ? { reason } : {}),
112 ...(facts.startedAt !== undefined ? { startedAt: facts.startedAt } : {}),
113 ...(ended && endedAt !== undefined ? { endedAt } : {}),
114 answer: facts.answer,
115 }
116}
117
118/** The runs to draw: live ones oldest first, then the ones that ended within RUN_KEEP_MS, newest first. */
119export function visibleRuns(rows: readonly RunRow[], now: number): RunRow[] {
120 const live = rows.filter(one => one.state === 'live').sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0))
121 const endOf = (one: RunRow) => one.endedAt ?? one.startedAt ?? 0
122 const ended = rows
123 .filter(one => one.state === 'ended' && now - endOf(one) < RUN_KEEP_MS)
124 .sort((a, b) => endOf(b) - endOf(a))
125 return [...live, ...ended]
126}
127
128/** Reads one run folder: label, start, end, exit and the first line of last.md, from plain files only. */
129export async function readFolder($: EngineInterface, dir: string): Promise<FolderFacts> {
130 const entries = await $.fs.list(dir)
131 const files = new Map(entries.filter(one => one.kind === 'file' && !one.isLink).map(one => [one.name, one]))
132 const read = (name: string) => (files.has(name) ? $.fs.read(`${dir}/${name}`).then(String).catch(() => '') : Promise.resolve(''))
133 const [label = '', start = '', end = '', exit = '', last = ''] = await Promise.all(['label', 'start', 'end', 'exit', 'last.md'].map(read))
134 const code = exit.trim()
135 return {
136 label: label.trim().split('\n')[0] ?? '',
137 startedAt: unixMs(start),
138 endedAt: unixMs(end) ?? (files.has('exit') ? files.get('exit')?.mtimeMs || undefined : undefined),
139 exit: files.has('exit') ? (/^-?\d+$/.test(code) ? Number(code) : 1) : undefined,
140 answer: firstLine(last),
141 }
142}
143
144/**
145 * The project whose .wipnote/ holds the runs, found as wipnote's CLI finds it:
146 * WIPNOTE_PROJECT_DIR when it has one, else the main checkout of a git worktree,
147 * else the nearest folder with one from the session's directory up.
148 */
149export async function findProject($: EngineInterface): Promise<string | null> {
150 const has = (dir: string) => $.fs.exists(`${dir}/.wipnote`).catch(() => false)
151 const env = await $.env.get('WIPNOTE_PROJECT_DIR').catch(() => undefined)
152 if (env !== undefined && env !== '' && (await has(env))) return env
153 const git = await $.process
154 .run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { timeoutMs: 5000 })
155 .catch(() => undefined)
156 const common = git?.exitCode === 0 ? git.stdout.trim() : ''
157 if (common.endsWith('/.git') && (await has(common.slice(0, -5) || '/'))) return common.slice(0, -5) || '/'
158 let dir = (await $.session.cwd()).replace(/\/+$/, '')
159 while (dir !== '') {
160 if (await has(dir)) return dir
161 dir = dir.slice(0, dir.lastIndexOf('/'))
162 }
163 return (await has('/')) ? '/' : null
164}
165
166type LedgerCache = { key: string; rows: Map<string, LedgerRow> }
167
168/** The ledger's rows, read again only when its size or mtime changed. */
169async function readLedger($: EngineInterface, path: string, last: LedgerCache): Promise<LedgerCache> {
170 const stat = await $.fs.stat(path).catch(() => undefined)
171 if (stat === undefined || stat.kind !== 'file') return { key: '', rows: new Map() }
172 const key = `${stat.size}:${stat.mtimeMs}`
173 if (key === last.key) return last
174 const html = await $.fs.read(path).then(String).catch(() => undefined)
175 return html === undefined ? { key: '', rows: new Map() } : { key, rows: parseLedger(html) }
176}
177
178/** What readRuns keeps between calls: the settled runs' facts and the ledger as last read. */
179export type RunCache = { settled: Map<string, FolderFacts>; ledger: LedgerCache }
180
181export const newRunCache = (): RunCache => ({ settled: new Map(), ledger: { key: '', rows: new Map() } })
182
183/**
184 * A project's runs, one row per run folder. A folder is read again on every call while its
185 * run is open; once it has an exit file or a closed ledger row its facts are kept in `cache`,
186 * and dropped when the folder is gone (swept).
187 */
188export async function readRuns($: EngineInterface, project: string, cache: RunCache): Promise<RunRow[]> {
189 cache.ledger = await readLedger($, `${project}/${LEDGER_FILE}`, cache.ledger)
190 const dir = `${project}/${RUNS_DIR}`
191 const entries = await $.fs.list(dir).catch(() => [])
192 const ids = entries.filter(one => one.kind === 'dir' && !one.isLink && RUN_ID.test(one.name)).map(one => one.name)
193 for (const id of cache.settled.keys()) if (!ids.includes(id)) cache.settled.delete(id)
194 const rows: RunRow[] = []
195 for (const id of ids) {
196 const facts = cache.settled.get(id) ?? (await readFolder($, `${dir}/${id}`).catch(() => undefined))
197 if (facts === undefined) continue
198 const row = rowOf(id, facts, cache.ledger.rows.get(id))
199 if (row.state === 'ended') cache.settled.set(id, facts)
200 rows.push(row)
201 }
202 return rows
203}
204hooks/swap.ts 43 lines1// Conversation swaps: /clear, /resume and /branch keep the process but move it to another
2// session id, and no session.start fires for the new conversation. classic.SessionStart says
3// so, but a managed (prepend-tier) plugin may continue classic.* events past every user-tier
4// plugin, so it cannot be the only path. The swap is also seen as a changed `$.session.id()`
5// (after session.end with `clear` or `resume`, and on the band's 1 s tick). Every path goes
6// through one tracker, so a swap rehydrates once however many of them see it. The tracker is
7// plain state; register.tsx reads the id and rehydrates.
8
9export type SwapState = {
10 /** The session id last seen; undefined before the first. */
11 seen?: string
12 /** The id the id check last rehydrated for, until classic.SessionStart for it consumes it. */
13 swappedTo?: string
14}
15
16/** session.start: the id the session started under; nothing to rehydrate. */
17export function swapStarted(s: SwapState, id: string | undefined): void {
18 if (id !== undefined) s.seen = id
19 s.swappedTo = undefined
20}
21
22/** An id check: true (rehydrate) when the id moved since it was last seen. */
23export function swapSeen(s: SwapState, id: string | undefined): boolean {
24 if (id === undefined || id === s.seen) return false
25 const isFirst = s.seen === undefined
26 s.seen = id
27 if (isFirst) return false
28 s.swappedTo = id
29 return true
30}
31
32/**
33 * classic.SessionStart (not `startup`): true (rehydrate) unless the id check already did for
34 * this very id, which it then forgets, so a later SessionStart on that id (a compact) still does.
35 */
36export function swapClassic(s: SwapState, id: string | undefined): boolean {
37 const isDone = id !== undefined && id === s.swappedTo
38 s.swappedTo = undefined
39 if (isDone) return false
40 if (id !== undefined) s.seen = id
41 return true
42}
43hooks/model.ts 406 lines1import type { AgentRow, Limit, PromptInfo, RunRow } from '../types'
2
3const ITEM = /\b(?:feat|bug|spk|trk)-[0-9a-f]{6,}\b/
4const WIPNOTE = /\bwipnote\s+(?:feature|bug|spike|track)\s+(start|complete)\s+((?:feat|bug|spk|trk)-[0-9a-f]+)/g
5
6/** The first wipnote work item id a subagent's brief names. */
7export function workItemIn(text: string): string | undefined {
8 return ITEM.exec(text)?.[0]
9}
10
11/** Applies every `wipnote <kind> start|complete <id>` in a shell command. */
12export function applyWipnote(items: readonly string[], command: string): string[] {
13 let next = [...items]
14 for (const [, verb, id] of command.matchAll(WIPNOTE)) {
15 if (id === undefined) continue
16 next = next.filter(one => one !== id)
17 if (verb === 'start') next.push(id)
18 }
19 return next
20}
21
22/** `## main...origin/main [ahead 1, behind 2]` plus one line per change. */
23export function parseGitStatus(out: string): Omit<PromptInfo, 'dir'> {
24 const [head = '', ...rest] = out.split('\n')
25 const branch = /^## (?:No commits yet on )?([^.\s]+)/.exec(head)?.[1]
26 return {
27 branch: branch === 'HEAD' ? 'detached' : branch,
28 dirty: rest.filter(line => line.trim() !== '').length,
29 ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
30 behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0),
31 }
32}
33
34/** The repo or worktree's folder name, from `git rev-parse --show-toplevel`. */
35export function baseName(path: string): string {
36 return path.replace(/\/+$/, '').split('/').pop() || path
37}
38
39/** Outside git: p10k-style dir, home as ~, at most the last three parts. */
40export function shortDir(cwd: string, home?: string): string {
41 const path = home !== undefined && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd
42 const parts = path.split('/').filter(Boolean)
43 if (parts.length <= 3) return path
44 return `…/${parts.slice(-3).join('/')}`
45}
46
47/** One short phrase for what a tool call is doing. */
48export function describeCall(tool: string, input: Record<string, unknown>): string {
49 const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
50 const base = (path: string) => path.split('/').pop() ?? path
51 switch (tool) {
52 case 'Bash':
53 return `$ ${str('command').split('\n')[0]}`
54 case 'Read':
55 case 'Edit':
56 case 'Write':
57 case 'NotebookEdit':
58 return `${tool} ${base(str('file_path') || str('notebook_path'))}`
59 case 'Grep':
60 case 'Glob':
61 return `${tool} ${str('pattern')}`
62 case 'WebFetch':
63 return `fetch ${str('url')}`
64 case 'WebSearch':
65 return `search ${str('query')}`
66 case 'Agent':
67 return `spawn ${str('subagent_type') || 'agent'}: ${str('description')}`
68 default:
69 return tool.startsWith('mcp__') ? tool.split('__').slice(1).join(':') : tool
70 }
71}
72
73/** 9s, 4m05s, 1h12m. */
74export function elapsed(ms: number): string {
75 const s = Math.max(0, Math.floor(ms / 1000))
76 if (s < 60) return `${s}s`
77 const m = Math.floor(s / 60)
78 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
79 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
80}
81
82export const isLive = (status: string) =>
83 status === 'running' || status === 'pending' || status === 'waiting' || status === 'idle'
84
85/** `claude-opus-5-5[1m]` → `Opus 5.5 1M`; anything unrecognized passes through. */
86export function modelLabel(id: string): string {
87 const wide = /\[1m\]$/i.test(id) ? ' 1M' : ''
88 const bare = id.replace(/\[[^\]]*\]$/, '').replace(/^(?:[a-z]+\.)?anthropic\./, '')
89 const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8}|@\d{8})?(?:-v\d.*)?$/.exec(bare)
90 if (m === null) return id
91 const [, family = '', major, minor] = m
92 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor !== undefined ? `.${minor}` : ''}${wide}`
93}
94
95/** A run of styled text in a line the band draws as one Text. */
96export type Span = {
97 text: string
98 color?: string
99 bg?: string
100 bold?: boolean
101 dim?: boolean
102 italic?: boolean
103}
104
105const cells = (text: string) => [...text].length
106
107/** Cuts a line of spans to `width` cells, ending a cut span with `…`, so it never wraps. */
108export function fit(spans: readonly Span[], width: number): Span[] {
109 const out: Span[] = []
110 let left = Math.max(0, width)
111 for (const span of spans) {
112 if (left <= 0) break
113 const chars = [...span.text]
114 if (chars.length <= left) {
115 out.push(span)
116 left -= chars.length
117 continue
118 }
119 out.push({ ...span, text: left > 1 ? `${chars.slice(0, left - 1).join('')}…` : '…' })
120 break
121 }
122 return out
123}
124
125/** A running agent with no tool call for this long is flagged as stalled. */
126export const STALL_MS = 5 * 60_000
127/** A failed or stopped agent stays flagged this long (Claude Code's own panel drops it after 30 s). */
128export const FAILED_KEEP_MS = 10 * 60_000
129
130const isFailed = (status: string) => status === 'failed' || status === 'killed'
131
132/** How long a finished agent stays in view: FAILED_KEEP_MS when it failed or was stopped, else `keepMs`. */
133export const keepFor = (status: string, keepMs: number) => (isFailed(status) ? FAILED_KEEP_MS : keepMs)
134
135/** The agents on one work item: how many are in each state. */
136export type ItemGroup = { item: string; running: number; waiting: number; done: number; failed: number }
137
138/**
139 * The agents in view grouped by the work item their brief names, in the order each item's first
140 * agent started: live ones, and finished ones for `keepMs` (failed ones for FAILED_KEEP_MS).
141 * Agents on no work item are left out: Claude Code's own panel lists every agent.
142 */
143export function groupByItem(rows: readonly AgentRow[], now: number, keepMs: number): ItemGroup[] {
144 const groups = new Map<string, ItemGroup>()
145 for (const row of [...rows].sort((a, b) => a.startedAt - b.startedAt)) {
146 if (row.workItem === undefined) continue
147 const ended = now - (row.endedAt ?? now)
148 if (!isLive(row.status) && ended >= keepFor(row.status, keepMs)) continue
149 const group = groups.get(row.workItem) ?? { item: row.workItem, running: 0, waiting: 0, done: 0, failed: 0 }
150 if (isFailed(row.status)) group.failed += 1
151 else if (row.status === 'waiting') group.waiting += 1
152 else if (isLive(row.status)) group.running += 1
153 else group.done += 1
154 groups.set(row.workItem, group)
155 }
156 return [...groups.values()]
157}
158
159/**
160 * An agent that needs a look: running and quiet for STALL_MS (on one long call, or between calls),
161 * or failed/stopped recently.
162 */
163export type Attention = { row: AgentRow; why: 'long-call' | 'stalled' | 'failed' | 'stopped'; since: number }
164
165/** The agents to flag, quiet ones first (longest quiet first), then failures (newest first). */
166export function needsAttention(rows: readonly AgentRow[], now: number): Attention[] {
167 const stalled = rows
168 .filter(row => row.status === 'running' && now - (row.lastAt ?? row.startedAt) >= STALL_MS)
169 .map(row => ({ row, why: (row.inFlight ?? 0) > 0 ? ('long-call' as const) : ('stalled' as const), since: row.lastAt ?? row.startedAt }))
170 .sort((a, b) => a.since - b.since)
171 const failed = rows
172 .filter(row => isFailed(row.status) && now - (row.endedAt ?? now) < FAILED_KEEP_MS)
173 .map(row => ({ row, why: row.status === 'killed' ? ('stopped' as const) : ('failed' as const), since: row.endedAt ?? now }))
174 .sort((a, b) => b.since - a.since)
175 return [...stalled, ...failed]
176}
177
178/** One work item's row: its title and step progress, then its agents by state. */
179export function groupSpans(group: ItemGroup, title: string, steps: string): Span[] {
180 const spans: Span[] = [{ text: ' ▸ ', dim: true }, { text: title, color: '#af87ff', bold: true }]
181 if (steps !== '') spans.push({ text: ` ${steps}`, dim: true })
182 const counts: [number, string, string][] = [
183 [group.running, '● {n} running', '#5fd7ff'],
184 [group.waiting, '◐ {n} waiting', '#ffaf00'],
185 [group.done, '✔ {n} done', '#5faf00'],
186 [group.failed, '✘ {n} failed', '#d70000'],
187 ]
188 spans.push({ text: ' ' })
189 for (const [n, label, color] of counts) if (n > 0) spans.push({ text: ` ${label.replace('{n}', String(n))}`, color })
190 return spans
191}
192
193/** One flagged agent's row: why, who, what it was doing, and for how long. */
194export function attentionSpans(flag: Attention, now: number): Span[] {
195 const { row } = flag
196 const took = elapsed(now - flag.since)
197 const looks: Record<Attention['why'], [string, string, string]> = {
198 stalled: ['⚠', '#ffaf00', `no tool call for ${took}`],
199 'long-call': ['◷', '#d7af00', `one call running for ${took}`],
200 stopped: ['✘', '#d75f00', `stopped ${took} ago`],
201 failed: ['✘', '#d70000', `failed ${took} ago`],
202 }
203 const [mark, color, said] = looks[flag.why]
204 const spans: Span[] = [
205 { text: ` ${mark} `, color },
206 { text: row.type, color, bold: true },
207 { text: ` ${said}`, color },
208 { text: ` ${row.description}`, dim: true },
209 ]
210 const isQuiet = flag.why === 'stalled' || flag.why === 'long-call'
211 if (isQuiet && row.lastTool !== undefined) spans.push({ text: ` ↳ ${row.lastTool}`, dim: true, italic: true })
212 return spans
213}
214
215/** ` ✔ 12 ✘ 1 `: every agent this session has seen finish, by outcome; empty before the first. */
216export function tallyText(tally: { done: number; failed: number }): string {
217 if (tally.done + tally.failed === 0) return ''
218 return `✔ ${tally.done}${tally.failed > 0 ? ` ✘ ${tally.failed}` : ''}`
219}
220
221const PILLS: Record<string, string> = {
222 auto: '⏵⏵ auto mode on',
223 acceptEdits: '⏵⏵ accept edits on',
224 plan: '⏸ plan mode on',
225 bypassPermissions: '⏵⏵ bypass permissions on',
226 dontAsk: "⏵⏵ don't ask on",
227}
228
229/**
230 * Cells the engine's mode pill and its ` · ` take left of the hint line, which is where a
231 * tree drawn there starts. The pill shows beside a `(shift+tab …)` hint, and also beside
232 * other hints (`1 shell · ← 6 agents`) whenever a known non-default mode is on; the idle
233 * `? for shortcuts` hint has no pill.
234 */
235export function pillIndent(permissionMode: string | null, hint: string): number {
236 const known = permissionMode !== null ? PILLS[permissionMode] : undefined
237 if (!hint.startsWith('(') && (known === undefined || hint.startsWith('?'))) return 0
238 const pill = known ?? PILLS.auto ?? ''
239 return [...pill].length + 3
240}
241
242/** One powerline segment: its text with a cell of padding each side, and its colors. */
243export type Segment = { text: string; bg: string; fg: string }
244
245export const CAP_LEFT = '\uE0B6'
246export const CAP_RIGHT = '\uE0B4'
247export const ARROW = '\uE0B0'
248
249/**
250 * Lays segments into as few lines as `width` allows, breaking only between segments. A line is
251 * `CAP_LEFT seg ARROW seg … CAP_RIGHT`, so it costs its texts plus one cell per segment and one more
252 * for the left cap. A segment wider than a line on its own is cut to fit with `…`.
253 */
254export function wrapSegments(segs: readonly Segment[], width: number): Segment[][] {
255 const room = Math.max(4, width)
256 const lines: Segment[][] = []
257 let line: Segment[] = []
258 let used = 1
259 for (const seg of segs) {
260 const most = room - 2
261 const text = cells(seg.text) > most ? `${[...seg.text].slice(0, most - 2).join('')}… ` : seg.text
262 const cost = cells(text) + 1
263 if (line.length > 0 && used + cost > room) {
264 lines.push(line)
265 line = []
266 used = 1
267 }
268 line.push({ ...seg, text })
269 used += cost
270 }
271 if (line.length > 0) lines.push(line)
272 return lines
273}
274
275/** The spans of one wrapped line: rounded caps at its ends, arrows between its segments. */
276export function lineSpans(line: readonly Segment[]): Span[] {
277 const first = line[0]
278 if (first === undefined) return []
279 const spans: Span[] = [{ text: CAP_LEFT, color: first.bg }]
280 line.forEach((seg, i) => {
281 const next = line[i + 1]
282 spans.push({ text: seg.text, bg: seg.bg, color: seg.fg, bold: true })
283 spans.push(next === undefined ? { text: CAP_RIGHT, color: seg.bg } : { text: ARROW, color: seg.bg, bg: next.bg })
284 })
285 return spans
286}
287
288/** The default zone, and the manifest's `timeZone` default: the container runs on UTC, the person on US Eastern (DST included). */
289export const TIME_ZONE = 'America/New_York'
290
291/** The `timeZone` option when Intl knows it, else TIME_ZONE, so a typo cannot throw while drawing. */
292export function zoneOf(option: unknown): string {
293 if (typeof option !== 'string' || option.trim() === '') return TIME_ZONE
294 try {
295 new Intl.DateTimeFormat('en-US', { timeZone: option.trim() })
296 return option.trim()
297 } catch {
298 return TIME_ZONE
299 }
300}
301
302/** `6:28 PM` in `zone`, the 12-hour form the person's prompt uses. */
303export function localTime(ms: number, zone: string = TIME_ZONE): string {
304 return new Intl.DateTimeFormat('en-US', { timeZone: zone, hour: 'numeric', minute: '2-digit', hour12: true }).format(ms)
305}
306
307/** `Tue 20 · 6:28 PM` in `zone`: the weekday, the day of the month and the time. */
308export function clockText(ms: number, zone: string = TIME_ZONE): string {
309 const day = new Intl.DateTimeFormat('en-US', { timeZone: zone, weekday: 'short', day: 'numeric' }).formatToParts(ms)
310 const part = (type: string) => day.find(one => one.type === type)?.value ?? ''
311 return `${part('weekday')} ${part('day')} · ${localTime(ms, zone)}`
312}
313
314const KINDS: Record<string, string> = { feat: 'feature', bug: 'bug', spk: 'spike' }
315
316/** The `wipnote <kind> show` argv for a work item id, or undefined for an id of no known kind. */
317export function showArgv(id: string): string[] | undefined {
318 const kind = KINDS[id.split('-')[0] ?? '']
319 return kind === undefined ? undefined : ['wipnote', kind, 'show', id, '--format', 'json']
320}
321
322/** A work item's title, status, track and step progress from `wipnote <kind> show --format json`. */
323export function parseItemJson(
324 text: string,
325): { title: string; status: string; trackId?: string; stepsDone?: number; stepsTotal?: number } | undefined {
326 try {
327 const item = JSON.parse(text) as {
328 title?: unknown
329 status?: unknown
330 steps?: unknown
331 edges?: { part_of?: { target_id?: unknown }[] }
332 }
333 if (typeof item.title !== 'string') return undefined
334 const track = item.edges?.part_of?.map(edge => edge.target_id).find(id => typeof id === 'string' && id.startsWith('trk-'))
335 const steps = Array.isArray(item.steps) ? (item.steps as { completed?: unknown }[]) : []
336 return {
337 title: item.title,
338 status: String(item.status ?? ''),
339 trackId: track as string | undefined,
340 ...(steps.length > 0 ? { stepsDone: steps.filter(step => step.completed === true).length, stepsTotal: steps.length } : {}),
341 }
342 } catch {
343 return undefined
344 }
345}
346
347/** A track's title and `(1/46 done)` progress from `wipnote track show` (its text form; the JSON has no counts). */
348export function parseTrackText(text: string): { title: string; done: number; total: number } | undefined {
349 const lines = text.split('\n').map(line => line.trim())
350 const title = lines.find(line => line !== '' && !/^[─-]+$/.test(line))
351 const progress = /\((\d+)\/(\d+) done\)/.exec(text)
352 if (title === undefined || progress === null) return undefined
353 return { title, done: Number(progress[1]), total: Number(progress[2]) }
354}
355
356/** `text` cut to `max` cells with `…`. */
357export function clip(text: string, max: number): string {
358 return cells(text) > max ? `${[...text].slice(0, Math.max(1, max - 1)).join('')}…` : text
359}
360
361const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
362
363/** When a window resets, in the person's zone: the time today, else the weekday and time. */
364export function resetLabel(iso: string, now: number, zone: string = TIME_ZONE): string {
365 const at = Date.parse(iso)
366 if (Number.isNaN(at)) return ''
367 const time = localTime(at, zone)
368 if (at - now < 20 * 3600_000) return time
369 const day = new Intl.DateTimeFormat('en-US', { timeZone: zone, weekday: 'short' }).format(at)
370 return `${day} ${time}`
371}
372
373/** The usage-limits row: each window's used share, colored by how close it is, and when it resets. */
374export function limitSpans(limits: readonly Limit[], now: number, zone: string = TIME_ZONE): Span[] {
375 if (limits.length === 0) return []
376 const spans: Span[] = [{ text: ' \uF0E4 ', dim: true }]
377 limits.forEach((limit, i) => {
378 const pct = Math.round(limit.percentUsed)
379 const color = pct >= 80 ? '#d70000' : pct >= 50 ? '#d7af00' : '#5faf00'
380 const reset = limit.resetsAt === undefined ? '' : resetLabel(limit.resetsAt, now, zone)
381 if (i > 0) spans.push({ text: ' ' })
382 spans.push({ text: `${LIMIT_LABELS[limit.kind] ?? limit.kind} `, dim: true })
383 spans.push({ text: `${pct}%`, color, bold: true })
384 if (reset !== '') spans.push({ text: ` resets ${reset}`, dim: true })
385 })
386 return spans
387}
388
389/** A delegate run's state as the band says it: live, or ended and how. */
390function runState(row: RunRow): [string, string] {
391 if (row.state === 'live') return ['● live', '#5fd7ff']
392 if (row.exit === undefined) return [`■ ended${row.reason ? ` (${row.reason})` : ''}`, '#d75f00']
393 return row.exit === 0 ? ['✔ ended', '#5faf00'] : [`✘ ended exit ${row.exit}`, '#d70000']
394}
395
396/** One delegate run's row: its name or label, harness and mode, state, and the first line of its last.md. */
397export function runSpans(row: RunRow): Span[] {
398 const [state, color] = runState(row)
399 const spans: Span[] = [{ text: ' ⇄ ', color: '#87afd7' }, { text: clip(row.title, 40), color: '#87afd7', bold: true }]
400 const meta = [row.harness, row.mode].filter(Boolean).join(' · ')
401 if (meta !== '') spans.push({ text: ` ${meta}`, dim: true })
402 spans.push({ text: ` ${state}`, color })
403 if (row.answer !== '') spans.push({ text: ` ${row.answer}`, dim: true, italic: true })
404 return spans
405}
406types/index.d.ts 115 lines1export type AgentRow = {
2 id: string
3 type: string
4 description: string
5 workItem?: string
6 model?: string
7 status: string
8 startedAt: number
9 endedAt?: number
10 tools: number
11 lastTool?: string
12 /** When a tool call of its last started or ended (ms): a running agent quiet for long is flagged. */
13 lastAt?: number
14 /** Its tool calls still running: quiet with one running reads as a long call, with none as a stall. */
15 inFlight?: number
16 /** `$.agent.list()` has shown it: its leaving the list means it ended. */
17 listed?: boolean
18 /** Counted in the session tally (once per agent). */
19 counted?: boolean
20}
21
22/** Every agent the session has seen finish, by outcome. */
23export type Tally = { done: number; failed: number }
24
25export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
26
27export type PromptInfo = {
28 dir: string
29 branch?: string
30 dirty: number
31 ahead: number
32 behind: number
33 model?: string
34 ctxPercent?: number
35 limits?: Limit[]
36}
37
38export type WorkCard = {
39 id: string
40 title: string
41 status: string
42 trackId?: string
43 trackTitle?: string
44 done?: number
45 total?: number
46 stepsDone?: number
47 stepsTotal?: number
48}
49
50/** Why a card lookup failed: the item is absent, wipnote could not run, or another error. */
51export type FailKind = 'missing' | 'unavailable' | 'error'
52
53export type Step = { description: string; completed: boolean }
54
55/** One work item as `wipnote <kind> show <id> --format json` reports it, for a wipnote-ref card. */
56export type Card = {
57 id: string
58 title: string
59 status: string
60 type: string
61 steps: Step[]
62}
63
64/**
65 * A card's fetch state: loaded, still loading, or failed with a reason.
66 * `at` is when the entry was written (ms), so a stale one is fetched again.
67 */
68export type CardEntry =
69 | { state: 'loaded'; card: Card; at: number }
70 | { state: 'loading'; at: number }
71 | { state: 'failed'; reason: FailKind; at: number }
72
73export type Mode = 'full' | 'compact' | 'off'
74export type Placement = 'above' | 'below'
75
76declare module 'claude-code' {
77 interface PluginState {
78 'wipnote-orchestra': {
79 prompt: PromptInfo | null
80 agents: AgentRow[]
81 items: string[]
82 cards: WorkCard[]
83 mainTool: string | null
84 now: number
85 mode: Mode
86 placement: Placement
87 permissionMode: string | null
88 refs: Record<string, CardEntry>
89 claim: string | null
90 tally: Tally
91 runs: RunRow[]
92 }
93 }
94}
95/** One delegate run (wipnote delegate) as the band shows it: one row per run, never per pane. */
96export type RunRow = {
97 /** The run id, dlg-<hex>: its folder under .wipnote/delegate/runs/. */
98 id: string
99 /** The ledger's name (interactive) or explicit label, else the folder's label, else the id. */
100 title: string
101 /** claude, claude-print, codex, codex-review or ccx; empty with no ledger row. */
102 harness: string
103 /** oneshot or interactive; empty with no ledger row. */
104 mode: string
105 /** Ended once the folder has an exit file or the ledger row is closed. */
106 state: 'live' | 'ended'
107 exit?: number
108 /** The ledger's end reason for a run that ended with no exit code (stop, pane_closed, gone). */
109 reason?: string
110 startedAt?: number
111 endedAt?: number
112 /** The first line of last.md, or empty. */
113 answer: string
114}
115