@ucsandman/legcli 0.12.0 → 0.13.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/src/server.mjs CHANGED
@@ -33,11 +33,13 @@ import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs
33
33
  import { hasRecentSynthesis } from './synthesis.mjs'
34
34
  import { refreshPointers } from './resume.mjs'
35
35
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
36
- import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
37
- import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
36
+ import { readUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
37
+ import { readAccounts } from './accounts.mjs'
38
+ import { createUsagePollers, USAGE_AGENTS } from './usage-poll.mjs'
38
39
  import { readCodexUsage, transcriptTail as codexTranscriptTail } from './taps/codex.mjs'
39
40
  import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder, ladderFor, requireHandoffLadder, requireClimbBack, requireReserve, orderFromLadder } from './preferences.mjs'
40
41
  import { isDownshift } from './buckets.mjs'
42
+ import { listModels } from './models.mjs'
41
43
  import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
42
44
  import { listWorktrees } from './history/worktrees.mjs'
43
45
 
@@ -575,6 +577,14 @@ function scrubOwnerUsage(s) {
575
577
  return out
576
578
  }
577
579
 
580
+ // The usage poller appends `claude usage unavailable since 9:03 AM: <reason>`
581
+ // to every active session of a login, a guest's own terminal included
582
+ // (src/usage-poll.mjs). That line is the owner's reading — the reason their
583
+ // endpoint is refusing and the clock it started — so it leaves with the rest of
584
+ // the figures scrubOwnerUsage withholds. The recovery line says nothing about
585
+ // the login and stays.
586
+ const isUsageFailureEvent = (e) => e?.type === 'status' && /^\w+ usage unavailable since /.test(String(e?.summary ?? ''))
587
+
578
588
  // A guest owns their own terminal, so its picker rows are theirs to read, but
579
589
  // a rung's reason can quote this machine's usage ("at 63%, not below 80%",
580
590
  // "past your 10% reserve"), and a percentage of this machine's login belongs to
@@ -683,7 +693,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
683
693
  // the percentages are: they say how much of this machine's login is gone.
684
694
  // The guest branch at the bottom of this function drops the slot to
685
695
  // {agent, account, live, shared}, so nothing here reaches them.
686
- accounts.push({ agent, account, five_hour: u.five_hour, seven_day: u.seven_day, limited_until: u.limited_until, limited_reason: u.limited_reason, source: u.source, observed_at: u.observed_at, updated_at: u.updated_at, stale: usageIsStale(u), live: sessions.filter((s) => s.active && s.agent === agent && s.account === account).length, buckets: u.buckets ?? [], walls: u.walls ?? {}, extra_usage: u.extra_usage ?? null, facts: u.facts ?? null })
696
+ // `error`/`error_since` are the READING's health, not the login's: they say
697
+ // the board could not ask, which is why a percentage is old. Owner-only for
698
+ // the same reason the percentages are, and dropped by the guest branch below
699
+ // with the rest of the slot.
700
+ accounts.push({ agent, account, five_hour: u.five_hour, seven_day: u.seven_day, limited_until: u.limited_until, limited_reason: u.limited_reason, source: u.source, observed_at: u.observed_at, updated_at: u.updated_at, stale: usageIsStale(u), live: sessions.filter((s) => s.active && s.agent === agent && s.account === account).length, buckets: u.buckets ?? [], walls: u.walls ?? {}, extra_usage: u.extra_usage ?? null, facts: u.facts ?? null, error: u.error ?? null, error_since: u.error_since ?? null })
687
701
  }
688
702
  const repos = new Map()
689
703
  for (const s of sessions) if (s.repo && (s.active || s.worktree) && !repos.has(canonPath(s.repo))) repos.set(canonPath(s.repo), s.repo)
@@ -903,8 +917,25 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
903
917
  return { add, stop, broadcast, clients }
904
918
  }
905
919
 
