@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.
- package/CHANGELOG.md +213 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +18 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +68 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +529 -96
- package/docs/cli-contracts.md +241 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +65 -1
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/verified.json +1 -1
- package/package.json +3 -2
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +378 -93
- package/src/audit.mjs +1 -1
- package/src/board/board.css +203 -11
- package/src/board/board.js +664 -200
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +122 -45
- package/src/board/sessions.js +1569 -141
- package/src/board/strip.js +163 -0
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/models.mjs +265 -0
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +278 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +625 -78
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +107 -3
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage-poll.mjs +260 -0
- 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
|
+
}
|