@sergeychuvayev/claude-fleet 0.4.0 → 0.6.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 +37 -0
- package/managed.js +109 -8
- package/package.json +6 -3
- package/public/app.js +112 -7
- package/public/control.js +8 -3
- package/public/index.html +1 -1
- package/public/styles.css +51 -0
- package/public/teams.js +136 -0
- package/server.js +17 -6
- package/tasks.js +102 -0
- package/team-store.js +70 -0
- package/teams.js +43 -2
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'])
|
|
@@ -36,6 +38,9 @@ const MAX_TOOL_INPUT = 2000
|
|
|
36
38
|
const MAX_TOOL_RESULT = 6000
|
|
37
39
|
// Tool names whose result is the point of the block; others are summarised by their input.
|
|
38
40
|
const QUIET_RESULT = new Set(['TodoWrite', 'Write', 'Edit', 'NotebookEdit'])
|
|
41
|
+
// A delegation can run a sub-agent through an unbounded number of tool calls; capped here
|
|
42
|
+
// so a long-running one cannot grow the session file without limit.
|
|
43
|
+
const MAX_DELEGATION_STEPS = 200
|
|
39
44
|
function fail(message, status = 400) { const error = new Error(message); error.status = status; throw error }
|
|
40
45
|
function text(value, name, max) {
|
|
41
46
|
if (typeof value !== 'string' || !value.trim() || value.length > max) fail(`${name} must contain 1–${max} characters.`)
|
|
@@ -65,12 +70,14 @@ class ManagedSessions extends EventEmitter {
|
|
|
65
70
|
this.lock = path.join(directory, 'server.lock')
|
|
66
71
|
this.acquireLock()
|
|
67
72
|
try {
|
|
73
|
+
this.teams = new TeamStore(directory)
|
|
68
74
|
if (fs.existsSync(this.file)) {
|
|
69
75
|
const data = JSON.parse(fs.readFileSync(this.file, 'utf8'))
|
|
70
76
|
if (data.version !== 1 || !Array.isArray(data.sessions)) throw new Error('Unsupported session store format')
|
|
71
77
|
for (const s of data.sessions) {
|
|
72
78
|
if (!s.id || !Array.isArray(s.messages)) throw new Error('Invalid saved session')
|
|
73
79
|
if (ACTIVE.has(s.status)) { s.status = 'stopped'; s.error = 'Fleet restarted. Send a message to continue this conversation.' }
|
|
80
|
+
tasks.interrupt(s)
|
|
74
81
|
s.approvals = []
|
|
75
82
|
s.currentTool = null
|
|
76
83
|
for (const m of s.messages) if (m.role === 'tool' && m.status === 'running') m.status = 'interrupted'
|
|
@@ -148,7 +155,7 @@ class ManagedSessions extends EventEmitter {
|
|
|
148
155
|
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
156
|
turn:turnSummary(managedEvents(s.messages), { working: ACTIVE.has(s.status) && s.status !== 'approval' }),
|
|
150
157
|
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,
|
|
158
|
+
kind:s.kind || 'agent', teamId:s.teamId || null, teamName:s.teamName || null, taskProgress:tasks.progress(s),
|
|
152
159
|
worktreeBranch:s.worktree?.branch || null, costUsd:s.costUsd || 0,
|
|
153
160
|
}))
|
|
154
161
|
}
|
|
@@ -176,12 +183,12 @@ class ManagedSessions extends EventEmitter {
|
|
|
176
183
|
this.checkCapacity()
|
|
177
184
|
// A team turns this conversation into an initiative: the manager takes the main thread
|
|
178
185
|
// and the work happens on a branch of its own rather than in the operator's checkout.
|
|
179
|
-
const team = body.teamId ?
|
|
186
|
+
const team = body.teamId ? this.teams.get(text(body.teamId,'Team',60)) : null
|
|
180
187
|
if (body.teamId && !team) fail('That team does not exist.')
|
|
181
188
|
if (team && resume) fail('A resumed conversation cannot be given a team.',409)
|
|
182
189
|
const id = randomUUID()
|
|
183
190
|
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}
|
|
191
|
+
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
192
|
this.sessions.set(s.id,s)
|
|
186
193
|
try { this.send(s.id,{message:prompt,images:body.images,requestId:rid}) }
|
|
187
194
|
catch (error) { this.sessions.delete(s.id); if (worktree) worktrees.remove(worktree); throw error }
|
|
@@ -278,8 +285,41 @@ class ManagedSessions extends EventEmitter {
|
|
|
278
285
|
// `agent` puts the manager on the main thread, so the operator's messages reach it and
|
|
279
286
|
// nobody else; `agents` is where the Agent tool resolves the rest of the team from.
|
|
280
287
|
// Both compose with the claude_code preset above, which keeps the built-in tools.
|
|
281
|
-
const team = getTeam(s.teamId)
|
|
282
|
-
if (team)
|
|
288
|
+
const team = s.teamSnapshot || getTeam(s.teamId)
|
|
289
|
+
if (team) {
|
|
290
|
+
Object.assign(options, compile(team))
|
|
291
|
+
if (s.selectedModel) options.agents[team.manager].model=s.selectedModel
|
|
292
|
+
}
|
|
293
|
+
if (team?.workflow) {
|
|
294
|
+
const remaining=(s.limits?.budgetUsd ?? team.workflow.budgetUsd)-(s.costUsd || 0)
|
|
295
|
+
if (remaining<=0) throw new Error('Usage cap reached. Increase the cap explicitly before continuing.')
|
|
296
|
+
options.maxBudgetUsd=remaining
|
|
297
|
+
options.maxTurns=100
|
|
298
|
+
options.mcpServers={fleet:await tasks.sdkServer(s,()=>this.changed(s,true))}
|
|
299
|
+
options.hooks={
|
|
300
|
+
PreToolUse:[{hooks:[async input=>{
|
|
301
|
+
if (!['Agent','Task'].includes(input.tool_name)) return {}
|
|
302
|
+
const before=structuredClone(s.taskBoard)
|
|
303
|
+
let applied=false
|
|
304
|
+
try {
|
|
305
|
+
const delegation=tasks.start(s,input.tool_use_id,input.tool_input)
|
|
306
|
+
applied=true
|
|
307
|
+
const task=s.taskBoard.tasks.find(t=>t.id===delegation.taskId)
|
|
308
|
+
this.changed(s,true)
|
|
309
|
+
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.`}}}
|
|
310
|
+
} catch(error) {if(applied)s.taskBoard=before;return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:error.message}}}
|
|
311
|
+
}]}],
|
|
312
|
+
Stop:[{hooks:[async ()=>{
|
|
313
|
+
const unfinished=s.taskBoard.tasks.filter(t=>!['verified','blocked'].includes(t.status) && t.attempt<(s.limits?.maxAttempts ?? team.workflow.maxAttempts))
|
|
314
|
+
if (unfinished.length && (run.continuations || 0)<2) {
|
|
315
|
+
run.continuations=(run.continuations || 0)+1
|
|
316
|
+
return {decision:'block',reason:'Fleet tasks remain unfinished. Read the board and continue implementation/verification, or record a concrete blocker before stopping.'}
|
|
317
|
+
}
|
|
318
|
+
return {}
|
|
319
|
+
}]}],
|
|
320
|
+
SubagentStart:[{hooks:[async input=>{run.agentRoles ||= new Map();run.agentRoles.set(input.agent_id,input.agent_type);return {}}]}],
|
|
321
|
+
}
|
|
322
|
+
}
|
|
283
323
|
if (process.env.CLAUDE_FLEET_EXECUTABLE) options.pathToClaudeCodeExecutable = process.env.CLAUDE_FLEET_EXECUTABLE
|
|
284
324
|
run.query = await this.queryFactory({prompt,options})
|
|
285
325
|
if (run.stopping) { run.query.close(); return }
|
|
@@ -297,6 +337,12 @@ class ManagedSessions extends EventEmitter {
|
|
|
297
337
|
run.finished = true
|
|
298
338
|
this.cancelApprovals(s.id,'The agent stopped before this request was answered.')
|
|
299
339
|
try { run.query?.close() } catch {}
|
|
340
|
+
// Only a delegation that is itself still running was actually interrupted here; a
|
|
341
|
+
// delegation that already finished may carry a step whose tool_result simply never
|
|
342
|
+
// arrived, and flipping that step to "interrupted" next to a completed delegation
|
|
343
|
+
// would misreport a race as a stop.
|
|
344
|
+
if (s.taskBoard) for (const d of s.taskBoard.delegations) if (d.status === 'running') for (const step of d.steps || []) if (step.status === 'running') step.status = 'interrupted'
|
|
345
|
+
tasks.interrupt(s)
|
|
300
346
|
for (const entry of run.tools?.values() || []) if (entry.status === 'running') entry.status = 'interrupted'
|
|
301
347
|
if (run.stopping) s.status='stopped'
|
|
302
348
|
else if (s.status !== 'error') s.status='idle'
|
|
@@ -306,7 +352,24 @@ class ManagedSessions extends EventEmitter {
|
|
|
306
352
|
}
|
|
307
353
|
}
|
|
308
354
|
event(s,run,event) {
|
|
309
|
-
if (event.session_id) s.sessionId=event.session_id
|
|
355
|
+
if (event.session_id && !event.parent_tool_use_id) s.sessionId=event.session_id
|
|
356
|
+
if (s.taskBoard && event.parent_tool_use_id) {
|
|
357
|
+
const d=s.taskBoard.delegations.find(d=>d.id===event.parent_tool_use_id)
|
|
358
|
+
if (d && event.type==='assistant') {
|
|
359
|
+
const content=event.message.content || []
|
|
360
|
+
d.activity=content.filter(b=>b.type==='tool_use').map(b=>b.name).join(', ') || d.activity
|
|
361
|
+
d.model=event.message.model || d.model
|
|
362
|
+
const output=content.filter(b=>b.type==='text').map(b=>b.text).join('\n')
|
|
363
|
+
if (output) d.output=output.slice(0,24000)
|
|
364
|
+
// The one place a sub-agent's own tool calls are kept at all: as steps on its
|
|
365
|
+
// delegation, never as messages (every branch above stays guarded by
|
|
366
|
+
// `!event.parent_tool_use_id`). No input, no result; those belong to d.report.
|
|
367
|
+
for (const block of content) if (block.type==='tool_use') this.stepStarted(d,block)
|
|
368
|
+
}
|
|
369
|
+
if (d && event.type==='user') {
|
|
370
|
+
for (const block of event.message?.content || []) if (block.type==='tool_result') this.stepFinished(d,block)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
310
373
|
if (event.type === 'system' && event.subtype === 'init') { s.model=event.model; s.status='running' }
|
|
311
374
|
if (event.type === 'stream_event' && !event.parent_tool_use_id) {
|
|
312
375
|
if (event.event.type === 'message_start') { run.assistant=null; run.streamText='' }
|
|
@@ -336,6 +399,7 @@ class ManagedSessions extends EventEmitter {
|
|
|
336
399
|
for (const block of event.message?.content || []) if (block.type==='tool_result') this.toolFinished(s,run,block)
|
|
337
400
|
}
|
|
338
401
|
if (event.type === 'tool_progress') s.currentTool=event.tool_name
|
|
402
|
+
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
403
|
if (event.type === 'result') {
|
|
340
404
|
run.result=true
|
|
341
405
|
if (event.is_error) { s.status='error'; s.error=event.errors?.join('\n') || event.result || 'Claude could not finish this turn.' }
|
|
@@ -364,9 +428,46 @@ class ManagedSessions extends EventEmitter {
|
|
|
364
428
|
entry.status = block.is_error ? 'error' : 'done'
|
|
365
429
|
entry.ms = Date.now()-entry.at
|
|
366
430
|
const result = resultText(block.content)
|
|
431
|
+
if (s.taskBoard) {
|
|
432
|
+
tasks.finish(s,block.tool_use_id,result,!!block.is_error)
|
|
433
|
+
// The delegation just reached a terminal status. A step whose own tool_result
|
|
434
|
+
// never arrived from the sub-agent can no longer resolve on its own, by
|
|
435
|
+
// definition; left as "running" it would look like a live step under a
|
|
436
|
+
// finished delegation, so it gets its own terminal label instead.
|
|
437
|
+
const d=s.taskBoard.delegations.find(d=>d.id===block.tool_use_id)
|
|
438
|
+
if (d && d.status!=='running') for (const step of d.steps || []) if (step.status==='running') step.status='unreported'
|
|
439
|
+
}
|
|
367
440
|
entry.truncated = result.length > MAX_TOOL_RESULT
|
|
368
441
|
entry.result = block.is_error || !QUIET_RESULT.has(entry.tool) ? result.slice(0,MAX_TOOL_RESULT) : null
|
|
369
442
|
}
|
|
443
|
+
// A sub-agent's tool call becomes a step on its delegation rather than a conversation
|
|
444
|
+
// entry: name, target, status and timing only, so the operator can see what happened
|
|
445
|
+
// without the console ever rendering it.
|
|
446
|
+
stepStarted(d,block) {
|
|
447
|
+
if (!block.id) return
|
|
448
|
+
d.steps ||= []
|
|
449
|
+
if (d.steps.some(step=>step.id===block.id)) return
|
|
450
|
+
d.steps.push({id:block.id,tool:block.name || 'Tool',target:toolTarget(block.name,block.input),status:'running',at:Date.now(),ms:null})
|
|
451
|
+
if (d.steps.length > MAX_DELEGATION_STEPS) { d.steps=d.steps.slice(-MAX_DELEGATION_STEPS); d.stepsTruncated=true }
|
|
452
|
+
}
|
|
453
|
+
stepFinished(d,block) {
|
|
454
|
+
const step=d.steps?.find(step=>step.id===block.tool_use_id)
|
|
455
|
+
if (!step || step.status!=='running') return
|
|
456
|
+
step.status=block.is_error ? 'error' : 'done'
|
|
457
|
+
step.ms=Date.now()-step.at
|
|
458
|
+
}
|
|
459
|
+
setLimits(id,body) {
|
|
460
|
+
const s=this.get(id)
|
|
461
|
+
if (!s.teamSnapshot?.workflow) fail('This initiative does not have configurable limits.')
|
|
462
|
+
if (this.runs.has(id)) fail('Stop the manager before changing its limits.',409)
|
|
463
|
+
const {budgetUsd,maxAttempts}=body
|
|
464
|
+
if (!Number.isFinite(budgetUsd) || budgetUsd<0.1 || budgetUsd>1000 || budgetUsd<(s.costUsd || 0)) fail('Choose a usage cap between the amount already used and $1,000 (minimum $0.10).')
|
|
465
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts<1 || maxAttempts>10) fail('Choose 1–10 attempts per task.')
|
|
466
|
+
const previous=s.limits
|
|
467
|
+
s.limits={budgetUsd,maxAttempts}
|
|
468
|
+
try {this.changed(s,true)} catch(error) {s.limits=previous;throw error}
|
|
469
|
+
return s
|
|
470
|
+
}
|
|
370
471
|
setModelChoice(id,body) {
|
|
371
472
|
const s=this.get(id)
|
|
372
473
|
s.selectedModel=modelChoice(body.model)
|
|
@@ -387,7 +488,7 @@ class ManagedSessions extends EventEmitter {
|
|
|
387
488
|
if (!reason) return Promise.resolve({behavior:'allow',updatedInput:input})
|
|
388
489
|
return new Promise(resolve => {
|
|
389
490
|
const id=randomUUID()
|
|
390
|
-
const approval={id,tool,input,at:Date.now(),reason,description:context.title || context.decisionReason || null,role:roleAsking(s,context)}
|
|
491
|
+
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
492
|
let settled=false
|
|
392
493
|
const finish=result=>{
|
|
393
494
|
if(settled)return
|
|
@@ -461,7 +562,7 @@ class ManagedSessions extends EventEmitter {
|
|
|
461
562
|
// from the delegation that is in flight. With two delegations running at once that is
|
|
462
563
|
// ambiguous, and an honest null beats a confident guess at the wrong role.
|
|
463
564
|
function roleAsking(s,context) {
|
|
464
|
-
const team = getTeam(s.teamId)
|
|
565
|
+
const team = s.teamSnapshot || getTeam(s.teamId)
|
|
465
566
|
if (!team) return null
|
|
466
567
|
// No agentID means the request came from the main thread, which is the manager by
|
|
467
568
|
// 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.
|
|
3
|
+
"version": "0.6.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
|
@@ -13,17 +13,28 @@ const age = timestamp => {
|
|
|
13
13
|
return secs < 60 ? `${secs}s` : secs < 3600 ? `${Math.floor(secs/60)}m` : secs < 86400 ? `${Math.floor(secs/3600)}h` : `${Math.floor(secs/86400)}d`
|
|
14
14
|
}
|
|
15
15
|
let snapshot = null, filter = 'all', selected = null, pending = false, toastTimer
|
|
16
|
+
// A delegation row nested under a team session. Keyed on the delegation id, never on
|
|
17
|
+
// its position or status, so a sub-agent finishing does not move the operator's focus.
|
|
18
|
+
let selectedChild = null, childDetail = null, childDetailFor = null, childDetailError = null, childRequest = 0, lastChildId = null
|
|
19
|
+
const DELEGATION_LABEL = { running: 'Working', completed: 'Done', failed: 'Failed', interrupted: 'Interrupted' }
|
|
20
|
+
const DELEGATION_BADGE = { running: 'busy', completed: 'idle', failed: 'hot', interrupted: 'stale' }
|
|
21
|
+
const STEP_LABEL = { running: 'Running', done: 'Done', error: 'Failed', interrupted: 'Interrupted', unreported: 'Unreported' }
|
|
22
|
+
const formatModel = m => m ? String(m).replace('claude-', '') : 'Model pending'
|
|
23
|
+
// A child row shares its data-session with the parent that owns it, so the session
|
|
24
|
+
// id alone is not a unique row key: folding in data-delegation is what tells a
|
|
25
|
+
// delegation row apart from its parent when the list redraws underneath focus.
|
|
26
|
+
const rowFocusKey = b => b.dataset.delegation ? `${b.dataset.session}::${b.dataset.delegation}` : b.dataset.session || b.dataset.filter
|
|
16
27
|
function update(id, html) {
|
|
17
28
|
const el = $(id)
|
|
18
29
|
if (!el || el.innerHTML === html) return
|
|
19
30
|
const active = document.activeElement
|
|
20
|
-
const focusKey = el.contains(active) ? active
|
|
31
|
+
const focusKey = el.contains(active) ? rowFocusKey(active) : null
|
|
21
32
|
const top = el.scrollTop
|
|
22
33
|
const responseTop = el.querySelector('.response')?.scrollTop || 0
|
|
23
34
|
el.innerHTML = html
|
|
24
35
|
el.scrollTop = top
|
|
25
36
|
if (el.querySelector('.response')) el.querySelector('.response').scrollTop = responseTop
|
|
26
|
-
if (focusKey) [...el.querySelectorAll('button')].find(b => b
|
|
37
|
+
if (focusKey) [...el.querySelectorAll('button')].find(b => rowFocusKey(b) === focusKey)?.focus({ preventScroll: true })
|
|
27
38
|
}
|
|
28
39
|
function status(s) {
|
|
29
40
|
// A Fleet conversation resumed in a terminal is driven there, whatever Fleet last recorded.
|
|
@@ -77,6 +88,33 @@ function markSeen(k, at) {
|
|
|
77
88
|
}
|
|
78
89
|
const hasUnseen = s => !!s.lastActivity && key(s) !== selected && (seen[key(s)] || 0) < s.lastActivity
|
|
79
90
|
|
|
91
|
+
// A Task-tool sub-agent never gets a process of its own, so this is the only row it
|
|
92
|
+
// ever gets: nested under the session that ran it, for as long as that session lives.
|
|
93
|
+
function childRowHtml(s, d) {
|
|
94
|
+
const cls = DELEGATION_BADGE[d.status] || ''
|
|
95
|
+
const label = DELEGATION_LABEL[d.status] || d.status
|
|
96
|
+
return `<button class="session session-child" data-session="${esc(key(s))}" data-delegation="${esc(d.id)}" aria-pressed="${selectedChild === d.id}" aria-controls="detail"><span><span class="session-top"><span class="badge ${cls}"><span class="dot"></span>${esc(label)}</span><span class="session-name">⑂ ${esc(d.role)}</span></span><span class="session-title">${esc(formatModel(d.model))}</span></span><span class="session-context"></span></button>`
|
|
97
|
+
}
|
|
98
|
+
// An initiative that runs long enough accumulates delegations without bound; the
|
|
99
|
+
// row list stays a list, not a scrollbar of its own, by showing only the tail.
|
|
100
|
+
const CHILD_ROW_LIMIT = 20
|
|
101
|
+
const childRowsHtml = s => {
|
|
102
|
+
const all = s.delegations || []
|
|
103
|
+
const recent = all.length > CHILD_ROW_LIMIT ? all.slice(-CHILD_ROW_LIMIT) : all
|
|
104
|
+
// The parent row has already handed its aria-pressed to session-ancestor, so a
|
|
105
|
+
// selected delegation that aged out of the tail must still be drawn here, however
|
|
106
|
+
// old it is, or nothing in the list reads as selected at all.
|
|
107
|
+
const selectedOutside = selectedChild && !recent.some(d => d.id === selectedChild) ? all.find(d => d.id === selectedChild) : null
|
|
108
|
+
const shown = selectedOutside ? [selectedOutside, ...recent] : recent
|
|
109
|
+
const earlier = all.length - shown.length
|
|
110
|
+
// The list is oldest-first, so what got cut is the oldest end of it: the marker
|
|
111
|
+
// belongs ahead of the rows that survived, not trailing the newest one. It is a
|
|
112
|
+
// plain, non-interactive node in the reading order — no role, no aria-hidden — so
|
|
113
|
+
// it is announced once when it appears rather than looping as a live region or
|
|
114
|
+
// vanishing from every screen reader that ignores an injected one.
|
|
115
|
+
return (earlier ? `<div class="session-child-more">+${earlier} earlier</div>` : '') + shown.map(d => childRowHtml(s, d)).join('')
|
|
116
|
+
}
|
|
117
|
+
|
|
80
118
|
function render() {
|
|
81
119
|
if (!snapshot) return
|
|
82
120
|
const {sessions, total} = snapshot
|
|
@@ -103,12 +141,32 @@ function render() {
|
|
|
103
141
|
renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
|
|
104
142
|
update('session-list', shown.length ? shown.map(s => {
|
|
105
143
|
const p = percent(s)
|
|
106
|
-
|
|
144
|
+
const childSelectedHere = !!selectedChild && (s.delegations || []).some(d => d.id === selectedChild)
|
|
145
|
+
return `<button class="session${childSelectedHere ? ' session-ancestor' : ''}" data-session="${esc(key(s))}" aria-pressed="${selected === key(s) && !childSelectedHere}" 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>${childRowsHtml(s)}`
|
|
107
146
|
}).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
147
|
const current = shown.find(s => key(s) === selected)
|
|
109
148
|
if (current) markSeen(key(current), current.lastActivity)
|
|
110
|
-
|
|
111
|
-
|
|
149
|
+
// A delegation belongs to whichever session is actually current; switching sessions,
|
|
150
|
+
// or the owning session dropping out of the current filter, clears a stale child pick.
|
|
151
|
+
const childId = selectedChild && current?.delegations?.some(d => d.id === selectedChild) ? selectedChild : null
|
|
152
|
+
selectedChild = childId
|
|
153
|
+
// A fresh fetch on every genuine transition, including back to a child left moments
|
|
154
|
+
// ago: childDetailFor otherwise still names it "loaded" even after its cache was
|
|
155
|
+
// cleared by the visit in between, and the view would be stuck on "Loading…".
|
|
156
|
+
if (childId !== lastChildId) {
|
|
157
|
+
lastChildId = childId
|
|
158
|
+
if (childId) { childDetail = null; childDetailFor = null; childDetailError = null; loadChildDetail(current.managedId, childId) }
|
|
159
|
+
}
|
|
160
|
+
if (childId) {
|
|
161
|
+
renderChildDetail(current, childId)
|
|
162
|
+
// A sub-agent is not addressable: clearing the control panel drops its composer
|
|
163
|
+
// and conversation from the DOM entirely, not merely hiding them.
|
|
164
|
+
if (typeof selectControl === 'function') selectControl(null)
|
|
165
|
+
} else {
|
|
166
|
+
renderDetail(current)
|
|
167
|
+
if (typeof selectControl === 'function') selectControl(current)
|
|
168
|
+
}
|
|
169
|
+
syncDetails()
|
|
112
170
|
}
|
|
113
171
|
// ── The archive ──────────────────────────────────────────────────────────────
|
|
114
172
|
// Putting a session away hides its row and nothing else: the transcript stays in
|
|
@@ -163,6 +221,44 @@ function renderDetail(s) {
|
|
|
163
221
|
const facts = [['Project',s.cwdShort],['Branch',s.branch],['Model',s.model?.replace('claude-','')],['Permissions',s.permissionMode || 'Default'],['Control',s.managed ? 'Fleet-managed' : s.alive ? 'Terminal · monitor only' : 'Saved · ready to continue'],['Session',s.sessionId]]
|
|
164
222
|
update('detail-content', `<div class="detail-top"><span class="eyebrow">SESSION INSPECTOR</span>${status(s)}</div><h2>${esc(s.title || s.name || 'Untitled session')}</h2><div class="detail-name">${esc(s.name || s.shortId)} · Active ${age(s.lastActivity)} ago</div><div class="context-label"><span>Context window</span><span class="${heat(p)}">${p === null ? 'Not available' : `${tokens(s.contextTokens)} / ${tokens(s.contextLimit)} · ${Math.round(p)}%`}</span></div><div class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></div>${p >= 75 ? `<p class="note ${heat(p)}">${p >= 90 ? 'Context nearly full. Compaction may happen soon.' : 'Context is getting full.'}</p>` : ''}${s.managed ? '' : `<section class="detail-section"><h3>Latest response <span>${s.latestResponseAt ? age(s.latestResponseAt)+' ago' : ''}</span></h3><div class="response ${s.latestResponse ? '' : 'missing'}">${esc(s.latestResponse || 'No assistant response recorded yet.')}</div></section>`}${s.lastPrompt && !s.managed ? `<section class="detail-section"><h3>Latest request</h3><div class="response">${esc(s.lastPrompt)}</div></section>` : ''}<section class="detail-section"><h3>Linked work <span>From transcript</span></h3>${links.length ? `<div class="links">${links.map(l => `<a class="work-link" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer" title="${esc(l.url)}">${l.kind === 'pr' ? '⑂' : '◩'} ${esc(l.label)} ↗</a>`).join('')}</div><p class="note" style="margin-top:9px">Recorded references, not live status.</p>` : '<p class="note">GitHub PR and Linear issue URLs appear here when mentioned in the conversation.</p>'}</section><section class="detail-section"><h3>Environment</h3><dl class="facts">${facts.map(([label,value]) => `<dt>${label}</dt><dd>${esc(value ?? '—')}</dd>`).join('')}</dl></section>${s.transcriptTruncated ? '<p class="note">Showing the most recent 6 MB of this transcript. Earlier responses and links may be absent.</p>' : ''}${s.archived ? '<p class="note archived-note">Archived. Hidden from your fleet, still on disk, still resumable and still searchable.</p>' : ''}<div class="detail-actions"><span class="subtle">${s.messages} recorded messages</span><span class="detail-buttons">${s.managed || !s.sessionId ? '' : `<button class="button" id="toggle-archive">${s.archived ? 'Restore' : 'Archive'}</button>`}${s.resumeCmd && !s.managed ? '<button class="button resume" id="copy-resume">Copy resume command ↗</button>' : ''}</span></div>`)
|
|
165
223
|
}
|
|
224
|
+
// A sub-agent's row: its mandate, its returned report and the steps it actually took.
|
|
225
|
+
// Read only — there is no composer here and nothing that could send it more input.
|
|
226
|
+
function renderChildDetail(s, delegationId) {
|
|
227
|
+
const compact = s.delegations?.find(d => d.id === delegationId)
|
|
228
|
+
if (!compact) return
|
|
229
|
+
const full = childDetailFor === delegationId ? childDetail : null
|
|
230
|
+
// The list poll and the session-detail fetch land independently; the list is the
|
|
231
|
+
// one running every couple of seconds, so its status is never staler than the
|
|
232
|
+
// detail fetch's, and the badge should never lag a row it sits right next to.
|
|
233
|
+
const state = compact.status
|
|
234
|
+
const cls = DELEGATION_BADGE[state] || ''
|
|
235
|
+
const label = DELEGATION_LABEL[state] || state
|
|
236
|
+
const steps = full?.steps || []
|
|
237
|
+
// Selecting a child tears down the console, so an approval sitting on the owning
|
|
238
|
+
// session would otherwise wait in total silence. A notice only: nothing here can
|
|
239
|
+
// answer it, so it just points the operator back to the row that can.
|
|
240
|
+
const approvalNotice = s.managedStatus === 'approval' ? `<p class="note child-approval-notice">${esc(s.name || s.title || 'This session')} needs your approval to continue. Select its row above to respond — this read-only view can’t.</p>` : ''
|
|
241
|
+
const stepsHtml = steps.length ? `<ol class="child-steps">${steps.map(step => `<li class="child-step" data-status="${esc(step.status)}"><span class="child-step-tool">${esc(step.tool)}</span>${step.target ? `<span class="child-step-target">${esc(step.target)}</span>` : ''}<span class="child-step-state">${esc(STEP_LABEL[step.status] || step.status)}</span><span class="child-step-time">${step.ms != null ? elapsed(step.ms) : step.status === 'running' ? 'running…' : ''}</span></li>`).join('')}</ol>` : `<p class="note">${full ? 'No tool steps recorded.' : 'Loading steps…'}</p>`
|
|
242
|
+
update('detail-content', `<div class="detail-top"><span class="eyebrow">SUB-AGENT · READ ONLY</span><span class="badge ${cls}"><span class="dot"></span>${esc(label)}</span></div>${approvalNotice}<h2>⑂ ${esc(compact.role)}</h2><div class="detail-name">${esc(formatModel(full?.model || compact.model))}</div><section class="detail-section"><h3>Mandate</h3><div class="response">${esc(full ? (full.prompt || 'No mandate recorded.') : 'Loading…')}</div></section><section class="detail-section"><h3>Steps${full?.stepsTruncated ? ' <span>Showing the most recent 200</span>' : ''}</h3>${stepsHtml}</section><section class="detail-section"><h3>Report to the manager</h3><div class="response ${full?.report ? '' : 'missing'}">${esc(full ? (full.report || 'Waiting for this agent’s report.') : 'Loading…')}</div></section>${childDetailError ? `<p class="note">${esc(childDetailError)}</p>` : ''}<p class="note">A sub-agent is not addressable on its own. This is a read-only report back to the manager.</p>`)
|
|
243
|
+
}
|
|
244
|
+
// The list payload only ever carries id/role/model/status for a delegation; its steps,
|
|
245
|
+
// mandate and report live on the session detail route, fetched independently of the
|
|
246
|
+
// manager's own control panel so viewing one never depends on that panel being mounted.
|
|
247
|
+
async function loadChildDetail(managedId, delegationId) {
|
|
248
|
+
if (typeof api !== 'function') return
|
|
249
|
+
const requestId = ++childRequest
|
|
250
|
+
try {
|
|
251
|
+
const data = await api(`/api/managed/${managedId}`)
|
|
252
|
+
if (requestId !== childRequest || selectedChild !== delegationId) return
|
|
253
|
+
childDetail = data.session?.taskBoard?.delegations?.find(d => d.id === delegationId) || null
|
|
254
|
+
childDetailFor = delegationId
|
|
255
|
+
childDetailError = null
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (requestId === childRequest && selectedChild === delegationId) childDetailError = error.message || 'Could not load this delegation.'
|
|
258
|
+
} finally {
|
|
259
|
+
if (requestId === childRequest && selectedChild === delegationId) render()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
166
262
|
// ── Modals ───────────────────────────────────────────────────────────────────
|
|
167
263
|
// Ask and New agent are overlays, not panels that push the workspace down. One at
|
|
168
264
|
// a time, Escape and backdrop close them, Tab stays inside, and focus returns to
|
|
@@ -219,8 +315,11 @@ document.addEventListener('click', async event => {
|
|
|
219
315
|
const b = event.target.closest('button')
|
|
220
316
|
if (!b) return
|
|
221
317
|
if (b.dataset.filter) { filter = b.dataset.filter; render() }
|
|
222
|
-
if (b.dataset.
|
|
223
|
-
selected = b.dataset.session; render()
|
|
318
|
+
if (b.dataset.delegation) {
|
|
319
|
+
selected = b.dataset.session; selectedChild = b.dataset.delegation; render()
|
|
320
|
+
if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
|
|
321
|
+
} else if (b.dataset.session) {
|
|
322
|
+
selected = b.dataset.session; selectedChild = null; render()
|
|
224
323
|
if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
|
|
225
324
|
}
|
|
226
325
|
if (b.id === 'archive-sweep') return setArchived(sweepTargets().map(s => s.sessionId), true)
|
|
@@ -245,6 +344,12 @@ async function tick() {
|
|
|
245
344
|
const data = await r.json()
|
|
246
345
|
if (!Array.isArray(data.sessions) || !data.counts) throw new Error('Invalid response')
|
|
247
346
|
snapshot = data; render()
|
|
347
|
+
// A selected delegation keeps polling its own steps and report at the same cadence
|
|
348
|
+
// as everything else, independent of whether the manager's own panel is mounted.
|
|
349
|
+
if (selectedChild) {
|
|
350
|
+
const owner = data.sessions.find(s => s.delegations?.some(d => d.id === selectedChild))
|
|
351
|
+
if (owner?.managedId) loadChildDetail(owner.managedId, selectedChild)
|
|
352
|
+
}
|
|
248
353
|
// Other panels (the ask results) re-read the snapshot to refresh "open now" state.
|
|
249
354
|
document.dispatchEvent(new CustomEvent('fleet-snapshot'))
|
|
250
355
|
$('connection').textContent = 'Live connection'; $('connection-dot').className = 'dot busy'
|
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
|
|
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
|
|
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,54 @@ 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 summary small{display:inline;color:var(--muted);margin-left:8px;font-size:10px}.handoff-mandate,.handoff-report{margin-top:8px}.handoff-report{border-top:1px solid var(--line);padding-top:8px}.handoff-mandate>summary,.handoff-report>summary{cursor:pointer;color:var(--muted);font-size:10px;font-weight:600}.handoff-mandate>summary:focus-visible,.handoff-report>summary:focus-visible{outline:2px solid var(--accent);outline-offset:4px}.initiative-handoff pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.7 var(--mono);max-height:300px;overflow:auto;margin-top:6px}.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%}.initiative-limits .note{width:100%;margin:12px 0 0}@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}
|
|
486
|
+
|
|
487
|
+
/* A Task-tool sub-agent has no process of its own, so this nested row under the
|
|
488
|
+
session that ran it is the only place it ever shows up. */
|
|
489
|
+
.session-child{padding-left:44px;background:color-mix(in oklab,var(--panel) 88%,var(--w-fg))}
|
|
490
|
+
.session-child .session-name{color:var(--muted)}
|
|
491
|
+
.session-child .session-title{font-size:11px;font-weight:400;color:var(--faint)}
|
|
492
|
+
@media(max-width:1000px){.session-child{padding-left:34px}}
|
|
493
|
+
/* Only one row reads as selected: a session holding a selected child is an ancestor,
|
|
494
|
+
not the selection itself, so it gets a quieter treatment and no accent bar. */
|
|
495
|
+
.session-ancestor{background:#1a1f1e}
|
|
496
|
+
.session-ancestor:hover{background:#1c2120}
|
|
497
|
+
/* A long initiative is capped to its most recent delegations; this line names the
|
|
498
|
+
rest without pretending to be a row of its own. */
|
|
499
|
+
.session-child-more{padding:9px 23px 9px 44px;color:var(--faint);font-size:10px;background:color-mix(in oklab,var(--panel) 88%,var(--w-fg))}
|
|
500
|
+
@media(max-width:1000px){.session-child-more{padding-left:34px}}
|
|
501
|
+
/* Its read-only report: steps, in the order Fleet saw them, each with what it touched. */
|
|
502
|
+
.child-steps{list-style:none;margin:0;padding:0;font-size:11px}
|
|
503
|
+
.child-step{display:flex;gap:10px;align-items:baseline;padding:7px 0;border-top:1px solid var(--line)}
|
|
504
|
+
.child-step:first-child{border-top:0}
|
|
505
|
+
.child-step-tool{font-weight:600;flex:none}
|
|
506
|
+
.child-step-target{color:var(--muted);overflow-wrap:anywhere;flex:1;min-width:0}
|
|
507
|
+
.child-step-state{color:var(--faint);flex:none}
|
|
508
|
+
.child-step[data-status="running"] .child-step-state{color:var(--busy)}
|
|
509
|
+
.child-step[data-status="error"] .child-step-state{color:var(--dead)}
|
|
510
|
+
.child-step[data-status="interrupted"] .child-step-state{color:var(--stale)}
|
|
511
|
+
/* Terminal, not in-progress: the delegation ended before this step ever reported
|
|
512
|
+
back, so it gets a cool, muted hue of its own rather than the plain "done" grey
|
|
513
|
+
or a transparency trick that would wash out against the panel. */
|
|
514
|
+
.child-step[data-status="unreported"] .child-step-state{color:color-mix(in oklab,var(--w-blue) 56%,var(--w-fg))}
|
|
515
|
+
.child-step-time{color:var(--faint);font-variant-numeric:tabular-nums;flex:none}
|
|
516
|
+
/* Selecting a child hides the console, so a pending approval on the owning session
|
|
517
|
+
would otherwise go unnoticed; this is the one visible sign it is still waiting. */
|
|
518
|
+
.child-approval-notice{color:var(--text);background:color-mix(in srgb,var(--stale) 14%,transparent);border:1px solid var(--stale);border-radius:6px;padding:10px 12px;margin-bottom:17px}
|
package/public/teams.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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>Usage cap · API-rate equivalent<input data-team-field="budgetUsd" type="number" min="0.1" max="1000" step="0.1" value="${draft.workflow.budgetUsd}"></label></div><p class="note">The usage cap is not a bill. It’s the API-rate equivalent the SDK reports, accumulated across the whole initiative; on a Claude subscription nothing is charged. Reaching the cap still stops the run.</p><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
|
+
// The model actually reported by a delegation wins; a role's configured model is
|
|
93
|
+
// only a fallback for a delegation that has not reported one yet (still running,
|
|
94
|
+
// or resumed before its first assistant event).
|
|
95
|
+
const handoffModel=(s,d)=>d.model || s.teamSnapshot.roles[d.role]?.model || ''
|
|
96
|
+
// Assignment and report each get their own collapsed <details>, keyed into the same
|
|
97
|
+
// data-evidence disclosure tracking as the task and handoff they live inside.
|
|
98
|
+
function handoffHtml(s,d,opened) {
|
|
99
|
+
const model=handoffModel(s,d)
|
|
100
|
+
const mandateId=`${d.id}-mandate`,reportId=`${d.id}-report`
|
|
101
|
+
return `<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>${model ? `<small>${escape(model)}</small>`:''}</summary><details class="handoff-mandate" data-evidence="${mandateId}" ${opened.has(mandateId) ? 'open':''}><summary>Assignment</summary><pre>${escape(d.prompt)}</pre></details><details class="handoff-report" data-evidence="${reportId}" ${opened.has(reportId) ? 'open':''}><summary>Report to ${escape(s.teamSnapshot.manager)}</summary><pre>${escape(d.report || 'Waiting for the agent’s report.')}</pre></details></details>`
|
|
102
|
+
}
|
|
103
|
+
function board(s) {
|
|
104
|
+
let panel=document.getElementById('initiative-board')
|
|
105
|
+
if(!s.teamSnapshot?.workflow){panel?.remove();return}
|
|
106
|
+
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)})}
|
|
107
|
+
const b=s.taskBoard || {tasks:[],delegations:[]},done=b.tasks.filter(t=>t.status==='verified').length
|
|
108
|
+
const signature=JSON.stringify([b,s.status,s.costUsd,s.teamSnapshot,s.limits])
|
|
109
|
+
if(panel.fleetSignature===signature)return
|
|
110
|
+
const opened=new Set([...panel.querySelectorAll('details[open][data-evidence]')].map(el=>el.dataset.evidence))
|
|
111
|
+
const scrollTop=panel.querySelector('.initiative-body')?.scrollTop || 0
|
|
112
|
+
const focused=document.activeElement?.closest('[data-evidence]')?.dataset.evidence
|
|
113
|
+
panel.fleetSignature=signature
|
|
114
|
+
const active=b.delegations.find(d=>d.status==='running')
|
|
115
|
+
panel.innerHTML=`<summary><strong>${escape(s.teamName)}</strong><span>${done}/${b.tasks.length} verified</span><span title="API-rate equivalent the SDK reports. Not billed on a Claude subscription; the run still stops here.">$${(s.costUsd || 0).toFixed(2)} / $${s.limits?.budgetUsd ?? s.teamSnapshot.workflow.budgetUsd} cap</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=>handoffHtml(s,d,opened)).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>`
|
|
116
|
+
panel.querySelector('.initiative-body').scrollTop=scrollTop
|
|
117
|
+
if(focused)panel.querySelector(`[data-evidence="${CSS.escape(focused)}"]>summary`)?.focus({preventScroll:true})
|
|
118
|
+
const composer=document.getElementById('message-input');composer.placeholder=`Message ${s.teamSnapshot.manager}…`
|
|
119
|
+
}
|
|
120
|
+
async function adjustLimits(id){
|
|
121
|
+
const s=controlSession;if(!s || s.id!==id)return
|
|
122
|
+
const panel=document.getElementById('initiative-board')
|
|
123
|
+
if(panel.querySelector('.initiative-limits'))return
|
|
124
|
+
const box=document.createElement('div');box.className='initiative-limits'
|
|
125
|
+
box.innerHTML=`<label>Usage cap · API-rate equivalent<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="note">Not a bill: the SDK reports this as an API-rate equivalent, and a Claude subscription is charged nothing. The cap still stops the run.</p><p class="form-error" role="alert" hidden></p>`
|
|
126
|
+
panel.querySelector('.initiative-body').append(box)
|
|
127
|
+
box.querySelector('input').focus()
|
|
128
|
+
box.querySelector('button').addEventListener('click',async event=>{
|
|
129
|
+
event.target.disabled=true
|
|
130
|
+
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.')}
|
|
131
|
+
catch(error){const el=box.querySelector('p');el.textContent=error.message;el.hidden=false;event.target.disabled=false}
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
function reset(){if(document.getElementById('team-editor'))toggle(false)}
|
|
135
|
+
return {open,board,reset,save,isEditing:()=>!!document.getElementById('team-editor') && !document.getElementById('team-editor').hidden}
|
|
136
|
+
})()
|
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 {
|
|
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'
|
|
@@ -71,6 +71,13 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
71
71
|
// that over Fleet's first-prompt slice, and keep it for the detail view too.
|
|
72
72
|
const aiTitle=transcriptFor(s.sessionId)?.title
|
|
73
73
|
if(aiTitle){s.title=aiTitle;try{manager.get(s.managedId).aiTitle=aiTitle}catch{}}
|
|
74
|
+
// A Task-tool sub-agent has no PID and never gets its own row, so the left panel
|
|
75
|
+
// needs just enough per-delegation state to draw a nested one. Prompts, reports
|
|
76
|
+
// and steps stay off this polled payload; the detail route carries those.
|
|
77
|
+
try {
|
|
78
|
+
const raw=manager.get(s.managedId)
|
|
79
|
+
if(raw.taskBoard?.delegations?.length) s.delegations=raw.taskBoard.delegations.map(d=>({id:d.id,role:d.role,model:d.model || raw.teamSnapshot?.roles?.[d.role]?.model || null,status:d.status}))
|
|
80
|
+
} catch {}
|
|
74
81
|
}
|
|
75
82
|
const sessions=[...managed,...external].sort((a,b)=>{
|
|
76
83
|
const rank=s=>s.managedStatus==='approval'?0:s.state==='busy'?1:s.state==='idle'?2:s.state==='stale'?3:4
|
|
@@ -95,7 +102,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
95
102
|
async function body(req, limit = 65536) {
|
|
96
103
|
if (!(req.headers['content-type'] || '').startsWith('application/json')) throw Object.assign(new Error('JSON content type is required.'),{status:415})
|
|
97
104
|
let size=0, chunks=[]
|
|
98
|
-
for await(const chunk of req){size+=chunk.length;if(size>limit) throw Object.assign(new Error(limit >
|
|
105
|
+
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
106
|
let data
|
|
100
107
|
try{data=JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw Object.assign(new Error('Invalid JSON.'),{status:400})}
|
|
101
108
|
if(!data || typeof data!=='object' || Array.isArray(data)) throw Object.assign(new Error('Expected a JSON object.'),{status:400})
|
|
@@ -121,7 +128,8 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
121
128
|
if(storageError && url.pathname!=='/api/update' && !/^\/api\/managed\/[\w-]+\/stop$/.test(url.pathname)) return json(res,503,{error:storageError})
|
|
122
129
|
// Only the two endpoints that carry a message accept image-sized bodies.
|
|
123
130
|
const carriesMessage=url.pathname==='/api/managed' || /^\/api\/managed\/[\w-]+\/messages$/.test(url.pathname)
|
|
124
|
-
const data=await body(req, carriesMessage ? 40 * 1024 * 1024 : 65536)
|
|
131
|
+
const data=await body(req, carriesMessage ? 40 * 1024 * 1024 : url.pathname==='/api/teams' ? 256000 : 65536)
|
|
132
|
+
if(url.pathname==='/api/teams') return json(res,200,{team:manager.teams.save(data)})
|
|
125
133
|
if(url.pathname==='/api/managed') return json(res,201,{session:manager.detail(manager.create(data).id)})
|
|
126
134
|
// Keyword hits come back at once; the answer is fetched by id while Claude reads them.
|
|
127
135
|
if(url.pathname==='/api/search') return json(res,201,{job:search.start(data)})
|
|
@@ -134,7 +142,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
134
142
|
if(restart) setTimeout(()=>{restart().catch(error=>console.error(error.message))},250).unref()
|
|
135
143
|
return json(res,200,{update:{...update,restarting:!!restart}})
|
|
136
144
|
}
|
|
137
|
-
const match=url.pathname.match(/^\/api\/managed\/([\w-]+)\/(messages|stop|mode|model|close|approvals\/([\w-]+))$/)
|
|
145
|
+
const match=url.pathname.match(/^\/api\/managed\/([\w-]+)\/(messages|stop|mode|model|limits|close|approvals\/([\w-]+))$/)
|
|
138
146
|
if(!match) return json(res,404,{error:'Unknown action.'})
|
|
139
147
|
const [,id,action,approvalId]=match
|
|
140
148
|
if(action==='close') return json(res,200,{closed:await manager.remove(id)})
|
|
@@ -142,6 +150,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
142
150
|
else if(action==='stop') manager.stop(id)
|
|
143
151
|
else if(action==='mode') manager.setMode(id,data)
|
|
144
152
|
else if(action==='model') manager.setModelChoice(id,data)
|
|
153
|
+
else if(action==='limits') manager.setLimits(id,data)
|
|
145
154
|
else manager.decide(id,approvalId,data)
|
|
146
155
|
return json(res,200,{session:manager.detail(id)})
|
|
147
156
|
}
|
|
@@ -168,7 +177,9 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
168
177
|
return res.end(themeCss(currentTheme()))
|
|
169
178
|
}
|
|
170
179
|
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:
|
|
180
|
+
if(url.pathname==='/api/teams') return json(res,200,{teams:manager.teams.list(),tools:TOOL_OPTIONS})
|
|
181
|
+
const teamRoute=url.pathname.match(/^\/api\/teams\/([a-z][a-z0-9-]*)$/)
|
|
182
|
+
if(teamRoute) {const team=manager.teams.get(teamRoute[1]);return json(res,team ? 200:404,team ? {team}:{error:'Team not found.'})}
|
|
172
183
|
if(url.pathname==='/api/sessions') return json(res,200,getSnapshot())
|
|
173
184
|
if(url.pathname==='/api/events') {
|
|
174
185
|
if(clients.size>=20) return json(res,429,{error:'Too many dashboard connections.'})
|
|
@@ -195,7 +206,7 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
195
206
|
if(holder) session.openElsewhere=holder
|
|
196
207
|
return json(res,200,{session})
|
|
197
208
|
}
|
|
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')}
|
|
209
|
+
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
210
|
const file=files[url.pathname]
|
|
200
211
|
if(!file) return json(res,404,{error:'Not found.'})
|
|
201
212
|
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('Usage cap 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) }
|