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…

<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> <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> <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> <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.
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.
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.
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.
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.
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.md. The contract is one command:
node scripts/check.mjs
Security problems go through private reporting, not a public issue: SECURITY.md.
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/
brigade/register.tsx 969 lines1import { 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}
969brigade/roster.ts 479 lines1import 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]))
479brigade/view.ts 738 lines1import { 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 ['&', '&'],
266 ['<', '<'],
267 ['>', '>'],
268 ['"', '"'],
269 ["'", '''],
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 lines1/** 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