@sergeychuvayev/claude-fleet 0.5.0 → 0.7.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 +24 -2
- package/managed.js +80 -8
- package/package.json +1 -1
- package/public/app.js +119 -7
- package/public/styles.css +41 -2
- package/public/teams.js +14 -3
- package/server.js +7 -0
- package/tasks.js +14 -3
- package/team-store.js +1 -1
- package/teams.js +14 -1
package/README.md
CHANGED
|
@@ -412,7 +412,11 @@ In **New agent**, choose **Software delivery** to give a brief to a Manager back
|
|
|
412
412
|
Product, Developer, Reviewer and QA roles. Choose **Customize team…** to save your own
|
|
413
413
|
team: rename/add/remove roles, write their instructions, select a Claude model per role,
|
|
414
414
|
and choose allowed tools. Built-in teams are copied; existing custom teams can be edited.
|
|
415
|
-
|
|
415
|
+
Choose **Quick task** for small, clearly scoped changes: a Sonnet manager, one developer,
|
|
416
|
+
and one independent QA verifier, with two implementation attempts and a $3 usage cap.
|
|
417
|
+
**Software delivery** keeps the thorough review-and-QA workflow. **No team · single agent**
|
|
418
|
+
avoids orchestration entirely when you just need one agent. Existing initiatives keep
|
|
419
|
+
their original team snapshot. The original Bug fix preset remains available.
|
|
416
420
|
|
|
417
421
|
One role is the manager and at least one separate role is a required verifier. A third
|
|
418
422
|
role owns the work. Fleet adds delegation and operator-question tools to the manager;
|
|
@@ -431,7 +435,7 @@ not a guarantee that their evaluation is correct or that later work cannot regre
|
|
|
431
435
|
|
|
432
436
|
Delegations run sequentially in the shared initiative worktree. After an interruption,
|
|
433
437
|
the saved task board survives and unfinished delegations are marked interrupted. Message
|
|
434
|
-
the Manager to resume.
|
|
438
|
+
the Manager to resume. Software delivery starts with three implementation attempts per task and
|
|
435
439
|
$10 in reported SDK usage; **Adjust limits** changes them explicitly while idle. The SDK
|
|
436
440
|
budget is an execution cutoff, not a billing guarantee: usage is reported at turn end,
|
|
437
441
|
and a killed runtime may not report its final spend. Each turn also has a 100-turn SDK
|
|
@@ -442,3 +446,21 @@ snapshots live with the initiative in `sessions.json`. This version supports mod
|
|
|
442
446
|
available through the Claude Agent SDK, up to eight roles per team, and 100 tasks per
|
|
443
447
|
initiative. It finishes at a local branch ready for review; it does not automatically
|
|
444
448
|
publish or merge changes.
|
|
449
|
+
|
|
450
|
+
### Inspect individual agents
|
|
451
|
+
|
|
452
|
+
Select a subagent row beneath a managed initiative to inspect its assignment, actual
|
|
453
|
+
model, status, elapsed time, attempt, tool steps, and report. Expand **Input and output**
|
|
454
|
+
to see a tool's recorded payload. The inspector is read-only; send direction and answer
|
|
455
|
+
approvals through the manager. Tool history keeps the most recent 200 steps with bounded
|
|
456
|
+
inputs and outputs. Older runs may have no recorded steps or usage.
|
|
457
|
+
|
|
458
|
+
The inspector shows reported input/output and cache tokens, or SDK progress totals when
|
|
459
|
+
only those are available. Repeated assistant events do not count usage twice. Per-agent
|
|
460
|
+
cost appears only when the SDK explicitly reports it; missing data is not shown as zero.
|
|
461
|
+
Token totals describe recorded usage across messages, not current context size.
|
|
462
|
+
|
|
463
|
+
Task-board reads return compact metadata, so repeatedly checking task state does not
|
|
464
|
+
re-inject all assignments, tool output and reports into the manager's context. The manager
|
|
465
|
+
can request a specific delegation's full assignment/report using the task tool's
|
|
466
|
+
`inspect` action with `delegationId`. Verification gates and saved evidence are unchanged.
|
package/managed.js
CHANGED
|
@@ -38,6 +38,9 @@ const MAX_TOOL_INPUT = 2000
|
|
|
38
38
|
const MAX_TOOL_RESULT = 6000
|
|
39
39
|
// Tool names whose result is the point of the block; others are summarised by their input.
|
|
40
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
|
|
41
44
|
function fail(message, status = 400) { const error = new Error(message); error.status = status; throw error }
|
|
42
45
|
function text(value, name, max) {
|
|
43
46
|
if (typeof value !== 'string' || !value.trim() || value.length > max) fail(`${name} must contain 1–${max} characters.`)
|
|
@@ -289,7 +292,7 @@ class ManagedSessions extends EventEmitter {
|
|
|
289
292
|
}
|
|
290
293
|
if (team?.workflow) {
|
|
291
294
|
const remaining=(s.limits?.budgetUsd ?? team.workflow.budgetUsd)-(s.costUsd || 0)
|
|
292
|
-
if (remaining<=0) throw new Error('
|
|
295
|
+
if (remaining<=0) throw new Error('Usage cap reached. Increase the cap explicitly before continuing.')
|
|
293
296
|
options.maxBudgetUsd=remaining
|
|
294
297
|
options.maxTurns=100
|
|
295
298
|
options.mcpServers={fleet:await tasks.sdkServer(s,()=>this.changed(s,true))}
|
|
@@ -334,6 +337,11 @@ class ManagedSessions extends EventEmitter {
|
|
|
334
337
|
run.finished = true
|
|
335
338
|
this.cancelApprovals(s.id,'The agent stopped before this request was answered.')
|
|
336
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'
|
|
337
345
|
tasks.interrupt(s)
|
|
338
346
|
for (const entry of run.tools?.values() || []) if (entry.status === 'running') entry.status = 'interrupted'
|
|
339
347
|
if (run.stopping) s.status='stopped'
|
|
@@ -348,13 +356,33 @@ class ManagedSessions extends EventEmitter {
|
|
|
348
356
|
if (s.taskBoard && event.parent_tool_use_id) {
|
|
349
357
|
const d=s.taskBoard.delegations.find(d=>d.id===event.parent_tool_use_id)
|
|
350
358
|
if (d && event.type==='assistant') {
|
|
351
|
-
|
|
359
|
+
const content=event.message.content || []
|
|
360
|
+
d.activity=content.filter(b=>b.type==='tool_use').map(b=>b.name).join(', ') || d.activity
|
|
352
361
|
d.model=event.message.model || d.model
|
|
353
|
-
|
|
362
|
+
this.delegationUsage(d,run,event.message)
|
|
363
|
+
const output=content.filter(b=>b.type==='text').map(b=>b.text).join('\n')
|
|
354
364
|
if (output) d.output=output.slice(0,24000)
|
|
365
|
+
// The one place a sub-agent's own tool calls are kept at all: as steps on its
|
|
366
|
+
// delegation, never as messages (every branch above stays guarded by
|
|
367
|
+
// `!event.parent_tool_use_id`). Inputs and results are bounded for inspection.
|
|
368
|
+
for (const block of content) if (block.type==='tool_use') this.stepStarted(d,block)
|
|
369
|
+
}
|
|
370
|
+
if (d && event.type==='result' && Number.isFinite(event.total_cost_usd) && event.total_cost_usd>=0) d.costUsd=event.total_cost_usd
|
|
371
|
+
if (d && event.type==='user') {
|
|
372
|
+
for (const block of event.message?.content || []) if (block.type==='tool_result') this.stepFinished(d,block)
|
|
355
373
|
}
|
|
356
374
|
}
|
|
357
|
-
if (event.type
|
|
375
|
+
if (s.taskBoard && event.type==='system' && ['task_progress','task_notification'].includes(event.subtype)) {
|
|
376
|
+
const d=s.taskBoard.delegations.find(d=>d.id===event.tool_use_id)
|
|
377
|
+
if (d && event.usage) {
|
|
378
|
+
d.runtimeUsage ||= {}
|
|
379
|
+
for (const key of ['total_tokens','tool_uses','duration_ms']) {
|
|
380
|
+
const value=event.usage[key]
|
|
381
|
+
if (Number.isFinite(value) && value>=0) d.runtimeUsage[key]=Math.max(d.runtimeUsage[key] || 0,value)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (event.type === 'system' && event.subtype === 'init' && !event.parent_tool_use_id) { s.model=event.model; s.status='running' }
|
|
358
386
|
if (event.type === 'stream_event' && !event.parent_tool_use_id) {
|
|
359
387
|
if (event.event.type === 'message_start') { run.assistant=null; run.streamText='' }
|
|
360
388
|
if (event.event.delta?.type === 'text_delta') {
|
|
@@ -382,9 +410,9 @@ class ManagedSessions extends EventEmitter {
|
|
|
382
410
|
if (event.type === 'user' && !event.parent_tool_use_id) {
|
|
383
411
|
for (const block of event.message?.content || []) if (block.type==='tool_result') this.toolFinished(s,run,block)
|
|
384
412
|
}
|
|
385
|
-
if (event.type === 'tool_progress') s.currentTool=event.tool_name
|
|
413
|
+
if (event.type === 'tool_progress' && !event.parent_tool_use_id) s.currentTool=event.tool_name
|
|
386
414
|
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)
|
|
387
|
-
if (event.type === 'result') {
|
|
415
|
+
if (event.type === 'result' && !event.parent_tool_use_id) {
|
|
388
416
|
run.result=true
|
|
389
417
|
if (event.is_error) { s.status='error'; s.error=event.errors?.join('\n') || event.result || 'Claude could not finish this turn.' }
|
|
390
418
|
else if (event.result && !s.messages.some(m=>m.role==='assistant' && m.text===event.result.slice(-24000))) s.messages.push({id:randomUUID(),role:'assistant',text:event.result.slice(-24000),at:Date.now()})
|
|
@@ -412,16 +440,60 @@ class ManagedSessions extends EventEmitter {
|
|
|
412
440
|
entry.status = block.is_error ? 'error' : 'done'
|
|
413
441
|
entry.ms = Date.now()-entry.at
|
|
414
442
|
const result = resultText(block.content)
|
|
415
|
-
if (s.taskBoard)
|
|
443
|
+
if (s.taskBoard) {
|
|
444
|
+
tasks.finish(s,block.tool_use_id,result,!!block.is_error)
|
|
445
|
+
// The delegation just reached a terminal status. A step whose own tool_result
|
|
446
|
+
// never arrived from the sub-agent can no longer resolve on its own, by
|
|
447
|
+
// definition; left as "running" it would look like a live step under a
|
|
448
|
+
// finished delegation, so it gets its own terminal label instead.
|
|
449
|
+
const d=s.taskBoard.delegations.find(d=>d.id===block.tool_use_id)
|
|
450
|
+
if (d && d.status!=='running') for (const step of d.steps || []) if (step.status==='running') step.status='unreported'
|
|
451
|
+
}
|
|
416
452
|
entry.truncated = result.length > MAX_TOOL_RESULT
|
|
417
453
|
entry.result = block.is_error || !QUIET_RESULT.has(entry.tool) ? result.slice(0,MAX_TOOL_RESULT) : null
|
|
418
454
|
}
|
|
455
|
+
// A sub-agent's tool call becomes a step on its delegation rather than a conversation
|
|
456
|
+
// entry: bounded input/output, status and timing, so the operator can see what happened
|
|
457
|
+
// without the console ever rendering it.
|
|
458
|
+
stepStarted(d,block) {
|
|
459
|
+
if (!block.id) return
|
|
460
|
+
d.steps ||= []
|
|
461
|
+
if (d.steps.some(step=>step.id===block.id)) return
|
|
462
|
+
d.steps.push({id:block.id,tool:block.name || 'Tool',target:toolTarget(block.name,block.input),input:clampInput(block.input),result:null,status:'running',at:Date.now(),ms:null})
|
|
463
|
+
if (d.steps.length > MAX_DELEGATION_STEPS) { d.steps=d.steps.slice(-MAX_DELEGATION_STEPS); d.stepsTruncated=true }
|
|
464
|
+
}
|
|
465
|
+
stepFinished(d,block) {
|
|
466
|
+
const step=d.steps?.find(step=>step.id===block.tool_use_id)
|
|
467
|
+
if (!step || step.status!=='running') return
|
|
468
|
+
step.status=block.is_error ? 'error' : 'done'
|
|
469
|
+
step.ms=Date.now()-step.at
|
|
470
|
+
const result=resultText(block.content)
|
|
471
|
+
step.result=result.slice(0,MAX_TOOL_RESULT)
|
|
472
|
+
step.truncated=result.length>MAX_TOOL_RESULT
|
|
473
|
+
}
|
|
474
|
+
// The SDK can emit multiple blocks for the same assistant message. Count each
|
|
475
|
+
// usage counter only once, accepting later updates without double-counting.
|
|
476
|
+
delegationUsage(d,run,message) {
|
|
477
|
+
if (!message.id || !message.usage) return
|
|
478
|
+
run.delegationUsage ||= new Map()
|
|
479
|
+
const key=JSON.stringify([d.id,message.id])
|
|
480
|
+
const previous=run.delegationUsage.get(key) || {}
|
|
481
|
+
d.usage ||= {}
|
|
482
|
+
for (const field of ['input_tokens','output_tokens','cache_read_input_tokens','cache_creation_input_tokens']) {
|
|
483
|
+
const value=message.usage[field]
|
|
484
|
+
if (!Number.isFinite(value) || value<0) continue
|
|
485
|
+
const next=Math.max(previous[field] || 0,value)
|
|
486
|
+
d.usage[field]=(d.usage[field] || 0)+next-(previous[field] || 0)
|
|
487
|
+
previous[field]=next
|
|
488
|
+
}
|
|
489
|
+
run.delegationUsage.set(key,previous)
|
|
490
|
+
}
|
|
419
491
|
setLimits(id,body) {
|
|
420
492
|
const s=this.get(id)
|
|
421
493
|
if (!s.teamSnapshot?.workflow) fail('This initiative does not have configurable limits.')
|
|
422
494
|
if (this.runs.has(id)) fail('Stop the manager before changing its limits.',409)
|
|
423
495
|
const {budgetUsd,maxAttempts}=body
|
|
424
|
-
if (!Number.isFinite(budgetUsd) || budgetUsd<0.1 || budgetUsd>1000 || budgetUsd<(s.costUsd || 0)) fail('Choose a
|
|
496
|
+
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).')
|
|
425
497
|
if (!Number.isInteger(maxAttempts) || maxAttempts<1 || maxAttempts>10) fail('Choose 1–10 attempts per task.')
|
|
426
498
|
const previous=s.limits
|
|
427
499
|
s.limits={budgetUsd,maxAttempts}
|
package/package.json
CHANGED
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,51 @@ 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
|
+
const openedSteps=new Set([...document.querySelectorAll('[data-child-step][open]')].map(el=>el.dataset.childStep))
|
|
238
|
+
const focusedStep=document.activeElement?.closest('[data-child-step]')?.dataset.childStep
|
|
239
|
+
const usage=full?.usage
|
|
240
|
+
const usageText=usage ? `${tokens(usage.input_tokens || 0)} input · ${tokens(usage.output_tokens || 0)} output · ${tokens(usage.cache_read_input_tokens || 0)} cache read · ${tokens(usage.cache_creation_input_tokens || 0)} cache write` : full?.runtimeUsage?.total_tokens != null ? `${tokens(full.runtimeUsage.total_tokens)} tokens reported` : 'Token usage not reported'
|
|
241
|
+
const duration=full?.startedAt ? elapsed((full.finishedAt || Date.now())-full.startedAt) : 'Duration unavailable'
|
|
242
|
+
|
|
243
|
+
// Selecting a child tears down the console, so an approval sitting on the owning
|
|
244
|
+
// session would otherwise wait in total silence. A notice only: nothing here can
|
|
245
|
+
// answer it, so it just points the operator back to the row that can.
|
|
246
|
+
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>` : ''
|
|
247
|
+
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>${step.input != null || step.result != null ? `<details class="child-step-detail" data-child-step="${esc(step.id)}" ${openedSteps.has(step.id) ? 'open':''}><summary>Input and output</summary><h4>Input</h4><pre>${esc(step.input == null ? 'Not recorded' : typeof step.input === 'string' ? step.input : JSON.stringify(step.input,null,2))}</pre><h4>Output${step.truncated ? ' · truncated':''}</h4><pre>${esc(step.result ?? 'No result reported yet.')}</pre></details>`:''}</li>`).join('')}</ol>` : `<p class="note">${full ? 'No tool steps recorded.' : 'Loading steps…'}</p>`
|
|
248
|
+
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))} · ${esc(duration)}${full?.attempt ? ` · attempt ${full.attempt}` : ''}</div><p class="note">${esc(usageText)}. ${full?.costUsd != null ? `Reported cost: ${esc(money(full.costUsd) || '$0.00')}` : 'Per-agent cost not reported'}.</p><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>`)
|
|
249
|
+
if(focusedStep) document.querySelector(`[data-child-step="${CSS.escape(focusedStep)}"]>summary`)?.focus({preventScroll:true})
|
|
250
|
+
}
|
|
251
|
+
// The list payload only ever carries id/role/model/status for a delegation; its steps,
|
|
252
|
+
// mandate and report live on the session detail route, fetched independently of the
|
|
253
|
+
// manager's own control panel so viewing one never depends on that panel being mounted.
|
|
254
|
+
async function loadChildDetail(managedId, delegationId) {
|
|
255
|
+
if (typeof api !== 'function') return
|
|
256
|
+
const requestId = ++childRequest
|
|
257
|
+
try {
|
|
258
|
+
const data = await api(`/api/managed/${managedId}`)
|
|
259
|
+
if (requestId !== childRequest || selectedChild !== delegationId) return
|
|
260
|
+
childDetail = data.session?.taskBoard?.delegations?.find(d => d.id === delegationId) || null
|
|
261
|
+
childDetailFor = delegationId
|
|
262
|
+
childDetailError = null
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (requestId === childRequest && selectedChild === delegationId) childDetailError = error.message || 'Could not load this delegation.'
|
|
265
|
+
} finally {
|
|
266
|
+
if (requestId === childRequest && selectedChild === delegationId) render()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
166
269
|
// ── Modals ───────────────────────────────────────────────────────────────────
|
|
167
270
|
// Ask and New agent are overlays, not panels that push the workspace down. One at
|
|
168
271
|
// a time, Escape and backdrop close them, Tab stays inside, and focus returns to
|
|
@@ -219,8 +322,11 @@ document.addEventListener('click', async event => {
|
|
|
219
322
|
const b = event.target.closest('button')
|
|
220
323
|
if (!b) return
|
|
221
324
|
if (b.dataset.filter) { filter = b.dataset.filter; render() }
|
|
222
|
-
if (b.dataset.
|
|
223
|
-
selected = b.dataset.session; render()
|
|
325
|
+
if (b.dataset.delegation) {
|
|
326
|
+
selected = b.dataset.session; selectedChild = b.dataset.delegation; render()
|
|
327
|
+
if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
|
|
328
|
+
} else if (b.dataset.session) {
|
|
329
|
+
selected = b.dataset.session; selectedChild = null; render()
|
|
224
330
|
if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
|
|
225
331
|
}
|
|
226
332
|
if (b.id === 'archive-sweep') return setArchived(sweepTargets().map(s => s.sessionId), true)
|
|
@@ -245,6 +351,12 @@ async function tick() {
|
|
|
245
351
|
const data = await r.json()
|
|
246
352
|
if (!Array.isArray(data.sessions) || !data.counts) throw new Error('Invalid response')
|
|
247
353
|
snapshot = data; render()
|
|
354
|
+
// A selected delegation keeps polling its own steps and report at the same cadence
|
|
355
|
+
// as everything else, independent of whether the manager's own panel is mounted.
|
|
356
|
+
if (selectedChild) {
|
|
357
|
+
const owner = data.sessions.find(s => s.delegations?.some(d => d.id === selectedChild))
|
|
358
|
+
if (owner?.managedId) loadChildDetail(owner.managedId, selectedChild)
|
|
359
|
+
}
|
|
248
360
|
// Other panels (the ask results) re-read the snapshot to refresh "open now" state.
|
|
249
361
|
document.dispatchEvent(new CustomEvent('fleet-snapshot'))
|
|
250
362
|
$('connection').textContent = 'Live connection'; $('connection-dot').className = 'dot busy'
|
package/public/styles.css
CHANGED
|
@@ -479,7 +479,46 @@ body[data-modal]{overflow:hidden}
|
|
|
479
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
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
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
|
|
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
483
|
@media(max-width:720px){.team-editor-head{align-items:start}.team-meta,.team-rules,.team-role-fields{grid-template-columns:1fr}.team-role>summary{flex-wrap:wrap}.team-role>summary span{flex-basis:100%;order:3}.team-role>summary small{margin-left:auto}#initiative-board>summary{flex-wrap:wrap}.initiative-body{max-height:32vh}}
|
|
484
|
-
#team-editor{padding:3px 32px 28px}.initiative-limits{display:flex;flex-wrap:wrap;align-items:end;gap:12px;margin-top:12px}.initiative-limits label{display:flex;flex-direction:column;gap:6px;color:var(--muted)}.initiative-limits input{width:110px;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px}.initiative-limits .form-error{width:100%}@media(max-width:720px){#team-editor{padding:0 20px 24px}.team-editor-head{flex-wrap:wrap}}
|
|
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
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}
|
|
519
|
+
|
|
520
|
+
/* Drill into a tool without expanding every payload in the agent inspector. */
|
|
521
|
+
.child-step-detail{grid-column:1/-1;min-width:0}
|
|
522
|
+
.child-step-detail summary{cursor:pointer;color:var(--muted);padding:4px 0}
|
|
523
|
+
.child-step-detail pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px}
|
|
524
|
+
.child-step-detail h4{margin:10px 0 4px;font-size:12px}
|
package/public/teams.js
CHANGED
|
@@ -46,7 +46,7 @@ window.FleetTeams=(()=>{
|
|
|
46
46
|
function render() {
|
|
47
47
|
document.getElementById('team-editor-error').hidden=true
|
|
48
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>
|
|
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
50
|
document.querySelector('[data-team-field="id"]').readOnly=!!originalId
|
|
51
51
|
syncTools()
|
|
52
52
|
}
|
|
@@ -89,6 +89,17 @@ window.FleetTeams=(()=>{
|
|
|
89
89
|
returnTeam=team.id;launchRequestId=null;toggle(false);toast('Team saved. Ready for your task.')
|
|
90
90
|
} catch(error){showError(error)}finally{button.disabled=false}
|
|
91
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
|
+
}
|
|
92
103
|
function board(s) {
|
|
93
104
|
let panel=document.getElementById('initiative-board')
|
|
94
105
|
if(!s.teamSnapshot?.workflow){panel?.remove();return}
|
|
@@ -101,7 +112,7 @@ window.FleetTeams=(()=>{
|
|
|
101
112
|
const focused=document.activeElement?.closest('[data-evidence]')?.dataset.evidence
|
|
102
113
|
panel.fleetSignature=signature
|
|
103
114
|
const active=b.delegations.find(d=>d.status==='running')
|
|
104
|
-
panel.innerHTML=`<summary><strong>${escape(s.teamName)}</strong><span>${done}/${b.tasks.length} verified</span><span>$${(s.costUsd || 0).toFixed(2)} / $${s.limits?.budgetUsd ?? s.teamSnapshot.workflow.budgetUsd}</span></summary><div class="initiative-body"><div class="initiative-roster" aria-label="Team and active agent">${Object.entries(s.teamSnapshot.roles).map(([name,r])=>`<div class="initiative-role ${(active?.role || (isWorking(s) ? s.teamSnapshot.manager:null))===name ? 'is-active':''}"><strong>${escape(name)}</strong><small>${escape(name===s.teamSnapshot.manager && s.selectedModel ? s.selectedModel : r.model)}${name===s.teamSnapshot.manager ? ' · your contact':active?.role===name ? ' · working':''}</small></div>`).join('<span class="team-connector" aria-hidden="true">·</span>')}</div>${b.tasks.length ? `<ol class="initiative-tasks">${b.tasks.map(t=>`<li><details data-evidence="${t.id}" ${opened.has(t.id) ? 'open':''}><summary><span class="task-state" data-state="${escape(t.status)}">${escape(t.status.replaceAll('_',' '))}</span><strong>${escape(t.title)}</strong><small>${escape(t.owner)} · attempt ${t.attempt}</small></summary><ul>${t.criteria.map(c=>`<li>${escape(c)}</li>`).join('')}</ul>${t.blocker ? `<p class="form-error">${escape(t.blocker)}</p>`:''}${t.dependencies.length ? `<p class="note">After: ${t.dependencies.map(id=>escape(b.tasks.find(t=>t.id===id)?.title || id)).join(', ')}</p>`:''}${b.delegations.filter(d=>d.taskId===t.id).map(d
|
|
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>`
|
|
105
116
|
panel.querySelector('.initiative-body').scrollTop=scrollTop
|
|
106
117
|
if(focused)panel.querySelector(`[data-evidence="${CSS.escape(focused)}"]>summary`)?.focus({preventScroll:true})
|
|
107
118
|
const composer=document.getElementById('message-input');composer.placeholder=`Message ${s.teamSnapshot.manager}…`
|
|
@@ -111,7 +122,7 @@ window.FleetTeams=(()=>{
|
|
|
111
122
|
const panel=document.getElementById('initiative-board')
|
|
112
123
|
if(panel.querySelector('.initiative-limits'))return
|
|
113
124
|
const box=document.createElement('div');box.className='initiative-limits'
|
|
114
|
-
box.innerHTML=`<label>
|
|
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>`
|
|
115
126
|
panel.querySelector('.initiative-body').append(box)
|
|
116
127
|
box.querySelector('input').focus()
|
|
117
128
|
box.querySelector('button').addEventListener('click',async event=>{
|
package/server.js
CHANGED
|
@@ -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
|
package/tasks.js
CHANGED
|
@@ -6,7 +6,18 @@ function ledger(s) {return s.taskBoard ||= {tasks:[],delegations:[]}}
|
|
|
6
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
7
|
function act(s,input) {
|
|
8
8
|
const board=ledger(s)
|
|
9
|
-
|
|
9
|
+
// The durable ledger also powers the inspector. Never send its full transcript
|
|
10
|
+
// back into the manager context on every list call.
|
|
11
|
+
if (input.action==='list') return {
|
|
12
|
+
tasks:board.tasks,
|
|
13
|
+
delegations:board.delegations.map(d=>({id:d.id,taskId:d.taskId,role:d.role,attempt:d.attempt,status:d.status,startedAt:d.startedAt,finishedAt:d.finishedAt})),
|
|
14
|
+
detailHint:'Use inspect with delegationId to read an assignment and report.',
|
|
15
|
+
}
|
|
16
|
+
if (input.action==='inspect') {
|
|
17
|
+
const d=board.delegations.find(d=>d.id===input.delegationId)
|
|
18
|
+
if (!d) fail('Delegation not found. Read the Fleet task board.')
|
|
19
|
+
return {id:d.id,taskId:d.taskId,role:d.role,status:d.status,prompt:d.prompt,report:d.report,output:d.report ? undefined:d.output}
|
|
20
|
+
}
|
|
10
21
|
if (input.action==='create') {
|
|
11
22
|
if (board.tasks.length>=100) fail('This initiative has reached its 100-task limit.')
|
|
12
23
|
const owner=input.owner,team=s.teamSnapshot
|
|
@@ -91,8 +102,8 @@ function progress(s) {
|
|
|
91
102
|
async function sdkServer(s,changed) {
|
|
92
103
|
const {createSdkMcpServer,tool}=await import('@anthropic-ai/claude-agent-sdk')
|
|
93
104
|
const {z}=require('zod/v4')
|
|
94
|
-
return createSdkMcpServer({name:'fleet',version:'1.0.0',tools:[tool('tasks','Read
|
|
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(),
|
|
105
|
+
return createSdkMcpServer({name:'fleet',version:'1.0.0',tools:[tool('tasks','Read a compact task board; inspect delegation assignments/reports by delegationId; create scoped tasks with criteria and dependencies; record blockers. Fleet records verification from actual agent reports.',{
|
|
106
|
+
action:z.enum(['list','inspect','create','block']),delegationId:z.string().optional(),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
107
|
},async input=>{
|
|
97
108
|
const before=structuredClone(s.taskBoard)
|
|
98
109
|
try {const result=act(s,input);changed();return {content:[{type:'text',text:JSON.stringify(result)}]}}
|
package/team-store.js
CHANGED
|
@@ -34,7 +34,7 @@ function validateTeam(input) {
|
|
|
34
34
|
if (!entries.some(([r])=>r!==manager && !reviewers.includes(r))) bad('Include a worker role separate from the verification roles.')
|
|
35
35
|
const maxAttempts=input.workflow.maxAttempts ?? 3, budgetUsd=input.workflow.budgetUsd ?? 10
|
|
36
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('
|
|
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
38
|
// Manager is a coordinator. Verification can run commands but cannot use edit tools.
|
|
39
39
|
roles[manager].tools=roles[manager].tools.filter(t=>['Read','Glob','Grep','WebSearch','WebFetch'].includes(t))
|
|
40
40
|
for (const key of reviewers) roles[key].tools=roles[key].tools.filter(t=>!['Write','Edit','MultiEdit','NotebookEdit'].includes(t))
|
package/teams.js
CHANGED
|
@@ -174,7 +174,7 @@ const TEAMS = {
|
|
|
174
174
|
const READ_TOOLS = ['Read','Glob','Grep','WebSearch','WebFetch']
|
|
175
175
|
TEAMS.delivery = {
|
|
176
176
|
id:'delivery', name:'Software delivery',
|
|
177
|
-
description:'
|
|
177
|
+
description:'Thorough workflow: scoped work, implementation, independent code review and QA.',
|
|
178
178
|
manager:'manager',
|
|
179
179
|
workflow:{reviewers:['reviewer','qa'],maxAttempts:3,budgetUsd:10},
|
|
180
180
|
roles:{
|
|
@@ -185,9 +185,22 @@ TEAMS.delivery = {
|
|
|
185
185
|
qa:{description:'Verifies acceptance criteria with reproducible evidence.',prompt:QA,model:'sonnet',tools:[...READ_TOOLS,'Bash']},
|
|
186
186
|
},
|
|
187
187
|
}
|
|
188
|
+
// A lighter explicit choice, using the same durable gates and snapshot mechanism.
|
|
189
|
+
TEAMS.quick = {
|
|
190
|
+
id:'quick',name:'Quick task',
|
|
191
|
+
description:'Small, clear changes: one developer and one independent verifier, with focused checks.',
|
|
192
|
+
manager:'manager',workflow:{reviewers:['qa'],maxAttempts:2,budgetUsd:3},
|
|
193
|
+
roles:{
|
|
194
|
+
manager:{...TEAMS.delivery.roles.manager,model:'sonnet',prompt:'Coordinate a small, clearly scoped task. Read only relevant instructions and files. Create one task unless the goal has independent deliverables. Give the developer file boundaries, acceptance criteria and exact checks. Use one QA verification. Avoid broad audits, speculative improvements and repeated repository exploration. Pass concise findings and test evidence between roles.'},
|
|
195
|
+
developer:{...TEAMS.delivery.roles.developer,prompt:DEVELOPER+'\nKeep investigation scoped to the acceptance criteria. Run relevant checks once after the final change; repeat only after a failure or further change.'},
|
|
196
|
+
qa:{...TEAMS.delivery.roles.qa,prompt:QA+'\nKeep verification proportional to this small task. Focus on acceptance criteria and directly affected behavior; stop once sufficient evidence exists.'},
|
|
197
|
+
},
|
|
198
|
+
}
|
|
188
199
|
const TASK_RULES = `
|
|
189
200
|
|
|
190
201
|
Fleet owns the durable task board. Use mcp__fleet__tasks to read it and create tasks before delegating.
|
|
202
|
+
List returns compact metadata; use inspect with delegationId when you need a prior assignment or report.
|
|
203
|
+
Read the board on resume and when state is uncertain, not repeatedly between every action.
|
|
191
204
|
Each task needs an owner, acceptance criteria and optional dependencies. All configured verification
|
|
192
205
|
roles must verify each deliverable. Include a line "Fleet task: <task ID>" in EVERY Agent prompt.
|
|
193
206
|
Invoke only the owner or a configured verification role. Work sequentially in this shared worktree.
|