@sergeychuvayev/claude-fleet 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,6 +121,55 @@ session's own process and never appear as separate rows at all.
121
121
 
122
122
  </details>
123
123
 
124
+ ### The status bar watches the account, not the session
125
+
126
+ Context pressure is per conversation. The five-hour and weekly plan windows are not:
127
+ every Claude process on the machine draws on the same ones, including the terminal
128
+ sessions Fleet only watches. They get one line along the bottom of the window.
129
+
130
+ ```
131
+ 5h ▇▇▇▇▇▇░░░░ 62% resets 13:28 · week 31% · opus wk 48%
132
+ ```
133
+
134
+ Only the window closest to stopping you gets a bar; the rest stay bare numbers. The
135
+ same 75% amber and 90% red as context, so "nearly full" reads the same way everywhere.
136
+ Past 90% the countdown replaces the bar, because by then the question is when it clears,
137
+ not how full it is.
138
+
139
+ When a request is actually refused, the bar turns into the refusal, and the new-agent
140
+ dialog says so before you write a prompt:
141
+
142
+ ```
143
+ ⊘ Rate limited · five-hour window · resets 11:42 (35m) · organisation spend cap reached
144
+ ```
145
+
146
+ <details>
147
+ <summary><b>Where the numbers come from, and when they are missing</b></summary>
148
+
149
+ Three sources, none of them available all the time:
150
+
151
+ | Source | Gives | Available |
152
+ |---|---|---|
153
+ | `rate_limit_event` | utilisation, reset | only while a Fleet agent streams |
154
+ | The runtime's `/usage` data | every window, the plan | pulled once at the end of a turn |
155
+ | A refused request in any transcript | the wall and its reset | whenever it happened, to any session |
156
+
157
+ Nothing here ever spends a token to find out: a synthetic request would consume the
158
+ window it claims to measure. That has consequences the bar is explicit about.
159
+
160
+ Utilisation only refreshes while an agent is running, so after an idle stretch the
161
+ cluster dims and stamps itself `as of 09:38` rather than presenting an old reading as
162
+ current. Nothing measured yet shows nothing at all, never `0%`. On an API key, Bedrock
163
+ or Vertex, where plan limits do not apply, the cluster is absent entirely.
164
+
165
+ A refusal is the one signal that survives Fleet being idle, because it is written into
166
+ the transcript of whichever session hit it. It expires by itself at its reset time, and
167
+ a refusal with no reset time is discarded rather than shown: a warning that cannot
168
+ clear itself is worse than none. Being blocked never disables anything. Fleet says what
169
+ it knows and leaves the decision where it belongs.
170
+
171
+ </details>
172
+
124
173
  ### Old sessions can be put away
125
174
 
126
175
  Every transcript Claude has ever written is a row, so a machine that has been
package/fleet.js CHANGED
@@ -189,6 +189,9 @@ function readTranscript(file) {
189
189
  events: [],
190
190
  cwd: null,
191
191
  gitBranch: null,
192
+ // The newest 429 this transcript recorded, if any. Plan limits are account-wide, so
193
+ // a rejection here describes the whole machine, not only this session.
194
+ rateLimit: null,
192
195
  }
193
196
 
