@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
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
- export function usageFile(agent, account = 'default') { return join(usageDir(), `${agent}--${account}.json`) }
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,49 @@ 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, 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 }
30
82
  }
31
83
 
84
+ // The ring key for a bucket: the kind alone when it is account-wide, the kind
85
+ // and the model when it is scoped ('weekly_scoped:fable').
86
+ export function bucketKey(b) {
87
+ return b.model ? `${b.kind}:${b.model}` : String(b.kind)
88
+ }
89
+
90
+ export const HISTORY_MAX = 24
91
+
32
92
  export function listUsage() {
33
93
  const dir = usageDir()
34
94
  if (!existsSync(dir)) return []
@@ -55,7 +115,9 @@ function mutate(agent, account, fn) {
55
115
  })
56
116
  }
57
117
 
58
- // windows: { five_hour: {pct, resets_at}|null, seven_day: ... }
118
+ // windows: { five_hour: {pct, resets_at}|null, seven_day: …,
119
+ // buckets: [...]|undefined, extra_usage: {...}|undefined,
120
+ // facts: {...}|undefined }
59
121
  // `available` must be an explicit backend answer. Percentages cannot clear a
60
122
  // wall: Codex's rate-limit schema says null availability is unknown, even when
61
123
  // a window is below 100%.
