@sergeychuvayev/claude-fleet 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -405,3 +405,40 @@ MIT. See [LICENSE](LICENSE).
405
405
  <div align="center">
406
406
  <sub>Screenshots use synthetic sessions generated for the purpose. Fleet is not affiliated with Anthropic.</sub>
407
407
  </div>
408
+
409
+ ### Configurable teams and durable tasks
410
+
411
+ In **New agent**, choose **Software delivery** to give a brief to a Manager backed by
412
+ Product, Developer, Reviewer and QA roles. Choose **Customize team…** to save your own
413
+ team: rename/add/remove roles, write their instructions, select a Claude model per role,
414
+ and choose allowed tools. Built-in teams are copied; existing custom teams can be edited.
415
+ The original Bug fix preset remains available for existing workflows.
416
+
417
+ One role is the manager and at least one separate role is a required verifier. A third
418
+ role owns the work. Fleet adds delegation and operator-question tools to the manager;
419
+ workers report back to it. Manager roles cannot use the shell or edit tools. Verifiers
420
+ cannot use direct edit tools; Bash, when enabled for tests, remains a general-purpose
421
+ shell governed by the initiative's approval mode, not a read-only sandbox.
422
+
423
+ Each initiative snapshots its team and works in its own Git worktree. Template edits
424
+ apply to new initiatives. The Manager creates tasks with owners, acceptance criteria and
425
+ dependencies through Fleet's task tool. The initiative inspector shows the roster,
426
+ task states, assignments and returned reports. Verification comes from actual subagent
427
+ reports, including each required verifier's PASS/FAIL; the Manager cannot mark a task
428
+ verified itself. A failed review returns work for repair and invalidates the previous
429
+ attempt's reviews. “Verified” records the configured agents' verdicts for that attempt,
430
+ not a guarantee that their evaluation is correct or that later work cannot regress it.
431
+
432
+ Delegations run sequentially in the shared initiative worktree. After an interruption,
433
+ the saved task board survives and unfinished delegations are marked interrupted. Message
434
+ the Manager to resume. The initial limits are three implementation attempts per task and
435
+ $10 in reported SDK usage; **Adjust limits** changes them explicitly while idle. The SDK
436
+ budget is an execution cutoff, not a billing guarantee: usage is reported at turn end,
437
+ and a killed runtime may not report its final spend. Each turn also has a 100-turn SDK
438
+ limit and bounded continuation reminders.
439
+
440
+ Custom teams live in `teams.json` under Fleet's state directory; task history and team
441
+ snapshots live with the initiative in `sessions.json`. This version supports models
442
+ available through the Claude Agent SDK, up to eight roles per team, and 100 tasks per
443
+ initiative. It finishes at a local branch ready for review; it does not automatically
444
+ publish or merge changes.
package/managed.js CHANGED
@@ -8,6 +8,8 @@ const { gitBranch, turnSummary, toolTarget } = require('./fleet')
8
8
  const { askReason, normaliseMode, MODES, DEFAULT_MODE } = require('./permissions')
9
9
  const { stateDir } = require('./paths')
10
10
  const { getTeam, compile } = require('./teams')
11
+ const { TeamStore } = require('./team-store')
12
+ const tasks = require('./tasks')
11
13
  const worktrees = require('./worktree')
12
14
 
13
15
  const ACTIVE = new Set(['starting', 'running', 'approval', 'stopping'])
@@ -65,12 +67,14 @@ class ManagedSessions extends EventEmitter {
65
67
  this.lock = path.join(directory, 'server.lock')
66
68
  this.acquireLock()
67
69
  try {
70
+ this.teams = new TeamStore(directory)
68
71
  if (fs.existsSync(this.file)) {
69
72
  const data = JSON.parse(fs.readFileSync(this.file, 'utf8'))
70
73
  if (data.version !== 1 || !Array.isArray(data.sessions)) throw new Error('Unsupported session store format')
71
74
  for (const s of data.sessions) {
72
75
  if (!s.id || !Array.isArray(s.messages)) throw new Error('Invalid saved session')
73
76
  if (ACTIVE.has(s.status)) { s.status = 'stopped'; s.error = 'Fleet restarted. Send a message to continue this conversation.' }
77
+ tasks.interrupt(s)
74
78
  s.approvals = []
75
79
  s.currentTool = null
76
80
  for (const m of s.messages) if (m.role === 'tool' && m.status === 'running') m.status = 'interrupted'
@@ -148,7 +152,7 @@ class ManagedSessions extends EventEmitter {
148
152
  permissionMode:'default', approvalMode:s.approvalMode || DEFAULT_MODE, selectedModel:s.selectedModel || '', messages:s.messages.filter(m=>m.role!=='tool').length, links:linksFromMessages(s.messages), approvals:s.approvals.length,
149
153
  turn:turnSummary(managedEvents(s.messages), { working: ACTIVE.has(s.status) && s.status !== 'approval' }),
150
154
  error:s.error, currentTool:s.currentTool, resumeCmd:s.sessionId ? `claude --resume ${s.sessionId}` : null,
151
- kind:s.kind || 'agent', teamId:s.teamId || null, teamName:s.teamName || null,
155
+ kind:s.kind || 'agent', teamId:s.teamId || null, teamName:s.teamName || null, taskProgress:tasks.progress(s),
152
156
  worktreeBranch:s.worktree?.branch || null, costUsd:s.costUsd || 0,
153
157
  }))
154
158
  }
@@ -176,12 +180,12 @@ class ManagedSessions extends EventEmitter {
176
180
  this.checkCapacity()
177
181
  // A team turns this conversation into an initiative: the manager takes the main thread
178
182
  // and the work happens on a branch of its own rather than in the operator's checkout.
179
- const team = body.teamId ? getTeam(text(body.teamId,'Team',60)) : null
183
+ const team = body.teamId ? this.teams.get(text(body.teamId,'Team',60)) : null
180
184
  if (body.teamId && !team) fail('That team does not exist.')
181
185
  if (team && resume) fail('A resumed conversation cannot be given a team.',409)
182
186
  const id = randomUUID()
183
187
  const worktree = team ? worktrees.create({cwd,id,name}) : null
184
- const s = {id,sessionId:resume,name,cwd:worktree ? worktree.path : cwd,createRequestId:rid,createdAt:Date.now(),updatedAt:Date.now(),status:'idle',approvalMode:normaliseMode(body.approvalMode),selectedModel:modelChoice(body.model),messages:[],approvals:[],model:null,contextTokens:null,error:null,currentTool:null,requestIds:[],kind:team ? 'initiative' : 'agent',teamId:team?.id || null,teamName:team?.name || null,worktree}
188
+ const s = {id,sessionId:resume,name,cwd:worktree ? worktree.path : cwd,createRequestId:rid,createdAt:Date.now(),updatedAt:Date.now(),status:'idle',approvalMode:normaliseMode(body.approvalMode),selectedModel:modelChoice(body.model),messages:[],approvals:[],model:null,contextTokens:null,error:null,currentTool:null,requestIds:[],kind:team ? 'initiative' : 'agent',teamId:team?.id || null,teamName:team?.name || null,teamSnapshot:team ? structuredClone(team) : null,taskBoard:team?.workflow ? {tasks:[],delegations:[]} : null,worktree}
185
189
  this.sessions.set(s.id,s)
186
190
  try { this.send(s.id,{message:prompt,images:body.images,requestId:rid}) }
187
191
  catch (error) { this.sessions.delete(s.id); if (worktree) worktrees.remove(worktree); throw error }
@@ -278,8 +282,41 @@ class ManagedSessions extends EventEmitter {
278
282
  // `agent` puts the manager on the main thread, so the operator's messages reach it and
279
283
  // nobody else; `agents` is where the Agent tool resolves the rest of the team from.
280
284
  // Both compose with the claude_code preset above, which keeps the built-in tools.
281
- const team = getTeam(s.teamId)
282
- if (team) Object.assign(options, compile(team))
285
+ const team = s.teamSnapshot || getTeam(s.teamId)
286
+ if (team) {
287
+ Object.assign(options, compile(team))
288
+ if (s.selectedModel) options.agents[team.manager].model=s.selectedModel
289
+ }
290
+ if (team?.workflow) {
291
+ const remaining=(s.limits?.budgetUsd ?? team.workflow.budgetUsd)-(s.costUsd || 0)
292
+ if (remaining<=0) throw new Error('Initiative budget reached. Increase the budget explicitly before continuing.')
293
+ options.maxBudgetUsd=remaining
294
+ options.maxTurns=100
295
+ options.mcpServers={fleet:await tasks.sdkServer(s,()=>this.changed(s,true))}
296
+ options.hooks={
297
+ PreToolUse:[{hooks:[async input=>{
298
+ if (!['Agent','Task'].includes(input.tool_name)) return {}
299
+ const before=structuredClone(s.taskBoard)
300
+ let applied=false
301
+ try {
302
+ const delegation=tasks.start(s,input.tool_use_id,input.tool_input)
303
+ applied=true
304
+ const task=s.taskBoard.tasks.find(t=>t.id===delegation.taskId)
305
+ this.changed(s,true)
306
+ return {hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,prompt:input.tool_input.prompt+`\n\nFleet acceptance criteria:\n${task.criteria.map(c=>'- '+c).join('\n')}\nReturn PASS or FAIL with evidence if you are verifying. Do not edit source while verifying.`}}}
307
+ } catch(error) {if(applied)s.taskBoard=before;return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:error.message}}}
308
+ }]}],
309
+ Stop:[{hooks:[async ()=>{
310
+ const unfinished=s.taskBoard.tasks.filter(t=>!['verified','blocked'].includes(t.status) && t.attempt<(s.limits?.maxAttempts ?? team.workflow.maxAttempts))
311
+ if (unfinished.length && (run.continuations || 0)<2) {
312
+ run.continuations=(run.continuations || 0)+1
313
+ return {decision:'block',reason:'Fleet tasks remain unfinished. Read the board and continue implementation/verification, or record a concrete blocker before stopping.'}
314
+ }
315
+ return {}
316
+ }]}],
317
+ SubagentStart:[{hooks:[async input=>{run.agentRoles ||= new Map();run.agentRoles.set(input.agent_id,input.agent_type);return {}}]}],
318
+ }
319
+ }
283
320
  if (process.env.CLAUDE_FLEET_EXECUTABLE) options.pathToClaudeCodeExecutable = process.env.CLAUDE_FLEET_EXECUTABLE
