A Team pane with one row per agent or teammate (working, done, blocked), a toast when one turns blocked, and teammate messages folded to one line in the…

Personal Claude Code mods.
/plugin marketplace add ColoMiranda/claude-mods
/plugin install team-board@claude-mods
/plugin install open-last@claude-mods
/plugin install copy-draft@claude-mods
/plugin install account-guard@claude-mods
/plugin install recent-chats@claude-mods
/reload-plugins
/team shows or hides the pane.open opens it in cmux (else the default app), diff shows what Claude changed in the last turn (in any folder, git or not; shell edits too: sed -i, perl -i, tee, > and >>), and stop kills the server. /open, /last-diff and /stop-servers do the same from the prompt (/diff is Claude Code's own)./gh-check). Red when that account cannot push to the repo, with a switch button and /gh-switch when another signed-in gh account can; a toast when Claude pushes or opens a PR anyway.copy button under each code block of Claude's answers (copy 1, copy 2 when there are several; terminal), and /copy-block [n] to copy a block of the last answer from the prompt. The built-in /copy copies the whole answer. A reply button under each table puts one line per row in the prompt box (R22 - , R23 - ...: the row's first cell, or its number), under anything you typed, so you write what to do with each row; /reply-table [n] does the same from the prompt./rename name (else Claude's title, else the last prompt), how long ago, and under it a one-line summary of where that chat stopped. Haiku writes each summary from the end of the chat (a few hundred tokens) and it is kept until that chat changes; only sessions with a person at the prompt call the model, never -p runs. Press 1 to 5 in the empty prompt box, or click a row, to resume that chat. When the www mod shows tasks, the chats take the next numbers. Type anything else to start a new chat; the band goes away at the first prompt. Not shown on claude -c or claude -r. Not listed: chats open in another terminal (two sessions would write one chat), and headless runs (claude -p, SDK).claude --plugin-dir ./plugins/team-board
claude plugin validate ./plugins/team-board
claude plugin test ./plugins/team-boardhooks/register.tsx 488 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TeamBoardAgent, TeamBoardLead } from '../types'
5import {
6 SPINNER,
7 STATE,
8 addCleared,
9 applyMessage,
10 applyTaskNote,
11 countLine,
12 dropCleared,
13 durationLabel,
14 effortLabel,
15 endTurn,
16 finishRun,
17 foldLine,
18 heardFrom,
19 markStep,
20 markWorking,
21 modelLabel,
22 parseTaskNotes,
23 parseTeammateMessages,
24 reconcile,
25 replyPrefix,
26 replyText,
27 rowFor,
28 stamp,
29 toMessage,
30 tokenLabel,
31 workedMs,
32} from './parse'
33
34const PANE = 'team'
35const TITLE = 'TEAM'
36const agents = atom({ plugin: 'team-board', key: 'agents' } as const, [])
37const hasAutoOpened = atom({ plugin: 'team-board', key: 'hasAutoOpened' } as const, false)
38const lead = atom({ plugin: 'team-board', key: 'lead' } as const, {} as TeamBoardLead)
39const frame = atom({ plugin: 'team-board', key: 'frame' } as const, 0)
40const expanded = atom({ plugin: 'team-board', key: 'expanded' } as const, [])
41const cleared = atom({ plugin: 'team-board', key: 'cleared' } as const, [])
42
43// The spinner's timer runs only while an agent works. A reload cancels the
44// module's timers, and session.start restarts this one if it still has work.
45let tick: { cancel: () => void } | undefined
46function syncTick($: EngineInterface, list: readonly TeamBoardAgent[]) {
47 const isWorking = list.some(one => one.status === 'working')
48 if (isWorking && !tick) tick = $.clock.every(120, () => void update($, frame, n => (n + 1) % SPINNER.length))
49 if (!isWorking && tick) {
50 tick.cancel()
51 tick = undefined
52 }
53}
54
55// The origin kinds of a row from an agent: a teammate's drain stamps none, and
56// another session's rows are peer rows. Any other kind is someone else's.
57const AGENT_ORIGINS: ReadonlySet<string> = new Set(['unclassified', 'peer', 'peer-send-message'])
58
59// Color means state only: names stay plain, so a teammate's own color (red,
60// green) never reads as blocked or done.
61const STATUS_COLOR = { working: 'yellow', done: 'green', blocked: 'red' } as const
62
63// A row's more/less button: its note shows in full, or folds back to the line.
64function toggleNote($: EngineInterface, key: string) {
65 return update($, expanded, keys => (keys.includes(key) ? keys.filter(one => one !== key) : [...keys, key]))
66}
67
68// The clear button and /team clear: finished rows leave the board and close
69// their notes, and their names are remembered (see addCleared).
70async function clearDone($: EngineInterface) {
71 let gone: TeamBoardAgent[] = []
72 const kept = await update($, agents, list => {
73 gone = list.filter(one => one.status === 'done')
74 return list.filter(one => one.status !== 'done')
75 })
76 await update($, cleared, names => addCleared(names, gone, kept))
77 await update($, expanded, keys => keys.filter(key => !gone.some(one => one.key === key)))
78 await syncStatus($, kept)
79}
80
81// The reply button: the row's prefix ("Tell scout: ") goes in the prompt box
82// ahead of what the person has typed, which stays. The mod sends nothing.
83async function replyTo($: EngineInterface, agent: TeamBoardAgent) {
84 const prefix = replyPrefix(agent)
85 const { text } = await $.prompt.read()
86 // An empty read can be a refused one (another plugin hooks prompt.read), and
87 // a replace would wipe the draft: insert at the cursor instead.
88 const filled =
89 text === ''
90 ? await $.prompt.fill({ text: prefix, mode: 'insert' })
91 : await $.prompt.fill({ text: replyText(prefix, text) })
92 if (!filled.isFilled) $.ui.toast(`Could not write "${prefix.trimEnd()}" in the prompt box`)
93}
94
95// This plugin's status line under the prompt. A clear is skipped while no line
96// is up, which keeps a drawn pane from calling it on every spinner tick. It
97// starts as maybe up: a reload does not unpin the line the old module set.
98let isStatusUp = true
99function setStatus($: EngineInterface, text: string | undefined) {
100 if (text === undefined && !isStatusUp) return
101 isStatusUp = text !== undefined
102 $.ui.status(text)
103}
104
105// How many times the Pane render has run. syncStatus reads it before it awaits
106// the panes: a draw during the wait is newer than the answer it brings.
107let draws = 0
108
109// One line under the prompt while the pane waits undrawn (narrow terminal).
110// The terminal widening places the pane without an event of its own: the Pane
111// render clears the line then (nothing else fires on a placement). If it drew
112// while the panes were being asked, the answer is stale: the pane is placed.
113async function syncStatus($: EngineInterface, list: readonly TeamBoardAgent[]) {
114 const drawn = draws
115 const pane = (await $.ui.panes()).find(one => one.id === PANE)
116 const isWaiting = pane?.isPlaced === false && list.length > 0 && draws === drawn
117 setStatus($, isWaiting ? `team: ${countLine(list)}` : undefined)
118}
119
120// Every status change comes through here, so this is the one place that stamps
121// when a row starts or stops working (the pane reads the stamps).
122async function change($: EngineInterface, fn: (list: TeamBoardAgent[]) => TeamBoardAgent[]) {
123 const now = await $.clock.now()
124 let before: TeamBoardAgent[] = []
125 const after = await update($, agents, list => {
126 before = list
127 return fn(list).map(agent => stamp(agent, list.find(one => one.key === agent.key), now))
128 })
129
130 for (const agent of after) {
131 const was = before.find(one => one.key === agent.key)
132 if (agent.status === 'blocked' && was?.status !== 'blocked') {
133 $.ui.toast(`${agent.name.toUpperCase()} BLOCKED: ${agent.reason ?? ''}`, { timeoutMs: 10000 })
134 }
135 }
136 if (after.length > 0 && !(await read($, hasAutoOpened))) {
137 await $.ui.open({ id: PANE, title: TITLE })
138 await update($, hasAutoOpened, () => true)
139 }
140 // An agent that works again is heard again.
141 await update($, cleared, names => dropCleared(names, after))
142 syncTick($, after)
143 await syncStatus($, after)
144}
145
146// The teammate messages in a row's text, as the board hears them: a cleared
147// agent's idle note that is not blocked is not heard (see heardFrom).
148async function hearText($: EngineInterface, text: string) {
149 const messages = parseTeammateMessages(text)
150 if (messages.length === 0) return
151 const heard = heardFrom(messages, await read($, cleared))
152 if (heard.length > 0) await change($, list => heard.reduce((acc, m) => applyMessage(acc, m), list))
153}
154
155// A row of the lead's conversation, as the board hears it: the engine's task
156// notification when a background task stops (its own origin), the backup to
157// turn.complete that touches only rows the board has, and teammate messages
158// (hearText). A task notification typed by a person is not heard. A subagent's
159// hand-back of its report is not read: its turn.complete, which comes first,
160// already carries the same words.
161async function hearRow($: EngineInterface, text: string, origin: string) {
162 const notes = origin === 'task-notification' ? parseTaskNotes(text) : []
163 if (notes.length > 0) await change($, list => notes.reduce(applyTaskNote, list))
164 await hearText($, text)
165}
166
167export const register: Register = on => {
168 on('session.start', async ($, e, next) => {
169 await $.command.register({
170 name: 'team',
171 description: 'Show or hide the team pane, or clear its finished agents',
172 argumentHint: '[clear]',
173 immediate: true,
174 })
175 // Rows still working whose agent has stopped meanwhile (see reconcile).
176 if ((await read($, agents)).some(one => one.status === 'working')) {
177 const infos = await $.agent.list()
178 await change($, list => reconcile(list, infos))
179 }
180 syncTick($, await read($, agents))
181
182 return next(e)
183 })
184
185 on('command.run', { command: 'team' }, async ($, e) => {
186 if (e.args.trim() === 'clear') {
187 await clearDone($)
188 return {}
189 }
190 // A pane waiting unplaced counts as hidden: closing it and opening it
191 // again from the person's command seats it at any width.
192 const pane = (await $.ui.panes()).find(one => one.id === PANE)
193 if (pane) await $.ui.close({ id: PANE })
194 if (pane?.isPlaced !== true) await $.ui.open({ id: PANE, title: TITLE })
195 await syncStatus($, await read($, agents))
196
197 return {}
198 })
199
200 on('ui.close', { id: PANE }, async ($, e, next) => {
201 const closed = await next(e)
202 setStatus($, undefined)
203
204 return closed
205 })
206
207 // Teammate messages and task notifications in the rows the main
208 // conversation stores (see hearRow). Read only: the row goes on unchanged, so
209 // the transcript and the model keep every word. It is stored first, so no $
210 // call that hangs can hold it back. The board hears it awaited, not left
211 // running: two rows heard at once would race, and the later could land first.
212 on('session.append', async ($, e, next) => {
213 const stored = await next(e)
214 if (e.agentId === undefined && e.message.role === 'user') {
215 const text = e.message.content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])).join('\n')
216 await hearRow($, text, e.origin.kind)
217 }
218
219 return stored
220 })
221
222 on('agent.spawn', async ($, e, next) => {
223 const spawned = await next(e)
224 if (spawned.deny === undefined) {
225 // Keyed by id so two spawns of one description stay two rows; labelled
226 // as UserMessageFrom.name is: the name, else the type.
227 const key = spawned.agentId ?? e.tool_use_id
228 await change($, list => markWorking(list, key, { name: e.name ?? e.subagentType, isNamed: e.name !== undefined, doing: e.description, model: spawned.model }))
229 }
230
231 return spawned
232 })
233
234 // An agent's run ended: its last words are its state (see endTurn). Every
235 // subagent run and every teammate turn raises it, foreground or background,
236 // so this is how the board learns an agent finished. A teammate goes done
237 // between turns, and its next turn's step wakes it.
238 on('turn.complete', async ($, e, next) => {
239 const agent = e.agentId === undefined ? undefined : rowFor(await read($, agents), e.agentId)
240 if (agent) {
241 const end = { reason: e.reason, answer: e.answer, turnId: e.turnId, refusal: e.reason === 'refusal' ? e.refusal.explanation : undefined }
242 await change($, list => endTurn(list, agent.key, agent.name, end))
243 }
244
245 return next(e)
246 })
247
248 // The same end, where a classic hook is not bypassed: it lands the row first,
249 // and turn.complete lands it again the same (see finishRun). A plugin seated
250 // above may bypass classic hooks for user plugins, so the board never relies
251 // on it.
252 on('classic.SubagentStop', async ($, e, next) => {
253 const agent = rowFor(await read($, agents), e.agent_id)
254 if (agent) await change($, list => finishRun(list, agent.key, toMessage(agent.name, e.last_assistant_message ?? '')))
255
256 return next(e)
257 })
258
259 // The lead handing a known agent new work puts it back to working; a cleared
260 // one, named as `to`, is back on the board.
261 on('session.send', async ($, e, next) => {
262 const sent = await next(e)
263 const agent = (await read($, agents)).findLast(one => one.name === e.to || one.key === e.to)
264 const key = agent?.key ?? ((await read($, cleared)).includes(e.to) ? e.to : undefined)
265 if (sent.isDelivered && e.agentId === undefined && key !== undefined) {
266 await change($, list => markWorking(list, key))
267 }
268
269 return sent
270 })
271
272 // Each step of an agent's loop: the agent is working (a blocked or done row
273 // stays so through the turn it turned so in, see markStep), on this model
274 // and effort, in this turn; after the step, its tokens add to the row. A loop the board does
275 // not know yet (a teammate: its spawn fires no agent.spawn) is named by
276 // $.agent.list() and joins the row its messages made, or a new one. The
277 // lead's model and effort are written only when they change.
278 on('turn.step', async function* ($, e, next) {
279 const id = e.agentId
280 const now = { model: e.model, effort: e.effort }
281 if (id === undefined) {
282 const was = await read($, lead)
283 if (was.model !== now.model || was.effort !== now.effort) await update($, lead, () => now)
284 return yield* next(e)
285 }
286
287 const step = { ...now, lastTurnId: e.turnId }
288 const list = await read($, agents)
289 let row = rowFor(list, id)
290 if (!row) {
291 const info = (await $.agent.list()).find(one => one.id === id)
292 const label = info ? (info.name ?? info.type) : undefined
293 row = label === undefined ? undefined : list.findLast(one => one.name === label && one.loopId === undefined)
294 const key = row?.key ?? id
295 if (label !== undefined) await change($, all => markStep(all, key, { name: label, isNamed: row?.isNamed === true || info?.name !== undefined, doing: row?.doing || info?.description || '', loopId: id, ...step }))
296 } else if (row.status === 'done' || row.model !== now.model || row.effort !== now.effort || row.lastTurnId !== e.turnId) {
297 const key = row.key
298 await change($, all => markStep(all, key, step))
299 }
300
301 const result = yield* next(e)
302 const u = result?.usage
303 if (u) {
304 const tokensIn = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
305 await update($, agents, all =>
306 all.map(one =>
307 one.key === id || one.loopId === id
308 ? { ...one, tokensIn: (one.tokensIn ?? 0) + tokensIn, tokensOut: (one.tokensOut ?? 0) + u.output_tokens }
309 : one,
310 ),
311 )
312 }
313
314 return result
315 })
316
317 // Two lines per agent, blocked agents first, a thin rule between agents:
318 // who, on what model and at what cost, then the state, how long it has
319 // worked and why. The spinner's redraw keeps a working agent's time live. A
320 // note the line cannot show whole gets a more/less button (hotkeys 1 to 9,
321 // by row) that shows it in full below the line. A blocked agent has a third
322 // line with a reply button (hotkey r on the first, as blocked rows sort
323 // first), on the terminal only: the other surfaces draw their own composer.
324 // The footer has a clear button (hotkey c) while an agent is done.
325 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
326 const { Box, Button, Text } = $.ui.resolve(e)
327 // A drawn pane is placed: the count line pinned while it waited is stale.
328 draws++
329 setStatus($, undefined)
330 const list = await read($, agents)
331 const boss = await read($, lead)
332 const opened = await read($, expanded)
333 const spin = SPINNER[await read($, frame)] ?? SPINNER[0]
334 const now = await $.clock.now()
335 const isNarrow = e.props.bodyColumns < 40
336 // The box border and padding take 4 columns.
337 const width = e.props.bodyColumns - 4
338 const rule = (char: string) => char.repeat(Math.max(width, 8))
339 const rows = [...list].sort((a, b) => Number(b.status === 'blocked') - Number(a.status === 'blocked'))
340 const totalIn = list.reduce((sum, one) => sum + (one.tokensIn ?? 0), 0)
341 const totalOut = list.reduce((sum, one) => sum + (one.tokensOut ?? 0), 0)
342
343 return (
344 <Box flexDirection="column" borderStyle="double" borderColor="cyan" paddingX={1}>
345 <Box justifyContent="space-between">
346 <Text bold color="yellow">
347 TEAM
348 </Text>
349 <Text dimColor wrap="truncate-end">
350 LEAD {modelLabel(boss.model)}·{effortLabel(boss.effort)}
351 </Text>
352 </Box>
353 <Text> </Text>
354 {rows.length === 0 && <Text dimColor>NO AGENTS YET</Text>}
355 {rows.map((agent, i) => {
356 const s = STATE[agent.status]
357 const color = STATUS_COLOR[agent.status]
358 const hasTokens = agent.tokensIn !== undefined
359 const worked = workedMs(agent, now)
360 // Line 2 is the state word, the time and the note, as drawn below.
361 // A narrow pane drops the note of a row that is not blocked; a wide
362 // one cuts it at the edge. Either way the button opens it in full.
363 const word = ` ${s.label}`
364 const time = worked === undefined ? '' : ` ${durationLabel(worked)}`
365 const gap = time === '' ? ' ' : ' '
366 const full = agent.status === 'blocked' ? (agent.reason ?? '') : agent.doing
367 const isDropped = isNarrow && agent.status !== 'blocked'
368 // ponytail: length counts code units, not cells, so wide glyphs (CJK,
369 // emoji) can read as fitting. Upgrade path: a cell-width measure, if
370 // the engine ever exposes one.
371 const isCut = full !== '' && (isDropped || word.length + time.length + gap.length + full.length > width)
372 const isOpen = isCut && opened.includes(agent.key)
373 const note = isOpen || isDropped ? '' : full
374
375 return (
376 <Box flexDirection="column">
377 {i > 0 && (
378 <Text dimColor wrap="truncate-end">
379 {rule('─')}
380 </Text>
381 )}
382 <Box justifyContent="space-between">
383 <Text wrap="truncate-end">
384 <Text color={color}>{agent.status === 'working' ? spin : s.icon} </Text>
385 <Text bold>
386 {agent.name.toUpperCase()}
387 </Text>
388 <Text dimColor>
389 {' '}
390 {modelLabel(agent.model)} · {effortLabel(agent.effort)}
391 </Text>
392 </Text>
393 {hasTokens && !isNarrow && (
394 <Text dimColor wrap="truncate-end">
395 in {tokenLabel(agent.tokensIn ?? 0)} · out {tokenLabel(agent.tokensOut ?? 0)}
396 </Text>
397 )}
398 </Box>
399 <Box justifyContent="space-between">
400 <Text wrap="truncate-end">
401 <Text bold color={color}>
402 {word}
403 </Text>
404 {time !== '' && <Text color={color}>{time}</Text>}
405 {note !== '' && (
406 <Text dimColor>
407 {gap}
408 {note}
409 </Text>
410 )}
411 </Text>
412 {isCut && (
413 <Box flexShrink={0} marginLeft={1}>
414 <Button
415 key={`more-${agent.key}`}
416 plain
417 label={isOpen ? 'less' : 'more'}
418 hotkey={i < 9 ? String(i + 1) : undefined}
419 onPress={() => toggleNote($, agent.key)}
420 />
421 </Box>
422 )}
423 </Box>
424 {isOpen && (
425 <Box paddingLeft={2}>
426 <Text dimColor wrap="wrap">
427 {full}
428 </Text>
429 </Box>
430 )}
431 {agent.status === 'blocked' && e.surface === 'terminal' && (
432 <Box paddingLeft={2}>
433 <Button
434 key={`reply-${agent.key}`}
435 plain
436 label="reply"
437 hotkey={i === 0 ? 'r' : undefined}
438 onPress={() => replyTo($, agent)}
439 />
440 </Box>
441 )}
442 </Box>
443 )
444 })}
445 <Text dimColor wrap="truncate-end">
446 {rule('═')}
447 </Text>
448 <Box flexWrap="wrap" justifyContent="space-between">
449 <Text bold wrap="truncate-end">
450 {countLine(list)}
451 </Text>
452 {list.some(one => one.status === 'done') && (
453 <Box flexShrink={0} marginLeft={1}>
454 <Button key="clear" plain label="clear" hotkey="c" onPress={() => clearDone($)} />
455 </Box>
456 )}
457 </Box>
458 {totalIn + totalOut > 0 && (
459 <Text dimColor wrap="truncate-end">
460 TEAM TOKENS in {tokenLabel(totalIn)} · out {tokenLabel(totalOut)}
461 </Text>
462 )}
463 </Box>
464 )
465 })
466
467 // A row from an agent on the board, or from one cleared from it (its late
468 // idle notes), folds to one dim line; ctrl+o (isExpanded) shows the engine's
469 // full row. `text` is the body less the engine's framing, summary line
470 // included, so the fold reads the body's headline. Only an agent's row
471 // folds, by its origin: a person who shares an agent's name (a channel
472 // message, say) is drawn as the engine draws it. A block from several
473 // senders has no `from`. The fold is the drawing only, never the stored row.
474 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
475 const { text, from, isExpanded, origin } = e.props
476 if (isExpanded || from === undefined || !AGENT_ORIGINS.has(origin.kind)) return next(e)
477 const isOnBoard = (await read($, agents)).some(one => one.name === from.name)
478 if (!isOnBoard && !(await read($, cleared)).includes(from.name)) return next(e)
479 const { Text } = $.ui.resolve(e)
480
481 return (
482 <Text dimColor wrap="truncate-end">
483 {foldLine(toMessage(from.name, text))}
484 </Text>
485 )
486 })
487}
488hooks/parse.ts 366 lines1import type { AgentInfo, TurnCompleteReason } from 'claude-code'
2
3import type { TeamBoardAgent, TeamBoardStatus } from '../types'
4
5// `isIdle`: the idle_notification JSON shape, a follow-up note to the lead.
6export type TeamMessage = { name: string; summary?: string; body: string; isIdle?: boolean }
7
8// An unclosed frame ends at the next frame or at the end of the text.
9const FRAME =
10 /<teammate-message\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)(?:<\/teammate-message>|(?=<teammate-message\b)|$)/g
11const ATTR = /([\w-]+)=(?:"([^"]*)"|'([^']*)')/g
12const ENTITY: Record<string, string> = { '"': '"', '<': '<', '>': '>', '&': '&' }
13
14// ponytail: keyword heuristic over the headline alone (the summary, else the
15// body's first line). It skips a keyword negated before it ("sin captcha",
16// "nothing was blocked", "not, as far as I can tell, blocked"), said as a
17// condition ("when an agent turns blocked"), answered "none" after a colon, or
18// followed by solved/handled/fixed; anything phrased otherwise slips through.
19// Upgrade path: a structured status field in the teammate's idle notification,
20// or a one-line $.model.complete classification of the headline.
21const BLOCKED = new RegExp(
22 [
23 String.raw`(?<!\b(?:sin|no|ni|without|never|nobody|ningun[oa]?|when|if|whether|once|turns?|becomes?|gets?)\b[^.,;:\n]{0,15})`,
24 String.raw`(?<!(?:\bnot|\bnothing|n't)\b[^.;:\n]{0,30})`,
25 String.raw`\b(?:captcha|capcha|blocked|bloque(?:ad[oa]s?|os?|ó)|eres un bot|(?:i'm|i am|am|still|got)\s+stuck|waiting for you|permission denied|need you to|necesito que|can(?:not|'t) (?:continue|proceed)|no puedo (?:seguir|continuar)|login wall)(?![\wáéíóúñ])`,
26 String.raw`(?![^\n]{0,20}?:[\s*_]*(?:ningun[oa]?|none|nada|0)\b)`,
27 String.raw`(?![^.\n]{0,30}\b(?:solved|resolved|handled|fixed|cleared|resuelt[oa]|solucionad[oa]|arreglad[oa])\b)`,
28 ].join(''),
29 'i',
30)
31
32// Every teammate frame in a row's text, in order: the idle_notification JSON
33// shape (its `result` is the body) and the free-text shape with a summary.
34export function parseTeammateMessages(text: string): TeamMessage[] {
35 return [...text.matchAll(FRAME)].flatMap(([, attrText = '', inner = '']) => {
36 const attrs = attrsOf(attrText)
37 const name = attrs.teammate_id
38
39 return name ? [{ ...toMessage(name, inner), summary: attrs.summary }] : []
40 })
41}
42
43// A frame's attributes, entities decoded.
44function attrsOf(attrText: string): Record<string, string | undefined> {
45 return Object.fromEntries(
46 [...attrText.matchAll(ATTR)].map(([, k, dq, sq]) => [k, (dq ?? sq ?? '').replace(/&(?:quot|lt|gt|amp);/g, one => ENTITY[one] ?? one)]),
47 )
48}
49
50// One frame's body: an idle_notification's `result`, else the text itself.
51export function toMessage(name: string, inner: string): TeamMessage {
52 const json = parseJson(inner.trim())
53 if (!json) return { name, body: inner.trim() }
54
55 return { name, body: typeof json.result === 'string' ? json.result : '', isIdle: json.type === 'idle_notification' }
56}
57
58function parseJson(text: string): Record<string, unknown> | undefined {
59 if (!text.startsWith('{')) return undefined
60 try {
61 const value: unknown = JSON.parse(text)
62 return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
63 } catch {
64 return undefined
65 }
66}
67
68// The first non-empty line, markdown emphasis and list marks stripped.
69export function headline(text: string): string {
70 const line = text.split('\n').find(one => one.trim() !== '') ?? ''
71 return line.replace(/[*_`#>]/g, '').replace(/^\s*[-\d.]+\s+/, '').trim()
72}
73
74// The headline of `text` when it trips the heuristic, else undefined. Pass
75// the summary when there is one, else the body.
76export function blockedReason(text: string): string | undefined {
77 const line = headline(text)
78 return BLOCKED.test(line) ? line : undefined
79}
80
81// The row a message updates: `key` when the caller knows it (a subagent's
82// id), else the latest row labelled with the sender's name. A message that
83// reads as blocked blocks the row; one that does not clears it, bar one case:
84// a blocked summary (not an idle note) is followed by an idle note that echoes
85// the same turn ("Report sent to team-lead."), which may refresh blocked but
86// never clears it. Every message after the echo that is not blocked does. A
87// row an idle note blocked has no echo due.
88export function applyMessage(agents: readonly TeamBoardAgent[], m: TeamMessage, key?: string): TeamBoardAgent[] {
89 const prev = key !== undefined ? agents.find(a => a.key === key) : agents.findLast(a => a.name === m.name)
90 const said = blockedReason(m.summary ?? m.body)
91 const isEcho = m.isIdle === true && prev?.status === 'blocked' && prev.isEchoDue === true
92 const reason = said ?? (isEcho ? prev?.reason : undefined)
93 const status: TeamBoardStatus = reason !== undefined ? 'blocked' : 'done'
94 const next: TeamBoardAgent = {
95 ...prev,
96 key: prev?.key ?? key ?? m.name,
97 name: prev?.name ?? m.name,
98 // A row a teammate message starts is named by it; a row that exists keeps
99 // what made it (a subagent's own last message carries only its label).
100 isNamed: prev ? prev.isNamed : true,
101 doing: m.summary ?? (headline(m.body) || prev?.doing || ''),
102 status,
103 reason,
104 isEchoDue: status === 'blocked' && !m.isIdle ? true : undefined,
105 blockedTurnId: status === 'blocked' ? prev?.lastTurnId : undefined,
106 }
107
108 return prev ? agents.map(a => (a === prev ? next : a)) : [...agents, next]
109}
110
111// The row an agent id names: its key (a spawn's id), else its loop id (a row a
112// teammate's message keyed by name first).
113export function rowFor(agents: readonly TeamBoardAgent[], id: string): TeamBoardAgent | undefined {
114 return agents.find(a => a.key === id || a.loopId === id)
115}
116
117// An agent's run ended and left its last words (its turn.complete answer, its
118// SubagentStop message): done or blocked as
119// applyMessage reads them, and the turn it ended in (given, else its latest
120// step's) recorded, so a late step in that turn cannot wake it (see markStep).
121// A row blocked in that same turn stays as it is when the words are not
122// blocked: a teammate that said it was stuck mid-turn ends the turn on
123// something like "Report sent." Applied twice (a SubagentStop, then the
124// turn.complete with the same words), it lands the same row.
125// Never adds a row: an id the board does not know is not an agent of its own.
126export function finishRun(agents: readonly TeamBoardAgent[], key: string, m: TeamMessage, turnId?: string): TeamBoardAgent[] {
127 const prev = agents.find(a => a.key === key)
128 if (!prev) return [...agents]
129 const turn = turnId ?? prev.lastTurnId
130 const isHeld = prev.status === 'blocked' && turn !== undefined && prev.blockedTurnId === turn && blockedReason(m.summary ?? m.body) === undefined
131 if (isHeld) return [...agents]
132
133 return applyMessage(agents, m, key).map(a =>
134 a.key !== key ? a : a.status === 'done' ? { ...a, doneTurnId: turn ?? a.doneTurnId } : { ...a, blockedTurnId: turn ?? a.blockedTurnId },
135 )
136}
137
138// An agent's run ended in a known state with no words to read (a task
139// notification, an interrupt, an API error): `reason` is a blocked row's note,
140// and the note it was doing stays. The turn is recorded as finishRun does.
141// Never adds a row.
142export function endRun(agents: readonly TeamBoardAgent[], key: string, status: 'done' | 'blocked', reason?: string, turnId?: string): TeamBoardAgent[] {
143 const prev = agents.find(a => a.key === key)
144 if (!prev) return [...agents]
145 const turn = turnId ?? prev.lastTurnId
146 const next: TeamBoardAgent = {
147 ...prev,
148 status,
149 reason: status === 'blocked' ? reason : undefined,
150 isEchoDue: undefined,
151 blockedTurnId: status === 'blocked' ? (turn ?? prev.blockedTurnId) : undefined,
152 doneTurnId: status === 'done' ? (turn ?? prev.doneTurnId) : undefined,
153 }
154
155 return agents.map(a => (a === prev ? next : a))
156}
157
158// How a turn of an agent's loop ended (its turn.complete). An answer is its
159// last words (finishRun); an interrupt is done; an API error or a refusal left
160// the work unfinished and needs the lead: blocked, with a short reason.
161export type TurnEnd = { reason: TurnCompleteReason; answer: string; turnId: string; refusal?: string | null }
162export function endTurn(agents: readonly TeamBoardAgent[], key: string, name: string, end: TurnEnd): TeamBoardAgent[] {
163 if (end.reason === 'answer') return finishRun(agents, key, toMessage(name, end.answer), end.turnId)
164 if (end.reason === 'aborted') return endRun(agents, key, 'done', undefined, end.turnId)
165 const reason = end.reason === 'error' ? 'Stopped on an API error' : `Refused${end.refusal ? `: ${end.refusal}` : ''}`
166
167 return endRun(agents, key, 'blocked', reason, end.turnId)
168}
169
170// The engine's note to the lead when a background task stops: an agent's, or a
171// background shell's. `id` is the task's id (a spawned agent's agentId);
172// `toolUseId` the Agent call's, which keys a spawn that returned no agentId.
173export type TaskNote = { id?: string; toolUseId?: string; status?: string; summary?: string }
174
175const TASK_NOTE = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/g
176// Each tag's first occurrence: the engine writes them in a fixed order, the
177// free text (`<result>`, which may quote anything) last.
178const tagText = (inner: string, tag: string) => new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(inner)?.[1]?.trim()
179
180export function parseTaskNotes(text: string): TaskNote[] {
181 return [...text.matchAll(TASK_NOTE)].map(([, inner = '']) => ({
182 id: tagText(inner, 'task-id'),
183 toolUseId: tagText(inner, 'tool-use-id'),
184 status: tagText(inner, 'status'),
185 summary: tagText(inner, 'summary'),
186 }))
187}
188
189// A task note against the board. Completed is done, but only for a row still
190// working: a row its turn.complete already finished keeps its
191// note, and a blocked one its block. Failed is blocked, its summary the reason
192// (the lead has to look); killed is done, as the person stopped it. A task the
193// board does not know (a background shell, a cleared agent) changes nothing.
194export function applyTaskNote(agents: readonly TeamBoardAgent[], note: TaskNote): TeamBoardAgent[] {
195 const prev = (note.id !== undefined ? rowFor(agents, note.id) : undefined) ?? agents.find(a => a.key === note.toolUseId)
196 if (!prev) return [...agents]
197 if (note.status === 'failed') return endRun(agents, prev.key, 'blocked', note.summary || 'Failed')
198 if (note.status === 'killed') return endRun(agents, prev.key, 'done')
199 if (note.status === 'completed' && prev.status === 'working') return endRun(agents, prev.key, 'done')
200
201 return [...agents]
202}
203
204// A row still working whose agent $.agent.list() says has stopped (events the
205// board missed while it reloaded, or a row stuck by an older version): done,
206// or blocked when it failed. A row the list does not name is left as it is:
207// one keyed by a teammate's name or a spawn's tool_use_id is not listed by
208// that key, so not being listed proves nothing.
209export function reconcile(agents: readonly TeamBoardAgent[], infos: readonly AgentInfo[]): TeamBoardAgent[] {
210 return agents.reduce<TeamBoardAgent[]>((acc, a) => {
211 const info = a.status === 'working' ? infos.find(one => one.id === a.key || one.id === a.loopId) : undefined
212 if (info?.status === 'failed') return endRun(acc, a.key, 'blocked', 'Failed')
213 if (info?.status === 'completed' || info?.status === 'killed') return endRun(acc, a.key, 'done')
214 return acc
215 }, [...agents])
216}
217
218// An agent started, or was handed new work: working, blocked and done cleared.
219export function markWorking(agents: readonly TeamBoardAgent[], key: string, patch: Partial<TeamBoardAgent> = {}): TeamBoardAgent[] {
220 const prev = agents.find(a => a.key === key)
221 const next: TeamBoardAgent = {
222 name: key,
223 doing: '',
224 ...prev,
225 ...patch,
226 key,
227 status: 'working',
228 reason: undefined,
229 isEchoDue: undefined,
230 blockedTurnId: undefined,
231 doneTurnId: undefined,
232 }
233
234 return prev ? agents.map(a => (a === prev ? next : a)) : [...agents, next]
235}
236
237// A step of an agent's loop (`patch.lastTurnId` is the step's turn): working.
238// A blocked row stays blocked through a step in the turn it turned blocked, the
239// agent finishing the turn in which it said it was stuck, and a finished row
240// stays done through a step in the turn it finished in, a late step of a run
241// that already ended; a step in another turn means the agent is awake again. A
242// row with no recorded turn (a peer) counts every step as a new turn. Model,
243// effort and loop id land either way.
244export function markStep(agents: readonly TeamBoardAgent[], key: string, patch: Partial<TeamBoardAgent> = {}): TeamBoardAgent[] {
245 const prev = agents.find(a => a.key === key)
246 const heldTurn = prev?.status === 'blocked' ? prev.blockedTurnId : prev?.status === 'done' ? prev.doneTurnId : undefined
247 const isSameTurn = heldTurn !== undefined && heldTurn === patch.lastTurnId
248 if (!isSameTurn) return markWorking(agents, key, patch)
249
250 return agents.map(a => (a === prev ? { ...prev, ...patch, key } : a))
251}
252
253// Stamps a row an update just wrote against the row it replaced: entering
254// working starts a stretch (and clears the last stop), leaving working ends
255// it, any other change keeps the stamps. A row never seen working has neither.
256export function stamp(agent: TeamBoardAgent, prev: TeamBoardAgent | undefined, now: number): TeamBoardAgent {
257 const wasWorking = prev?.status === 'working'
258 if (agent.status === 'working' && !wasWorking) return { ...agent, startedAt: now, stoppedAt: undefined }
259 if (agent.status !== 'working' && wasWorking) return { ...agent, stoppedAt: now }
260
261 return agent
262}
263
264// How long the latest work stretch ran: up to `now` while the agent works,
265// else up to when it stopped. Undefined for a row never seen working.
266export function workedMs(agent: TeamBoardAgent, now: number): number | undefined {
267 const end = agent.status === 'working' ? now : agent.stoppedAt
268 return agent.startedAt === undefined || end === undefined ? undefined : end - agent.startedAt
269}
270
271// A clear remembers the names of the rows it removes, bar a name a remaining
272// row has: a late message from a cleared agent (the idle note it sends after
273// it finishes) must not bring its row back.
274export function addCleared(cleared: readonly string[], gone: readonly TeamBoardAgent[], kept: readonly TeamBoardAgent[]): string[] {
275 const names = gone.map(a => a.name).filter(name => !kept.some(a => a.name === name))
276 return [...new Set([...cleared, ...names])]
277}
278
279// An agent back on the board (a message or a step brought its row back, in any
280// state) is heard again: its name leaves the cleared list.
281export function dropCleared(cleared: readonly string[], agents: readonly TeamBoardAgent[]): string[] {
282 return cleared.filter(name => !agents.some(a => a.name === name))
283}
284
285// The messages that count. A cleared agent is silent only for the idle note it
286// sends after it finished, and only if that note is not blocked: a real message,
287// or a blocker, brings its row back.
288export function heardFrom(messages: readonly TeamMessage[], cleared: readonly string[]): TeamMessage[] {
289 return messages.filter(m => !(cleared.includes(m.name) && m.isIdle && blockedReason(m.summary ?? m.body) === undefined))
290}
291
292// Each state's icon and word, in the pane, the fold line and the footer.
293export const STATE = {
294 done: { icon: '✔', label: 'DONE' },
295 working: { icon: '▶', label: 'WORKING' },
296 blocked: { icon: '✖', label: 'BLOCKED' },
297} as const
298
299export function countLine(agents: readonly TeamBoardAgent[]): string {
300 const parts = (['done', 'working', 'blocked'] as const)
301 .map(status => [agents.filter(a => a.status === status).length, status] as const)
302 .filter(([n]) => n > 0)
303 .map(([n, status]) => `${STATE[status].icon}${n} ${STATE[status].label}`)
304
305 return parts.length === 0 ? 'NO AGENTS YET' : parts.join(' ')
306}
307
308// "claude-opus-5-5" -> "OPUS 5.5", "claude-haiku-4-5-20251001" -> "HAIKU 4.5",
309// "sonnet" -> "SONNET"; anything else upper-cased as given.
310export function modelLabel(model: string | undefined): string {
311 if (!model) return '?'
312 const m = /^(?:claude-)?([a-z]+)(?:-(\d+)(?:-(\d{1,2}))?)?(?:-\d{8})?(?:\[.*\])?$/i.exec(model)
313 if (!m) return model.toUpperCase()
314 const [, family = '', major, minor] = m
315 return [family.toUpperCase(), major && (minor ? `${major}.${minor}` : major)].filter(Boolean).join(' ')
316}
317
318// The spinner a working agent shows in place of its icon, one frame per tick.
319export const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
320
321// 950 -> "950", 45123 -> "45.1k", 2300000 -> "2.3M".
322export function tokenLabel(n: number): string {
323 if (n < 1000) return String(n)
324 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
325 return `${(n / 1_000_000).toFixed(1)}M`
326}
327
328// 45000 -> "45s", 133000 -> "2m 13s", 3840000 -> "1h 04m"; a clock set back
329// reads "0s".
330export function durationLabel(ms: number): string {
331 const seconds = Math.max(0, Math.floor(ms / 1000))
332 const minutes = Math.floor(seconds / 60)
333 if (minutes === 0) return `${seconds}s`
334 if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
335 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
336}
337
338export function effortLabel(effort: string | number | undefined): string {
339 return effort === undefined ? '?' : String(effort).toUpperCase()
340}
341
342// One dim transcript line per message: "✔ QA-PAGES-B DONE Round 3 verified".
343export function foldLine(m: TeamMessage): string {
344 const reason = blockedReason(m.summary ?? m.body)
345 const s = STATE[reason !== undefined ? 'blocked' : 'done']
346
347 return `${s.icon} ${m.name.toUpperCase()} ${s.label} ${reason ?? m.summary ?? headline(m.body)}`
348}
349
350// What a reply to the row starts the prompt with. A row keyed by something
351// other than its name (a spawn or loop id) names the id too, so the lead can
352// message that agent and not its type.
353export function replyPrefix(agent: TeamBoardAgent): string {
354 return agent.isNamed === true || agent.key === agent.name ? `Tell ${agent.name}: ` : `Tell ${agent.name} (${agent.key}): `
355}
356
357// A reply prefix at the start of a draft: "Tell scout: " or "Tell scout (a1): ".
358const REPLY_PREFIX = /^Tell [^:\n]{1,80}: /
359
360// The prompt box after a reply: `prefix` ahead of the draft, which stays. One
361// reply prefix the draft already starts with, whoever it names, is swapped, not
362// stacked.
363export function replyText(prefix: string, draft: string): string {
364 return prefix + draft.replace(REPLY_PREFIX, '')
365}
366types/index.d.ts 57 lines1export type TeamBoardStatus = 'working' | 'done' | 'blocked'
2
3// `key`: the agent's id when known (a spawn), else the teammate's name.
4// `name`: the label (Agent({ name }), else the subagent type).
5export type TeamBoardAgent = {
6 key: string
7 name: string
8 doing: string
9 status: TeamBoardStatus
10 reason?: string
11 // From the agent's latest model request (turn.step), else its spawn.
12 model?: string
13 effort?: string | number
14 // Summed over the agent's model requests: input (cache reads and writes
15 // included) and output.
16 tokensIn?: number
17 tokensOut?: number
18 // The agent's loop id ($.agent.list()), when the row was keyed by name first
19 // (a teammate is seen by its messages before its steps).
20 loopId?: string
21 // True when `name` is the agent's own (Agent({ name }), a teammate id): the
22 // lead can message it by name. False or absent: `name` is its type.
23 isNamed?: boolean
24 // Epoch milliseconds of the agent's latest work stretch: `startedAt` when it
25 // began working, `stoppedAt` when it stopped (unset while it works). A row
26 // never seen working has neither.
27 startedAt?: number
28 stoppedAt?: number
29 // The turn of the agent's latest model request (turn.step), the turn the row
30 // turned blocked in, and the turn its run finished in (done): a step in that
31 // same turn is the agent finishing it, a step in another turn is the agent
32 // awake again. A row with no recorded turn counts every step as a new turn.
33 lastTurnId?: string
34 blockedTurnId?: string
35 doneTurnId?: string
36 // True after a blocked summary (not an idle note) turned the row blocked: the
37 // agent's next idle note is the echo of that turn and cannot clear it.
38 isEchoDue?: boolean
39}
40
41export type TeamBoardLead = { model?: string; effort?: string | number }
42
43declare module 'claude-code' {
44 interface PluginState {
45 // `expanded`: the keys of the rows whose note shows in full. `cleared`: the
46 // names of agents cleared from the board, whose late messages are not heard.
47 'team-board': {
48 agents: TeamBoardAgent[]
49 hasAutoOpened: boolean
50 lead: TeamBoardLead
51 frame: number
52 expanded: string[]
53 cleared: string[]
54 }
55 }
56}
57