@sergeychuvayev/claude-fleet 0.7.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 CHANGED
@@ -121,6 +121,55 @@ session's own process and never appear as separate rows at all.
121
121
 
122
122
  </details>
123
123
 
124
+ ### The status bar watches the account, not the session
125
+
126
+ Context pressure is per conversation. The five-hour and weekly plan windows are not:
127
+ every Claude process on the machine draws on the same ones, including the terminal
128
+ sessions Fleet only watches. They get one line along the bottom of the window.
129
+
130
+ ```
131
+ 5h ▇▇▇▇▇▇░░░░ 62% resets 13:28 · week 31% · opus wk 48%
132
+ ```
133
+
134
+ Only the window closest to stopping you gets a bar; the rest stay bare numbers. The
135
+ same 75% amber and 90% red as context, so "nearly full" reads the same way everywhere.
136
+ Past 90% the countdown replaces the bar, because by then the question is when it clears,
137
+ not how full it is.
138
+
139
+ When a request is actually refused, the bar turns into the refusal, and the new-agent
140
+ dialog says so before you write a prompt:
141
+
142
+ ```
143
+ ⊘ Rate limited · five-hour window · resets 11:42 (35m) · organisation spend cap reached
144
+ ```
145
+
146
+ <details>
147
+ <summary><b>Where the numbers come from, and when they are missing</b></summary>
148
+
149
+ Three sources, none of them available all the time:
150
+
151
+ | Source | Gives | Available |
152
+ |---|---|---|
153
+ | `rate_limit_event` | utilisation, reset | only while a Fleet agent streams |
154
+ | The runtime's `/usage` data | every window, the plan | pulled once at the end of a turn |
155
+ | A refused request in any transcript | the wall and its reset | whenever it happened, to any session |
156
+
157
+ Nothing here ever spends a token to find out: a synthetic request would consume the
158
+ window it claims to measure. That has consequences the bar is explicit about.
159
+
160
+ Utilisation only refreshes while an agent is running, so after an idle stretch the
161
+ cluster dims and stamps itself `as of 09:38` rather than presenting an old reading as
162
+ current. Nothing measured yet shows nothing at all, never `0%`. On an API key, Bedrock
163
+ or Vertex, where plan limits do not apply, the cluster is absent entirely.
164
+
165
+ A refusal is the one signal that survives Fleet being idle, because it is written into
166
+ the transcript of whichever session hit it. It expires by itself at its reset time, and
167
+ a refusal with no reset time is discarded rather than shown: a warning that cannot
168
+ clear itself is worse than none. Being blocked never disables anything. Fleet says what
169
+ it knows and leaves the decision where it belongs.
170
+
171
+ </details>
172
+
124
173
  ### Old sessions can be put away
125
174
 
126
175
  Every transcript Claude has ever written is a row, so a machine that has been
package/fleet.js CHANGED
@@ -189,6 +189,9 @@ function readTranscript(file) {
189
189
  events: [],
190
190
  cwd: null,
191
191
  gitBranch: null,
192
+ // The newest 429 this transcript recorded, if any. Plan limits are account-wide, so
193
+ // a rejection here describes the whole machine, not only this session.
194
+ rateLimit: null,
192
195
  }
193
196
 
