@sergeychuvayev/claude-fleet 0.5.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/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('Initiative budget reached. Increase the budget explicitly before continuing.')
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,10 +356,18 @@ 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
- d.activity=(event.message.content || []).filter(b=>b.type==='tool_use').map(b=>b.name).join(', ') || d.activity
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
- const output=(event.message.content || []).filter(b=>b.type==='text').map(b=>b.text).join('\n')
362
+ const output=content.filter(b=>b.type==='text').map(b=>b.text).join('\n')
354
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)
355
371
  }
356
372
  }
357
373
  if (event.type === 'system' && event.subtype === 'init') { s.model=event.model; s.status='running' }
@@ -412,16 +428,40 @@ class ManagedSessions extends EventEmitter {
412
428
  entry.status = block.is_error ? 'error' : 'done'
413
429
  entry.ms = Date.now()-entry.at
414
430
  const result = resultText(block.content)
415
- if (s.taskBoard) tasks.finish(s,block.tool_use_id,result,!!block.is_error)
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
+ }
416
440
  entry.truncated = result.length > MAX_TOOL_RESULT
417
441
  entry.result = block.is_error || !QUIET_RESULT.has(entry.tool) ? result.slice(0,MAX_TOOL_RESULT) : null
418
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
+ }
419
459
  setLimits(id,body) {
420
460
  const s=this.get(id)
421
461
  if (!s.teamSnapshot?.workflow) fail('This initiative does not have configurable limits.')
422
462
  if (this.runs.has(id)) fail('Stop the manager before changing its limits.',409)
423
463
  const {budgetUsd,maxAttempts}=body
424
- if (!Number.isFinite(budgetUsd) || budgetUsd<0.1 || budgetUsd>1000 || budgetUsd<(s.costUsd || 0)) fail('Choose a budget between the amount already spent and $1,000 (minimum $0.10).')
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).')
425
465
  if (!Number.isInteger(maxAttempts) || maxAttempts<1 || maxAttempts>10) fail('Choose 1–10 attempts per task.')
426
466
  const previous=s.limits
