@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/sessions.mjs CHANGED
@@ -48,15 +48,31 @@ export function isActive(s) { return ACTIVE.includes(s?.status) }
48
48
  // it one (repo stays the main checkout, for grouping and landing), else the repo.
49
49
  export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
50
50
 
51
- export function createSession({ id, agent, account = 'default', cwd, repo = null, branch = null, argv = [], runner_pid = process.pid, chain = [], worktree = null, owner = null, handoffOrder = AGENTS, installed = null, runtimeCapabilities = [] }) {
51
+ export function createSession({ id, agent, account = 'default', cwd, repo = null, branch = null, argv = [], runner_pid = process.pid, chain = [], worktree = null, owner = null, handoffOrder = AGENTS, installed = null, runtimeCapabilities = [], model = null }) {
52
52
  const session = {
53
53
  session_id: id, agent, account, cwd, repo, branch, argv, worktree, owner,
54
54
  repo_name: repo ? repo.split(/[\\/]/).filter(Boolean).pop() : null,
55
+ // the model this leg resolved to: the `--model`/`-m` the human passed, else
56
+ // null. Never a guessed default — a printed model nobody chose is a wrong
57
+ // number in disguise. For claude the runner refreshes it from the
58
+ // transcript's per-assistant-line `message.model`, so a silent fallback off
59
+ // Fable becomes visible on the row (docs/redesign-2026-09-17.md B.1).
60
+ model,
61
+ // what this terminal is waiting for, or null. Two shapes, told apart by
62
+ // `type`: `{ type: 'reset', agent, account, resets_at, since }` is the
63
+ // all-out countdown the runner writes (src/attach.mjs), and
64
+ // `{ type: 'permission_prompt'|'idle_prompt'|'agent_needs_input'|'quota_auto_resume', message, since }`
65
+ // is a human being waited on, from Claude Code's Notification hook.
66
+ waiting: null,
55
67
  status: 'starting', runner_pid, pid: null,
56
68
  started_at: now(), updated_at: now(), ended_at: null, last_activity: now(),
57
69
  agent_session_id: null, transcript_path: null,
58
70
  task: null, turns: 0,
59
71
  files_touched: [], files_dirty: [], head: null, head_at_start: null,
72
+ // commits this checkout is ahead of its upstream (else of head_at_start),
73
+ // refreshed by the runner's git poll. null until it has been counted, and
74
+ // null again whenever it cannot be: never a zero standing in for unknown.
75
+ ahead: null,
60
76
  limits: null, limit: null, warning: null,
61
77
  bundle: null, handoff: null, chain, checkpoints: [],
62
78
  handoff_order: normalizeHandoffOrder(handoffOrder), installed,
package/src/share.mjs CHANGED
@@ -6,15 +6,29 @@
6
6
  // (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
7
7
  // open and `BATON_TOKEN` is the only token.
8
8
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
9
- import { existsSync, readFileSync, mkdirSync } from 'node:fs'
9
+ import { existsSync, readFileSync, mkdirSync, statSync } from 'node:fs'
10
10
  import { networkInterfaces, userInfo } from 'node:os'
11
11
  import { createSocket } from 'node:dgram'
12
12
  import { join } from 'node:path'
13
13
  import { home } from './store.mjs'
14
14
  import { writeJsonAtomic } from './fsx.mjs'
15
15
 
16
- export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [] }
17
- export const ROLES = ['owner', 'guest']
16
+ export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [], tls: null }
17
+
18
+ // Three roles, because two were not enough to describe a second human who runs
19
+ // cards on this machine but has no business in its settings or its project map.
20
+ // owner everything: machine settings, the harness, every terminal, cards
21
+ // operator the pipeline board and their own terminals; not the settings,
22
+ // not the history index, not anyone else's terminal
23
+ // guest the terminals lane, read-only and redacted; may ask for a hand-off
24
+ export const ROLES = ['owner', 'operator', 'guest']
25
+
26
+ // One place that says what a role may reach, so no endpoint decides for itself.
27
+ // `cards` is the pipeline side of the board. `machine` is everything that
28
+ // describes this computer rather than the work: the settings, the harness
29
+ // policy, the history index and the worktree map.
30
+ export function mayUseCards(role) { return role === 'owner' || role === 'operator' }
31
+ export function mayUseMachine(role) { return role === 'owner' }
18
32
 
19
33
  export function sharePath() { return join(home(), 'share.json') }
20
34
 
@@ -55,9 +69,49 @@ export function identify(share, presented) {
55
69
  export function personNamed(share, name) { return share.people.find((p) => p.name.toLowerCase() === String(name ?? '').toLowerCase()) ?? null }
56
70
  export function isOwner(person) { return person?.role === 'owner' }
57
71
 
72
+ // ---- TLS ----
73
+ // Leg does not make certificates. It uses a pair you already have, which on a
74
+ // Tailscale network is one command (`tailscale cert <machine>.<tailnet>.ts.net`)
75
+ // and gives a certificate browsers already trust. A self-signed pair would
76
+ // teach everyone on the board to click through a warning, which is worse than
77
+ // no TLS at all on a network that is already private.
78
+ export class TlsRefused extends Error {
79
+ constructor(msg) { super(msg); this.name = 'TlsRefused'; this.exitCode = 3 }
80
+ }
81
+
82
+ function tlsPaths(share, env) {
83
+ return {
84
+ cert: env.LEG_TLS_CERT || env.BATON_TLS_CERT || share?.tls?.cert || null,
85
+ key: env.LEG_TLS_KEY || env.BATON_TLS_KEY || share?.tls?.key || null,
86
+ }
87
+ }
88
+
89
+ // → { cert, key, cert_path, key_path } | null. Throws TlsRefused when a pair is
90
+ // configured but unusable: a board that quietly fell back to plaintext after
91
+ // being told to use TLS is the one failure this must not have.
92
+ export function readTls(share = readShare(), env = process.env) {
93
+ const { cert: certPath, key: keyPath } = tlsPaths(share, env)
94
+ if (!certPath && !keyPath) return null
95
+ if (!certPath || !keyPath) throw new TlsRefused('TLS needs both a certificate and a key (--tls-cert and --tls-key, or LEG_TLS_CERT and LEG_TLS_KEY)')
96
+ for (const [label, file] of [['certificate', certPath], ['key', keyPath]]) {
97
+ if (!existsSync(file)) throw new TlsRefused(`TLS ${label} not found: ${file}`)
98
+ try { statSync(file) } catch (err) { throw new TlsRefused(`TLS ${label} ${file}: ${err.message}`) }
99
+ }
100
+ let cert
101
+ let key
102
+ try { cert = readFileSync(certPath) } catch (err) { throw new TlsRefused(`TLS certificate ${certPath}: ${err.message}`) }
103
+ try { key = readFileSync(keyPath) } catch (err) { throw new TlsRefused(`TLS key ${keyPath}: ${err.message}`) }
104
+ if (!cert.length || !key.length) throw new TlsRefused('the TLS certificate or key is empty')
105
+ return { cert, key, cert_path: certPath, key_path: keyPath }
106
+ }
107
+
108
+ export function tlsConfigured(share = readShare(), env = process.env) { return Boolean(tlsPaths(share, env).cert) }
109
+
110
+ export function scheme(share = readShare(), env = process.env) { return tlsConfigured(share, env) ? 'https' : 'http' }
111
+
58
112
  export function addPerson(name, { role = 'guest', share = readShare() } = {}) {
59
113
  if (!validName(name)) throw new Error(`bad name "${name}": letters, digits, dash and underscore, up to 32 characters`)
60
- if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (owner|guest)`)
114
+ if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (${ROLES.join('|')})`)
61
115
  if (personNamed(share, name)) throw new Error(`"${name}" is already on the board; baton share rotate ${name} issues a new link`)
62
116
  const token = newToken()
63
117
  const person = { name, role, token_sha256: hashToken(token), created_at: new Date().toISOString(), last_seen: null }
@@ -122,7 +176,7 @@ export async function resolveBind(kind = 'tailscale') {
122
176
  return lan.address
123
177
  }
124
178
 
125
- export function linkFor(share, token) { return `http://${share.bind}:${share.port}/?token=${token}` }
179
+ export function linkFor(share, token) { return `${scheme(share)}://${share.bind}:${share.port}/?token=${token}` }
126
180
 
127
181
  // Whose terminal this is: BATON_PERSON, else the board's owner, else 'local'.
128
182
  export function whoami(share = readShare()) {
@@ -131,9 +185,15 @@ export function whoami(share = readShare()) {
131
185
  return share.owner || 'local'
132
186
  }
133
187
 
134
- export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner } = {}) {
188
+ export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner, tlsCert = null, tlsKey = null } = {}) {
135
189
  const share = readShare()
136
190
  const address = await resolveBind(bind)
191
+ if (tlsCert || tlsKey) {
192
+ if (!tlsCert || !tlsKey) throw new TlsRefused('TLS needs both --tls-cert and --tls-key')
193
+ share.tls = { cert: tlsCert, key: tlsKey }
194
+ // read the pair now, so a bad one fails here and not at the next board start
195
+ readTls(share, {})
196
+ }
137
197
  share.on = true
138
198
  share.bind = address
139
199
  share.bind_kind = ['tailscale', 'lan'].includes(String(bind).toLowerCase()) ? String(bind).toLowerCase() : 'address'
@@ -14,6 +14,7 @@ import http from 'node:http'
14
14
  import { existsSync, readFileSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
16
  import { LAYOUT } from '../accounts.mjs'
17
+ import { MODEL_ALIASES } from '../buckets.mjs'
17
18
 
18
19
  export const USAGE_URL = (process.env.LEG_CLAUDE_USAGE_URL || process.env.BATON_CLAUDE_USAGE_URL) || 'https://api.anthropic.com/api/oauth/usage'
19
20
 
@@ -37,6 +38,87 @@ function window(x) {
37
38
  return { pct, resets_at: resets }
38
39
  }
39
40
 
41
+ function epoch(x) {
42
+ if (x === null || x === undefined) return null
43
+ if (typeof x === 'number') return Number.isFinite(x) ? Math.floor(x > 1e12 ? x / 1000 : x) : null
44
+ const t = Date.parse(x)
45
+ return Number.isFinite(t) ? Math.floor(t / 1000) : null
46
+ }
47
+
48
+ // The model a limit row is scoped to, lowercased, or null when the row is an
49
+ // account-wide bucket. The endpoint is undocumented, so the scope is read by
50
+ // shape (a `model` object carrying a display name) rather than by a type word.
51
+ // A display name carries a version the rest of Leg never says ("Fable 5.1",
52
+ // "Claude Opus 5"), and every other model name in the system is an alias: the
53
+ // walls (src/buckets.mjs), the rungs (src/preferences.mjs) and the board all
54
+ // join on one. So the name is matched word by word against the alias list and
55
+ // the alias is what is stored; a name Leg does not know keeps its own
56
+ // lowercased text, because inventing a model is worse than printing an unknown
57
+ // one.
58
+ function scopeModel(scope) {
59
+ const name = scope?.model?.display_name ?? scope?.model?.displayName ?? null
60
+ if (typeof name !== 'string' || !name.trim()) return null
61
+ const raw = name.trim().toLowerCase()
62
+ for (const alias of MODEL_ALIASES.claude) {
63
+ if (raw.split(/[^a-z0-9]+/).includes(alias)) return alias
64
+ }
65
+ return raw
66
+ }
67
+
68
+ function groupOf(kind) {
69
+ const k = String(kind ?? '')
70
+ if (k.startsWith('weekly')) return 'weekly'
71
+ if (k.startsWith('session') || k.startsWith('five_hour')) return 'session'
72
+ if (k.startsWith('spend') || k.startsWith('extra')) return 'spend'
73
+ return k || 'unknown'
74
+ }
75
+
76
+ // One `limits[]` row → a bucket. Percentages only: what a row *means* (whether
77
+ // a model switch helps) is decided in src/buckets.mjs from the wall wording,
78
+ // never from a number.
79
+ export function bucketOf(x) {
80
+ if (!x || typeof x !== 'object') return null
81
+ const kind = x.type ?? x.kind ?? x.name ?? null
82
+ if (!kind) return null
83
+ const percent = Number(x.utilization ?? x.used_percentage ?? x.used_percent ?? x.percent)
84
+ if (!Number.isFinite(percent)) return null
85
+ return {
86
+ kind: String(kind),
87
+ group: groupOf(kind),
88
+ model: scopeModel(x.scope),
89
+ percent,
90
+ resets_at: epoch(x.resets_at ?? x.resetsAt ?? null),
91
+ is_active: Boolean(x.is_active ?? x.isActive ?? false),
92
+ severity: typeof x.severity === 'string' ? x.severity : 'normal',
93
+ }
94
+ }
95
+
96
+ export function bucketsFrom(j) {
97
+ if (!Array.isArray(j?.limits)) return []
98
+ return j.limits.map(bucketOf).filter(Boolean)
99
+ }
100
+
101
+ // `extra_usage` / `spend`, the two sentences the capacity drawer prints. Only
102
+ // the fields that exist are carried; the eighteen codename keys the payload
103
+ // also holds (tangelo, iguana_necktie, ...) are never read.
104
+ export function extraUsageFrom(j) {
105
+ const e = j?.extra_usage
106
+ const s = j?.spend
107
+ if ((!e || typeof e !== 'object') && (!s || typeof s !== 'object')) return null
108
+ const out = {}
109
+ const enabled = e?.is_enabled ?? e?.enabled
110
+ if (typeof enabled === 'boolean') out.enabled = enabled
111
+ const reason = e?.disabled_reason ?? e?.reason
112
+ if (typeof reason === 'string') out.reason = reason
113
+ const canToggle = s?.can_toggle ?? e?.can_toggle
114
+ if (typeof canToggle === 'boolean') out.can_toggle = canToggle
115
+ const limitMinor = Number(e?.monthly_limit ?? e?.limit ?? s?.monthly_limit)
116
+ if (Number.isFinite(limitMinor)) out.limit_minor = limitMinor
117
+ const usedMinor = Number(e?.monthly_used ?? e?.used ?? s?.monthly_used ?? s?.used)
118
+ if (Number.isFinite(usedMinor)) out.used_minor = usedMinor
119
+ return Object.keys(out).length ? out : null
120
+ }
121
+
40
122
  function getJson(url, headers, timeoutMs) {
41
123
  return new Promise((resolvePromise) => {
42
124
  const u = new URL(url)
@@ -54,7 +136,12 @@ function getJson(url, headers, timeoutMs) {
54
136
  })
55
137
  }
56
138
 
57
- // → { ok, limits: {five_hour, seven_day}|null, status, error, expired }
139
+ // → { ok, limits: {five_hour, seven_day, buckets, extra_usage}|null, status, error, expired }
140
+ // The two windows keep their shape and their place: every older reader of this
141
+ // function still gets exactly what it got before. `buckets` is OMITTED when the
142
+ // payload has no `limits` array, which is what an older endpoint answers: that
143
+ // is no information about buckets, and recordUsage's `Array.isArray` guard then
144
+ // leaves the last measured ones in place instead of erasing them.
58
145
  export async function fetchClaudeUsage({ configDir = LAYOUT.claude.home(), timeoutMs = 8000, url = USAGE_URL } = {}) {
59
146
  const t = readToken(configDir)
60
147
  if (!t) return { ok: false, limits: null, error: 'no claude.ai login found in ' + configDir }
@@ -63,5 +150,7 @@ export async function fetchClaudeUsage({ configDir = LAYOUT.claude.home(), timeo
63
150
  if (r.status !== 200) return { ok: false, limits: null, status: r.status, expired: t.expired, error: `usage endpoint ${r.status}: ${r.text.slice(0, 120)}` }
64
151
  let j
65
152
  try { j = JSON.parse(r.text) } catch { return { ok: false, limits: null, status: r.status, error: 'usage endpoint returned no JSON' } }
66
- return { ok: true, limits: { five_hour: window(j.five_hour), seven_day: window(j.seven_day) }, status: r.status, expired: t.expired, raw_keys: Object.keys(j) }
153
+ const limits = { five_hour: window(j.five_hour), seven_day: window(j.seven_day), extra_usage: extraUsageFrom(j) }
154
+ if (Array.isArray(j.limits)) limits.buckets = bucketsFrom(j)
155
+ return { ok: true, limits, status: r.status, expired: t.expired, raw_keys: Object.keys(j) }
67
156
  }
@@ -5,12 +5,14 @@
5
5
  // Sources: code.claude.com/docs/en/hooks (StopFailure `error: rate_limit`),
6
6
  // docs/en/statusline (rate_limits.five_hour/seven_day used_percentage,
7
7
  // resets_at), docs/en/settings (`--settings` sits above user settings).
8
- import { existsSync, readFileSync } from 'node:fs'
8
+ import { existsSync, readFileSync, openSync, closeSync, fstatSync, readSync } from 'node:fs'
9
9
  import { join, dirname, resolve } from 'node:path'
10
10
  import { fileURLToPath } from 'node:url'
11
11
  import { sessionDir, updateSession, appendEvent, readSession, workRoot } from '../sessions.mjs'
12
12
  import { recordUsage, markLimited, WARN_PCT } from '../usage.mjs'
13
+ import { bucketFromWall, MODEL_ALIASES } from '../buckets.mjs'
13
14
  import { writeJsonAtomic } from '../fsx.mjs'
15
+ import { readPreferences } from '../preferences.mjs'
14
16
  import { LAYOUT } from '../accounts.mjs'
15
17
 
16
18
  const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'hook.mjs')
@@ -29,6 +31,19 @@ export function userStatusLine(configDir = LAYOUT.claude.home()) {
29
31
  return null
30
32
  }
31
33
 
34
+ // The four Notification types Leg acts on, as one matcher. Notification
35
+ // "matches on notification type" and, not being FileChanged or StopFailure,
36
+ // takes `|` as the alternation separator (hooks doc lines 173, 165, 1424).
37
+ // The other eight types (auth_success, the elicitation family, agent_completed,
38
+ // quota_auto_resume_stale, quota_auto_resume_disabled) say nothing about a
39
+ // human being waited on, so they never start a hook process.
40
+ export const WAITING_TYPES = ['permission_prompt', 'idle_prompt', 'agent_needs_input']
41
+ export const NOTIFY_MATCHER = [...WAITING_TYPES, 'quota_auto_resume_fired'].join('|')
42
+ // Claude Code waiting at the limit by itself. Leg sets autoContinueAtUsageLimit
43
+ // false, but the human's own settings can re-enable it; two waiters on one
44
+ // terminal is the failure to avoid, so Leg stands down and the row says so.
45
+ export const QUOTA_STAND_DOWN = 'Claude Code is waiting at the limit itself; Leg is not handing this one off.'
46
+
32
47
  export function settingsFor(sessionId, { statusLine = null } = {}) {
33
48
  const cmd = (kind) => ({ type: 'command', command: `node ${q(HOOK)} ${kind} --session ${sessionId}`, timeout: 20 })
34
49
  const settings = {
@@ -36,6 +51,7 @@ export function settingsFor(sessionId, { statusLine = null } = {}) {
36
51
  SessionStart: [{ hooks: [cmd('claude-hook')] }],
37
52
  UserPromptSubmit: [{ hooks: [cmd('claude-hook')] }],
38
53
  PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [cmd('claude-hook')] }],
54
+ Notification: [{ matcher: NOTIFY_MATCHER, hooks: [cmd('claude-hook')] }],
39
55
  Stop: [{ hooks: [cmd('claude-hook')] }],
40
56
  StopFailure: [{ hooks: [cmd('claude-hook')] }],
41
57
  SessionEnd: [{ hooks: [cmd('claude-hook')] }],
@@ -84,6 +100,58 @@ export function transcriptTail(path, limit = 8) {
84
100
  return messagesFromLines(readFileSync(path, 'utf8').split('\n'), limit)
85
101
  }
86
102
 
103
+ // A model id from a CLI (`claude-fable-5-1`) said as the name the ladder, the
104
+ // picker and the row use (`fable`). An id that matches no alias is kept raw:
105
+ // printing a model Leg does not recognise is honest, inventing one is not.
106
+ export function modelAlias(agent, id) {
107
+ const raw = String(id ?? '').trim()
108
+ if (!raw) return null
109
+ for (const alias of MODEL_ALIASES[agent] ?? []) {
110
+ // whitespace is a separator too: a display name reads "Claude Opus 5" where
111
+ // a CLI id reads "claude-opus-5", and both name the same rung
112
+ if (new RegExp(`(?:^|[-_\\s])${alias}(?:$|[-_.\\s])`, 'i').test(raw)) return alias
113
+ }
114
+ return raw
115
+ }
116
+
117
+ // The model that actually answered, from the transcript's per-assistant-line
118
+ // `message.model` (VERIFIED: 29 assistant lines of the newest jsonl for this
119
+ // repo carry "claude-fable-5-1"). This is how a silent fallback off Fable
120
+ // becomes visible, so it reads the file itself rather than trusting the argv.
121
+ //
122
+ // Two Windows facts shape the read. The file is appended to while Claude Code
123
+ // runs, so a read can land mid-write: it is retried, and a torn last line is
124
+ // dropped by the per-line JSON.parse rather than failing the whole read. And
125
+ // mtime is not a content clock here, so nothing is skipped on a timestamp — the
126
+ // tail is read every time and the answer is whatever the bytes say.
127
+ export function modelFromTranscript(path, { agent = 'claude', tailBytes = 262144, attempts = 3 } = {}) {
128
+ if (!path || !existsSync(path)) return null
129
+ for (let i = 0; i < attempts; i++) {
130
+ let fd = null
131
+ try {
132
+ fd = openSync(path, 'r')
133
+ const size = fstatSync(fd).size
134
+ const want = Math.min(size, tailBytes)
135
+ const buf = Buffer.alloc(want)
136
+ readSync(fd, buf, 0, want, size - want)
137
+ const lines = buf.toString('utf8').split('\n')
138
+ // a partial first line when the tail starts mid-file, a partial last line
139
+ // when the writer is mid-append: both are dropped by the parse below
140
+ for (let k = lines.length - 1; k >= 0; k--) {
141
+ let j
142
+ try { j = JSON.parse(lines[k]) } catch { continue }
143
+ if (j?.type !== 'assistant') continue
144
+ const id = j.message?.model
145
+ if (id) return modelAlias(agent, id)
146
+ }
147
+ return null
148
+ } catch {
149
+ // EBUSY / EPERM / a share violation while Claude Code writes: try again
150
+ } finally { if (fd !== null) try { closeSync(fd) } catch {} }
151
+ }
152
+ return null
153
+ }
154
+
87
155
  export function firstPrompt(path) {
88
156
  const t = transcriptTail(path, 1000).find((m) => m.role === 'user')
89
157
  return t ? t.text.slice(0, 500) : null
@@ -95,6 +163,40 @@ function limitsFrom(rl) {
95
163
  return { five_hour: w(rl.five_hour), seven_day: w(rl.seven_day) }
96
164
  }
97
165
 
166
+ // The all-out countdown the runner owns (src/attach.mjs) also lives on
167
+ // `waiting`. A Notification never overwrites it: while that is set the child is
168
+ // already dead and nobody is being waited on in the terminal.
169
+ const humanWait = (w) => Boolean(w) && w.type !== 'reset'
170
+ const clearHumanWait = (cur) => (humanWait(cur.waiting) ? { waiting: null } : {})
171
+
172
+ // What the hook prints back to Claude Code. `terminalSequence` is emitted by
173
+ // Claude Code itself on events that discard systemMessage and continue, which
174
+ // Notification is (hooks doc lines 608, 622, 1490); OSC 9 is the desktop
175
+ // notification Windows Terminal renders (line 617). Restricted to the OSC
176
+ // 0/1/2/9/99/777 allowlist, so anything in the message that could close or open
177
+ // a sequence is dropped rather than risking the whole field being ignored (608).
178
+ // Every control byte out, ESC and BEL included: one of them inside the message
179
+ // would close the sequence Leg is building and open whatever followed it.
180
+ // Written as a scan rather than a regex because a control-character class is
181
+ // exactly what the linter stops, and for good reason.
182
+ // The C1 range (U+0080 to U+009F) goes out with C0 and DEL: on a terminal that
183
+ // decodes C1 from UTF-8, U+009C is ST and closes the sequence Leg is building,
184
+ // and U+009D is OSC and opens whatever follows it. That is the same hazard as a
185
+ // raw ESC or BEL, in two bytes instead of one.
186
+ export const printable = (s) => [...String(s ?? '')].map((c) => {
187
+ const cp = c.codePointAt(0)
188
+ return cp < 0x20 || (cp >= 0x7f && cp <= 0x9f) ? ' ' : c
189
+ }).join('')
190
+
191
+ export function terminalSequenceFor(p, { preferences = null } = {}) {
192
+ if (p?.hook_event_name !== 'Notification') return null
193
+ if (!WAITING_TYPES.includes(String(p.notification_type ?? ''))) return null
194
+ const prefs = preferences ?? readPreferences()
195
+ if (!prefs.notify_terminal) return null
196
+ const text = printable(p.message).trim().slice(0, 160)
197
+ return `\x1b]9;${text || 'leg: this terminal is waiting on you'}\x07`
198
+ }
199
+
98
200
  // Hook payload → session record. Returns a short line for the hook log.
99
201
  export function handleHook(sessionId, p) {
100
202
  const s = readSession(sessionId)
@@ -106,7 +208,8 @@ export function handleHook(sessionId, p) {
106
208
  updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'agent_ready', summary: `claude session ${p.session_id ?? '?'} (${p.source ?? 'startup'})` } })
107
209
  return 'session start'
108
210
  case 'UserPromptSubmit': {
109
- updateSession(sessionId, (cur) => ({ ...base, task: cur.task ?? (p.prompt ? String(p.prompt).slice(0, 500) : null), turns: (cur.turns ?? 0) + 1 }), { event: { type: 'turn', summary: `prompt: ${String(p.prompt ?? '').slice(0, 120)}` } })
211
+ // the human typed, so whatever was being waited on has been answered
212
+ updateSession(sessionId, (cur) => ({ ...base, ...clearHumanWait(cur), task: cur.task ?? (p.prompt ? String(p.prompt).slice(0, 500) : null), turns: (cur.turns ?? 0) + 1 }), { event: { type: 'turn', summary: `prompt: ${String(p.prompt ?? '').slice(0, 120)}` } })
110
213
  return 'prompt'
111
214
  }
112
215
  case 'PostToolUse': {
@@ -118,16 +221,52 @@ export function handleHook(sessionId, p) {
118
221
  updateSession(sessionId, (cur) => ({ ...base, files_touched: cur.files_touched.includes(rel) ? cur.files_touched : [...cur.files_touched, rel].slice(-200) }))
119
222
  return `touched ${rel}`
120
223
  }
224
+ case 'Notification': {
225
+ const type = String(p.notification_type ?? '')
226
+ const since = new Date().toISOString()
227
+ // reducer, and the hazard the status-line handler documents: this hook is
228
+ // its own process and a StopFailure can be writing `status: 'limit'` in
229
+ // the same moment. Only `waiting` is touched from `cur` inside the lock —
230
+ // never status, never limit — so a Notification can never erase a wall.
231
+ if (type === 'quota_auto_resume_fired') {
232
+ updateSession(sessionId, (cur) => ({ ...base, waiting: cur.waiting?.type === 'reset' ? cur.waiting : { type: 'quota_auto_resume', message: QUOTA_STAND_DOWN, since } }),
233
+ { event: { type: 'status', summary: QUOTA_STAND_DOWN } })
234
+ return 'notify quota_auto_resume'
235
+ }
236
+ if (!WAITING_TYPES.includes(type)) { updateSession(sessionId, base); return `notify ${type || 'unknown'}` }
237
+ // the question verbatim: a paraphrase of what an agent is asking for is
238
+ // the one thing a human cannot check against the terminal in front of them
239
+ const message = String(p.message ?? '').slice(0, 160)
240
+ updateSession(sessionId, (cur) => ({ ...base, waiting: cur.waiting?.type === 'reset' ? cur.waiting : { type, message, since } }),
241
+ { event: { type: 'waiting', summary: `waiting on you (${type}): ${message}` } })
242
+ return `notify ${type}`
243
+ }
121
244
  case 'Stop':
122
245
  // reducer: never turn a 'limit'/'handing_off' back to 'running' by racing
123
- updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'turn_done', summary: String(p.last_assistant_message ?? '').slice(0, 160) || 'turn done' } })
246
+ updateSession(sessionId, (cur) => ({ ...base, ...clearHumanWait(cur), status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'turn_done', summary: String(p.last_assistant_message ?? '').slice(0, 160) || 'turn done' } })
124
247
  return 'stop'
125
248
  case 'StopFailure': {
126
249
  if (p.error === 'rate_limit') {
127
250
  // a simulated wall (baton sessions simulate-limit) clears after two minutes so a test never walls the real login for hours
128
251
  const simulated = Boolean(p.leg_simulated || p.baton_simulated)
129
- const u = markLimited('claude', s.account, { reason: 'rate_limit', source: simulated ? 'leg simulate-limit' : 'claude StopFailure', resets_at: simulated ? Math.floor(Date.now() / 1000) + 120 : null })
130
- updateSession(sessionId, { ...base, status: 'limit', limit: { reason: 'rate_limit', detail: String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 300), resets_at: u.limited_until, at: new Date().toISOString(), simulated } }, { event: { type: 'limit', summary: `claude usage limit${simulated ? ' (simulated)' : ''}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` } })
252
+ const detail = String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 300)
253
+ // which bucket the wording walled: one model family, or the whole
254
+ // login. Unrecognised wording walls the login (src/buckets.mjs rule 5).
255
+ const hit = bucketFromWall('claude', p.last_assistant_message)
256
+ // markLimited takes the same cross-process lock recordUsage does, so a
257
+ // percentage arriving from the poller in this same moment cannot erase
258
+ // the wall this hook is writing (that race handed the baton straight
259
+ // back to a walled login).
260
+ const u = markLimited('claude', s.account, {
261
+ reason: hit.scope === 'model' ? 'model_limit' : 'rate_limit',
262
+ source: simulated ? 'leg simulate-limit' : 'claude StopFailure',
263
+ resets_at: simulated ? Math.floor(Date.now() / 1000) + 120 : null,
264
+ scope: hit.scope,
265
+ model: hit.model ?? null,
266
+ evidence: detail,
267
+ })
268
+ const resets = hit.scope === 'model' ? (u.walls?.[hit.model]?.limited_until ?? null) : u.limited_until
269
+ updateSession(sessionId, { ...base, status: 'limit', limit: { reason: 'rate_limit', detail, resets_at: resets, at: new Date().toISOString(), simulated, scope: hit.scope, model: hit.model ?? null } }, { event: { type: 'limit', summary: `claude usage limit${simulated ? ' (simulated)' : ''}${hit.scope === 'model' ? ` (${hit.model})` : ''}: ${detail.slice(0, 160)}` } })
131
270
  return 'LIMIT'
132
271
  }
133
272
  appendEvent(sessionId, { type: 'error', summary: `claude ${p.error}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` })
@@ -155,6 +155,20 @@ export function normalizeRateLimits(rateLimits) {
155
155
  return out
156
156
  }
157
157
 
158
+ // What the rollout says about the account in words rather than percentages:
159
+ // the plan it is on and the credit balance it prints. Measured, carried
160
+ // through untouched, and never summed with anything: they are strings from
161
+ // codex, not a figure Leg computed. Observed live in the newest rollout
162
+ // (plan_type "prolite", credits.balance "0").
163
+ export function factsFromRateLimits(rateLimits) {
164
+ const out = {}
165
+ const plan = rateLimits?.plan_type ?? rateLimits?.planType
166
+ if (typeof plan === 'string' && plan) out.plan_type = plan
167
+ const balance = rateLimits?.credits?.balance
168
+ if (typeof balance === 'string' || typeof balance === 'number') out.credits_balance = String(balance)
169
+ return out
170
+ }
171
+
158
172
  function parseRetryAt(msg) {
159
173
  const m = RETRY_AT_RE.exec(msg ?? '')
160
174
  if (!m) return null
@@ -165,16 +179,21 @@ function parseRetryAt(msg) {
165
179
 
166
180
  // lines → { limits, limit, messages, turnsDone, taskStarted, threadId }
167
181
  export function parseLines(lines) {
168
- const out = { limits: null, limits_at: null, limit: null, messages: [], turnsDone: 0, taskStarted: 0, threadId: null, files: [] }
182
+ const out = { limits: null, limits_at: null, limit: null, messages: [], turnsDone: 0, taskStarted: 0, threadId: null, files: [], facts: {} }
169
183
  for (const line of lines) {
170
184
  let j
171
185
  try { j = JSON.parse(line) } catch { continue }
172
186
  const p = j.payload ?? {}
173
- if (j.type === 'session_meta') { out.threadId = p.id ?? null; continue }
187
+ if (j.type === 'session_meta') {
188
+ out.threadId = p.id ?? null
189
+ if (typeof p.model === 'string' && p.model) out.facts.model = p.model
190
+ continue
191
+ }
174
192
  if (j.type === 'event_msg') {
175
193
  if (p.type === 'token_count' && p.rate_limits) {
176
194
  out.limits = normalizeRateLimits(p.rate_limits)
177
195
  out.limits_at = j.timestamp ?? null
196
+ Object.assign(out.facts, factsFromRateLimits(p.rate_limits))
178
197
  } else if (p.type === 'task_started') out.taskStarted += 1
179
198
  else if (p.type === 'task_complete') {
180
199
  out.turnsDone += 1
@@ -265,8 +284,9 @@ export function readCodexUsage({ codexHome = LAYOUT.codex.home(), timeoutMs = 80
265
284
  if (message.error || !message.result) return stop('codex rate-limit read failed')
266
285
  const snapshot = message.result.rateLimitsByLimitId?.codex ?? message.result.rateLimits
267
286
  const limits = normalizeRateLimits(snapshot)
287
+ const facts = factsFromRateLimits(snapshot)
268
288
  const available = typeof message.result.ordinaryUsageAllowed === 'boolean' ? message.result.ordinaryUsageAllowed : null
269
- return finish({ ok: available !== null || Boolean(limits.five_hour || limits.seven_day), limits, available, observed_at: new Date().toISOString(), error: null })
289
+ return finish({ ok: available !== null || Boolean(limits.five_hour || limits.seven_day), limits, facts, available, observed_at: new Date().toISOString(), error: null })
270
290
  }
271
291
  }
272
292
  })
package/src/taps/grok.mjs CHANGED
@@ -183,6 +183,10 @@ const LIMIT_RES = [
183
183
  ['grok-rate-limit-event', /"rate_limit"/i],
184
184
  ['grok-free-usage-exhausted', /subscription:free-usage-exhausted|You've used all of your free queries/i],
185
185
  ['grok-too-many-requests', /TOO_MANY_REQUESTS/],
186
+ // Observed live 2026-09-17 on grok 1.0.34: an account with no balance left
187
+ // answers 402, never 429, and none of the strings above appear. Without this
188
+ // the terminal sat on an exhausted login instead of handing off.
189
+ ['grok-balance-exhausted', /usage balance exhausted|status 402 Payment Required/i],
186
190
  ]
187
191
 
188
192
  // Scans log or stream text for Grok rate limit signals.