SLOPSHOPPER

grimoire

A spellbook of agent skills for AI. Cast wisely. Four skills: eagle-eye, which lays coupled decisions out as a morphological box and renders one self-contained…

newpaneguardcommandtoasttool
★ 1v0.40.0MITupdated 2026-10-09mephistopheles4/grimoire
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grimoire
│ ┃ Brigade ✕ › fix the failing auth test and add an audit log call │ ┃ Roster not named yet │ ┃ the session id is not the engine's shape, so ⏺ Read(src/auth.ts) │ ┃ no roster file is named. Nothing was read. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Waiting on you 0 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ Nothing waits on you. │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Sessions 0 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ No reports since the pane opened. › /brigade │ ┃ ⎿ grimoire: Brigade pane opened. The pane cannot name the roster f │ ┃ Usage │ ┃ Context ██████████░░░░░░░░░░ 49% 97k │ ┃ Current session ██████░░░░░░░░░░░░░░ 31% │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Brigade
Roster not named yet the session id is not the engine's shape, so no roster file is named. Nothing was read. Waiting on you 0 ╭──────────────────────────────────────────────────────────╮ │ Nothing waits on you. │ ╰──────────────────────────────────────────────────────────╯ Sessions 0 No reports since the pane opened. Usage Context ██████████░░░░░░░░░░ 49% 97k of 200k Current session ██████░░░░░░░░░░░░░░ 31%
README

<img src="docs/brand/grimoire/grimoire-mark.svg" width="128" alt="grimoire mark">

<h1 align="center">G R I M O I R E</h1>

<a href="skills/contract"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/brand/contract/contract-sigil-dark.svg"><img src="docs/brand/contract/contract-sigil-light.svg" width="112" alt="contract"></picture></a> &nbsp;&nbsp;&nbsp;&nbsp; <a href="skills/head-chef"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/brand/head-chef/head-chef-sigil-dark.svg"><img src="docs/brand/head-chef/head-chef-sigil-light.svg" width="112" alt="head-chef"></picture></a> &nbsp;&nbsp;&nbsp;&nbsp; <a href="skills/eagle-eye"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/brand/eagle-eye/eagle-eye-sigil-dark.svg"><img src="docs/brand/eagle-eye/eagle-eye-sigil-light.svg" width="112" alt="eagle-eye"></picture></a> &nbsp;&nbsp;&nbsp;&nbsp; <a href="skills/groundtrack"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/brand/groundtrack/groundtrack-sigil-dark.svg"><img src="docs/brand/groundtrack/groundtrack-sigil-light.svg" width="112" alt="groundtrack"></picture></a>


A skill is a folder of instructions your coding agent reads when the moment calls for it — a reference book it knows when to open. Grimoire holds four. contract helps you build a skill or an agent of your own. head-chef lets one Claude Desktop session start and lead others, and comes with a pane that shows them. eagle-eye and groundtrack do the same kind of work: they take something you can only hold in your head and put it on a page you can look at.

[See one before you install anything.][gallery] The gallery holds every page eagle-eye and groundtrack have drawn. Start with [a live decision grid][eagle-demo] about whether to publish this repository. Click an option and watch it recolour. eagle-eye wrote it, about itself.

Install

Four skills today, more later. Works with any agent:

npx skills@latest add mephistopheles4/grimoire

That is skills, which installs into Claude Code, Cursor, Codex, Gemini CLI, Copilot, Windsurf, Zed, opencode, Amp and around seventy more. It copies the whole skill directory, renderer included.

The Brigade pane needs grimoire installed as a Claude Code plugin. The command above copies skill folders only, so it gives you head-chef but not /brigade. To get both, run:

claude plugin marketplace add mephistopheles4/grimoire
claude plugin install grimoire@mephistopheles4

As a Claude Code plugin, if you would rather the marketplace handled updates:

/plugin marketplace add mephistopheles4/grimoire
/plugin install grimoire@mephistopheles4

Plugin skills are namespaced, so that route invokes them as /grimoire:eagle-eye, /grimoire:groundtrack, /grimoire:contract and /grimoire:head-chef. The installer route keeps the plain /eagle-eye, /groundtrack, /contract and /head-chef.

By hand, if you want neither installer:

git clone https://github.com/mephistopheles4/grimoire.git
cp -r grimoire/skills/eagle-eye ~/.claude/skills/
cp -r grimoire/skills/groundtrack ~/.claude/skills/
cp -r grimoire/skills/contract ~/.claude/skills/
cp -r grimoire/skills/head-chef ~/.claude/skills/

Pinned to one commit, if you want a copy that only changes when you choose. Use the full 40-character commit SHA:

npx skills@latest add mephistopheles4/grimoire#<commit-sha>

No skill names a fixed path to its own scripts, so each runs from wherever it lands. eagle-eye has been run from three directories: the author's skills folder, the plugin install, and a copy made by skills.

Every route gives you all four skill directories, each with its SKILL.md and the scripts that go with it. Only the plugin route also gives you the Brigade pane. Each skill carries a README of its own, which is where it is documented; contract and head-chef also carry the CONTRACT.md their SKILL.md is generated from. What follows is only enough to tell you which one you want.


<img src="docs/brand/contract/contract-mark-solid.svg" width="32" align="absmiddle" alt=""> contract

Agree the terms first. The prompt is build output.

Reach for it when you want a skill or an agent of your own and have never written one. contract interviews you through a template, one group of questions at a time, and writes your answers down as a contract. It calls what you build a familiar: a skill or an agent that does one job for you.

It then generates the familiar's file from the contract and checks its format. A mark in the file makes the check fail when anyone edits the file by hand. When a familiar goes wrong, you amend the contract and generate the file again. It never installs what it builds. Not for a one-off prompt.

The questions, the levels and the marks: skills/contract/references/template.md. The skill's own contract, which its SKILL.md is generated from: skills/contract/CONTRACT.md.

<img src="docs/brand/head-chef/head-chef-mark-solid.svg" width="32" align="absmiddle" alt=""> head-chef

Lead the sessions; let them do the work.

Reach for it in Claude Desktop when you want work to run in another session, or want to lead several at once. head-chef starts each session in the background, or as a Desktop session you work in, with the model and effort set. It points the session to where its brief lives, takes its milestone reports, relays between sessions, and cleans a session up when you say it is done. It takes the when and the why from your own process, and it never counts a message from another session as your yes. A session can take your answer to its own question through the lead, only as one of the choices it offered. Not for work in the same session.

/brigade opens the Brigade pane: one card per session the lead started, with its work, phase, settings, live busy or idle state, cache warmth and latest report. A card that waits on you and holds a large context says when to reply to keep its cache, or suggests a hand-off once it has gone cold. At its bottom it shows the lead session's own rate limits and context. It needs the plugin install above. The skill works without it.

What it does, what counts as your yes, and how cleanup refuses: skills/head-chef/README.md. The skill's contract, which its SKILL.md is generated from: skills/head-chef/CONTRACT.md.

<img src="docs/brand/eagle-eye/eagle-eye-mark-solid.svg" width="32" align="absmiddle" alt=""> eagle-eye

A decision is made once it has been seen against the whole system.

Reach for it when three or more decisions are open and one choice changes what is possible in another: picking the cheap database changes what the deployment can be, which changes who can be on call. Asked one at a time, those questions hide the thing you need to see.

eagle-eye draws a morphological box instead — one row per decision, one cell per option, an edge wherever two options rule each other out or require each other — then renders a page that reads any configuration back. Not for two independent choices.

Live: [the decision to publish this repository][eagle-demo].

The seven findings, why every edge carries an evidence tier, and the renderer's flags: skills/eagle-eye/README.md.

<img src="docs/brand/groundtrack/groundtrack-mark-solid.svg" width="32" align="absmiddle" alt=""> groundtrack

A reader who did not write a change cannot see its shape.

Reach for it when a plan is made or the work is done and someone else has to understand it. A change arrives as a list of files. A plan arrives as a list of tickets. Neither says what calls what, what each part hands back, where it can break, or what it needs in order to work.

groundtrack writes one call graph with recorded traces through it, renders a self-contained page you can step a cursor across, and prints the same graph as an indented tree on request. Not for a conversation, because nothing durable exists to check the graph against.

Live: [a pull request, stepped through][track-demo]. For a complex sheet, see [a larger pull request with 88 nodes][track-complex]. Every published page is in [the gallery][gallery].

The three channels, what a layer redraws, the page's controls, and the honesty property's stated limit: skills/groundtrack/README.md.


The marks, the cards and the tokens behind them are in docs/brand/.

The repository is the plugin. .claude-plugin/plugin.json names it grimoire; .claude-plugin/marketplace.json is the shelf that lists it with "source": "./". Skills sit at skills/<name>/, which is the one level the default scan reads and the layout the skills installer finds first.

The two manifests carry different names on purpose: the shelf is mephistopheles4, the book is grimoire. The version lives in plugin.json and nowhere else, because a second copy is a second place to forget.

This shape follows mattpocock/skills, which ships a marketplace manifest and a plugin manifest side by side at the root. The Claude Code docs describe each separately and never that pairing, so the evidence it works is a repository that does it, plus claude plugin validate . passing here.

Contributing

CONTRIBUTING.md. The contract is one command:

node scripts/check.mjs

Security problems go through private reporting, not a public issue: SECURITY.md.

Licence

MIT. © 2026 Ayman Diab.

[eagle-demo]: https://mephistopheles4.github.io/grimoire/docs-decisions-publish-eagle-eye.html [track-demo]: https://mephistopheles4.github.io/grimoire/skills-groundtrack-examples-pr-313.html [track-complex]: https://mephistopheles4.github.io/grimoire/docs-examples-pr-382.html [gallery]: https://mephistopheles4.github.io/grimoire/