@@ -67,6 +129,29 @@ export function recordUsage(agent, account, windows, source, { observed_at = new
67
129
  if (Number.isFinite(seenMs) && Number.isFinite(currentMs) && seenMs < currentMs) return false
68
130
  if (windows.five_hour !== undefined) u.five_hour = windows.five_hour
69
131
  if (windows.seven_day !== undefined) u.seven_day = windows.seven_day
132
+ if (Array.isArray(windows.buckets)) {
133
+ const atS = Number.isFinite(seenMs) ? Math.floor(seenMs / 1000) : Math.floor(Date.now() / 1000)
134
+ if (windows.buckets.length) {
135
+ u.history = recordHistory(u.history ?? {}, u.buckets ?? [], windows.buckets, atS)
136
+ u.buckets = windows.buckets
137
+ } else {
138
+ // An empty list is no information, not "this login has no buckets": an
139
+ // older endpoint answers the two windows and no `limits` key at all
140
+ // (src/taps/claude-usage.mjs), and erasing the measured buckets on it
141
+ // loses the wall clock, the ring and the binding bucket in one write.
142
+ // The one thing an empty reading does settle is a window that has run
143
+ // out: a bucket whose reset has passed is dropped rather than kept.
144
+ const kept = (u.buckets ?? []).filter((b) => !(Number.isFinite(b?.resets_at) && b.resets_at <= atS))
145
+ for (const b of u.buckets ?? []) if (!kept.includes(b)) delete u.history?.[bucketKey(b)]
146
+ u.buckets = kept
147
+ }
148
+ }
149
+ if (windows.extra_usage !== undefined) u.extra_usage = windows.extra_usage
150
+ if (windows.facts && typeof windows.facts === 'object') {
151
+ const next = { ...(u.facts ?? {}) }
152
+ for (const [k, v] of Object.entries(windows.facts)) if (v !== undefined && v !== null) next[k] = v
153
+ u.facts = Object.keys(next).length ? next : null
154
+ }
70
155
  u.source = source
71
156
  u.observed_at = Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString()
72
157
  applied = true
@@ -74,6 +159,9 @@ export function recordUsage(agent, account, windows, source, { observed_at = new
74
159
  // A window that has reset clears an old wall.
75
160
  const nowS = Math.floor(Date.now() / 1000)
76
161
  if (u.limited_until && u.limited_until <= nowS) { u.limited_until = null; u.limited_reason = null; u.limited_at = null }
162
+ // …and a model's own wall, the same way: a Fable wall whose clock has run
163
+ // out must not keep claude/fable off the ladder for the rest of the week.
164
+ for (const [m, w] of Object.entries(u.walls ?? {})) if (!wallActive(w, nowS)) delete u.walls[m]
77
165
  const wallMs = Date.parse(u.limited_at ?? u.updated_at ?? 0)
78
166
  if (u.limited_until && available === true && (!Number.isFinite(wallMs) || !Number.isFinite(seenMs) || seenMs >= wallMs)) {
79
167
  u.limited_until = null
@@ -91,7 +179,150 @@ export function recordUsage(agent, account, windows, source, { observed_at = new
91
179
  return { ...value, usage_applied: applied }
92
180
  }
93
181
 
94
- export function markLimited(agent, account, { resets_at = null, reason = 'limit', source, observed_at = new Date().toISOString() } = {}) {
182
+ // One ring per bucket, capped by TIME first and by HISTORY_MAX second: a new
183
+ // entry at most once per HISTORY_MIN_GAP_S, and never one older than the
184
+ // window it was measured in. A window that has reset starts its ring again: a
185
+ // rate computed across a reset is a wrong number, and a wrong number is worse
186
+ // than none.
187
+ //
188
+ // Why time and not writes: every attached terminal runs its own poller against
189
+ // the same per-login record, so three terminals write three times as often. A
190
+ // ring capped only by count then spans a third of the wall clock, falls under
191
+ // the 10-minute burn gate, and the forecast disappears from exactly the login
192
+ // the board's headline is about.
193
+ //
194
+ // The "only when it changed" rule is about NOISE (a status line writing the
195
+ // same 63 every second fills a 24-entry ring in half a minute), not about
196
+ // starving the forecast: a percentage that holds for an hour is a measured
197
+ // zero rate, and a ring that refuses to record it can never say so. One sample
198
+ // per ten minutes while the figure holds keeps both facts.
199
+ export const HISTORY_FLAT_S = 10 * 60
200
+ export const HISTORY_MIN_GAP_S = 60
201
+ // How long a bucket's own window runs, which is how far back its ring may
202
+ // reach. Weekly buckets run seven days; a session (5-hour) window runs five.
203
+ const WEEK_S = 7 * 24 * 3600
204
+ function windowLength(b) {
205
+ if (b?.group === 'weekly' || String(b?.kind ?? '').startsWith('weekly')) return WEEK_S
206
+ return DEFAULT_LIMIT_S
207
+ }
208
+ function recordHistory(history, oldBuckets, newBuckets, atS) {
209
+ const out = { ...history }
210
+ const before = new Map((oldBuckets ?? []).map((b) => [bucketKey(b), b]))
211
+ for (const b of newBuckets) {
212
+ if (!Number.isFinite(b?.percent)) continue
213
+ const key = bucketKey(b)
214
+ const prev = before.get(key)
215
+ const resets = b.resets_at ?? null
216
+ let ring = out[key] ?? []
217
+ // The window a ring belongs to is remembered ON the ring, not derived from
218
+ // the previous write: a bucket that was missing from one reading has no
219
+ // `prev`, and deriving it there carried the old window's samples into the
220
+ // new one and printed a forecast twice as long as the truth.
221
+ const lastWindow = ring.length ? ring[ring.length - 1].resets_at : undefined
222
+ const moved = lastWindow !== undefined ? lastWindow !== resets : Boolean(prev && prev.resets_at !== resets)
223
+ if (moved) ring = []
224
+ const last = ring.at(-1)
225
+ if (last && Number.isFinite(last.at)) {
226
+ const held = last.percent === b.percent
227
+ if (atS - last.at < (held ? HISTORY_FLAT_S : HISTORY_MIN_GAP_S)) { out[key] = ring; continue }
228
+ }
229
+ const maxAge = windowLength(b)
230
+ out[key] = [...ring, { percent: b.percent, at: atS, resets_at: resets }]
231
+ .filter((e) => Number.isFinite(e.at) && atS - e.at <= maxAge)
232
+ .slice(-HISTORY_MAX)
233
+ }
234
+ return out
235
+ }
236
+
237
+ // The forecast (spec A.5 row 4, E rule 6): how long the bucket behind `key`
238
+ // lasts at the rate its own ring has been measured moving.
239
+ //
240
+ // The gate is 3 samples spanning 10 minutes, and it is the point of the whole
241
+ // function: a slope drawn through two readings a minute apart is a guess, and
242
+ // this number is printed in the largest type on the page. Every sample is
243
+ // inside the window that is running now, because the ring is emptied whenever
244
+ // `resets_at` moves.
245
+ //
246
+ // Endpoint slope, not least squares: the series is a counter that only rises,
247
+ // the two ends are what a human would draw through it, and the sentence that
248
+ // explains it ("from 9 samples over 4h") is the truth about it rather than a
249
+ // description of a fit nobody can check.
250
+ // → { seconds_left, samples, span_s, rate_pct_per_h } | null
251
+ export const BURN_MIN_SAMPLES = 3
252
+ export const BURN_MIN_SPAN_S = 10 * 60
253
+ export function burn(u, key, nowS = Math.floor(Date.now() / 1000)) {
254
+ const bucket = (Array.isArray(u?.buckets) ? u.buckets : []).find((x) => x && bucketKey(x) === key)
255
+ const ring = (Array.isArray(u?.history?.[key]) ? u.history[key] : [])
256
+ .filter((e) => e && Number.isFinite(e.percent) && Number.isFinite(e.at))
257
+ // a sample that names a different window than the bucket now standing is
258
+ // not part of this rate, whoever wrote it (an older record names none)
259
+ .filter((e) => e.resets_at === undefined || !bucket || e.resets_at === (bucket.resets_at ?? null))
260
+ if (ring.length < BURN_MIN_SAMPLES) return null
261
+ const first = ring[0]
262
+ const last = ring[ring.length - 1]
263
+ const span = last.at - first.at
264
+ if (span < BURN_MIN_SPAN_S) return null
265
+ const rate = (last.percent - first.percent) / span
266
+ // A flat line is a measured zero: real, and not a time. A falling percentage
267
+ // inside one window is a data error, not a refund, and a negative rate would
268
+ // print a time running backwards.
269
+ if (!(rate > 0)) return null
270
+ const resets = Number.isFinite(bucket?.resets_at) ? bucket.resets_at : null
271
+ // Never extrapolate across a reset. Past `resets_at` the percentage belongs
272
+ // to a window this rate says nothing about, so the time is capped there; a
273
+ // reset already behind us means the ring is waiting to be cleared by the next
274
+ // reading, and until it arrives there is no forecast at all.
275
+ if (resets !== null && resets <= nowS) return null
276
+ const toWall = (100 - last.percent) / rate
277
+ return {
278
+ seconds_left: Math.max(0, resets === null ? toWall : Math.min(toWall, resets - nowS)),
279
+ samples: ring.length,
280
+ span_s: span,
281
+ rate_pct_per_h: rate * 3600,
282
+ }
283
+ }
284
+
285
+ // Is this model's own wall still standing? A wall with no clock, or one whose
286
+ // clock has passed, is not.
287
+ export function wallActive(wall, nowS = Math.floor(Date.now() / 1000)) {
288
+ return Boolean(wall && Number.isFinite(wall.limited_until) && wall.limited_until > nowS)
289
+ }
290
+
291
+ // The bucket that will actually stop this terminal: the one the endpoint says
292
+ // is active, else the one scoped to the model being asked about, else the
293
+ // account's weekly, else its session, else the legacy hottest window (which is
294
+ // all an older record, or a login with no `limits[]`, has).
295
+ // → { kind, model, percent, resets_at, scope: 'model'|'account', forecast } | null
296
+ // `scope` is what decides whether another model on the same login can help.
297
+ // `forecast` is burn() for that same bucket, null whenever the sample gate
298
+ // fails, and it rides this object everywhere the binding bucket already goes
299
+ // (src/server.mjs puts it on each session as `capacity`), so the time figure on
300
+ // the board costs no second endpoint.
301
+ export function binding(u, model = null, nowS = Math.floor(Date.now() / 1000)) {
302
+ const buckets = Array.isArray(u?.buckets) ? u.buckets.filter((b) => b && Number.isFinite(b.percent)) : []
303
+ const pick = (list) => (list.length ? [...list].sort((a, b) => b.percent - a.percent)[0] : null)
304
+ const want = model ? String(model).toLowerCase() : null
305
+ // The active row answers for the model that was asked about, never for
306
+ // another one: a fable row at 100% is not the sonnet rung's percentage, and
307
+ // judging sonnet by it skips the whole downshift ladder (B.5). An
308
+ // account-scoped active row carries no model, so it still wins for every one.
309
+ const b = pick(buckets.filter((x) => x.is_active && (!want || !x.model || x.model === want)))
310
+ ?? (want ? pick(buckets.filter((x) => x.model === want)) : null)
311
+ ?? pick(buckets.filter((x) => x.kind === 'weekly_all'))
312
+ ?? pick(buckets.filter((x) => x.kind === 'session'))
313
+ 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) }
314
+ const h = hottest(u ?? {})
315
+ if (!h) return null
316
+ // the legacy path: a record with no `buckets` has no ring under either window
317
+ // key either, so burn() answers null and the percentage stands alone.
318
+ return { kind: h.window === '5h' ? 'five_hour' : 'seven_day', model: null, percent: h.pct, resets_at: h.resets_at ?? null, scope: 'account', forecast: null }
319
+ }
320
+
321
+ // `scope: 'model'` walls one model family and leaves the login open, so a
322
+ // Fable wall never stops claude/sonnet. `scope: 'account'` (the default, and
323
+ // what every caller did before) walls the login exactly as it always has.
324
+ export function markLimited(agent, account, { resets_at = null, reason = 'limit', source, observed_at = new Date().toISOString(), scope = 'account', model = null, evidence = null } = {}) {
325
+ if (scope === 'model' && model) return markModelLimited(agent, account, { resets_at, reason, source, observed_at, model: String(model).toLowerCase(), evidence })
95
326
  let applied = false
96
327
  const value = mutate(agent, account, (u) => {
97
328
  const seenMs = Date.parse(observed_at)
@@ -118,6 +349,47 @@ export function markLimited(agent, account, { resets_at = null, reason = 'limit'
118
349
  return { ...value, wall_applied: applied }
119
350
  }
120
351
 
352
+ function markModelLimited(agent, account, { resets_at, reason, source, observed_at, model, evidence }) {
353
+ let applied = false
354
+ const value = mutate(agent, account, (u) => {
355
+ const seenMs = Date.parse(observed_at)
356
+ const currentMs = Date.parse(u.walls?.[model]?.limited_at ?? 0) || 0
357
+ if (Number.isFinite(seenMs) && currentMs && seenMs < currentMs) return false
358
+ const nowS = Math.floor(Date.now() / 1000)
359
+ let until = Number.isFinite(resets_at) && resets_at > nowS ? resets_at : null
360
+ if (!until) {
361
+ // this model's own bucket knows when it comes back; the account windows
362
+ // are the fallback, exactly as they are for an account wall.
363
+ const own = (u.buckets ?? []).find((b) => b?.model === model && Number.isFinite(b.resets_at) && b.resets_at > nowS)
364
+ if (own) until = own.resets_at
365
+ }
366
+ if (!until) {
367
+ // No bucket of its own: date it from the WEEKLY window, not the hottest
368
+ // one. Every wording that reaches here is a per-model limit, and a
369
+ // per-model limit is a weekly bucket (B.1, docs/en/costs). The account
370
+ // path's highest-used heuristic inverts for a model: the 5-hour window
371
+ // churns past 80% several times a day, so it would hand fable back in
372
+ // twelve minutes and re-wall it every few minutes for the rest of the week.
373
+ const weekly = (u.buckets ?? []).find((b) => String(b?.kind ?? '').startsWith('weekly') && Number.isFinite(b.resets_at) && b.resets_at > nowS)
374
+ if (u.seven_day && Number.isFinite(u.seven_day.resets_at) && u.seven_day.resets_at > nowS) until = u.seven_day.resets_at
375
+ else if (weekly) until = weekly.resets_at
376
+ else if (u.five_hour && Number.isFinite(u.five_hour.resets_at) && u.five_hour.resets_at > nowS) until = u.five_hour.resets_at
377
+ else until = nowS + DEFAULT_LIMIT_S
378
+ }
379
+ u.walls = { ...(u.walls ?? {}) }
380
+ u.walls[model] = {
381
+ limited_until: until,
382
+ limited_reason: reason,
383
+ limited_at: Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString(),
384
+ source: source ?? null,
385
+ evidence: evidence ? String(evidence).slice(0, 300) : null,
386
+ }
387
+ applied = true
388
+ return u
389
+ })
390
+ return { ...value, wall_applied: applied, wall_scope: 'model', wall_model: model }
391
+ }
392
+
121
393
  export function clearLimited(agent, account) {
122
394
  return mutate(agent, account, (u) => { u.limited_until = null; u.limited_reason = null; u.limited_at = null; return u })
123
395
  }
@@ -150,13 +422,138 @@ export function hottest(u) {
150
422
  // stays last whichever agent the terminal started on (codex → claude → agy
151
423
  // hands claude to codex, never to agy first).
152
424
  // accounts: { claude: ['default', 'work'], codex: ['default'], agy: ['default'] }
153
- export function candidates({ agent, account = 'default', accounts, order = AGENTS }) {
425
+ // A ladder walks the same way, one RUNG at a time. A rung is a destination
426
+ // ({agent, account, model}), so `claude/opus` after `claude/fable` is a real
427
+ // move, which an order of agent names could not express. The account fallback
428
+ // the order always had is kept around each rung: a login with two accounts
429
+ // still tries its other account, and the source agent's other accounts still
430
+ // come first. `model` is only ever on a rung that names one, so a ladder
431
+ // expanded from a bare order produces exactly the objects the order did.
432
+ export function candidates({ agent, account = 'default', accounts, order = AGENTS, ladder = null, model = null }) {
154
433
  const out = []
155
- for (const a of accounts[agent] ?? ['default']) if (a !== account) out.push({ agent, account: a })
156
- for (const ag of order) if (ag !== agent) for (const a of accounts[ag] ?? ['default']) out.push({ agent: ag, account: a })
434
+ if (!ladder) {
435
+ for (const a of accounts[agent] ?? ['default']) if (a !== account) out.push({ agent, account: a })
436
+ for (const ag of order) if (ag !== agent) for (const a of accounts[ag] ?? ['default']) out.push({ agent: ag, account: a })
437
+ return out
438
+ }
439
+ const seen = new Set()
440
+ const from = { agent, account, model: model ?? null }
441
+ const push = (r) => {
442
+ const key = `${r.agent}--${r.account}--${r.model ?? ''}`
443
+ if (seen.has(key)) return
444
+ // The same login is a destination only when the rung names a DIFFERENT
445
+ // model. Itself is not a hand-off, and neither is a rung with no model at
446
+ // all: "claude, whatever model it defaults to" on the login that just
447
+ // stopped is the walled model again as often as not, and Leg cannot know
448
+ // which. This is also what the agent order did before rungs existed.
449
+ if (r.agent === from.agent && r.account === from.account && (!r.model || r.model === from.model)) return
450
+ seen.add(key)
451
+ out.push({ agent: r.agent, account: r.account, ...(r.model ? { model: r.model } : {}), when: r.when ?? 'always', cost: r.cost ?? staticCost(r.agent) })
452
+ }
453
+ for (const a of accounts[agent] ?? ['default']) if (a !== account) push({ agent, account: a, model: null })
454
+ for (const r of ladder) {
455
+ push(r)
456
+ 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 })
457
+ }
157
458
  return out
158
459
  }
159
460
 
461
+ // The label a human reads for a rung: `claude/fable`, `codex`, `claude/work/opus`.
462
+ export function rungLabel(r) {
463
+ if (!r) return 'nothing'
464
+ return `${r.agent}${r.account && r.account !== 'default' ? '/' + r.account : ''}${r.model ? '/' + r.model : ''}`
465
+ }
466
+
467
+ // One ledger line for a rung that was passed over. Exact wording matters: this
468
+ // is what the terminal and the card say instead of going somewhere unexplained.
469
+ export function skipLine(r) {
470
+ return `skipped ${rungLabel(r)}: ${r.reason}`
471
+ }
472
+
473
+ const COST_REASON = {
474
+ credits: 'it spends usage credits and you have not allowed that',
475
+ metered: 'it spends metered credits and you have not allowed that',
476
+ }
477
+
478
+ // Is this rung a destination right now, and if not, why not (B.3). One pass
479
+ // over the list, so the chooser, the board's picker and `leg ladder` all read
480
+ // the same answers and the same words.
481
+ // → [{ agent, account, model, cost, ok, reason, resets_at }]
482
+ export function evaluateLadder({
483
+ from, list, installed = null, nowS = Math.floor(Date.now() / 1000), exclude = [],
484
+ maySpend = false, reserve = {}, automatic = true, climbBack = 'next-handoff', ladder = null, read = readUsage,
485
+ } = {}) {
486
+ const usageOf = new Map()
487
+ const usage = (r) => {
488
+ const key = `${r.agent}--${r.account}`
489
+ if (!usageOf.has(key)) usageOf.set(key, read(r.agent, r.account))
490
+ return usageOf.get(key)
491
+ }
492
+ // What `walled-only` means by "walled": a rung above that could not take this
493
+ // hand-off in the next minute either way. The account wall and the model wall
494
+ // are the walls themselves; a bucket at 100% is at its limit with or without
495
+ // a recorded wall; and a rung that is not installed on this machine, or that
496
+ // the strict harness policy refused for this hand-off, is not an open rung
497
+ // above by any reading. The cost gate is deliberately NOT in this list: a
498
+ // rung the human could take by allowing spending is a rung that is open.
499
+ const walled = list.map((r) => {
500
+ const u = usage(r)
501
+ if (!isAvailable(u, nowS)) return true
502
+ if (r.model && wallActive(u.walls?.[r.model], nowS)) return true
503
+ if (installed && installed[r.agent] === false) return true
504
+ if (exclude.some((x) => x.agent === r.agent && x.account === r.account)) return true
505
+ const b = binding(u, r.model ?? null, nowS)
506
+ return Boolean(b && Number.isFinite(b.percent) && b.percent >= 100)
507
+ })
508
+ const rank = (r) => (ladder ?? []).findIndex((x) => x.agent === r.agent && x.account === r.account && (x.model ?? null) === (r.model ?? null))
509
+ const fromRank = from ? rank(from) : -1
510
+ return list.map((r, i) => {
511
+ const u = usage(r)
512
+ const cost = rungCost(r, u)
513
+ const row = { agent: r.agent, account: r.account, model: r.model ?? null, cost, ok: true, reason: null, resets_at: null }
514
+ if (installed && installed[r.agent] === false) return { ...row, ok: false, reason: 'not installed on this machine' }
515
+ if (exclude.some((x) => x.agent === r.agent && x.account === r.account)) return { ...row, ok: false, reason: 'refused for this hand-off' }
516
+ // The cost gate. `-p` mode bills a credits request without asking and an
517
+ // interactive one stalls five minutes at a consent prompt nobody is there
518
+ // to answer (B.5), so an unattended hand-off never takes one unless the
519
+ // human turned spending on.
520
+ if (!['free', 'plan'].includes(cost) && !maySpend) return { ...row, ok: false, reason: COST_REASON[cost] ?? `it spends ${cost} and you have not allowed that` }
521
+ const b = binding(u, r.model ?? null)
522
+ const sameLogin = Boolean(from && r.agent === from.agent && r.account === from.account)
523
+ // The wasted switch: the same login as the terminal that stopped, and what
524
+ // is out is the account's own window, which every model shares
525
+ // (docs/en/costs). Another model here cannot help, and offering it would be
526
+ // a lie with a button on it. Said with the account wall's own words,
527
+ // because on this login that IS what the wall means.
528
+ if (sameLogin && (!isAvailable(u, nowS) || (b && b.scope === 'account' && b.percent >= 100))) {
529
+ return { ...row, ok: false, reason: 'shares the window that is out, buys nothing', resets_at: u.limited_until ?? null }
530
+ }
531
+ if (!isAvailable(u, nowS)) return { ...row, ok: false, reason: 'at its usage limit', resets_at: u.limited_until ?? null }
532
+ 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 }
533
+ if (automatic && climbBack === 'never' && from && r.agent === from.agent && r.account === from.account && fromRank >= 0 && rank(r) >= 0 && rank(r) < fromRank) {
534
+ return { ...row, ok: false, reason: 'climb-back is off; Back to the top rung does it by hand' }
535
+ }
536
+ const floor = Number(reserve?.[r.agent])
537
+ if (Number.isFinite(floor) && b && Number.isFinite(b.percent) && b.percent > 100 - floor) {
538
+ // A human pressing Hand off > ignores the reserve; the row still says so
539
+ // rather than hiding, because a floor you cannot see is a floor you swear at.
540
+ if (automatic) return { ...row, ok: false, reason: `past your ${floor}% reserve` }
541
+ return { ...row, reason: `past your ${floor}% reserve` }
542
+ }
543
+ const when = r.when ?? 'always'
544
+ if (when.startsWith('below:')) {
545
+ const n = Number(when.slice('below:'.length))
546
+ if (!b || !Number.isFinite(b.percent)) return { ...row, ok: false, reason: `no reading, so "below ${n}%" cannot be checked` }
547
+ if (!(b.percent < n)) return { ...row, ok: false, reason: `at ${Math.round(b.percent)}%, not below ${n}%` }
548
+ }
549
+ if (when === 'walled-only') {
550
+ const aboveOpen = list.slice(0, i).some((_, j) => !walled[j])
551
+ if (aboveOpen) return { ...row, ok: false, reason: 'only when every rung above it is walled' }
552
+ }
553
+ return row
554
+ })
555
+ }
556
+
160
557
  // → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted
161
558
  // by reset, preferred_taken: bool }
162
559
  // `exclude` names (agent, account) pairs this choice must skip: a destination
@@ -167,8 +564,38 @@ export function candidates({ agent, account = 'default', accounts, order = AGENT
167
564
  // it is none of those the order decides instead and `preferred_taken` is false,
168
565
  // which is what the session event says: a pick made a minute ago must not leave
169
566
  // a terminal stopped because that account walled in the meantime.
170
- export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null }) {
567
+ export function chooseNext({
568
+ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null,
569
+ ladder = null, model = null, maySpend = false, reserve = {}, automatic = null, climbBack = 'next-handoff',
570
+ }) {
171
571
  const out = []
572
+ if (ladder) {
573
+ // The ladder walk. `reasons` carries one line per rung that was passed
574
+ // over, so the ledger and the picker can say what was skipped and why
575
+ // instead of a terminal turning up somewhere unexplained.
576
+ const reasons = []
577
+ const list = candidates({ agent, account, accounts, order, ladder, model })
578
+ // Only a caller that says nothing at all falls back to the old inference.
579
+ // "No destination named" is NOT "nobody asked": the plain Hand off now
580
+ // button sends no target, and reading that as automatic applied the reserve
581
+ // and the cost gate to a hand-off a human had just pressed (B.3).
582
+ const auto = automatic === null ? !prefer : automatic
583
+ const rows = evaluateLadder({ from: { agent, account, model: model ?? null }, list, installed, nowS, exclude, maySpend, reserve, automatic: auto, climbBack, ladder })
584
+ const trim = (r) => ({ agent: r.agent, account: r.account, ...(r.model ? { model: r.model } : {}) })
585
+ 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 }) }
586
+ if (prefer) {
587
+ const want = { agent: prefer.agent, account: prefer.account ?? 'default', model: prefer.model ?? null }
588
+ const hit = rows.find((r) => r.agent === want.agent && r.account === want.account && (want.model ? r.model === want.model : true))
589
+ if (hit && hit.ok) return { next: trim(hit), out, reasons, preferred_taken: true }
590
+ }
591
+ for (const r of rows) {
592
+ if (r.ok) return { next: trim(r), out, reasons, preferred_taken: false }
593
+ reasons.push({ agent: r.agent, account: r.account, model: r.model, reason: r.reason })
594
+ noteOut(r)
595
+ }
596
+ out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
597
+ return { next: null, out, reasons, preferred_taken: false }
598
+ }
172
599
  const list = candidates({ agent, account, accounts, order })
173
600
  const eligible = (c) => {
174
601
  if (installed && installed[c.agent] === false) return false
@@ -177,17 +604,17 @@ export function chooseNext({ agent, account, accounts, installed, order = AGENTS
177
604
  }
178
605
  if (prefer) {
179
606
  const hit = list.find((c) => c.agent === prefer.agent && c.account === (prefer.account ?? 'default'))
180
- if (hit && eligible(hit)) return { next: hit, out, preferred_taken: true }
607
+ if (hit && eligible(hit)) return { next: hit, out, reasons: [], preferred_taken: true }
181
608
  }
182
609
  for (const c of list) {
183
610
  if (installed && installed[c.agent] === false) continue
184
611
  if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) continue
185
612
  const u = readUsage(c.agent, c.account)
186
- if (isAvailable(u, nowS)) return { next: c, out, preferred_taken: false }
613
+ if (isAvailable(u, nowS)) return { next: c, out, reasons: [], preferred_taken: false }
187
614
  out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
188
615
  }
189
616
  out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
190
- return { next: null, out, preferred_taken: false }
617
+ return { next: null, out, reasons: [], preferred_taken: false }
191
618
  }
192
619
 
193
620
  export function fmtReset(epochS) {