284
321
  run.query = await this.queryFactory({prompt,options})
285
322
  if (run.stopping) { run.query.close(); return }
@@ -297,6 +334,7 @@ class ManagedSessions extends EventEmitter {
297
334
  run.finished = true
298
335
  this.cancelApprovals(s.id,'The agent stopped before this request was answered.')
299
336
  try { run.query?.close() } catch {}
337
+ tasks.interrupt(s)
300
338
  for (const entry of run.tools?.values() || []) if (entry.status === 'running') entry.status = 'interrupted'
301
339
  if (run.stopping) s.status='stopped'
302
340
  else if (s.status !== 'error') s.status='idle'
@@ -306,7 +344,16 @@ class ManagedSessions extends EventEmitter {
306
344
  }
307
345
  }
308
346
  event(s,run,event) {
309
- if (event.session_id) s.sessionId=event.session_id
347
+ if (event.session_id && !event.parent_tool_use_id) s.sessionId=event.session_id
348
+ if (s.taskBoard && event.parent_tool_use_id) {
349
+ const d=s.taskBoard.delegations.find(d=>d.id===event.parent_tool_use_id)
350
+ if (d && event.type==='assistant') {
351
+ d.activity=(event.message.content || []).filter(b=>b.type==='tool_use').map(b=>b.name).join(', ') || d.activity
352
+ d.model=event.message.model || d.model
353
+ const output=(event.message.content || []).filter(b=>b.type==='text').map(b=>b.text).join('\n')
354
+ if (output) d.output=output.slice(0,24000)
355
+ }
356
+ }
310
357
  if (event.type === 'system' && event.subtype === 'init') { s.model=event.model; s.status='running' }
311
358
  if (event.type === 'stream_event' && !event.parent_tool_use_id) {
312
359
  if (event.event.type === 'message_start') { run.assistant=null; run.streamText='' }
@@ -336,6 +383,7 @@ class ManagedSessions extends EventEmitter {
336
383
  for (const block of event.message?.content || []) if (block.type==='tool_result') this.toolFinished(s,run,block)
337
384
  }
338
385
  if (event.type === 'tool_progress') s.currentTool=event.tool_name
386
+ if (s.taskBoard && event.type==='system' && event.subtype==='task_notification' && event.tool_use_id && event.status!=='completed') tasks.finish(s,event.tool_use_id,event.summary,true)
339
387
  if (event.type === 'result') {
340
388
  run.result=true
341
389
  if (event.is_error) { s.status='error'; s.error=event.errors?.join('\n') || event.result || 'Claude could not finish this turn.' }
@@ -364,9 +412,22 @@ class ManagedSessions extends EventEmitter {
364
412
  entry.status = block.is_error ? 'error' : 'done'
365
413
  entry.ms = Date.now()-entry.at
366
414
  const result = resultText(block.content)
415
+ if (s.taskBoard) tasks.finish(s,block.tool_use_id,result,!!block.is_error)
367
416
  entry.truncated = result.length > MAX_TOOL_RESULT
368
417
  entry.result = block.is_error || !QUIET_RESULT.has(entry.tool) ? result.slice(0,MAX_TOOL_RESULT) : null
369
418
  }
419
+ setLimits(id,body) {
420
+ const s=this.get(id)
421
+ if (!s.teamSnapshot?.workflow) fail('This initiative does not have configurable limits.')
422
+ if (this.runs.has(id)) fail('Stop the manager before changing its limits.',409)
423
+ const {budgetUsd,maxAttempts}=body
424
+ if (!Number.isFinite(budgetUsd) || budgetUsd<0.1 || budgetUsd>1000 || budgetUsd<(s.costUsd || 0)) fail('Choose a budget between the amount already spent and $1,000 (minimum $0.10).')
425
+ if (!Number.isInteger(maxAttempts) || maxAttempts<1 || maxAttempts>10) fail('Choose 1–10 attempts per task.')
426
+ const previous=s.limits
427
+ s.limits={budgetUsd,maxAttempts}
428
+ try {this.changed(s,true)} catch(error) {s.limits=previous;throw error}
429
+ return s
430
+ }
370
431
  setModelChoice(id,body) {
371
432
  const s=this.get(id)
372
433
  s.selectedModel=modelChoice(body.model)
@@ -387,7 +448,7 @@ class ManagedSessions extends EventEmitter {
387
448
  if (!reason) return Promise.resolve({behavior:'allow',updatedInput:input})
388
449
  return new Promise(resolve => {
389
450
  const id=randomUUID()
390
- const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null,role:roleAsking(s,context)}
451
+ const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null,role:run.agentRoles?.get(context.agentID) || roleAsking(s,context)}
391
452
  let settled=false
392
453
  const finish=result=>{
393
454
  if(settled)return
@@ -461,7 +522,7 @@ class ManagedSessions extends EventEmitter {
461
522
  // from the delegation that is in flight. With two delegations running at once that is
462
523
  // ambiguous, and an honest null beats a confident guess at the wrong role.
463
524
  function roleAsking(s,context) {
464
- const team = getTeam(s.teamId)
525
+ const team = s.teamSnapshot || getTeam(s.teamId)
465
526
  if (!team) return null
466
527
  // No agentID means the request came from the main thread, which is the manager by
467
528
  // definition. The role name comes from the team rather than a literal, because a future
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sergeychuvayev/claude-fleet",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A local control room for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
@@ -37,7 +37,9 @@
37
37
  "teams.js",
38
38
  "theme.js",
39
39
  "update.js",
40
- "worktree.js"
40
+ "worktree.js",
41
+ "team-store.js",
42
+ "tasks.js"
41
43
  ],
42
44
  "scripts": {
43
45
  "start": "node bin/claude-fleet.js start",
@@ -57,7 +59,8 @@
57
59
  "@anthropic-ai/claude-agent-sdk": "0.3.177",
58
60
  "dompurify": "^3.4.15",
59
61
  "highlight.js": "^11.12.0",
60
- "marked": "^18.0.13"
62
+ "marked": "^18.0.13",
63
+ "zod": "^4.6.5"
61
64
  },
62
65
  "devDependencies": {
63
66
  "esbuild": "^0.28.2"
package/public/app.js CHANGED
@@ -103,7 +103,7 @@ function render() {
103
103
  renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
104
104
  update('session-list', shown.length ? shown.map(s => {
105
105
  const p = percent(s)
106
- return `<button class="session" data-session="${esc(key(s))}" aria-pressed="${selected === key(s)}" aria-controls="detail" title="${hasUnseen(s) ? 'New output since you last opened this' : ''}"><span><span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc((s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session'))}</span>${spawnCounts.get(s.pid) ? `<span class="spawn-badge" title="Running ${spawnCounts.get(s.pid)} background session(s)">⑂ ${spawnCounts.get(s.pid)}</span>` : ''}${s.background ? `<span class="spawn-owner" title="Started by ${esc(s.spawnedByName || 'a program')}, not from a terminal">via ${esc(s.spawnedByName || 'a program')}</span>` : ''}${s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : ''}</span>${s.kind === 'initiative' ? `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}</span>` : ''}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span><span class="session-meta"><span>${esc(s.cwd?.split('/').filter(Boolean).pop() || 'No project')}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${money(s.costUsd) ? `<span class="session-cost" title="What this conversation has cost so far">${esc(money(s.costUsd))}</span>` : ''}</span>${turnRow(s)}</span><span class="session-context ${heat(p)}">${p === null ? '—' : Math.round(p)+'%'}<span class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></span><small>${age(s.lastActivity)} ago</small></span></button>`
106
+ return `<button class="session" data-session="${esc(key(s))}" aria-pressed="${selected === key(s)}" aria-controls="detail" title="${hasUnseen(s) ? 'New output since you last opened this' : ''}"><span><span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc((s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session'))}</span>${spawnCounts.get(s.pid) ? `<span class="spawn-badge" title="Running ${spawnCounts.get(s.pid)} background session(s)">⑂ ${spawnCounts.get(s.pid)}</span>` : ''}${s.background ? `<span class="spawn-owner" title="Started by ${esc(s.spawnedByName || 'a program')}, not from a terminal">via ${esc(s.spawnedByName || 'a program')}</span>` : ''}${s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : ''}</span>${s.kind === 'initiative' ? `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}${s.taskProgress ? ` · ${s.taskProgress.verified}/${s.taskProgress.total} verified${s.taskProgress.blocked ? ` · ${s.taskProgress.blocked} need attention` : ''}` : ''}</span>` : ''}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span><span class="session-meta"><span>${esc(s.cwd?.split('/').filter(Boolean).pop() || 'No project')}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${money(s.costUsd) ? `<span class="session-cost" title="What this conversation has cost so far">${esc(money(s.costUsd))}</span>` : ''}</span>${turnRow(s)}</span><span class="session-context ${heat(p)}">${p === null ? '—' : Math.round(p)+'%'}<span class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></span><small>${age(s.lastActivity)} ago</small></span></button>`
107
107
  }).join('') : `<div class="empty">${filter === 'background' ? 'No background sessions right now.' : total ? 'No sessions match your filters.<br>Try another search or select All sessions.' : 'Your fleet is quiet.<br>Start a Claude Code session and it will appear here automatically.'}</div>`)
108
108
  const current = shown.find(s => key(s) === selected)
109
109
  if (current) markSeen(key(current), current.lastActivity)
package/public/control.js CHANGED
@@ -29,14 +29,16 @@ function updateLaunchTeam() {
29
29
  $('launch-title').textContent=resumeSource ? 'Continue this conversation in Fleet.' : team ? 'Give your team a brief.' : 'Give your next task a home.'
30
30
  document.querySelector('label[for="launch-prompt"]').textContent=team ? 'Brief for the manager' : 'What are we working on?'
31
31
  document.querySelector('.launch-task-note').textContent=team ? `You talk to the ${team.manager || 'manager'}. They delegate to the team and bring the reports back here.` : 'Big ideas, small fixes. Every task starts here.'
32
- $('launch-prompt').placeholder=team ? 'Describe the goal, boundaries, verification steps, and what a good result looks like.' : 'There’s something I’d love your help with…\n\nDescribe the task, what a good result looks like, and anything your agent should know.'
32
+ $('launch-prompt').placeholder=team ? 'Describe what you want to accomplish. Your manager will work out the tasks and bring back any questions.' : 'There’s something I’d love your help with…\n\nDescribe the task, what a good result looks like, and anything your agent should know.'
33
33
  if(!$('launch-submit').disabled) $('launch-submit').textContent=team ? 'Launch initiative ↗' : 'Launch agent ↗'
34
+ if($('customize-team')) $('customize-team').disabled=!!resumeSource || launchTeamsLoading || $('launch-submit').disabled
34
35
  $('launch-team').disabled=!!resumeSource || launchTeamsLoading || $('launch-submit').disabled
35
36
  $('launch-team-note').textContent=resumeSource ? 'Continuing with the existing agent.' : team ? [team.description, `Roles: ${team.roles.map(r=>r.name).join(', ')}.`].filter(Boolean).join(' ') : launchTeamsLoading ? 'Loading teams…' : launchTeams ? 'No team keeps this a single-agent conversation.' : 'Teams unavailable. Reopen this dialog to retry; single agents are still available.'
36
37
  }
37
38
  async function loadLaunchTeams() {
38
39
  if(!$('launch-team')) {
39
- document.querySelector('.launch-fields').insertAdjacentHTML('afterbegin','<label for="launch-team">Team<select id="launch-team" name="teamId" aria-describedby="launch-team-note"><option value="">No team · single agent</option></select><span class="note" id="launch-team-note" role="status"></span></label>')
40
+ document.querySelector('.launch-fields').insertAdjacentHTML('afterbegin','<label for="launch-team">Team<select id="launch-team" name="teamId" aria-describedby="launch-team-note"><option value="">No team · single agent</option></select><span class="note" id="launch-team-note" role="status"></span></label><button type="button" class="button" id="customize-team">Customize team…</button>')
41
+ $('customize-team').addEventListener('click',()=>window.FleetTeams.open())
40
42
  $('launch-team').addEventListener('change',()=>{launchRequestId=null;updateLaunchTeam()})
41
43
  }
42
44
  if(launchTeams || launchTeamsLoading){updateLaunchTeam();return}
@@ -50,6 +52,7 @@ async function loadLaunchTeams() {
50
52
  finally {launchTeamsLoading=false;updateLaunchTeam()}
51
53
  }
52
54
  function openLaunch(source=null) {
55
+ window.FleetTeams?.reset()
53
56
  resumeSource=source
54
57
  $('launch-title').textContent=source ? 'Continue this conversation in Fleet.' : 'Give your next task a home.'
55
58
  if(source){$('launch-cwd').value=source.cwd || '';$('launch-form').elements.name.value=source.title || source.name || ''}
@@ -68,6 +71,7 @@ document.addEventListener('keydown', event => {
68
71
  $('launch-form').addEventListener('input',()=>{launchRequestId=null})
69
72
  $('launch-form').addEventListener('submit',async event=>{
70
73
  event.preventDefault()
74
+ if(window.FleetTeams?.isEditing()){window.FleetTeams.save();return}
71
75
  const button=$('launch-submit'); if(button.disabled)return
72
76
  button.disabled=true;button.textContent='Launching…';$('launch-error').hidden=true
73
77
  const form=event.currentTarget
@@ -81,7 +85,7 @@ $('launch-form').addEventListener('submit',async event=>{
81
85
  await tick();toast(form.elements.teamId?.value && !resumeSource ? 'Initiative launched' : 'Agent launched')
82
86
  if(matchMedia('(max-width:720px)').matches)$('detail').scrollIntoView({block:'start',behavior:'instant'})
83
87
  }catch(error){$('launch-error').textContent=error.message;$('launch-error').hidden=false}
84
- finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea,select').forEach(el=>el.disabled=false);if($('launch-team'))updateLaunchTeam()}
88
+ finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea,select').forEach(el=>el.disabled=!!el.closest('#team-editor'));if($('launch-team'))updateLaunchTeam()}
85
89
  })
86
90
  function selectControl(session) {
87
91
  const next=session?.managedId || null
@@ -142,6 +146,7 @@ async function refreshControl() {
142
146
  }
143
147
  function renderControl() {
144
148
  const s=controlSession;if(!s || s.id!==controlId || !$('composer'))return
149
+ window.FleetTeams?.board(s)
145
150
  $('conversation-title').textContent=s.aiTitle || s.name
146
151
  $('agent-state').textContent=s.currentTool && s.status==='running' ? `Using ${s.currentTool}` : managedLabels[s.status]
147
152
  $('agent-state').className=`subtle ${s.status==='approval' ? 'stale' : ''}`
package/public/index.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>Fleet — Claude Code control room</title><link rel="manifest" href="/manifest.webmanifest"><link rel="icon" href="/icons/fleet-192.png" type="image/png"><link rel="apple-touch-icon" href="/icons/fleet-192.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Fleet"><link rel="stylesheet" href="/theme.css"><link rel="stylesheet" href="/styles.css"><script src="/vendor/libs.js" defer></script><script src="/app.js" defer></script><script src="/blocks.js" defer></script><script src="/control.js" defer></script><script src="/ask.js" defer></script></head>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>Fleet — Claude Code control room</title><link rel="manifest" href="/manifest.webmanifest"><link rel="icon" href="/icons/fleet-192.png" type="image/png"><link rel="apple-touch-icon" href="/icons/fleet-192.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Fleet"><link rel="stylesheet" href="/theme.css"><link rel="stylesheet" href="/styles.css"><script src="/vendor/libs.js" defer></script><script src="/app.js" defer></script><script src="/blocks.js" defer></script><script src="/control.js" defer></script><script src="/teams.js" defer></script><script src="/ask.js" defer></script></head>
3
3
  <body><header class="topbar"><a class="brand" href="/" aria-label="Fleet home"><span class="brandmark">✳</span> fleet <span class="brand-sub">/ CLAUDE CODE</span></a><span class="app-version" id="app-version" title="The version of Fleet this server is running"></span><div class="connection"><span id="connection-dot" class="dot busy"></span><span id="connection">Connecting</span><span class="local">LOCAL CONTROL ROOM</span></div><div class="refresh-info"><button id="update-pill" class="button update-pill" hidden></button><span id="updated">Waiting for first snapshot</span><button id="refresh" class="button">↻ Refresh</button><button id="ask-sessions" class="button" aria-expanded="false" aria-controls="ask-backdrop" title="Ask a question across every session on this machine">⌕ Ask <kbd id="ask-shortcut">⌘K</kbd></button><button id="new-session" class="button resume" aria-expanded="false" aria-controls="launch-backdrop">+ New agent</button></div></header>
4
4
  <main>
5
5
  <div id="error" class="error" role="status" hidden></div>
package/public/styles.css CHANGED
@@ -465,3 +465,21 @@ body[data-modal]{overflow:hidden}
465
465
 
466
466
  .app-version{align-self:center;margin-left:8px;color:var(--muted);font-size:10px;letter-spacing:.04em;font-variant-numeric:tabular-nums}
467
467
  .session-cost{font-variant-numeric:tabular-nums}
468
+
469
+ /* Configurable teams share the launch dialog and the Warp console palette. */
470
+ .team-editor-head,.team-editor-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:20px}
471
+ .team-editor-head h3{margin:5px 0 8px;font-size:20px}.team-editor-head p{margin:0}.team-editor-head>.button{flex-shrink:0}
472
+ .team-meta,.team-rules{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:16px 0}.team-meta>label:last-child{grid-column:1/-1}
473
+ .team-role-list{margin:20px 0}.team-role{border-top:1px solid var(--line)}.team-role:last-child{border-bottom:1px solid var(--line)}
474
+ .team-role>summary{display:flex;gap:12px;align-items:center;cursor:pointer;padding:16px 0}.team-role>summary strong{min-width:100px;color:var(--text)}.team-role>summary span{flex:1;color:var(--muted);font-size:11px}.team-role>summary small{color:var(--accent)}
475
+ .team-role-fields{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:0 0 20px}.team-wide{grid-column:1/-1}.team-role-fields textarea{font-family:var(--mono);font-size:12px}.team-role-kind,.team-tools{display:flex;flex-wrap:wrap;gap:16px;align-items:center}
476
+ #team-editor .team-role-kind label,#team-editor .team-tools label{display:flex;flex-direction:row;align-items:center;gap:6px}#team-editor input[type=checkbox],#team-editor input[type=radio]{width:auto;min-height:20px;accent-color:var(--accent)}
477
+ .team-role-kind button{margin-left:auto}.team-role-fields fieldset{margin:0;padding:12px;border:1px solid var(--line);border-radius:6px}.team-role-fields legend{font-size:11px;color:var(--muted);padding:0 6px}.team-editor-actions{margin:20px 0 0}
478
+ #initiative-board{flex:none;border:1px solid var(--line);border-radius:8px;margin-bottom:10px;font-size:11px;overflow:hidden}
479
+ #initiative-board>summary{display:flex;gap:12px;padding:12px;cursor:pointer;background:var(--panel)}#initiative-board>summary strong{margin-right:auto}#initiative-board>summary span{color:var(--muted);font-variant-numeric:tabular-nums}
480
+ .initiative-body{max-height:38vh;overflow:auto;padding:12px}.initiative-roster{display:flex;align-items:center;gap:10px;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:12px}.initiative-role{padding:5px 0}.initiative-role strong,.initiative-role small{display:block}.initiative-role small{margin-top:5px;color:var(--muted);font-size:10px}.initiative-role.is-active strong{color:var(--accent)}.team-connector{color:var(--faint)}
481
+ .initiative-tasks{list-style:none;padding:0;margin:0}.initiative-tasks>li{padding:12px 0;border-bottom:1px solid var(--line)}.initiative-tasks summary{cursor:pointer;line-height:1.7}.initiative-tasks summary small{display:block;color:var(--muted)}.task-state{display:inline-block;color:var(--muted);margin-right:8px}.task-state[data-state=verified]{color:var(--busy)}.task-state[data-state=blocked],.task-state[data-state=changes_requested]{color:var(--stale)}.task-state[data-state=working]{color:var(--accent)}
482
+ .initiative-tasks ul{padding-left:20px;line-height:1.8}.initiative-handoff{margin-top:10px;padding:8px 10px;background:var(--panel);border-radius:5px}.initiative-handoff summary span{color:var(--muted);margin-left:8px}.initiative-handoff h4{margin-bottom:6px;color:var(--muted)}.initiative-handoff pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.7 var(--mono);max-height:300px;overflow:auto}.initiative-body>.note{margin:12px 0 0;font-size:10px}
483
+ @media(max-width:720px){.team-editor-head{align-items:start}.team-meta,.team-rules,.team-role-fields{grid-template-columns:1fr}.team-role>summary{flex-wrap:wrap}.team-role>summary span{flex-basis:100%;order:3}.team-role>summary small{margin-left:auto}#initiative-board>summary{flex-wrap:wrap}.initiative-body{max-height:32vh}}
484
+ #team-editor{padding:3px 32px 28px}.initiative-limits{display:flex;flex-wrap:wrap;align-items:end;gap:12px;margin-top:12px}.initiative-limits label{display:flex;flex-direction:column;gap:6px;color:var(--muted)}.initiative-limits input{width:110px;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px}.initiative-limits .form-error{width:100%}@media(max-width:720px){#team-editor{padding:0 20px 24px}.team-editor-head{flex-wrap:wrap}}
485
+ .modal-launch.is-editing-team>.modal-head .modal-heading{display:none}.modal-launch.is-editing-team>.modal-head{justify-content:flex-end;padding-bottom:8px}.modal-launch.is-editing-team #team-editor{padding-top:0}
@@ -0,0 +1,125 @@
1
+ 'use strict'
2
+ // An isolated scope: Fleet's browser files are classic scripts.
3
+ window.FleetTeams=(()=>{
4
+ let draft=null,tools=[],originalId=null,returnTeam=null
5
+ const escape=value=>esc(String(value ?? ''))
6
+ const field=(label,key,value,max=500)=>`<label>${label}<input data-team-field="${key}" value="${escape(value)}" maxlength="${max}" required></label>`
7
+ function mount() {
8
+ if(document.getElementById('team-editor'))return
9
+ const container=document.createElement('section')
10
+ container.id='team-editor';container.hidden=true;container.setAttribute('aria-label','Team editor')
11
+ container.innerHTML='<div class="team-editor-head"><div><span class="modal-eyebrow">YOUR TEAM, YOUR WAY</span><h3>Shape the team.</h3><p class="note">Choose the specialists. Fleet handles task tracking and independent verification.</p></div><button type="button" class="button" id="team-editor-back">Back to task</button></div><div id="team-editor-fields"></div><p id="team-editor-error" class="form-error" role="alert" hidden></p><div class="team-editor-actions"><button type="button" class="button" id="team-add-role">+ Add role</button><button type="button" class="button resume" id="team-save">Save team</button></div>'
12
+ document.getElementById('launch-form').append(container)
13
+ container.querySelector('#team-editor-back').addEventListener('click',()=>toggle(false))
14
+ container.querySelector('#team-add-role').addEventListener('click',()=>{try{read();if(Object.keys(draft.roles).length>=8)throw new Error('A team can have up to eight roles.');let key='specialist';while(draft.roles[key])key+='x';draft.roles[key]={description:'A specialist for this task',prompt:'Describe this specialist’s responsibility and expected output.',model:'sonnet',tools:['Read','Glob','Grep']};render()}catch(error){showError(error)}})
15
+ container.querySelector('#team-save').addEventListener('click',save)
16
+ container.querySelector('#team-editor-fields').addEventListener('change',event=>{if(['manager','reviewer'].includes(event.target.dataset.roleField))syncTools()})
17
+ container.querySelector('#team-editor-fields').addEventListener('click',event=>{
18
+ const button=event.target.closest('[data-remove-role]');if(!button)return
19
+ try{read();const key=button.dataset.removeRole;if(key===draft.manager)throw new Error('Choose another manager before removing this role.');delete draft.roles[key];draft.workflow.reviewers=draft.workflow.reviewers.filter(r=>r!==key);render()}catch(error){showError(error)}
20
+ })
21
+ }
22
+ function toggle(editing) {
23
+ const form=document.getElementById('launch-form')
24
+ form.noValidate=editing
25
+ document.querySelector('.modal-launch').classList.toggle('is-editing-team',editing)
26
+ for(const child of form.children)child.hidden=editing ? child.id!=='team-editor' : child.id==='team-editor' || child.id==='launch-error'
27
+ // Hidden editor fields must not participate in the launch form's validation.
28
+ document.querySelectorAll('#team-editor input,#team-editor textarea,#team-editor select').forEach(el=>el.disabled=!editing)
29
+ if(editing){syncTools();document.querySelector('#team-editor input')?.focus()}
30
+ else {document.getElementById('launch-team').value=returnTeam || '';updateLaunchTeam();document.getElementById('launch-prompt').focus()}
31
+ }
32
+ async function open() {
33
+ mount()
34
+ try {
35
+ const selected=document.getElementById('launch-team').value || 'delivery'
36
+ const [data,catalog]=await Promise.all([api(`/api/teams/${encodeURIComponent(selected)}`),api('/api/teams')])
37
+ tools=catalog.tools;draft=data.team;returnTeam=selected
38
+ const custom=catalog.teams.find(t=>t.id===selected)?.custom
39
+ originalId=custom ? selected:null
40
+ if(!custom){draft.id=`${draft.id}-custom`;while(catalog.teams.some(t=>t.id===draft.id))draft.id+='-copy';draft.name+= ' · custom'}
41
+ draft.workflow ||= {reviewers:['qa'],maxAttempts:3,budgetUsd:10}
42
+ for(const [name,role] of Object.entries(draft.roles))role.tools ||= tools.filter(t=>!(role.disallowedTools || []).includes(t) && (name!==draft.manager || ['Read','Glob','Grep','WebSearch','WebFetch'].includes(t)))
43
+ render();toggle(true)
44
+ } catch(error){toast(error.message)}
45
+ }
46
+ function render() {
47
+ document.getElementById('team-editor-error').hidden=true
48
+ const roles=Object.entries(draft.roles)
49
+ document.getElementById('team-editor-fields').innerHTML=`<div class="team-meta">${field('Team name','name',draft.name,80)}${field('Team ID','id',draft.id,60)}${field('Purpose','description',draft.description,500)}</div><div class="team-rules"><label>Repair attempts per task<input data-team-field="maxAttempts" type="number" min="1" max="10" value="${draft.workflow.maxAttempts}"></label><label>Initiative budget · USD<input data-team-field="budgetUsd" type="number" min="0.1" max="1000" step="0.1" value="${draft.workflow.budgetUsd}"></label></div><p class="note">One manager talks to you. Verification roles check every task; at least one separate worker does the work. Shell access permits commands and is governed by your approval mode.</p><div class="team-role-list">${roles.map(([key,role],index)=>`<details class="team-role" data-role-key="${escape(key)}" ${index===0 ? 'open':''}><summary><strong>${escape(key)}</strong><span>${escape(role.description)}</span><small>${escape(role.model)}</small></summary><div class="team-role-fields"><label>Role ID<input data-role-field="id" value="${escape(key)}" maxlength="40" required></label><label>Model<input data-role-field="model" value="${escape(role.model || 'inherit')}" list="team-model-options" maxlength="80" required></label><label class="team-wide">Purpose<input data-role-field="description" value="${escape(role.description)}" maxlength="500" required></label><label class="team-wide">Instructions<textarea data-role-field="prompt" rows="6" maxlength="12000" required>${escape(role.prompt)}</textarea></label><div class="team-role-kind team-wide"><label><input type="radio" name="team-manager-role" data-role-field="manager" ${key===draft.manager ? 'checked':''}> Manager</label><label><input type="checkbox" data-role-field="reviewer" ${draft.workflow.reviewers.includes(key) ? 'checked':''}> Required verifier</label><button type="button" class="button" data-remove-role="${escape(key)}">Remove role</button></div><fieldset class="team-wide"><legend>Allowed tools</legend><div class="team-tools">${tools.map(tool=>`<label><input type="checkbox" data-tool="${escape(tool)}" ${(role.tools || []).includes(tool) ? 'checked':''}> ${escape(tool)}</label>`).join('')}</div></fieldset></div></details>`).join('')}</div><datalist id="team-model-options"><option value="opus"><option value="sonnet"><option value="haiku"><option value="inherit"></datalist>`
50
+ document.querySelector('[data-team-field="id"]').readOnly=!!originalId
51
+ syncTools()
52
+ }
53
+ function syncTools() {
54
+ for(const row of document.querySelectorAll('#team-editor [data-role-key]')) {
55
+ const manager=row.querySelector('[data-role-field="manager"]').checked
56
+ const verifier=row.querySelector('[data-role-field="reviewer"]').checked
57
+ for(const el of row.querySelectorAll('[data-tool]')) {
58
+ el.disabled=(manager && !['Read','Glob','Grep','WebSearch','WebFetch'].includes(el.dataset.tool)) || (verifier && ['Write','Edit','MultiEdit','NotebookEdit'].includes(el.dataset.tool))
59
+ if(el.disabled)el.checked=false
60
+ }
61
+ }
62
+ }
63
+ function read() {
64
+ const next={roles:{},workflow:{reviewers:[]}}
65
+ for(const el of document.querySelectorAll('[data-team-field]')) {
66
+ const key=el.dataset.teamField
67
+ if(['maxAttempts','budgetUsd'].includes(key))next.workflow[key]=Number(el.value)
68
+ else next[key]=el.value.trim()
69
+ }
70
+ for(const row of document.querySelectorAll('#team-editor [data-role-key]')) {
71
+ const value=key=>row.querySelector(`[data-role-field="${key}"]`)
72
+ const id=value('id').value.trim()
73
+ if(!/^[a-z][a-z0-9-]{0,39}$/.test(id) || ['constructor','prototype','__proto__'].includes(id))throw new Error('Role IDs need lowercase letters, numbers and hyphens.')
74
+ if(Object.hasOwn(next.roles,id))throw new Error(`Role ID “${id}” is used twice.`)
75
+ next.roles[id]={description:value('description').value.trim(),prompt:value('prompt').value.trim(),model:value('model').value.trim(),tools:[...row.querySelectorAll('[data-tool]:checked')].map(el=>el.dataset.tool)}
76
+ if(value('manager').checked)next.manager=id
77
+ if(value('reviewer').checked)next.workflow.reviewers.push(id)
78
+ }
79
+ draft=next
80
+ }
81
+ function showError(error){const el=document.getElementById('team-editor-error');el.textContent=error.message;el.hidden=false}
82
+ async function save() {
83
+ const button=document.getElementById('team-save');button.disabled=true
84
+ try {
85
+ read();const {team}=await api('/api/teams',draft)
86
+ const catalog=await api('/api/teams');launchTeams=catalog.teams
87
+ const select=document.getElementById('launch-team');select.replaceChildren(new Option('No team · single agent',''))
88
+ for(const t of launchTeams)select.add(new Option(t.name,t.id))
89
+ returnTeam=team.id;launchRequestId=null;toggle(false);toast('Team saved. Ready for your task.')
90
+ } catch(error){showError(error)}finally{button.disabled=false}
91
+ }
92
+ function board(s) {
93
+ let panel=document.getElementById('initiative-board')
94
+ if(!s.teamSnapshot?.workflow){panel?.remove();return}
95
+ if(!panel){panel=document.createElement('details');panel.id='initiative-board';panel.open=true;document.getElementById('conversation').before(panel);panel.addEventListener('click',event=>{if(event.target.closest('[data-adjust-limits]'))adjustLimits(s.id)})}
96
+ const b=s.taskBoard || {tasks:[],delegations:[]},done=b.tasks.filter(t=>t.status==='verified').length
97
+ const signature=JSON.stringify([b,s.status,s.costUsd,s.teamSnapshot,s.limits])
98
+ if(panel.fleetSignature===signature)return
99
+ const opened=new Set([...panel.querySelectorAll('details[open][data-evidence]')].map(el=>el.dataset.evidence))
100
+ const scrollTop=panel.querySelector('.initiative-body')?.scrollTop || 0
101
+ const focused=document.activeElement?.closest('[data-evidence]')?.dataset.evidence
102
+ panel.fleetSignature=signature
103
+ const active=b.delegations.find(d=>d.status==='running')
104
+ panel.innerHTML=`<summary><strong>${escape(s.teamName)}</strong><span>${done}/${b.tasks.length} verified</span><span>$${(s.costUsd || 0).toFixed(2)} / $${s.limits?.budgetUsd ?? s.teamSnapshot.workflow.budgetUsd}</span></summary><div class="initiative-body"><div class="initiative-roster" aria-label="Team and active agent">${Object.entries(s.teamSnapshot.roles).map(([name,r])=>`<div class="initiative-role ${(active?.role || (isWorking(s) ? s.teamSnapshot.manager:null))===name ? 'is-active':''}"><strong>${escape(name)}</strong><small>${escape(name===s.teamSnapshot.manager && s.selectedModel ? s.selectedModel : r.model)}${name===s.teamSnapshot.manager ? ' · your contact':active?.role===name ? ' · working':''}</small></div>`).join('<span class="team-connector" aria-hidden="true">·</span>')}</div>${b.tasks.length ? `<ol class="initiative-tasks">${b.tasks.map(t=>`<li><details data-evidence="${t.id}" ${opened.has(t.id) ? 'open':''}><summary><span class="task-state" data-state="${escape(t.status)}">${escape(t.status.replaceAll('_',' '))}</span><strong>${escape(t.title)}</strong><small>${escape(t.owner)} · attempt ${t.attempt}</small></summary><ul>${t.criteria.map(c=>`<li>${escape(c)}</li>`).join('')}</ul>${t.blocker ? `<p class="form-error">${escape(t.blocker)}</p>`:''}${t.dependencies.length ? `<p class="note">After: ${t.dependencies.map(id=>escape(b.tasks.find(t=>t.id===id)?.title || id)).join(', ')}</p>`:''}${b.delegations.filter(d=>d.taskId===t.id).map(d=>`<details class="initiative-handoff" data-evidence="${d.id}" ${opened.has(d.id) ? 'open':''}><summary>${escape(s.teamSnapshot.manager)} → ${escape(d.role)} <span>${escape(d.status)}${d.activity && d.status==='running' ? ' · '+escape(d.activity):''}</span></summary><h4>Assignment</h4><pre>${escape(d.prompt)}</pre><h4>Report to ${escape(s.teamSnapshot.manager)}</h4><pre>${escape(d.report || 'Waiting for the agent’s report.')}</pre></details>`).join('')}</details></li>`).join('')}</ol>`:'<p class="note">The manager is shaping your brief. Tasks and handoffs will appear here as work begins.</p>'}<button type="button" class="button" data-adjust-limits ${isWorking(s) ? 'disabled':''}>Adjust limits</button><p class="note">Verified means all configured verifiers returned passing reports for that task’s attempt. Expand a task to inspect the evidence.</p></div>`
105
+ panel.querySelector('.initiative-body').scrollTop=scrollTop
106
+ if(focused)panel.querySelector(`[data-evidence="${CSS.escape(focused)}"]>summary`)?.focus({preventScroll:true})
107
+ const composer=document.getElementById('message-input');composer.placeholder=`Message ${s.teamSnapshot.manager}…`
108
+ }
109
+ async function adjustLimits(id){
110
+ const s=controlSession;if(!s || s.id!==id)return
111
+ const panel=document.getElementById('initiative-board')
112
+ if(panel.querySelector('.initiative-limits'))return
113
+ const box=document.createElement('div');box.className='initiative-limits'
114
+ box.innerHTML=`<label>Budget · USD<input type="number" data-limit="budgetUsd" min="0.1" max="1000" step="0.1" value="${s.limits?.budgetUsd ?? s.teamSnapshot.workflow.budgetUsd}"></label><label>Attempts per task<input type="number" data-limit="maxAttempts" min="1" max="10" value="${s.limits?.maxAttempts ?? s.teamSnapshot.workflow.maxAttempts}"></label><button type="button" class="button">Save limits</button><p class="form-error" role="alert" hidden></p>`
115
+ panel.querySelector('.initiative-body').append(box)
116
+ box.querySelector('input').focus()
117
+ box.querySelector('button').addEventListener('click',async event=>{
118
+ event.target.disabled=true
119
+ try{const limits=Object.fromEntries([...box.querySelectorAll('[data-limit]')].map(el=>[el.dataset.limit,Number(el.value)]));await api(`/api/managed/${id}/limits`,limits);await refreshControl();toast('Limits saved. Message the manager to continue.')}
120
+ catch(error){const el=box.querySelector('p');el.textContent=error.message;el.hidden=false;event.target.disabled=false}
121
+ })
122
+ }
123
+ function reset(){if(document.getElementById('team-editor'))toggle(false)}
124
+ return {open,board,reset,save,isEditing:()=>!!document.getElementById('team-editor') && !document.getElementById('team-editor').hidden}
125
+ })()
package/server.js CHANGED
@@ -12,7 +12,7 @@ const { SearchJobs, warm: warmSearch, WINDOW_DAYS: SEARCH_DAYS } = require('./se
12
12
  const { Archive } = require('./archive.js')
13
13
  const { Updater } = require('./update.js')
14
14
  const { defaultCwd } = require('./paths.js')
15
- const { listTeams } = require('./teams.js')
15
+ const { TOOL_OPTIONS } = require('./team-store.js')
16
16
  const { openDashboard } = require('./open.js')
17
17
  const { version: VERSION } = require('./package.json')
18
18
  const HOST = '127.0.0.1'
@@ -95,7 +95,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
95
95
  async function body(req, limit = 65536) {
96
96
  if (!(req.headers['content-type'] || '').startsWith('application/json')) throw Object.assign(new Error('JSON content type is required.'),{status:415})
97
97
  let size=0, chunks=[]
98
- for await(const chunk of req){size+=chunk.length;if(size>limit) throw Object.assign(new Error(limit > 65536 ? 'Attachments are too large for one message.' : 'Request is too large.'),{status:413});chunks.push(chunk)}
98
+ for await(const chunk of req){size+=chunk.length;if(size>limit) throw Object.assign(new Error(limit > 256000 ? 'Attachments are too large for one message.' : 'Request is too large.'),{status:413});chunks.push(chunk)}
99
99
  let data
100
100
  try{data=JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw Object.assign(new Error('Invalid JSON.'),{status:400})}
101
101
  if(!data || typeof data!=='object' || Array.isArray(data)) throw Object.assign(new Error('Expected a JSON object.'),{status:400})
@@ -121,7 +121,8 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
121
121
  if(storageError && url.pathname!=='/api/update' && !/^\/api\/managed\/[\w-]+\/stop$/.test(url.pathname)) return json(res,503,{error:storageError})
122
122
  // Only the two endpoints that carry a message accept image-sized bodies.
123
123
  const carriesMessage=url.pathname==='/api/managed' || /^\/api\/managed\/[\w-]+\/messages$/.test(url.pathname)
124
- const data=await body(req, carriesMessage ? 40 * 1024 * 1024 : 65536)
124
+ const data=await body(req, carriesMessage ? 40 * 1024 * 1024 : url.pathname==='/api/teams' ? 256000 : 65536)
125
+ if(url.pathname==='/api/teams') return json(res,200,{team:manager.teams.save(data)})
125
126
  if(url.pathname==='/api/managed') return json(res,201,{session:manager.detail(manager.create(data).id)})
126
127
  // Keyword hits come back at once; the answer is fetched by id while Claude reads them.
127
128
  if(url.pathname==='/api/search') return json(res,201,{job:search.start(data)})
@@ -134,7 +135,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
134
135
  if(restart) setTimeout(()=>{restart().catch(error=>console.error(error.message))},250).unref()
135
136
  return json(res,200,{update:{...update,restarting:!!restart}})
136
137
  }
137
- const match=url.pathname.match(/^\/api\/managed\/([\w-]+)\/(messages|stop|mode|model|close|approvals\/([\w-]+))$/)
138
+ const match=url.pathname.match(/^\/api\/managed\/([\w-]+)\/(messages|stop|mode|model|limits|close|approvals\/([\w-]+))$/)
138
139
  if(!match) return json(res,404,{error:'Unknown action.'})
139
140
  const [,id,action,approvalId]=match
140
141
  if(action==='close') return json(res,200,{closed:await manager.remove(id)})
@@ -142,6 +143,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
142
143
  else if(action==='stop') manager.stop(id)
143
144
  else if(action==='mode') manager.setMode(id,data)
144
145
  else if(action==='model') manager.setModelChoice(id,data)
146
+ else if(action==='limits') manager.setLimits(id,data)
145
147
  else manager.decide(id,approvalId,data)
146
148
  return json(res,200,{session:manager.detail(id)})
147
149
  }
@@ -168,7 +170,9 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
168
170
  return res.end(themeCss(currentTheme()))
169
171
  }
170
172
  if(url.pathname==='/api/models') return json(res,200,{models:manager.models || MODEL_FALLBACK})
171
- if(url.pathname==='/api/teams') return json(res,200,{teams:listTeams()})
173
+ if(url.pathname==='/api/teams') return json(res,200,{teams:manager.teams.list(),tools:TOOL_OPTIONS})
174
+ const teamRoute=url.pathname.match(/^\/api\/teams\/([a-z][a-z0-9-]*)$/)
175
+ if(teamRoute) {const team=manager.teams.get(teamRoute[1]);return json(res,team ? 200:404,team ? {team}:{error:'Team not found.'})}
172
176
  if(url.pathname==='/api/sessions') return json(res,200,getSnapshot())
173
177
  if(url.pathname==='/api/events') {
174
178
  if(clients.size>=20) return json(res,429,{error:'Too many dashboard connections.'})
@@ -195,7 +199,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
195
199
  if(holder) session.openElsewhere=holder
196
200
  return json(res,200,{session})
197
201
  }
198
- const files={'/':'index.html','/index.html':'index.html','/styles.css':'styles.css','/app.js':'app.js','/control.js':'control.js','/blocks.js':'blocks.js','/ask.js':'ask.js','/vendor/libs.js':path.join('vendor','libs.js'),'/icons/fleet-192.png':path.join('icons','fleet-192.png'),'/icons/fleet-512.png':path.join('icons','fleet-512.png')}
202
+ const files={'/':'index.html','/index.html':'index.html','/styles.css':'styles.css','/app.js':'app.js','/control.js':'control.js','/blocks.js':'blocks.js','/ask.js':'ask.js','/teams.js':'teams.js','/vendor/libs.js':path.join('vendor','libs.js'),'/icons/fleet-192.png':path.join('icons','fleet-192.png'),'/icons/fleet-512.png':path.join('icons','fleet-512.png')}
199
203
  const file=files[url.pathname]
200
204
  if(!file) return json(res,404,{error:'Not found.'})
201
205
  const data=await fs.promises.readFile(path.join(PUBLIC,file))
package/tasks.js ADDED
@@ -0,0 +1,102 @@
1
+ 'use strict'
2
+ const {randomUUID}=require('node:crypto')
3
+ const fail=message=>{throw new Error(message)}
4
+ const idFrom=prompt=>typeof prompt==='string' ? prompt.match(/^Fleet task: ([\w-]+)\s*$/m)?.[1] : null
5
+ function ledger(s) {return s.taskBoard ||= {tasks:[],delegations:[]}}
6
+ function taskFor(s,id) {const task=ledger(s).tasks.find(t=>t.id===id);if(!task)fail('Task not found. Read the Fleet task board.');return task}
7
+ function act(s,input) {
8
+ const board=ledger(s)
9
+ if (input.action==='list') return board
10
+ if (input.action==='create') {
11
+ if (board.tasks.length>=100) fail('This initiative has reached its 100-task limit.')
12
+ const owner=input.owner,team=s.teamSnapshot
13
+ if (!Object.hasOwn(team.roles,owner) || owner===team.manager || team.workflow.reviewers.includes(owner)) fail('Choose a worker role as owner, separate from manager and verification roles.')
14
+ if (typeof input.title!=='string' || !input.title.trim() || input.title.length>200) fail('Task title must contain 1–200 characters.')
15
+ if (!Array.isArray(input.criteria) || !input.criteria.length || input.criteria.length>20 || input.criteria.some(c=>typeof c!=='string' || !c.trim() || c.length>2000)) fail('Supply 1–20 concrete acceptance criteria.')
16
+ const dependencies=input.dependencies || []
17
+ if (!Array.isArray(dependencies) || dependencies.length>100) fail('Invalid dependencies.')
18
+ for (const id of dependencies) taskFor(s,id)
19
+ const task={id:randomUUID(),title:input.title.trim(),owner,criteria:input.criteria,dependencies:[...new Set(dependencies)],status:'pending',attempt:0,reviews:{},createdAt:Date.now()}
20
+ board.tasks.push(task);return task
21
+ }
22
+ if (input.action==='block') {
23
+ const task=taskFor(s,input.taskId)
24
+ if (task.status==='verified') fail('This task is already verified. Create a follow-up for additional work.')
25
+ if (board.delegations.some(d=>d.taskId===task.id && d.status==='running')) fail('Wait for the running delegation before blocking this task.')
26
+ if (typeof input.reason!=='string' || !input.reason.trim() || input.reason.length>2000) fail('Explain the blocker in 1–2,000 characters.')
27
+ task.status='blocked';task.blocker=input.reason;return task
28
+ }
29
+ fail('Unknown task action.')
30
+ }
31
+ function start(s,toolId,input) {
32
+ const board=ledger(s),task=taskFor(s,idFrom(input.prompt)),role=input.subagent_type,team=s.teamSnapshot
33
+ if (board.delegations.some(d=>d.id===toolId)) return board.delegations.find(d=>d.id===toolId)
34
+ if (input.run_in_background || input.resume) fail('Use a fresh foreground delegation so Fleet can track its evidence.')
35
+ if (role!==task.owner && !team.workflow.reviewers.includes(role)) fail('Delegate to the task owner or one of its verification roles.')
36
+ if (board.delegations.some(d=>d.status==='running')) fail('Wait for the current delegation: this initiative shares one worktree.')
37
+ if (task.dependencies.some(id=>taskFor(s,id).status!=='verified')) fail('Complete this task’s dependencies first.')
38
+ if (role===task.owner) {
39
+ if (task.attempt>=(s.limits?.maxAttempts ?? team.workflow.maxAttempts)) fail('Repair attempt limit reached. Report the blocker to the operator.')
40
+ // A worker can touch shared code; prior verification must be refreshed. Reject
41
+ // changes once dependents began, rather than silently invalidating their inputs.
42
+ if (board.tasks.some(t=>t.dependencies.includes(task.id) && t.attempt>0)) fail('A dependent task has already started. Create a follow-up task for new work.')
43
+ task.attempt++;task.reviews={};task.status='working';task.blocker=null
44
+ } else {
45
+ if (task.status!=='review') fail('The owner must complete implementation before independent verification.')
46
+ if (task.reviews[role]?.verdict==='PASS') fail('This role already verified the current attempt.')
47
+ }
48
+ const d={id:toolId,taskId:task.id,role,attempt:task.attempt,status:'running',startedAt:Date.now(),prompt:String(input.prompt).slice(0,24000),report:null}
49
+ board.delegations.push(d)
50
+ return d
51
+ }
52
+ function unwrapReport(report) {
53
+ const text=String(report || '')
54
+ if (!text.startsWith('[Subagent hand-back]')) return text
55
+ const marker='The report follows:\n'
56
+ const offset=text.indexOf(marker)
57
+ if (offset<0) return text
58
+ const lines=[]
59
+ for (const line of text.slice(offset+marker.length).split('\n')) {
60
+ if (!line.startsWith(' ')) break
61
+ lines.push(line.slice(2))
62
+ }
63
+ return lines.length ? lines.join('\n') : text
64
+ }
65
+
66
+ function finish(s,toolId,report,error=false) {
67
+ const d=ledger(s).delegations.find(d=>d.id===toolId)
68
+ if (!d || d.status!=='running') return
69
+ d.report=String(error ? report : d.output || unwrapReport(report)).slice(0,24000);d.status=error ? 'failed':'completed';d.finishedAt=Date.now()
70
+ const task=taskFor(s,d.taskId)
71
+ if (error) {task.status='blocked';task.blocker='Delegation failed. Read the report before retrying.';return}
72
+ if (d.role===task.owner) {task.status='review';return}
73
+ const verdict=d.report.trim().replace(/^\*\*/, '').match(/^(PASS|FAIL)\b/)?.[1]
74
+ const evidence=d.report.replace(/^\s*\*{0,2}(PASS|FAIL)\*{0,2}[\s:—-]*/, '').trim()
75
+ task.reviews[d.role]={verdict:verdict==='PASS' && evidence.length>=20 ? 'PASS':'FAIL',delegationId:d.id,attempt:d.attempt}
76
+ if (task.reviews[d.role].verdict==='FAIL') {task.status='changes_requested';return}
77
+ if (s.teamSnapshot.workflow.reviewers.every(r=>task.reviews[r]?.verdict==='PASS')) task.status='verified'
78
+ }
79
+ function interrupt(s) {
80
+ if (!s.taskBoard) return
81
+ for (const d of s.taskBoard.delegations) if (d.status==='running') {
82
+ d.status='interrupted';d.finishedAt=Date.now()
83
+ const task=taskFor(s,d.taskId);task.status='blocked';task.blocker='Execution interrupted. Ask the manager to resume.'
84
+ }
85
+ }
86
+ function progress(s) {
87
+ if (!s.taskBoard) return null
88
+ const tasks=s.taskBoard.tasks
89
+ return {total:tasks.length,verified:tasks.filter(t=>t.status==='verified').length,blocked:tasks.filter(t=>['blocked','changes_requested'].includes(t.status)).length}
90
+ }
91
+ async function sdkServer(s,changed) {
92
+ const {createSdkMcpServer,tool}=await import('@anthropic-ai/claude-agent-sdk')
93
+ const {z}=require('zod/v4')
94
+ return createSdkMcpServer({name:'fleet',version:'1.0.0',tools:[tool('tasks','Read the durable task board; create scoped tasks with criteria and dependencies; record blockers. Fleet records verification from actual agent reports.',{
95
+ action:z.enum(['list','create','block']),title:z.string().optional(),owner:z.string().optional(),criteria:z.array(z.string()).optional(),dependencies:z.array(z.string()).optional(),taskId:z.string().optional(),reason:z.string().optional(),
96
+ },async input=>{
97
+ const before=structuredClone(s.taskBoard)
98
+ try {const result=act(s,input);changed();return {content:[{type:'text',text:JSON.stringify(result)}]}}
99
+ catch(error){s.taskBoard=before;return {isError:true,content:[{type:'text',text:error.message}]}}
100
+ })]})
101
+ }
102
+ module.exports={ledger,act,start,finish,interrupt,progress,sdkServer,idFrom}
package/team-store.js ADDED
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const { randomUUID } = require('node:crypto')
5
+ const { TEAMS } = require('./teams')
6
+
7
+ const TOOL_OPTIONS = ['Read','Glob','Grep','Bash','Write','Edit','MultiEdit','NotebookEdit','WebSearch','WebFetch']
8
+ const bad = message => { throw Object.assign(new Error(message), {status:400}) }
9
+ function string(value, label, max) {
10
+ if (typeof value !== 'string' || !value.trim() || value.length > max) bad(`${label} must contain 1–${max} characters.`)
11
+ return value.trim()
12
+ }
13
+ function validateTeam(input) {
14
+ if (!input || typeof input !== 'object' || Array.isArray(input)) bad('A team is required.')
15
+ const id=string(input.id,'Team ID',60)
16
+ if (!/^[a-z][a-z0-9-]*$/.test(id) || ['constructor','prototype','__proto__'].includes(id)) bad('Use a lowercase team ID with letters, numbers and hyphens.')
17
+ const name=string(input.name,'Team name',80), description=string(input.description,'Team description',500)
18
+ if (!input.roles || typeof input.roles!=='object' || Array.isArray(input.roles)) bad('Roles must be an object.')
19
+ const entries=Object.entries(input.roles)
20
+ if (entries.length<3 || entries.length>8) bad('A team needs 3–8 roles, including its manager.')
21
+ const manager=string(input.manager,'Manager role',40), roles={}
22
+ for (const [key,role] of entries) {
23
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(key) || ['constructor','prototype','__proto__'].includes(key)) bad('Role IDs must use lowercase letters, numbers and hyphens.')
24
+ if (!role || typeof role!=='object') bad(`Invalid role: ${key}`)
25
+ const model=string(role.model || 'inherit',`${key} model`,80)
26
+ if (!/^[\w.:[\]-]+$/.test(model)) bad(`Invalid model for ${key}.`)
27
+ if (!Array.isArray(role.tools) || role.tools.some(t=>!TOOL_OPTIONS.includes(t))) bad(`Choose supported tools for ${key}.`)
28
+ roles[key]={description:string(role.description,`${key} purpose`,500),prompt:string(role.prompt,`${key} instructions`,12000),model,tools:[...new Set(role.tools)]}
29
+ }
30
+ if (!Object.hasOwn(roles,manager)) bad('Choose an existing role as manager.')
31
+ if (!Array.isArray(input.workflow?.reviewers) || !input.workflow.reviewers.length) bad('Choose at least one independent verification role.')
32
+ const reviewers=[...new Set(input.workflow.reviewers)]
33
+ if (reviewers.some(r=>typeof r!=='string' || !Object.hasOwn(roles,r) || r===manager)) bad('Verification roles must exist and cannot be the manager.')
34
+ if (!entries.some(([r])=>r!==manager && !reviewers.includes(r))) bad('Include a worker role separate from the verification roles.')
35
+ const maxAttempts=input.workflow.maxAttempts ?? 3, budgetUsd=input.workflow.budgetUsd ?? 10
36
+ if (!Number.isInteger(maxAttempts) || maxAttempts<1 || maxAttempts>10) bad('Attempts must be between 1 and 10.')
37
+ if (typeof budgetUsd!=='number' || !Number.isFinite(budgetUsd) || budgetUsd<0.1 || budgetUsd>1000) bad('Budget must be between $0.10 and $1,000.')
38
+ // Manager is a coordinator. Verification can run commands but cannot use edit tools.
39
+ roles[manager].tools=roles[manager].tools.filter(t=>['Read','Glob','Grep','WebSearch','WebFetch'].includes(t))
40
+ for (const key of reviewers) roles[key].tools=roles[key].tools.filter(t=>!['Write','Edit','MultiEdit','NotebookEdit'].includes(t))
41
+ return {id,name,description,manager,roles,workflow:{reviewers,maxAttempts,budgetUsd}}
42
+ }
43
+ function summary(team) {
44
+ return {id:team.id,name:team.name,description:team.description,manager:team.manager,custom:!!team.custom,roles:Object.entries(team.roles).map(([name,r])=>({name,description:r.description,model:r.model || null}))}
45
+ }
46
+ class TeamStore {
47
+ constructor(directory) {
48
+ this.file=path.join(directory,'teams.json')
49
+ this.custom=new Map()
50
+ try {
51
+ const saved=JSON.parse(fs.readFileSync(this.file,'utf8'))
52
+ if (saved.version!==1 || !Array.isArray(saved.teams) || saved.teams.length>50) throw new Error('Unsupported team store')
53
+ for (const raw of saved.teams) { const team=validateTeam(raw); if (Object.hasOwn(TEAMS,team.id) || this.custom.has(team.id)) throw new Error('Duplicate team ID'); this.custom.set(team.id,team) }
54
+ } catch (error) { if (error.code!=='ENOENT') throw new Error(`Cannot read Fleet teams: ${error.message}`) }
55
+ }
56
+ get(id) { const team=this.custom.get(id) || (Object.hasOwn(TEAMS,id) ? TEAMS[id] : null); return team ? structuredClone(team) : null }
57
+ list() { return [...Object.values(TEAMS),...[...this.custom.values()].map(t=>({...t,custom:true}))].map(summary) }
58
+ save(input) {
59
+ const team=validateTeam(input)
60
+ if (Object.hasOwn(TEAMS,team.id)) bad('Built-in teams are read-only. Save a copy with a new ID.')
61
+ if (!this.custom.has(team.id) && this.custom.size>=50) bad('Keep at most 50 custom teams.')
62
+ const next=new Map(this.custom);next.set(team.id,team)
63
+ const tmp=`${this.file}.${randomUUID()}.tmp`
64
+ try {fs.writeFileSync(tmp,JSON.stringify({version:1,teams:[...next.values()]}),{mode:0o600});fs.renameSync(tmp,this.file)}
65
+ finally {try{fs.unlinkSync(tmp)}catch{}}
66
+ this.custom=next
67
+ return structuredClone(team)
68
+ }
69
+ }
70
+ module.exports={TeamStore,validateTeam,TOOL_OPTIONS}
package/teams.js CHANGED
@@ -171,9 +171,40 @@ const TEAMS = {
171
171
  },
172
172
  }
173
173
 
174
+ const READ_TOOLS = ['Read','Glob','Grep','WebSearch','WebFetch']
175
+ TEAMS.delivery = {
176
+ id:'delivery', name:'Software delivery',
177
+ description:'Turn a brief into scoped work, implementation, independent code review and QA.',
178
+ manager:'manager',
179
+ workflow:{reviewers:['reviewer','qa'],maxAttempts:3,budgetUsd:10},
180
+ roles:{
181
+ manager:{description:'Owns the goal, plans and delegates work, and talks to you.',prompt:'Read the project instructions and understand the goal. Use the product role when scope needs clarification. Create scoped tasks with testable acceptance criteria. Delegate implementation and independent verification, repair failures, and report evidence. Ask the operator only when a decision changes the scope or work cannot proceed.',model:'opus',tools:READ_TOOLS},
182
+ product:{description:'Defines scope and measurable acceptance criteria.',prompt:'Read the relevant project context. Produce a concise specification with scope, exclusions, acceptance criteria and edge cases. Return questions to the manager when a material product decision is missing. Do not edit files.',model:'opus',tools:READ_TOOLS},
183
+ developer:{description:'Implements scoped changes and fixes reported failures.',prompt:DEVELOPER,model:'sonnet',tools:[...READ_TOOLS,'Bash','Write','Edit','NotebookEdit']},
184
+ reviewer:{description:'Independently reviews correctness and maintainability.',prompt:'Review the actual changes against the task and repository conventions. Check correctness, regression risks, boundaries and error handling. Do not fix the implementation. Return PASS or FAIL followed by concrete evidence and actionable findings. You may run commands for verification; do not modify source files.',model:'opus',tools:[...READ_TOOLS,'Bash']},
185
+ qa:{description:'Verifies acceptance criteria with reproducible evidence.',prompt:QA,model:'sonnet',tools:[...READ_TOOLS,'Bash']},
186
+ },
187
+ }
188
+ const TASK_RULES = `
189
+
190
+ Fleet owns the durable task board. Use mcp__fleet__tasks to read it and create tasks before delegating.
191
+ Each task needs an owner, acceptance criteria and optional dependencies. All configured verification
192
+ roles must verify each deliverable. Include a line "Fleet task: <task ID>" in EVERY Agent prompt.
193
+ Invoke only the owner or a configured verification role. Work sequentially in this shared worktree.
194
+ After the owner returns, delegate each verification role with the original criteria and actual work.
195
+ Verification reports must start with PASS or FAIL and include evidence. A FAIL returns the task to
196
+ its owner; repeat implementation and ALL verification roles. Fleet enforces the attempt limit.
197
+ Do not use background agents. A completed conversation turn is not task completion. Keep going
198
+ until every task is verified, a blocker needs operator input, or the budget/attempt limit is reached.
199
+ The task board is restored on resume: read it before acting; never recreate completed work.
200
+ You are the only role that speaks to the operator. Do not delegate to yourself. Do not edit code.
201
+ Report blockers through the task tool and ask the operator yourself. Finish at a verified local
202
+ branch; do not claim a PR was opened without a real PR URL. A budget limit requires operator action.
203
+ `
204
+
174
205
  function getTeam(id) {
175
206
  if (!id) return null
176
- return Object.prototype.hasOwnProperty.call(TEAMS, id) ? TEAMS[id] : null
207
+ return Object.prototype.hasOwnProperty.call(TEAMS, id) ? structuredClone(TEAMS[id]) : null
177
208
  }
178
209
 
179
210
  // What the UI needs to offer a choice. Prompts are large and of no use to the browser.
@@ -193,7 +224,17 @@ function listTeams() {
193
224
  function compile(team) {
194
225
  if (!team) return null
195
226
  if (!team.roles[team.manager]) throw new Error(`Team ${team.id} names a manager role that does not exist.`)
196
- return { agent: team.manager, agents: { ...team.roles } }
227
+ if (!team.workflow) return { agent: team.manager, agents: { ...team.roles } }
228
+ const agents={}
229
+ for (const [name,role] of Object.entries(team.roles)) {
230
+ const manager=name===team.manager
231
+ agents[name]={...role,
232
+ tools:[...(role.tools || []),...(manager ? ['Agent','AskUserQuestion','mcp__fleet__tasks'] : [])],
233
+ disallowedTools:manager ? [...NO_EDITS,'Bash'] : [...NO_DELEGATION,'AskUserQuestion','mcp__fleet__tasks',...(team.workflow.reviewers.includes(name) ? NO_EDITS : [])],
234
+ prompt:role.prompt+(manager ? TASK_RULES : SUBAGENT_RULE),
235
+ }
236
+ }
237
+ return {agent:team.manager,agents}
197
238
  }
198
239
 
199
240
  module.exports = { TEAMS, getTeam, listTeams, compile, roleNames: team => Object.keys(team.roles) }