194
197
  for (const line of text.split('\n')) {
@@ -209,6 +212,16 @@ function readTranscript(file) {
209
212
  if (!data.firstTs) data.firstTs = d.timestamp
210
213
  data.lastTs = d.timestamp
211
214
  }
215
+ // Claude records the quota it hit only when a request was actually refused. There is
216
+ // no routine utilisation record to read here, so this is a wall, never a gauge.
217
+ if (d.quotaLimits && d.quotaLimits.status === 'rejected') {
218
+ data.rateLimit = {
219
+ at: d.timestamp ? Date.parse(d.timestamp) || null : null,
220
+ rateLimitType: d.quotaLimits.rateLimitType || null,
221
+ resetsAt: d.quotaLimits.resetsAt || null,
222
+ reason: d.quotaLimits.overageDisabledReason || null,
223
+ }
224
+ }
212
225
  if (d.timestamp) {
213
226
  const at = Date.parse(d.timestamp)
214
227
  if (at) for (const event of classify(d)) {
@@ -349,6 +362,7 @@ function collect() {
349
362
  const index = transcriptIndex()
350
363
  const now = Date.now()
351
364
  const sessions = []
365
+ let rateLimit = null
352
366
 
353
367
  // The registry describes running processes, not saved conversations: Claude
354
368
  // removes registrations on exit. Join it with durable transcripts so handoff
@@ -373,6 +387,10 @@ function collect() {
373
387
  const file = meta.sessionId ? index.get(meta.sessionId) : null
374
388
  const t = file ? readTranscript(file) : null
375
389
 
390
+ // Whoever hit the wall, the wall is the account's. The freshest rejection on this
391
+ // machine describes the whole fleet, including sessions Fleet only watches.
392
+ if (t && t.rateLimit && (!rateLimit || (t.rateLimit.at || 0) > (rateLimit.at || 0))) rateLimit = t.rateLimit
393
+
376
394
  const lastActivity = Math.max(
377
395
  meta.updatedAt || 0,
378
396
  t && t.lastTs ? Date.parse(t.lastTs) : 0
@@ -436,7 +454,7 @@ function collect() {
436
454
  const counts = { busy: 0, idle: 0, stale: 0, dead: 0 }
437
455
  for (const s of sessions) counts[s.state] = (counts[s.state] || 0) + 1
438
456
 
439
- return { generatedAt: now, counts, total: sessions.length, sessions }
457
+ return { generatedAt: now, counts, total: sessions.length, sessions, rateLimit }
440
458
  }
441
459
 
442
460
  // Look up a transcript by session id without going through the process registry.
package/managed.js CHANGED
@@ -9,6 +9,7 @@ const { askReason, normaliseMode, MODES, DEFAULT_MODE } = require('./permissions
9
9
  const { stateDir } = require('./paths')
10
10
  const { getTeam, compile } = require('./teams')
11
11
  const { TeamStore } = require('./team-store')
12
+ const { UsageTracker } = require('./usage')
12
13
  const tasks = require('./tasks')
13
14
  const worktrees = require('./worktree')
14
15
 
@@ -63,6 +64,10 @@ class ManagedSessions extends EventEmitter {
63
64
  this.closed = false
64
65
  this.models = null
65
66
  this.saveTimer = null
67
+ // Plan windows belong to the account, so one tracker serves every session and
68
+ // outlives all of them. It is deliberately not persisted: a utilisation figure from
69
+ // before a restart describes a window that has probably already turned over.
70
+ this.usage = new UsageTracker()
66
71
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
67
72
  this.file = path.join(directory, 'sessions.json')
68
73
  this.attachmentsDir = path.join(directory, 'attachments')
@@ -353,6 +358,8 @@ class ManagedSessions extends EventEmitter {
353
358
  }
354
359
  event(s,run,event) {
355
360
  if (event.session_id && !event.parent_tool_use_id) s.sessionId=event.session_id
361
+ // Account-wide, so it is recorded whoever emitted it, sub-agent turns included.
362
+ if (event.type==='rate_limit_event') this.usage.recordEvent(event.rate_limit_info)
356
363
  if (s.taskBoard && event.parent_tool_use_id) {
357
364
  const d=s.taskBoard.delegations.find(d=>d.id===event.parent_tool_use_id)
358
365
  if (d && event.type==='assistant') {
@@ -418,9 +425,21 @@ class ManagedSessions extends EventEmitter {
418
425
  else if (event.result && !s.messages.some(m=>m.role==='assistant' && m.text===event.result.slice(-24000))) s.messages.push({id:randomUUID(),role:'assistant',text:event.result.slice(-24000),at:Date.now()})
419
426
  s.costUsd=(s.costUsd||0)+(event.total_cost_usd||0)
420
427
  s.messages=s.messages.slice(-MAX_MESSAGES)
428
+ this.captureUsage(run)
421
429
  }
422
430
  this.changed(s)
423
431
  }
432
+ // The end of a turn is the one moment a query is both idle and still open, so it is
433
+ // where the every-window reading is taken. Fire and forget: the push event already
434
+ // carries the window that matters, this only fills in the rest. The method is marked
435
+ // experimental upstream and may simply not be there, which is not an error.
436
+ captureUsage(run) {
437
+ if (!run || run.usagePulled) return
438
+ const pull=run.query?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET
439
+ if (typeof pull!=='function') return
440
+ run.usagePulled=true
441
+ Promise.resolve(pull.call(run.query)).then(response=>this.usage.recordUsage(response)).catch(()=>{})
442
+ }
424
443
  // A tool call becomes its own conversation entry so the UI can render it as a command block.
425
444
  toolStarted(s,run,block) {
426
445
  if (!block.id || run.tools?.has(block.id)) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sergeychuvayev/claude-fleet",
3
- "version": "0.7.0",
3
+ "version": "0.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 => {
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="connection"><span id="connection-dot" class="dot busy"></span><span id="connection">Connecting</span><span class="local">LOCAL CONTROL ROOM</span></div><div class="refresh-info"><button id="update-pill" class="button update-pill" hidden></button><span id="updated">Waiting for first snapshot</span><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>
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…&#10;&#10;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
- .app-version{align-self:center;margin-left:8px;color:var(--muted);font-size:10px;letter-spacing:.04em;font-variant-numeric:tabular-nums}
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. */
@@ -522,3 +525,38 @@ body[data-modal]{overflow:hidden}
522
525
  .child-step-detail summary{cursor:pointer;color:var(--muted);padding:4px 0}
523
526
  .child-step-detail pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px}
524
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
- return {...snap,sessions,counts,total:sessions.length-archived,archived,archiveRule:archive.rule,storageError}
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/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}