194
197
  for (const line of text.split('\n')) {
@@ -209,6 +212,16 @@ function readTranscript(file) {
209
212
  if (!data.firstTs) data.firstTs = d.timestamp
210
213
  data.lastTs = d.timestamp
211
214
  }
215
+ // Claude records the quota it hit only when a request was actually refused. There is
216
+ // no routine utilisation record to read here, so this is a wall, never a gauge.
217
+ if (d.quotaLimits && d.quotaLimits.status === 'rejected') {
218
+ data.rateLimit = {
219
+ at: d.timestamp ? Date.parse(d.timestamp) || null : null,
220
+ rateLimitType: d.quotaLimits.rateLimitType || null,
221
+ resetsAt: d.quotaLimits.resetsAt || null,
222
+ reason: d.quotaLimits.overageDisabledReason || null,
223
+ }
224
+ }
212
225
  if (d.timestamp) {
213
226
  const at = Date.parse(d.timestamp)
214
227
  if (at) for (const event of classify(d)) {
@@ -349,6 +362,7 @@ function collect() {
349
362
  const index = transcriptIndex()
350
363
  const now = Date.now()
351
364
  const sessions = []
365
+ let rateLimit = null
352
366
 
353
367
  // The registry describes running processes, not saved conversations: Claude
354
368
  // removes registrations on exit. Join it with durable transcripts so handoff
@@ -373,6 +387,10 @@ function collect() {
373
387
  const file = meta.sessionId ? index.get(meta.sessionId) : null
374
388
  const t = file ? readTranscript(file) : null
375
389
 
390
+ // Whoever hit the wall, the wall is the account's. The freshest rejection on this
391
+ // machine describes the whole fleet, including sessions Fleet only watches.
392
+ if (t && t.rateLimit && (!rateLimit || (t.rateLimit.at || 0) > (rateLimit.at || 0))) rateLimit = t.rateLimit
393
+
376
394
  const lastActivity = Math.max(
377
395
  meta.updatedAt || 0,
378
396
  t && t.lastTs ? Date.parse(t.lastTs) : 0
@@ -436,7 +454,7 @@ function collect() {
436
454
  const counts = { busy: 0, idle: 0, stale: 0, dead: 0 }
437
455
  for (const s of sessions) counts[s.state] = (counts[s.state] || 0) + 1
438
456
 
439
- return { generatedAt: now, counts, total: sessions.length, sessions }
457
+ return { generatedAt: now, counts, total: sessions.length, sessions, rateLimit }
440
458
  }
441
459
 
442
460
  // Look up a transcript by session id without going through the process registry.
package/managed.js CHANGED
@@ -9,6 +9,7 @@ const { askReason, normaliseMode, MODES, DEFAULT_MODE } = require('./permissions
9
9
  const { stateDir } = require('./paths')
10
10
  const { getTeam, compile } = require('./teams')
11
11
  const { TeamStore } = require('./team-store')
12
+ const { UsageTracker } = require('./usage')
12
13
  const tasks = require('./tasks')
13
14
  const worktrees = require('./worktree')
14
15
 
@@ -63,6 +64,10 @@ class ManagedSessions extends EventEmitter {
63
64
  this.closed = false
64
65
  this.models = null
65
66
  this.saveTimer = null
67
+ // Plan windows belong to the account, so one tracker serves every session and
68
+ // outlives all of them. It is deliberately not persisted: a utilisation figure from
69
+ // before a restart describes a window that has probably already turned over.
70
+ this.usage = new UsageTracker()
66
71
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
67
72
  this.file = path.join(directory, 'sessions.json')
68
73
  this.attachmentsDir = path.join(directory, 'attachments')
@@ -353,6 +358,8 @@ class ManagedSessions extends EventEmitter {
353
358
  }
354
359
  event(s,run,event) {
355
360
  if (event.session_id && !event.parent_tool_use_id) s.sessionId=event.session_id
361
+ // Account-wide, so it is recorded whoever emitted it, sub-agent turns included.
362
+ if (event.type==='rate_limit_event') this.usage.recordEvent(event.rate_limit_info)
356
363
  if (s.taskBoard && event.parent_tool_use_id) {
357
364
  const d=s.taskBoard.delegations.find(d=>d.id===event.parent_tool_use_id)
358
365
  if (d && event.type==='assistant') {
@@ -418,9 +425,21 @@ class ManagedSessions extends EventEmitter {
418
425
  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()})
419
426
  s.costUsd=(s.costUsd||0)+(event.total_cost_usd||0)
420
427
  s.messages=s.messages.slice(-MAX_MESSAGES)
428
+ this.captureUsage(run)
421
429
  }
422
430
  this.changed(s)
423
431
  }
432
+ // The end of a turn is the one moment a query is both idle and still open, so it is
433
+ // where the every-window reading is taken. Fire and forget: the push event already
434
+ // carries the window that matters, this only fills in the rest. The method is marked
435
+ // experimental upstream and may simply not be there, which is not an error.
436
+ captureUsage(run) {
437
+ if (!run || run.usagePulled) return
438
+ const pull=run.query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET
439
+ if (typeof pull!=='function') return
440
+ run.usagePulled=true
441
+ Promise.resolve(pull.call(run.query)).then(response=>this.usage.recordUsage(response)).catch(()=>{})
442
+ }
424
443
  // A tool call becomes its own conversation entry so the UI can render it as a command block.
425
444
  toolStarted(s,run,block) {
426
445
  if (!block.id || run.tools?.has(block.id)) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sergeychuvayev/claude-fleet",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A local control room for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
@@ -39,7 +39,8 @@
39
39
  "update.js",
40
40
  "worktree.js",
41
41
  "team-store.js",
42
- "tasks.js"
42
+ "tasks.js",
43
+ "usage.js"
43
44
  ],
44
45
  "scripts": {
45
46
  "start": "node bin/claude-fleet.js start",
package/public/app.js CHANGED
@@ -1,5 +1,17 @@
1
1
  'use strict'
2
+ // An isolated scope, the way teams.js and blocks.js already do it. Everything in
3
+ // here used to sit in the page's shared global scope alongside control.js, blocks.js
4
+ // and ask.js, where a name chosen twice in two files is a SyntaxError that takes the
5
+ // whole dashboard down before a line of it runs. What the other files legitimately
6
+ // need is window.Fleet, published partway down; nothing else escapes.
7
+ // The leading semicolon is load-bearing: without it the directive above and the
8
+ // parenthesis below join into a call on the string "use strict".
9
+ ;(() => {
2
10
  const $ = id => document.getElementById(id)
11
+ // The one thing the core borrows back from control.js, which owns the control
12
+ // token every write is authenticated with. Resolved per call, not at load: app.js
13
+ // runs first, and every caller here is an event handler that fires long after.
14
+ const api = (...args) => window.FleetControl.api(...args)
3
15
  const STATES = ['busy', 'idle', 'stale', 'dead']
4
16
  const LABELS = { busy: 'Working', idle: 'Waiting', stale: 'Stale', dead: 'Offline' }
5
17
  const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))
@@ -12,6 +24,15 @@ const age = timestamp => {
12
24
  const secs = Math.max(0, Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000))
13
25
  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
26
  }
27
+ // Everything Fleet remembers between visits goes through here. It is declared before
28
+ // its first reader on purpose: a `const` used above its declaration throws a
29
+ // ReferenceError that the callers' own try/catch would quietly absorb, leaving every
30
+ // remembered preference silently reset on each load.
31
+ const store = {
32
+ get(key) { try { return localStorage.getItem(key) } catch { return null } },
33
+ set(key, value) { try { localStorage.setItem(key, value) } catch {} },
34
+ clear(key) { try { localStorage.removeItem(key) } catch {} },
35
+ }
15
36
  let snapshot = null, filter = 'all', selected = null, pending = false, toastTimer
16
37
  // A delegation row nested under a team session. Keyed on the delegation id, never on
17
38
  // its position or status, so a sub-agent finishing does not move the operator's focus.
@@ -23,7 +44,10 @@ const formatModel = m => m ? String(m).replace('claude-', '') : 'Model pending'
23
44
  // A child row shares its data-session with the parent that owns it, so the session
24
45
  // id alone is not a unique row key: folding in data-delegation is what tells a
25
46
  // 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
47
+ // The fold header shares no data-session with anything, so it needs a key of its own:
48
+ // without one it reads as unkeyed and the poll two seconds after a click would drop
49
+ // the focus ring off the control the operator just used.
50
+ const rowFocusKey = b => b.dataset.foldSession ? `fold::${b.dataset.foldSession}` : b.dataset.delegation ? `${b.dataset.session}::${b.dataset.delegation}` : b.dataset.session || b.dataset.filter
27
51
  function update(id, html) {
28
52
  const el = $(id)
29
53
  if (!el || el.innerHTML === html) return
@@ -95,10 +119,44 @@ function childRowHtml(s, d) {
95
119
  const label = DELEGATION_LABEL[d.status] || d.status
96
120
  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
121
  }
122
+ // A session's sub-agents fold away behind a header of their own. An initiative can sit
123
+ // between two sessions with twenty delegation rows wedged in the gap, which buries the
124
+ // list those rows belong to. Folding is per session and remembered, so putting one
125
+ // team's sub-agents away leaves every other team's on screen.
126
+ const COLLAPSED_KEY = 'fleet:children-collapsed'
127
+ let collapsedChildren = new Set()
128
+ try { collapsedChildren = new Set(JSON.parse(store.get(COLLAPSED_KEY) || '[]')) } catch { collapsedChildren = new Set() }
129
+ const childGroupId = k => `children-${String(k).replace(/[^\w-]/g, '_')}`
130
+ function setChildrenCollapsed(k, collapsed) {
131
+ if (collapsed) collapsedChildren.add(k)
132
+ else collapsedChildren.delete(k)
133
+ // Sessions come and go; only the folds still worth honouring are worth storing.
134
+ store.set(COLLAPSED_KEY, JSON.stringify([...collapsedChildren].slice(-200)))
135
+ }
136
+ // The header names the group and carries the fold. It is a sibling of the session
137
+ // row rather than part of it because that row is itself a button, and a button
138
+ // cannot hold another one.
139
+ function childToggleHtml(s, collapsed) {
140
+ const all = s.delegations || []
141
+ const running = all.filter(d => d.status === 'running').length
142
+ const failed = all.filter(d => d.status === 'failed').length
143
+ const counts = [`${all.length} sub-agent${all.length === 1 ? '' : 's'}`, running ? `${running} working` : '', failed ? `${failed} failed` : ''].filter(Boolean).join(' · ')
144
+ return `<button class="session-children-toggle${collapsed ? ' is-collapsed' : ''}" data-fold-session="${esc(key(s))}" aria-expanded="${!collapsed}" aria-controls="${esc(childGroupId(key(s)))}"><span class="children-chevron" aria-hidden="true">›</span><span class="children-count">⑂ ${esc(counts)}</span></button>`
145
+ }
98
146
  // An initiative that runs long enough accumulates delegations without bound; the
99
147
  // row list stays a list, not a scrollbar of its own, by showing only the tail.
100
148
  const CHILD_ROW_LIMIT = 20
101
149
  const childRowsHtml = s => {
150
+ const all = s.delegations || []
151
+ if (!all.length) return ''
152
+ // A fold never hides the one row the list reads as selected: clicking the header
153
+ // hands the selection back to the session first, and a group still holding the
154
+ // selected delegation by any other route draws open however it was left.
155
+ const collapsed = collapsedChildren.has(key(s)) && !all.some(d => d.id === selectedChild)
156
+ const group = `<div class="session-children" id="${esc(childGroupId(key(s)))}"${collapsed ? ' hidden' : ''}>${collapsed ? '' : childTailHtml(s)}</div>`
157
+ return childToggleHtml(s, collapsed) + group
158
+ }
159
+ const childTailHtml = s => {
102
160
  const all = s.delegations || []
103
161
  const recent = all.length > CHILD_ROW_LIMIT ? all.slice(-CHILD_ROW_LIMIT) : all
104
162
  // The parent row has already handed its aria-pressed to session-ancestor, so a
@@ -115,6 +173,123 @@ const childRowsHtml = s => {
115
173
  return (earlier ? `<div class="session-child-more">+${earlier} earlier</div>` : '') + shown.map(d => childRowHtml(s, d)).join('')
116
174
  }
117
175
 
176
+ // ── Account usage ─────────────────────────────────────────────────────────────
177
+ // The plan windows belong to the account, not to a session: every Claude process on
178
+ // this machine draws on them, including the terminal sessions Fleet only watches. The
179
+ // bar says so, and it uses the same thresholds and colours as context pressure so
180
+ // "nearly full" reads the same way everywhere.
181
+ const WINDOW_WORD = { five_hour:'five-hour', seven_day:'weekly', seven_day_opus:'weekly Opus', seven_day_sonnet:'weekly Sonnet', seven_day_oauth_apps:'weekly apps' }
182
+ const BLOCK_REASON = {
183
+ org_spend_cap_reached:'organisation spend cap reached', out_of_credits:'out of credits',
184
+ overage_not_provisioned:'no overage configured', org_level_disabled:'overage off for this organisation',
185
+ member_level_disabled:'overage off for this member', no_limits_configured:'no overage limits set',
186
+ fetch_error:'usage lookup failed',
187
+ }
188
+ const clockAt = ms => new Date(ms).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})
189
+ const untilReset = (ms, now) => {
190
+ const left = ms - now
191
+ if (left <= 0) return 'any moment'
192
+ return left < 3600000 ? `${Math.max(1, Math.round(left / 60000))}m` : `${Math.floor(left / 3600000)}h ${Math.round(left % 3600000 / 60000)}m`
193
+ }
194
+ const blockReason = r => BLOCK_REASON[r] || (r ? String(r).replace(/_/g, ' ') : null)
195
+ const windowWord = name => WINDOW_WORD[name] || String(name || '').replace(/_/g, ' ')
196
+ function usageHtml(usage, now) {
197
+ // Unknown is not zero. With nothing measured, and on an API key or a third-party
198
+ // provider where plan limits do not apply at all, the cluster is simply absent.
199
+ if (!usage || !usage.available || !usage.known) return ''
200
+ const blocked = usage.blocked
201
+ if (blocked) return `<span class="usage-blocked hot" title="A request was refused by this window. Every Claude session on this machine is affected until it resets.">⊘ Rate limited · ${esc(windowWord(blocked.rateLimitType))} window · resets ${clockAt(blocked.resetsAt)} (${untilReset(blocked.resetsAt, now)})${blockReason(blocked.reason) ? ` · ${esc(blockReason(blocked.reason))}` : ''}</span>`
202
+ const binding = usage.windows.find(w => w.name === usage.binding) || usage.windows[0]
203
+ if (!binding) return ''
204
+ const cls = heat(binding.utilization)
205
+ // Past 90% the countdown is the decision and the percentage is trivia, so they swap.
206
+ const critical = binding.utilization >= 90
207
+ const reset = binding.resetsAt ? (critical ? `${untilReset(binding.resetsAt, now)} left` : `resets ${clockAt(binding.resetsAt)}`) : ''
208
+ const detail = [
209
+ usage.subscription ? `Plan: ${usage.subscription}.` : '',
210
+ 'Account-wide, including the terminal sessions Fleet only watches.',
211
+ usage.observedAt ? `Last read ${clockAt(usage.observedAt)}.` : '',
212
+ ].filter(Boolean).join(' ')
213
+ // One bar, on whichever window is closest to stopping the fleet. The rest are bare
214
+ // numbers: a second bar would just be a second thing to look at.
215
+ const others = usage.windows.filter(w => w !== binding)
216
+ .map(w => `<span class="usage-other ${heat(w.utilization)}"><b>${esc(w.label)}</b> ${w.utilization}%</span>`).join('')
217
+ return `<span class="usage-window ${cls}${usage.stale ? ' is-stale' : ''}" title="${esc(detail)}"><b>${esc(binding.label)}</b>${critical ? `<em>${reset}</em><span class="usage-pct">${binding.utilization}%</span>` : `<span class="mini-bar"><i class="${cls}" style="width:${binding.utilization}%"></i></span><span class="usage-pct">${binding.utilization}%</span>${reset ? `<small>${reset}</small>` : ''}`}</span>${others}${usage.stale && usage.observedAt ? `<small class="usage-stale" title="Utilisation only updates while a Fleet agent is running.">as of ${clockAt(usage.observedAt)}</small>` : ''}`
218
+ }
219
+ function renderStatusbar(usage, sessions) {
220
+ const now = Date.now()
221
+ update('status-usage', usageHtml(usage, now))
222
+ const managed = sessions.filter(s => s.managed && !s.archived)
223
+ const waiting = managed.filter(s => s.managedStatus === 'approval').length
224
+ const working = sessions.filter(s => !s.archived && isWorkingRow(s)).length
225
+ const spend = managed.reduce((sum, s) => sum + (s.costUsd || 0), 0)
226
+ update('status-fleet', [
227
+ `<span>${working} working</span>`,
228
+ waiting ? `<span class="warn">${waiting} needs you</span>` : '',
229
+ money(spend) ? `<span title="Reported for Fleet’s own conversations only. Terminal sessions are not included, and a Claude subscription is not billed for this.">${esc(money(spend))}</span>` : '',
230
+ ].filter(Boolean).join('<span class="status-sep" aria-hidden="true">·</span>'))
231
+ // The same wall, said where it changes a decision: in the dialog that starts agents.
232
+ const banner = $('launch-blocked')
233
+ if (banner) {
234
+ banner.hidden = !usage?.blocked
235
+ if (usage?.blocked) banner.textContent = `Rate limited until ${clockAt(usage.blocked.resetsAt)} (${untilReset(usage.blocked.resetsAt, now)}). A new agent will not get past its first message until this window resets.`
236
+ }
237
+ }
238
+
239
+ // ── The session row ─────────────────────────────────────────────────────────
240
+ // One row is four lines: what the agent is and how it is doing, what it was asked,
241
+ // where it is working, and the story of its latest turn. Each line is built by its
242
+ // own function, so changing the look of one does not mean reading the other three.
243
+
244
+ // Line one, after the status badge: the qualifiers that say this row is not an
245
+ // ordinary foreground session you started yourself.
246
+ function rowTags(s, spawnCounts) {
247
+ const spawned = spawnCounts.get(s.pid)
248
+ return [
249
+ spawned ? `<span class="spawn-badge" title="Running ${spawned} background session(s)">⑂ ${spawned}</span>` : '',
250
+ 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>` : '',
251
+ s.archived ? '<span class="archived-tag" title="Archived. Hidden from your fleet, still on disk and still resumable.">archived</span>' : '',
252
+ ].join('')
253
+ }
254
+ // A team session says which team is running it and how far through its tasks it is.
255
+ function initiativeTag(s) {
256
+ if (s.kind !== 'initiative') return ''
257
+ const p = s.taskProgress
258
+ const progress = p ? ` · ${p.verified}/${p.total} verified${p.blocked ? ` · ${p.blocked} need attention` : ''}` : ''
259
+ return `<span class="initiative-tag">Initiative · ${esc(s.teamName || s.teamId || 'Team')}${progress}</span>`
260
+ }
261
+ // Where the work is happening, and what it has cost.
262
+ function rowMeta(s) {
263
+ const project = s.cwd?.split('/').filter(Boolean).pop() || 'No project'
264
+ const spend = money(s.costUsd)
265
+ return `<span class="session-meta"><span>${esc(project)}</span><span class="branch">⑂ ${esc(s.branch || 'No branch')}</span>${s.links?.length ? `<span>↗ ${s.links.length}</span>` : ''}${spend ? `<span class="session-cost" title="What this conversation has cost so far">${esc(spend)}</span>` : ''}</span>`
266
+ }
267
+ // The right-hand column: how full the context window is, and how long ago the
268
+ // agent last did anything.
269
+ function contextCell(s) {
270
+ const p = percent(s)
271
+ return `<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>`
272
+ }
273
+ function sessionRowHtml(s, spawnCounts) {
274
+ // A row holding the selected sub-agent is an ancestor of the selection, not the
275
+ // selection itself, so it gives up aria-pressed to the child row below it.
276
+ const childSelectedHere = !!selectedChild && (s.delegations || []).some(d => d.id === selectedChild)
277
+ const name = (s.managed ? 'FLEET · ' : '') + (s.name || s.shortId || 'Unnamed session')
278
+ const top = `<span class="session-top">${hasUnseen(s) ? '<span class="unseen" aria-label="New output"></span>' : ''}${status(s)}<span class="session-name">${esc(name)}</span>${rowTags(s, spawnCounts)}</span>`
279
+ const body = `${top}${initiativeTag(s)}<span class="session-title">${esc(s.title || s.lastPrompt || 'Untitled session')}</span>${rowMeta(s)}${turnRow(s)}`
280
+ 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>${body}</span>${contextCell(s)}</button>${childRowsHtml(s)}`
281
+ }
282
+ const filterBarHtml = (counts, foreground, background, archived) => [
283
+ ['all', 'All sessions', foreground],
284
+ ...STATES.map(s => [s, LABELS[s], counts[s] || 0]),
285
+ ...(background ? [['background', 'Background', background]] : []),
286
+ ...(archived ? [['archived', 'Archived', archived]] : []),
287
+ ].map(([s, label, n]) => `<button class="filter" data-filter="${s}" aria-pressed="${filter === s}">${label}<span>${n}</span></button>`).join('')
288
+ const emptyListHtml = total =>
289
+ filter === 'background' ? 'No background sessions right now.'
290
+ : total ? 'No sessions match your filters.<br>Try another search or select All sessions.'
291
+ : 'Your fleet is quiet.<br>Start a Claude Code session and it will appear here automatically.'
292
+
118
293
  function render() {
119
294
  if (!snapshot) return
120
295
  const {sessions, total} = snapshot
@@ -137,13 +312,12 @@ function render() {
137
312
  const shown = pool.filter(s => filter === 'all' || filter === 'background' || filter === 'archived' || s.state === filter)
138
313
  if (!shown.some(s => key(s) === selected)) selected = shown[0] ? key(shown[0]) : null
139
314
  $('shown-count').textContent = shown.length
140
- update('filters', [['all','All sessions',foreground.length],...STATES.map(s => [s,LABELS[s],(visibleCounts[s] || 0)]),...(background.length ? [['background','Background',background.length]] : []),...(archived.length ? [['archived','Archived',archived.length]] : [])].map(([s,label,n]) => `<button class="filter" data-filter="${s}" aria-pressed="${filter === s}">${label}<span>${n}</span></button>`).join(''))
315
+ renderStatusbar(snapshot.usage, live)
316
+ update('filters', filterBarHtml(visibleCounts, foreground.length, background.length, archived.length))
141
317
  renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
142
- update('session-list', shown.length ? shown.map(s => {
143
- const p = percent(s)
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)}`
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>`)
318
+ update('session-list', shown.length
319
+ ? shown.map(s => sessionRowHtml(s, spawnCounts)).join('')
320
+ : `<div class="empty">${emptyListHtml(total)}</div>`)
147
321
  const current = shown.find(s => key(s) === selected)
148
322
  if (current) markSeen(key(current), current.lastActivity)
149
323
  // A delegation belongs to whichever session is actually current; switching sessions,
@@ -161,10 +335,10 @@ function render() {
161
335
  renderChildDetail(current, childId)
162
336
  // A sub-agent is not addressable: clearing the control panel drops its composer
163
337
  // and conversation from the DOM entirely, not merely hiding them.
164
- if (typeof selectControl === 'function') selectControl(null)
338
+ window.FleetControl?.selectControl(null)
165
339
  } else {
166
340
  renderDetail(current)
167
- if (typeof selectControl === 'function') selectControl(current)
341
+ window.FleetControl?.selectControl(current)
168
342
  }
169
343
  syncDetails()
170
344
  }
@@ -317,11 +491,32 @@ document.addEventListener('click', event => {
317
491
  if (event.target === $(openModalId) || event.target.closest('[data-close-modal]')) closeModal()
318
492
  })
319
493
 
320
- function toast(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').hidden = true, 3000) }
494
+ // Reached from every error path, including ones that fire before the page has
495
+ // finished wiring itself up, so a missing toast element costs the message and
496
+ // nothing more.
497
+ function toast(message) {
498
+ const box = $('toast')
499
+ if (!box) return
500
+ box.textContent = message
501
+ box.hidden = false
502
+ clearTimeout(toastTimer)
503
+ toastTimer = setTimeout(() => { box.hidden = true }, 3000)
504
+ }
321
505
  document.addEventListener('click', async event => {
322
506
  const b = event.target.closest('button')
323
507
  if (!b) return
324
508
  if (b.dataset.filter) { filter = b.dataset.filter; render() }
509
+ if (b.dataset.foldSession) {
510
+ const k = b.dataset.foldSession, collapsed = !collapsedChildren.has(k)
511
+ setChildrenCollapsed(k, collapsed)
512
+ // Folding the group away takes the selection back up to the session that owns it,
513
+ // so the list never hides the one row reading as selected.
514
+ if (collapsed && selectedChild && snapshot?.sessions.find(s => key(s) === k)?.delegations?.some(d => d.id === selectedChild)) {
515
+ selected = k
516
+ selectedChild = null
517
+ }
518
+ return render()
519
+ }
325
520
  if (b.dataset.delegation) {
326
521
  selected = b.dataset.session; selectedChild = b.dataset.delegation; render()
327
522
  if (matchMedia('(max-width:720px)').matches) $('detail').scrollIntoView({behavior:'instant',block:'start'})
@@ -342,9 +537,23 @@ document.addEventListener('click', async event => {
342
537
  catch { toast('Clipboard unavailable. The command is shown below.'); const code = document.createElement('pre'); code.className = 'response'; code.textContent = s.resumeCmd; $('detail').append(code) }
343
538
  }
344
539
  })
540
+ const setBusy = busy => { if ($('refresh')) $('refresh').disabled = busy }
541
+ const setConnection = (text, state) => {
542
+ if ($('connection')) $('connection').textContent = text
543
+ if ($('connection-dot')) $('connection-dot').className = `dot ${state}`
544
+ }
545
+ const showError = message => {
546
+ const box = $('error')
547
+ if (!box) return
548
+ box.hidden = !message
549
+ if (message) box.textContent = message
550
+ }
345
551
  async function tick() {
346
552
  if (pending) return
347
- pending = true; $('refresh').disabled = true
553
+ // The poll runs on a timer and in the catch below, so it never assumes the
554
+ // chrome it writes into is mounted.
555
+ pending = true
556
+ setBusy(true)
348
557
  try {
349
558
  const r = await fetch('/api/sessions', {cache:'no-store',signal:AbortSignal.timeout(8000)})
350
559
  if (!r.ok) throw new Error(`HTTP ${r.status}`)
@@ -359,18 +568,55 @@ async function tick() {
359
568
  }
360
569
  // Other panels (the ask results) re-read the snapshot to refresh "open now" state.
361
570
  document.dispatchEvent(new CustomEvent('fleet-snapshot'))
362
- $('connection').textContent = 'Live connection'; $('connection-dot').className = 'dot busy'
363
- $('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
364
- $('error').hidden = !data.storageError
365
- if (data.storageError) $('error').textContent = data.storageError
571
+ setConnection('Live connection', 'busy')
572
+ if ($('updated')) $('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
573
+ showError(data.storageError)
366
574
  } catch {
367
- $('connection').textContent = 'Disconnected'; $('connection-dot').className = 'dot stale'
368
- $('error').textContent = snapshot ? 'Connection lost. Showing the last successful snapshot; retrying automatically.' : 'Unable to connect to the local server. Retrying automatically.'
369
- $('error').hidden = false
575
+ setConnection('Disconnected', 'stale')
576
+ showError(snapshot ? 'Connection lost. Showing the last successful snapshot; retrying automatically.' : 'Unable to connect to the local server. Retrying automatically.')
370
577
  if (!snapshot) { update('session-list','<div class="empty">Waiting for the local server…</div>'); renderDetail(null) }
371
- } finally { pending = false; $('refresh').disabled = false }
578
+ } finally { pending = false; setBusy(false) }
579
+ }
580
+ // ── What the rest of the page may use ───────────────────────────────────────
581
+ // Published here, before the boot sequence below runs, and not as the value the
582
+ // wrapper returns: control.js, teams.js and ask.js destructure this the moment they
583
+ // load, so an element missing from the wiring that follows must not take the whole
584
+ // page down with it. State is handed out through functions rather than as live
585
+ // bindings, so a caller cannot take a copy of `snapshot` and read a stale one after
586
+ // the next poll.
587
+ window.Fleet = {
588
+ // DOM and formatting helpers the other files share.
589
+ $, esc, update, key, age, tokens, money, status, store,
590
+ // The account's plan windows, rendered from a usage reading.
591
+ usageHtml,
592
+ // Current state.
593
+ snapshot: () => snapshot,
594
+ // Actions. Each one renders, so a caller never has to remember to.
595
+ render,
596
+ setSnapshot(next) { snapshot = next; render() },
597
+ setFilter(next) { filter = next; render() },
598
+ select(sessionKey, delegationId = null) { selected = sessionKey; selectedChild = delegationId; render() },
599
+ setChildrenCollapsed(sessionKey, collapsed) { setChildrenCollapsed(sessionKey, collapsed); render() },
600
+ // What loadChildDetail's completion does: hand over the delegation the session
601
+ // route returned, then redraw. Select the delegation first; a detail handed over
602
+ // for one that is not selected has nowhere to be drawn.
603
+ setChildDetail(delegationId, detail, error = null) {
604
+ childDetail = detail
605
+ childDetailFor = delegationId
606
+ childDetailError = error
607
+ render()
608
+ },
609
+ toast,
610
+ // Modals.
611
+ modalIsOpen, openModal, closeModal,
612
+ // Layout, formerly window.FleetLayout.
613
+ watchConversation, syncDetails,
614
+ // Control-panel rendering, for tests and for the update poller.
615
+ renderUpdate: (...args) => window.FleetControl.renderUpdate(...args),
372
616
  }
373
- $('refresh').addEventListener('click',tick)
617
+
618
+ // ── Boot ────────────────────────────────────────────────────────────────────
619
+ $('refresh')?.addEventListener('click',tick)
374
620
  tick()
375
621
  setInterval(() => { if (!document.hidden) tick() },2000)
376
622
  document.addEventListener('visibilitychange', () => { if (!document.hidden) tick() })
@@ -379,11 +625,6 @@ document.addEventListener('visibilitychange', () => { if (!document.hidden) tick
379
625
  // inspector, and a resizable console. Both are remembered per browser; a storage
380
626
  // failure (private window, blocked site data) only costs the remembered size.
381
627
  const LAYOUT = { split: 'fleet:split', height: 'fleet:conv-height' }
382
- const store = {
383
- get(key) { try { return localStorage.getItem(key) } catch { return null } },
384
- set(key, value) { try { localStorage.setItem(key, value) } catch {} },
385
- clear(key) { try { localStorage.removeItem(key) } catch {} },
386
- }
387
628
  const SPLIT_DEFAULT = 58, LIST_MIN = 300, DETAIL_MIN = 380
388
629
 
389
630
  function applySplit(percent, { save = true } = {}) {
@@ -462,7 +703,6 @@ function watchConversation(element) {
462
703
  conversationObserver.disconnect()
463
704
  conversationObserver.observe(element)
464
705
  }
465
- window.FleetLayout = { watchConversation }
466
706
  initSplitter()
467
707
 
468
708
  // The session details sit behind a disclosure: with a console on screen the terminal
@@ -482,4 +722,4 @@ $('details-toggle')?.addEventListener('click', () => {
482
722
  store.set(DETAILS_KEY, open ? '1' : '0')
483
723
  syncDetails()
484
724
  })
485
- window.FleetLayout.syncDetails = syncDetails
725
+ })()