@sergeychuvayev/claude-fleet 0.2.0 → 0.3.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
@@ -192,6 +192,34 @@ conversation on every question.
192
192
 
193
193
  </details>
194
194
 
195
+ ### An initiative is a team behind one conversation
196
+
197
+ Some work is too big for one agent and too small to project-manage by hand. Launch it with a
198
+ **team** instead of alone and you get an *initiative*: a manager that plans and delegates, a
199
+ developer that implements, and a QA that independently verifies, all behind a single
200
+ conversation.
201
+
202
+ You talk to the manager and only to the manager. That is not a rule in a prompt: the manager
203
+ holds the main thread, and the rest of the team is reachable only through the Agent tool, so
204
+ they have no channel to you at all. Their work arrives as delegation blocks in the
205
+ conversation, each showing the role, the mandate it was given, and the report it sent back.
206
+
207
+ The manager has no edit tools. Its only way to ship is to delegate, which is the entire point
208
+ of having a team rather than an agent with a long prompt. QA has no edit tools either: it
209
+ reproduces the problem, runs the project's own gates, and returns PASS or FAIL with evidence,
210
+ so a developer's account of its own work is never the last word.
211
+
212
+ An initiative works in a git worktree of its own, branched from wherever the project is
213
+ checked out, so a team editing files cannot collide with your own editing or with another
214
+ initiative. It finishes by opening a pull request. Pushing stops for your approval like any
215
+ other publishing command, so nothing leaves the machine without you.
216
+
217
+ Closing an initiative forgets Fleet's record of the conversation and leaves the worktree and
218
+ its branch alone. Deleting code is never the same click as tidying a list.
219
+
220
+ Teams cost roughly an order of magnitude more tokens than a single agent, and only earn it
221
+ when the work genuinely splits. For a one-line fix, launch an agent.
222
+
195
223
  ### Approvals that stay out of the way
196
224
 
197
225
  Every agent runs in one of three modes, chosen at launch and changeable from the
@@ -309,6 +337,8 @@ for the app itself.
309
337
  | [`fleet.js`](fleet.js) | Cached, read-only collection of external Claude sessions |
310
338
  | [`archive.js`](archive.js) | Which sessions are put away, the age rule, and its store |
311
339
  | [`managed.js`](managed.js) | SDK runs, approvals, tool blocks, persistence, cancellation |
340
+ | [`teams.js`](teams.js) | The roles an initiative runs, and how they compile into SDK options |
341
+ | [`worktree.js`](worktree.js) | The git worktree an initiative works in, and its branch |
312
342
  | [`search.js`](search.js) | Transcript index, BM25 ranking, and the answering turn |
313
343
  | [`permissions.js`](permissions.js) | The three approval modes and the command list that still stops |
314
344
  | [`theme.js`](theme.js) | Reads the local Warp palette and renders it as CSS variables |
package/fleet.js CHANGED
@@ -59,7 +59,7 @@ function toolTarget(name, input) {
59
59
  if (!input || typeof input !== 'object') return null
60
60
  const first = value => (typeof value === 'string' ? value.split('\n')[0].trim().slice(0, 120) : null)
61
61
  if (name === 'Bash' || name === 'BashOutput') return first(input.description) || first(input.command)
62
- if (name === 'Task') return first(input.description)
62
+ if (name === 'Task' || name === 'Agent') return first(input.description)
63
63
  if (name === 'WebSearch') return first(input.query)
64
64
  if (name === 'WebFetch') return first(input.url)
65
65
  if (name === 'Grep' || name === 'Glob') return first(input.pattern)
package/managed.js CHANGED
@@ -7,6 +7,8 @@ const { EventEmitter } = require('node:events')
7
7
  const { gitBranch, turnSummary, toolTarget } = require('./fleet')
8
8
  const { askReason, normaliseMode, MODES, DEFAULT_MODE } = require('./permissions')
9
9
  const { stateDir } = require('./paths')
10
+ const { getTeam, compile } = require('./teams')
11
+ const worktrees = require('./worktree')
10
12
 
11
13
  const ACTIVE = new Set(['starting', 'running', 'approval', 'stopping'])
12
14
  // Used until a live run reports the runtime's own list, which replaces it.
@@ -146,6 +148,8 @@ class ManagedSessions extends EventEmitter {
146
148
  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,
147
149
  turn:turnSummary(managedEvents(s.messages), { working: ACTIVE.has(s.status) && s.status !== 'approval' }),
148
150
  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,
152
+ worktreeBranch:s.worktree?.branch || null,
149
153
  }))
150
154
  }
151
155
  create(body) {
@@ -170,10 +174,17 @@ class ManagedSessions extends EventEmitter {
170
174
  resume = source.sessionId
171
175
  }
172
176
  this.checkCapacity()
173
- const s = {id:randomUUID(),sessionId:resume,name,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:[]}
177
+ // A team turns this conversation into an initiative: the manager takes the main thread
178
+ // 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
180
+ if (body.teamId && !team) fail('That team does not exist.')
181
+ if (team && resume) fail('A resumed conversation cannot be given a team.',409)
182
+ const id = randomUUID()
183
+ 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}
174
185
  this.sessions.set(s.id,s)
