@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/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,
@@ -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,99 @@ 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
+
122
+ // What a failing answer is allowed to say: the status code and the `type` the
123
+ // body names ('rate_limit_error'), never the body itself. A login shared by
124
+ // several terminals answers 429 often, and the whole JSON on every one of them
125
+ // turned the terminal's timeline into a wall of payloads.
126
+ function errorType(text) {
127
+ try {
128
+ const j = JSON.parse(text)
129
+ const t = j?.error?.type ?? j?.type
130
+ return typeof t === 'string' && t && t !== 'error' ? t : null
131
+ } catch { return null }
132
+ }
133
+
40
134
  function getJson(url, headers, timeoutMs) {
41
135
  return new Promise((resolvePromise) => {
42
136
  const u = new URL(url)
@@ -54,14 +148,24 @@ function getJson(url, headers, timeoutMs) {
54
148
  })
55
149
  }
56
150
 
57
- // → { ok, limits: {five_hour, seven_day}|null, status, error, expired }
151
+ // → { ok, limits: {five_hour, seven_day, buckets, extra_usage}|null, status, error, expired }
152
+ // The two windows keep their shape and their place: every older reader of this
153
+ // function still gets exactly what it got before. `buckets` is OMITTED when the
154
+ // payload has no `limits` array, which is what an older endpoint answers: that
155
+ // is no information about buckets, and recordUsage's `Array.isArray` guard then
156
+ // leaves the last measured ones in place instead of erasing them.
58
157
  export async function fetchClaudeUsage({ configDir = LAYOUT.claude.home(), timeoutMs = 8000, url = USAGE_URL } = {}) {
59
158
  const t = readToken(configDir)
60
159
  if (!t) return { ok: false, limits: null, error: 'no claude.ai login found in ' + configDir }
61
160
  const r = await getJson(url, { Authorization: `Bearer ${t.token}`, 'anthropic-beta': 'oauth-2025-04-20', Accept: 'application/json', 'User-Agent': 'legcli' }, timeoutMs)
62
161
  if (r.error) return { ok: false, limits: null, status: 0, error: r.error }
63
- 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)}` }
162
+ if (r.status !== 200) {
163
+ const kind = errorType(r.text)
164
+ return { ok: false, limits: null, status: r.status, expired: t.expired, error: `usage endpoint ${r.status}${kind ? `: ${kind}` : ''}` }
165
+ }
64
166
  let j
65
167
  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) }
168
+ const limits = { five_hour: window(j.five_hour), seven_day: window(j.seven_day), extra_usage: extraUsageFrom(j) }
169
+ if (Array.isArray(j.limits)) limits.buckets = bucketsFrom(j)
170
+ return { ok: true, limits, status: r.status, expired: t.expired, raw_keys: Object.keys(j) }
67
171
  }
@@ -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
  })