Shows which other Claude Code sessions on this machine are waiting for you, and what they are working on.

A Claude Code mod for people who run several sessions at once. Type /board in one window and it lists your other sessions above the prompt: which ones are waiting for you, which are still working, and on what.
<sub>An illustration, not a recording: the rows are the mod's own output for four made-up sessions, drawn as it draws them.</sub>
It draws nothing until you ask. Only the window you typed /board in shows the board; the rest stay as they were.
Claude Code 2.1.287 or later.
/plugin marketplace add dim-owl/roll-call
/plugin install roll-call@roll-call
/reload-plugins
Or from a terminal:
claude plugin marketplace add dim-owl/roll-call
claude plugin install roll-call@roll-call
Sessions that were already open pick the mod up after /reload-plugins. A session shows on the board only if the mod is loaded in it.
To update: claude plugin marketplace update roll-call, then claude plugin update roll-call@roll-call, then /reload-plugins. An install is a copy, so nothing changes until you update. To remove: claude plugin uninstall roll-call@roll-call, and delete the roll-call folder under your Claude Code folder.
| Command | Does |
|---|---|
/board | show the board above this window's prompt |
/board again | hide it |
ask api-review Should the default stay on? 4m
done docs Rewrote the install section; nothing left to do. now
work migration agents 3 shell 1 final-gate ████████░░ 14/18 · 6m move the billing tables 12m
wait frontend → api-review · sent the merge request
| Word | Means | Beside it |
|---|---|---|
ask | it asked you a question | the question |
approve | a tool call is waiting for your permission | the tool, and the program, file or host it is for |
error | its turn broke off on an API error | why (usage limit, overloaded, ...) |
done | its turn ended | the first line of its answer |
work | it is working | what you last asked of it |
wait | it sent a message to another session and stopped | which session |
idle | nothing asked yet | where it runs |
The time at the end of a row is how long that session has waited for you, or, for one at work, how long since you gave it the task.
Colour has one meaning each.
ask, approve, error; green done) means the session is calling for you. Green fades after five minutes; the row stays.work) means it is running.A working session also shows what it has running: agents 3 (its subagents), shell 2 (background shells), monitor 1, and a bar for each workflow run with agents finished over agents started. A session whose turn ended while any of that still runs reads as work, not done: it will wake by itself.
The mod only watches. Every hook passes its event on unchanged; it never denies, rewrites or delays a tool call or a prompt.
Each session writes one small file, <Claude Code folder>/roll-call/<session id>.json, and the board reads the files of the others (a text read from another session's file has its control characters taken out before it is drawn). A file holds:
/rename), the folder it works in and its git branchKnow this before installing: your prompts' first lines are kept on disk in that folder, and shown in any window where the board is on. If you share your screen, the board shares them too.
Files of sessions that ended stay behind (the mod API has no way to delete a file). They are skipped when read. Delete the roll-call folder whenever you like.
It reads, and never writes, the files Claude Code itself keeps of its sessions (<Claude Code folder>/sessions/<pid>.json), for one thing: the name you gave this session. Those files are not a documented interface; if a release changes them, a session goes by its folder until the mod catches up. It also runs git in the session's folder (rev-parse, twice). It makes no network requests and calls no model. claude plugin validate . prints exactly what it hooks and calls.
In /config, or under pluginConfigs in settings.json:
| Option | Default | Meaning |
|---|---|---|
language | en | the language of the board's own words: en or ko |
agents N. They show only in the bar.monitor N counts the monitors the session started itself. Claude Code files other things under the same name (the watch left by a published artifact, for one), and those never end, so they are left out.error (built from the documented event for a turn that ends on an API error; nobody has hit a usage limit with the board open yet), and going on after an in-session /resume or /clear under the new session id. If either misbehaves, please open an issue./board answers that the board is on, and nothing is drawn: that surface has no band above the prompt (seen on a session followed from the phone). Claude Code Desktop has such a band, so the board should draw there; nobody has looked yet. A session you follow from an app still gets its row on the board of a terminal window.workflow 1 from the session's next turn end.claude --plugin-dir . # load it; edits hot-reload
claude plugin validate .
claude plugin test .
| File | Holds |
|---|---|
hooks/register.tsx | the hooks, the file each session writes, and the drawing |
hooks/register.test.ts | the tests, run against the engine |
types/index.d.ts | the shape of a session's file and of the board's state |
hooks/register.tsx 853 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, Flow, Seen, Shown, Tag } from '../types'
5
6const TICK_MS = 10_000
7const BEAT_MS = 30_000
8const POLL_MS = 5_000
9// A session that missed three beats is taken for dead: its file is skipped.
10const STALE_MS = 90_000
11const MINUTE_MS = 60_000
12const LINE_CHARS = 160
13// A session that finished calls for the person this long; then its color goes
14// and it stays where it stood, with how long ago it ended.
15const DONE_CALL_MIN = 5
16// How a session reads on the board, in the order the rows stand: the ones that
17// need an answer, the ones that broke off, the ones that finished, the ones at
18// work, the ones that wait for another session, the ones not begun.
19// A color behind the word is a call for the person. One at work has its word
20// in the color the engine draws its own work in, with nothing behind it.
21const ROWS: Record<Exclude<Shown, 'gone'>, { color?: 'warning' | 'success'; ink?: string }> = {
22 asking: { color: 'warning' },
23 permit: { color: 'warning' },
24 failed: { color: 'warning' },
25 done: { color: 'success' },
26 working: { ink: 'claude' },
27 waiting: {},
28 idle: {},
29}
30const ORDER = Object.keys(ROWS)
31const TIME_CELLS = 5
32const GAPS = 3
33const BAR_CELLS = 18
34
35// The words of an answer that only tells the session to go on.
36const GO_ON: ReadonlySet<string> = new Set(
37 'ok okay k y yes yep yeah sure go on ahead continue proceed next do it keep going carry please then now thanks thank you ty'.split(' '),
38)
39
40// What the board says, in the language the person set (`language`). The words
41// for how a session stands are short: they stand in a column of their own.
42const TEXTS = {
43 en: {
44 words: { asking: 'ask', permit: 'approve', failed: 'error', done: 'done', working: 'work', waiting: 'wait', idle: 'idle' },
45 reasons: { rate_limit: 'usage limit', overloaded: 'overloaded', server_error: 'server error' },
46 minutes: (min: number) => `${min}m`,
47 now: 'now',
48 none: 'no other sessions · /board to hide',
49 command: 'Show or hide other Claude Code sessions on this machine above this window\'s prompt',
50 shown: 'The session board is on in this window. /board hides it.',
51 hidden: 'The session board is off.',
52 // an answer made of these words alone (ok, go on, continue) is no new ask
53 isBrief: (ask: string) =>
54 ask
55 .toLowerCase()
56 .split(/[^a-z]+/)
57 .filter(Boolean)
58 .every(word => GO_ON.has(word)),
59 },
60 ko: {
61 words: { asking: '질문', permit: '권한', failed: '멎음', done: '끝남', working: '일함', waiting: '기다림', idle: '쉼' },
62 reasons: { rate_limit: '사용량 한도', overloaded: '서버 혼잡', server_error: '서버 오류' },
63 minutes: (min: number) => `${min}분`,
64 now: '방금',
65 none: '다른 세션 없음 · /board 로 끈다',
66 command: '다른 세션 현황판을 이 창에 켜거나 끈다',
67 shown: '세션 현황판을 이 창에 켰다. /board 로 끈다.',
68 hidden: '세션 현황판을 껐다.',
69 // a few letters (ㅇㅇ, 이어서) are no new ask
70 isBrief: (ask: string) => ask.replace(/\s/g, '').length < 6,
71 },
72} as const
73
74// set as the module registers, from the plugin's options
75let text: (typeof TEXTS)[keyof typeof TEXTS] = TEXTS.en
76
77const others = atom({ plugin: 'roll-call', key: 'others' } as const, [])
78// The board is drawn in the one window the person asked for it in (/board).
79const isShown = atom({ plugin: 'roll-call', key: 'isShown' } as const, false)
80
81// The ranges a terminal draws two cells wide: Hangul, CJK, full-width forms.
82const WIDE: readonly (readonly [number, number])[] = [
83 [0x1100, 0x115f],
84 [0x2e80, 0xa4cf],
85 [0xac00, 0xd7a3],
86 [0xf900, 0xfaff],
87 [0xfe30, 0xfe4f],
88 [0xff00, 0xff60],
89 [0xffe0, 0xffe6],
90]
91
92// what a text takes on a terminal row: an arrow or a dash takes one cell, as a letter does
93const cells = (text: string) =>
94 [...text].reduce((sum, ch) => {
95 const code = ch.codePointAt(0) ?? 0
96
97 return sum + (WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1)
98 }, 0)
99
100// the widest word of the language, with a cell on either side of it
101const chipCells = () => Math.max(...Object.values(text.words).map(word => cells(word))) + 2
102
103// A text another process wrote is drawn in this terminal: its control characters
104// (an escape sequence among them) are taken out before it is.
105const CONTROLS = /[\u0000-\u001f\u007f-\u009f]/g
106
107const textOf = (value: unknown) =>
108 typeof value === 'string' ? value.replace(CONTROLS, ' ') : undefined
109const countOf = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
110
111const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
112
113// The first line that says something, without its markdown marks.
114const tidy = (text: string) => {
115 const line = text
116 .split(/\r?\n/)
117 .map(one => one.replace(/^[\s#>*\-`|]+/, '').replace(/[*`]/g, '').trim())
118 .find(one => one !== '')
119
120 return line === undefined ? undefined : line.replace(/\s+/g, ' ').slice(0, LINE_CHARS)
121}
122
123// What the engine writes in the person's place is no ask of theirs: a delivery
124// or a notification (in a tag of the engine's, its name hyphened), the summary a
125// compaction leaves, an interrupt mark,
126// the nudge after a usage limit resets.
127const NOT_AN_ASK =
128 /^(<[a-z]+(-[a-z]+)+[\s>]|This session is being continued|\[Request interrupted|Caveat:|Your claude\.ai usage limit has reset)/
129
130// The person's ask as one line, or undefined when the text is not theirs.
131const askOf = (text: string) =>
132 NOT_AN_ASK.test(text.trimStart())
133 ? undefined
134 : tidy(text.replace(/\[Image #\d+\]/g, ''))
135
136// A notification that background work ended names the kind in its summary:
137// `Background command "..." completed`, `Dynamic workflow "..." completed`.
138const ENDED = /<task-notification>[\s\S]*?<status>(?!running)\w+<\/status>[\s\S]*?<summary>([^<]*)/
139
140const endedKindOf = (text: string) => {
141 const summary = ENDED.exec(text)?.[1]
142 if (summary === undefined) {
143 return undefined
144 }
145
146 // by how the summary opens: a command's own words follow, and may say anything
147 return /^Background command/.test(summary)
148 ? 'shell'
149 : /^(Dynamic )?workflow/i.test(summary)
150 ? 'workflow'
151 : /^Monitor/i.test(summary)
152 ? 'monitor'
153 : undefined
154}
155
156// An answer that only says go on is no new ask: the last one stands.
157const isBrief = (ask: string) => text.isBrief(ask)
158
159// Why a turn broke off, in the person's words.
160const reasonOf = (error: string) => (text.reasons as Record<string, string>)[error] ?? error
161
162// What a call that waits for the person's leave is, in a few words: the tool,
163// and the program it runs, the file it touches or the host it reaches. Never
164// the whole command or address: either may carry a secret, and this is kept on
165// disk and drawn in other windows.
166const permitLine = (tool: string, input: unknown) => {
167 const one = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
168 const what =
169 textOf(one.command)?.trim().split(/\s+/)[0] ??
170 (textOf(one.file_path) === undefined ? undefined : basename(textOf(one.file_path) ?? '')) ??
171 /^[a-z]+:\/\/([^/?#]+)/i.exec(textOf(one.url) ?? '')?.[1]
172
173 return `${tool} ${what ?? ''}`.trim().slice(0, LINE_CHARS)
174}
175
176// Running work stands in the color of its kind, by the engine's own word for
177// it. None is a call's color: those are for the person to answer.
178const KIND_COLORS: Record<string, string> = {
179 agents: 'suggestion',
180 shell: 'bashBorder',
181 workflow: 'merged',
182}
183const OTHER_KIND_COLOR = 'planMode'
184
185const kindsOf = (entry: Entry) => entry.jobs ?? []
186
187// The engine files more under `monitor` than the Monitor tool's watches: the
188// watch a published artifact leaves is one, and it is no work and never ends.
189// So of the engine's monitors, only as many count as the session itself started.
190let ownMonitors = 0
191
192const kindsLeft = (tasks: readonly { type: string }[] | undefined) => {
193 const kinds = (tasks ?? []).map(task => task.type).filter(kind => kind !== 'subagent')
194 ownMonitors = Math.min(ownMonitors, kinds.filter(kind => kind === 'monitor').length)
195
196 return [
197 ...kinds.filter(kind => kind !== 'monitor'),
198 ...Array.from({ length: ownMonitors }, () => 'monitor'),
199 ]
200}
201
202const flowsOf = (entry: Entry) => entry.flows ?? []
203
204// The name a Workflow call gives its run: the saved workflow's, the one in the
205// script's meta, or the script file's.
206const flowNameOf = (call: { name?: unknown; script?: unknown; scriptPath?: unknown }) => {
207 if (typeof call.name === 'string' && call.name !== '') {
208 return call.name
209 }
210 const inMeta =
211 typeof call.script === 'string' ? /name:\s*['"]([^'"]+)['"]/.exec(call.script)?.[1] : undefined
212 const file =
213 typeof call.scriptPath === 'string' ? basename(call.scriptPath).replace(/\.[^.]*$/, '') : ''
214
215 return inMeta ?? (file === '' ? 'workflow' : file)
216}
217
218const barOf = (done: number, started: number) => {
219 const filled = started > 0 ? Math.round((Math.min(done, started) / started) * BAR_CELLS) : 0
220
221 return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
222}
223
224// agents 3, shell 2, workflow 1
225const tagsOf = (entry: Entry): Tag[] => {
226 const count = new Map<string, number>()
227 if ((entry.agents ?? 0) > 0) {
228 count.set('agents', entry.agents ?? 0)
229 }
230 for (const kind of kindsOf(entry)) {
231 count.set(kind, (count.get(kind) ?? 0) + 1)
232 }
233 // a run drawn as a bar is not counted here again
234 const uncounted = (count.get('workflow') ?? 0) - flowsOf(entry).length
235 if (uncounted > 0) {
236 count.set('workflow', uncounted)
237 } else {
238 count.delete('workflow')
239 }
240
241 return [...count].map(([kind, n]) => ({
242 text: `${kind} ${n}`,
243 color: KIND_COLORS[kind] ?? OTHER_KIND_COLOR,
244 }))
245}
246
247// the session's own name first: several sessions share one folder
248const label = (seen: { name?: string; where: string }) => seen.name ?? seen.where
249
250const place = (entry: Entry) =>
251 [entry.name === undefined ? '' : entry.where, entry.branch === entry.where ? '' : entry.branch]
252 .filter(Boolean)
253 .join(' · ')
254
255// A session whose turn ended while its subagents or its background work still
256// run has not finished: it wakes by itself when they report back.
257// One that ended its turn on a message to a session that is still there waits
258// for that session, not for the person.
259const phaseOf = (entry: Entry, names: ReadonlySet<string>): Shown => {
260 if (
261 (entry.agents ?? 0) + kindsOf(entry).length + flowsOf(entry).length > 0 &&
262 (entry.phase === 'done' || entry.phase === 'idle')
263 ) {
264 return 'working'
265 }
266
267 return entry.phase === 'done' && entry.to !== undefined && names.has(entry.to)
268 ? 'waiting'
269 : entry.phase
270}
271
272const lineOf = (entry: Entry, phase: Shown) => {
273 if (phase === 'waiting') {
274 return `→ ${entry.to} · ${entry.note ?? entry.task ?? place(entry)}`
275 }
276 const isWaiting = ROWS[phase as keyof typeof ROWS]?.color !== undefined
277
278 return (isWaiting ? entry.note : undefined) ?? entry.task ?? place(entry)
279}
280
281const PHASES: ReadonlySet<string> = new Set(['idle', 'working', 'done', 'asking', 'permit', 'failed', 'gone'])
282
283// A session's file as another process wrote it, held to the shape this version
284// reads: sessions of other versions write into the same folder. One that does
285// not hold is passed over, and the rest of the board stands.
286const parseEntry = (raw: unknown): Entry | undefined => {
287 const one = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
288 const where = textOf(one.where)
289 const since = countOf(one.since)
290 if (typeof one.phase !== 'string' || !PHASES.has(one.phase) || where === undefined || since === undefined) {
291 return undefined
292 }
293
294 return {
295 name: textOf(one.name),
296 where,
297 branch: textOf(one.branch) ?? '',
298 phase: one.phase as Entry['phase'],
299 since,
300 task: textOf(one.task),
301 askedAt: countOf(one.askedAt),
302 note: textOf(one.note),
303 agents: countOf(one.agents),
304 jobs: Array.isArray(one.jobs) ? one.jobs.filter((kind): kind is string => typeof kind === 'string') : [],
305 to: textOf(one.to),
306 flows: (Array.isArray(one.flows) ? one.flows : []).flatMap((flow: unknown): Flow[] => {
307 const f = (typeof flow === 'object' && flow !== null ? flow : {}) as Record<string, unknown>
308 const [name, done, started, at] = [textOf(f.name), countOf(f.done), countOf(f.started), countOf(f.since)]
309
310 return name === undefined || done === undefined || started === undefined || at === undefined
311 ? []
312 : [{ name, done, started, since: at }]
313 }),
314 }
315}
316
317let mine: Entry | null = null
318// the call the session waits for the person on: its question, or its leave
319let waitingOn: string | undefined
320let savedAt = 0
321// The runs going, by run id, each with the ids of its agents still at work.
322const runs = new Map<string, Flow & { ids: Set<string> }>()
323// The name each Workflow call gave its run, by the call's id: a run's agents
324// carry that id. The last name stands in where a run carries none.
325const flowNames = new Map<string, string>()
326let flowName: string | undefined
327
328const flowsNow = (): Flow[] =>
329 [...runs.values()].map(({ name, done, started, since }) => ({ name, done, started, since }))
330
331// One piece of background work of a kind ended: it leaves the count, and a
332// workflow takes its run along (one whose agents all ended, before any other).
333const jobsWithout = (entry: Entry, kind: string) => {
334 const jobs = kindsOf(entry)
335 const at = jobs.indexOf(kind)
336 if (kind === 'monitor') {
337 ownMonitors = Math.max(0, ownMonitors - 1)
338 }
339 if (kind === 'workflow') {
340 const over =
341 [...runs].find(([, run]) => run.ids.size === 0)?.[0] ?? [...runs.keys()][0]
342 if (over !== undefined) {
343 runs.delete(over)
344 }
345 }
346
347 return { jobs: at < 0 ? jobs : jobs.filter((_, i) => i !== at), flows: flowsNow() }
348}
349
350// The engine's list of background work says which runs still go.
351const jobsLeft = (tasks: readonly { type: string; name?: string }[]) => {
352 const going = tasks.filter(task => task.type === 'workflow').map(task => task.name)
353 for (const [id, run] of runs) {
354 if (going.length === 0 || (!going.includes(undefined) && !going.includes(run.name))) {
355 runs.delete(id)
356 }
357 }
358
359 return { jobs: kindsLeft(tasks), flows: flowsNow() }
360}
361
362// Claude Code's own folder: where it was told to keep it, else under the home
363// folder. With neither known the mod writes nothing: never into the work folder.
364const configDir = async ($: EngineInterface) => {
365 const set = await $.env.get('CLAUDE_CONFIG_DIR')
366 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
367
368 return set ?? (home === undefined ? undefined : `${home}/.claude`)
369}
370
371// The name the person gave this session, as Claude Code itself lists it: one
372// file a process, under `sessions` in its folder. It is not a documented
373// interface: a file that does not read as expected is passed over, and the
374// session then goes by its folder.
375const nameOf = async ($: EngineInterface) => {
376 const config = await configDir($)
377 const dir = config === undefined ? undefined : `${config}/sessions`
378 const files = dir === undefined ? [] : await $.fs.list(dir).catch(() => [])
379 const id = await $.session.id()
380
381 for (const file of files) {
382 if (file.kind !== 'file' || !file.name.endsWith('.json')) {
383 continue
384 }
385 try {
386 const one = JSON.parse(String(await $.fs.read(`${dir}/${file.name}`))) as Record<string, unknown>
387 if (one.sessionId === id) {
388 return textOf(one.name) || undefined
389 }
390 } catch {
391 // left by a process that ended mid-write, or of a shape not known here
392 }
393 }
394
395 return undefined
396}
397
398const boardDir = async ($: EngineInterface) => {
399 const config = await configDir($)
400
401 return config === undefined ? undefined : `${config}/roll-call`
402}
403
404// The folder the session works in: the worktree's own, wherever in it the
405// shell stands, and not the project a worktree session was started from.
406const whereOf = async ($: EngineInterface, cwd: string) => {
407 const ran = await $.process
408 .run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
409 .catch(() => undefined)
410
411 return basename(ran?.exitCode === 0 ? ran.stdout.trim() : cwd)
412}
413
414const branchOf = async ($: EngineInterface) => {
415 const ran = await $.process
416 .run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
417 .catch(() => undefined)
418
419 return ran?.exitCode === 0 ? ran.stdout.trim() : ''
420}
421
422const save = async ($: EngineInterface, id?: string) => {
423 if (mine === null) {
424 return
425 }
426 const dir = await boardDir($)
427 if (dir === undefined) {
428 throw new Error('no home folder to keep the board in')
429 }
430 await $.fs.write(`${dir}/${id ?? (await $.session.id())}.json`, JSON.stringify(mine))
431 savedAt = await $.clock.now()
432}
433
434// Sets what changed and writes it. The board is a view: a failed write never
435// stands in a tool's or a turn's way.
436const put = async ($: EngineInterface, change: Partial<Entry>) => {
437 // the clock is read first: a change another hook makes meanwhile is kept
438 const now = await $.clock.now()
439 if (mine === null) {
440 return
441 }
442 const isNewPhase = change.phase !== undefined && change.phase !== mine.phase
443 mine = { ...mine, ...change, since: isNewPhase ? now : mine.since }
444 await save($).catch(() => undefined)
445}
446
447// Counts the subagents still running, and keeps the file fresh for the others.
448const tick = async ($: EngineInterface) => {
449 if (mine === null) {
450 return
451 }
452 const list = await $.agent.list().catch(() => [])
453 const agents = list.filter(agent => agent.status === 'running').length
454 const isStale = (await $.clock.now()) - savedAt >= BEAT_MS
455 if (agents !== (mine.agents ?? 0) || isStale) {
456 await put($, { agents })
457 }
458}
459
460const poll = async ($: EngineInterface) => {
461 if (!(await read($, isShown))) {
462 return
463 }
464 const dir = await boardDir($)
465 const ownFile = `${await $.session.id()}.json`
466 const now = await $.clock.now()
467 const files = dir === undefined ? [] : await $.fs.list(dir).catch(() => [])
468 const live: Entry[] = []
469
470 for (const file of files) {
471 const isLive =
472 file.kind === 'file' &&
473 file.name.endsWith('.json') &&
474 file.name !== ownFile &&
475 now - file.mtimeMs < STALE_MS
476 if (!isLive) {
477 continue
478 }
479 try {
480 const entry = parseEntry(JSON.parse(String(await $.fs.read(`${dir}/${file.name}`))))
481 if (entry !== undefined && entry.phase !== 'gone') {
482 live.push(entry)
483 }
484 } catch {
485 // half-written or unreadable: the next poll reads it again
486 }
487 }
488 // this window's session can be the one waited for
489 const names = new Set(
490 [mine, ...live].flatMap(entry => (entry?.name === undefined ? [] : [entry.name])),
491 )
492 const seen: Seen[] = live.map(entry => {
493 const phase = phaseOf(entry, names)
494
495 return {
496 name: entry.name,
497 where: entry.where,
498 branch: entry.branch,
499 phase,
500 waitedMin: Math.floor((now - entry.since) / MINUTE_MS),
501 workedMin:
502 phase === 'working' && entry.askedAt !== undefined
503 ? Math.floor((now - entry.askedAt) / MINUTE_MS)
504 : undefined,
505 // a session that calls says what it wants: what it runs is beside the point
506 tags: phase === 'working' ? tagsOf(entry) : [],
507 line: lineOf(entry, phase),
508 flows: flowsOf(entry).map(({ name, done, started, since }) => ({
509 name,
510 done,
511 started,
512 min: Math.floor((now - since) / MINUTE_MS),
513 })),
514 }
515 })
516 seen.sort(
517 (a, b) =>
518 ORDER.indexOf(a.phase) - ORDER.indexOf(b.phase) ||
519 b.waitedMin - a.waitedMin ||
520 label(a).localeCompare(label(b)),
521 )
522
523 if (JSON.stringify(await read($, others)) !== JSON.stringify(seen)) {
524 await update($, others, () => seen)
525 }
526}
527
528export const register: Register = (on, options) => {
529 text = options.language === 'ko' ? TEXTS.ko : TEXTS.en
530
531 on('session.start', async ($, e, next) => {
532 const started = await next(e)
533 if (!e.isInteractive) {
534 return started
535 }
536
537 // A session the mod is loaded into midway has a past: it is not one that
538 // never began. It reads as finished, by its last words, until a tool runs.
539 const past = await $.session.messages().catch(() => [])
540 const linesOf = (role: string) =>
541 [...past]
542 .reverse()
543 .flatMap(m => (m.role === role ? [askOf(m.text)] : []))
544 .flatMap(line => (line === undefined ? [] : [line]))
545 const asks = linesOf('user')
546 // a reload starts the module over: what its own file says of the ask stands
547 const before = await $.fs
548 .read(`${await boardDir($)}/${await $.session.id()}.json`)
549 .then(text => parseEntry(JSON.parse(String(text))))
550 .catch(() => undefined)
551
552 mine = {
553 name: await nameOf($),
554 where: await whereOf($, e.cwd),
555 branch: await branchOf($),
556 phase: past.length > 0 ? 'done' : 'idle',
557 // one that had finished before the reload finished then, not now
558 since: before?.phase === 'done' && past.length > 0 ? before.since : await $.clock.now(),
559 task: asks.find(ask => !isBrief(ask)) ?? asks[0],
560 askedAt: before?.askedAt,
561 note: linesOf('assistant')[0],
562 }
563 const isKept = await save($).then(
564 () => true,
565 () => false,
566 )
567 if (!isKept) {
568 mine = null
569 $.ui.toast('roll-call: cannot write under the Claude Code folder, so it is off in this session')
570
571 return started
572 }
573 await $.command.register({
574 name: 'board',
575 description: text.command,
576 })
577 await tick($)
578 await poll($)
579 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
580 $.clock.every(POLL_MS, () => void poll($).catch(() => undefined))
581
582 return started
583 })
584
585 on('turn.start', async ($, e, next) => {
586 // a turn a delivery or a notification started carries no ask of the person's
587 if (mine === null) {
588 return next(e)
589 }
590 waitingOn = undefined
591 const asked = askOf(e.text)
592 const ended = endedKindOf(e.text)
593 const isNewAsk = asked !== undefined && !(isBrief(asked) && mine?.task !== undefined)
594 await put($, {
595 ...(ended === undefined || mine === null ? {} : jobsWithout(mine, ended)),
596 name: await nameOf($),
597 where: await whereOf($, mine?.where ?? ''),
598 branch: await branchOf($),
599 task: isNewAsk ? asked : mine?.task,
600 askedAt: isNewAsk ? await $.clock.now() : mine?.askedAt,
601 note: undefined,
602 phase: 'working',
603 to: undefined,
604 })
605
606 return next(e)
607 })
608
609 // The end of a turn, and of a subagent, says what still runs in the
610 // background. Subagents are left out: the tick counts them as they start and end.
611 on('classic.Stop', async ($, e, next) => {
612 await put($, jobsLeft(e.background_tasks ?? []))
613
614 return next(e)
615 })
616
617 on('classic.SubagentStop', async ($, e, next) => {
618 if (e.background_tasks !== undefined) {
619 await put($, jobsLeft(e.background_tasks))
620 }
621
622 return next(e)
623 })
624
625 on('classic.StopFailure', async ($, e, next) => {
626 await put($, { note: reasonOf(e.error), phase: 'failed' })
627
628 return next(e)
629 })
630
631 on('turn.complete', async ($, e, next) => {
632 const completed = await next(e)
633 // a subagent's turn ends inside the main one: the session still works
634 if (e.agentId === undefined && mine?.phase !== 'failed') {
635 waitingOn = undefined
636 await put($, { note: tidy(e.answer), phase: 'done' })
637 }
638 const run = [...runs.values()].find(one => one.ids.has(e.agentId ?? ''))
639 if (run !== undefined) {
640 run.ids.delete(e.agentId ?? '')
641 run.done += 1
642 await put($, { flows: flowsNow() })
643 }
644
645 return completed
646 })
647
648 // A workflow run is followed by its agents: each one it starts, each that ends.
649 on('agent.spawn', async ($, e, next) => {
650 const spawned = await next(e)
651 const runId = e.workflow?.runId ?? ''
652 if (runId === '' || spawned.deny !== undefined) {
653 return spawned
654 }
655 const run = runs.get(runId) ?? {
656 name: flowNames.get(e.tool_use_id ?? '') ?? flowName ?? 'workflow',
657 done: 0,
658 started: 0,
659 since: await $.clock.now(),
660 ids: new Set<string>(),
661 }
662 run.started = Math.max(run.started + 1, e.workflow?.agentIndex ?? 0)
663 if (spawned.agentId !== undefined) {
664 run.ids.add(spawned.agentId)
665 }
666 runs.set(runId, run)
667 await put($, { flows: flowsNow() })
668
669 return spawned
670 })
671
672 on('tool.call', async ($, e, next) => {
673 if (e.tool === 'Workflow') {
674 flowName = flowNameOf(e)
675 flowNames.set(e.tool_use_id, flowName)
676 }
677 if (e.tool === 'AskUserQuestion') {
678 waitingOn = e.tool_use_id
679 await put($, { note: tidy(e.questions[0]?.question ?? ''), phase: 'asking' })
680 } else if (e.tool === 'SendMessage' && typeof e.to === 'string') {
681 // a listing's " [ref]" is no part of the name
682 await put($, { to: e.to.replace(/\s*\[[^\]]*\]$/, '') })
683 } else if (mine?.phase === 'idle' || mine?.phase === 'done') {
684 // a tool runs, so a turn does: the mod loaded mid-turn, or the session
685 // went on by itself when a subagent reported back
686 await put($, { phase: 'working' })
687 }
688 const ran = await next(e)
689 // started mid-turn, it shows at once: the engine's list comes at the turn's end
690 const started =
691 e.tool === 'Workflow'
692 ? 'workflow'
693 : (e.tool === 'Bash' || e.tool === 'PowerShell') && e.run_in_background === true
694 ? 'shell'
695 : e.tool === 'Monitor'
696 ? 'monitor'
697 : undefined
698 if (started === 'monitor' && e.agentId === undefined) {
699 ownMonitors += 1
700 }
701 // a subagent's shell is its own to wait for: the session's are the main loop's
702 if (started !== undefined && mine !== null && e.agentId === undefined) {
703 await put($, { jobs: [...kindsOf(mine), started] })
704 }
705 // another call ending meanwhile (a subagent's, one of a batch) ends no wait
706 if ((mine?.phase === 'asking' || mine?.phase === 'permit') && e.tool_use_id === waitingOn) {
707 waitingOn = undefined
708 await put($, { note: undefined, phase: 'working' })
709 }
710
711 return ran
712 })
713
714 // Work that ends while a turn is on reports into that turn: no turn starts
715 // for it, so its notification is read as it is put to the session.
716 on('prompt.submit', async ($, e, next) => {
717 const ended = mine?.phase === 'working' ? endedKindOf(e.text) : undefined
718 if (ended !== undefined && mine !== null) {
719 await put($, jobsWithout(mine, ended))
720 }
721
722 return next(e)
723 })
724
725
726 // A call the engine's own verdict puts to the person waits for them until it
727 // settles: the `tool.call` around it sets the session back to work. Under a
728 // mode that decides by itself the wait is over at once.
729 on('tool.check', async ($, e, next) => {
730 const verdict = await next(e)
731 const isWaiting =
732 verdict.decision === 'ask' &&
733 e.tool_use_id !== undefined &&
734 e.tool !== 'AskUserQuestion' &&
735 mine?.phase === 'working'
736 if (isWaiting) {
737 waitingOn = e.tool_use_id
738 await put($, { note: permitLine(e.tool, e.input), phase: 'permit' })
739 }
740
741 return verdict
742 })
743
744 on('session.end', async ($, e, next) => {
745 if (mine !== null) {
746 const last = mine
747 mine = { ...last, phase: 'gone' }
748 await save($, e.sessionId).catch(() => undefined)
749 // After a /clear or a resume the process goes on under a new id, with no
750 // session.start: the mod goes on too, from an entry of its own for it.
751 const goesOn = e.reason === 'clear' || e.reason === 'resume'
752 runs.clear()
753 flowNames.clear()
754 ownMonitors = 0
755 waitingOn = undefined
756 mine = goesOn
757 ? { where: last.where, branch: last.branch, phase: 'idle', since: await $.clock.now() }
758 : null
759 }
760
761 return next(e)
762 })
763
764 on('command.run', { command: 'board' }, async $ => {
765 const isOn = !(await read($, isShown))
766 await update($, isShown, () => isOn)
767 await poll($)
768
769 return { text: isOn ? text.shown : text.hidden }
770 })
771
772 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
773 if (e.props.hasSurvey || !(await read($, isShown))) {
774 return next(e)
775 }
776
777 const { Box, Text } = $.ui.resolve(e)
778 const rows = await read($, others)
779 if (rows.length === 0) {
780 return <Text dimColor>{text.none}</Text>
781 }
782 const chip = chipCells()
783 const nameCells = Math.max(...rows.map(s => cells(label(s)))) + 1
784 const lineCells = Math.max(
785 10,
786 e.props.bodyColumns - chip - nameCells - TIME_CELLS - GAPS,
787 )
788
789 return (
790 <Box flexDirection="column">
791 {rows.map(s => {
792 const row = ROWS[s.phase as keyof typeof ROWS]
793 const waits = row.color !== undefined
794 const isCall = waits && !(s.phase === 'done' && s.waitedMin >= DONE_CALL_MIN)
795
796 return (
797 <Box key={`${label(s)}:${s.where}:${s.branch}`} flexDirection="row" gap={1}>
798 <Box width={chip}>
799 {isCall ? (
800 <Text backgroundColor={row.color} color="inverseText" bold>
801 {` ${text.words[s.phase as keyof typeof text.words]} `}
802 </Text>
803 ) : (
804 <Text
805 color={row.ink}
806 bold={row.ink !== undefined}
807 dimColor={row.ink === undefined}
808 wrap="truncate-end"
809 >
810 {` ${text.words[s.phase as keyof typeof text.words]}`}
811 </Text>
812 )}
813 </Box>
814 <Box width={nameCells}>
815 <Text bold={isCall} dimColor={!isCall && row.ink === undefined} wrap="truncate-end">
816 {label(s)}
817 </Text>
818 </Box>
819 <Box width={lineCells}>
820 {/* the counts lead: at the end of a long line they are the part cut off */}
821 <Text wrap="truncate-end">
822 {s.tags.map(tag => (
823 <Text key={tag.text} color={tag.color} bold>
824 {`${tag.text} `}
825 </Text>
826 ))}
827 {s.flows.flatMap(flow => [
828 <Text key={flow.name} color={KIND_COLORS.workflow} bold>
829 {`${flow.name} ${barOf(flow.done, flow.started)}`}
830 </Text>,
831 <Text key={`${flow.name}:count`} dimColor>
832 {` ${flow.done}/${flow.started} · ${flow.min > 0 ? text.minutes(flow.min) : text.now} `}
833 </Text>,
834 ])}
835 <Text dimColor={!isCall}>{s.line}</Text>
836 </Text>
837 </Box>
838 <Box width={TIME_CELLS} justifyContent="flex-end">
839 {/* a call: how long it has waited for the person. At work: how long on the ask */}
840 {waits ? (
841 <Text dimColor>{s.waitedMin > 0 ? text.minutes(s.waitedMin) : text.now}</Text>
842 ) : (
843 s.workedMin !== undefined && <Text dimColor>{text.minutes(s.workedMin)}</Text>
844 )}
845 </Box>
846 </Box>
847 )
848 })}
849 </Box>
850 )
851 })
852}
853types/index.d.ts 70 lines1/**
2 * idle: no turn yet. done / asking / permit: the session waits for the person.
3 * failed: its turn broke off on an API error. gone: it ended.
4 */
5export type Phase = 'idle' | 'working' | 'done' | 'asking' | 'permit' | 'failed' | 'gone'
6
7/** As the board reads a session. waiting: it finished its turn on a message to another session. */
8export type Shown = Phase | 'waiting'
9
10/** What a session writes about itself, one file per session. */
11export type Entry = {
12 /** The name the person gave the session, when it has one. */
13 name?: string
14 where: string
15 branch: string
16 phase: Phase
17 since: number
18 /** What the person last asked of it, one line. */
19 task?: string
20 /** When they asked it: the work on it began then, whatever turns it took since. */
21 askedAt?: number
22 /** What it wants of the person while it waits: its question, or how it ended. */
23 note?: string
24 /** Subagents of its own still running. */
25 agents?: number
26 /**
27 * Background work of its own in flight, one kind a piece as the engine names
28 * it: shell, workflow, monitor. Added to as the session starts one, and set
29 * right by the engine's own list each time a turn or a subagent ends.
30 */
31 jobs?: string[]
32 /** The session it wrote to in its turn, by name. */
33 to?: string
34 /** The workflow runs it has going, as it counted their agents. */
35 flows?: Flow[]
36}
37
38/**
39 * A workflow run by its agents: how many it started and how many of those
40 * ended. A run starts agents as it goes, so `started` is not what it will come to.
41 */
42export type Flow = { name: string; done: number; started: number; since: number }
43
44/** Another session as this one last read it. */
45export type Seen = {
46 name?: string
47 where: string
48 branch: string
49 /** As the board reads it: a session whose subagents or background work still run is at work. */
50 phase: Shown
51 waitedMin: number
52 /** Minutes since the person asked what it works on; absent when it is not at work. */
53 workedMin?: number
54 /** What it has running, each a kind and a count: agents 3, shell 2. */
55 tags: Tag[]
56 /** The one line beside the name. */
57 line: string
58 /** Its workflow runs, each a bar on its line, ahead of what it was asked. */
59 flows: (Pick<Flow, 'name' | 'done' | 'started'> & { min: number })[]
60}
61
62/** A count of one kind of running work, in the color of its kind. */
63export type Tag = { text: string; color: string }
64
65declare module 'claude-code' {
66 interface PluginState {
67 'roll-call': { others: Seen[]; isShown: boolean }
68 }
69}
70