175
186
  try { this.send(s.id,{message:prompt,images:body.images,requestId:rid}) }
176
- catch (error) { this.sessions.delete(s.id); throw error }
187
+ catch (error) { this.sessions.delete(s.id); if (worktree) worktrees.remove(worktree); throw error }
177
188
  return s
178
189
  }
179
190
  checkCapacity() {
@@ -264,6 +275,11 @@ class ManagedSessions extends EventEmitter {
264
275
  ...(s.sessionId ? {resume:s.sessionId} : {}),
265
276
  }
266
277
  if (s.selectedModel) options.model = s.selectedModel
278
+ // `agent` puts the manager on the main thread, so the operator's messages reach it and
279
+ // nobody else; `agents` is where the Agent tool resolves the rest of the team from.
280
+ // 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))
267
283
  if (process.env.CLAUDE_FLEET_EXECUTABLE) options.pathToClaudeCodeExecutable = process.env.CLAUDE_FLEET_EXECUTABLE
268
284
  run.query = await this.queryFactory({prompt,options})
269
285
  if (run.stopping) { run.query.close(); return }
@@ -371,7 +387,7 @@ class ManagedSessions extends EventEmitter {
371
387
  if (!reason) return Promise.resolve({behavior:'allow',updatedInput:input})
372
388
  return new Promise(resolve => {
373
389
  const id=randomUUID()
374
- const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null}
390
+ const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null,role:roleAsking(s,context)}
375
391
  let settled=false
376
392
  const finish=result=>{
377
393
  if(settled)return
@@ -424,7 +440,11 @@ class ManagedSessions extends EventEmitter {
424
440
  this.sessions.delete(id)
425
441
  try { this.save() } catch (error) { this.emit('storage-error',error) }
426
442
  this.emit('change',id)
427
- return {id, sessionId:s.sessionId}
443
+ // An initiative's worktree is left on disk on purpose. Forgetting a conversation is a
444
+ // change to Fleet's records; deleting a branch with uncommitted work on it is a change
445
+ // to the operator's code, and the two should never happen with the same click. The path
446
+ // comes back so the caller can say where the work went.
447
+ return {id, sessionId:s.sessionId, worktree:s.worktree?.path || null, branch:s.worktree?.branch || null}
428
448
  }
429
449
  async close() {
430
450
  this.closed=true
@@ -435,6 +455,21 @@ class ManagedSessions extends EventEmitter {
435
455
  }
436
456
  }
437
457
  // An empty choice means "leave it to the project", which is the SDK's own default.
458
+ // Who is asking for this approval. Inside an initiative, "the session wants to run rm" is
459
+ // not good enough: the operator needs to know which role wants it. The SDK marks a
460
+ // subagent's request with an agentID but not with the role name, so the name is recovered
461
+ // from the delegation that is in flight. With two delegations running at once that is
462
+ // ambiguous, and an honest null beats a confident guess at the wrong role.
463
+ function roleAsking(s,context) {
464
+ const team = getTeam(s.teamId)
465
+ if (!team) return null
466
+ // No agentID means the request came from the main thread, which is the manager by
467
+ // definition. The role name comes from the team rather than a literal, because a future
468
+ // team is free to call that role something else.
469
+ if (!context.agentID) return team.manager
470
+ const running = s.messages.filter(m => m.role==='tool' && (m.tool==='Agent' || m.tool==='Task') && m.status==='running')
471
+ return running.length === 1 ? (running[0].input?.subagent_type || null) : null
472
+ }
438
473
  function modelChoice(value) {
439
474
  if (value === undefined || value === null || value === '') return ''
440
475
  if (typeof value !== 'string' || !/^[\w.:-]{1,80}$/.test(value)) fail('That model name is not valid.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sergeychuvayev/claude-fleet",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A local control room for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
@@ -34,8 +34,10 @@
34
34
  "permissions.js",
35
35
  "search.js",
36
36
  "server.js",
37
+ "teams.js",
37
38
  "theme.js",
38
- "update.js"
39
+ "update.js",
40
+ "worktree.js"
39
41
  ],
40
42
  "scripts": {
41
43
  "start": "node bin/claude-fleet.js start",
package/public/app.js CHANGED
@@ -99,7 +99,7 @@ function render() {
99
99
  renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
100
100
  update('session-list', shown.length ? shown.map(s => {
101
101
  const p = percent(s)
102
- 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><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>` : ''}</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>`
102
+ 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>` : ''}</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>`
103
103
  }).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>`)
104
104
  const current = shown.find(s => key(s) === selected)
105
105
  if (current) markSeen(key(current), current.lastActivity)
package/public/blocks.js CHANGED
@@ -7,7 +7,7 @@
7
7
  const LIBS = () => window.FleetLibs || null
8
8
  const ICONS = {
9
9
  Bash: '⚡', BashOutput: '⚡', Read: '▤', Write: '✎', Edit: '✎', NotebookEdit: '✎',
10
- Grep: '⌕', Glob: '⌕', WebSearch: '⌕', WebFetch: '↓', Task: '✳', Skill: '◆',
10
+ Grep: '⌕', Glob: '⌕', WebSearch: '⌕', WebFetch: '↓', Task: '✳', Agent: '✳', Skill: '◆',
11
11
  TodoWrite: '☑', AskUserQuestion: '?', ExitPlanMode: '▸',
12
12
  }
13
13
  const EXTENSIONS = {
@@ -18,9 +18,13 @@ const EXTENSIONS = {
18
18
  }
19
19
  // Mirrors toolTarget() in managed.js: the input key already shown in the block header.
20
20
  const TARGET_KEYS = {
21
- Bash: 'command', BashOutput: 'bash_id', Task: 'description', WebSearch: 'query',
21
+ Bash: 'command', BashOutput: 'bash_id', Task: 'description', Agent: 'description', WebSearch: 'query',
22
22
  WebFetch: 'url', Grep: 'pattern', Glob: 'pattern', Skill: 'skill',
23
23
  }
24
+ // The runtime names this tool `Agent`; `Task` is the older name for the same call and still
25
+ // appears in transcripts recorded before the rename. Both carry {subagent_type, description,
26
+ // prompt}, so both render as a delegation.
27
+ const isDelegation = name => name === 'Agent' || name === 'Task'
24
28
  const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
25
29
  const languageFor = file => EXTENSIONS[String(file || '').split('.').pop().toLowerCase()] || null
26
30
  const duration = ms => ms == null ? null : ms < 1000 ? `${ms}ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 60000)}m ${Math.round(ms % 60000 / 1000)}s`
@@ -87,7 +91,8 @@ function toolBody(message) {
87
91
  const mark = t => t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '▸' : '☐'
88
92
  return `<ul class="block-todos">${todos.map(t => `<li class="todo-${escapeHtml(t.status)}"><span aria-hidden="true">${mark(t)}</span>${escapeHtml(t.content || t.activeForm || '')}</li>`).join('')}</ul>`
89
93
  }
90
- if (name === 'Task' || name === 'Skill') return codeHtml(input.prompt || input.args || input.description || '', 'plaintext')
94
+ if (isDelegation(name)) return `<details class="delegation-mandate" data-delegation="mandate"><summary>Mandate</summary>${proseHtml(input.prompt || input.description || 'No mandate recorded.')}</details>`
95
+ if (name === 'Skill') return codeHtml(input.prompt || input.args || input.description || '', 'plaintext')
91
96
  if (name === 'AskUserQuestion') return ''
92
97
  // The header already shows the main argument, so repeating it as JSON is noise.
93
98
  const shown = TARGET_KEYS[name] || 'file_path'
@@ -95,6 +100,12 @@ function toolBody(message) {
95
100
  return Object.keys(rest).length ? codeHtml(JSON.stringify(rest, null, 2), 'json') : ''
96
101
  }
97
102
  function resultHtml(message) {
103
+ if (isDelegation(message.tool)) {
104
+ const report = String(message.result || '')
105
+ if (!report) return `<p class="block-note">${message.status === 'running' ? 'Awaiting report…' : message.status === 'error' ? 'Delegation failed without a report.' : 'No report returned.'}</p>`
106
+ const long = report.length > 1200 || report.split('\n').length > 16
107
+ return `<details class="delegation-report" data-delegation="report"${long ? '' : ' open'}><summary>Returned report${long ? ' · long' : ''}</summary>${proseHtml(report)}${message.truncated ? '<p class="block-note">Report truncated by Fleet.</p>' : ''}</details>`
108
+ }
98
109
  if (!message.result) return ''
99
110
  const language = message.tool === 'Read' ? languageFor(message.input?.file_path) : message.status === 'error' ? 'plaintext' : null
100
111
  const note = message.truncated ? '<p class="block-note">Output truncated by Fleet.</p>' : ''
@@ -107,10 +118,10 @@ function blockHtml(message, { streaming = false } = {}) {
107
118
  const icon = ICONS[message.tool] || '▸'
108
119
  const meta = [duration(message.ms), clock(message.at)].filter(Boolean).join(' · ')
109
120
  const state = message.status === 'error' ? 'is-failed' : message.status === 'running' ? 'is-running' : message.status === 'interrupted' ? 'is-interrupted' : 'is-done'
110
- const stateLabel = state === 'is-done' ? '' : state.slice(3)
121
+ const stateLabel = state === 'is-done' ? isDelegation(message.tool) ? 'done' : '' : state.slice(3)
111
122
  const target = message.target ? `<span class="block-target" title="${escapeHtml(message.target)}">${escapeHtml(message.target)}</span>` : ''
112
123
  const auto = message.approval === 'auto' ? '<span class="block-auto" title="Fleet approved this automatically">auto</span>' : ''
113
- return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${escapeHtml(message.tool)}</span>${target}<span class="block-meta">${escapeHtml(meta)}</span>${auto}<span class="block-state ${state}">${stateLabel}</span>${actionsHtml}</div><div class="block-body">${toolBody(message)}${resultHtml(message)}</div>`
124
+ return `<div class="block-head"><span class="block-icon" aria-hidden="true">${icon}</span><span class="block-tool">${isDelegation(message.tool) ? `Delegation · ${escapeHtml(message.input?.subagent_type || 'subagent')}` : escapeHtml(message.tool)}</span>${target}<span class="block-meta">${escapeHtml(meta)}</span>${auto}<span class="block-state ${state}">${stateLabel}</span>${actionsHtml}</div><div class="block-body">${toolBody(message)}${resultHtml(message)}</div>`
114
125
  }
115
126
  const who = message.role === 'user' ? 'YOU' : 'CLAUDE'
116
127
  const icon = message.role === 'user' ? '›' : '✳'
@@ -152,14 +163,16 @@ function renderBlocks(container, messages, { streamingId = null, onCopy = () =>
152
163
  element.className = 'block'
153
164
  element.dataset.block = message.id
154
165
  // Long tool output starts collapsed, the way a long command block does in a terminal.
155
- if (message.role === 'tool' && (message.result || '').length > 1200) element.classList.add('collapsed')
166
+ if (message.role === 'tool' && !isDelegation(message.tool) && (message.result || '').length > 1200) element.classList.add('collapsed')
156
167
  }
157
168
  if (element.dataset.sig !== sig) {
158
169
  element.dataset.sig = sig
159
170
  element.dataset.role = message.role
160
171
  element.dataset.tool = message.tool || ''
161
172
  element.dataset.status = message.status || ''
173
+ const disclosures = new Map([...element.querySelectorAll('[data-delegation]')].map(el => [el.dataset.delegation, el.open]))
162
174
  element.innerHTML = blockHtml(message, { streaming })
175
+ for (const el of element.querySelectorAll('[data-delegation]')) if (disclosures.has(el.dataset.delegation)) el.open = disclosures.get(el.dataset.delegation)
163
176
  element.querySelector('[data-collapse]')?.addEventListener('click', event => {
164
177
  const collapsed = element.classList.toggle('collapsed')
165
178
  event.currentTarget.setAttribute('aria-expanded', String(!collapsed))
package/public/control.js CHANGED
@@ -20,10 +20,37 @@ async function initializeControls() {
20
20
  controlToken=data.token
21
21
  if(!$('launch-cwd').value) $('launch-cwd').value=data.defaultCwd
22
22
  }
23
+ let launchTeams=null, launchTeamsLoading=false
24
+ function updateLaunchTeam() {
25
+ const team=resumeSource ? null : launchTeams?.find(t=>t.id===$('launch-team')?.value)
26
+ $('launch-title').textContent=resumeSource ? 'Continue this conversation in Fleet.' : team ? 'Give your team a brief.' : 'Give your next task a home.'
27
+ document.querySelector('label[for="launch-prompt"]').textContent=team ? 'Brief for the manager' : 'What are we working on?'
28
+ 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.'
29
+ $('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.'
30
+ if(!$('launch-submit').disabled) $('launch-submit').textContent=team ? 'Launch initiative ↗' : 'Launch agent ↗'
31
+ $('launch-team').disabled=!!resumeSource || launchTeamsLoading || $('launch-submit').disabled
32
+ $('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.'
33
+ }
34
+ async function loadLaunchTeams() {
35
+ if(!$('launch-team')) {
36
+ 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>')
37
+ $('launch-team').addEventListener('change',()=>{launchRequestId=null;updateLaunchTeam()})
38
+ }
39
+ if(launchTeams || launchTeamsLoading){updateLaunchTeam();return}
40
+ launchTeamsLoading=true;updateLaunchTeam()
41
+ try {
42
+ const data=await api('/api/teams')
43
+ if(!Array.isArray(data.teams)) throw new Error('Invalid teams')
44
+ launchTeams=data.teams
45
+ for(const team of launchTeams) $('launch-team').add(new Option(team.name,team.id))
46
+ } catch { launchTeams=null }
47
+ finally {launchTeamsLoading=false;updateLaunchTeam()}
48
+ }
23
49
  function openLaunch(source=null) {
24
50
  resumeSource=source
25
51
  $('launch-title').textContent=source ? 'Continue this conversation in Fleet.' : 'Give your next task a home.'
26
52
  if(source){$('launch-cwd').value=source.cwd || '';$('launch-form').elements.name.value=source.title || source.name || ''}
53
+ loadLaunchTeams()
27
54
  $('launch-cwd').readOnly=!!source
28
55
  openModal('launch-backdrop', '[name=prompt]')
29
56
  }
@@ -41,17 +68,17 @@ $('launch-form').addEventListener('submit',async event=>{
41
68
  const button=$('launch-submit'); if(button.disabled)return
42
69
  button.disabled=true;button.textContent='Launching…';$('launch-error').hidden=true
43
70
  const form=event.currentTarget
44
- form.querySelectorAll('input,textarea').forEach(el=>el.disabled=true)
71
+ form.querySelectorAll('input,textarea,select').forEach(el=>el.disabled=true)
45
72
  launchRequestId ||= crypto.randomUUID()
46
73
  try{
47
- const data=await api('/api/managed',{cwd:form.elements.cwd.value,name:form.elements.name.value,prompt:form.elements.prompt.value,approvalMode:form.elements.approvalMode.value,model:form.elements.model.value,requestId:launchRequestId,...(resumeSource ? {resumeSessionId:resumeSource.sessionId}: {})})
74
+ const data=await api('/api/managed',{...(form.elements.teamId?.value && !resumeSource ? {teamId:form.elements.teamId.value}: {}),cwd:form.elements.cwd.value,name:form.elements.name.value,prompt:form.elements.prompt.value,approvalMode:form.elements.approvalMode.value,model:form.elements.model.value,requestId:launchRequestId,...(resumeSource ? {resumeSessionId:resumeSource.sessionId}: {})})
48
75
  selected=data.session.id;filter='all'
49
76
  form.elements.prompt.value='';launchRequestId=null
50
77
  closeModal()
51
- await tick();toast('Agent launched')
78
+ await tick();toast(form.elements.teamId?.value && !resumeSource ? 'Initiative launched' : 'Agent launched')
52
79
  if(matchMedia('(max-width:720px)').matches)$('detail').scrollIntoView({block:'start',behavior:'instant'})
53
80
  }catch(error){$('launch-error').textContent=error.message;$('launch-error').hidden=false}
54
- finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea').forEach(el=>el.disabled=false)}
81
+ finally{button.disabled=false;button.textContent='Launch agent ↗';form.querySelectorAll('input,textarea,select').forEach(el=>el.disabled=false);if($('launch-team'))updateLaunchTeam()}
55
82
  })
56
83
  function selectControl(session) {
57
84
  const next=session?.managedId || null
@@ -155,7 +182,11 @@ function renderControl() {
155
182
  function renderApprovals(approvals) {
156
183
  $('approvals').innerHTML=approvals.map(p=>{
157
184
  const question=p.tool==='AskUserQuestion'
158
- return `<form class="approval" data-approval="${esc(p.id)}"><div class="eyebrow">${question?'CLAUDE HAS A QUESTION':'APPROVAL REQUIRED'}</div><h4>${esc(p.description || p.tool)}</h4>${p.reason && !question ? `<p class="approval-reason">${esc(p.reason)}</p>` : ''}${question ? (p.input.questions || []).map((q,i)=>`<fieldset><legend>${esc(q.question)}</legend>${(q.options || []).map(o=>`<label class="answer-option"><input type="${q.multiSelect?'checkbox':'radio'}" name="q${i}" value="${esc(o.label)}"><span>${esc(o.label)}${o.description?`<small>${esc(o.description)}</small>`:''}</span></label>`).join('')}<label class="other-answer">Your answer<input type="text" name="other${i}" placeholder="Or type your own answer" maxlength="4000"></label></fieldset>`).join('') : `<pre class="tool-input">${esc(JSON.stringify(p.input,null,2))}</pre>`}<div class="approval-actions"><button class="button" type="button" data-deny="${esc(p.id)}">${question?'Skip question':'Deny'}</button><button class="button resume" type="submit">${question?'Send answer':'Allow once'}</button></div><p class="form-error" role="alert" hidden></p></form>`
185
+ // Inside an initiative, which role wants this is the whole question. "Approve rm?" with
186
+ // no name attached is how an operator ends up approving something the developer asked
187
+ // for while believing the manager did.
188
+ const who=p.role ? ` · ${esc(p.role.toUpperCase())}` : ''
189
+ return `<form class="approval" data-approval="${esc(p.id)}"><div class="eyebrow">${question?'CLAUDE HAS A QUESTION':'APPROVAL REQUIRED'}${who}</div><h4>${esc(p.description || p.tool)}</h4>${p.reason && !question ? `<p class="approval-reason">${esc(p.reason)}</p>` : ''}${question ? (p.input.questions || []).map((q,i)=>`<fieldset><legend>${esc(q.question)}</legend>${(q.options || []).map(o=>`<label class="answer-option"><input type="${q.multiSelect?'checkbox':'radio'}" name="q${i}" value="${esc(o.label)}"><span>${esc(o.label)}${o.description?`<small>${esc(o.description)}</small>`:''}</span></label>`).join('')}<label class="other-answer">Your answer<input type="text" name="other${i}" placeholder="Or type your own answer" maxlength="4000"></label></fieldset>`).join('') : `<pre class="tool-input">${esc(JSON.stringify(p.input,null,2))}</pre>`}<div class="approval-actions"><button class="button" type="button" data-deny="${esc(p.id)}">${question?'Skip question':'Deny'}</button><button class="button resume" type="submit">${question?'Send answer':'Allow once'}</button></div><p class="form-error" role="alert" hidden></p></form>`
159
190
  }).join('')
160
191
  $('approvals').querySelectorAll('form').forEach(form=>{
161
192
  const approval=approvals.find(p=>p.id===form.dataset.approval)
package/public/styles.css CHANGED
@@ -452,3 +452,13 @@ body[data-modal]{overflow:hidden}
452
452
  .topbar .update-pill:disabled{opacity:.72;cursor:default}
453
453
  @media(prefers-reduced-motion:no-preference){.update-pill:not([hidden]){animation:update-pill-in .3s ease-out}}
454
454
  @keyframes update-pill-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
455
+
456
+ .initiative-tag{display:inline-block;max-width:100%;margin-top:6px;padding:2px 6px;border:1px solid var(--line);border-radius:3px;color:var(--muted);font-size:10px;overflow-wrap:anywhere}
457
+ .block[data-tool="Task"],.block[data-tool="Agent"]{border-left:2px solid var(--accent)}
458
+ .block[data-tool="Task"] .block-tool,.block[data-tool="Agent"] .block-tool{white-space:normal;overflow-wrap:anywhere}
459
+ .delegation-mandate,.delegation-report{margin:0 13px;padding:12px 0}
460
+ .delegation-report{border-top:1px solid var(--line)}
461
+ .delegation-mandate>summary,.delegation-report>summary{cursor:pointer;color:var(--muted);font-size:11px;font-weight:600}
462
+ .delegation-mandate>summary:focus-visible,.delegation-report>summary:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
463
+ .delegation-mandate .block-prose,.delegation-report .block-prose{padding:12px 0 0}
464
+ .delegation-mandate .block-plain,.delegation-report .block-plain{white-space:pre-wrap;overflow-wrap:anywhere}
package/server.js CHANGED
@@ -12,6 +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
16
  const { openDashboard } = require('./open.js')
16
17
  const { version: VERSION } = require('./package.json')
17
18
  const HOST = '127.0.0.1'
@@ -167,6 +168,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
167
168
  return res.end(themeCss(currentTheme()))
168
169
  }
169
170
  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()})
170
172
  if(url.pathname==='/api/sessions') return json(res,200,getSnapshot())
171
173
  if(url.pathname==='/api/events') {
172
174
  if(clients.size>=20) return json(res,429,{error:'Too many dashboard connections.'})
package/teams.js ADDED
@@ -0,0 +1,199 @@
1
+ 'use strict'
2
+ // A team is a named set of roles. One role is the manager: it owns the main thread, so it
3
+ // owns the conversation with the operator. The others are reachable only through the Agent
4
+ // tool, which is what makes "you talk to the manager" structural rather than a house rule.
5
+ //
6
+ // Role prompts are deliberately project-agnostic. Fleet runs against whatever directory it
7
+ // is pointed at, so a role discovers the repo's own gates instead of carrying someone's.
8
+
9
+ // Every non-manager role gets this appended. Subagents do not know they are subagents, and
10
+ // left unlabelled they write to the operator and try to delegate further.
11
+ const SUBAGENT_RULE = `
12
+
13
+ ## You are a subagent
14
+
15
+ You were invoked by the manager of an initiative. You are not talking to a person.
16
+ Your reply goes back to the manager and to nobody else, so never address the operator,
17
+ never ask them a question, and never promise to follow up.
18
+
19
+ You cannot delegate. If the work is larger than your mandate, do the part that is clearly
20
+ yours and say in your report exactly what you left and why. Returning a smaller honest
21
+ result beats returning a larger invented one.`
22
+
23
+ const MANAGER = `You are the manager of an initiative: one goal, a small team, and a single
24
+ conversation with the operator. You are the only member of the team they can hear.
25
+
26
+ ## You do not write code
27
+
28
+ You have no edit tools. That is deliberate, not an oversight to work around. Your only way
29
+ to ship is to delegate, and the moment you start patching things yourself with shell
30
+ redirection or heredocs the team stops meaning anything. If a change is one character, it
31
+ is still the developer's change.
32
+
33
+ ## Order of work
34
+
35
+ 1. **Read before you plan.** Find the project's own instructions (CLAUDE.md, AGENTS.md,
36
+ CONTRIBUTING, README) and any rules scoped to the paths in question. Locate the actual
37
+ files the goal touches. A brief that says "fix the login redirect" maps onto specific
38
+ modules; find them before you split anything.
39
+
40
+ 2. **Find the gates.** Every repo has commands that decide whether work is acceptable:
41
+ a test runner, a type check, a linter. Read package.json scripts, the CI workflow, or
42
+ the contributing guide and write down the exact commands. You will hand these to every
43
+ delegate. Guessing at them wastes a whole round.
44
+
45
+ 3. **Ask if the goal is genuinely ambiguous.** Now, while it costs one message, rather than
46
+ after three delegations have built the wrong thing. Ambiguity that changes the work is
47
+ worth a question; ambiguity you can resolve by reading is not.
48
+
49
+ 4. **Split into tasks that cannot collide.** Each task is one deliverable with a file set
50
+ disjoint from every other task in flight. Two delegates editing the same file is the
51
+ most reliable way to make this whole arrangement fail. If two tasks need the same file,
52
+ they are one task, or they run in sequence.
53
+
54
+ 5. **Delegate with a complete mandate.** A delegate inherits none of your conversation and
55
+ none of your reading. Every delegation must carry four things or it will drift:
56
+ - **Objective**: what to change, in which files
57
+ - **Boundaries**: what it must not touch
58
+ - **Gates**: the exact commands that must pass, copied, not described
59
+ - **Done**: how you will know it worked, in terms someone else could check
60
+
61
+ Delegate to the roles on your team and never to yourself. You appear in your own roster
62
+ for mechanical reasons; invoking yourself buys nothing and costs a full context.
63
+
64
+ 6. **Have QA verify, and believe QA.** Send finished work to qa before you report it as
65
+ done. A developer's account of its own work is a claim, not evidence. If qa fails the
66
+ work, send it back to the developer with what qa found; do not overrule it because the
67
+ diff looks fine to you.
68
+
69
+ 7. **Report once, at the end of the turn.** One message to the operator: what changed, what
70
+ passed, what you deliberately did not do, and what you need from them if anything.
71
+ Not a running commentary.
72
+
73
+ ## Finishing
74
+
75
+ When qa passes and the goal is met, commit on the initiative's branch and open a pull
76
+ request with \`gh\`. Use the project's commit convention if it has one. Pushing will stop
77
+ for the operator's approval, which is intended: they get the last look before anything
78
+ leaves the machine.
79
+
80
+ If you cannot finish, say so plainly and say what is blocking. A stalled initiative
81
+ reported honestly is worth more than a green one that is lying.`
82
+
83
+ const DEVELOPER = `You implement one scoped task inside an initiative, end to end.
84
+
85
+ Work only inside the boundaries your mandate gives you. If the fix genuinely requires
86
+ touching a file outside them, stop and report that, rather than reaching for it: the
87
+ boundary probably exists because somebody else is in that file right now.
88
+
89
+ Before you report anything as done, run the gates in your mandate and read the output. Not
90
+ "they should pass", not "the change is small". Run them. If they fail and you cannot fix
91
+ it, that is a legitimate report; a false green is not.
92
+
93
+ Follow the conventions already in the files you are editing: their naming, their error
94
+ handling, their comment density. New code should be hard to pick out of a diff by style
95
+ alone.
96
+
97
+ ## Your report
98
+
99
+ - what you changed, file by file
100
+ - the gate commands you ran and what they printed
101
+ - anything you deliberately did not do, and why
102
+ - anything you found that the manager did not know about
103
+
104
+ Keep it short enough to read and specific enough to act on.${SUBAGENT_RULE}`
105
+
106
+ const QA = `You verify work inside an initiative. You are the only reason the manager can
107
+ trust anything, so behave like it.
108
+
109
+ You have no edit tools. You cannot fix what you find, and you should not want to: your
110
+ output is a verdict with evidence, not a patch.
111
+
112
+ ## How to verify
113
+
114
+ Start from the original problem, not from the diff. A reviewer who only reads the change
115
+ agrees with the change. Reproduce the behaviour the initiative set out to fix, then check
116
+ whether it is actually fixed.
117
+
118
+ Run the gates yourself. The developer's report is a claim about what happened on their
119
+ turn; re-running costs little and catches the difference between "I ran it" and "it
120
+ passed". Read the output rather than the exit code alone where the two can disagree.
121
+
122
+ Then look for what the mandate did not mention: cases the change breaks, boundaries it
123
+ crossed, tests that assert the implementation instead of the behaviour, error paths that
124
+ are now unreachable.
125
+
126
+ ## Your verdict
127
+
128
+ Open with exactly one of **PASS** or **FAIL**, then the evidence.
129
+
130
+ For a FAIL, state what you did, what you expected, what happened instead, and where. Be
131
+ specific enough that the developer can act without asking you anything.
132
+
133
+ Failing work is an ordinary outcome and the most useful thing you produce. Do not soften a
134
+ FAIL into a pass with reservations, and do not pad a PASS with speculative concerns to look
135
+ thorough. If it works, say it works.${SUBAGENT_RULE}`
136
+
137
+ // Subagents must not spawn their own subagents: nothing supervises the result, and a
138
+ // runaway nest is expensive before it is visible.
139
+ const NO_DELEGATION = ['Agent', 'Task']
140
+ const NO_EDITS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
141
+
142
+ const TEAMS = {
143
+ bugfix: {
144
+ id: 'bugfix',
145
+ name: 'Bug fix',
146
+ description: 'A manager who plans and delegates, a developer who implements, and a QA who independently verifies.',
147
+ manager: 'manager',
148
+ roles: {
149
+ manager: {
150
+ description: 'Plans the work, delegates it, and reports back. Never writes code.',
151
+ prompt: MANAGER,
152
+ model: 'opus',
153
+ effort: 'high',
154
+ disallowedTools: NO_EDITS,
155
+ },
156
+ developer: {
157
+ description: 'Implements one scoped task and runs the gates. Use for any change to the code.',
158
+ prompt: DEVELOPER,
159
+ model: 'opus',
160
+ effort: 'high',
161
+ disallowedTools: NO_DELEGATION,
162
+ },
163
+ qa: {
164
+ description: 'Independently verifies finished work and returns PASS or FAIL with evidence. Use before reporting anything as done.',
165
+ prompt: QA,
166
+ model: 'sonnet',
167
+ effort: 'medium',
168
+ disallowedTools: [...NO_EDITS, ...NO_DELEGATION],
169
+ },
170
+ },
171
+ },
172
+ }
173
+
174
+ function getTeam(id) {
175
+ if (!id) return null
176
+ return Object.prototype.hasOwnProperty.call(TEAMS, id) ? TEAMS[id] : null
177
+ }
178
+
179
+ // What the UI needs to offer a choice. Prompts are large and of no use to the browser.
180
+ function listTeams() {
181
+ return Object.values(TEAMS).map(team => ({
182
+ id: team.id, name: team.name, description: team.description, manager: team.manager,
183
+ roles: Object.entries(team.roles).map(([name, role]) => ({
184
+ name, description: role.description, model: role.model || null,
185
+ })),
186
+ }))
187
+ }
188
+
189
+ // A team becomes two SDK options: `agent` names who holds the main thread, `agents` carries
190
+ // every definition. The manager has to appear in `agents` too, because that is where `agent`
191
+ // resolves the name from; it is not reachable as a subagent of itself by convention rather
192
+ // than by construction, which is why its prompt says so outright.
193
+ function compile(team) {
194
+ if (!team) return null
195
+ 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 } }
197
+ }
198
+
199
+ module.exports = { TEAMS, getTeam, listTeams, compile, roleNames: team => Object.keys(team.roles) }
package/worktree.js ADDED
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+ // An initiative gets its own git worktree, so a team editing files cannot collide with the
3
+ // operator's own checkout or with another initiative in the same repo. Worktrees live under
4
+ // Fleet's state directory rather than inside the project, for the same reason the archive
5
+ // does: Fleet's bookkeeping is Fleet's business and should be removable in one directory.
6
+ const fs = require('node:fs')
7
+ const path = require('node:path')
8
+ const { execFileSync } = require('node:child_process')
9
+ const { stateDir } = require('./paths')
10
+
11
+ // Arguments always travel as an array. A branch name is operator-derived text and must
12
+ // never reach a shell.
13
+ function git(cwd, args) {
14
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
15
+ }
16
+
17
+ function repoRoot(cwd) {
18
+ try { return git(cwd, ['rev-parse', '--show-toplevel']) } catch { return null }
19
+ }
20
+
21
+ // The branch an initiative forks from: the checked-out branch, or the commit itself when
22
+ // HEAD is detached, which still gives the worktree something to stand on.
23
+ //
24
+ // A repository with no commits answers `symbolic-ref` perfectly happily with its unborn
25
+ // branch, so the name alone proves nothing. Confirm HEAD resolves to a commit first, or the
26
+ // operator gets `fatal: invalid reference: main` instead of being told the repo is empty.
27
+ function baseRef(root) {
28
+ try { git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) } catch { return null }
29
+ try {
30
+ const branch = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
31
+ if (branch) return branch
32
+ } catch {}
33
+ try { return git(root, ['rev-parse', 'HEAD']) } catch { return null }
34
+ }
35
+
36
+ function slugify(value, fallback) {
37
+ const slug = String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
38
+ return slug || fallback
39
+ }
40
+
41
+ function branchExists(root, branch) {
42
+ try { git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]); return true } catch { return false }
43
+ }
44
+
45
+ // Create the worktree for one initiative. Throws with a message meant for the operator:
46
+ // every failure here happens at launch, where there is still a human watching.
47
+ function create({ cwd, id, name }) {
48
+ const root = repoRoot(cwd)
49
+ if (!root) {
50
+ const error = new Error('An initiative needs a git repository: its team works on a branch and finishes with a pull request. Point it at a checkout, or launch a single agent instead.')
51
+ error.status = 400
52
+ throw error
53
+ }
54
+ const base = baseRef(root)
55
+ if (!base) {
56
+ const error = new Error('This repository has no commits yet, so there is nothing to branch from.')
57
+ error.status = 400
58
+ throw error
59
+ }
60
+
61
+ const short = String(id).slice(0, 8)
62
+ let branch = `initiative/${slugify(name, short)}`
63
+ if (branchExists(root, branch)) branch = `${branch}-${short}`
64
+
65
+ const dir = path.join(stateDir(), 'worktrees', short)
66
+ fs.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 })
67
+ if (fs.existsSync(dir)) {
68
+ const error = new Error(`A worktree already exists at ${dir}.`)
69
+ error.status = 409
70
+ throw error
71
+ }
72
+
73
+ try { git(root, ['worktree', 'add', '-b', branch, dir, base]) }
74
+ catch (cause) {
75
+ const error = new Error(`Could not create a worktree for this initiative: ${String(cause.stderr || cause.message).trim().slice(0, 500)}`)
76
+ error.status = 400
77
+ throw error
78
+ }
79
+ return { path: dir, branch, base, repo: root }
80
+ }
81
+
82
+ // Best effort: an initiative whose worktree is gone is still readable, and a half-removed
83
+ // worktree is worse than one left behind for `git worktree prune` to notice.
84
+ function remove(worktree) {
85
+ if (!worktree?.path || !worktree?.repo) return false
86
+ try { git(worktree.repo, ['worktree', 'remove', '--force', worktree.path]); return true }
87
+ catch { return false }
88
+ }
89
+
90
+ module.exports = { create, remove, repoRoot, baseRef, slugify }