SLOPSHOPPER

teams

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…

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-24tylerhagy/claude-teams
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · teams
│ ┃ Team ✕ › fix the failing auth test and add an audit log call │ ┃ /Users/dev/.claude/teams │ ┃ ⏺ Read(src/auth.ts) │ ┃ ○ No team on. Type /team <name> or press a ⎿ Read 6 lines │ ┃ button. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ ⎿ teams: Teams: none found. On now: none. Use /team <name> or /tea │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Team
/Users/dev/.claude/teams ○ No team on. Type /team <name> or press a button.
README

claude-teams

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.

What it does

  • /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.
  • A pane (/team opens it) with the team's folder at the top, whether the team is active, a two-sentence status, and four tabs:
  • Work: the plan as a plain-language list with a status symbol on the left (○ not started, ◐ in progress, ● done, ! blocked, ⊘ dropped). You can drop or restore an item, or clear the plan.
  • Agents: which specialist is working on what, and its latest step.
  • Needs You: things you have to decide or do, each with a Done button. A toast appears when one arrives.
  • Log: a time-stamped history: helpers started and finished, plan updates, blocked actions, your own changes.
  • Controls in the pane: Turn Off / Turn On, and a helper limit with − and +, No Limit, and a field that takes any number.
  • Guardrails on specialists, enforced by the mod rather than only asked for in their instructions:
  • a helper limit you set (none by default), and an optional approval prompt before each helper starts (/team gate on);
  • helpers cannot call tools that create, edit or send things in connected systems (wikis, trackers, chat, email); the lead can, when you ask;
  • per-specialist limits you declare in the member file (below).
  • Saved between sessions, per team: the status, plan, actions and log. /team clear resets them.

How it works

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.

Requirements

  • Claude Code with mods (plugins with a hooks module).
  • A terminal wide enough for a pane (about 110 columns).
  • Tested in the Claude Code terminal only.

Install

/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.

Writing a team

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.
  • The lead's file (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.

Commands

CommandWhat it does
/teamList teams, show which is on, open the pane
/team <name>Turn a team on
/team offTurn the team off (running helpers finish)
/team gate on / offAsk before each helper starts
/team limit <n> / limit offCap how many helpers run at once
/team clearClear the pane's status, plan, actions and log

Options

  • Teams folder: where teams live. Empty means ~/.claude/teams.

Status

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.

License

MIT

Source 2 files
hooks/register.tsx 843 lines
1import { 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 &lt;name&gt; 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}
843
types/index.d.ts 31 lines
1export 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