@sergeychuvayev/claude-fleet 0.6.0 → 0.8.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 +73 -2
- package/fleet.js +19 -1
- package/managed.js +57 -6
- package/package.json +3 -2
- package/public/app.js +72 -2
- package/public/index.html +3 -1
- package/public/styles.css +45 -1
- package/server.js +5 -1
- package/tasks.js +14 -3
- package/teams.js +14 -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
|
|
@@ -412,7 +461,11 @@ In **New agent**, choose **Software delivery** to give a brief to a Manager back
|
|
|
412
461
|
Product, Developer, Reviewer and QA roles. Choose **Customize team…** to save your own
|
|
413
462
|
team: rename/add/remove roles, write their instructions, select a Claude model per role,
|
|
414
463
|
and choose allowed tools. Built-in teams are copied; existing custom teams can be edited.
|
|
415
|
-
|
|
464
|
+
Choose **Quick task** for small, clearly scoped changes: a Sonnet manager, one developer,
|
|
465
|
+
and one independent QA verifier, with two implementation attempts and a $3 usage cap.
|
|
466
|
+
**Software delivery** keeps the thorough review-and-QA workflow. **No team · single agent**
|
|
467
|
+
avoids orchestration entirely when you just need one agent. Existing initiatives keep
|
|
468
|
+
their original team snapshot. The original Bug fix preset remains available.
|
|
416
469
|
|
|
417
470
|
One role is the manager and at least one separate role is a required verifier. A third
|
|
418
471
|
role owns the work. Fleet adds delegation and operator-question tools to the manager;
|
|
@@ -431,7 +484,7 @@ not a guarantee that their evaluation is correct or that later work cannot regre
|
|
|
431
484
|
|
|
432
485
|
Delegations run sequentially in the shared initiative worktree. After an interruption,
|
|
433
486
|
the saved task board survives and unfinished delegations are marked interrupted. Message
|
|
434
|
-
the Manager to resume.
|
|
487
|
+
the Manager to resume. Software delivery starts with three implementation attempts per task and
|
|
435
488
|
$10 in reported SDK usage; **Adjust limits** changes them explicitly while idle. The SDK
|
|
436
489
|
budget is an execution cutoff, not a billing guarantee: usage is reported at turn end,
|
|
437
490
|
and a killed runtime may not report its final spend. Each turn also has a 100-turn SDK
|
|
@@ -442,3 +495,21 @@ snapshots live with the initiative in `sessions.json`. This version supports mod
|
|
|
442
495
|
available through the Claude Agent SDK, up to eight roles per team, and 100 tasks per
|
|
443
496
|
initiative. It finishes at a local branch ready for review; it does not automatically
|
|
444
497
|
publish or merge changes.
|
|
498
|
+
|
|
499
|
+
### Inspect individual agents
|
|
500
|
+
|
|
501
|
+
Select a subagent row beneath a managed initiative to inspect its assignment, actual
|
|
502
|
+
model, status, elapsed time, attempt, tool steps, and report. Expand **Input and output**
|
|
503
|
+
to see a tool's recorded payload. The inspector is read-only; send direction and answer
|
|
504
|
+
approvals through the manager. Tool history keeps the most recent 200 steps with bounded
|
|
505
|
+
inputs and outputs. Older runs may have no recorded steps or usage.
|
|
506
|
+
|
|
507
|
+
The inspector shows reported input/output and cache tokens, or SDK progress totals when
|
|
508
|
+
only those are available. Repeated assistant events do not count usage twice. Per-agent
|
|
509
|
+
cost appears only when the SDK explicitly reports it; missing data is not shown as zero.
|
|
510
|
+
Token totals describe recorded usage across messages, not current context size.
|
|
511
|
+
|
|
512
|
+
Task-board reads return compact metadata, so repeatedly checking task state does not
|
|
513
|
+
re-inject all assignments, tool output and reports into the manager's context. The manager
|
|
514
|
+
can request a specific delegation's full assignment/report using the task tool's
|
|
515
|
+
`inspect` action with `delegationId`. Verification gates and saved evidence are unchanged.
|
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,24 +358,38 @@ 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') {
|
|
359
366
|
const content=event.message.content || []
|
|
360
367
|
d.activity=content.filter(b=>b.type==='tool_use').map(b=>b.name).join(', ') || d.activity
|
|
361
368
|
d.model=event.message.model || d.model
|
|
369
|
+
this.delegationUsage(d,run,event.message)
|
|
362
370
|
const output=content.filter(b=>b.type==='text').map(b=>b.text).join('\n')
|
|
363
371
|
if (output) d.output=output.slice(0,24000)
|
|
364
372
|
// The one place a sub-agent's own tool calls are kept at all: as steps on its
|
|
365
373
|
// delegation, never as messages (every branch above stays guarded by
|
|
366
|
-
// `!event.parent_tool_use_id`).
|
|
374
|
+
// `!event.parent_tool_use_id`). Inputs and results are bounded for inspection.
|
|
367
375
|
for (const block of content) if (block.type==='tool_use') this.stepStarted(d,block)
|
|
368
376
|
}
|
|
377
|
+
if (d && event.type==='result' && Number.isFinite(event.total_cost_usd) && event.total_cost_usd>=0) d.costUsd=event.total_cost_usd
|
|
369
378
|
if (d && event.type==='user') {
|
|
370
379
|
for (const block of event.message?.content || []) if (block.type==='tool_result') this.stepFinished(d,block)
|
|
371
380
|
}
|
|
372
381
|
}
|
|
373
|
-
if (event.type
|
|
382
|
+
if (s.taskBoard && event.type==='system' && ['task_progress','task_notification'].includes(event.subtype)) {
|
|
383
|
+
const d=s.taskBoard.delegations.find(d=>d.id===event.tool_use_id)
|
|
384
|
+
if (d && event.usage) {
|
|
385
|
+
d.runtimeUsage ||= {}
|
|
386
|
+
for (const key of ['total_tokens','tool_uses','duration_ms']) {
|
|
387
|
+
const value=event.usage[key]
|
|
388
|
+
if (Number.isFinite(value) && value>=0) d.runtimeUsage[key]=Math.max(d.runtimeUsage[key] || 0,value)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (event.type === 'system' && event.subtype === 'init' && !event.parent_tool_use_id) { s.model=event.model; s.status='running' }
|
|
374
393
|
if (event.type === 'stream_event' && !event.parent_tool_use_id) {
|
|
375
394
|
if (event.event.type === 'message_start') { run.assistant=null; run.streamText='' }
|
|
376
395
|
if (event.event.delta?.type === 'text_delta') {
|
|
@@ -398,17 +417,29 @@ class ManagedSessions extends EventEmitter {
|
|
|
398
417
|
if (event.type === 'user' && !event.parent_tool_use_id) {
|
|
399
418
|
for (const block of event.message?.content || []) if (block.type==='tool_result') this.toolFinished(s,run,block)
|
|
400
419
|
}
|
|
401
|
-
if (event.type === 'tool_progress') s.currentTool=event.tool_name
|
|
420
|
+
if (event.type === 'tool_progress' && !event.parent_tool_use_id) s.currentTool=event.tool_name
|
|
402
421
|
if (s.taskBoard && event.type==='system' && event.subtype==='task_notification' && event.tool_use_id && event.status!=='completed') tasks.finish(s,event.tool_use_id,event.summary,true)
|
|
403
|
-
if (event.type === 'result') {
|
|
422
|
+
if (event.type === 'result' && !event.parent_tool_use_id) {
|
|
404
423
|
run.result=true
|
|
405
424
|
if (event.is_error) { s.status='error'; s.error=event.errors?.join('\n') || event.result || 'Claude could not finish this turn.' }
|
|
406
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()})
|
|
407
426
|
s.costUsd=(s.costUsd||0)+(event.total_cost_usd||0)
|
|
408
427
|
s.messages=s.messages.slice(-MAX_MESSAGES)
|
|
428
|
+
this.captureUsage(run)
|
|
409
429
|
}
|
|
410
430
|
this.changed(s)
|
|
411
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
|
+
}
|
|
412
443
|
// A tool call becomes its own conversation entry so the UI can render it as a command block.
|
|
413
444
|
toolStarted(s,run,block) {
|
|
414
445
|
if (!block.id || run.tools?.has(block.id)) return
|
|
@@ -441,13 +472,13 @@ class ManagedSessions extends EventEmitter {
|
|
|
441
472
|
entry.result = block.is_error || !QUIET_RESULT.has(entry.tool) ? result.slice(0,MAX_TOOL_RESULT) : null
|
|
442
473
|
}
|
|
443
474
|
// A sub-agent's tool call becomes a step on its delegation rather than a conversation
|
|
444
|
-
// entry:
|
|
475
|
+
// entry: bounded input/output, status and timing, so the operator can see what happened
|
|
445
476
|
// without the console ever rendering it.
|
|
446
477
|
stepStarted(d,block) {
|
|
447
478
|
if (!block.id) return
|
|
448
479
|
d.steps ||= []
|
|
449
480
|
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})
|
|
481
|
+
d.steps.push({id:block.id,tool:block.name || 'Tool',target:toolTarget(block.name,block.input),input:clampInput(block.input),result:null,status:'running',at:Date.now(),ms:null})
|
|
451
482
|
if (d.steps.length > MAX_DELEGATION_STEPS) { d.steps=d.steps.slice(-MAX_DELEGATION_STEPS); d.stepsTruncated=true }
|
|
452
483
|
}
|
|
453
484
|
stepFinished(d,block) {
|
|
@@ -455,6 +486,26 @@ class ManagedSessions extends EventEmitter {
|
|
|
455
486
|
if (!step || step.status!=='running') return
|
|
456
487
|
step.status=block.is_error ? 'error' : 'done'
|
|
457
488
|
step.ms=Date.now()-step.at
|
|
489
|
+
const result=resultText(block.content)
|
|
490
|
+
step.result=result.slice(0,MAX_TOOL_RESULT)
|
|
491
|
+
step.truncated=result.length>MAX_TOOL_RESULT
|
|
492
|
+
}
|
|
493
|
+
// The SDK can emit multiple blocks for the same assistant message. Count each
|
|
494
|
+
// usage counter only once, accepting later updates without double-counting.
|
|
495
|
+
delegationUsage(d,run,message) {
|
|
496
|
+
if (!message.id || !message.usage) return
|
|
497
|
+
run.delegationUsage ||= new Map()
|
|
498
|
+
const key=JSON.stringify([d.id,message.id])
|
|
499
|
+
const previous=run.delegationUsage.get(key) || {}
|
|
500
|
+
d.usage ||= {}
|
|
501
|
+
for (const field of ['input_tokens','output_tokens','cache_read_input_tokens','cache_creation_input_tokens']) {
|
|
502
|
+
const value=message.usage[field]
|
|
503
|
+
if (!Number.isFinite(value) || value<0) continue
|
|
504
|
+
const next=Math.max(previous[field] || 0,value)
|
|
505
|
+
d.usage[field]=(d.usage[field] || 0)+next-(previous[field] || 0)
|
|
506
|
+
previous[field]=next
|
|
507
|
+
}
|
|
508
|
+
run.delegationUsage.set(key,previous)
|
|
458
509
|
}
|
|
459
510
|
setLimits(id,body) {
|
|
460
511
|
const s=this.get(id)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sergeychuvayev/claude-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
@@ -115,6 +115,68 @@ const childRowsHtml = s => {
|
|
|
115
115
|
return (earlier ? `<div class="session-child-more">+${earlier} earlier</div>` : '') + shown.map(d => childRowHtml(s, d)).join('')
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// ── Account usage ─────────────────────────────────────────────────────────────
|
|
119
|
+
// The plan windows belong to the account, not to a session: every Claude process on
|
|
120
|
+
// this machine draws on them, including the terminal sessions Fleet only watches. The
|
|
121
|
+
// bar says so, and it uses the same thresholds and colours as context pressure so
|
|
122
|
+
// "nearly full" reads the same way everywhere.
|
|
123
|
+
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' }
|
|
124
|
+
const BLOCK_REASON = {
|
|
125
|
+
org_spend_cap_reached:'organisation spend cap reached', out_of_credits:'out of credits',
|
|
126
|
+
overage_not_provisioned:'no overage configured', org_level_disabled:'overage off for this organisation',
|
|
127
|
+
member_level_disabled:'overage off for this member', no_limits_configured:'no overage limits set',
|
|
128
|
+
fetch_error:'usage lookup failed',
|
|
129
|
+
}
|
|
130
|
+
const clockAt = ms => new Date(ms).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})
|
|
131
|
+
const untilReset = (ms, now) => {
|
|
132
|
+
const left = ms - now
|
|
133
|
+
if (left <= 0) return 'any moment'
|
|
134
|
+
return left < 3600000 ? `${Math.max(1, Math.round(left / 60000))}m` : `${Math.floor(left / 3600000)}h ${Math.round(left % 3600000 / 60000)}m`
|
|
135
|
+
}
|
|
136
|
+
const blockReason = r => BLOCK_REASON[r] || (r ? String(r).replace(/_/g, ' ') : null)
|
|
137
|
+
const windowWord = name => WINDOW_WORD[name] || String(name || '').replace(/_/g, ' ')
|
|
138
|
+
function usageHtml(usage, now) {
|
|
139
|
+
// Unknown is not zero. With nothing measured, and on an API key or a third-party
|
|
140
|
+
// provider where plan limits do not apply at all, the cluster is simply absent.
|
|
141
|
+
if (!usage || !usage.available || !usage.known) return ''
|
|
142
|
+
const blocked = usage.blocked
|
|
143
|
+
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>`
|
|
144
|
+
const binding = usage.windows.find(w => w.name === usage.binding) || usage.windows[0]
|
|
145
|
+
if (!binding) return ''
|
|
146
|
+
const cls = heat(binding.utilization)
|
|
147
|
+
// Past 90% the countdown is the decision and the percentage is trivia, so they swap.
|
|
148
|
+
const critical = binding.utilization >= 90
|
|
149
|
+
const reset = binding.resetsAt ? (critical ? `${untilReset(binding.resetsAt, now)} left` : `resets ${clockAt(binding.resetsAt)}`) : ''
|
|
150
|
+
const detail = [
|
|
151
|
+
usage.subscription ? `Plan: ${usage.subscription}.` : '',
|
|
152
|
+
'Account-wide, including the terminal sessions Fleet only watches.',
|
|
153
|
+
usage.observedAt ? `Last read ${clockAt(usage.observedAt)}.` : '',
|
|
154
|
+
].filter(Boolean).join(' ')
|
|
155
|
+
// One bar, on whichever window is closest to stopping the fleet. The rest are bare
|
|
156
|
+
// numbers: a second bar would just be a second thing to look at.
|
|
157
|
+
const others = usage.windows.filter(w => w !== binding)
|
|
158
|
+
.map(w => `<span class="usage-other ${heat(w.utilization)}"><b>${esc(w.label)}</b> ${w.utilization}%</span>`).join('')
|
|
159
|
+
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>` : ''}`
|
|
160
|
+
}
|
|
161
|
+
function renderStatusbar(usage, sessions) {
|
|
162
|
+
const now = Date.now()
|
|
163
|
+
update('status-usage', usageHtml(usage, now))
|
|
164
|
+
const managed = sessions.filter(s => s.managed && !s.archived)
|
|
165
|
+
const waiting = managed.filter(s => s.managedStatus === 'approval').length
|
|
166
|
+
const working = sessions.filter(s => !s.archived && isWorkingRow(s)).length
|
|
167
|
+
const spend = managed.reduce((sum, s) => sum + (s.costUsd || 0), 0)
|
|
168
|
+
update('status-fleet', [
|
|
169
|
+
`<span>${working} working</span>`,
|
|
170
|
+
waiting ? `<span class="warn">${waiting} needs you</span>` : '',
|
|
171
|
+
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>` : '',
|
|
172
|
+
].filter(Boolean).join('<span class="status-sep" aria-hidden="true">·</span>'))
|
|
173
|
+
// The same wall, said where it changes a decision: in the dialog that starts agents.
|
|
174
|
+
const banner = $('launch-blocked')
|
|
175
|
+
if (banner) {
|
|
176
|
+
banner.hidden = !usage?.blocked
|
|
177
|
+
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.`
|
|
178
|
+
}
|
|
179
|
+
}
|
|
118
180
|
function render() {
|
|
119
181
|
if (!snapshot) return
|
|
120
182
|
const {sessions, total} = snapshot
|
|
@@ -137,6 +199,7 @@ function render() {
|
|
|
137
199
|
const shown = pool.filter(s => filter === 'all' || filter === 'background' || filter === 'archived' || s.state === filter)
|
|
138
200
|
if (!shown.some(s => key(s) === selected)) selected = shown[0] ? key(shown[0]) : null
|
|
139
201
|
$('shown-count').textContent = shown.length
|
|
202
|
+
renderStatusbar(snapshot.usage, live)
|
|
140
203
|
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(''))
|
|
141
204
|
renderArchiveBar(live.filter(s => s.state === 'dead'), archived.length)
|
|
142
205
|
update('session-list', shown.length ? shown.map(s => {
|
|
@@ -234,12 +297,19 @@ function renderChildDetail(s, delegationId) {
|
|
|
234
297
|
const cls = DELEGATION_BADGE[state] || ''
|
|
235
298
|
const label = DELEGATION_LABEL[state] || state
|
|
236
299
|
const steps = full?.steps || []
|
|
300
|
+
const openedSteps=new Set([...document.querySelectorAll('[data-child-step][open]')].map(el=>el.dataset.childStep))
|
|
301
|
+
const focusedStep=document.activeElement?.closest('[data-child-step]')?.dataset.childStep
|
|
302
|
+
const usage=full?.usage
|
|
303
|
+
const usageText=usage ? `${tokens(usage.input_tokens || 0)} input · ${tokens(usage.output_tokens || 0)} output · ${tokens(usage.cache_read_input_tokens || 0)} cache read · ${tokens(usage.cache_creation_input_tokens || 0)} cache write` : full?.runtimeUsage?.total_tokens != null ? `${tokens(full.runtimeUsage.total_tokens)} tokens reported` : 'Token usage not reported'
|
|
304
|
+
const duration=full?.startedAt ? elapsed((full.finishedAt || Date.now())-full.startedAt) : 'Duration unavailable'
|
|
305
|
+
|
|
237
306
|
// Selecting a child tears down the console, so an approval sitting on the owning
|
|
238
307
|
// session would otherwise wait in total silence. A notice only: nothing here can
|
|
239
308
|
// answer it, so it just points the operator back to the row that can.
|
|
240
309
|
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>`)
|
|
310
|
+
const stepsHtml = steps.length ? `<ol class="child-steps">${steps.map(step => `<li class="child-step" data-status="${esc(step.status)}"><span class="child-step-tool">${esc(step.tool)}</span>${step.target ? `<span class="child-step-target">${esc(step.target)}</span>` : ''}<span class="child-step-state">${esc(STEP_LABEL[step.status] || step.status)}</span><span class="child-step-time">${step.ms != null ? elapsed(step.ms) : step.status === 'running' ? 'running…' : ''}</span>${step.input != null || step.result != null ? `<details class="child-step-detail" data-child-step="${esc(step.id)}" ${openedSteps.has(step.id) ? 'open':''}><summary>Input and output</summary><h4>Input</h4><pre>${esc(step.input == null ? 'Not recorded' : typeof step.input === 'string' ? step.input : JSON.stringify(step.input,null,2))}</pre><h4>Output${step.truncated ? ' · truncated':''}</h4><pre>${esc(step.result ?? 'No result reported yet.')}</pre></details>`:''}</li>`).join('')}</ol>` : `<p class="note">${full ? 'No tool steps recorded.' : 'Loading steps…'}</p>`
|
|
311
|
+
update('detail-content', `<div class="detail-top"><span class="eyebrow">SUB-AGENT · READ ONLY</span><span class="badge ${cls}"><span class="dot"></span>${esc(label)}</span></div>${approvalNotice}<h2>⑂ ${esc(compact.role)}</h2><div class="detail-name">${esc(formatModel(full?.model || compact.model))} · ${esc(duration)}${full?.attempt ? ` · attempt ${full.attempt}` : ''}</div><p class="note">${esc(usageText)}. ${full?.costUsd != null ? `Reported cost: ${esc(money(full.costUsd) || '$0.00')}` : 'Per-agent cost not reported'}.</p><section class="detail-section"><h3>Mandate</h3><div class="response">${esc(full ? (full.prompt || 'No mandate recorded.') : 'Loading…')}</div></section><section class="detail-section"><h3>Steps${full?.stepsTruncated ? ' <span>Showing the most recent 200</span>' : ''}</h3>${stepsHtml}</section><section class="detail-section"><h3>Report to the manager</h3><div class="response ${full?.report ? '' : 'missing'}">${esc(full ? (full.report || 'Waiting for this agent’s report.') : 'Loading…')}</div></section>${childDetailError ? `<p class="note">${esc(childDetailError)}</p>` : ''}<p class="note">A sub-agent is not addressable on its own. This is a read-only report back to the manager.</p>`)
|
|
312
|
+
if(focusedStep) document.querySelector(`[data-child-step="${CSS.escape(focusedStep)}"]>summary`)?.focus({preventScroll:true})
|
|
243
313
|
}
|
|
244
314
|
// The list payload only ever carries id/role/model/status for a delegation; its steps,
|
|
245
315
|
// mandate and report live on the session detail route, fetched independently of the
|
package/public/index.html
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>Fleet — Claude Code control room</title><link rel="manifest" href="/manifest.webmanifest"><link rel="icon" href="/icons/fleet-192.png" type="image/png"><link rel="apple-touch-icon" href="/icons/fleet-192.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Fleet"><link rel="stylesheet" href="/theme.css"><link rel="stylesheet" href="/styles.css"><script src="/vendor/libs.js" defer></script><script src="/app.js" defer></script><script src="/blocks.js" defer></script><script src="/control.js" defer></script><script src="/teams.js" defer></script><script src="/ask.js" defer></script></head>
|
|
3
|
-
<body><header class="topbar"><a class="brand" href="/" aria-label="Fleet home"><span class="brandmark">✳</span> fleet <span class="brand-sub">/ CLAUDE CODE</span></a><span class="app-version" id="app-version" title="The version of Fleet this server is running"></span><div class="
|
|
3
|
+
<body><header class="topbar"><a class="brand" href="/" aria-label="Fleet home"><span class="brandmark">✳</span> fleet <span class="brand-sub">/ CLAUDE CODE</span></a><span class="app-version" id="app-version" title="The version of Fleet this server is running"></span><div class="refresh-info"><button id="update-pill" class="button update-pill" hidden></button><button id="refresh" class="button">↻ Refresh</button><button id="ask-sessions" class="button" aria-expanded="false" aria-controls="ask-backdrop" title="Ask a question across every session on this machine">⌕ Ask <kbd id="ask-shortcut">⌘K</kbd></button><button id="new-session" class="button resume" aria-expanded="false" aria-controls="launch-backdrop">+ New agent</button></div></header>
|
|
4
4
|
<main>
|
|
5
5
|
<div id="error" class="error" role="status" hidden></div>
|
|
6
6
|
<section class="workspace" aria-label="Sessions"><div class="sessions-pane"><div class="section-heading"><h2>Sessions <span id="shown-count">0</span></h2></div><div class="filters" id="filters" aria-label="Filter sessions by status"></div><div class="archive-bar" id="archive-bar" role="group" aria-label="Archive" hidden></div><div class="list-head"><span>SESSION / PROJECT</span><span>CONTEXT</span></div><div id="session-list" class="session-list"><div class="empty">Loading your sessions…</div></div></div><div id="splitter" class="splitter" role="separator" aria-orientation="vertical" aria-label="Resize the session inspector" aria-valuemin="25" aria-valuemax="75" aria-valuenow="58" tabindex="0" title="Drag to resize · double-click to reset"></div><aside id="detail" class="detail" aria-label="Session details"><section id="control-panel" aria-label="Agent controls"></section><button type="button" id="details-toggle" class="details-toggle" aria-expanded="true" aria-controls="detail-content" hidden><span class="details-chevron" aria-hidden="true">›</span>Session details</button><div id="detail-content"></div></aside></section>
|
|
7
7
|
</main>
|
|
8
|
+
<footer class="statusbar" id="statusbar"><div class="status-usage" id="status-usage"></div><div class="status-fleet" id="status-fleet"></div><div class="status-conn"><span id="connection-dot" class="dot busy"></span><span id="connection">Connecting</span><span id="updated">Waiting for first snapshot</span><span class="local">LOCAL CONTROL ROOM</span></div></footer>
|
|
8
9
|
<div class="modal-backdrop" id="ask-backdrop" hidden>
|
|
9
10
|
<section class="modal modal-ask" role="dialog" aria-modal="true" aria-labelledby="ask-title">
|
|
10
11
|
<header class="modal-head">
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
<section class="modal modal-launch launch-panel" role="dialog" aria-modal="true" aria-labelledby="launch-title">
|
|
32
33
|
<header class="modal-head"><div class="modal-heading"><span class="modal-spark" aria-hidden="true">✳</span><div><span class="modal-eyebrow">A FRESH PAIR OF HANDS</span><h2 id="launch-title">Give your next task a home.</h2><p>A little context. A clear task. Off you go.</p></div></div><button type="button" id="close-launch" class="modal-close" data-close-modal aria-label="Close new agent form">✕</button></header>
|
|
33
34
|
<form id="launch-form" class="modal-body">
|
|
35
|
+
<p id="launch-blocked" class="rate-warning" role="status" hidden></p>
|
|
34
36
|
<div class="launch-layout">
|
|
35
37
|
<div class="launch-task"><label for="launch-prompt">What are we working on?</label><textarea id="launch-prompt" name="prompt" rows="8" placeholder="There’s something I’d love your help with… Describe the task, what a good result looks like, and anything your agent should know." required maxlength="16000"></textarea><p class="note launch-task-note">Big ideas, small fixes. Every task starts here.</p></div>
|
|
36
38
|
<div class="launch-fields">
|
package/public/styles.css
CHANGED
|
@@ -463,7 +463,10 @@ body[data-modal]{overflow:hidden}
|
|
|
463
463
|
.delegation-mandate .block-prose,.delegation-report .block-prose{padding:12px 0 0}
|
|
464
464
|
.delegation-mandate .block-plain,.delegation-report .block-plain{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
465
465
|
|
|
466
|
-
.
|
|
466
|
+
/* The version belongs to the wordmark. With connection state moved to the status bar
|
|
467
|
+
it is the only thing left between the brand and the buttons, so it is pinned here
|
|
468
|
+
rather than left to drift into the middle of the bar. */
|
|
469
|
+
.app-version{align-self:center;margin-left:8px;margin-right:auto;color:var(--muted);font-size:10px;letter-spacing:.04em;font-variant-numeric:tabular-nums}
|
|
467
470
|
.session-cost{font-variant-numeric:tabular-nums}
|
|
468
471
|
|
|
469
472
|
/* Configurable teams share the launch dialog and the Warp console palette. */
|
|
@@ -516,3 +519,44 @@ body[data-modal]{overflow:hidden}
|
|
|
516
519
|
/* Selecting a child hides the console, so a pending approval on the owning session
|
|
517
520
|
would otherwise go unnoticed; this is the one visible sign it is still waiting. */
|
|
518
521
|
.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}
|
|
522
|
+
|
|
523
|
+
/* Drill into a tool without expanding every payload in the agent inspector. */
|
|
524
|
+
.child-step-detail{grid-column:1/-1;min-width:0}
|
|
525
|
+
.child-step-detail summary{cursor:pointer;color:var(--muted);padding:4px 0}
|
|
526
|
+
.child-step-detail pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px}
|
|
527
|
+
.child-step-detail h4{margin:10px 0 4px;font-size:12px}
|
|
528
|
+
|
|
529
|
+
/* ── Status bar ────────────────────────────────────────────────────────────────
|
|
530
|
+
Ambient telemetry at the bottom, actions at the top. The plan windows belong to
|
|
531
|
+
the account rather than to any one session, so they are drawn once here and never
|
|
532
|
+
on a row: every Claude process on this machine spends the same five hours. */
|
|
533
|
+
.statusbar{flex:none;display:flex;align-items:center;gap:14px;height:26px;padding:0 14px;border-top:1px solid var(--line);background:color-mix(in oklab,var(--w-bg) 96%,var(--w-fg));font:10.5px/1 var(--mono);color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden}
|
|
534
|
+
.status-usage{display:flex;align-items:center;gap:12px;min-width:0;overflow:hidden}
|
|
535
|
+
.status-fleet{display:flex;align-items:center;gap:7px;margin-left:auto;flex:none}
|
|
536
|
+
.status-conn{display:flex;align-items:center;gap:8px;flex:none;border-left:1px solid var(--line);padding-left:14px}
|
|
537
|
+
.status-conn .local{border:0;margin:0;padding:0;font-size:9px}
|
|
538
|
+
.status-sep{color:var(--line)}
|
|
539
|
+
.usage-window{display:flex;align-items:center;gap:7px}
|
|
540
|
+
.usage-window b,.usage-other b{font-weight:600;letter-spacing:.3px;color:var(--text)}
|
|
541
|
+
.usage-window.warn b,.usage-window.hot b{color:currentColor}
|
|
542
|
+
/* The countdown, shown instead of the bar once the window is nearly gone. */
|
|
543
|
+
.usage-window em{font-style:normal;font-weight:600}
|
|
544
|
+
.usage-window small,.usage-stale{color:var(--faint);font-size:10px}
|
|
545
|
+
.status-usage .mini-bar{display:block;width:58px;height:4px;margin:0;flex:none}
|
|
546
|
+
.usage-pct{color:var(--text)}
|
|
547
|
+
.usage-window.warn .usage-pct,.usage-window.hot .usage-pct{color:currentColor}
|
|
548
|
+
.usage-other{display:flex;align-items:center;gap:5px;color:var(--faint)}
|
|
549
|
+
.usage-other b{font-weight:500;color:var(--muted)}
|
|
550
|
+
/* A reading only refreshes while an agent runs. An idle hour must not look live, and
|
|
551
|
+
every window in the cluster shares the one timestamp, so they dim together. */
|
|
552
|
+
.status-usage:has(.is-stale){opacity:.55}
|
|
553
|
+
.usage-blocked{display:flex;align-items:center;gap:7px;font-weight:600}
|
|
554
|
+
/* The same wall, repeated where it changes a decision rather than only informing one. */
|
|
555
|
+
.rate-warning{font-size:12px;line-height:1.6;color:var(--stale);border:1px solid color-mix(in oklab,var(--stale) 45%,var(--line));background:color-mix(in oklab,var(--stale) 9%,var(--panel));border-radius:8px;padding:11px 14px;margin:0 32px 14px}
|
|
556
|
+
@media(max-width:720px){.rate-warning{margin:0 20px 12px}}
|
|
557
|
+
/* The form scrolls inside the dialog and was already within a few pixels of pushing its
|
|
558
|
+
own submit button out of sight. Anything added above the fields, this warning
|
|
559
|
+
included, would have hidden it, so the action bar now holds the bottom edge. */
|
|
560
|
+
.modal-launch .launch-footer{position:sticky;bottom:0;z-index:1}
|
|
561
|
+
@media(max-width:900px){.statusbar .local,.usage-other{display:none}}
|
|
562
|
+
@media(max-width:720px){.statusbar{height:auto;flex-wrap:wrap;gap:8px;padding:8px 14px;white-space:normal}.status-fleet{margin-left:0}.status-conn{border:0;padding-left:0}}
|
package/server.js
CHANGED
|
@@ -92,7 +92,11 @@ function createApp({manager = new ManagedSessions({externalSessions:()=>collect(
|
|
|
92
92
|
if(s.archived) archived++
|
|
93
93
|
else counts[s.state]++
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// The transcript rejection is merged into the account view rather than shipped
|
|
96
|
+
// beside it: the dashboard should never have to reconcile two rate-limit stories.
|
|
97
|
+
const {rateLimit,...rest}=snap
|
|
98
|
+
const usage=manager.usage ? manager.usage.snapshot({rejection:rateLimit}) : null
|
|
99
|
+
return {...rest,sessions,counts,total:sessions.length-archived,archived,archiveRule:archive.rule,storageError,usage}
|
|
96
100
|
}
|
|
97
101
|
const authorized=(req)=>{
|
|
98
102
|
const supplied=req.headers['x-fleet-token']
|
package/tasks.js
CHANGED
|
@@ -6,7 +6,18 @@ function ledger(s) {return s.taskBoard ||= {tasks:[],delegations:[]}}
|
|
|
6
6
|
function taskFor(s,id) {const task=ledger(s).tasks.find(t=>t.id===id);if(!task)fail('Task not found. Read the Fleet task board.');return task}
|
|
7
7
|
function act(s,input) {
|
|
8
8
|
const board=ledger(s)
|
|
9
|
-
|
|
9
|
+
// The durable ledger also powers the inspector. Never send its full transcript
|
|
10
|
+
// back into the manager context on every list call.
|
|
11
|
+
if (input.action==='list') return {
|
|
12
|
+
tasks:board.tasks,
|
|
13
|
+
delegations:board.delegations.map(d=>({id:d.id,taskId:d.taskId,role:d.role,attempt:d.attempt,status:d.status,startedAt:d.startedAt,finishedAt:d.finishedAt})),
|
|
14
|
+
detailHint:'Use inspect with delegationId to read an assignment and report.',
|
|
15
|
+
}
|
|
16
|
+
if (input.action==='inspect') {
|
|
17
|
+
const d=board.delegations.find(d=>d.id===input.delegationId)
|
|
18
|
+
if (!d) fail('Delegation not found. Read the Fleet task board.')
|
|
19
|
+
return {id:d.id,taskId:d.taskId,role:d.role,status:d.status,prompt:d.prompt,report:d.report,output:d.report ? undefined:d.output}
|
|
20
|
+
}
|
|
10
21
|
if (input.action==='create') {
|
|
11
22
|
if (board.tasks.length>=100) fail('This initiative has reached its 100-task limit.')
|
|
12
23
|
const owner=input.owner,team=s.teamSnapshot
|
|
@@ -91,8 +102,8 @@ function progress(s) {
|
|
|
91
102
|
async function sdkServer(s,changed) {
|
|
92
103
|
const {createSdkMcpServer,tool}=await import('@anthropic-ai/claude-agent-sdk')
|
|
93
104
|
const {z}=require('zod/v4')
|
|
94
|
-
return createSdkMcpServer({name:'fleet',version:'1.0.0',tools:[tool('tasks','Read
|
|
95
|
-
action:z.enum(['list','create','block']),title:z.string().optional(),owner:z.string().optional(),criteria:z.array(z.string()).optional(),dependencies:z.array(z.string()).optional(),taskId:z.string().optional(),reason:z.string().optional(),
|
|
105
|
+
return createSdkMcpServer({name:'fleet',version:'1.0.0',tools:[tool('tasks','Read a compact task board; inspect delegation assignments/reports by delegationId; create scoped tasks with criteria and dependencies; record blockers. Fleet records verification from actual agent reports.',{
|
|
106
|
+
action:z.enum(['list','inspect','create','block']),delegationId:z.string().optional(),title:z.string().optional(),owner:z.string().optional(),criteria:z.array(z.string()).optional(),dependencies:z.array(z.string()).optional(),taskId:z.string().optional(),reason:z.string().optional(),
|
|
96
107
|
},async input=>{
|
|
97
108
|
const before=structuredClone(s.taskBoard)
|
|
98
109
|
try {const result=act(s,input);changed();return {content:[{type:'text',text:JSON.stringify(result)}]}}
|
package/teams.js
CHANGED
|
@@ -174,7 +174,7 @@ const TEAMS = {
|
|
|
174
174
|
const READ_TOOLS = ['Read','Glob','Grep','WebSearch','WebFetch']
|
|
175
175
|
TEAMS.delivery = {
|
|
176
176
|
id:'delivery', name:'Software delivery',
|
|
177
|
-
description:'
|
|
177
|
+
description:'Thorough workflow: scoped work, implementation, independent code review and QA.',
|
|
178
178
|
manager:'manager',
|
|
179
179
|
workflow:{reviewers:['reviewer','qa'],maxAttempts:3,budgetUsd:10},
|
|
180
180
|
roles:{
|
|
@@ -185,9 +185,22 @@ TEAMS.delivery = {
|
|
|
185
185
|
qa:{description:'Verifies acceptance criteria with reproducible evidence.',prompt:QA,model:'sonnet',tools:[...READ_TOOLS,'Bash']},
|
|
186
186
|
},
|
|
187
187
|
}
|
|
188
|
+
// A lighter explicit choice, using the same durable gates and snapshot mechanism.
|
|
189
|
+
TEAMS.quick = {
|
|
190
|
+
id:'quick',name:'Quick task',
|
|
191
|
+
description:'Small, clear changes: one developer and one independent verifier, with focused checks.',
|
|
192
|
+
manager:'manager',workflow:{reviewers:['qa'],maxAttempts:2,budgetUsd:3},
|
|
193
|
+
roles:{
|
|
194
|
+
manager:{...TEAMS.delivery.roles.manager,model:'sonnet',prompt:'Coordinate a small, clearly scoped task. Read only relevant instructions and files. Create one task unless the goal has independent deliverables. Give the developer file boundaries, acceptance criteria and exact checks. Use one QA verification. Avoid broad audits, speculative improvements and repeated repository exploration. Pass concise findings and test evidence between roles.'},
|
|
195
|
+
developer:{...TEAMS.delivery.roles.developer,prompt:DEVELOPER+'\nKeep investigation scoped to the acceptance criteria. Run relevant checks once after the final change; repeat only after a failure or further change.'},
|
|
196
|
+
qa:{...TEAMS.delivery.roles.qa,prompt:QA+'\nKeep verification proportional to this small task. Focus on acceptance criteria and directly affected behavior; stop once sufficient evidence exists.'},
|
|
197
|
+
},
|
|
198
|
+
}
|
|
188
199
|
const TASK_RULES = `
|
|
189
200
|
|
|
190
201
|
Fleet owns the durable task board. Use mcp__fleet__tasks to read it and create tasks before delegating.
|
|
202
|
+
List returns compact metadata; use inspect with delegationId when you need a prior assignment or report.
|
|
203
|
+
Read the board on resume and when state is uncertain, not repeatedly between every action.
|
|
191
204
|
Each task needs an owner, acceptance criteria and optional dependencies. All configured verification
|
|
192
205
|
roles must verify each deliverable. Include a line "Fleet task: <task ID>" in EVERY Agent prompt.
|
|
193
206
|
Invoke only the owner or a configured verification role. Work sequentially in this shared worktree.
|
package/usage.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Plan usage is an account fact, not a session one: the five-hour and weekly windows
|
|
3
|
+
// are shared by every Claude process on this machine, including the terminal sessions
|
|
4
|
+
// Fleet only watches. Three sources feed this, none of them available all of the time:
|
|
5
|
+
// - `rate_limit_event`, pushed by the SDK while a Fleet run is streaming
|
|
6
|
+
// - the runtime's own /usage data, pulled once at the end of a turn
|
|
7
|
+
// - a rejected `quotaLimits` record in any transcript, which is the only signal that
|
|
8
|
+
// survives Fleet being idle and the only one that says "you are blocked right now"
|
|
9
|
+
// Nothing here spends a token to find out. A synthetic request would consume the very
|
|
10
|
+
// window it claims to measure.
|
|
11
|
+
const WINDOWS=['five_hour','seven_day','seven_day_opus','seven_day_sonnet','seven_day_oauth_apps']
|
|
12
|
+
const LABELS={five_hour:'5h',seven_day:'week',seven_day_opus:'opus wk',seven_day_sonnet:'sonnet wk',seven_day_oauth_apps:'apps wk'}
|
|
13
|
+
// A live reading only arrives while a run streams. Past this age the bar says "as of",
|
|
14
|
+
// because presenting an hour-old number as current is the one thing it must never do.
|
|
15
|
+
const STALE_MS=120000
|
|
16
|
+
// Utilisation is a whole percentage. A value outside the range is discarded rather than
|
|
17
|
+
// clamped: that far off is a change of shape, not a reading worth drawing.
|
|
18
|
+
const valid=n=>typeof n==='number' && Number.isFinite(n) && n>=0 && n<=100
|
|
19
|
+
// `resetsAt` is unix seconds on the push event and an ISO string on the pull.
|
|
20
|
+
function resetTime(value) {
|
|
21
|
+
if (typeof value==='number' && Number.isFinite(value) && value>0) return Math.round(value<1e12 ? value*1000 : value)
|
|
22
|
+
if (typeof value==='string') {const parsed=Date.parse(value);return Number.isFinite(parsed) ? parsed:null}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
// A block with no reset time cannot be shown honestly and cannot expire on its own, so
|
|
26
|
+
// it is dropped: a red bar that never clears is worse than no bar.
|
|
27
|
+
const live=(block,now)=>block && block.resetsAt && block.resetsAt>now ? block:null
|
|
28
|
+
|
|
29
|
+
class UsageTracker {
|
|
30
|
+
constructor() {
|
|
31
|
+
this.windows=new Map()
|
|
32
|
+
this.observedAt=null
|
|
33
|
+
this.subscription=null
|
|
34
|
+
// null until something says either way. Unknown is not "no limits": the bar stays
|
|
35
|
+
// empty rather than reporting a zero it never measured.
|
|
36
|
+
this.available=null
|
|
37
|
+
this.blocked=null
|
|
38
|
+
}
|
|
39
|
+
// The SDK's push event carries one window at a time and is the only source that
|
|
40
|
+
// updates mid-turn.
|
|
41
|
+
recordEvent(info,now=Date.now()) {
|
|
42
|
+
if (!info || typeof info!=='object') return false
|
|
43
|
+
const type=typeof info.rateLimitType==='string' ? info.rateLimitType:null
|
|
44
|
+
let changed=false
|
|
45
|
+
if (type && valid(info.utilization)) {
|
|
46
|
+
this.windows.set(type,{utilization:info.utilization,resetsAt:resetTime(info.resetsAt)})
|
|
47
|
+
this.observedAt=now
|
|
48
|
+
this.available=true
|
|
49
|
+
changed=true
|
|
50
|
+
}
|
|
51
|
+
if (info.status==='rejected' && type) {
|
|
52
|
+
this.blocked={rateLimitType:type,resetsAt:resetTime(info.resetsAt),reason:info.overageDisabledReason || null,at:now,source:'run'}
|
|
53
|
+
changed=true
|
|
54
|
+
} else if (info.status && this.blocked) {this.blocked=null;changed=true}
|
|
55
|
+
return changed
|
|
56
|
+
}
|
|
57
|
+
// The pull at turn end: every window at once, plus the plan behind them. Experimental
|
|
58
|
+
// upstream, so absence and malformed shapes are ordinary outcomes, never errors.
|
|
59
|
+
recordUsage(response,now=Date.now()) {
|
|
60
|
+
if (!response || typeof response!=='object') return false
|
|
61
|
+
if (response.rate_limits_available===false) {
|
|
62
|
+
const had=this.available!==false
|
|
63
|
+
this.available=false;this.windows.clear();this.observedAt=now
|
|
64
|
+
return had
|
|
65
|
+
}
|
|
66
|
+
let changed=false
|
|
67
|
+
const limits=response.rate_limits
|
|
68
|
+
if (limits && typeof limits==='object') for (const name of WINDOWS) {
|
|
69
|
+
const window=limits[name]
|
|
70
|
+
if (!window || typeof window!=='object' || !valid(window.utilization)) continue
|
|
71
|
+
this.windows.set(name,{utilization:window.utilization,resetsAt:resetTime(window.resets_at)})
|
|
72
|
+
changed=true
|
|
73
|
+
}
|
|
74
|
+
if (typeof response.subscription_type==='string' && response.subscription_type!==this.subscription) {
|
|
75
|
+
this.subscription=response.subscription_type
|
|
76
|
+
changed=true
|
|
77
|
+
}
|
|
78
|
+
if (changed) {this.observedAt=now;this.available=true}
|
|
79
|
+
return changed
|
|
80
|
+
}
|
|
81
|
+
// A rejection read out of a transcript. Any session on this machine can supply it,
|
|
82
|
+
// which is what makes a block visible while Fleet itself is driving nothing.
|
|
83
|
+
static rejection(record) {
|
|
84
|
+
if (!record || typeof record!=='object') return null
|
|
85
|
+
const resetsAt=resetTime(record.resetsAt)
|
|
86
|
+
if (!resetsAt) return null
|
|
87
|
+
return {rateLimitType:record.rateLimitType || null,resetsAt,reason:record.reason || null,at:record.at || null,source:'transcript'}
|
|
88
|
+
}
|
|
89
|
+
snapshot({now=Date.now(),rejection=null}={}) {
|
|
90
|
+
// Whichever block is newer wins; both expire by themselves at their reset time.
|
|
91
|
+
const blocks=[live(this.blocked,now),live(UsageTracker.rejection(rejection),now)].filter(Boolean)
|
|
92
|
+
const blocked=blocks.sort((a,b)=>(b.at || 0)-(a.at || 0))[0] || null
|
|
93
|
+
const windows=[...this.windows.entries()]
|
|
94
|
+
.map(([name,w])=>({name,label:LABELS[name] || name,utilization:Math.round(w.utilization),resetsAt:w.resetsAt}))
|
|
95
|
+
.sort((a,b)=>WINDOWS.indexOf(a.name)-WINDOWS.indexOf(b.name))
|
|
96
|
+
// One bar on screen, drawn for whichever window is closest to stopping the fleet.
|
|
97
|
+
const binding=windows.reduce((worst,w)=>!worst || w.utilization>worst.utilization ? w:worst,null)
|
|
98
|
+
return {
|
|
99
|
+
available:this.available!==false,
|
|
100
|
+
known:windows.length>0 || !!blocked,
|
|
101
|
+
windows,
|
|
102
|
+
binding:binding ? binding.name:null,
|
|
103
|
+
subscription:this.subscription,
|
|
104
|
+
observedAt:this.observedAt,
|
|
105
|
+
stale:this.observedAt ? now-this.observedAt>STALE_MS:true,
|
|
106
|
+
blocked,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports={UsageTracker,WINDOWS,LABELS,STALE_MS}
|