920
+ // Which logins this board reads usage for. LEG_CLAUDE_BIN and LEG_GROK_BIN say
921
+ // where a CLI lives, not that its login is fake, and gating on them switched
922
+ // every percentage, every 5h/7d number and every usage_error off for a user
923
+ // whose claude simply lives somewhere Leg's resolver does not look — with
924
+ // nothing on the board to say why. codex is the one agent whose binary the
925
+ // poller really needs, because it spawns the app-server to ask it; a stub there
926
+ // cannot answer, so codex alone is skipped on its BIN var. A suite that must
927
+ // reach no endpoint at all says so in one variable, LEG_NO_USAGE_POLL=1
928
+ // (test/helpers.mjs sets it for every spawned board), and an injected fetcher
929
+ // always wins: a test that supplied a reader is asking for it to be used.
930
+ export function usageAgentsFor({ env = process.env, injected = {} } = {}) {
931
+ const set = (name) => env[`LEG_${name}`] || env[`BATON_${name}`]
932
+ const off = set('NO_USAGE_POLL') === '1'
933
+ const stubbedCodex = Boolean(set('CODEX_BIN'))
934
+ return USAGE_AGENTS.filter((a) => injected[a] || (!off && !(a === 'codex' && stubbedCodex)))
935
+ }
936
+
906
937
  // ---- the server ----
907
- export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
938
+ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage, usageFetchers = {} } = {}) {
908
939
  // An explicit `share` (tests) is fixed; the real server passes none and reads
909
940
  // share.json from disk, re-reading it per request (mtime-cached) so `leg
910
941
  // share add|rotate|rm` takes effect on a live board — a new link works at
@@ -991,27 +1022,19 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
991
1022
  arm()
992
1023
  }
993
1024
  let sched = null
994
- let usageTimer = null
995
- let usageController = null
996
- let usageInFlight = null
997
- const refreshCodexAccounts = () => {
998
- if (!usagePolling || ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN) && usageReader === readCodexUsage) || usageInFlight) return usageInFlight
999
- usageController = new AbortController()
1000
- const signal = usageController.signal
1001
- usageInFlight = Promise.all((readAccounts().codex ?? ['default']).map(async (account) => {
1002
- const codexHome = envFor('codex', account).CODEX_HOME || LAYOUT.codex.home()
1003
- const r = await usageReader({ codexHome, timeoutMs: 8000, signal })
1004
- if (!r.ok) return false
1005
- recordUsage('codex', account, r.limits, 'codex app-server account/rateLimits/read', { observed_at: r.observed_at, available: r.available })
1006
- return true
1007
- })).then((changed) => {
1008
- if (changed.some(Boolean)) sse.broadcast('sessions', (viewer) => viewFor(viewer))
1009
- }).catch((err) => log(`codex usage refresh: ${err.message}`)).finally(() => {
1010
- usageInFlight = null
1011
- usageController = null
1012
- })
1013
- return usageInFlight
1014
- }
1025
+ // ---- usage polling: one poller per LOGIN, not one per terminal ----
1026
+ // The terminals used to ask their agent's endpoint once a minute each, so a
1027
+ // login with three of them drew three times the requests and three copies of
1028
+ // every 429 (src/usage-poll.mjs). The board asks once per login, backs off on
1029
+ // a failure, and pushes what it read onto that login's active sessions.
1030
+ const usageFetchersFor = { codex: usageReader, ...usageFetchers }
1031
+ const injectedUsage = { codex: usageReader !== readCodexUsage, claude: Boolean(usageFetchers.claude), grok: Boolean(usageFetchers.grok) }
1032
+ const usagePollers = createUsagePollers({
1033
+ agents: usageAgentsFor({ injected: injectedUsage }),
1034
+ fetchers: usageFetchersFor,
1035
+ onChange: () => sse.broadcast('sessions', (viewer) => viewFor(viewer)),
1036
+ onLog: (msg) => log(msg),
1037
+ })
1015
1038
 
