@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. 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
- 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,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
- export function markLimited(agent, account, { resets_at = null, reason = 'limit', source, observed_at = new Date().toISOString() } = {}) {
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
- export function candidates({ agent, account = 'default', accounts, order = AGENTS }) {
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
- 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 })
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
- // { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted by reset }
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
- export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
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
- for (const c of candidates({ agent, account, accounts, order })) {
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) {