Source 4 files
brigade/register.tsx 969 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Card, Roster } from './types'
5import {
6  MAX_ROSTER_BYTES,
7  MAX_TEXT,
8  OPEN_NAME,
9  OPEN_SERVER,
10  OPEN_TARGET,
11  OPEN_TOOL,
12  OPEN_WAIT_MS,
13  SESSION_ID,
14  STATUSES,
15  appLink,
16  checkRoster,
17  configFromRoot,
18  configFromTranscript,
19  dataFolder,
20  dataId,
21  listsOpenTool,
22  lookup,
23  marketplaceFromRoot,
24  oneLine,
25  parseAgents,
26  placeRoster,
27  readOpenAnswer,
28  remoteLink,
29  report,
30  rosterFile,
31  rulesAllowTool,
32  serialize,
33  statRejection,
34  toolSession,
35  transcriptFile,
36} from './roster.ts'
37import type { StatAnswer } from './roster.ts'
38import {
39  EMPTY_USAGE,
40  idsFrom,
41  liveState,
42  readWarmth,
43  settleTicks,
44  snapshotFrom,
45  ticksFrom,
46  toggleTick,
47  usageView,
48  waitingCount,
49  warmthFrom,
50  warmthLine,
51} from './view.ts'
52import type { ReadMemory } from './view.ts'
53
54// The Brigade pane: one card per session a lead session started, each in a
55// rounded box with its work, phase, settings, live busy or idle state, cache
56// warmth and latest report; the owner's to-dos in one box, each ticked with a press that
57// a second press undoes; and at the bottom this session's own rate limits and
58// context. The pane reads the roster file while it is open. The head chef
59// fills it through the set_roster tool below, which writes the file for it
60// with no permission prompt; the head chef writes no file itself. The usage
61// section is worked out in view.ts, as plain values; this file reads and
62// stores the reading and turns the view's result into elements.
63//
64// Idle by default. At session start it registers /brigade and nothing else: no
65// timer, no process, no file read, no tool and no pane. The pane opens only on
66// /brigade, and closing it stops every timer. A message from another session
67// never opens it.
68
69const PANE = 'brigade'
70const TITLE = 'Brigade'
71const ROSTER_MS = 5000
72const AGENTS_MS = 10000
73const TOOL = 'set_roster'
74
75// The values are the plugin's, so they sit under its manifest name.
76const armed = atom({ plugin: 'grimoire', key: 'armed' } as const, false)
77const started = atom({ plugin: 'grimoire', key: 'start' } as const, { sessionId: '', transcript: '' })
78const files = atom({ plugin: 'grimoire', key: 'files' } as const, { current: '', previous: '' })
79const roster = atom({ plugin: 'grimoire', key: 'roster' } as const, { cards: [], todos: [] })
80const rosterError = atom({ plugin: 'grimoire', key: 'rosterError' } as const, '')
81const live = atom({ plugin: 'grimoire', key: 'live' } as const, [])
82const liveError = atom({ plugin: 'grimoire', key: 'liveError' } as const, '')
83const links = atom({ plugin: 'grimoire', key: 'links' } as const, [])
84const looked = atom({ plugin: 'grimoire', key: 'looked' } as const, [])
85const reports = atom({ plugin: 'grimoire', key: 'reports' } as const, [])
86const dismissed = atom({ plugin: 'grimoire', key: 'dismissed' } as const, [])
87const doneTodos = atom({ plugin: 'grimoire', key: 'doneTodos' } as const, [])
88const ticking = atom({ plugin: 'grimoire', key: 'ticking' } as const, [])
89const usage = atom({ plugin: 'grimoire', key: 'usage' } as const, EMPTY_USAGE)
90const warmth = atom({ plugin: 'grimoire', key: 'warmth' } as const, [])
91
92// Colours are the app's own theme keys, so the pane follows the person's
93// theme, light or dark, as the rest of Claude Code does. The live state's
94// colours are in view.ts.
95//
96// The status the head chef wrote on the card.
97const MARK = new Map<Card['status'], { mark: string; color: string }>([
98  ['needs-you', { mark: '●', color: 'warning' }],
99  ['working', { mark: '◐', color: 'suggestion' }],
100  ['done', { mark: '✓', color: 'success' }],
101  ['stopped', { mark: '○', color: 'inactive' }],
102])
103
104// A button's key: the verb, the card's place, and its title folded to letters
105// and digits, so a press finds the card it was drawn for or nothing.
106const slug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 40)
107const keyFor = (verb: string, i: number, title: string) => `${verb}-${i}-${slug(title)}`
108// A Button must carry onPress. The ui.press hook below answers every press
109// itself, so this bottom of the chain never runs.
110const noop = () => {}
111// A to-do's checkbox. A desktop draws a native button as wide as its label,
112// and folds a plain space away, so the blank at rest there is an en space and
113// a four-per-em space: 0.75 em, the width of the ✓ (0.749 em, measured in
114// Segoe UI's fallback), so the box keeps its size when ticked. The terminal
115// draws every one of them a cell wide, so it keeps one plain space. The slot
116// is as wide as the terminal's `[ ✓ ]`.
117const UNTICKED = '\u{2002}\u{2005}'
118const UNTICKED_TERMINAL = ' '
119const CHECK_CELLS = 5
120
121// The module's own timers. A reload starts the module over and the engine
122// drops the old timers with it.
123let timers: Timer[] = []
124
125// Where this lead session's roster file is, or why it cannot be named. The
126// roster lives in the plugin's data folder, built by the plugin-manifest
127// reference's rule from the config folder alone: <config>/plugins/data/<id>.
128// `CLAUDE_PLUGIN_DATA` is never read: a mod's environment usually lacks it,
129// and another plugin can set it. The config folder comes from where the plugin
130// was installed, else from the transcript path the engine reported at this
131// session's start; with neither, nothing is read or written. The config folder
132// also locates other sessions' transcripts.
133type Where = { file: string; config: string; plugin: string; id: string }
134async function where($: EngineInterface): Promise<Where | { error: string }> {
135  const id = await $.session.id()
136  if (!SESSION_ID.test(id)) return { error: 'the session id is not the engine\'s shape, so no roster file is named. Nothing was read.' }
137  const start = await read($, started)
138  const config =
139    configFromRoot($.plugin.root) ??
140    (start.sessionId === id ? configFromTranscript(start.transcript, id) : undefined)
141  const plugin = dataId($.plugin.name, marketplaceFromRoot($.plugin.root))
142  if (config === undefined) {
143    return {
144      error: `Cannot find the Claude config folder from where the plugin was loaded (${oneLine($.plugin.root, 200)}). The roster would be <Claude config folder>/plugins/data/${plugin}/brigade/${id}.json. Nothing was read.`,
145    }
146  }
147  return { file: rosterFile(dataFolder(config, plugin), id), config, plugin, id }
148}
149
150// One `stat` for the path rule in roster.ts: resolving, and a rejection
151// sorted into missing or failed by its message.
152const statOf = ($: EngineInterface) => (path: string): Promise<StatAnswer> =>
153  $.fs.stat(path, { resolve: true }).then(
154    found => ({ found }),
155    err => statRejection(String(err instanceof Error ? err.message : err)),
156  )
157
158// Resolves the to-do ids of the roster it loaded, or undefined when it loaded
159// none: no file named, no file, or a file that failed its check. The file is
160// read only where the path rule allows it, so the pane follows no link either.
161async function loadRoster($: EngineInterface): Promise<string[] | undefined> {
162  const at = await where($)
163  if ('error' in at) {
164    await update($, rosterError, () => at.error)
165    await update($, roster, () => ({ cards: [], todos: [] }))
166    return undefined
167  }
168  // After a clear or a resume the session id changes, and so does the file:
169  // name the new one and the one before it.
170  await update($, files, f =>
171    f.current === at.file ? f : { current: at.file, previous: f.current },
172  )
173  try {
174    const placed = await placeRoster(at.config, at.plugin, at.id, statOf($))
175    if ('refused' in placed) {
176      throw new Error(placed.refused.startsWith('link:') ? `${placed.refused}; the roster path must not pass through a link or junction` : placed.refused)
177    }
178    if (placed.existing === undefined) {
179      await update($, roster, () => ({ cards: [], todos: [] }))
180      await update($, rosterError, () => '')
181      return undefined
182    }
183    if (placed.existing.size > MAX_ROSTER_BYTES) throw new Error(`the roster is over ${MAX_ROSTER_BYTES} bytes`)
184    const checked = checkRoster(String(await $.fs.read(placed.file)))
185    if ('error' in checked) throw new Error(checked.error)
186    const value: Roster = checked.value
187    await update($, roster, () => value)
188    await update($, rosterError, () => '')
189    return value.todos.map(t => t.id)
190  } catch (err) {
191    await update($, rosterError, () => `Roster not shown: ${oneLine(String(err instanceof Error ? err.message : err), 200)}`)
192    return undefined
193  }
194}
195
196// After each roster load: move the ticks past their grace period to done, and
197// drop a tick whose to-do left the roster. The rules are in view.ts; a load
198// that read no roster changes no tick.
199async function sweepTodos($: EngineInterface, todoIds: string[] | undefined) {
200  try {
201    await settleTicks(
202      fn => update($, ticking, fn),
203      fn => update($, doneTodos, fn),
204      await $.clock.now(),
205      todoIds,
206    )
207  } catch {
208    // The next roster tick sweeps again.
209  }
210}
211
212// What the warmth reads remember of each transcript, keyed by its path. A
213// reload starts it over, which costs one read per member.
214const memory: ReadMemory = new Map()
215let polling = false
216
217async function pollAgents($: EngineInterface, open: () => boolean) {
218  // One poll at a time: `claude agents` may take up to 15 s and the
219  // transcript reads add to that, so a tick that finds the last poll still
220  // running does nothing.
221  if (polling) return
222  polling = true
223  try {
224    // With no roster file to read there is no brigade to show, so no process
225    // runs either; the pane already shows why.
226    const at = await where($)
227    if ('error' in at) return
228    try {
229      const { exitCode, stdout, stderr } = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 15000 })
230      if (!open()) return
231      if (exitCode !== 0) throw new Error(oneLine(stderr, 200) || `exit ${exitCode}`)
232      const rows = parseAgents(stdout)
233      if ('error' in rows) throw new Error(rows.error)
234      await update($, live, () => rows.value.map(r => ({ name: r.name, value: r.status })))
235      await update($, liveError, () => '')
236
237      // Transcripts are found under the config folder.
238      const config = at.config
239      const cards = (await read($, roster)).cards
240
241      // Each roster member's cache warmth, from its transcript's last model
242      // call. The rules for what is read, and when, are in view.ts.
243      const found = await readWarmth(rows.value, cards.map(c => c.title), config, memory, {
244        live: open,
245        stat: path => $.fs.stat(path),
246        read: async path => String(await $.fs.read(path)),
247      })
248      if (found === undefined || !open()) return
249      await update($, warmth, () => found)
250
251      // A Remote Control session's claude.ai link sits in its transcript, in a
252      // row the engine writes. Read only a roster member's, by the id the engine
253      // listed, once per session, and keep the misses too. The read comes
254      // first and is kept only if the pane is still open, so a close during
255      // it records nothing and the next open tries again.
256      const members = new Set(cards.filter(c => c.desktopId === undefined && c.url === undefined).map(c => c.title))
257      const seen = new Set(await read($, looked))
258      for (const row of rows.value) {
259        if (!members.has(row.name) || seen.has(row.name)) continue
260        const file = transcriptFile(config, row)
261        if (file === undefined) continue
262        if (!open()) return
263        // No transcript yet, or none for this kind of session: no link.
264        const url = await $.fs.read(file).then(t => remoteLink(String(t)), () => undefined)
265        if (!open()) return
266        await update($, looked, list => [...list, row.name].slice(-200))
267        if (url !== undefined && open()) {
268          await update($, links, list => [...list.filter(p => p.name !== row.name), { name: row.name, value: url }])
269        }
270      }
271    } catch (err) {
272      await update($, liveError, () => `claude agents: ${oneLine(String(err instanceof Error ? err.message : err), 200)}`)
273    }
274  } finally {
275    polling = false
276  }
277}
278
279// This session's own usage, for the section at the bottom of the pane. The
280// plain reading costs nothing. The context breakdown is asked for only when
281// the session draws somewhere other than the terminal, the one place the
282// image shows its rows, and only as the local summary estimate, which sends
283// no request. Only the fields the section draws are stored. A failed reading
284// keeps the last one, and a reading that started before the pane closed is
285// dropped, so it cannot land over a fresh one after a reopen.
286async function readUsage($: EngineInterface) {
287  const mine = generation
288  try {
289    const image = (await $.session.surfaces()).some(s => s !== 'terminal')
290    const reading = await $.session.usage(image ? { breakdown: 'summary' } : undefined)
291    if (generation === mine) await update($, usage, () => snapshotFrom(reading))
292  } catch {
293    // No reading this time: the section keeps what it showed.
294  }
295}
296
297// The measure hook's reads, one at a time. A measurement that comes while one
298// runs asks for one more after it, so a burst folds into one trailing read,
299// an older reading never lands after a newer one, and a reading that hung
300// holds at most one waiting behind it. A close ends the run.
301let measuring = -1
302let again = false
303async function measureUsage($: EngineInterface) {
304  const mine = generation
305  if (measuring === mine) {
306    again = true
307    return
308  }
309  measuring = mine
310  try {
311    do {
312      again = false
313      await readUsage($)
314    } while (again && generation === mine)
315  } finally {
316    if (measuring === mine) measuring = -1
317  }
318}
319
320// Start the reads, once: a second /brigade while they run starts nothing.
321// The timers are taken before the first await, so two arms that overlap
322// cannot both pass the check. Each arm carries its generation, and a close
323// moves the generation on, so neither its first reads nor a tick already
324// queued run after the pane closed.
325let generation = 0
326async function arm($: EngineInterface) {
327  if (timers.length === 0) {
328    const mine = ++generation
329    const live = () => generation === mine
330    timers = [
331      $.clock.every(ROSTER_MS, () => void (live() && rosterTick($, live))),
332      $.clock.every(AGENTS_MS, () => void (live() && pollAgents($, live))),
333    ]
334    await update($, armed, () => true)
335    if (live()) await readUsage($)
336    if (live()) await loadRoster($)
337    if (live()) await pollAgents($, live)
338    return
339  }
340  await update($, armed, () => true)
341}
342function disarm() {
343  generation++
344  for (const t of timers) t.cancel()
345  timers = []
346}
347
348// The roster timer's tick. The engine drops a pane whose drawing threw
349// without telling its close hook, so each tick first checks that the pane is
350// still listed, and stops every read when it is not: the timers, the measure
351// hook's reads and the report keeping all end with it.
352async function rosterTick($: EngineInterface, live: () => boolean) {
353  try {
354    const listed = (await $.ui.panes()).some(p => p.id === PANE)
355    // A close or a fresh arm while the answer was on its way: act on nothing.
356    if (!live()) return
357    if (!listed) {
358      disarm()
359      await update($, armed, () => false)
360      return
361    }
362  } catch {
363    // No answer this time: the next tick asks again.
364    return
365  }
366  const ids = await loadRoster($)
367  if (live()) await sweepTodos($, ids)
368}
369
370// The Desktop ids whose press is in its tool route now. A second press for
371// one of them is ignored until the route ends, so a press inside the wait
372// sends no second call and cannot run the link route twice. The card's button
373// and its to-do's share the mark, since both name the same id.
374const opening = new Set<string>()
375
376// Open in app, first through the Desktop app's own tool, which shows a session
377// this lead started in a split beside it. The tool is called only where the
378// Desktop app draws the session, when the engine lists the tool by its exact
379// name and the owner's rules allow it, and its
380// answer is read as untrusted. No permission prompt sees this call, so those
381// checks stand in for one. Any refusal, rejection, throw or a wait past 5 s
382// on the engine's clock, from the tool list to the answer, ends the route, and
383// the press goes on to the link. Resolves true when the app said it opened
384// the session.
385async function viaTool($: EngineInterface, session: string): Promise<boolean> {
386  let over = false
387  let timer: Timer | undefined
388  const waited = new Promise<false>(res => {
389    timer = $.clock.after(OPEN_WAIT_MS, () => {
390      over = true
391      res(false)
392    })
393  })
394  const route = askTool($, session, () => over)
395  // A route that ends or fails after the wait is dropped here, unread.
396  route.catch(() => undefined)
397  try {
398    return await Promise.race([route, waited])
399  } finally {
400    timer?.cancel()
401  }
402}
403
404// The tool route itself. After the wait is over it makes no call and shows
405// nothing; a call already made may still show the split after the link route
406// ran.
407async function askTool($: EngineInterface, session: string, over: () => boolean): Promise<boolean> {
408  // Only where the Desktop app draws this session: elsewhere its server is
409  // absent, and a server of the same name would be the only one to answer.
410  if (!(await $.session.surfaces()).includes('desktop')) return false
411  if (!listsOpenTool(await $.tool.list())) return false
412  const args = { session_id: session, target: OPEN_TARGET }
413  if (!rulesAllowTool(await $.tool.check({ tool: OPEN_TOOL, input: args }))) return false
414  if (over()) return false
415  const outcome = readOpenAnswer(await $.mcp.call(OPEN_SERVER, OPEN_NAME, args))
416  if (over()) return false
417  if (outcome.opened) {
418    $.ui.toast(outcome.toast)
419    return true
420  }
421  if (outcome.reason !== undefined) $.ui.toast(outcome.reason)
422  return false
423}
424
425// The pane's Link takes https only, so the app link goes to Windows' own
426// handler for claude://. Only a link of the two known shapes goes, built
427// from a checked id, as one argument with no shell. A card with a Desktop id
428// tries the app's own tool first, one press at a time.
429async function openInApp($: EngineInterface, c: Card) {
430  const app = appLink(c, lookup(await read($, links)).get(c.title))
431  if (app === undefined) return
432  const session = toolSession(c)
433  if (session !== undefined) {
434    if (opening.has(session)) return
435    opening.add(session)
436    try {
437      if (await viaTool($, session).catch(() => false)) return
438    } finally {
439      opening.delete(session)
440    }
441  }
442  const { exitCode } = await $.process.run(['explorer.exe', app])
443  // explorer.exe exits 1 even when it hands the link on, so say what was sent.
444  $.ui.toast(`Opening ${oneLine(c.title, 40)} in the app (${exitCode})`)
445}
446
447// --- set_roster ---------------------------------------------------------------
448//
449// The tool the head chef keeps the roster with. A file write of its own into
450// the plugin's data folder asks the owner every time, in every mode, because
451// Claude Code protects that folder; this tool writes the same file with no
452// prompt. So it is guarded here instead: it is offered only once the owner
453// opens the pane, writes only while the engine lists the pane open, refuses a
454// subagent's call, a deny verdict and an owner's ask rule, takes only a roster
455// that passes the shared check and cap, writes only the one file the path rule
456// in roster.ts allows, and never names a path from its input.
457
458const TOOL_NAME = 'mcp__grimoire__set_roster'
459
460const DESCRIPTION =
461  "Keeps the Brigade pane's roster for this lead session. Pass the whole roster every time, every card and every to-do; it replaces the last one. Both lists are required; two empty lists are an empty roster. It works only while the Brigade pane is open, which the owner opens with /brigade, and refuses otherwise. A refusal names the rule that failed."
462
463const field = (max: number) => ({ type: 'string', maxLength: max })
464const SCHEMA = {
465  type: 'object',
466  additionalProperties: false,
467  required: ['cards', 'todos'],
468  properties: {
469    cards: {
470      type: 'array',
471      maxItems: 50,
472      items: {
473        type: 'object',
474        additionalProperties: false,
475        required: ['title', 'status'],
476        properties: {
477          title: field(80),
478          work: field(MAX_TEXT),
479          phase: field(MAX_TEXT),
480          settings: field(MAX_TEXT),
481          status: { type: 'string', enum: [...STATUSES] },
482          desktopId: field(100),
483          bgId: field(100),
484          url: field(100),
485        },
486      },
487    },
488    todos: {
489      type: 'array',
490      maxItems: 50,
491      items: {
492        type: 'object',
493        additionalProperties: false,
494        required: ['id', 'text'],
495        properties: { id: field(40), text: field(MAX_TEXT), session: field(80) },
496      },
497    },
498  },
499}
500
501// The engine's own fields on a tool call. Everything else is the model's
502// input and goes to the shared check whole, so an unknown key is refused.
503const ENVELOPE = ['tool', 'tool_use_id', 'agentId', 'consent']
504
505// The permission mode the session last reported, from each prompt and from
506// every other tool call. Plan and don't-ask modes refuse the write. A mode not
507// yet seen, as after a reload before the next prompt or tool call, refuses
508// nothing: refusing it would end the roster for the session after every
509// resume. A switch made since the last report is not seen until the next one.
510const REFUSING_MODES = ['plan', 'dontAsk']
511let mode: string | undefined
512
513const refuse = (rule: string) => ({ deny: `set_roster refused: ${rule}. The roster file is unchanged.` })
514// Once the write has started, a failure cannot say the file is unchanged.
515const failedAfterWrite = (why: string) => ({
516  deny: `set_roster failed after the write began: ${why}. The roster may or may not have been kept; stop keeping the roster and tell the owner.`,
517})
518
519const paneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
520
521async function offerTool($: EngineInterface) {
522  await $.tool.register({ name: TOOL, description: DESCRIPTION, inputSchema: SCHEMA })
523}
524
525// Writes run one after another, so two calls in one turn cannot interleave
526// their checks and writes. A call whose dispatch was abandoned while it
527// waited, because it ran out of time or the owner interrupted it, writes
528// nothing when its turn comes. A write that never returns holds the queue
529// until the module reloads; row 15 names it.
530let writing: Promise<unknown> = Promise.resolve()
531function oneAtATime<T>(work: () => Promise<T>): Promise<T> {
532  const run = writing.then(work, work)
533  writing = run.catch(() => undefined)
534  return run
535}
536
537// What a call has done so far, so a failure says the right thing about the file.
538type Progress = { written: boolean }
539
540async function setRoster($: EngineInterface, e: Record<string, unknown>, signal: AbortSignal, progress: Progress) {
541  if (e.agentId !== undefined) return refuse("subagent: only the lead session's own turns keep the roster")
542  if (!(await paneOpen($))) return refuse('pane closed; roster not kept')
543
544  // Own entries copied into a fresh object, so no key reaches a prototype.
545  const input = Object.fromEntries(Object.entries(e).filter(([k]) => !ENVELOPE.includes(k)))
546
547  const verdict = await $.tool.check({ tool: TOOL_NAME, input })
548  if (verdict.decision === 'deny') return refuse(`deny verdict: ${oneLine(verdict.reason ?? 'a rule denies this tool', 200)}`)
549  if (verdict.decision === 'ask' && verdict.rule !== undefined) {
550    return refuse(`ask rule: the owner's rule ${oneLine(verdict.rule, 120)} covers this tool`)
551  }
552  // An organisation's ceiling below allow: the tool may never run unasked.
553  if (verdict.ceiling !== undefined && verdict.ceiling !== 'allow') return refuse(`organisation ceiling: ${verdict.ceiling}`)
554  if (mode !== undefined && REFUSING_MODES.includes(mode)) return refuse(`permission mode: ${mode}`)
555
556  for (const k of ['cards', 'todos']) {
557    if (!Object.hasOwn(input, k)) return refuse(`malformed input: "${k}" is missing; pass both lists, empty if need be`)
558  }
559  const checked = checkRoster(JSON.stringify(input), true)
560  if ('error' in checked) return refuse(`shape: ${checked.error}`)
561  const text = serialize(checked.value)
562  if ('error' in text) return refuse(`size: ${text.error}`)
563  const value = checked.value
564  const at = await where($)
565  if ('error' in at) return refuse(`path: ${at.error}`)
566
567  return oneAtATime(async () => {
568    if (signal.aborted) return refuse('abandoned: the call ran out of time or was interrupted before its turn')
569    if (!(await paneOpen($))) return refuse('pane closed; roster not kept')
570    if (mode !== undefined && REFUSING_MODES.includes(mode)) return refuse(`permission mode: ${mode}`)
571    const placed = await placeRoster(at.config, at.plugin, at.id, statOf($))
572    if ('refused' in placed) return refuse(placed.refused)
573    // A file already there is overwritten only if it is a roster, both lists
574    // and all. What it holds otherwise is never echoed.
575    if (placed.existing !== undefined) {
576      const isRoster =
577        placed.existing.size <= MAX_ROSTER_BYTES && !('error' in checkRoster(String(await $.fs.read(placed.file)), true))
578      if (!isRoster) {
579        return refuse(`not a roster: ${placed.file} holds something other than a roster, so it is not overwritten. Ask the owner to delete that file`)
580      }
581    }
582    if (signal.aborted) return refuse('abandoned: the call ran out of time or was interrupted before its write')
583    progress.written = true
584    await $.fs.write(placed.file, text.value)
585    // A link swapped in between the check and the write, at the file or at
586    // any folder above it, is caught here by the same path rule, after the
587    // fact, and said loudly. A hard link is not.
588    const after = await placeRoster(at.config, at.plugin, at.id, statOf($)).catch(err => ({
589      refused: `the check failed (${oneLine(String(err instanceof Error ? err.message : err), 200)})`,
590    }))
591    const why = 'refused' in after ? after.refused : after.existing === undefined ? 'the file is not there' : undefined
592    if (why !== undefined) {
593      const warning = `Brigade: after the roster write, the roster path failed its check: ${why}. Something changed it between the check and the write; check ${placed.file} and where it leads.`
594      await update($, rosterError, () => warning)
595      $.ui.toast(warning, { timeoutMs: 15000 })
596      return failedAfterWrite(`the path then failed its check (${why}); the owner has been told`)
597    }
598    // Only now does the pane change: it redraws at once, and the 5 s poll
599    // stays the reader for changes made elsewhere.
600    await update($, roster, () => value)
601    await update($, rosterError, () => '')
602    return { result: `Roster kept: ${value.cards.length} card(s), ${value.todos.length} to-do(s).` }
603  })
604}
605
606export const register: Register = on => {
607  on('session.start', async ($, e, next) => {
608    await $.command.register({
609      name: 'brigade',
610      description: 'Show the Brigade pane: the sessions this lead session started',
611    })
612    // A reload of the module while the pane stays up finds it in the engine's
613    // record. Only then do the reads start again, and the tool is offered
614    // again; a fresh session has no pane and no tool.
615    if (await paneOpen($)) {
616      await arm($)
617      await offerTool($)
618    }
619    return next(e)
620  })
621
622  // The engine's own record of this session: its id and transcript path, the
623  // one place the config folder can be read from when the plugin's location
624  // does not name it. A clear or a resume fires this again with the new id,
625  // and session.start does not, so the tool is offered again here while the
626  // pane is open; registering a name again replaces it.
627  on('classic.SessionStart', async ($, e, next) => {
628    if (SESSION_ID.test(e.session_id) && typeof e.transcript_path === 'string') {
629      await update($, started, () => ({ sessionId: e.session_id, transcript: e.transcript_path }))
630    }
631    if (typeof e.permission_mode === 'string') mode = e.permission_mode
632    try {
633      if (await paneOpen($)) await offerTool($)
634    } catch {
635      // No tool this time: the next /brigade offers it again.
636    }
637    return next(e)
638  })
639
640  // The mode each prompt runs in, and each other tool call, as far as the
641  // engine tells a hook. These only note the mode and pass the event on.
642  on('classic.UserPromptSubmit', ($, e, next) => {
643    if (typeof e.permission_mode === 'string') mode = e.permission_mode
644    return next(e)
645  })
646  on('classic.PreToolUse', ($, e, next) => {
647    if (typeof e.permission_mode === 'string') mode = e.permission_mode
648    return next(e)
649  })
650
651  on('command.run', { command: 'brigade' }, async $ => {
652    // Open first, and start the reads and offer the tool only once the engine
653    // lists the pane: a pane another plugin refuses or answers for starts
654    // neither.
655    const opened = await $.ui.open({ id: PANE, title: TITLE })
656    let ready = ''
657    if (await paneOpen($)) {
658      await arm($)
659      try {
660        await offerTool($)
661        ready = ` \`${TOOL}\` is ready: call it with the full roster.`
662      } catch (err) {
663        ready = ` \`${TOOL}\` could not be offered (${oneLine(String(err instanceof Error ? err.message : err), 200)}); keep no roster.`
664      }
665    }
666    // The reply names no roster path: no skill reads one, and the pane's own
667    // top line shows it to the owner. When no file can be named, the pane
668    // keeps the full reason.
669    const at = await where($)
670    const named = 'error' in at ? ' The pane cannot name the roster file, so it shows none; the pane says why.' : ''
671    return {
672      text: `${opened.isPlaced ? 'Brigade pane opened.' : `Brigade pane is open but not shown: ${opened.reason}.`}${named}${ready}`,
673    }
674  })
675
676  // The tool, answered here and nowhere beneath: every path returns its own
677  // answer. A throw inside becomes a refusal in code, and the engine's .catch
678  // answers for a throw, an overrun or a wrong shape the code did not catch.
679  on('tool.call', { tool: TOOL_NAME }, async ($, e, next) => {
680    const progress: Progress = { written: false }
681    try {
682      return await setRoster($, e as unknown as Record<string, unknown>, next.signal, progress)
683    } catch (err) {
684      const why = `error: ${oneLine(String(err instanceof Error ? err.message : err), 200)}`
685      return progress.written ? failedAfterWrite(why) : refuse(why)
686    }
687  }).catch(() => ({
688    deny: 'set_roster refused: error: the tool failed or ran out of time. The roster may or may not have been kept; stop keeping the roster and tell the owner.',
689  }))
690
691  on('ui.close', { id: PANE }, async ($, e, next) => {
692    disarm()
693    await update($, armed, () => false)
694    return next(e)
695  })
696
697  // The engine measured the session and a figure moved. Idle by default: this
698  // does nothing unless this module armed the pane, which it reads from its
699  // own timers rather than from plugin state another plugin could set. It
700  // passes the event on, unchanged, on every path, and does not wait for the
701  // read: a reading that hung would otherwise hold up every hook after it.
702  on('session.measure', ($, e, next) => {
703    if (timers.length > 0) void measureUsage($)
704    return next(e)
705  })
706
707  // A report from another session: its claimed sender and first line, kept
708  // while the pane is open. The text is data to show, never an instruction.
709  // Like the measure hook, it reads the open pane from this module's own
710  // timers, not from plugin state another plugin could set.
711  on('session.receive', async ($, e, next) => {
712    const kind = e.origin.kind
713    if ((kind === 'peer' || kind === 'peer-send-message') && timers.length > 0) {
714      const { from, line } = report(e.text)
715      const at = new Date(await $.clock.now()).toISOString().slice(11, 16)
716      await update($, reports, list => [...list, { from, line, at }].slice(-50))
717    }
718    return next(e)
719  })
720
721  // Presses arrive here with a fresh `$`. Each finds its card or to-do again
722  // in the current state by place and title, and does nothing if it moved.
723  on('ui.press', { plugin: 'grimoire', requestId: PANE }, async ($, e, next) => {
724    const [verb, place, folded] = e.element.split('-')
725    const i = Number(place)
726    const current = await read($, roster)
727
728    if (verb === 'todo' || verb === 'go') {
729      const item = current.todos[i]
730      if (item === undefined || slug(item.id) !== folded) return { element: e.element }
731      if (verb === 'todo') {
732        // A tick, or a second press inside the grace period that undoes it.
733        const now = await $.clock.now()
734        await update($, ticking, list => toggleTick(list, item.id, now))
735        return { element: e.element }
736      }
737      const who = current.cards.find(c => c.title === item.session)
738      if (who !== undefined) await openInApp($, who)
739      return { element: e.element }
740    }
741
742    const c = current.cards[i]
743    if (c === undefined || slug(c.title) !== folded) return { element: e.element }
744    if (verb === 'open') await openInApp($, c)
745    if (verb === 'dismiss') await update($, dismissed, list => [...list, c.title])
746    return { element: e.element }
747  })
748
749
750  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
751    const table = $.ui.resolve(e)
752    const { Box, Text, Button } = table
753    // The terminal's table has no Svg, and a surface without one gets the
754    // text bars too. The render only reads the stored reading: it fetches none.
755    const Svg = 'Svg' in table ? table.Svg : undefined
756    // The clock moves the warmth line too: the roster timer's redraws every
757    // 5 s move its countdown.
758    const now = await $.clock.now()
759    const metered = usageView(await read($, usage), Svg === undefined ? 'terminal' : e.surface, now)
760    const paths = await read($, files)
761    const error = await read($, rosterError)
762    const pollError = await read($, liveError)
763    const { cards, todos } = await read($, roster)
764    const hidden = new Set(await read($, dismissed))
765    const running = lookup(await read($, live))
766    const found = lookup(await read($, links))
767    const warm = new Map(warmthFrom(await read($, warmth)).map(w => [w.name, w]))
768    const inbox = await read($, reports)
769    const done = idsFrom(await read($, doneTodos))
770    const ticked = new Set(done)
771    const ticks = await read($, ticking)
772    const crossed = new Set(ticksFrom(ticks).map(p => p.name))
773    const last = (title: string) => [...inbox].reverse().find(r => r.from === oneLine(title, 80))
774
775    const shown = cards.map((c, i) => ({ c, i })).filter(({ c }) => !hidden.has(c.title))
776    const needsYou = shown.filter(({ c }) => c.status === 'needs-you')
777    // A ticked to-do stays in the list, crossed out, until the sweep moves it.
778    const open = todos.map((t, i) => ({ t, i })).filter(({ t }) => !ticked.has(t.id))
779    const waiting = waitingCount(needsYou.length, todos, done, ticks)
780
781    // One card, in a rounded box: amber when it needs the owner, dim
782    // otherwise. The status mark, the title and its live state on one line,
783    // the title cut first so the state stays in view. Then the ◆ warmth line
784    // and its nudge, which wraps, the phase, the work, the settings and the
785    // latest report, each on its own line and cut to the pane's width, and
786    // the buttons on their own row.
787    const card = ({ c, i }: { c: Card; i: number }) => {
788      const mark = MARK.get(c.status) ?? { mark: '?', color: 'inactive' }
789      const state = liveState(running.get(c.title))
790      const warmLine = warmthLine(warm.get(c.title), running.get(c.title), c.status, now)
791      const report = last(c.title)
792      const closed = c.status === 'done' || c.status === 'stopped'
793      const needs = c.status === 'needs-you'
794      const canOpen = appLink(c, found.get(c.title)) !== undefined
795      return (
796        <Box
797          flexDirection="column"
798          borderStyle="round"
799          borderColor={needs ? 'warning' : 'inactive'}
800          borderDimColor={!needs}
801          paddingX={1}
802          marginBottom={1}
803        >
804          <Box flexDirection="row" columnGap={1}>
805            <Box flexShrink={0}>
806              <Text color={mark.color}>{mark.mark}</Text>
807            </Box>
808            <Box flexGrow={1} flexShrink={1} minWidth={0}>
809              <Text bold wrap="truncate-end">
810                {oneLine(c.title, 80)}
811              </Text>
812            </Box>
813            <Box flexShrink={0}>
814              <Text color={state.color}>{state.text}</Text>
815            </Box>
816          </Box>
817          <Box flexDirection="column" marginLeft={2} marginTop={1}>
818            {warmLine !== undefined && (
819              <Text {...(warmLine.tone === 'dim' ? { dimColor: true } : { color: warmLine.tone })} wrap="truncate-end">
820                {warmLine.text}
821              </Text>
822            )}
823            {warmLine?.nudge !== undefined && (
824              <Text color="warning" wrap="wrap">
825                {warmLine.nudge}
826              </Text>
827            )}
828            <Text wrap="truncate-end">{oneLine(c.phase, 160)}</Text>
829            <Text dimColor wrap="truncate-end">
830              {oneLine(c.work, 160)}
831            </Text>
832            <Text dimColor wrap="truncate-end">
833              {oneLine(c.settings, 80)}
834            </Text>
835            {report !== undefined && (
836              <Text dimColor wrap="truncate-end">
837                {report.at} ↳ {report.line}
838              </Text>
839            )}
840            {(canOpen || closed) && (
841              <Box flexDirection="row" columnGap={2} marginTop={1}>
842                {canOpen && (
843                  <Button key={keyFor('open', i, c.title)} onPress={noop}>
844                    Open in app
845                  </Button>
846                )}
847                {closed && (
848                  <Button key={keyFor('dismiss', i, c.title)} dimColor onPress={noop}>
849                    Dismiss
850                  </Button>
851                )}
852              </Box>
853            )}
854            {closed && <Text dimColor>Archive it in the sidebar when you are done with it.</Text>}
855          </Box>
856        </Box>
857      )
858    }
859
860    return (
861      <Box flexDirection="column">
862        <Box flexDirection="row" columnGap={1}>
863          <Text dimColor>Roster</Text>
864          <Text dimColor wrap="wrap">
865            {paths.current === '' ? 'not named yet' : paths.current}
866          </Text>
867        </Box>
868        {paths.previous !== '' && (
869          <Box flexDirection="row" columnGap={1}>
870            <Text dimColor>Before</Text>
871            <Text dimColor wrap="wrap">
872              {paths.previous}
873            </Text>
874          </Box>
875        )}
876        {error !== '' && <Text color="error">{error}</Text>}
877        {pollError !== '' && <Text color="error">{pollError}</Text>}
878        <Box flexDirection="column" marginTop={1} marginBottom={1}>
879          <Box marginBottom={1}>
880            <Text bold>
881              Waiting on you <Text dimColor>{waiting}</Text>
882            </Text>
883          </Box>
884          {/* One rounded box, a line between rows. A card that needs the
885              owner has a fixed two-cell gutter for its mark, top-aligned; a
886              to-do has its checkbox there, a full button. The text may shrink,
887              so a long line wraps inside the box. */}
888          <Box flexDirection="column" borderStyle="round" borderColor="inactive" borderDimColor paddingX={1} rowGap={1}>
889            {needsYou.map(({ c }) => (
890              <Box flexDirection="row" alignItems="flex-start">
891                <Box width={2} flexShrink={0}>
892                  <Text color="warning">●</Text>
893                </Box>
894                <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
895                  <Text bold wrap="truncate-end">
896                    {oneLine(c.title, 80)}
897                  </Text>
898                  <Text dimColor wrap="wrap">
899                    {oneLine(c.phase, MAX_TEXT)}
900                  </Text>
901                </Box>
902              </Box>
903            ))}
904            {open.map(({ t, i }) => {
905              const isTicked = crossed.has(t.id)
906              return (
907                <Box flexDirection="row" alignItems="flex-start" columnGap={1}>
908                  {/* Blank and dim at rest, a tick once pressed; a second
909                      press inside the grace period undoes it. The blank is
910                      as wide as the tick, and the slot fixed, so neither the
911                      box nor the text moves when it is ticked. */}
912                  <Box width={CHECK_CELLS} flexShrink={0}>
913                    <Button key={keyFor('todo', i, t.id)} dimColor={!isTicked} onPress={noop}>
914                      {isTicked ? '✓' : e.surface === 'terminal' ? UNTICKED_TERMINAL : UNTICKED}
915                    </Button>
916                  </Box>
917                  <Box flexGrow={1} flexShrink={1} minWidth={0}>
918                    <Text wrap="wrap" strikethrough={isTicked} dimColor={isTicked}>
919                      {oneLine(t.text, MAX_TEXT)}
920                    </Text>
921                  </Box>
922                  {t.session !== undefined && cards.some(c => c.title === t.session && appLink(c, found.get(c.title)) !== undefined) && (
923                    <Box flexShrink={0}>
924                      <Button key={keyFor('go', i, t.id)} onPress={noop}>
925                        Open
926                      </Button>
927                    </Box>
928                  )}
929                </Box>
930              )
931            })}
932            {needsYou.length + open.length === 0 && <Text dimColor>Nothing waits on you.</Text>}
933          </Box>
934        </Box>
935        <Box flexDirection="column">
936          <Box marginBottom={1}>
937            <Text bold>
938              Sessions <Text dimColor>{shown.length}</Text>
939            </Text>
940          </Box>
941          {shown.map(card)}
942          {shown.length === 0 && error === '' && <Text dimColor>No cards in the roster yet.</Text>}
943        </Box>
944        {inbox.length === 0 && <Text dimColor>No reports since the pane opened.</Text>}
945        <Box flexDirection="column" marginTop={1}>
946          <Text bold>Usage</Text>
947          {metered.kind === 'none' && <Text dimColor>No reading yet: it arrives with the next reply.</Text>}
948          {metered.kind === 'text' &&
949            metered.rows.map(r => (
950              <Box flexDirection="row" columnGap={1}>
951                <Text>{r.name}</Text>
952                <Text color={r.tone}>{r.bar}</Text>
953                <Text>{r.percent}</Text>
954                <Text dimColor wrap="truncate-end">
955                  {r.note}
956                </Text>
957              </Box>
958            ))}
959          {metered.kind === 'svg' && Svg !== undefined && (
960            <Box flexDirection="column" alignItems="center">
961              <Svg source={metered.source} alt={metered.alt} width={metered.width} height={metered.height} />
962            </Box>
963          )}
964        </Box>
965      </Box>
966    )
967  })
968}
969
brigade/roster.ts 479 lines
1import type { Card, Pair, Roster, Status, Todo } from './types'
2
3// What the Brigade pane reads from outside itself, checked before it is drawn
4// or used: the roster, the rows `claude agents --json` prints, the transcript
5// paths the engine reports and the reports other sessions send. Every one is
6// text another process wrote, so each is held to a shape, cut to a length and
7// shown as text only. Nothing here runs, and nothing here builds a command or
8// a path from roster text.
9//
10// The roster has two users, and both take their rules from here so the two
11// cannot drift: the pane, which reads the file, and the set_roster tool, which
12// writes it. The shape check, the byte cap and the decision whether a roster
13// path may be read or written are each one function below.
14
15export const STATUSES: readonly Status[] = ['working', 'needs-you', 'done', 'stopped']
16
17// The caps a roster is held to. A roster over them is refused with an error in
18// the pane rather than drawn in part.
19const MAX_CARDS = 50
20const MAX_TODOS = 50
21const MAX_TITLE = 80
22/** The cap on a card's or to-do's text field; the pane draws a wrapped field
23 *  whole up to it. */
24export const MAX_TEXT = 300
25const MAX_ID = 100
26
27// The engine's session ids, and so the roster files' names.
28export const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
29
30// C0 and C1 controls, and the characters that reorder or hide text: every
31// default-ignorable code point (the zero-width ones, the variation selectors,
32// the tag block, the soft hyphen and the rest), and the bidi marks,
33// embeddings, overrides and isolates, some of which are not in that set.
34const CONTROL = /[\u{0}-\u{1F}\u{7F}-\u{9F}\u{2028}\u{2029}]/gu
35const HIDDEN = /[\p{Default_Ignorable_Code_Point}\u{61C}\u{200E}\u{200F}\u{202A}-\u{202E}\u{2066}-\u{2069}]/gu
36
37/** Text made safe to draw on one line: controls become spaces, hidden
38 *  characters go, runs of space fold, and it is cut to `max` with an ellipsis. */
39export function oneLine(text: string, max: number): string {
40  const flat = text.replace(CONTROL, ' ').replace(HIDDEN, '').replace(/\s+/g, ' ').trim()
41  const chars = [...flat]
42  return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : flat
43}
44
45type Checked<T> = { value: T } | { error: string }
46
47const isRecord = (v: unknown): v is Record<string, unknown> =>
48  typeof v === 'object' && v !== null && !Array.isArray(v)
49
50// Own keys only, and only the ones named: a key the shape does not know is a
51// typo or a field nobody reads, and either way the file is refused.
52function fields(where: string, v: unknown, allowed: readonly string[]): Checked<Record<string, unknown>> {
53  if (!isRecord(v)) return { error: `${where} is not an object` }
54  const extra = Object.keys(v).filter(k => !allowed.includes(k))
55  if (extra.length > 0) return { error: `${where} has a field the roster does not use: ${oneLine(extra[0] ?? '', 40)}` }
56  return { value: v }
57}
58
59function text(where: string, v: unknown, max: number, required: boolean): Checked<string | undefined> {
60  if (v === undefined && !required) return { value: undefined }
61  if (typeof v !== 'string') return { error: `${where} is not text` }
62  if (required && v.trim() === '') return { error: `${where} is empty` }
63  if ([...v].length > max) return { error: `${where} is over ${max} characters` }
64  return { value: v }
65}
66
67function card(i: number, v: unknown): Checked<Card> {
68  const where = `card ${i + 1}`
69  const f = fields(where, v, ['title', 'work', 'phase', 'settings', 'status', 'desktopId', 'bgId', 'url'])
70  if ('error' in f) return f
71  const o = f.value
72  const title = text(`${where} title`, o.title, MAX_TITLE, true)
73  if ('error' in title) return title
74  const out: Card = { title: title.value ?? '', work: '', phase: '', settings: '', status: 'working' }
75  for (const k of ['work', 'phase', 'settings'] as const) {
76    const t = text(`${where} ${k}`, o[k], MAX_TEXT, false)
77    if ('error' in t) return t
78    out[k] = t.value ?? ''
79  }
80  if (typeof o.status !== 'string' || !(STATUSES as readonly string[]).includes(o.status)) {
81    return { error: `${where} status is not one of ${STATUSES.join(', ')}` }
82  }
83  out.status = o.status as Status
84  for (const k of ['desktopId', 'bgId', 'url'] as const) {
85    const t = text(`${where} ${k}`, o[k], MAX_ID, false)
86    if ('error' in t) return t
87    if (t.value !== undefined) out[k] = t.value
88  }
89  return { value: out }
90}
91
92function todo(i: number, v: unknown): Checked<Todo> {
93  const where = `to-do ${i + 1}`
94  const f = fields(where, v, ['id', 'text', 'session'])
95  if ('error' in f) return f
96  const id = text(`${where} id`, f.value.id, 40, true)
97  if ('error' in id) return id
98  const body = text(`${where} text`, f.value.text, MAX_TEXT, true)
99  if ('error' in body) return body
100  const session = text(`${where} session`, f.value.session, MAX_TITLE, false)
101  if ('error' in session) return session
102  const out: Todo = { id: id.value ?? '', text: body.value ?? '' }
103  if (session.value !== undefined) out.session = session.value
104  return { value: out }
105}
106
107/** The roster's text, checked: `{ cards, todos }` with every field a string
108 *  of its length, the status from the fixed set, no title twice and no to-do
109 *  id twice. The pane reads a missing list as empty. `strict` requires both
110 *  lists, as the writer does of a file it would overwrite: `{}` is then no
111 *  roster, so a hard link to some other JSON file is not taken for one. */
112export function checkRoster(raw: string, strict = false): Checked<Roster> {
113  let parsed: unknown
114  try {
115    parsed = JSON.parse(raw)
116  } catch {
117    return { error: 'the roster is not JSON' }
118  }
119  const f = fields('the roster', parsed, ['cards', 'todos'])
120  if ('error' in f) return f
121  if (strict) {
122    for (const k of ['cards', 'todos'] as const) {
123      if (!Object.hasOwn(f.value, k)) return { error: `the roster has no "${k}" list` }
124    }
125  }
126  const cards = f.value.cards ?? []
127  const todos = f.value.todos ?? []
128  if (!Array.isArray(cards)) return { error: 'the roster\'s "cards" is not a list' }
129  if (!Array.isArray(todos)) return { error: 'the roster\'s "todos" is not a list' }
130  if (cards.length > MAX_CARDS) return { error: `the roster has over ${MAX_CARDS} cards` }
131  if (todos.length > MAX_TODOS) return { error: `the roster has over ${MAX_TODOS} to-dos` }
132  const out: Roster = { cards: [], todos: [] }
133  const titles = new Set<string>()
134  for (const [i, v] of cards.entries()) {
135    const c = card(i, v)
136    if ('error' in c) return c
137    // Compared as drawn, so two titles that differ only in a hidden
138    // character cannot pose as one card.
139    const drawn = oneLine(c.value.title, MAX_TITLE)
140    if (drawn === '') return { error: `card ${i + 1} title is empty once hidden characters go` }
141    if (titles.has(drawn)) return { error: `card ${i + 1} repeats the title of an earlier card` }
142    titles.add(drawn)
143    out.cards.push(c.value)
144  }
145  const ids = new Set<string>()
146  for (const [i, v] of todos.entries()) {
147    const t = todo(i, v)
148    if ('error' in t) return t
149    if (ids.has(t.value.id)) return { error: `to-do ${i + 1} repeats the id of an earlier to-do` }
150    ids.add(t.value.id)
151    out.todos.push(t.value)
152  }
153  return { value: out }
154}
155
156/** The cap on a roster file, in bytes: the pane reads no larger file, and the
157 *  writer saves none. */
158export const MAX_ROSTER_BYTES = 256 * 1024
159
160/** A checked roster as the text the writer saves, measured in UTF-8 bytes
161 *  against the cap: what is measured is what is written. */
162export function serialize(roster: Roster): Checked<string> {
163  const text = JSON.stringify(roster)
164  const bytes = new TextEncoder().encode(text).length
165  return bytes > MAX_ROSTER_BYTES ? { error: `the roster is ${bytes} bytes, over the cap of ${MAX_ROSTER_BYTES}` } : { value: text }
166}
167
168// --- the roster path ---------------------------------------------------------
169//
170// Whether a roster path may be read or written, decided from `stat` answers
171// alone. The engine offers no write that refuses a link, and a write follows
172// one: through a broken file link it creates the link's target, and through a
173// hard link it replaces the other file's text. So before each write, and each
174// read, every folder from the config folder down is looked at, then the file.
175//
176// The answers come from the caller, so the rule runs the same against the
177// engine and against recorded answers in a test. A `stat` the engine rejects
178// reaches a mod as a message alone, with no error code: a missing path's ends
179// "failed: ENOENT" (measured on 2.1.292), and only that one means missing.
180
181/** What `$.fs.stat(path, { resolve: true })` answered, as far as the rule
182 *  reads it. `isLink` is the path's own; `kind` and `realPath` are where it
183 *  leads, `realPath` absent when it leads nowhere. */
184export type StatFound = { kind: 'file' | 'dir' | 'other'; size: number; isLink: boolean; realPath?: string }
185
186/** One `stat`: found, missing, or refused for any other reason. */
187export type StatAnswer = { found: StatFound } | { missing: true } | { failed: string }
188
189/** A rejected `stat`'s message, sorted: missing only for ENOENT. */
190export function statRejection(message: string): StatAnswer {
191  return /(?:^|[\s:])ENOENT$/.test(message.trim()) ? { missing: true } : { failed: oneLine(message, 200) }
192}
193
194/** Where the roster may go: `existing` is the file there now, which a writer
195 *  must still find to be a roster before it overwrites it. */
196export type Placed = { file: string; existing?: { size: number } } | { refused: string }
197
198// The folders below the config folder, in order. `plugins` is the engine's
199// own and must be there; the mod may create the rest.
200const FOLDERS = (plugin: string) => ['plugins', 'data', plugin, 'brigade']
201
202// A path as the real-path compare reads it: one separator, no trailing one,
203// and case folded where the file system ignores it.
204const norm = (path: string, fold: boolean) => {
205  const flat = path.replace(/[\\/]+/g, '/').replace(/(.)\/$/, '$1')
206  return fold ? flat.toLowerCase() : flat
207}
208
209/** Whether the file system under this path ignores case: Windows' and,
210 *  by its default home and volumes, macOS'. Elsewhere the compare is exact,
211 *  which can only refuse more. */
212export function foldsCase(path: string): boolean {
213  return /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path) || /^\/(?:Users|Volumes)\//.test(path)
214}
215
216/** Decides whether this session's roster file under the config folder may be
217 *  read or written, asking `stat` (resolving) of each folder on the way and
218 *  then of the file.
219 *
220 *  Refused: a session id or data id not of their shape; a config folder that
221 *  does not resolve; `plugins` missing; any folder that is a link, broken or
222 *  not, or is not a folder; any `stat` that fails other than as missing, or
223 *  finds a path with no real path; any folder whose real path is not the
224 *  config folder's real path joined with the same names, which is the
225 *  decisive control, since only junctions and file links were measured for
226 *  `isLink`; and a file that is a link, is not a plain file, or lands
227 *  anywhere but in the checked folder. A missing folder below `plugins` ends
228 *  the walk: the write creates it and everything below it. */
229export async function placeRoster(
230  config: string,
231  plugin: string,
232  sessionId: string,
233  stat: (path: string) => Promise<StatAnswer>,
234): Promise<Placed> {
235  if (!SESSION_ID.test(sessionId)) return { refused: 'path: the session id is not the engine\'s shape' }
236  if (!/^[A-Za-z0-9_-]{1,200}$/.test(plugin)) return { refused: 'path: the plugin\'s data id is not of its shape' }
237  const sep = sepOf(config)
238  const fold = foldsCase(config)
239  const top = await stat(config)
240  if (!('found' in top) || top.found.kind !== 'dir' || top.found.realPath === undefined) {
241    return { refused: 'path: the config folder does not resolve to a folder' }
242  }
243  const real = norm(top.found.realPath, fold)
244  let at = config
245  let expected = real
246  for (const name of FOLDERS(plugin)) {
247    at = `${at}${sep}${name}`
248    expected = `${expected}/${fold ? name.toLowerCase() : name}`
249    const s = await stat(at)
250    if ('missing' in s) {
251      if (name === 'plugins') return { refused: 'path: the config folder has no plugins folder' }
252      return { file: `${config}${sep}${FOLDERS(plugin).join(sep)}${sep}${sessionId}.json` }
253    }
254    if ('failed' in s) return { refused: `path: ${name} could not be read (${s.failed})` }
255    if (s.found.isLink) return { refused: `link: ${name} is a link` }
256    if (s.found.kind !== 'dir') return { refused: `path: ${name} is not a folder` }
257    if (s.found.realPath === undefined || norm(s.found.realPath, fold) !== expected) {
258      return { refused: `link: ${name} does not lead where its name says` }
259    }
260  }
261  const file = `${at}${sep}${sessionId}.json`
262  const s = await stat(file)
263  if ('missing' in s) return { file }
264  if ('failed' in s) return { refused: `path: the roster file could not be read (${s.failed})` }
265  if (s.found.isLink) return { refused: 'link: the roster file is a link' }
266  if (s.found.kind !== 'file') return { refused: 'path: the roster path is not a plain file' }
267  if (s.found.realPath === undefined || norm(s.found.realPath, fold) !== `${expected}/${fold ? `${sessionId}.json`.toLowerCase() : `${sessionId}.json`}`) {
268    return { refused: 'link: the roster file does not lead where its name says' }
269  }
270  return { file, existing: { size: s.found.size } }
271}
272
273// The config folder and the marketplace from where the plugin was installed:
274// the engine keeps an installed plugin under
275// <config>/plugins/cache/<marketplace>/... or reads it from
276// <config>/plugins/marketplaces/<marketplace>. The last such segment wins, so
277// a config folder whose own path holds the words still resolves.
278const INSTALLED = /^(.+)[\\/]plugins[\\/](?:cache|marketplaces)[\\/]([^\\/]+)(?:[\\/]|$)/
279
280/** The config folder the plugin's own location names, or undefined. */
281export function configFromRoot(root: string): string | undefined {
282  return INSTALLED.exec(root)?.[1]
283}
284
285/** The marketplace the plugin's own location names, or undefined: a plugin
286 *  loaded from a folder of its own (`--plugin-dir`) has none. */
287export function marketplaceFromRoot(root: string): string | undefined {
288  return INSTALLED.exec(root)?.[2]
289}
290
291/** The plugin's data folder id, by the plugin-manifest reference's rule: the
292 *  identifier `<name>@<marketplace>`, or `<name>@inline` for a plugin loaded
293 *  from a folder, with every character but a letter, digit, `_` or `-` made
294 *  `-`. */
295export function dataId(name: string, marketplace: string | undefined): string {
296  return `${name}@${marketplace ?? 'inline'}`.replace(/[^A-Za-z0-9_-]/g, '-')
297}
298
299/** The plugin's data folder under a config folder, built from the config
300 *  folder alone: `CLAUDE_PLUGIN_DATA` is not read, since a mod's environment
301 *  usually lacks it and another plugin can set it. */
302export function dataFolder(config: string, id: string): string {
303  const sep = sepOf(config)
304  return `${config}${sep}plugins${sep}data${sep}${id}`
305}
306
307/** The config folder above the engine's transcript path for this session:
308 *  <config>/projects/<folder>/<session id>.jsonl, or undefined. */
309export function configFromTranscript(transcript: string, sessionId: string): string | undefined {
310  if (!SESSION_ID.test(sessionId)) return undefined
311  const m = /^(.+)[\\/]projects[\\/][^\\/]+[\\/]([^\\/]+)\.jsonl$/.exec(transcript)
312  return m !== null && m[2] === sessionId ? m[1] : undefined
313}
314
315const sepOf = (dir: string) => (dir.includes('\\') ? '\\' : '/')
316
317/** The roster file of one lead session in the plugin's data folder, named by
318 *  its checked session id. */
319export function rosterFile(data: string, sessionId: string): string {
320  const sep = sepOf(data)
321  return `${data}${sep}brigade${sep}${sessionId}.json`
322}
323
324/** A row of `claude agents --json`, as far as the pane uses it. `status` is
325 *  the live state: an interactive row's `status`, else a background row's
326 *  `state`. */
327export type AgentRow = { name: string; status: string; sessionId?: string; cwd?: string }
328
329/** The rows `claude agents --json` printed, each field checked; a row with
330 *  no name is dropped, and a session id that is not the engine's shape too.
331 *  An interactive row says its live state in `status` (`busy`, `idle`), a
332 *  background row in `state` (`blocked`, for one). */
333export function parseAgents(stdout: string): Checked<AgentRow[]> {
334  let parsed: unknown
335  try {
336    parsed = JSON.parse(stdout)
337  } catch {
338    return { error: 'claude agents printed no JSON' }
339  }
340  if (!Array.isArray(parsed)) return { error: 'claude agents printed no list' }
341  const rows: AgentRow[] = []
342  for (const r of parsed.slice(0, 500)) {
343    if (!isRecord(r) || typeof r.name !== 'string' || r.name === '' || r.name.length > MAX_TEXT) continue
344    const state = typeof r.status === 'string' ? r.status : typeof r.state === 'string' ? r.state : undefined
345    const row: AgentRow = { name: r.name, status: state === undefined ? '?' : oneLine(state, 20) }
346    if (typeof r.sessionId === 'string' && SESSION_ID.test(r.sessionId)) row.sessionId = r.sessionId
347    if (typeof r.cwd === 'string' && r.cwd.length <= 1000) row.cwd = r.cwd
348    rows.push(row)
349  }
350  return { value: rows }
351}
352
353/** The transcript of a session the engine listed: its folder is the working
354 *  directory with every separator, colon and dot made a dash, which leaves no
355 *  separator in it, and its name is the checked session id. */
356export function transcriptFile(config: string, row: AgentRow): string | undefined {
357  if (row.sessionId === undefined || row.cwd === undefined || !SESSION_ID.test(row.sessionId)) return undefined
358  const sep = sepOf(config)
359  return `${config}${sep}projects${sep}${row.cwd.replace(/[:\\/.]/g, '-')}${sep}${row.sessionId}.jsonl`
360}
361
362// A Remote Control session's link, whole.
363const WEB_LINK = /^https:\/\/claude\.ai\/code\/session_[A-Za-z0-9]{1,80}$/
364
365/** A Remote Control session's link from its transcript, or undefined: only
366 *  from a row the engine itself writes when Remote Control starts, a system
367 *  row of subtype `bridge_status` carrying `url`, the last one in the file.
368 *  A link quoted in a prompt, a brief or a relayed message sits inside another
369 *  row's text and is never read. */
370export function remoteLink(transcript: string): string | undefined {
371  let found: string | undefined
372  for (const line of transcript.split('\n')) {
373    if (!line.includes('"bridge_status"')) continue
374    let row: unknown
375    try {
376      row = JSON.parse(line)
377    } catch {
378      continue
379    }
380    if (isRecord(row) && row.type === 'system' && row.subtype === 'bridge_status' && typeof row.url === 'string' && WEB_LINK.test(row.url)) {
381      found = row.url
382    }
383  }
384  return found
385}
386
387// A Desktop session's local id, whole: the one shape both routes of Open in
388// app take from a card.
389const DESKTOP_ID = /^local_[0-9a-f-]{1,80}$/
390
391/** The app's own link to a card's session, of one of the two known shapes,
392 *  or undefined: a Desktop session by its local id, or a Remote Control
393 *  session by its claude.ai path under the app's scheme. */
394export function appLink(c: Card, found: string | undefined): string | undefined {
395  if (c.desktopId !== undefined && DESKTOP_ID.test(c.desktopId)) {
396    return `claude://claude.ai/epitaxy/${c.desktopId}`
397  }
398  const web = c.url ?? found
399  return web !== undefined && WEB_LINK.test(web)
400    ? `claude://claude.ai/code/${web.slice('https://claude.ai/code/'.length)}`
401    : undefined
402}
403
404// Open in app through the Desktop app's own tool, which shows a session this
405// lead started beside it. The tool, its server and the target are constants:
406// no roster text names any of them, and only a checked Desktop id is sent.
407export const OPEN_TOOL = 'mcp__ccd_window__open_session_in'
408export const OPEN_SERVER = 'ccd_window'
409export const OPEN_NAME = 'open_session_in'
410export const OPEN_TARGET = 'split'
411export const OPEN_WAIT_MS = 5000
412export const OPENED = 'Opened in the app.'
413export const NOT_OPENED = 'The app did not open it: '
414
415/** The session id a press asks the Desktop app's tool to show, or undefined
416 *  when the card takes the link route alone: only a Desktop id of the checked
417 *  shape. A background card, a Remote Control link or any other text in the
418 *  card never reaches the tool. */
419export function toolSession(c: Card): string | undefined {
420  return c.desktopId !== undefined && DESKTOP_ID.test(c.desktopId) ? c.desktopId : undefined
421}
422
423/** Whether the engine lists the Desktop app's tool by exactly its name. The
424 *  owner's rules are matched on that name, so the press asks about no other;
425 *  the terminal lists none. The list is read as untrusted. */
426export function listsOpenTool(tools: unknown): boolean {
427  return Array.isArray(tools) && tools.some(t => isRecord(t) && t.name === OPEN_TOOL)
428}
429
430/** Whether the owner's rules let a press call the tool, from the engine's
431 *  verdict, read as untrusted: `allow`, or an `ask` that names no rule, which
432 *  is a mode's; and an organisation's ceiling, where the engine reports one, of
433 *  `allow`. A `deny`, an `ask` naming a rule, a lower ceiling or anything else
434 *  keeps the press on the link route. */
435export function rulesAllowTool(verdict: unknown): boolean {
436  if (!isRecord(verdict)) return false
437  if (verdict.ceiling !== undefined && verdict.ceiling !== 'allow') return false
438  if (verdict.decision === 'allow') return true
439  return verdict.decision === 'ask' && verdict.rule === undefined
440}
441
442// The first line of a text block, made safe to draw and cut to the pane's
443// one-line length. The line is taken before cleaning, which folds breaks.
444const firstLine = (text: string) => oneLine(text.split(/\r\n|[\n\r\u{2028}\u{2029}]/u)[0] ?? '', 200)
445
446/** What the Desktop app's answer means, read as untrusted, since another
447 *  plugin can answer in the app's place. Exactly one of three outcomes:
448 *  opened with the answer's first line to show; opened with no text, shown as
449 *  a fixed line; or not opened, with the app's own reason when it gave one as
450 *  text. Nothing in it throws. */
451export function readOpenAnswer(answer: unknown): { opened: true; toast: string } | { opened: false; reason?: string } {
452  try {
453    if (!isRecord(answer) || !Array.isArray(answer.content) || typeof answer.isError !== 'boolean') return { opened: false }
454    const first: unknown = answer.content[0]
455    const text = isRecord(first) && first.type === 'text' && typeof first.text === 'string' ? firstLine(first.text) : ''
456    if (answer.isError === false) return { opened: true, toast: text === '' ? OPENED : text }
457    return text === '' ? { opened: false } : { opened: false, reason: `${NOT_OPENED}${text}` }
458  } catch {
459    // A field that throws when read is not an answer.
460    return { opened: false }
461  }
462}
463
464// The wrapper the engine puts round a message from another session. Only a
465// wrapper that opens the delivery counts, so a body quoting one names nobody.
466const WRAPPER = /^\s*<cross-session-message\s+from="([^"]{1,200})"[^>]*>/
467
468/** A report's claimed sender and its first line of text, both made safe to
469 *  draw. The sender is the wrapper's claim, never a credential. */
470export function report(raw: string): { from: string; line: string } {
471  const m = WRAPPER.exec(raw)
472  const body = (m === null ? raw : raw.slice(m[0].length)).replace(/<\/cross-session-message>\s*$/, '')
473  const line = body.split('\n').find(l => l.trim() !== '') ?? ''
474  return { from: oneLine(m?.[1] ?? 'a session', MAX_TITLE), line: oneLine(line, 200) }
475}
476
477/** A lookup over pairs that never reaches a prototype: a Map, built per read. */
478export const lookup = (pairs: readonly Pair[]) => new Map(pairs.map(p => [p.name, p.value]))
479
brigade/view.ts 738 lines
1import { oneLine, transcriptFile } from './roster.ts'
2import type { AgentRow } from './roster.ts'
3import type { Pair, UsageCategory, UsageLimit, UsageSnapshot, Warmth } from './types'
4
5// What the Brigade pane draws, worked out from plain values: the render hook
6// in register.tsx only turns these results into elements. Plain TypeScript
7// with erasable syntax only and no engine import, so `node --test` imports
8// this file as it is.
9//
10// The to-dos: a tick that can be undone for a grace period, then moves the
11// to-do to done.
12//
13// The cache warmth: each card's ◆ line, read from the last real model call in
14// its session's transcript, and which transcripts the agents poll reads.
15//
16// The usage section: this session's own rate limits and context fill, at the
17// bottom of the pane. On the terminal it is rows of text bars. Everywhere else
18// it is an SVG image, built here as a string. Every piece of text in that
19// string goes through `svgText`, and every attribute value is a number this
20// module worked out or a name from the fixed set in `STYLE`.
21
22/** A snapshot before the first reading: what the pane holds until one comes. */
23export const EMPTY_USAGE: UsageSnapshot = { limits: [], context: {} }
24
25// What a stored snapshot may hold. A reading comes from the engine, and a
26// rate limit's kind may come from a gateway, so each field is checked and cut
27// here as well as where it is drawn. Plugin state is readable by other
28// plugins, so only the fields the view draws are kept.
29const MAX_LIMITS = 20
30const MAX_CATEGORIES = 50
31const MAX_STORED_TEXT = 200
32const MAX_PERCENT = 100000
33const MAX_TOKENS = 1e10
34
35const isRecord = (v: unknown): v is Record<string, unknown> =>
36  typeof v === 'object' && v !== null && !Array.isArray(v)
37const inRange = (v: unknown, max: number): v is number =>
38  typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= max
39const storedText = (v: unknown): v is string => typeof v === 'string' && v.length <= MAX_STORED_TEXT
40
41// One rate limit, one context and one list of breakdown rows, each checked.
42// A reading names a limit's percent `percentUsed`; a stored snapshot, `percent`.
43function limitsFrom(rows: unknown, percentKey: 'percentUsed' | 'percent'): UsageLimit[] {
44  const out: UsageLimit[] = []
45  if (!Array.isArray(rows)) return out
46  for (const r of rows.slice(0, MAX_LIMITS)) {
47    if (!isRecord(r) || !storedText(r.kind) || !inRange(r[percentKey], MAX_PERCENT)) continue
48    const limit: UsageLimit = { kind: r.kind, percent: r[percentKey] as number }
49    if (typeof r.resetsAt === 'string' && r.resetsAt.length <= 40) limit.resetsAt = r.resetsAt
50    out.push(limit)
51  }
52  return out
53}
54
55function contextFrom(ctx: unknown): UsageSnapshot['context'] {
56  const out: UsageSnapshot['context'] = {}
57  if (!isRecord(ctx)) return out
58  if (inRange(ctx.percent, MAX_PERCENT)) out.percent = ctx.percent
59  if (inRange(ctx.tokens, MAX_TOKENS)) out.tokens = ctx.tokens
60  if (inRange(ctx.window, MAX_TOKENS)) out.window = ctx.window
61  return out
62}
63
64function categoriesFrom(rows: unknown[]): UsageCategory[] {
65  const out: UsageCategory[] = []
66  for (const c of rows.slice(0, MAX_CATEGORIES)) {
67    if (!isRecord(c) || !storedText(c.name) || typeof c.kind !== 'string' || !KINDS.has(c.kind) || !inRange(c.tokens, MAX_TOKENS)) continue
68    out.push({ name: c.name, kind: c.kind, tokens: c.tokens })
69  }
70  return out
71}
72
73/** The fields the view draws from a `$.session.usage()` reading, each checked:
74 *  per rate limit its kind, percent and reset time; the context's percent,
75 *  tokens and window; per breakdown row its name, kind and tokens. A field
76 *  that fails its check is left out, and a row whose kind or number fails is
77 *  dropped. */
78export function snapshotFrom(reading: unknown): UsageSnapshot {
79  if (!isRecord(reading)) return { limits: [], context: {} }
80  const out: UsageSnapshot = { limits: limitsFrom(reading.rateLimits, 'percentUsed'), context: contextFrom(reading.context) }
81  const b = isRecord(reading.context) ? reading.context.breakdown : undefined
82  if (isRecord(b) && Array.isArray(b.categories)) out.categories = categoriesFrom(b.categories)
83  return out
84}
85
86/** A stored snapshot checked again before it is drawn, by the same rules.
87 *  Only `snapshotFrom` writes it, but plugin state is the engine's, and the
88 *  engine lets another plugin rewrite a value as it is set: a value of the
89 *  wrong shape draws as far as it checks, and never throws. */
90export function storedSnapshot(stored: unknown): UsageSnapshot {
91  if (!isRecord(stored)) return { limits: [], context: {} }
92  const out: UsageSnapshot = { limits: limitsFrom(stored.limits, 'percent'), context: contextFrom(stored.context) }
93  if (Array.isArray(stored.categories)) out.categories = categoriesFrom(stored.categories)
94  return out
95}
96const KINDS = new Set(['used', 'free', 'buffer', 'deferred'])
97
98// The names the app gives its own windows. Looked up in a Map, never a plain
99// object: a kind is free text, and on a plain object `constructor` or
100// `toString` would answer with a function.
101const LIMIT_NAMES = new Map([
102  ['five_hour', 'Current session'],
103  ['seven_day', 'Weekly limit'],
104  ['spend_limit', 'Spend limit'],
105])
106const MAX_KIND = 30
107const MAX_CATEGORY = 60
108
109/** A rate limit's name as drawn: the app's own for a window it knows, else
110 *  its kind made safe to draw on one line and cut to 30 characters. */
111export const limitName = (kind: string): string => LIMIT_NAMES.get(kind) ?? oneLine(kind, MAX_KIND)
112
113// An ISO 8601 time with seconds and either Z or an offset, read by hand so
114// every engine reads it alike.
115const ISO = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/
116
117function isoTime(text: string): number | undefined {
118  const m = ISO.exec(text)
119  if (m === null) return undefined
120  const [, y, mo, d, h, mi, s, frac, zone] = m
121  const month = Number(mo)
122  const day = Number(d)
123  const hour = Number(h)
124  const minute = Number(mi)
125  const second = Number(s)
126  if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return undefined
127  const ms = frac === undefined ? 0 : Number(frac.padEnd(3, '0').slice(0, 3))
128  let at = Date.UTC(Number(y), month - 1, day, hour, minute, second, ms)
129  if (zone !== undefined && zone !== 'Z') {
130    const sign = zone.startsWith('-') ? -1 : 1
131    const zh = Number(zone.slice(1, 3))
132    const zm = Number(zone.slice(4, 6))
133    if (zh > 23 || zm > 59) return undefined
134    at -= sign * (zh * 60 + zm) * 60000
135  }
136  // Date.UTC rolls 31 April over to 1 May; a day that rolled is refused.
137  return new Date(Date.UTC(Number(y), month - 1, day)).getUTCDate() === day && Number.isFinite(at) ? at : undefined
138}
139
140/** "Resets in N d N hr", "Resets in N hr N min" or "Resets in N min", or
141 *  undefined when the time is missing or not a valid ISO time. A time in the
142 *  past reads as 0 min. */
143export function resetsIn(iso: string | undefined, now: number): string | undefined {
144  if (iso === undefined) return undefined
145  const at = isoTime(iso)
146  if (at === undefined || !Number.isFinite(now)) return undefined
147  const mins = Math.max(0, Math.round((at - now) / 60000))
148  const d = Math.floor(mins / 1440)
149  const h = Math.floor((mins % 1440) / 60)
150  const m = mins % 60
151  return `Resets in ${d > 0 ? `${d} d ${h} hr` : h > 0 ? `${h} hr ${m} min` : `${m} min`}`
152}
153
154/** A token count as the pane draws it: "Nk" from 10,000, "N.Nk" from 1,000,
155 *  and "N" below that. */
156export function tokens(n: number): string {
157  if (!Number.isFinite(n) || n < 0) return '0'
158  if (n >= 10000) return `${Math.round(n / 1000)}k`
159  if (n >= 1000) return `${(Math.floor(n / 100) / 10).toFixed(1)}k`
160  return `${Math.round(n)}`
161}
162
163const percentText = (p: number) => `${Math.round(p)}%`
164
165// From 75% a bar is amber, from 90% red.
166const level = (p: number) => (p >= 90 ? 'hot' : p >= 75 ? 'warn' : 'ok')
167const TONE = new Map([
168  ['ok', 'success'],
169  ['warn', 'warning'],
170  ['hot', 'error'],
171])
172
173/** One row of the terminal's text bars. `tone` is a theme colour key. */
174export type UsageRow = { name: string; bar: string; percent: string; note: string; tone: string }
175
176/** What the usage section draws: nothing yet, text bars, or an SVG image. */
177export type UsageView =
178  | { kind: 'none' }
179  | { kind: 'text'; rows: UsageRow[] }
180  | { kind: 'svg'; source: string; alt: string; width: number; height: number }
181
182const BAR_CELLS = 20
183
184/** The engine's cap on an SVG's source. */
185export const SVG_MAX = 131072
186/** The most breakdown rows the legend lists. */
187export const LEGEND_MAX = 12
188
189/** The breakdown rows the context bar draws: not the tools loaded on demand,
190 *  which sit outside the window, and not a row with no tokens. */
191const drawnCategories = (s: UsageSnapshot) =>
192  (s.categories ?? []).filter(c => c.kind !== 'deferred' && Number.isFinite(c.tokens) && c.tokens > 0)
193
194/** The usage section for one surface at one moment, from the stored snapshot,
195 *  checked again here. With no rate limit, no breakdown and no context reading
196 *  it is nothing yet, on every surface. The terminal gets text bars: a Context
197 *  row from the context's percent and tokens, then a row per rate limit. Every
198 *  other surface gets the SVG, or the text bars when there is no rate limit
199 *  and no breakdown to draw or the SVG would pass the engine's cap. max is
200 *  that cap; the stored caps keep a real snapshot far below it, so a test
201 *  passes a smaller one to reach the fallback. */
202export function usageView(stored: unknown, surface: string, now: number, max = SVG_MAX): UsageView {
203  const s = storedSnapshot(stored)
204  if (s.limits.length === 0 && drawnCategories(s).length === 0 && s.context.percent === undefined) return { kind: 'none' }
205  if (surface !== 'terminal') {
206    const svg = usageSvg(s, now)
207    if (svg !== undefined && svg.source.length <= max) return svg
208  }
209  const rows = textRows(s, now)
210  return rows.length === 0 ? { kind: 'none' } : { kind: 'text', rows }
211}
212
213function textRows(s: UsageSnapshot, now: number): UsageRow[] {
214  const rows: Omit<UsageRow, 'name'>[] = []
215  const names: string[] = []
216  const cells = (p: number) => {
217    const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((p / 100) * BAR_CELLS)))
218    return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
219  }
220  const { percent, tokens: used, window } = s.context
221  if (percent !== undefined && Number.isFinite(percent)) {
222    names.push('Context')
223    rows.push({
224      bar: cells(percent),
225      percent: percentText(percent),
226      note: used !== undefined && window !== undefined ? `${tokens(used)} of ${tokens(window)}` : '',
227      tone: TONE.get(level(percent)) ?? 'success',
228    })
229  }
230  for (const limit of s.limits) {
231    if (!Number.isFinite(limit.percent)) continue
232    names.push(limitName(limit.kind))
233    rows.push({
234      bar: cells(limit.percent),
235      percent: percentText(limit.percent),
236      note: resetsIn(limit.resetsAt, now) ?? '',
237      tone: TONE.get(level(limit.percent)) ?? 'success',
238    })
239  }
240  const width = Math.max(0, ...names.map(n => [...n].length))
241  return rows.map((r, i) => ({ name: (names[i] ?? '').padEnd(width), ...r }))
242}
243
244// The image cannot read the app's theme, so it carries a palette of its own
245// that only approximates the app's, with a dark-mode rule. The class names
246// here are the only ones the SVG uses.
247const STYLE = [
248  "text{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;font-size:12px;fill:#3d3d3a}",
249  '.muted{fill:#73726c}',
250  '.track{fill:#e8e6dc}',
251  '.ok{fill:#2c84db}.warn{fill:#c27c0e}.hot{fill:#c6413a}',
252  '.free{fill:#e8e6dc}.buffer{fill:#d6d3c8}',
253  '.c0{fill:#d97757}.c1{fill:#6a9bcc}.c2{fill:#788c5d}.c3{fill:#c46686}.c4{fill:#8b7fc7}.c5{fill:#c9a227}.c6{fill:#5fa8a0}',
254  '@media (prefers-color-scheme: dark){text{fill:#e8e6e1}.muted{fill:#9c9a92}.track,.free{fill:#3a3935}.buffer{fill:#4a4944}.ok{fill:#5aa2ef}.warn{fill:#e8a23a}.hot{fill:#ef6b62}}',
255].join('')
256
257const W = 480
258const XMLNS = 'http://www.w3.org/2000/svg'
259
260// Code points XML forbids even escaped: a lone surrogate, U+FFFE and U+FFFF.
261// One of them makes the whole image fail to draw. The controls XML forbids
262// are gone already: the one-line cleaner makes them spaces.
263const XML_INVALID = /[\u{D800}-\u{DFFF}\u{FFFE}\u{FFFF}]/gu
264const XML_ESCAPE = new Map([
265  ['&', '&amp;'],
266  ['<', '&lt;'],
267  ['>', '&gt;'],
268  ['"', '&quot;'],
269  ["'", '&#39;'],
270])
271
272/** Text made safe for the SVG's element content: the one-line cleaner, cut to
273 *  `max`, then the code points XML forbids removed, then the XML escape. */
274export function svgText(text: string, max: number): string {
275  return oneLine(text, max)
276    .replace(XML_INVALID, '')
277    .replace(/[&<>"']/g, ch => XML_ESCAPE.get(ch) ?? '')
278}
279
280/** A number as an attribute value: finite, clamped to the drawing's range and
281 *  rounded to two places, so no NaN or Infinity is ever written. */
282export function num(v: number, max = 100000): string {
283  const n = Number.isFinite(v) ? Math.min(max, Math.max(0, v)) : 0
284  return String(Math.round(n * 100) / 100)
285}
286
287// A name on the left, a note on the right, and a thin rounded bar beneath.
288function barRow(y: number, name: string, right: string, percent: number): string {
289  const fill = (Math.max(0, Math.min(100, percent)) / 100) * W
290  return (
291    `<text x="0" y="${num(y + 12)}">${svgText(name, MAX_KIND)}</text>` +
292    `<text class="muted" x="${num(W)}" y="${num(y + 12)}" text-anchor="end">${svgText(right, 80)}</text>` +
293    `<rect class="track" x="0" y="${num(y + 20)}" width="${num(W)}" height="6" rx="3"/>` +
294    (fill > 0 ? `<rect class="${level(percent)}" x="0" y="${num(y + 20)}" width="${num(Math.max(6, fill))}" height="6" rx="3"/>` : '')
295  )
296}
297
298function usageSvg(s: UsageSnapshot, now: number): Extract<UsageView, { kind: 'svg' }> | undefined {
299  const parts: string[] = []
300  const alt: string[] = []
301  let y = 0
302
303  for (const limit of s.limits) {
304    if (!Number.isFinite(limit.percent)) continue
305    const name = limitName(limit.kind)
306    const when = resetsIn(limit.resetsAt, now)
307    parts.push(barRow(y, name, when === undefined ? percentText(limit.percent) : `${when} · ${percentText(limit.percent)}`, limit.percent))
308    alt.push(`${name} ${percentText(limit.percent)}`)
309    y += 38
310  }
311
312  const shown = drawnCategories(s)
313  if (shown.length > 0) {
314    // The bar's segments are the breakdown's rows, free space and buffer
315    // included. The figures beside it are the engine's own context reading,
316    // the ones the terminal's Context row shows, so one reading shows one
317    // figure everywhere; only without that reading are they summed from the
318    // rows: used is the rows of kind `used`, the window every row drawn.
319    const total = shown.reduce((sum, c) => sum + c.tokens, 0)
320    const used = shown.filter(c => c.kind === 'used')
321    const { percent: ctxPercent, tokens: ctxTokens, window: ctxWindow } = s.context
322    const engine = ctxPercent !== undefined && ctxTokens !== undefined && ctxWindow !== undefined
323    const usedTokens = engine ? ctxTokens : used.reduce((sum, c) => sum + c.tokens, 0)
324    const windowTokens = engine ? ctxWindow : total
325    const usedPercent = engine ? ctxPercent : total > 0 ? (usedTokens / total) * 100 : 0
326    const cls = (c: UsageCategory) => (c.kind === 'free' ? 'free' : c.kind === 'buffer' ? 'buffer' : `c${Math.max(0, used.indexOf(c)) % 7}`)
327
328    parts.push(`<text x="0" y="${num(y + 12)}">${svgText('Context', MAX_KIND)}</text>`)
329    parts.push(
330      `<text class="muted" x="${num(W)}" y="${num(y + 12)}" text-anchor="end">${svgText(`${tokens(usedTokens)} / ${tokens(windowTokens)} · ${percentText(usedPercent)}`, 80)}</text>`,
331    )
332    parts.push(`<clipPath id="cb"><rect x="0" y="${num(y + 20)}" width="${num(W)}" height="8" rx="4"/></clipPath>`)
333    parts.push(`<rect class="track" x="0" y="${num(y + 20)}" width="${num(W)}" height="8" rx="4"/>`)
334    const segments: string[] = []
335    let x = 0
336    for (const c of shown) {
337      const w = total > 0 ? (c.tokens / total) * W : 0
338      // A hairline gap between used segments, as the app's segmented bar has.
339      const gap = c.kind === 'used' && w > 2 ? 1 : 0
340      segments.push(`<rect class="${cls(c)}" x="${num(x)}" y="${num(y + 20)}" width="${num(w - gap)}" height="8"/>`)
341      x += w
342    }
343    parts.push(`<g clip-path="url(#cb)">${segments.join('')}</g>`)
344    y += 40
345
346    // The legend: two columns of dot, name and tokens, at most LEGEND_MAX rows.
347    const legend = shown.slice(0, LEGEND_MAX)
348    const col = W / 2
349    legend.forEach((c, i) => {
350      const cx = (i % 2) * col
351      const cy = y + Math.floor(i / 2) * 18
352      parts.push(`<circle class="${cls(c)}" cx="${num(cx + 4)}" cy="${num(cy + 8)}" r="4"/>`)
353      parts.push(`<text x="${num(cx + 14)}" y="${num(cy + 12)}">${svgText(c.name, MAX_CATEGORY)}</text>`)
354      parts.push(`<text class="muted" x="${num(cx + col - 10)}" y="${num(cy + 12)}" text-anchor="end">${svgText(tokens(c.tokens), 20)}</text>`)
355    })
356    y += Math.ceil(legend.length / 2) * 18
357    alt.push(`Context ${percentText(usedPercent)}: ${legend.map(c => `${oneLine(c.name, MAX_CATEGORY)} ${tokens(c.tokens)}`).join(', ')}`)
358  }
359
360  if (y === 0) return undefined
361  const height = num(y)
362  const source =
363    `<svg xmlns="${XMLNS}" width="${num(W)}" height="${height}" viewBox="0 0 ${num(W)} ${height}">` +
364    `<style>${STYLE}</style>${parts.join('')}</svg>`
365  return { kind: 'svg', source, alt: oneLine(`Usage: ${alt.join('; ')}`, 2000), width: W, height: Number(height) }
366}
367
368// --- Tick and undo ---------------------------------------------------------
369//
370// A press on a to-do's box ticks it: the to-do stays, crossed out, for the
371// grace period, and a second press inside it undoes the tick. The roster
372// timer's sweep then moves it to done. `ticking` holds pairs of to-do id and
373// tick time, in epoch ms as text.
374
375/** How long a ticked to-do stays, crossed out, before the sweep moves it. */
376export const GRACE_MS = 30000
377const MAX_TICKS = 50
378
379/** The stored ticks, checked again: a list of pairs whose name is text within
380 *  the stored cap, the first of a repeated name kept, at most 50. A value
381 *  that is not text is kept as an empty value, which the sweep counts as due.
382 *  Plugin state is the engine's, and another plugin may rewrite it. */
383export function ticksFrom(stored: unknown): Pair[] {
384  const out: Pair[] = []
385  if (!Array.isArray(stored)) return out
386  const names = new Set<string>()
387  for (const p of stored) {
388    if (out.length >= MAX_TICKS) break
389    if (!isRecord(p) || typeof p.name !== 'string' || p.name === '' || p.name.length > MAX_STORED_TEXT || names.has(p.name)) continue
390    names.add(p.name)
391    out.push({ name: p.name, value: typeof p.value === 'string' ? p.value : '' })
392  }
393  return out
394}
395
396/** The stored done list, checked again: the text entries of a list, and
397 *  nothing from any other shape, so a value another plugin wrote cannot make
398 *  the render throw. */
399export function idsFrom(stored: unknown): string[] {
400  return Array.isArray(stored) ? stored.filter((v): v is string => typeof v === 'string') : []
401}
402
403/** A press: tick the to-do with the press time, or undo its tick. */
404export function toggleTick(stored: unknown, id: string, now: number): Pair[] {
405  const ticking = ticksFrom(stored)
406  return ticking.some(p => p.name === id) ? ticking.filter(p => p.name !== id) : [...ticking, { name: id, value: String(now) }]
407}
408
409// Due: the grace period has passed, the time is not a number, or the time is
410// more than a grace period ahead of the clock, so a planted far-future time
411// cannot keep a to-do crossed out for good.
412const isDue = (p: Pair, now: number) => {
413  const age = now - Number(p.value)
414  return !Number.isFinite(age) || age >= GRACE_MS || age <= -GRACE_MS
415}
416
417/** The sweep: a tick whose to-do is no longer in the roster is dropped, a due
418 *  one moves to `done`, and the rest stay. `todoIds` is undefined when the
419 *  roster load failed or found no file: then every tick stays, due ones too,
420 *  so a brief miss cannot silently undo a tick. */
421export function sweepTicks(stored: unknown, now: number, todoIds: readonly string[] | undefined): { ticking: Pair[]; done: string[] } {
422  const ticking = ticksFrom(stored)
423  if (todoIds === undefined) return { ticking, done: [] }
424  const present = new Set(todoIds)
425  const kept: Pair[] = []
426  const done: string[] = []
427  for (const p of ticking) {
428    if (!present.has(p.name)) continue
429    if (isDue(p, now)) done.push(p.name)
430    else kept.push(p)
431  }
432  return { ticking: kept, done }
433}
434
435/** One write to a stored value, as the engine's `update` makes it: the
436 *  function may run more than once, and the last run's result is written. */
437export type Updater<T> = (fn: (value: T) => T) => Promise<unknown>
438
439/** The roster timer's sweep, carried out. The due ids are worked out inside
440 *  the `ticking` update, from the value that update writes over, so an undo
441 *  that lands between a read and a write cannot be lost to a stale read. Then
442 *  those ids, and no others, are added to `doneTodos`, each once. With no
443 *  roster load to go by (`todoIds` undefined) nothing is written. Resolves the
444 *  ids it moved. */
445export async function settleTicks(
446  ticking: Updater<Pair[]>,
447  doneTodos: Updater<string[]>,
448  now: number,
449  todoIds: readonly string[] | undefined,
450): Promise<string[]> {
451  if (todoIds === undefined) return []
452  let due: string[] = []
453  await ticking(list => {
454    const swept = sweepTicks(list, now, todoIds)
455    due = swept.done
456    return swept.ticking
457  })
458  if (due.length > 0) {
459    const moved = due
460    await doneTodos(list => {
461      const had = idsFrom(list)
462      const seen = new Set(had)
463      return [...had, ...moved.filter(id => !seen.has(id))]
464    })
465  }
466  return due
467}
468
469/** The count beside "Waiting on you": the cards that need the owner, plus the
470 *  to-dos that are neither done nor ticking. */
471export function waitingCount(needsYou: number, todos: readonly { id: string }[], done: unknown, ticking: unknown): number {
472  const out = new Set([...idsFrom(done), ...ticksFrom(ticking).map(p => p.name)])
473  return needsYou + todos.filter(t => !out.has(t.id)).length
474}
475
476// --- Cache warmth ----------------------------------------------------------
477//
478// A session's prompt cache lasts for the window its last cache write asked
479// for, one hour or five minutes, from its last model call. The ◆ line says
480// how long the session has been idle, when it goes cold, and how big its
481// context is. All of it comes from the session's own transcript, text another
482// session wrote, so every row is checked for shape and size before it counts.
483
484/** The engine refuses to read a file over 4 MiB, so the pane does not try. */
485export const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024
486/** From this many context tokens a cold cache costs enough to nudge about. */
487export const NUDGE_TOKENS = 100000
488
489const MIN_MS = 60000
490const HOUR_MS = 60 * MIN_MS
491const SHORT_MS = 5 * MIN_MS
492const AMBER_MS = 15 * MIN_MS
493// A row may claim a time up to this much past the file's modified time, for
494// the clock's slack; a later one claims to be newer than the file it is in.
495const SLACK_MS = 2 * MIN_MS
496const MAX_COUNT = 10000000
497const MAX_WARMTH = 50
498const MAX_NAME = 300
499
500/** The last real model call in a transcript: its time, its context tokens,
501 *  and its cache window when a call that wrote cache says it. */
502export type LastCall = { at: number; tokens: number; windowMs?: number }
503
504// The time Claude Code writes, ISO in UTC: an offset is refused.
505const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/
506
507// A token count as a row may give it: absent counts as 0, else a whole
508// number from 0 to 10,000,000. Anything else spoils the row.
509const count = (v: unknown): number | undefined =>
510  v === undefined ? 0 : typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_COUNT ? v : undefined
511
512// One transcript line as a model call, or undefined when it is not a real one
513// or fails a check: a `<synthetic>` row, an API error, a sidechain, an offset
514// or unreadable time, a time past `latest`, a count out of range, or no
515// context at all. Claude Code writes an assistant-shaped row with zero usage
516// when a turn ends on an error, and taking it would show a cold session warm.
517function callRow(line: string, latest: number): LastCall | undefined {
518  let r: unknown
519  try {
520    r = JSON.parse(line)
521  } catch {
522    return undefined
523  }
524  if (!isRecord(r) || r.type !== 'assistant' || typeof r.timestamp !== 'string') return undefined
525  if (r.isApiErrorMessage === true || r.isSidechain === true) return undefined
526  const m = r.message
527  if (!isRecord(m) || !isRecord(m.usage) || m.model === '<synthetic>') return undefined
528  if (!UTC.test(r.timestamp)) return undefined
529  const at = isoTime(r.timestamp)
530  if (at === undefined || at > latest) return undefined
531  const u = m.usage
532  const input = count(u.input_tokens)
533  const made = count(u.cache_creation_input_tokens)
534  const cached = count(u.cache_read_input_tokens)
535  if (input === undefined || made === undefined || cached === undefined) return undefined
536  let short: number | undefined = 0
537  let long: number | undefined = 0
538  if (u.cache_creation !== undefined) {
539    if (!isRecord(u.cache_creation)) return undefined
540    short = count(u.cache_creation.ephemeral_5m_input_tokens)
541    long = count(u.cache_creation.ephemeral_1h_input_tokens)
542    if (short === undefined || long === undefined) return undefined
543  }
544  const tokens = input + made + cached
545  if (tokens === 0) return undefined
546  const call: LastCall = { at, tokens }
547  if (long > 0) call.windowMs = HOUR_MS
548  else if (short > 0) call.windowMs = SHORT_MS
549  return call
550}
551
552/** The last real model call in a transcript's text, or undefined. Scans from
553 *  the end for the first row that passes every check, then on back, in the
554 *  same scan, for the last row that wrote cache, whose window it takes: a
555 *  call that only read the cache does not say its window. With no such row
556 *  the window is unknown. `mtimeMs` is the file's modified time, used only to
557 *  refuse a row that claims a time more than 2 minutes after it, never as
558 *  the call's time. */
559export function lastCall(text: string, mtimeMs: number): LastCall | undefined {
560  if (!Number.isFinite(mtimeMs)) return undefined
561  const latest = mtimeMs + SLACK_MS
562  const lines = text.split('\n')
563  let found: LastCall | undefined
564  for (let i = lines.length - 1; i >= 0; i--) {
565    const line = lines[i] ?? ''
566    if (!line.includes('"assistant"')) continue
567    const call = callRow(line, latest)
568    if (call === undefined) continue
569    found ??= { at: call.at, tokens: call.tokens }
570    if (call.windowMs !== undefined) {
571      found.windowMs = call.windowMs
572      break
573    }
574  }
575  return found
576}
577
578/** A live state that means the session is working: its cache is warm. */
579// A background row with no `status` says it in `state`, which reads `working`
580// while it works.
581export const isWorking = (state: string | undefined) => state === 'busy' || state === 'running' || state === 'working'
582
583/** A live state as the card draws it, with its theme colour: `busy`,
584 *  `running` and `working` success, `idle` and `blocked` warning, anything
585 *  else inactive. */
586export function liveState(state: string | undefined): { text: string; color: string } {
587  if (state === undefined) return { text: 'not running', color: 'inactive' }
588  return { text: state, color: isWorking(state) ? 'success' : state === 'idle' || state === 'blocked' ? 'warning' : 'inactive' }
589}
590
591const KINDS_OF_WARMTH = new Set(['call', 'too-large', 'shared', 'unread'])
592const OTHER_KINDS = new Map<string, 'too-large' | 'shared' | 'unread'>([
593  ['too-large', 'too-large'],
594  ['shared', 'shared'],
595  ['unread', 'unread'],
596])
597const WINDOWS = new Set([SHORT_MS, HOUR_MS])
598
599/** The stored warmth records, checked again: plugin state is the engine's,
600 *  and another plugin may rewrite it. A record of the wrong shape goes, a
601 *  repeated name keeps the first, and at most 50 stay. */
602export function warmthFrom(stored: unknown): Warmth[] {
603  const out: Warmth[] = []
604  if (!Array.isArray(stored)) return out
605  const names = new Set<string>()
606  for (const w of stored) {
607    if (out.length >= MAX_WARMTH) break
608    if (!isRecord(w) || typeof w.name !== 'string' || w.name === '' || w.name.length > MAX_NAME || names.has(w.name)) continue
609    if (typeof w.kind !== 'string' || !KINDS_OF_WARMTH.has(w.kind)) continue
610    if (w.kind === 'call') {
611      if (typeof w.at !== 'number' || !Number.isFinite(w.at)) continue
612      if (typeof w.tokens !== 'number' || !Number.isInteger(w.tokens) || w.tokens < 0 || w.tokens > 3 * MAX_COUNT) continue
613      if (w.windowMs !== undefined && (typeof w.windowMs !== 'number' || !WINDOWS.has(w.windowMs))) continue
614      const call: Warmth = { name: w.name, kind: 'call', at: w.at, tokens: w.tokens }
615      if (w.windowMs !== undefined) call.windowMs = w.windowMs
616      out.push(call)
617    } else {
618      const kind = OTHER_KINDS.get(w.kind)
619      if (kind === undefined) continue
620      out.push({ name: w.name, kind })
621    }
622    names.add(w.name)
623  }
624  return out
625}
626
627// "N min" under an hour, "N hr N min" from an hour.
628const span = (mins: number) => (mins >= 60 ? `${Math.floor(mins / 60)} hr ${mins % 60} min` : `${mins} min`)
629
630/** One card's ◆ line: its tone (`dim` or a theme colour), its text, and a
631 *  nudge for a large card that waits on the owner, or undefined with no
632 *  record. A transcript too large to read and a name two sessions share
633 *  read unknown, whatever the live state. A working session is warm, with no
634 *  size when its transcript has not been read yet. Else
635 *  the idle time sets the band: more than 15 min left green, 15 min or less
636 *  amber, none left red; with no known window, no countdown. The nudge needs
637 *  status `needs-you`, 100,000 or more tokens, a known window and a session
638 *  that is not working. */
639export function warmthLine(
640  w: Warmth | undefined,
641  state: string | undefined,
642  status: string,
643  now: number,
644): { tone: 'dim' | 'success' | 'warning' | 'error'; text: string; nudge?: string } | undefined {
645  if (w === undefined) return undefined
646  if (w.kind === 'too-large') return { tone: 'dim', text: '◆ cache unknown · transcript too large to read' }
647  if (w.kind === 'shared') return { tone: 'dim', text: '◆ cache unknown · two sessions share this name' }
648  if (w.kind === 'unread') return isWorking(state) ? { tone: 'dim', text: '◆ cache warm (working)' } : undefined
649  const size = `${tokens(w.tokens)} context`
650  if (isWorking(state)) return { tone: 'dim', text: `◆ cache warm (working) · ${size}` }
651  const idle = Math.max(0, now - w.at)
652  const idleText = span(Math.floor(idle / MIN_MS))
653  if (w.windowMs === undefined) return { tone: 'dim', text: `◆ cache window unknown · idle ${idleText} · ${size}` }
654  const left = w.windowMs - idle
655  const large = status === 'needs-you' && w.tokens >= NUDGE_TOKENS
656  if (left <= 0) {
657    const cold = { tone: 'error' as const, text: `◆ cache cold · idle ${idleText} · ${size}` }
658    return large
659      ? { ...cold, nudge: `Replying re-reads about ${tokens(w.tokens)} tokens at full price. Consider a hand-off through the issue to a fresh session.` }
660      : cold
661  }
662  const leftText = span(Math.ceil(left / MIN_MS))
663  const text = `◆ cache warm · idle ${idleText} · cold in ${leftText} · ${size}`
664  if (left > AMBER_MS) return { tone: 'success', text }
665  return large ? { tone: 'warning', text, nudge: `Reply within ${leftText} to keep the cache.` } : { tone: 'warning', text }
666}
667
668/** What the agents poll remembers of each transcript it read, keyed by path:
669 *  its modified time at that read and the last call parsed from it. */
670export type ReadMemory = Map<string, { mtimeMs: number; call: LastCall | undefined }>
671
672/** The file system as the poll reaches it, and whether the pane is still
673 *  open. `stat` and `read` may reject. */
674export type WarmthIo = {
675  live: () => boolean
676  stat: (path: string) => Promise<{ kind: string; size: number; mtimeMs: number; isLink?: boolean }>
677  read: (path: string) => Promise<string>
678}
679
680/** The agents poll's warmth reads, and the records they give, or undefined
681 *  when the pane closed on the way, so nothing is stored.
682 *
683 *  Only a roster member is read, by the transcript path its one row names. A
684 *  name on two rows is shared: neither transcript is read. Each path must
685 *  stat as a regular file, the file itself not a link. One over 4 MiB is not
686 *  read and records "too large". A transcript is read only when its modified
687 *  time differs from the one `memory` holds for its path, and never while
688 *  its session is working (`busy`, `running` or `working`). `memory` gets an
689 *  entry only after a read and a parse that succeeded, so a failed read is
690 *  tried again at the next poll; the record is then the last good one. A
691 *  working member with no known call records `unread`. Paths no member
692 *  names leave it. */
693export async function readWarmth(
694  rows: readonly AgentRow[],
695  titles: readonly string[],
696  config: string,
697  memory: ReadMemory,
698  io: WarmthIo,
699): Promise<Warmth[] | undefined> {
700  const members = new Set(titles)
701  const byName = new Map<string, AgentRow[]>()
702  for (const r of rows) {
703    if (members.has(r.name)) byName.set(r.name, [...(byName.get(r.name) ?? []), r])
704  }
705  const out: Warmth[] = []
706  const named = new Set<string>()
707  for (const [name, list] of byName) {
708    const row = list[0]
709    if (list.length > 1 || row === undefined) {
710      out.push({ name, kind: 'shared' })
711      continue
712    }
713    const path = transcriptFile(config, row)
714    if (path === undefined) continue
715    named.add(path)
716    if (!io.live()) return undefined
717    const stat = await io.stat(path).catch(() => undefined)
718    if (!io.live()) return undefined
719    if (stat === undefined || stat.kind !== 'file' || stat.isLink === true || !Number.isFinite(stat.mtimeMs)) continue
720    if (!Number.isFinite(stat.size) || stat.size > MAX_TRANSCRIPT_BYTES) {
721      out.push({ name, kind: 'too-large' })
722      continue
723    }
724    if (!isWorking(row.status) && memory.get(path)?.mtimeMs !== stat.mtimeMs) {
725      const text = await io.read(path).catch(() => undefined)
726      if (!io.live()) return undefined
727      if (text !== undefined) memory.set(path, { mtimeMs: stat.mtimeMs, call: lastCall(text, stat.mtimeMs) })
728    }
729    const call = memory.get(path)?.call
730    if (call !== undefined) out.push({ name, kind: 'call', ...call })
731    // Working with no known call: never read, or read before its first model
732    // call. No read runs while it works, so its card says it is working, with
733    // no size.
734    else if (isWorking(row.status)) out.push({ name, kind: 'unread' })
735  }
736  for (const path of [...memory.keys()]) if (!named.has(path)) memory.delete(path)
737  return out
738}
brigade/types/index.d.ts 90 lines
1/** The status the head chef writes on a card, from a fixed set. */
2export type Status = 'working' | 'needs-you' | 'done' | 'stopped'
3
4/** One session the lead started, as its roster card says. */
5export type Card = {
6  title: string
7  work: string
8  phase: string
9  settings: string
10  status: Status
11  /** A Desktop session's local id (local_...). */
12  desktopId?: string
13  /** A background session's id. */
14  bgId?: string
15  /** The session's https link on claude.ai, when the head chef knows it. */
16  url?: string
17}
18
19/** Something waiting on the owner that no card already shows. */
20export type Todo = { id: string; text: string; session?: string }
21
22/** A lead session's roster file, after its shape check. */
23export type Roster = { cards: Card[]; todos: Todo[] }
24
25/** A report from another session: its claimed sender and first line. */
26export type Report = { from: string; line: string; at: string }
27
28/** A pair from a lookup, kept as a list so no key reaches a prototype. */
29export type Pair = { name: string; value: string }
30
31/** Where the roster file is, and the one before a clear or a resume. */
32export type Files = { current: string; previous: string }
33
34/** What the engine said at session start: the id and its transcript path. */
35export type Start = { sessionId: string; transcript: string }
36
37/** One rate-limit window: its kind, how much of it is used, when it resets. */
38export type UsageLimit = { kind: string; percent: number; resetsAt?: string }
39
40/** One row of the context breakdown: its name, what it is, its tokens. */
41export type UsageCategory = { name: string; kind: string; tokens: number }
42
43/** This session's usage as the pane last read it: only the fields it draws.
44 *  `categories` is absent when no breakdown was asked for. */
45export type UsageSnapshot = {
46  limits: UsageLimit[]
47  context: { percent?: number; tokens?: number; window?: number }
48  categories?: UsageCategory[]
49}
50
51/** One card's cache warmth, from its session's transcript: the last real
52 *  model call's time, its context tokens and its cache window (absent when no
53 *  call that wrote cache says it); or unknown, because the transcript is too
54 *  large to read or two sessions share the card's name; or not read yet,
55 *  because the session has been working since the pane first saw it. */
56export type Warmth =
57  | { name: string; kind: 'call'; at: number; tokens: number; windowMs?: number }
58  | { name: string; kind: 'too-large' | 'shared' | 'unread' }
59
60declare module 'claude-code' {
61  interface PluginState {
62    grimoire: {
63      /** True while the pane is open. Nothing in the mod gates on it: the
64       *  hooks read the open pane from the module's own timers. */
65      armed: boolean
66      start: Start
67      files: Files
68      roster: Roster
69      rosterError: string
70      live: Pair[]
71      liveError: string
72      links: Pair[]
73      /** Session names whose transcript was read, found or not. */
74      looked: string[]
75      reports: Report[]
76      dismissed: string[]
77      doneTodos: string[]
78      /** Ticked to-dos still inside their grace period: the to-do's id and
79       *  the tick's time in epoch ms, as text. A second press removes one;
80       *  the roster timer's sweep moves a due one to `doneTodos`. */
81      ticking: Pair[]
82      /** This session's last usage reading, read while the pane is open. */
83      usage: UsageSnapshot
84      /** Each roster member's cache warmth, from the agents poll while the
85       *  pane is open. Checked again before it is drawn. */
86      warmth: Warmth[]
87    }
88  }
89}
90