Turn on a team of agents (a lead and specialists) with /team, with a live pane showing the plan, who is doing what, what needs you, and a log, plus guardrails…

A Claude Code mod for working with a team of agents on demand. You turn a team on with /team, your session becomes the team's lead, and the lead hands work to specialists. A pane beside the chat shows the plan, who is doing what, what needs you, and a log of what happened. Your ordinary prompts are untouched until you turn a team on.
It ships one example team, a design team (a lead and six specialists). Teams are plain markdown files, so you can edit that one or write your own, for example a team for management work.
/team <name> turns a team on: this session acts as the team's lead and the specialists become available to delegate to. /team off turns it off./team opens it) with the team's folder at the top, whether the team is active, a two-sentence status, and four tabs:/team gate on);/team clear resets them.The lead is told, through a small tool (team_update), to keep the pane current: a status brief, the plan as a list, and anything it needs from you. The Agents tab and the Log fill in on their own from what actually runs. Because the brief, plan and actions come from the model, they can be stale or wrong sometimes; the Agents tab and Log are the reliable record.
/plugin install teams --marketplace tylerhagy/claude-teams
Then copy the example team into the folder the mod reads (~/.claude/teams by default):
git clone https://github.com/tylerhagy/claude-teams
mkdir -p ~/.claude/teams
cp -r claude-teams/teams/design ~/.claude/teams/
Or leave the example where it is and point the mod's Teams folder option at the repo's teams folder. Then run /team design.
A team is a folder with a lead and a members folder:
~/.claude/teams/<team>/
team.md the lead's instructions
shared.md rules for everyone (optional)
members/<id>.md one file per specialist
Each file is markdown with a short header, then the instructions as plain text:
---
name: researcher
team: design
role: member
description: One line the lead reads when choosing who to delegate to.
disallowed_tools: Bash, Edit, NotebookEdit
code_edits: no
---
You are a Researcher. ...
name is the id the lead delegates by. Name members in the lead's instructions by this id.description is what the lead sees when picking a specialist.disallowed_tools (optional) lists tools the specialist may not call.code_edits: no (optional) stops the specialist writing or editing code files (.ts, .css, .html and similar). It can still write notes.team.md) reaches the model as extra context beside your next prompt when the team is turned on, and again if it changes or after a compaction. It is never shown in the chat.shared.md (optional) holds rules that apply to everyone, such as who the person is and what never to publish. It is added to the lead's instructions and to the end of every specialist's, so each rule is written once.The example files are the design team's instructions. They are written to be adapted: the intake questions, the routing table and the "never publish" rules are the parts to change for another kind of team.
| Command | What it does |
|---|---|
/team | List teams, show which is on, open the pane |
/team <name> | Turn a team on |
/team off | Turn the team off (running helpers finish) |
/team gate on / off | Ask before each helper starts |
/team limit <n> / limit off | Cap how many helpers run at once |
/team clear | Clear the pane's status, plan, actions and log |
~/.claude/teams.An early version. The guardrails block by tool name and file extension, so they are a good fence and not a guarantee: a tool with an unexpected name can slip through, and a blocked name can be a false alarm.
MIT
hooks/register.tsx 843 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Action, Item, ItemStatus, LogLine, Row } from '../types'
5
6const PANE = 'team-feed'
7
8const active = atom({ plugin: 'teams', key: 'active' } as const, null)
9// null: no limit on how many helpers run at once. /team limit <n> sets one.
10const limit = atom({ plugin: 'teams', key: 'limit' } as const, null)
11const isGated = atom({ plugin: 'teams', key: 'isGated' } as const, false)
12const rows = atom({ plugin: 'teams', key: 'rows' } as const, [])
13// What the lead reports into the pane (team_update), kept per team between sessions.
14const brief = atom({ plugin: 'teams', key: 'brief' } as const, '')
15const items = atom({ plugin: 'teams', key: 'items' } as const, [])
16const actions = atom({ plugin: 'teams', key: 'actions' } as const, [])
17const log = atom({ plugin: 'teams', key: 'log' } as const, [])
18// Which version of the lead's instructions the conversation already holds.
19const delivered = atom({ plugin: 'teams', key: 'delivered' } as const, '')
20const tab = atom({ plugin: 'teams', key: 'tab' } as const, 'work')
21
22type Member = {
23 name: string
24 description: string
25 prompt: string
26 disallowedTools: string[]
27 isNoCodeEdits: boolean
28}
29type Team = {
30 name: string
31 lead: string
32 leadName: string
33 leadDescription: string
34 // rules for everyone, from shared.md next to team.md (optional)
35 shared: string
36 members: Member[]
37}
38type Color = 'green' | 'yellow' | 'red' | undefined
39
40// Limits a team's member files declare in their frontmatter:
41// disallowed_tools: Bash, Edit tools the member may not call
42// code_edits: no the member writes notes and drafts, not code files
43const CODE_FILE = /\.(jsx?|tsx?|css|scss|html?|vue|svelte|py|sh)$/i
44// Any connected tool whose name says it creates, changes or sends something.
45const EXTERNAL_WRITE =
46 /(^|_)(create|edit|update|add|transition|delete|write|send|post|publish|upload|reply|comment)|^trelloWrite|use_figma|html_to_figma|generate_figma|generate_image|executeWrite|executeDestructive/i
47
48const ITEM_STATUSES: ItemStatus[] = ['todo', 'doing', 'done', 'blocked', 'dropped']
49const MARKS: Record<ItemStatus, { mark: string; color: Color }> = {
50 todo: { mark: '○', color: undefined },
51 doing: { mark: '◐', color: 'yellow' },
52 done: { mark: '●', color: 'green' },
53 blocked: { mark: '!', color: 'red' },
54 dropped: { mark: '⊘', color: undefined },
55}
56
57const UPDATE_TOOL = {
58 name: 'team_update',
59 description:
60 'Keep the team pane the person sees up to date. Call it when a plan is approved or changes, when work moves forward, and when you need the person to do or decide something. Never needed for chat replies. Fields are all optional: send only what changed, but always send the whole items list.',
61 inputSchema: {
62 type: 'object',
63 properties: {
64 brief: {
65 type: 'string',
66 description:
67 'Where things stand and what is being worked on now, in two sentences or fewer, plain language.',
68 },
69 items: {
70 type: 'array',
71 description:
72 'The plan as 3 to 8 plain-language things the team is digging into. Replaces the whole list.',
73 items: {
74 type: 'object',
75 properties: {
76 id: { type: 'string', description: 'Stable short id, e.g. "1".' },
77 title: { type: 'string', description: 'Plain words, under 60 characters.' },
78 status: { type: 'string', enum: ITEM_STATUSES },
79 who: { type: 'string', description: 'Specialist id working on it, if any.' },
80 },
81 required: ['id', 'title', 'status'],
82 },
83 },
84 addActions: {
85 type: 'array',
86 description:
87 'Things the person needs to decide or do themselves, one short sentence each.',
88 items: { type: 'string' },
89 },
90 doneActions: {
91 type: 'array',
92 description: 'Ids of actions that no longer need the person (shown when you add them).',
93 items: { type: 'string' },
94 },
95 },
96 },
97 isDeferred: false,
98}
99
100const splitFrontmatter = (text: string) => {
101 const meta: Record<string, string> = {}
102 if (!text.startsWith('---\n')) return { meta, body: text }
103 const end = text.indexOf('\n---\n', 4)
104 if (end < 0) return { meta, body: text }
105 for (const line of text.slice(4, end).split('\n')) {
106 const at = line.indexOf(':')
107 if (at > 0) meta[line.slice(0, at).trim()] = line.slice(at + 1).trim()
108 }
109 return { meta, body: text.slice(end + 5).replace(/^\n+/, '') }
110}
111
112const firstSentences = (text: string, n = 2) => text.trim().split(/(?<=[.!?])\s+/).slice(0, n).join(' ')
113
114const clock = (t: number) => new Date(t).toTimeString().slice(0, 5)
115
116let loaded: Team | null = null
117// The teams folder the person set in the mod's options; empty means ~/.claude/teams.
118let configuredDir = ''
119
120const teamsDir = async ($: any): Promise<string> => {
121 const home = ((await $.env.get('HOME')) as string | undefined) ?? ''
122 const dir = configuredDir.trim() === '' ? '~/.claude/teams' : configuredDir.trim()
123 return dir.startsWith('~') ? `${home}${dir.slice(1)}` : dir
124}
125
126const prefix = ($: { plugin: { name: string } }) => `${$.plugin.name}:`
127
128const loadTeam = async ($: any, name: string): Promise<Team> => {
129 const dir = `${await teamsDir($)}/${name}`
130 const leadFile = splitFrontmatter(await $.fs.read(`${dir}/team.md`))
131 const lead = leadFile.body
132 const files = (await $.fs.list(`${dir}/members`)) as { name: string }[]
133 const members: Member[] = []
134 for (const file of files.filter(f => f.name.endsWith('.md'))) {
135 const { meta, body } = splitFrontmatter(
136 await $.fs.read(`${dir}/members/${file.name}`),
137 )
138 const id = meta.name ?? file.name.replace(/\.md$/, '')
139 members.push({
140 name: id,
141 description: meta.description ?? id,
142 prompt: body,
143 disallowedTools: (meta.disallowed_tools ?? '')
144 .split(',')
145 .map(tool => tool.trim())
146 .filter(tool => tool !== ''),
147 isNoCodeEdits: (meta.code_edits ?? '').toLowerCase() === 'no',
148 })
149 }
150 const shared = (await $.fs.exists(`${dir}/shared.md`)) ? ((await $.fs.read(`${dir}/shared.md`)) as string).trim() : ''
151 return {
152 name,
153 lead,
154 shared,
155 leadName: leadFile.meta.name ?? `${name}-lead`,
156 leadDescription: leadFile.meta.description ?? '',
157 members,
158 }
159}
160
161const registerMembers = async ($: any, team: Team) => {
162 for (const member of team.members) {
163 await $.agent.register({
164 name: member.name,
165 description: member.description,
166 prompt: team.shared === '' ? member.prompt : `${member.prompt.trimEnd()}\n\n${team.shared}`,
167 disallowedTools: member.disallowedTools.length > 0 ? member.disallowedTools : undefined,
168 })
169 }
170}
171
172// The team the person turned on, loaded again if the module restarted.
173const current = async ($: any): Promise<Team | null> => {
174 const name = await read($, active)
175 if (name === null) return null
176 if (loaded?.name !== name) {
177 loaded = await loadTeam($, name)
178 await registerMembers($, loaded)
179 }
180 return loaded
181}
182
183const roleOf = async ($: any, agentId: string | undefined) => {
184 if (agentId === undefined) return undefined
185 const agents = await $.agent.list()
186 const agent = agents.find((a: { id: string }) => a.id === agentId)
187 return agent?.type.startsWith(prefix($))
188 ? (agent.type.slice(prefix($).length) as string)
189 : undefined
190}
191
192// The pane's own words (brief, plan, actions, log) outlive the session, per team.
193const save = async ($: any) => {
194 const team = await read($, active)
195 if (team === null) return
196 await $.store.set(`pane:${team}`, {
197 brief: await read($, brief),
198 items: await read($, items),
199 actions: await read($, actions),
200 log: await read($, log),
201 })
202}
203
204const restore = async ($: any, team: string) => {
205 const saved = (await $.store.get(`pane:${team}`)) as
206 | { brief?: string; items?: Item[]; actions?: Action[]; log?: LogLine[] }
207 | undefined
208 await update($, brief, () => saved?.brief ?? '')
209 await update($, items, () => saved?.items ?? [])
210 await update($, actions, () => saved?.actions ?? [])
211 await update($, log, () => saved?.log ?? [])
212}
213
214const note = async ($: any, text: string) => {
215 const t = (await $.clock.now()) as number
216 await update($, log, (list: LogLine[]) => [...list, { t, text }].slice(-100))
217 await save($)
218}
219
220const setItemStatus = async ($: any, item: Item, status: ItemStatus) => {
221 await update($, items, (list: Item[]) => list.map(i => (i.id === item.id ? { ...i, status } : i)))
222 await note($, status === 'dropped' ? `You dropped: ${item.title}` : `You brought back: ${item.title}`)
223}
224
225const clearPlan = async ($: any) => {
226 await update($, items, () => [])
227 await note($, 'You cleared the plan')
228}
229
230const finishAction = async ($: any, action: Action) => {
231 await update($, actions, (list: Action[]) => list.filter(a => a.id !== action.id))
232 await note($, `You finished: ${action.text}`)
233}
234
235const turnOn = async ($: any, name: string): Promise<string> => {
236 const root = await teamsDir($)
237 if (!(await $.fs.exists(`${root}/${name}/team.md`))) {
238 return `No team called "${name}" in ${root}.`
239 }
240 loaded = await loadTeam($, name)
241 await registerMembers($, loaded)
242 await update($, active, () => name)
243 await update($, delivered, () => '')
244 await update($, rows, () => [])
245 await restore($, name)
246 await note($, `Team turned on: ${name}`)
247 void $.ui.open({ id: PANE, title: 'Team' })
248 const names = loaded.members.map(m => m.name).join(', ')
249 const max = await read($, limit)
250 return `${name} team on. Lead: this session. Specialists: ${names}. ${max === null ? 'No helper limit.' : `Max ${max} running at once.`}`
251}
252
253const turnOff = async ($: any) => {
254 await note($, 'Team turned off')
255 await update($, active, () => null)
256 await update($, delivered, () => '')
257 loaded = null
258}
259
260const setLimit = async ($: any, next: number | null) => {
261 await update($, limit, () => next)
262 await note($, next === null ? 'Helper limit removed' : `Helper limit set to ${next}`)
263}
264
265// From the pane's number field: a whole number of 1 or more, or nothing/"none" for no limit.
266const setLimitFromText = async ($: any, text: string) => {
267 const word = text.trim().toLowerCase()
268 if (/^[1-9]\d*$/.test(word)) return setLimit($, Number(word))
269 if (word === '' || word === 'none' || word === 'off' || word === 'no limit') return setLimit($, null)
270}
271
272// What the lead is told when a team is on: its own file, how to delegate, and how to fill the pane.
273const leadText = async ($: any, team: Team): Promise<string> => {
274 const ids = team.members
275 .map(m => `${m.name} (subagent_type "${prefix($)}${m.name}")`)
276 .join(', ')
277 const max = await read($, limit)
278 return [
279 `The person turned on the "${team.name}" team with /team. For this conversation you are the lead described below.`,
280 `Delegate with the Agent tool to these specialists: ${ids}. Where the instructions name a specialist by id, use its subagent_type here.`,
281 max === null
282 ? 'The person has set no limit on how many specialists run at once, so ignore any helper limit written in the instructions below. They can set one with /team limit.'
283 : `The system allows at most ${max} specialists running at once, so ignore any other helper limit written in the instructions below. It may also ask the person before one starts.`,
284 'The person watches a team pane. Keep it current with the team_update tool (mcp__teams__team_update), without mentioning the tool in chat:',
285 '- brief: where things stand and what is being worked on now, two sentences or fewer, plain words.',
286 '- items: once a plan is approved, the plan as 3 to 8 plain-language things the team is digging into, each with a status (todo, doing, done, blocked) and the specialist working it. Send the whole list each time and update statuses as work moves.',
287 '- Items the person dropped (status dropped) stay dropped: leave them in the list and do not bring them back or work on them.',
288 '- addActions: anything the person must decide or do themselves (approvals, answers, things to paste or send), one short sentence each. Add one the moment you ask for it.',
289 '',
290 team.lead,
291 ...(team.shared === '' ? [] : ['', team.shared]),
292 ].join('\n')
293}
294
295const hashOf = (text: string) => {
296 let h = 5381
297 for (let i = 0; i < text.length; i += 1) {
298 h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
299 }
300 return h.toString(36)
301}
302
303export const register: Register = (on, options) => {
304 configuredDir = typeof options.teamsDir === 'string' ? options.teamsDir : ''
305
306 on('session.start', async ($, e, next) => {
307 await $.command.register({
308 name: 'team',
309 description: 'Turn a team of agents on or off, or see what they are doing',
310 argumentHint: '[<team> | off | gate on | gate off | limit <n> | limit off | clear]',
311 })
312 await $.tool.register(UPDATE_TOOL)
313 await current($)
314 return next(e)
315 })
316
317 on('command.run', { command: 'team' }, async ($, e) => {
318 const arg = e.args.trim()
319
320 if (arg === 'off') {
321 await turnOff($)
322 return { text: 'Team off. Your prompts go to the main session again.' }
323 }
324
325 if (arg === 'clear') {
326 await update($, brief, () => '')
327 await update($, items, () => [])
328 await update($, actions, () => [])
329 await update($, log, () => [])
330 await save($)
331 return { text: 'Cleared the pane: status, plan, actions and log.' }
332 }
333
334 if (arg === 'gate on' || arg === 'gate off') {
335 await update($, isGated, () => arg === 'gate on')
336 await note($, arg === 'gate on' ? 'Spawn approval turned on' : 'Spawn approval turned off')
337 return {
338 text:
339 arg === 'gate on'
340 ? 'Spawn approval on: you will be asked before each helper starts.'
341 : 'Spawn approval off.',
342 }
343 }
344
345 const limitArg = /^limit\s+(\d+|off)$/.exec(arg)
346 if (limitArg !== null) {
347 const next = limitArg[1] === 'off' ? null : Number(limitArg[1])
348 if (next === 0) return { text: 'A limit of 0 would block every helper. Use /team limit off for no limit.' }
349 await setLimit($, next)
350 return {
351 text:
352 next === null
353 ? 'No helper limit. Any number can run at once.'
354 : `Helper limit: ${next} running at once. A further helper is refused until one finishes.`,
355 }
356 }
357
358 if (arg === '' || arg === 'status') {
359 const entries = (await $.fs.list(await teamsDir($))) as {
360 name: string
361 kind: string
362 }[]
363 const names = entries
364 .filter(entry => entry.kind === 'dir')
365 .map(entry => entry.name)
366 const now = await read($, active)
367 void $.ui.open({ id: PANE, title: 'Team' })
368 return {
369 text: `Teams: ${names.join(', ') || 'none found'}. On now: ${now ?? 'none'}. Use /team <name> or /team off.`,
370 }
371 }
372
373 return { text: await turnOn($, arg) }
374 })
375
376 // The lead's instructions ride beside the prompt as context (the person never sees it). They go once
377 // when a team is turned on, again if they change or after a compaction, not on every prompt.
378 on('prompt.submit', async ($, e, next) => {
379 if (e.command !== undefined) return next(e)
380 const team = await current($)
381 if (team === null) return next(e)
382 const text = await leadText($, team)
383 const stamp = `${team.name}:${hashOf(text)}`
384 if ((await read($, delivered)) === stamp) return next(e)
385 await update($, delivered, () => stamp)
386 return next({ ...e, context: [...(e.context ?? []), text] })
387 })
388
389 on('session.compact', async ($, e, next) => {
390 if (e.trigger !== 'precompute') await update($, delivered, () => '')
391 return next(e)
392 })
393
394 on('agent.offer', async ($, e, next) => {
395 if (!e.agent.startsWith(prefix($))) return next(e)
396 const team = await current($)
397 const isOn = team?.members.some(m => `${prefix($)}${m.name}` === e.agent)
398 return isOn ? next(e) : { isOffered: false }
399 }).catch(() => ({ isOffered: false }))
400
401 on('agent.spawn', async ($, e, next) => {
402 if (!e.subagentType.startsWith(prefix($))) return next(e)
403 const role = e.subagentType.slice(prefix($).length)
404
405 const agents = await $.agent.list()
406 const live = agents.filter(
407 a =>
408 a.type.startsWith(prefix($)) &&
409 ['pending', 'running', 'waiting'].includes(a.status),
410 )
411 const max = await read($, limit)
412 if (max !== null && live.length >= max) {
413 const who = live.map(a => a.type.slice(prefix($).length)).join(', ')
414 await note($, `Held back ${role}: limit of ${max} reached`)
415 return {
416 deny: `The person's helper limit of ${max} is reached (${who} running). Ask the person what to pause before starting ${role}.`,
417 }
418 }
419
420 if (await read($, isGated)) {
421 const choice = await $.ui
422 .ask(`Start ${role} on "${e.description}"?`, ['Yes', 'No'])
423 .catch(() => 'No')
424 if (choice !== 'Yes') {
425 await note($, `You declined to start ${role}`)
426 return { deny: `The person did not approve starting ${role}.` }
427 }
428 }
429
430 const started = await next(e)
431 if (started.deny === undefined && started.agentId !== undefined) {
432 const row: Row = {
433 id: started.agentId,
434 role,
435 task: e.description,
436 status: 'running',
437 note: 'starting',
438 }
439 await update($, rows, list => [...list, row].slice(-30))
440 await note($, `Started ${role}: ${e.description}`)
441 }
442 return started
443 }).catch(($, e, next) =>
444 !e.subagentType.startsWith(prefix($)) || next.called
445 ? next(e)
446 : { deny: 'teams: could not check the helper limit, so the helper was not started.' },
447 )
448
449 // The lead's way of filling the pane: brief, plan list, things the person must do.
450 on('tool.call', { tool: 'mcp__teams__team_update' }, async ($, e) => {
451 const team = await read($, active)
452 if (team === null) {
453 return { result: 'No team is on, so there is no pane to update. The person can turn one on with /team.' }
454 }
455 const input = e as unknown as {
456 brief?: string
457 items?: { id?: string; title?: string; status?: string; who?: string }[]
458 addActions?: string[]
459 doneActions?: string[]
460 }
461
462 if (typeof input.brief === 'string') {
463 const text = input.brief.trim()
464 await update($, brief, () => text)
465 }
466
467 if (Array.isArray(input.items)) {
468 const before = await read($, items)
469 const droppedIds = new Set(before.filter(i => i.status === 'dropped').map(i => i.id))
470 const next: Item[] = input.items
471 .filter(i => i && typeof i.title === 'string' && i.title.trim() !== '')
472 .slice(0, 12)
473 .map((i, n) => ({
474 id: String(i.id ?? n),
475 title: (i.title ?? '').trim(),
476 status: droppedIds.has(String(i.id ?? n))
477 ? 'dropped'
478 : ITEM_STATUSES.includes(i.status as ItemStatus)
479 ? (i.status as ItemStatus)
480 : 'todo',
481 who: (i.who ?? '').trim(),
482 }))
483 await update($, items, () => next)
484 await note($, `Plan updated: ${next.filter(i => i.status === 'done').length} of ${next.length} done`)
485 }
486
487 if (Array.isArray(input.doneActions) && input.doneActions.length > 0) {
488 const gone = input.doneActions
489 await update($, actions, list => list.filter(a => !gone.includes(a.id)))
490 await note($, `Cleared ${gone.length} item(s) that no longer need you`)
491 }
492
493 if (Array.isArray(input.addActions)) {
494 const t = (await $.clock.now()) as number
495 const added: Action[] = input.addActions
496 .filter(s => typeof s === 'string' && s.trim() !== '')
497 .map((text, n) => ({ id: `a${(t % 1000000).toString(36)}${n}`, text: text.trim() }))
498 if (added.length > 0) {
499 await update($, actions, list => [...list, ...added])
500 for (const action of added) await note($, `Needs you: ${action.text}`)
501 $.ui.toast(
502 added.length === 1 ? `Needs you: ${added[0].text}` : `${added.length} new things need you`,
503 )
504 }
505 }
506
507 await save($)
508 const open = await read($, actions)
509 return {
510 result: `Pane updated. Open actions for the person: ${open.map(a => `[${a.id}] ${a.text}`).join('; ') || 'none'}.`,
511 }
512 }).catch(() => ({ result: 'The pane could not be updated this time.' }))
513
514 on('tool.call', async ($, e, next) => {
515 const role = await roleOf($, e.agentId)
516 if (role === undefined) return next(e)
517
518 if (e.tool.startsWith('mcp__') && EXTERNAL_WRITE.test(e.tool.split('__').pop() ?? '')) {
519 await note($, `Blocked ${role} from changing a shared system (${e.tool.split('__').pop()})`)
520 return {
521 deny: `${role} cannot change or send anything in a shared system. Hand the draft to the lead for the person to publish.`,
522 }
523 }
524 if (
525 (await current($))?.members.find(m => m.name === role)?.isNoCodeEdits === true &&
526 (e.tool === 'Write' || e.tool === 'Edit') &&
527 CODE_FILE.test(e.file_path)
528 ) {
529 await note($, `Blocked ${role} from editing a code file`)
530 return {
531 deny: `${role} writes notes and drafts, not code or prototype files (${e.file_path}).`,
532 }
533 }
534
535 const id = e.agentId
536 await update($, rows, list =>
537 list.map(r => (r.id === id ? { ...r, note: e.tool.replace(/^mcp__/, '') } : r)),
538 )
539 return next(e)
540 }).catch(($, e, next) =>
541 e.agentId === undefined || next.called
542 ? next(e)
543 : { deny: 'teams: could not check this call for a team helper, so it was refused.' },
544 )
545
546 on('turn.complete', async ($, e, next) => {
547 const id = e.agentId
548 if (id !== undefined) {
549 const summary = e.answer.replace(/\s+/g, ' ').slice(0, 140)
550 const before = (await read($, rows)).find(r => r.id === id)
551 await update($, rows, list =>
552 list.map(r => (r.id === id ? { ...r, status: 'done' as const, note: summary } : r)),
553 )
554 if (before?.status === 'running') {
555 await note($, `${before.role} finished: ${summary.slice(0, 100)}`)
556 }
557 }
558 return next(e)
559 })
560
561 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
562 const els = $.ui.resolve(e)
563 const { Box, Button, Text } = els
564 // Phones draw no text field yet; the +/- buttons still work there.
565 const Input = 'Input' in els ? els.Input : null
566 const team = await read($, active)
567 const max = await read($, limit)
568 const agents = await read($, rows)
569 const summary = await read($, brief)
570 const plan = await read($, items)
571 const todo = await read($, actions)
572 const history = await read($, log)
573 const view = await read($, tab)
574 const roster = team === null ? null : loaded?.name === team ? loaded : await current($)
575 const root = await teamsDir($)
576 const entries = (await $.fs.list(root).catch(() => [])) as { name: string; kind: string }[]
577 const teamNames = entries.filter(entry => entry.kind === 'dir').map(entry => entry.name)
578 const room = Math.max(4, (e.viewport?.rows ?? 24) - 18)
579 const spaced = Math.max(1, Math.floor(room / 2))
580 const working = agents.filter(r => r.status === 'running').length
581 const tabs = [
582 { key: 'work', label: 'Work' },
583 { key: 'agents', label: 'Team' },
584 { key: 'actions', label: todo.length > 0 ? `Needs You (${todo.length})` : 'Needs You' },
585 { key: 'log', label: 'Log' },
586 ] as const
587 const more = (shown: number, total: number) =>
588 total > shown ? <Text dimColor>+{total - shown} more. Make the pane taller to see them.</Text> : null
589
590 return (
591 <Box flexDirection="column" gap={1}>
592 <Text dimColor wrap="wrap">
593 {team === null ? root : `${root}/${team}`}
594 </Text>
595
596 {team === null && (
597 <Box flexDirection="column" gap={1}>
598 <Text dimColor>○ No team on. Type /team <name> or press a button.</Text>
599 {teamNames.length > 0 && (
600 <Box gap={1}>
601 {teamNames.map(name => (
602 <Button
603 key={`on-${name}`}
604 variant="primary"
605 label={`Turn On ${name}`}
606 onPress={() => void turnOn($, name)}
607 />
608 ))}
609 </Box>
610 )}
611 </Box>
612 )}
613
614 {team !== null && (
615 <Box flexDirection="column" gap={1}>
616 <Box justifyContent="space-between">
617 <Box flexDirection="column" flexShrink={1}>
618 <Text color="green" bold>● {team} team: active</Text>
619 <Text dimColor wrap="wrap">
620 {working > 0 ? `${working} helper${working === 1 ? '' : 's'} working` : 'No helpers running'}
621 </Text>
622 </Box>
623 <Box flexShrink={0} paddingLeft={2}>
624 <Button key="team-off" label="Turn Off" onPress={() => void turnOff($)} />
625 </Box>
626 </Box>
627
628 <Text wrap="wrap">
629 {summary !== '' ? summary : 'No status yet. The lead fills this in once there is work.'}
630 </Text>
631
632 <Box flexDirection="column">
633 <Box gap={1}>
634 <Text>Helpers at once:</Text>
635 {max === null ? (
636 <Text bold>no limit</Text>
637 ) : (
638 <Text bold>{max}</Text>
639 )}
640 </Box>
641 <Box gap={1}>
642 {max !== null && (
643 <Button key="limit-down" label="−" onPress={() => void setLimit($, Math.max(1, max - 1))} />
644 )}
645 {max !== null && (
646 <Button key="limit-up" label="+" onPress={() => void setLimit($, max + 1)} />
647 )}
648 {max === null ? (
649 <Button key="limit-set" label="Set a Limit" onPress={() => void setLimit($, Math.max(1, working))} />
650 ) : (
651 <Button key="limit-none" label="No Limit" onPress={() => void setLimit($, null)} />
652 )}
653 </Box>
654 {Input !== null && (
655 <Input
656 key="limit-input"
657 label="Or type a number (or none)"
658 placeholder="3"
659 onSubmit={(value: string) => void setLimitFromText($, value)}
660 />
661 )}
662 </Box>
663
664 <Box gap={1}>
665 {tabs.map(t => (
666 <Button
667 key={`tab-${t.key}`}
668 plain
669 label={t.label}
670 onPress={() => update($, tab, () => t.key)}
671 >
672 {view === t.key ? (
673 <Text inverse bold> {t.label} </Text>
674 ) : (
675 <Text dimColor> {t.label} </Text>
676 )}
677 </Button>
678 ))}
679 </Box>
680
681 {view === 'work' && (
682 <Box flexDirection="column" gap={1}>
683 {plan.length === 0 && (
684 <Text dimColor wrap="wrap">
685 No plan yet. The lead lists what the team is digging into once there is one.
686 </Text>
687 )}
688 {plan.slice(0, spaced).map(item => (
689 <Box key={`item-${item.id}`}>
690 <Box width={2} flexShrink={0}>
691 <Text color={MARKS[item.status].color}>{MARKS[item.status].mark}</Text>
692 </Box>
693 <Box flexGrow={1} flexShrink={1}>
694 <Text wrap="wrap" dimColor={item.status === 'done' || item.status === 'dropped'}>
695 {item.title}
696 {item.who !== '' ? <Text dimColor> · {item.who}</Text> : null}
697 </Text>
698 </Box>
699 <Box flexShrink={0} paddingLeft={1}>
700 {item.status === 'dropped' ? (
701 <Button
702 key={`undo-${item.id}`}
703 label="Undo"
704 onPress={() => void setItemStatus($, item, 'todo')}
705 />
706 ) : item.status !== 'done' ? (
707 <Button
708 key={`drop-${item.id}`}
709 label="Drop"
710 onPress={() => void setItemStatus($, item, 'dropped')}
711 />
712 ) : null}
713 </Box>
714 </Box>
715 ))}
716 {more(spaced, plan.length)}
717 {plan.length > 0 && (
718 <Text dimColor wrap="wrap">○ not started ◐ in progress ● done ! blocked ⊘ dropped</Text>
719 )}
720 {plan.length > 0 && (
721 <Box>
722 <Button key="plan-clear" label="Clear Plan" onPress={() => void clearPlan($)} />
723 </Box>
724 )}
725 </Box>
726 )}
727
728 {view === 'agents' && roster !== null && (
729 <Box flexDirection="column" gap={1}>
730 <Box flexDirection="column">
731 <Box>
732 <Box width={2} flexShrink={0}>
733 <Text color="green">●</Text>
734 </Box>
735 <Box flexGrow={1} flexShrink={1}>
736 <Text wrap="wrap">
737 <Text bold>{roster.leadName}</Text>
738 <Text dimColor> · lead · this session</Text>
739 </Text>
740 </Box>
741 </Box>
742 <Box paddingLeft={2} flexDirection="column">
743 {roster.leadDescription !== '' && (
744 <Text dimColor wrap="wrap">{firstSentences(roster.leadDescription)}</Text>
745 )}
746 {summary !== '' && <Text wrap="wrap">now: {summary}</Text>}
747 </Box>
748 </Box>
749 <Box flexDirection="column" paddingLeft={2} gap={1}>
750 {roster.members.map((m, i) => {
751 const mine = agents.filter(r => r.role === m.name)
752 const running = mine.filter(r => r.status === 'running')
753 const last = [...mine].reverse().find(r => r.status === 'done')
754 const planned = plan.filter(
755 p => p.who === m.name && ['doing', 'blocked', 'todo'].includes(p.status),
756 )
757 const state = running.length > 0 ? 'working' : last !== undefined ? 'finished' : 'idle'
758 return (
759 <Box key={`member-${m.name}`} flexDirection="column">
760 <Box>
761 <Box flexShrink={0}>
762 <Text dimColor>{i === roster.members.length - 1 ? '└─' : '├─'}</Text>
763 </Box>
764 <Box width={3} flexShrink={0} paddingLeft={1}>
765 <Text color={state === 'working' ? 'yellow' : state === 'finished' ? 'green' : undefined} dimColor={state === 'idle'}>
766 {state === 'working' ? '◐' : state === 'finished' ? '●' : '○'}
767 </Text>
768 </Box>
769 <Box flexGrow={1} flexShrink={1}>
770 <Text wrap="wrap">
771 <Text bold>{m.name}</Text>
772 <Text dimColor>
773 {' · '}
774 {state === 'working' && running.length > 1 ? `working (${running.length} runs)` : state}
775 </Text>
776 </Text>
777 </Box>
778 </Box>
779 <Box paddingLeft={5} flexDirection="column">
780 <Text dimColor wrap="wrap">{firstSentences(m.description)}</Text>
781 {running.map(r => (
782 <Text key={`on-${r.id}`} color="yellow" wrap="wrap">on: {r.task}</Text>
783 ))}
784 {running.length === 0 && last !== undefined && (
785 <Text dimColor wrap="wrap">last: {last.task}</Text>
786 )}
787 {planned.map(p => (
788 <Text key={`plan-${p.id}`} wrap="wrap" color={MARKS[p.status].color}>
789 {MARKS[p.status].mark} {p.title}
790 </Text>
791 ))}
792 </Box>
793 </Box>
794 )
795 })}
796 </Box>
797 </Box>
798 )}
799
800 {view === 'actions' && (
801 <Box flexDirection="column" gap={1}>
802 {todo.length === 0 && <Text dimColor>Nothing needs you right now.</Text>}
803 {todo.slice(0, spaced).map(action => (
804 <Box key={`action-${action.id}`} justifyContent="space-between">
805 <Box flexGrow={1} flexShrink={1} paddingRight={1}>
806 <Text color="yellow" wrap="wrap">→ {action.text}</Text>
807 </Box>
808 <Box flexShrink={0}>
809 <Button
810 key={`done-${action.id}`}
811 label="Done"
812 onPress={() => void finishAction($, action)}
813 />
814 </Box>
815 </Box>
816 ))}
817 {more(spaced, todo.length)}
818 </Box>
819 )}
820
821 {view === 'log' && (
822 <Box flexDirection="column">
823 {history.length === 0 && <Text dimColor>Nothing has happened yet.</Text>}
824 {[...history].reverse().slice(0, room).map((line, n) => (
825 <Box key={`log-${line.t}-${n}`}>
826 <Box width={7} flexShrink={0}>
827 <Text dimColor>{clock(line.t)}</Text>
828 </Box>
829 <Box flexGrow={1} flexShrink={1}>
830 <Text wrap="wrap">{line.text}</Text>
831 </Box>
832 </Box>
833 ))}
834 {more(Math.min(room, history.length), history.length)}
835 </Box>
836 )}
837 </Box>
838 )}
839 </Box>
840 )
841 })
842}
843types/index.d.ts 31 lines1export type Row = {
2 id: string
3 role: string
4 task: string
5 status: 'running' | 'done'
6 note: string
7}
8
9export type ItemStatus = 'todo' | 'doing' | 'done' | 'blocked' | 'dropped'
10export type Item = { id: string; title: string; status: ItemStatus; who: string }
11export type Action = { id: string; text: string }
12export type LogLine = { t: number; text: string }
13export type Tab = 'work' | 'agents' | 'actions' | 'log'
14
15declare module 'claude-code' {
16 interface PluginState {
17 teams: {
18 active: string | null
19 isGated: boolean
20 limit: number | null
21 rows: Row[]
22 brief: string
23 items: Item[]
24 actions: Action[]
25 log: LogLine[]
26 tab: Tab
27 delivered: string
28 }
29 }
30}
31