SLOPSHOPPER

wipnote-orchestra

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…

bandspinnerrowsguardcommandprocess
★ 4v0.2.1MITupdated 2026-10-26shakestzd/wipnote/mods/wipnote-orchestra
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wipnote-orchestra
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /orchestra ⎿ wipnote-orchestra: Orchestra band: off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

wipnote-orchestra (dev mod)

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

What it shows

RowContentSource
HintClaude Code's hint line exactly as it draws it (the mode pill stays beside it)PromptHint props
Limitseach 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
Segmentsrepo/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 looka 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 runsone 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 itemsone 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 integration

  • Active work items: the session's claim from 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.
  • Titles and progress: every 5 s, 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.
  • Delegate runs: every second the band lists .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.
  • The band and the cards never write to wipnote: the run reader only lists, stats and reads.

Cards (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.

  • Fetches: when a reply with a fence is stored, right after a Bash call that runs 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.
  • One lookup per item: an item that is also in the band takes its card from the band's 5-second refresh.
  • Failures read as 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.

Commands

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

Files

.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

Run it

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.

Check it

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.

Settings

OptionDefaultEffect
timeZoneAmerica/New_YorkIANA 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).

Known limits

  • Mode pill alignment: Claude Code draws the permission-mode pill beside the mod's tree, so the band rows are shifted left by the pill's width, computed from 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.
  • Usage limits appear only with a subscription sign-in (an API key reports no windows) and after the first API response.
  • Glyphs need a Nerd Font: powerline arrow and caps U+E0B0, U+E0B4, U+E0B6; branch U+E0A0; clock U+F017; gauge U+F0E4. The example above shows them as spaces. Write them as \u escapes in code, never literal: a byte-level edit tool once double-encoded the file and the engine refused the tree.
  • Colors: hex colors fall back to the 16 ANSI colors when the terminal advertises none (TERM=xterm, no COLORTERM); set COLORTERM=truecolor.
  • State keys are under the plugin name 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.
Source 8 files
hooks/register.tsx 450 lines
1import { 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}
450
hooks/band.tsx 163 lines
1import 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}
163
hooks/refcards.tsx 200 lines
1// 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}
200
hooks/refs.ts 181 lines
1// 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}
181
hooks/runs.ts 204 lines
1import 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}
204
hooks/swap.ts 43 lines
1// 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}
43
hooks/model.ts 406 lines
1import 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}
406
types/index.d.ts 115 lines
1export 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