@ucsandman/legcli 0.11.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
@@ -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
+ }