@ucsandman/legcli 0.12.0 → 0.13.1
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 +88 -0
- package/README.md +1 -1
- package/docs/DECISIONS.md +8 -0
- package/docs/ERRORS.md +42 -0
- package/docs/board-guide.md +129 -31
- package/docs/cli-contracts.md +42 -0
- package/docs/configuration.md +8 -2
- package/docs/faq.md +3 -1
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/build-docs-site.mjs +3 -3
- package/src/attach.mjs +60 -57
- package/src/board/board.css +84 -2
- package/src/board/board.js +349 -261
- 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 +55 -38
- package/src/board/sessions.js +342 -144
- package/src/board/strip.js +163 -0
- package/src/models.mjs +265 -0
- package/src/preferences.mjs +69 -5
- package/src/server.mjs +81 -33
- package/src/taps/claude-usage.mjs +16 -1
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +33 -1
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,
|
|
37
|
-
import { readAccounts
|
|
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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|