SLOPSHOPPER

team-board

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…

newpanerowscommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · team-board
│ ┃ TEAM ✕ › fix the failing auth test and add an audit log call │ ┃ ╔══════════════════════════════════════════╗ │ ┃ ║ TEAM LEAD ?·? ║ ⏺ Read(src/auth.ts) │ ┃ ║ ║ ⎿ Read 6 lines │ ┃ ║ NO AGENTS YET ║ ⏺ Update(src/auth.ts) │ ┃ ║ ═══════════════════════════════════════… ║ ⎿ Added 2 lines, removed 1 line │ ┃ ║ NO AGENTS YET ║ ⏺ Bash(bun test) │ ┃ ╚══════════════════════════════════════════╝ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /team │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TEAM
╔══════════════════════════════════════════════════════════╗ ║ TEAM LEAD ?·? ║ ║ ║ ║ NO AGENTS YET ║ ║ ════════════════════════════════════════════════════ ║ ║ NO AGENTS YET ║ ╚══════════════════════════════════════════════════════════╝
README

claude-mods

Personal Claude Code mods.

Install

/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

Mods

  • team-board: a Team pane for agent teams. One row per agent: working, done, or blocked (red, with the reason). Teammate messages fold to one line in the chat. /team shows or hides the pane.
  • open-last: a band above the prompt with the last file Claude wrote and any local dev server it started. 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).
  • account-guard: silent while the GitHub account a push from this repo would use can push. Otherwise a line above the prompt names that account (gh's active account or GH_TOKEN for https; for ssh, the key's account once you run /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-draft: a 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.
  • recent-chats: when Claude opens fresh, a band above the prompt lists this folder's last 5 chats: the /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).

Develop

claude --plugin-dir ./plugins/team-board
claude plugin validate ./plugins/team-board
claude plugin test ./plugins/team-board
Source 3 files
hooks/register.tsx 488 lines
1import { 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}
488
hooks/parse.ts 366 lines
1import 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> = { '&quot;': '"', '&lt;': '<', '&gt;': '>', '&amp;': '&' }
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}
366
types/index.d.ts 57 lines
1export 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