427
467
  s.limits={budgetUsd,maxAttempts}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sergeychuvayev/claude-fleet",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A local control room for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
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.dataset.session || active.dataset.filter : null
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.dataset.session === focusKey || b.dataset.filter === focusKey)?.focus({ preventScroll: true })
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
- return `<button class="session" data-session="${esc(key(s))}" aria-pressed="${selected === key(s)}" aria-controls="detail" title="${hasUnseen(s) ? 'New output since you last opened this' : ''}"><span><span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc((s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session'))}</span>${spawnCounts.get(s.pid) ? `<span class="spawn-badge" title="Running ${spawnCounts.get(s.pid)} background session(s)">⑂ ${spawnCounts.get(s.pid)}</span>` : ''}${s.background ? `<span class="spawn-owner" title="Started by ${esc(s.spawnedByName || 'a program')}, not from a terminal">via ${esc(s.spawnedByName || 'a program')}</span>` : ''}${s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : ''}</span>${s.kind === 'initiative' ? `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}${s.taskProgress ? ` · ${s.taskProgress.verified}/${s.taskProgress.total} verified${s.taskProgress.blocked ? ` · ${s.taskProgress.blocked} need attention` : ''}` : ''}</span>` : ''}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span><span class="session-meta"><span>${esc(s.cwd?.split('/').filter(Boolean).pop() || 'No project')}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${money(s.costUsd) ? `<span class="session-cost" title="What this conversation has cost so far">${esc(money(s.costUsd))}</span>` : ''}</span>${turnRow(s)}</span><span class="session-context ${heat(p)}">${p === null ? '—' : Math.round(p)+'%'}<span class="mini-bar"><i class="${heat(p)}" style="width:${p || 0}%"></i></span><small>${age(s.lastActivity)} ago</small></span></button>`
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
- renderDetail(current)
111
- if (typeof selectControl === 'function') selectControl(current)
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.session) {
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/styles.css CHANGED
@@ -479,7 +479,40 @@ 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 h4{margin-bottom:6px;color:var(--muted)}.initiative-handoff pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.7 var(--mono);max-height:300px;overflow:auto}.initiative-body>.note{margin:12px 0 0;font-size:10px}
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}
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>Initiative budget · USD<input data-team-field="budgetUsd" type="number" min="0.1" max="1000" step="0.1" value="${draft.workflow.budgetUsd}"></label></div><p class="note">One manager talks to you. Verification roles check every task; at least one separate worker does the work. Shell access permits commands and is governed by your approval mode.</p><div class="team-role-list">${roles.map(([key,role],index)=>`<details class="team-role" data-role-key="${escape(key)}" ${index===0 ? 'open':''}><summary><strong>${escape(key)}</strong><span>${escape(role.description)}</span><small>${escape(role.model)}</small></summary><div class="team-role-fields"><label>Role ID<input data-role-field="id" value="${escape(key)}" maxlength="40" required></label><label>Model<input data-role-field="model" value="${escape(role.model || 'inherit')}" list="team-model-options" maxlength="80" required></label><label class="team-wide">Purpose<input data-role-field="description" value="${escape(role.description)}" maxlength="500" required></label><label class="team-wide">Instructions<textarea data-role-field="prompt" rows="6" maxlength="12000" required>${escape(role.prompt)}</textarea></label><div class="team-role-kind team-wide"><label><input type="radio" name="team-manager-role" data-role-field="manager" ${key===draft.manager ? 'checked':''}> Manager</label><label><input type="checkbox" data-role-field="reviewer" ${draft.workflow.reviewers.includes(key) ? 'checked':''}> Required verifier</label><button type="button" class="button" data-remove-role="${escape(key)}">Remove role</button></div><fieldset class="team-wide"><legend>Allowed tools</legend><div class="team-tools">${tools.map(tool=>`<label><input type="checkbox" data-tool="${escape(tool)}" ${(role.tools || []).includes(tool) ? 'checked':''}> ${escape(tool)}</label>`).join('')}</div></fieldset></div></details>`).join('')}</div><datalist id="team-model-options"><option value="opus"><option value="sonnet"><option value="haiku"><option value="inherit"></datalist>`
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=>`<details class="initiative-handoff" data-evidence="${d.id}" ${opened.has(d.id) ? 'open':''}><summary>${escape(s.teamSnapshot.manager)} → ${escape(d.role)} <span>${escape(d.status)}${d.activity && d.status==='running' ? ' · '+escape(d.activity):''}</span></summary><h4>Assignment</h4><pre>${escape(d.prompt)}</pre><h4>Report to ${escape(s.teamSnapshot.manager)}</h4><pre>${escape(d.report || 'Waiting for the agent’s report.')}</pre></details>`).join('')}</details></li>`).join('')}</ol>`:'<p class="note">The manager is shaping your brief. Tasks and handoffs will appear here as work begins.</p>'}<button type="button" class="button" data-adjust-limits ${isWorking(s) ? 'disabled':''}>Adjust limits</button><p class="note">Verified means all configured verifiers returned passing reports for that task’s attempt. Expand a task to inspect the evidence.</p></div>`
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>Budget · USD<input type="number" data-limit="budgetUsd" min="0.1" max="1000" step="0.1" value="${s.limits?.budgetUsd ?? s.teamSnapshot.workflow.budgetUsd}"></label><label>Attempts per task<input type="number" data-limit="maxAttempts" min="1" max="10" value="${s.limits?.maxAttempts ?? s.teamSnapshot.workflow.maxAttempts}"></label><button type="button" class="button">Save limits</button><p class="form-error" role="alert" hidden></p>`
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/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('Budget must be between $0.10 and $1,000.')
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))