1016
1039
  async function handle(req, res) {
1017
1040
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
@@ -1054,7 +1077,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1054
1077
  const canMachine = !shared || mayUseMachine(viewer.role)
1055
1078
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
1056
1079
  const parts = path.split('/').filter(Boolean) // ['api', ...]
1057
- if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
1080
+ if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases', 'models'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
1058
1081
  if (!canMachine && ['trunk', 'history', 'worktrees', 'audit'].includes(parts[1])) return send(res, 403, { error: 'this is the map of the machine itself: every repository path and every conversation on it. It belongs to the owner of this machine.' })
1059
1082
  try {
1060
1083
  if (req.method === 'GET' && path === '/api/health') {
@@ -1065,6 +1088,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1065
1088
  }
1066
1089
  if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
1067
1090
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
1091
+ // Which models this machine can start each agent on (src/models.mjs).
1092
+ // Sits beside /api/adapters because it answers the second half of the
1093
+ // same question — an adapter says WHO can run, this says WHAT it runs as
1094
+ // — and it is guarded the same way: a guest picking a model is a guest
1095
+ // spending the owner's plan. Never waits on a child process: agy's and
1096
+ // grok's lists come off an hourly cache and refresh behind the answer.
1097
+ if (req.method === 'GET' && path === '/api/models') return send(res, 200, listModels())
1068
1098
  if (req.method === 'GET' && path === '/api/cards') {
1069
1099
  const cards = listCards()
1070
1100
  return send(res, 200, { columns: columnsFor(cards), cards: cards.map((c) => summarize(c)) })
@@ -1165,11 +1195,32 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1165
1195
  sse.broadcast('sessions', (v) => viewFor(v))
1166
1196
  return send(res, 200, { ok: true, request: hit })
1167
1197
  }
1168
- if (req.method === 'GET' && parts.length === 3) return send(res, 200, { session: sess, events: readSessionEvents(id), requests: readRequests(id) })
1198
+ // A guest owns their own terminal, so this route hands them the record
1199
+ // verbatim — including the two window percentages, the reading source
1200
+ // and the poller's usage_error, every one of which the LIST route
1201
+ // scrubs (`scrubOwnerUsage`, sessionsView). One GET of their own
1202
+ // session id was the whole share boundary walked around. The same scrub
1203
+ // runs here, and the poller's failure line is kept off their timeline:
1204
+ // it names the owner's reason and the clock it started.
1205
+ if (req.method === 'GET' && parts.length === 3) {
1206
+ const asGuest = shared && viewer.role !== 'owner'
1207
+ const events = readSessionEvents(id)
1208
+ return send(res, 200, {
1209
+ session: asGuest ? scrubOwnerUsage(sess) : sess,
1210
+ events: asGuest ? events.filter((e) => !isUsageFailureEvent(e)) : events,
1211
+ requests: readRequests(id),
1212
+ })
1213
+ }
1169
1214
  // the card's drawer: what the agent last said, what it changed, what it
1170
1215
  // has done. Only ever this viewer's own terminal; the guard above sent
1171
1216
  // anyone else away before we read a transcript.
1172
- if (req.method === 'GET' && parts[3] === 'detail') return send(res, 200, sessionDetail(sess))
1217
+ if (req.method === 'GET' && parts[3] === 'detail') {
1218
+ const detail = sessionDetail(sess)
1219
+ // the drawer of a guest's OWN terminal is theirs; the poller's line
1220
+ // about the owner's login is not (the same event the route above drops)
1221
+ if (shared && viewer.role !== 'owner') detail.events = detail.events.filter((e) => !isUsageFailureEvent(e))
1222
+ return send(res, 200, detail)
1223
+ }
1173
1224
  if (req.method === 'GET' && parts[3] === 'diff') {
1174
1225
  try {
1175
1226
  return send(res, 200, sessionDiff(sess, url.searchParams.get('file') ?? ''))
@@ -1596,9 +1647,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1596
1647
  sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
1597
1648
  }
1598
1649
  if (usagePolling) {
1599
- refreshCodexAccounts()
1600
- usageTimer = setInterval(refreshCodexAccounts, 60000)
1601
- usageTimer.unref?.()
1650
+ usagePollers.start().catch((err) => log(`usage polling: ${err.message}`))
1602
1651
  }
1603
1652
  if (loopbackCompanion) {
1604
1653
  loopbackCompanion.on('error', (err) => log(`loopback companion: ${err.message}`))
@@ -1609,8 +1658,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1609
1658
  })
1610
1659
  },
1611
1660
  async stop() {
1612
- if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
1613
- usageController?.abort()
1661
+ usagePollers.stop()
1614
1662
  for (const t of enqueueTimers.values()) clearTimeout(t)
1615
1663
  enqueueTimers.clear()
1616
1664
  sse.stop()
@@ -119,6 +119,18 @@ export function extraUsageFrom(j) {
119
119
  return Object.keys(out).length ? out : null
120
120
  }
121
121
 
122
+ // What a failing answer is allowed to say: the status code and the `type` the
123
+ // body names ('rate_limit_error'), never the body itself. A login shared by
124
+ // several terminals answers 429 often, and the whole JSON on every one of them
125
+ // turned the terminal's timeline into a wall of payloads.
126
+ function errorType(text) {
127
+ try {
128
+ const j = JSON.parse(text)
129
+ const t = j?.error?.type ?? j?.type
130
+ return typeof t === 'string' && t && t !== 'error' ? t : null
131
+ } catch { return null }
132
+ }
133
+
122
134
  function getJson(url, headers, timeoutMs) {
123
135
  return new Promise((resolvePromise) => {
124
136
  const u = new URL(url)
@@ -147,7 +159,10 @@ export async function fetchClaudeUsage({ configDir = LAYOUT.claude.home(), timeo
147
159
  if (!t) return { ok: false, limits: null, error: 'no claude.ai login found in ' + configDir }
148
160
  const r = await getJson(url, { Authorization: `Bearer ${t.token}`, 'anthropic-beta': 'oauth-2025-04-20', Accept: 'application/json', 'User-Agent': 'legcli' }, timeoutMs)
149
161
  if (r.error) return { ok: false, limits: null, status: 0, error: r.error }
150
- if (r.status !== 200) return { ok: false, limits: null, status: r.status, expired: t.expired, error: `usage endpoint ${r.status}: ${r.text.slice(0, 120)}` }
162
+ if (r.status !== 200) {
163
+ const kind = errorType(r.text)
164
+ return { ok: false, limits: null, status: r.status, expired: t.expired, error: `usage endpoint ${r.status}${kind ? `: ${kind}` : ''}` }
165
+ }
151
166
  let j
152
167
  try { j = JSON.parse(r.text) } catch { return { ok: false, limits: null, status: r.status, error: 'usage endpoint returned no JSON' } }
153
168
  const limits = { five_hour: window(j.five_hour), seven_day: window(j.seven_day), extra_usage: extraUsageFrom(j) }
@@ -0,0 +1,260 @@
1
+ // usage polling: one poller per LOGIN, living in the board process.
2
+ //
3
+ // Why here and not in the terminal: every attached terminal used to ask its
4
+ // agent's usage endpoint once a minute (src/attach.mjs). Three claude
5
+ // terminals on one login, next to Claude Code's own polling, drew a 429 every
6
+ // other minute, and each failure wrote a session event, so the timeline read as
7
+ // a wall of rate-limit payloads. A login has ONE poller now, wherever its
8
+ // terminals are, and what it reads is pushed onto every active session of that
9
+ // login exactly as the terminal used to write it.
10
+ //
11
+ // Backoff is per login: an answer that is not usable doubles the wait up to
12
+ // USAGE_POLL_MAX_MS, and the first usable one puts it straight back to the base
13
+ // interval. The failure is recorded ONCE, on the usage record (`error`,
14
+ // `error_since`, src/usage.mjs), with one status event on that login's
15
+ // sessions when it starts and one when it ends.
16
+ //
17
+ // What stays in the terminal: anything measured from that terminal's own files
18
+ // (the codex rollout scan, the transcript's model line). Only the per-login
19
+ // endpoint reads moved here.
20
+ import { LAYOUT, readAccounts, envFor } from './accounts.mjs'
21
+ import { listSessions, isActive, updateSession, appendEvent } from './sessions.mjs'
22
+ import { recordUsage, noteUsageError } from './usage.mjs'
23
+ import { fetchClaudeUsage } from './taps/claude-usage.mjs'
24
+ import { fetchGrokUsage } from './taps/grok.mjs'
25
+ import { readCodexUsage } from './taps/codex.mjs'
26
+
27
+ // The interval is a knob a human types, so it arrives as "5m", "60_000" or
28
+ // "60 000" as readily as a number. Number() turns all three into NaN, every
29
+ // timer was then armed with NaN, Node rounds that to 1ms, and the board asked
30
+ // the usage endpoint about a thousand times a second per login — with a backoff
31
+ // that could never rescue it, because Math.max(NaN, delay) * 2 is NaN too.
32
+ // A value Leg cannot read is the default; a value under the floor is the floor,
33
+ // because no reading of a plan's percentages is worth a request every second.
34
+ export const USAGE_POLL_FLOOR_MS = 5000
35
+
36
+ export function clampPollMs(raw, fallback, floorMs = USAGE_POLL_FLOOR_MS) {
37
+ if (raw === undefined || raw === null || raw === '') return fallback
38
+ const n = Number(raw)
39
+ if (!Number.isFinite(n) || n <= 0) return fallback
40
+ return Math.max(floorMs, n)
41
+ }
42
+
43
+ const rawInterval = process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS
44
+ const rawMax = process.env.LEG_USAGE_POLL_MAX_MS || process.env.BATON_USAGE_POLL_MAX_MS
45
+ export const USAGE_POLL_MS = clampPollMs(rawInterval, 60000)
46
+ export const USAGE_POLL_MAX_MS = clampPollMs(rawMax, 10 * 60 * 1000, USAGE_POLL_MS)
47
+
48
+ // A knob that was typed and not used says so once on the board's log: silence
49
+ // there is how a user learns nothing, and keeps typing "5m".
50
+ const unread = (name, raw, used) => (raw === undefined || raw === null || raw === '' || (Number.isFinite(Number(raw)) && Number(raw) > 0)
51
+ ? null
52
+ : `${name}=${String(raw).slice(0, 40)} is not a number of milliseconds; reading usage every ${used}ms instead`)
53
+ export const USAGE_POLL_NOTES = [
54
+ unread('LEG_USAGE_POLL_MS', rawInterval, USAGE_POLL_MS),
55
+ unread('LEG_USAGE_POLL_MAX_MS', rawMax, USAGE_POLL_MAX_MS),
56
+ ].filter(Boolean)
57
+ const said = new Set()
58
+
59
+ export const USAGE_AGENTS = ['claude', 'codex', 'grok']
60
+
61
+ // The wording on the card and in the record: one name per reading source, the
62
+ // same strings the terminals wrote before this moved.
63
+ const SOURCE = {
64
+ claude: 'claude usage endpoint',
65
+ codex: 'codex app-server account/rateLimits/read',
66
+ grok: 'grok billing proxy',
67
+ }
68
+
69
+ // "9:03 AM": the time a human reads on a card, not an ISO stamp.
70
+ export function clockTime(iso) {
71
+ const ms = Date.parse(iso ?? '')
72
+ if (!Number.isFinite(ms)) return 'just now'
73
+ const d = new Date(ms)
74
+ const h = d.getHours()
75
+ return `${h % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
76
+ }
77
+
78
+ function configDirFor(agent, account) {
79
+ const l = LAYOUT[agent]
80
+ if (!l) return null
81
+ return (l.env ? envFor(agent, account)[l.env] : null) || l.home()
82
+ }
83
+
84
+ // One reading for one login → { ok, error } or { ok: true, patch }, where the
85
+ // patch is what every active session of that login gets.
86
+ // `halted` is the answer when the board stopped while the endpoint was still
87
+ // thinking: the reading is thrown away rather than recorded, because every
88
+ // write it would make (the usage record, a session's percentages, a line on a
89
+ // timeline, an SSE push) belongs to a board that no longer exists.
90
+ async function readLogin(agent, account, { read, timeoutMs, signal, stopped = () => false }) {
91
+ const configDir = configDirFor(agent, account)
92
+ if (agent === 'codex') {
93
+ const r = await read.codex({ codexHome: configDir, timeoutMs, signal })
94
+ if (stopped()) return { halted: true }
95
+ if (!r.ok) return { ok: false, error: r.error ?? 'the codex app server answered with no rate limits' }
96
+ const u = recordUsage('codex', account, { ...r.limits, facts: r.facts }, SOURCE.codex, { observed_at: r.observed_at, available: r.available })
97
+ const patch = { limits: r.limits, usage_source: SOURCE.codex }
98
+ // an explicit "ordinary usage is unavailable" is the wall itself, and the
99
+ // terminal hands off on it (src/attach.mjs reads status from the record)
100
+ if (r.available === false) {
101
+ patch.status = 'limit'
102
+ patch.limit = { reason: 'usage_limit_exceeded', detail: 'Codex reports ordinary usage is unavailable', resets_at: u.limited_until, at: r.observed_at ?? new Date().toISOString() }
103
+ }
104
+ return { ok: true, patch }
105
+ }
106
+ const r = agent === 'claude'
107
+ ? await read.claude({ configDir, timeoutMs })
108
+ : await read.grok({ configDir, timeoutMs })
109
+ // neither reader takes an AbortSignal, so the wait is not cut short by stop();
110
+ // what it must not do is come back and write
111
+ if (stopped()) return { halted: true }
112
+ const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
113
+ if (!usable) return { ok: false, error: r.error ?? 'the usage endpoint answered with no window' }
114
+ recordUsage(agent, account, r.limits, SOURCE[agent])
115
+ // the session record keeps the two windows it always had: the buckets live on
116
+ // the usage record, which is per login and not per terminal
117
+ const limits = agent === 'claude' ? { five_hour: r.limits.five_hour, seven_day: r.limits.seven_day } : r.limits
118
+ return { ok: true, patch: { limits, usage_source: SOURCE[agent] } }
119
+ }
120
+
121
+ // → { start, stop, pollNow, delayOf, logins }
122
+ // `schedule`/`cancel` are seams: a test drives the clock by hand instead of
123
+ // waiting minutes for a backoff to prove itself.
124
+ export function createUsagePollers({
125
+ agents = USAGE_AGENTS,
126
+ fetchers = {},
127
+ intervalMs = USAGE_POLL_MS,
128
+ maxMs = USAGE_POLL_MAX_MS,
129
+ timeoutMs = 8000,
130
+ onChange = () => {},
131
+ onLog = () => {},
132
+ accounts = readAccounts,
133
+ schedule = (fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t },
134
+ cancel = (t) => clearTimeout(t),
135
+ } = {}) {
136
+ const read = { claude: fetchClaudeUsage, grok: fetchGrokUsage, codex: readCodexUsage, ...fetchers }
137
+ const state = new Map()
138
+ let stopped = false
139
+ let controller = new AbortController()
140
+
141
+ const key = (agent, account) => `${agent}--${account}`
142
+ function slot(agent, account) {
143
+ const k = key(agent, account)
144
+ if (!state.has(k)) state.set(k, { agent, account, delay: intervalMs, timer: null, inFlight: null })
145
+ return state.get(k)
146
+ }
147
+
148
+ function sessionsOf(agent, account) {
149
+ try { return listSessions().filter((s) => s && s.agent === agent && s.account === account && isActive(s)) } catch { return [] }
150
+ }
151
+
152
+ // Write to one terminal of this login. The leg can hand off between the read
153
+ // and the write, so the patch is applied inside the record's own lock and
154
+ // only while the record still names this login: claude's percentages, and
155
+ // claude's failure line, must never follow codex onto the card. The event is
156
+ // written after, and only if the patch was the right terminal's.
157
+ function pushToSession(id, agent, account, patch, event) {
158
+ const next = updateSession(id, (cur) => (cur.agent === agent && cur.account === account && isActive(cur) ? patch : {}))
159
+ if (event && next && next.agent === agent && next.account === account) appendEvent(id, event)
160
+ return next
161
+ }
162
+
163
+ // One reading, then the fan-out. Single-flight per login: a slow endpoint
164
+ // never stacks two requests on one login, whatever the timer does.
165
+ function tick(agent, account) {
166
+ const st = slot(agent, account)
167
+ if (st.inFlight) return st.inFlight
168
+ const run = (async () => {
169
+ let r
170
+ try {
171
+ r = await readLogin(agent, account, { read, timeoutMs, signal: controller.signal, stopped: () => stopped })
172
+ } catch (err) {
173
+ r = { ok: false, error: String(err?.message ?? err).slice(0, 200) }
174
+ }
175
+ // stop() means stop: a reading that lands after the board has gone is
176
+ // dropped whole, so nothing is written and onChange never fires into a
177
+ // closed SSE hub.
178
+ if (stopped || r.halted) return { ok: Boolean(r.ok), changed: false }
179
+ let changed = false
180
+ if (r.ok) {
181
+ const cleared = noteUsageError(agent, account, null)
182
+ const event = cleared.changed ? { type: 'status', summary: `${agent} usage is back` } : null
183
+ for (const s of sessionsOf(agent, account)) pushToSession(s.session_id, agent, account, { ...r.patch, usage_error: null }, event)
184
+ changed = true
185
+ } else {
186
+ // The failure is the RECORD's, and the timeline hears about it once:
187
+ // the event is written on the transition alone, so a long outage is one
188
+ // line and not one line per refusal.
189
+ //
190
+ // The CARD is a different question. A reason that changes mid-outage
191
+ // (logged out, then a stale token answering 401) left every card naming
192
+ // the first cause for the rest of the outage, sending the user to fix
193
+ // something already fixed. The text is pushed whenever it differs from
194
+ // what the row carries; the event stays null, so the timeline is still
195
+ // one line per outage while the card stays truthful.
196
+ const noted = noteUsageError(agent, account, r.error)
197
+ const event = noted.changed ? { type: 'status', summary: `${agent} usage unavailable since ${clockTime(noted.error_since)}: ${r.error}` } : null
198
+ for (const s of sessionsOf(agent, account)) {
199
+ if (noted.changed || s.usage_error !== noted.error) pushToSession(s.session_id, agent, account, { usage_error: noted.error }, event)
200
+ }
201
+ changed = noted.changed
202
+ if (noted.changed) onLog(`${agent}/${account} usage: ${r.error}`)
203
+ }
204
+ st.delay = r.ok ? intervalMs : Math.min(maxMs, Math.max(intervalMs, st.delay) * 2)
205
+ return { ...r, changed }
206
+ })()
207
+ st.inFlight = run.finally(() => { st.inFlight = null })
208
+ return st.inFlight
209
+ }
210
+
211
+ function arm(agent, account) {
212
+ if (stopped) return
213
+ const st = slot(agent, account)
214
+ if (st.timer) return
215
+ st.timer = schedule(() => { st.timer = null; return cycle(agent, account) }, st.delay)
216
+ }
217
+
218
+ // A login added while the board is up (leg account add) starts polling on the
219
+ // next round rather than on the next restart.
220
+ function adopt(agent) {
221
+ if (stopped) return
222
+ for (const account of accounts()[agent] ?? ['default']) if (!state.has(key(agent, account))) cycle(agent, account)
223
+ }
224
+
225
+ async function cycle(agent, account) {
226
+ let r = null
227
+ try {
228
+ r = await tick(agent, account)
229
+ if (r?.changed) onChange()
230
+ } catch (err) {
231
+ onLog(`${agent}/${account} usage poll: ${err.message}`)
232
+ }
233
+ adopt(agent)
234
+ arm(agent, account)
235
+ return r
236
+ }
237
+
238
+ return {
239
+ // → a promise for the FIRST round, so a caller (or a test) can wait for one
240
+ // complete reading of every login without knowing the timer.
241
+ start() {
242
+ stopped = false
243
+ controller = new AbortController()
244
+ for (const note of USAGE_POLL_NOTES) if (!said.has(note)) { said.add(note); onLog(note) }
245
+ const acc = accounts()
246
+ const first = []
247
+ for (const agent of agents) for (const account of acc[agent] ?? ['default']) first.push(cycle(agent, account))
248
+ return Promise.all(first)
249
+ },
250
+ stop() {
251
+ stopped = true
252
+ for (const st of state.values()) { if (st.timer) cancel(st.timer); st.timer = null }
253
+ try { controller.abort() } catch {}
254
+ state.clear()
255
+ },
256
+ pollNow: (agent, account = 'default') => cycle(agent, account),
257
+ delayOf: (agent, account = 'default') => state.get(key(agent, account))?.delay ?? null,
258
+ logins: () => [...state.values()].map((s) => ({ agent: s.agent, account: s.account, delay: s.delay })),
259
+ }
260
+ }
package/src/usage.mjs CHANGED
@@ -46,7 +46,39 @@ export function readUsage(agent, account = 'default') {
46
46
  }
47
47
 
48
48
  function emptyUsage(agent, account) {
49
- return { agent, account, five_hour: null, seven_day: null, limited_until: null, limited_reason: null, limited_at: null, source: null, observed_at: null, available_at: null, updated_at: null, buckets: [], walls: {}, history: {}, extra_usage: null, facts: null }
49
+ return { agent, account, five_hour: null, seven_day: null, limited_until: null, limited_reason: null, limited_at: null, source: null, observed_at: null, available_at: null, updated_at: null, buckets: [], walls: {}, history: {}, extra_usage: null, facts: null, error: null, error_since: null }
50
+ }
51
+
52
+ // The READING's health, which is not the login's health: a 429 from the usage
53
+ // endpoint says nothing about how much of the plan is left, so it never touches
54
+ // the windows, the buckets or a wall. It is written once — `error_since` keeps
55
+ // the moment it started — and cleared by the first reading that works, so the
56
+ // board can say "unavailable since 9:03 AM" instead of one line per failed
57
+ // poll (src/usage-poll.mjs).
58
+ // → { error, error_since, changed } — `changed` is the transition only, which
59
+ // is what decides whether a session event is worth writing.
60
+ export function noteUsageError(agent, account, error, { at = new Date().toISOString() } = {}) {
61
+ let changed = false
62
+ const value = mutate(agent, account, (u) => {
63
+ if (!error) {
64
+ if (!u.error && !u.error_since) return false
65
+ u.error = null
66
+ u.error_since = null
67
+ changed = true
68
+ return u
69
+ }
70
+ const why = String(error).slice(0, 300)
71
+ if (u.error) {
72
+ if (u.error === why) return false
73
+ u.error = why
74
+ return u
75
+ }
76
+ u.error = why
77
+ u.error_since = at
78
+ changed = true
79
+ return u
80
+ })
81
+ return { error: value.error ?? null, error_since: value.error_since ?? null, changed }
50
82
  }
51
83
 
52
84
  // The ring key for a bucket: the kind alone when it is account-wide, the kind