@ucsandman/legcli 0.10.0 → 0.12.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 +212 -0
- package/README.md +158 -67
- package/bin/leg.mjs +168 -18
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +94 -0
- package/docs/ROADMAP-v2.md +69 -11
- package/docs/VOCABULARY.md +27 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +235 -22
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +113 -5
- 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/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/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +3 -2
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +350 -42
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +134 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +89 -7
- package/src/board/sessions.js +1371 -113
- 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/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +615 -50
- package/src/sessions.mjs +17 -1
- package/src/share.mjs +66 -6
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +424 -13
package/src/usage.mjs
CHANGED
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
// $BATON_HOME/usage/<agent>--<account>.json:
|
|
3
3
|
// { agent, account, five_hour: {pct, resets_at}|null, seven_day: {...}|null,
|
|
4
4
|
// limited_until: epoch-seconds|null, limited_reason, limited_at,
|
|
5
|
-
// source, observed_at, available_at, updated_at
|
|
5
|
+
// source, observed_at, available_at, updated_at,
|
|
6
|
+
// buckets: [{kind, group, model, percent, resets_at, is_active, severity}],
|
|
7
|
+
// walls: { <model>: {limited_until, limited_reason, limited_at, source, evidence} },
|
|
8
|
+
// history: { '<kind>:<model>': [{percent, at}] }, // max 24, per bucket
|
|
9
|
+
// extra_usage: {enabled, reason, can_toggle, limit_minor, used_minor}|null,
|
|
10
|
+
// facts: { … }|null } // measured, agent-specific
|
|
11
|
+
// The record itself is the account bucket and keeps every field it had; the
|
|
12
|
+
// five new keys are additive, and an older Leg reading this file ignores them.
|
|
13
|
+
// `buckets` is measured (numbers); `walls` is attributed from wording
|
|
14
|
+
// (src/buckets.mjs). They stay apart because one is a number and the other is
|
|
15
|
+
// a word, and one must never be printed as the other.
|
|
6
16
|
// Sources: claude statusline JSON (rate_limits.*) and StopFailure rate_limit;
|
|
7
17
|
// codex app-server/rollout rate limits (identified by window duration) and
|
|
8
18
|
// the usage-limit error; agy only the wall itself (no percent exposed).
|
|
@@ -11,13 +21,23 @@ import { join } from 'node:path'
|
|
|
11
21
|
import { home } from './store.mjs'
|
|
12
22
|
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
13
23
|
import { AGENTS } from './sessions.mjs'
|
|
24
|
+
import { ACCOUNT_NAME_RE } from './accounts.mjs'
|
|
25
|
+
import { rungCost, staticCost } from './preferences.mjs'
|
|
14
26
|
|
|
15
27
|
export const WARN_PCT = Number((process.env.LEG_WARN_PCT || process.env.BATON_WARN_PCT) || 85)
|
|
16
28
|
// A limit hit with no reset time from the agent: assume the 5-hour window.
|
|
17
29
|
const DEFAULT_LIMIT_S = 5 * 3600
|
|
18
30
|
|
|
19
31
|
export function usageDir() { return join(home(), 'usage') }
|
|
20
|
-
|
|
32
|
+
// The file name is built from two names, so both are names: `claude--<account>`
|
|
33
|
+
// with `../../..` in it resolves to a fully chosen path with a .json suffix,
|
|
34
|
+
// written by every recordUsage and markLimited call. A rung's account is
|
|
35
|
+
// validated where it is saved (src/preferences.mjs); this is the second latch.
|
|
36
|
+
export function usageFile(agent, account = 'default') {
|
|
37
|
+
if (!ACCOUNT_NAME_RE.test(String(agent ?? ''))) throw new TypeError(`invalid agent name "${agent}"`)
|
|
38
|
+
if (!ACCOUNT_NAME_RE.test(String(account ?? ''))) throw new TypeError(`invalid account name "${account}"`)
|
|
39
|
+
return join(usageDir(), `${agent}--${account}.json`)
|
|
40
|
+
}
|
|
21
41
|
|
|
22
42
|
export function readUsage(agent, account = 'default') {
|
|
23
43
|
const f = usageFile(agent, account)
|
|
@@ -26,9 +46,17 @@ export function readUsage(agent, account = 'default') {
|
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
function emptyUsage(agent, account) {
|
|
29
|
-
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 }
|
|
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 }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The ring key for a bucket: the kind alone when it is account-wide, the kind
|
|
53
|
+
// and the model when it is scoped ('weekly_scoped:fable').
|
|
54
|
+
export function bucketKey(b) {
|
|
55
|
+
return b.model ? `${b.kind}:${b.model}` : String(b.kind)
|
|
30
56
|
}
|
|
31
57
|
|
|
58
|
+
export const HISTORY_MAX = 24
|
|
59
|
+
|
|
32
60
|
export function listUsage() {
|
|
33
61
|
const dir = usageDir()
|
|
34
62
|
if (!existsSync(dir)) return []
|
|
@@ -55,7 +83,9 @@ function mutate(agent, account, fn) {
|
|
|
55
83
|
})
|
|
56
84
|
}
|
|
57
85
|
|
|
58
|
-
// windows: { five_hour: {pct, resets_at}|null, seven_day:
|
|
86
|
+
// windows: { five_hour: {pct, resets_at}|null, seven_day: …,
|
|
87
|
+
// buckets: [...]|undefined, extra_usage: {...}|undefined,
|
|
88
|
+
// facts: {...}|undefined }
|
|
59
89
|
// `available` must be an explicit backend answer. Percentages cannot clear a
|
|
60
90
|
// wall: Codex's rate-limit schema says null availability is unknown, even when
|
|
61
91
|
// a window is below 100%.
|
|
@@ -67,6 +97,29 @@ export function recordUsage(agent, account, windows, source, { observed_at = new
|
|
|
67
97
|
if (Number.isFinite(seenMs) && Number.isFinite(currentMs) && seenMs < currentMs) return false
|
|
68
98
|
if (windows.five_hour !== undefined) u.five_hour = windows.five_hour
|
|
69
99
|
if (windows.seven_day !== undefined) u.seven_day = windows.seven_day
|
|
100
|
+
if (Array.isArray(windows.buckets)) {
|
|
101
|
+
const atS = Number.isFinite(seenMs) ? Math.floor(seenMs / 1000) : Math.floor(Date.now() / 1000)
|
|
102
|
+
if (windows.buckets.length) {
|
|
103
|
+
u.history = recordHistory(u.history ?? {}, u.buckets ?? [], windows.buckets, atS)
|
|
104
|
+
u.buckets = windows.buckets
|
|
105
|
+
} else {
|
|
106
|
+
// An empty list is no information, not "this login has no buckets": an
|
|
107
|
+
// older endpoint answers the two windows and no `limits` key at all
|
|
108
|
+
// (src/taps/claude-usage.mjs), and erasing the measured buckets on it
|
|
109
|
+
// loses the wall clock, the ring and the binding bucket in one write.
|
|
110
|
+
// The one thing an empty reading does settle is a window that has run
|
|
111
|
+
// out: a bucket whose reset has passed is dropped rather than kept.
|
|
112
|
+
const kept = (u.buckets ?? []).filter((b) => !(Number.isFinite(b?.resets_at) && b.resets_at <= atS))
|
|
113
|
+
for (const b of u.buckets ?? []) if (!kept.includes(b)) delete u.history?.[bucketKey(b)]
|
|
114
|
+
u.buckets = kept
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (windows.extra_usage !== undefined) u.extra_usage = windows.extra_usage
|
|
118
|
+
if (windows.facts && typeof windows.facts === 'object') {
|
|
119
|
+
const next = { ...(u.facts ?? {}) }
|
|
120
|
+
for (const [k, v] of Object.entries(windows.facts)) if (v !== undefined && v !== null) next[k] = v
|
|
121
|
+
u.facts = Object.keys(next).length ? next : null
|
|
122
|
+
}
|
|
70
123
|
u.source = source
|
|
71
124
|
u.observed_at = Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString()
|
|
72
125
|
applied = true
|
|
@@ -74,6 +127,9 @@ export function recordUsage(agent, account, windows, source, { observed_at = new
|
|
|
74
127
|
// A window that has reset clears an old wall.
|
|
75
128
|
const nowS = Math.floor(Date.now() / 1000)
|
|
76
129
|
if (u.limited_until && u.limited_until <= nowS) { u.limited_until = null; u.limited_reason = null; u.limited_at = null }
|
|
130
|
+
// …and a model's own wall, the same way: a Fable wall whose clock has run
|
|
131
|
+
// out must not keep claude/fable off the ladder for the rest of the week.
|
|
132
|
+
for (const [m, w] of Object.entries(u.walls ?? {})) if (!wallActive(w, nowS)) delete u.walls[m]
|
|
77
133
|
const wallMs = Date.parse(u.limited_at ?? u.updated_at ?? 0)
|
|
78
134
|
if (u.limited_until && available === true && (!Number.isFinite(wallMs) || !Number.isFinite(seenMs) || seenMs >= wallMs)) {
|
|
79
135
|
u.limited_until = null
|
|
@@ -91,7 +147,150 @@ export function recordUsage(agent, account, windows, source, { observed_at = new
|
|
|
91
147
|
return { ...value, usage_applied: applied }
|
|
92
148
|
}
|
|
93
149
|
|
|
94
|
-
|
|
150
|
+
// One ring per bucket, capped by TIME first and by HISTORY_MAX second: a new
|
|
151
|
+
// entry at most once per HISTORY_MIN_GAP_S, and never one older than the
|
|
152
|
+
// window it was measured in. A window that has reset starts its ring again: a
|
|
153
|
+
// rate computed across a reset is a wrong number, and a wrong number is worse
|
|
154
|
+
// than none.
|
|
155
|
+
//
|
|
156
|
+
// Why time and not writes: every attached terminal runs its own poller against
|
|
157
|
+
// the same per-login record, so three terminals write three times as often. A
|
|
158
|
+
// ring capped only by count then spans a third of the wall clock, falls under
|
|
159
|
+
// the 10-minute burn gate, and the forecast disappears from exactly the login
|
|
160
|
+
// the board's headline is about.
|
|
161
|
+
//
|
|
162
|
+
// The "only when it changed" rule is about NOISE (a status line writing the
|
|
163
|
+
// same 63 every second fills a 24-entry ring in half a minute), not about
|
|
164
|
+
// starving the forecast: a percentage that holds for an hour is a measured
|
|
165
|
+
// zero rate, and a ring that refuses to record it can never say so. One sample
|
|
166
|
+
// per ten minutes while the figure holds keeps both facts.
|
|
167
|
+
export const HISTORY_FLAT_S = 10 * 60
|
|
168
|
+
export const HISTORY_MIN_GAP_S = 60
|
|
169
|
+
// How long a bucket's own window runs, which is how far back its ring may
|
|
170
|
+
// reach. Weekly buckets run seven days; a session (5-hour) window runs five.
|
|
171
|
+
const WEEK_S = 7 * 24 * 3600
|
|
172
|
+
function windowLength(b) {
|
|
173
|
+
if (b?.group === 'weekly' || String(b?.kind ?? '').startsWith('weekly')) return WEEK_S
|
|
174
|
+
return DEFAULT_LIMIT_S
|
|
175
|
+
}
|
|
176
|
+
function recordHistory(history, oldBuckets, newBuckets, atS) {
|
|
177
|
+
const out = { ...history }
|
|
178
|
+
const before = new Map((oldBuckets ?? []).map((b) => [bucketKey(b), b]))
|
|
179
|
+
for (const b of newBuckets) {
|
|
180
|
+
if (!Number.isFinite(b?.percent)) continue
|
|
181
|
+
const key = bucketKey(b)
|
|
182
|
+
const prev = before.get(key)
|
|
183
|
+
const resets = b.resets_at ?? null
|
|
184
|
+
let ring = out[key] ?? []
|
|
185
|
+
// The window a ring belongs to is remembered ON the ring, not derived from
|
|
186
|
+
// the previous write: a bucket that was missing from one reading has no
|
|
187
|
+
// `prev`, and deriving it there carried the old window's samples into the
|
|
188
|
+
// new one and printed a forecast twice as long as the truth.
|
|
189
|
+
const lastWindow = ring.length ? ring[ring.length - 1].resets_at : undefined
|
|
190
|
+
const moved = lastWindow !== undefined ? lastWindow !== resets : Boolean(prev && prev.resets_at !== resets)
|
|
191
|
+
if (moved) ring = []
|
|
192
|
+
const last = ring.at(-1)
|
|
193
|
+
if (last && Number.isFinite(last.at)) {
|
|
194
|
+
const held = last.percent === b.percent
|
|
195
|
+
if (atS - last.at < (held ? HISTORY_FLAT_S : HISTORY_MIN_GAP_S)) { out[key] = ring; continue }
|
|
196
|
+
}
|
|
197
|
+
const maxAge = windowLength(b)
|
|
198
|
+
out[key] = [...ring, { percent: b.percent, at: atS, resets_at: resets }]
|
|
199
|
+
.filter((e) => Number.isFinite(e.at) && atS - e.at <= maxAge)
|
|
200
|
+
.slice(-HISTORY_MAX)
|
|
201
|
+
}
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The forecast (spec A.5 row 4, E rule 6): how long the bucket behind `key`
|
|
206
|
+
// lasts at the rate its own ring has been measured moving.
|
|
207
|
+
//
|
|
208
|
+
// The gate is 3 samples spanning 10 minutes, and it is the point of the whole
|
|
209
|
+
// function: a slope drawn through two readings a minute apart is a guess, and
|
|
210
|
+
// this number is printed in the largest type on the page. Every sample is
|
|
211
|
+
// inside the window that is running now, because the ring is emptied whenever
|
|
212
|
+
// `resets_at` moves.
|
|
213
|
+
//
|
|
214
|
+
// Endpoint slope, not least squares: the series is a counter that only rises,
|
|
215
|
+
// the two ends are what a human would draw through it, and the sentence that
|
|
216
|
+
// explains it ("from 9 samples over 4h") is the truth about it rather than a
|
|
217
|
+
// description of a fit nobody can check.
|
|
218
|
+
// → { seconds_left, samples, span_s, rate_pct_per_h } | null
|
|
219
|
+
export const BURN_MIN_SAMPLES = 3
|
|
220
|
+
export const BURN_MIN_SPAN_S = 10 * 60
|
|
221
|
+
export function burn(u, key, nowS = Math.floor(Date.now() / 1000)) {
|
|
222
|
+
const bucket = (Array.isArray(u?.buckets) ? u.buckets : []).find((x) => x && bucketKey(x) === key)
|
|
223
|
+
const ring = (Array.isArray(u?.history?.[key]) ? u.history[key] : [])
|
|
224
|
+
.filter((e) => e && Number.isFinite(e.percent) && Number.isFinite(e.at))
|
|
225
|
+
// a sample that names a different window than the bucket now standing is
|
|
226
|
+
// not part of this rate, whoever wrote it (an older record names none)
|
|
227
|
+
.filter((e) => e.resets_at === undefined || !bucket || e.resets_at === (bucket.resets_at ?? null))
|
|
228
|
+
if (ring.length < BURN_MIN_SAMPLES) return null
|
|
229
|
+
const first = ring[0]
|
|
230
|
+
const last = ring[ring.length - 1]
|
|
231
|
+
const span = last.at - first.at
|
|
232
|
+
if (span < BURN_MIN_SPAN_S) return null
|
|
233
|
+
const rate = (last.percent - first.percent) / span
|
|
234
|
+
// A flat line is a measured zero: real, and not a time. A falling percentage
|
|
235
|
+
// inside one window is a data error, not a refund, and a negative rate would
|
|
236
|
+
// print a time running backwards.
|
|
237
|
+
if (!(rate > 0)) return null
|
|
238
|
+
const resets = Number.isFinite(bucket?.resets_at) ? bucket.resets_at : null
|
|
239
|
+
// Never extrapolate across a reset. Past `resets_at` the percentage belongs
|
|
240
|
+
// to a window this rate says nothing about, so the time is capped there; a
|
|
241
|
+
// reset already behind us means the ring is waiting to be cleared by the next
|
|
242
|
+
// reading, and until it arrives there is no forecast at all.
|
|
243
|
+
if (resets !== null && resets <= nowS) return null
|
|
244
|
+
const toWall = (100 - last.percent) / rate
|
|
245
|
+
return {
|
|
246
|
+
seconds_left: Math.max(0, resets === null ? toWall : Math.min(toWall, resets - nowS)),
|
|
247
|
+
samples: ring.length,
|
|
248
|
+
span_s: span,
|
|
249
|
+
rate_pct_per_h: rate * 3600,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Is this model's own wall still standing? A wall with no clock, or one whose
|
|
254
|
+
// clock has passed, is not.
|
|
255
|
+
export function wallActive(wall, nowS = Math.floor(Date.now() / 1000)) {
|
|
256
|
+
return Boolean(wall && Number.isFinite(wall.limited_until) && wall.limited_until > nowS)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The bucket that will actually stop this terminal: the one the endpoint says
|
|
260
|
+
// is active, else the one scoped to the model being asked about, else the
|
|
261
|
+
// account's weekly, else its session, else the legacy hottest window (which is
|
|
262
|
+
// all an older record, or a login with no `limits[]`, has).
|
|
263
|
+
// → { kind, model, percent, resets_at, scope: 'model'|'account', forecast } | null
|
|
264
|
+
// `scope` is what decides whether another model on the same login can help.
|
|
265
|
+
// `forecast` is burn() for that same bucket, null whenever the sample gate
|
|
266
|
+
// fails, and it rides this object everywhere the binding bucket already goes
|
|
267
|
+
// (src/server.mjs puts it on each session as `capacity`), so the time figure on
|
|
268
|
+
// the board costs no second endpoint.
|
|
269
|
+
export function binding(u, model = null, nowS = Math.floor(Date.now() / 1000)) {
|
|
270
|
+
const buckets = Array.isArray(u?.buckets) ? u.buckets.filter((b) => b && Number.isFinite(b.percent)) : []
|
|
271
|
+
const pick = (list) => (list.length ? [...list].sort((a, b) => b.percent - a.percent)[0] : null)
|
|
272
|
+
const want = model ? String(model).toLowerCase() : null
|
|
273
|
+
// The active row answers for the model that was asked about, never for
|
|
274
|
+
// another one: a fable row at 100% is not the sonnet rung's percentage, and
|
|
275
|
+
// judging sonnet by it skips the whole downshift ladder (B.5). An
|
|
276
|
+
// account-scoped active row carries no model, so it still wins for every one.
|
|
277
|
+
const b = pick(buckets.filter((x) => x.is_active && (!want || !x.model || x.model === want)))
|
|
278
|
+
?? (want ? pick(buckets.filter((x) => x.model === want)) : null)
|
|
279
|
+
?? pick(buckets.filter((x) => x.kind === 'weekly_all'))
|
|
280
|
+
?? pick(buckets.filter((x) => x.kind === 'session'))
|
|
281
|
+
if (b) return { kind: b.kind, model: b.model ?? null, percent: b.percent, resets_at: b.resets_at ?? null, scope: b.model ? 'model' : 'account', forecast: burn(u, bucketKey(b), nowS) }
|
|
282
|
+
const h = hottest(u ?? {})
|
|
283
|
+
if (!h) return null
|
|
284
|
+
// the legacy path: a record with no `buckets` has no ring under either window
|
|
285
|
+
// key either, so burn() answers null and the percentage stands alone.
|
|
286
|
+
return { kind: h.window === '5h' ? 'five_hour' : 'seven_day', model: null, percent: h.pct, resets_at: h.resets_at ?? null, scope: 'account', forecast: null }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// `scope: 'model'` walls one model family and leaves the login open, so a
|
|
290
|
+
// Fable wall never stops claude/sonnet. `scope: 'account'` (the default, and
|
|
291
|
+
// what every caller did before) walls the login exactly as it always has.
|
|
292
|
+
export function markLimited(agent, account, { resets_at = null, reason = 'limit', source, observed_at = new Date().toISOString(), scope = 'account', model = null, evidence = null } = {}) {
|
|
293
|
+
if (scope === 'model' && model) return markModelLimited(agent, account, { resets_at, reason, source, observed_at, model: String(model).toLowerCase(), evidence })
|
|
95
294
|
let applied = false
|
|
96
295
|
const value = mutate(agent, account, (u) => {
|
|
97
296
|
const seenMs = Date.parse(observed_at)
|
|
@@ -118,6 +317,47 @@ export function markLimited(agent, account, { resets_at = null, reason = 'limit'
|
|
|
118
317
|
return { ...value, wall_applied: applied }
|
|
119
318
|
}
|
|
120
319
|
|
|
320
|
+
function markModelLimited(agent, account, { resets_at, reason, source, observed_at, model, evidence }) {
|
|
321
|
+
let applied = false
|
|
322
|
+
const value = mutate(agent, account, (u) => {
|
|
323
|
+
const seenMs = Date.parse(observed_at)
|
|
324
|
+
const currentMs = Date.parse(u.walls?.[model]?.limited_at ?? 0) || 0
|
|
325
|
+
if (Number.isFinite(seenMs) && currentMs && seenMs < currentMs) return false
|
|
326
|
+
const nowS = Math.floor(Date.now() / 1000)
|
|
327
|
+
let until = Number.isFinite(resets_at) && resets_at > nowS ? resets_at : null
|
|
328
|
+
if (!until) {
|
|
329
|
+
// this model's own bucket knows when it comes back; the account windows
|
|
330
|
+
// are the fallback, exactly as they are for an account wall.
|
|
331
|
+
const own = (u.buckets ?? []).find((b) => b?.model === model && Number.isFinite(b.resets_at) && b.resets_at > nowS)
|
|
332
|
+
if (own) until = own.resets_at
|
|
333
|
+
}
|
|
334
|
+
if (!until) {
|
|
335
|
+
// No bucket of its own: date it from the WEEKLY window, not the hottest
|
|
336
|
+
// one. Every wording that reaches here is a per-model limit, and a
|
|
337
|
+
// per-model limit is a weekly bucket (B.1, docs/en/costs). The account
|
|
338
|
+
// path's highest-used heuristic inverts for a model: the 5-hour window
|
|
339
|
+
// churns past 80% several times a day, so it would hand fable back in
|
|
340
|
+
// twelve minutes and re-wall it every few minutes for the rest of the week.
|
|
341
|
+
const weekly = (u.buckets ?? []).find((b) => String(b?.kind ?? '').startsWith('weekly') && Number.isFinite(b.resets_at) && b.resets_at > nowS)
|
|
342
|
+
if (u.seven_day && Number.isFinite(u.seven_day.resets_at) && u.seven_day.resets_at > nowS) until = u.seven_day.resets_at
|
|
343
|
+
else if (weekly) until = weekly.resets_at
|
|
344
|
+
else if (u.five_hour && Number.isFinite(u.five_hour.resets_at) && u.five_hour.resets_at > nowS) until = u.five_hour.resets_at
|
|
345
|
+
else until = nowS + DEFAULT_LIMIT_S
|
|
346
|
+
}
|
|
347
|
+
u.walls = { ...(u.walls ?? {}) }
|
|
348
|
+
u.walls[model] = {
|
|
349
|
+
limited_until: until,
|
|
350
|
+
limited_reason: reason,
|
|
351
|
+
limited_at: Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString(),
|
|
352
|
+
source: source ?? null,
|
|
353
|
+
evidence: evidence ? String(evidence).slice(0, 300) : null,
|
|
354
|
+
}
|
|
355
|
+
applied = true
|
|
356
|
+
return u
|
|
357
|
+
})
|
|
358
|
+
return { ...value, wall_applied: applied, wall_scope: 'model', wall_model: model }
|
|
359
|
+
}
|
|
360
|
+
|
|
121
361
|
export function clearLimited(agent, account) {
|
|
122
362
|
return mutate(agent, account, (u) => { u.limited_until = null; u.limited_reason = null; u.limited_at = null; return u })
|
|
123
363
|
}
|
|
@@ -150,28 +390,199 @@ export function hottest(u) {
|
|
|
150
390
|
// stays last whichever agent the terminal started on (codex → claude → agy
|
|
151
391
|
// hands claude to codex, never to agy first).
|
|
152
392
|
// accounts: { claude: ['default', 'work'], codex: ['default'], agy: ['default'] }
|
|
153
|
-
|
|
393
|
+
// A ladder walks the same way, one RUNG at a time. A rung is a destination
|
|
394
|
+
// ({agent, account, model}), so `claude/opus` after `claude/fable` is a real
|
|
395
|
+
// move, which an order of agent names could not express. The account fallback
|
|
396
|
+
// the order always had is kept around each rung: a login with two accounts
|
|
397
|
+
// still tries its other account, and the source agent's other accounts still
|
|
398
|
+
// come first. `model` is only ever on a rung that names one, so a ladder
|
|
399
|
+
// expanded from a bare order produces exactly the objects the order did.
|
|
400
|
+
export function candidates({ agent, account = 'default', accounts, order = AGENTS, ladder = null, model = null }) {
|
|
154
401
|
const out = []
|
|
155
|
-
|
|
156
|
-
|
|
402
|
+
if (!ladder) {
|
|
403
|
+
for (const a of accounts[agent] ?? ['default']) if (a !== account) out.push({ agent, account: a })
|
|
404
|
+
for (const ag of order) if (ag !== agent) for (const a of accounts[ag] ?? ['default']) out.push({ agent: ag, account: a })
|
|
405
|
+
return out
|
|
406
|
+
}
|
|
407
|
+
const seen = new Set()
|
|
408
|
+
const from = { agent, account, model: model ?? null }
|
|
409
|
+
const push = (r) => {
|
|
410
|
+
const key = `${r.agent}--${r.account}--${r.model ?? ''}`
|
|
411
|
+
if (seen.has(key)) return
|
|
412
|
+
// The same login is a destination only when the rung names a DIFFERENT
|
|
413
|
+
// model. Itself is not a hand-off, and neither is a rung with no model at
|
|
414
|
+
// all: "claude, whatever model it defaults to" on the login that just
|
|
415
|
+
// stopped is the walled model again as often as not, and Leg cannot know
|
|
416
|
+
// which. This is also what the agent order did before rungs existed.
|
|
417
|
+
if (r.agent === from.agent && r.account === from.account && (!r.model || r.model === from.model)) return
|
|
418
|
+
seen.add(key)
|
|
419
|
+
out.push({ agent: r.agent, account: r.account, ...(r.model ? { model: r.model } : {}), when: r.when ?? 'always', cost: r.cost ?? staticCost(r.agent) })
|
|
420
|
+
}
|
|
421
|
+
for (const a of accounts[agent] ?? ['default']) if (a !== account) push({ agent, account: a, model: null })
|
|
422
|
+
for (const r of ladder) {
|
|
423
|
+
push(r)
|
|
424
|
+
for (const a of accounts[r.agent] ?? ['default']) if (a !== r.account) push({ agent: r.agent, account: a, model: r.model ?? null, when: r.when, cost: r.cost })
|
|
425
|
+
}
|
|
157
426
|
return out
|
|
158
427
|
}
|
|
159
428
|
|
|
160
|
-
//
|
|
429
|
+
// The label a human reads for a rung: `claude/fable`, `codex`, `claude/work/opus`.
|
|
430
|
+
export function rungLabel(r) {
|
|
431
|
+
if (!r) return 'nothing'
|
|
432
|
+
return `${r.agent}${r.account && r.account !== 'default' ? '/' + r.account : ''}${r.model ? '/' + r.model : ''}`
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// One ledger line for a rung that was passed over. Exact wording matters: this
|
|
436
|
+
// is what the terminal and the card say instead of going somewhere unexplained.
|
|
437
|
+
export function skipLine(r) {
|
|
438
|
+
return `skipped ${rungLabel(r)}: ${r.reason}`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const COST_REASON = {
|
|
442
|
+
credits: 'it spends usage credits and you have not allowed that',
|
|
443
|
+
metered: 'it spends metered credits and you have not allowed that',
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Is this rung a destination right now, and if not, why not (B.3). One pass
|
|
447
|
+
// over the list, so the chooser, the board's picker and `leg ladder` all read
|
|
448
|
+
// the same answers and the same words.
|
|
449
|
+
// → [{ agent, account, model, cost, ok, reason, resets_at }]
|
|
450
|
+
export function evaluateLadder({
|
|
451
|
+
from, list, installed = null, nowS = Math.floor(Date.now() / 1000), exclude = [],
|
|
452
|
+
maySpend = false, reserve = {}, automatic = true, climbBack = 'next-handoff', ladder = null, read = readUsage,
|
|
453
|
+
} = {}) {
|
|
454
|
+
const usageOf = new Map()
|
|
455
|
+
const usage = (r) => {
|
|
456
|
+
const key = `${r.agent}--${r.account}`
|
|
457
|
+
if (!usageOf.has(key)) usageOf.set(key, read(r.agent, r.account))
|
|
458
|
+
return usageOf.get(key)
|
|
459
|
+
}
|
|
460
|
+
// What `walled-only` means by "walled": a rung above that could not take this
|
|
461
|
+
// hand-off in the next minute either way. The account wall and the model wall
|
|
462
|
+
// are the walls themselves; a bucket at 100% is at its limit with or without
|
|
463
|
+
// a recorded wall; and a rung that is not installed on this machine, or that
|
|
464
|
+
// the strict harness policy refused for this hand-off, is not an open rung
|
|
465
|
+
// above by any reading. The cost gate is deliberately NOT in this list: a
|
|
466
|
+
// rung the human could take by allowing spending is a rung that is open.
|
|
467
|
+
const walled = list.map((r) => {
|
|
468
|
+
const u = usage(r)
|
|
469
|
+
if (!isAvailable(u, nowS)) return true
|
|
470
|
+
if (r.model && wallActive(u.walls?.[r.model], nowS)) return true
|
|
471
|
+
if (installed && installed[r.agent] === false) return true
|
|
472
|
+
if (exclude.some((x) => x.agent === r.agent && x.account === r.account)) return true
|
|
473
|
+
const b = binding(u, r.model ?? null, nowS)
|
|
474
|
+
return Boolean(b && Number.isFinite(b.percent) && b.percent >= 100)
|
|
475
|
+
})
|
|
476
|
+
const rank = (r) => (ladder ?? []).findIndex((x) => x.agent === r.agent && x.account === r.account && (x.model ?? null) === (r.model ?? null))
|
|
477
|
+
const fromRank = from ? rank(from) : -1
|
|
478
|
+
return list.map((r, i) => {
|
|
479
|
+
const u = usage(r)
|
|
480
|
+
const cost = rungCost(r, u)
|
|
481
|
+
const row = { agent: r.agent, account: r.account, model: r.model ?? null, cost, ok: true, reason: null, resets_at: null }
|
|
482
|
+
if (installed && installed[r.agent] === false) return { ...row, ok: false, reason: 'not installed on this machine' }
|
|
483
|
+
if (exclude.some((x) => x.agent === r.agent && x.account === r.account)) return { ...row, ok: false, reason: 'refused for this hand-off' }
|
|
484
|
+
// The cost gate. `-p` mode bills a credits request without asking and an
|
|
485
|
+
// interactive one stalls five minutes at a consent prompt nobody is there
|
|
486
|
+
// to answer (B.5), so an unattended hand-off never takes one unless the
|
|
487
|
+
// human turned spending on.
|
|
488
|
+
if (!['free', 'plan'].includes(cost) && !maySpend) return { ...row, ok: false, reason: COST_REASON[cost] ?? `it spends ${cost} and you have not allowed that` }
|
|
489
|
+
const b = binding(u, r.model ?? null)
|
|
490
|
+
const sameLogin = Boolean(from && r.agent === from.agent && r.account === from.account)
|
|
491
|
+
// The wasted switch: the same login as the terminal that stopped, and what
|
|
492
|
+
// is out is the account's own window, which every model shares
|
|
493
|
+
// (docs/en/costs). Another model here cannot help, and offering it would be
|
|
494
|
+
// a lie with a button on it. Said with the account wall's own words,
|
|
495
|
+
// because on this login that IS what the wall means.
|
|
496
|
+
if (sameLogin && (!isAvailable(u, nowS) || (b && b.scope === 'account' && b.percent >= 100))) {
|
|
497
|
+
return { ...row, ok: false, reason: 'shares the window that is out, buys nothing', resets_at: u.limited_until ?? null }
|
|
498
|
+
}
|
|
499
|
+
if (!isAvailable(u, nowS)) return { ...row, ok: false, reason: 'at its usage limit', resets_at: u.limited_until ?? null }
|
|
500
|
+
if (r.model && wallActive(u.walls?.[r.model], nowS)) return { ...row, ok: false, reason: `the ${r.model} window is out`, resets_at: u.walls[r.model].limited_until ?? null }
|
|
501
|
+
if (automatic && climbBack === 'never' && from && r.agent === from.agent && r.account === from.account && fromRank >= 0 && rank(r) >= 0 && rank(r) < fromRank) {
|
|
502
|
+
return { ...row, ok: false, reason: 'climb-back is off; Back to the top rung does it by hand' }
|
|
503
|
+
}
|
|
504
|
+
const floor = Number(reserve?.[r.agent])
|
|
505
|
+
if (Number.isFinite(floor) && b && Number.isFinite(b.percent) && b.percent > 100 - floor) {
|
|
506
|
+
// A human pressing Hand off > ignores the reserve; the row still says so
|
|
507
|
+
// rather than hiding, because a floor you cannot see is a floor you swear at.
|
|
508
|
+
if (automatic) return { ...row, ok: false, reason: `past your ${floor}% reserve` }
|
|
509
|
+
return { ...row, reason: `past your ${floor}% reserve` }
|
|
510
|
+
}
|
|
511
|
+
const when = r.when ?? 'always'
|
|
512
|
+
if (when.startsWith('below:')) {
|
|
513
|
+
const n = Number(when.slice('below:'.length))
|
|
514
|
+
if (!b || !Number.isFinite(b.percent)) return { ...row, ok: false, reason: `no reading, so "below ${n}%" cannot be checked` }
|
|
515
|
+
if (!(b.percent < n)) return { ...row, ok: false, reason: `at ${Math.round(b.percent)}%, not below ${n}%` }
|
|
516
|
+
}
|
|
517
|
+
if (when === 'walled-only') {
|
|
518
|
+
const aboveOpen = list.slice(0, i).some((_, j) => !walled[j])
|
|
519
|
+
if (aboveOpen) return { ...row, ok: false, reason: 'only when every rung above it is walled' }
|
|
520
|
+
}
|
|
521
|
+
return row
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted
|
|
526
|
+
// by reset, preferred_taken: bool }
|
|
161
527
|
// `exclude` names (agent, account) pairs this choice must skip: a destination
|
|
162
528
|
// the strict harness policy refused is neither available nor out, it is off
|
|
163
529
|
// the list for this hand-off.
|
|
164
|
-
|
|
530
|
+
// `prefer` is a human's pick from the board ("Hand off now to codex"). It wins
|
|
531
|
+
// over the saved order when it is installed, available and not excluded. When
|
|
532
|
+
// it is none of those the order decides instead and `preferred_taken` is false,
|
|
533
|
+
// which is what the session event says: a pick made a minute ago must not leave
|
|
534
|
+
// a terminal stopped because that account walled in the meantime.
|
|
535
|
+
export function chooseNext({
|
|
536
|
+
agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null,
|
|
537
|
+
ladder = null, model = null, maySpend = false, reserve = {}, automatic = null, climbBack = 'next-handoff',
|
|
538
|
+
}) {
|
|
165
539
|
const out = []
|
|
166
|
-
|
|
540
|
+
if (ladder) {
|
|
541
|
+
// The ladder walk. `reasons` carries one line per rung that was passed
|
|
542
|
+
// over, so the ledger and the picker can say what was skipped and why
|
|
543
|
+
// instead of a terminal turning up somewhere unexplained.
|
|
544
|
+
const reasons = []
|
|
545
|
+
const list = candidates({ agent, account, accounts, order, ladder, model })
|
|
546
|
+
// Only a caller that says nothing at all falls back to the old inference.
|
|
547
|
+
// "No destination named" is NOT "nobody asked": the plain Hand off now
|
|
548
|
+
// button sends no target, and reading that as automatic applied the reserve
|
|
549
|
+
// and the cost gate to a hand-off a human had just pressed (B.3).
|
|
550
|
+
const auto = automatic === null ? !prefer : automatic
|
|
551
|
+
const rows = evaluateLadder({ from: { agent, account, model: model ?? null }, list, installed, nowS, exclude, maySpend, reserve, automatic: auto, climbBack, ladder })
|
|
552
|
+
const trim = (r) => ({ agent: r.agent, account: r.account, ...(r.model ? { model: r.model } : {}) })
|
|
553
|
+
const noteOut = (r) => { if (Number.isFinite(r.resets_at) && !out.some((x) => x.agent === r.agent && x.account === r.account)) out.push({ agent: r.agent, account: r.account, resets_at: r.resets_at, reason: r.reason }) }
|
|
554
|
+
if (prefer) {
|
|
555
|
+
const want = { agent: prefer.agent, account: prefer.account ?? 'default', model: prefer.model ?? null }
|
|
556
|
+
const hit = rows.find((r) => r.agent === want.agent && r.account === want.account && (want.model ? r.model === want.model : true))
|
|
557
|
+
if (hit && hit.ok) return { next: trim(hit), out, reasons, preferred_taken: true }
|
|
558
|
+
}
|
|
559
|
+
for (const r of rows) {
|
|
560
|
+
if (r.ok) return { next: trim(r), out, reasons, preferred_taken: false }
|
|
561
|
+
reasons.push({ agent: r.agent, account: r.account, model: r.model, reason: r.reason })
|
|
562
|
+
noteOut(r)
|
|
563
|
+
}
|
|
564
|
+
out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
|
|
565
|
+
return { next: null, out, reasons, preferred_taken: false }
|
|
566
|
+
}
|
|
567
|
+
const list = candidates({ agent, account, accounts, order })
|
|
568
|
+
const eligible = (c) => {
|
|
569
|
+
if (installed && installed[c.agent] === false) return false
|
|
570
|
+
if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) return false
|
|
571
|
+
return isAvailable(readUsage(c.agent, c.account), nowS)
|
|
572
|
+
}
|
|
573
|
+
if (prefer) {
|
|
574
|
+
const hit = list.find((c) => c.agent === prefer.agent && c.account === (prefer.account ?? 'default'))
|
|
575
|
+
if (hit && eligible(hit)) return { next: hit, out, reasons: [], preferred_taken: true }
|
|
576
|
+
}
|
|
577
|
+
for (const c of list) {
|
|
167
578
|
if (installed && installed[c.agent] === false) continue
|
|
168
579
|
if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) continue
|
|
169
580
|
const u = readUsage(c.agent, c.account)
|
|
170
|
-
if (isAvailable(u, nowS)) return { next: c, out }
|
|
581
|
+
if (isAvailable(u, nowS)) return { next: c, out, reasons: [], preferred_taken: false }
|
|
171
582
|
out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
|
|
172
583
|
}
|
|
173
584
|
out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
|
|
174
|
-
return { next: null, out }
|
|
585
|
+
return { next: null, out, reasons: [], preferred_taken: false }
|
|
175
586
|
}
|
|
176
587
|
|
|
177
588
|
export function fmtReset(epochS) {
|