@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 +49 -0
- package/fleet.js +19 -1
- package/managed.js +19 -0
- package/package.json +3 -2
- package/public/app.js +267 -27
- package/public/ask.js +22 -6
- package/public/blocks.js +4 -1
- package/public/control.js +29 -7
- package/public/index.html +3 -1
- package/public/styles.css +2545 -475
- package/public/teams.js +8 -5
- package/server.js +5 -1
- package/usage.js +111 -0
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.
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
338
|
+
window.FleetControl?.selectControl(null)
|
|
165
339
|
} else {
|
|
166
340
|
renderDetail(current)
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
$('updated').textContent = `Updated ${new Date(data.generatedAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}`
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
725
|
+
})()
|