@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
@@ -0,0 +1,163 @@
1
+ // The capacity strip: one 44px band, one token per login, and the bucket that
2
+ // will actually stop the work printed in it.
3
+ //
4
+ // THIS FILE OWNS THE STRIP. The board draws it under the verdict and the floor
5
+ // draws it under the masthead, and a strip written twice is a percentage that
6
+ // can read two ways on two pages, which is the defect the strip exists to fix.
7
+ // src/board/sessions.js and src/board/floor.js keep the names (capFigure,
8
+ // capToken, bindingOf and the rest) and delegate here; nothing in either file
9
+ // draws a token itself.
10
+ //
11
+ // The three board scripts have no module system, so the page hands its own
12
+ // primitives over with use(): el(), the login labels and the time grammar are
13
+ // owned by sessions.js (and carried, character for character, by floor.js), so
14
+ // this file never grows a third copy of them.
15
+ (function () {
16
+ 'use strict'
17
+
18
+ // { el, accountLabel, idOf, acctState, worstWindow, until, clockAt, spoken }
19
+ let H = null
20
+ function use(helpers) { H = helpers || H; return api }
21
+
22
+ // ---- the binding bucket -------------------------------------------------
23
+ // The bucket that will actually stop the work: the one the endpoint marked
24
+ // active, else the highest percentage it reported, else the legacy hottest of
25
+ // the two windows, which is all an older record or a guest payload carries.
26
+ // Mirrors binding() in src/usage.mjs; the board cannot import from it.
27
+ const BUCKET_WORD = { weekly_scoped: 'week', weekly_all: 'week', session: 'session', spend: 'spend', seven_day: '7d', five_hour: '5h' }
28
+ function bindingOf(a) {
29
+ const buckets = Array.isArray(a && a.buckets) ? a.buckets.filter((b) => b && Number.isFinite(b.percent)) : []
30
+ const top = (l) => (l.length ? [...l].sort((x, y) => y.percent - x.percent)[0] : null)
31
+ const b = top(buckets.filter((x) => x.is_active)) || top(buckets)
32
+ if (b) return { kind: b.kind, model: b.model || null, percent: b.percent, resets_at: Number.isFinite(b.resets_at) ? b.resets_at : null, scope: b.model ? 'model' : 'account' }
33
+ const w = H.worstWindow(a)
34
+ if (!w || !Number.isFinite(w.pct)) return null
35
+ return { kind: a && a.seven_day === w ? 'seven_day' : 'five_hour', model: null, percent: w.pct, resets_at: Number.isFinite(w.resets_at) ? w.resets_at : null, scope: 'account' }
36
+ }
37
+ // the token's two words: `fable week`, `week`, `session`, `5h`
38
+ function bucketWord(b) { const word = BUCKET_WORD[b.kind] || b.kind; return b.model ? `${b.model} ${word}` : word }
39
+ // the same bucket inside a sentence: "63% of its week"
40
+ function windowPhrase(b) { return b.kind === 'session' ? 'its session' : b.kind === 'five_hour' ? 'its 5 hours' : 'its week' }
41
+ // every model this login has published anything about. A model named by
42
+ // neither a bucket nor a wall is one Leg has never seen, and it is never
43
+ // guessed at.
44
+ function knownModels(a) {
45
+ const out = []
46
+ for (const b of (a && a.buckets) || []) if (b && b.model && !out.includes(b.model)) out.push(b.model)
47
+ for (const m of Object.keys((a && a.walls) || {})) if (!out.includes(m)) out.push(m)
48
+ return out
49
+ }
50
+ function wallFor(a, model) {
51
+ const w = a && a.walls ? a.walls[model] : null
52
+ return w && Number.isFinite(w.limited_until) && w.limited_until * 1000 > Date.now() ? w : null
53
+ }
54
+ function walledModels(a) { return knownModels(a).filter((m) => wallFor(a, m)) }
55
+
56
+ // ---- one token ----------------------------------------------------------
57
+ function capFigure(a, b) {
58
+ // the same two refusals the gauge prints, in the strip's shorter grammar
59
+ if (a.shared === false) return 'not shared'
60
+ if (a.loading) return 'reading'
61
+ if (H.acctState(a) === 'walled') return Number.isFinite(a.limited_until) ? `back ${H.until(a.limited_until)}` : 'back when it resets'
62
+ // agy publishes no percentage, ever; a login that has one and has not
63
+ // reported it yet is a different fact and says so.
64
+ if (!b) return a.agent === 'agy' ? 'no figure' : 'no reading'
65
+ const observed = Date.parse(a.observed_at || a.updated_at || '')
66
+ // a reading older than the window it describes prints the clock it was
67
+ // taken at instead of a bucket word: it is a measurement, not a reading now
68
+ if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) return `${Math.round(b.percent)}% ${H.clockAt(observed)}`
69
+ return `${Math.round(b.percent)}% ${bucketWord(b)}`
70
+ }
71
+ // the spoken sentence carries what the visible token cannot: the reset, the
72
+ // source, the wall and the age of the reading, exactly as the gauges do.
73
+ function capValueText(a, b) {
74
+ const parts = []
75
+ if (a.shared === false) parts.push(`Usage for ${H.accountLabel(a)} is not shared with guests.`)
76
+ else if (!b) {
77
+ parts.push(a.agent === 'agy'
78
+ ? 'agy publishes no usage percentage, ever. Leg sees the wall when agy hits it.'
79
+ : `No reading has come back from ${H.accountLabel(a)} yet.`)
80
+ } else {
81
+ parts.push(`${Math.round(b.percent)} percent of ${b.model ? `the ${b.model} ${BUCKET_WORD[b.kind] || b.kind}` : windowPhrase(b)} used.`)
82
+ if (Number.isFinite(b.resets_at)) parts.push(`Resets at ${H.until(b.resets_at)}, in ${H.spoken(b.resets_at * 1000 - Date.now())}.`)
83
+ }
84
+ if (H.acctState(a) === 'walled') parts.push(`${H.accountLabel(a)} is at its wall until ${H.until(a.limited_until)}, in ${H.spoken(a.limited_until * 1000 - Date.now())}.`)
85
+ for (const m of walledModels(a)) parts.push(`${m} is out until ${H.until(wallFor(a, m).limited_until)}.`)
86
+ if (a.source) parts.push(`Source: ${a.source}.`)
87
+ const observed = Date.parse(a.observed_at || a.updated_at || '')
88
+ if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) parts.push(`Read at ${H.clockAt(observed)}, ${H.spoken(Date.now() - observed)} ago, stale.`)
89
+ return parts.join(' ')
90
+ }
91
+ function capToken(a) {
92
+ const el = H.el
93
+ const id = H.idOf(a.agent)
94
+ const b = bindingOf(a)
95
+ const walled = H.acctState(a) === 'walled'
96
+ const pct = b ? Math.max(0, Math.min(100, Math.round(b.percent))) : null
97
+ const token = el('span', { class: 'cap-token' }, [
98
+ el('span', { class: `dot id-${id}`, 'aria-hidden': 'true' }),
99
+ el('span', { class: `cap-name id-${id}` }, [H.accountLabel(a)]),
100
+ ])
101
+ // no number, no instrument. A track with nothing in it is a reading of zero
102
+ // to anyone glancing at it, which is exactly what agy does not have.
103
+ if (pct !== null || walled) {
104
+ const stop = pct !== null && pct > 85 ? `${((85 / pct) * 100).toFixed(2)}%` : null
105
+ const fill = el('span', {
106
+ class: 'cap-fill',
107
+ style: walled ? 'width:100%;background:var(--danger)'
108
+ : stop ? `width:${pct}%;background:linear-gradient(to right,var(--id-${id}) 0 ${stop},var(--danger) ${stop} 100%)`
109
+ : `width:${pct}%;background:var(--id-${id})`,
110
+ })
111
+ // a walled login with no percentage is not a meter: 100 would be a number
112
+ // nobody measured. It keeps the track and carries the sentence instead.
113
+ const semantics = pct === null
114
+ ? { role: 'img', 'aria-label': `${H.accountLabel(a)} capacity. ${capValueText(a, b)}` }
115
+ : { role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(pct), 'aria-label': `${H.accountLabel(a)} capacity`, 'aria-valuetext': capValueText(a, b) }
116
+ token.appendChild(el('span', { class: 'cap-track', ...semantics }, [fill]))
117
+ }
118
+ // with no track the figure carries the whole sentence itself, the way the
119
+ // gauge's readout does when a window has never been read
120
+ const quiet = pct === null && !walled ? { role: 'img', 'aria-label': `${H.accountLabel(a)} capacity. ${capValueText(a, b)}` } : {}
121
+ token.appendChild(el('span', { class: `cap-figure${walled ? ' is-out' : ''}${pct === null && !walled ? ' cap-figure--none' : ''}`, ...quiet }, [capFigure(a, b)]))
122
+ return token
123
+ }
124
+ function capacityStrip(list) {
125
+ const box = document.getElementById('capacity-tokens')
126
+ if (!box) return
127
+ box.textContent = ''
128
+ for (const a of list || []) box.appendChild(capToken(a))
129
+ }
130
+
131
+ // ---- the disclosure the login panels live behind ------------------------
132
+ // Whether it is open is the reader's decision, kept across reloads and shared
133
+ // by both pages: opening it on the board and finding it shut on the floor is
134
+ // the same fact answering two ways. localStorage throws in a private window
135
+ // and on a page opened from a file, so it is never load bearing.
136
+ const CAP_KEY = 'legCapacityOpen'
137
+ let capacityOpen = (() => { try { return localStorage.getItem(CAP_KEY) === '1' } catch { return false } })()
138
+ function renderCapacityToggle() {
139
+ const btn = document.getElementById('capacity-toggle')
140
+ const drawer = document.getElementById('capacity-drawer')
141
+ if (btn) {
142
+ btn.setAttribute('aria-expanded', capacityOpen ? 'true' : 'false')
143
+ btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models >'
144
+ }
145
+ if (drawer) drawer.hidden = !capacityOpen
146
+ }
147
+ function toggleCapacity() {
148
+ capacityOpen = !capacityOpen
149
+ try { localStorage.setItem(CAP_KEY, capacityOpen ? '1' : '0') } catch { /* private window: the drawer still opens, it just does not remember */ }
150
+ renderCapacityToggle()
151
+ }
152
+
153
+ const api = {
154
+ use, BUCKET_WORD, bindingOf, bucketWord, windowPhrase, knownModels, wallFor, walledModels,
155
+ capFigure, capValueText, capToken, capacityStrip, renderCapacityToggle, toggleCapacity,
156
+ isCapacityOpen: () => capacityOpen,
157
+ }
158
+
159
+ if (typeof window !== 'undefined') window.legStrip = api
160
+ // test seam: node:test runs this file with a stub document, the way the other
161
+ // board scripts are run; in a browser there is no `module`
162
+ if (typeof module !== 'undefined') module.exports = api
163
+ })()
@@ -0,0 +1,101 @@
1
+ // buckets — the one place per-model knowledge lives: the wording a CLI uses
2
+ // when it walls a single model, the model names Leg is willing to say out
3
+ // loud, and the flag each CLI spells its model with.
4
+ // Why one file: the same three facts were about to be needed by the usage
5
+ // record (which wall goes where), the taps (what a StopFailure message meant)
6
+ // and argv building (`--model` vs `-m`). Split across those, a reworded wall
7
+ // or a new alias would have to be fixed in three places and would be found by
8
+ // whichever one was missed. Nothing here reads or writes state.
9
+ //
10
+ // Sources for the wording table: docs/en/costs ("You've hit your Opus limit",
11
+ // session and weekly limits shared across models), the live StopFailure in
12
+ // fixtures/live/claude/limit-rate_limit.json ("You've reached your Fable
13
+ // limit."), and docs/cli-contracts.md:464 for codex ("usage limit for {name}",
14
+ // docs-only: no live codex per-model wall has been captured).
15
+ // Flags: verified in the adapters — src/adapters/claude.mjs:29 and agy.mjs:34
16
+ // push `--model`, codex.mjs:42 and grok.mjs:45 push `-m`.
17
+
18
+ // Model names per agent, lowercase, in ladder order (strongest first).
19
+ // Only claude publishes per-model buckets today; the others take a model on
20
+ // the command line but expose no per-model limit, so their lists stay empty
21
+ // rather than carrying a guess.
22
+ export const MODEL_ALIASES = {
23
+ claude: ['fable', 'opus', 'sonnet', 'haiku'],
24
+ codex: [],
25
+ agy: [],
26
+ grok: [],
27
+ }
28
+
29
+ const MODEL_FLAGS = { claude: '--model', agy: '--model', fake: '--model', codex: '-m', grok: '-m' }
30
+
31
+ // The flag this agent's CLI spells a model with, or null when Leg does not
32
+ // know it. A null answer means "do not pass a model", never "guess --model".
33
+ export function modelFlagFor(agent) {
34
+ return MODEL_FLAGS[agent] ?? null
35
+ }
36
+
37
+ // Where a model sits on its agent's own list: 0 is the strongest. -1 means Leg
38
+ // does not know the name, and an unknown name is never called stronger or
39
+ // weaker than anything.
40
+ export function modelRank(agent, model) {
41
+ if (!model) return -1
42
+ return (MODEL_ALIASES[agent] ?? []).indexOf(String(model).toLowerCase())
43
+ }
44
+
45
+ // A downshift is a move to a weaker model of the SAME login. It is the only
46
+ // move `claude --resume <id> --model <alias>` is used for: the probe in
47
+ // fixtures/live/claude/resume-model-probe.json shows the conversation survives
48
+ // and only the new model answers, but the context is re-read at the new model's
49
+ // rate (cache read 0 on the first resumed turn), so an upshift back to fable
50
+ // would pay that re-read at fable's price and takes the bundle instead.
51
+ export function isDownshift(from, to) {
52
+ if (!from || !to) return false
53
+ if (from.agent !== to.agent || (from.account ?? 'default') !== (to.account ?? 'default')) return false
54
+ const a = modelRank(from.agent, from.model)
55
+ const b = modelRank(to.agent, to.model)
56
+ return a >= 0 && b >= 0 && b > a
57
+ }
58
+
59
+ const SESSION_OR_WEEKLY = /(session|weekly) limit/i
60
+ const SPEND_WALL = /spend limit/i
61
+ const CODEX_MODEL_WALL = /usage limit for ([\w .-]+)/i
62
+
63
+ // "You've hit your Fable limit" / "You've reached your Opus limit". Built from
64
+ // the agent's own alias list, so codex's "You've hit your usage limit" can
65
+ // never be read as a model called "usage": a name Leg does not know falls
66
+ // through to rule 5 and walls the login.
67
+ function modelWallRe(agent) {
68
+ const names = MODEL_ALIASES[agent] ?? []
69
+ if (!names.length) return null
70
+ return new RegExp(`You.ve (?:hit|reached) your (${names.join('|')}) limit`, 'i')
71
+ }
72
+
73
+ // What a wall message walled. Ordered; the first rule that matches wins.
74
+ // { scope: 'account' } the whole login is out
75
+ // { scope: 'account', bucket: 'spend' } the spend cap, not a window
76
+ // { scope: 'model', model: 'fable' } one model family only
77
+ // Unrecognised wording is 'account' on purpose: walling the whole login is the
78
+ // direction that fails safe when the wording is reworded again, which it
79
+ // already was once ("hit" became "reached").
80
+ export function bucketFromWall(agent, text) {
81
+ const s = String(text ?? '')
82
+ // 1. session and weekly limits are shared across every model (docs/en/costs),
83
+ // so switching model buys nothing: the account is out.
84
+ if (SESSION_OR_WEEKLY.test(s)) return { scope: 'account' }
85
+ // 2. "You've hit/reached your <Model> limit" — one family.
86
+ const re = modelWallRe(agent)
87
+ const m = re ? re.exec(s) : null
88
+ if (m) return { scope: 'model', model: m[1].toLowerCase() }
89
+ // 3. the spend cap is an account fact, and it is not a window.
90
+ if (SPEND_WALL.test(s)) return { scope: 'account', bucket: 'spend' }
91
+ // 4. codex names the limit it hit (docs-only wording). The name runs to the
92
+ // end of the sentence, so cut at the first period that ends one: a model
93
+ // name's own dots (gpt-5.6-sol) are never followed by a space.
94
+ const c = CODEX_MODEL_WALL.exec(s)
95
+ if (c) {
96
+ const model = c[1].split(/\.(?=\s|$)/)[0].trim().toLowerCase()
97
+ if (model) return { scope: 'model', model }
98
+ }
99
+ // 5. anything else: the whole login.
100
+ return { scope: 'account' }
101
+ }
package/src/cards.mjs CHANGED
@@ -38,7 +38,12 @@ function kv(raw) {
38
38
  return m
39
39
  }
40
40
 
41
- export async function createCard(input, actor = { type: 'human', id: 'local' }) {
41
+ // `allowPipelineFile` is false for anything that arrives over HTTP. A pipeline
42
+ // that is a filesystem path is a CLI convenience (`--pipeline ./my.json`);
43
+ // taken from a request body it is an arbitrary read of this machine, and the
44
+ // JSON parser's own message quotes the first bytes of whatever it opened, so a
45
+ // 400 would hand a non-owner the contents of a file they may not see.
46
+ export async function createCard(input, actor = { type: 'human', id: 'local' }, { allowPipelineFile = true } = {}) {
42
47
  if (!input.repo) throw new CardInputError('missing repo')
43
48
  // stored as given (resolved); every comparison below is canonical, and the
44
49
  // worktree path is derived from the real long form in src/worktree.mjs
@@ -93,6 +98,9 @@ export async function createCard(input, actor = { type: 'human', id: 'local' })
93
98
  if (Array.isArray(pipelineArg)) pipeline = buildPipeline({ stations: pipelineArg, chain })
94
99
  else if (typeof pipelineArg === 'string' && pipelineArg.trim().startsWith('[')) pipeline = buildPipeline({ stations: JSON.parse(pipelineArg), chain })
95
100
  else if (PRESET_NAMES.includes(pipelineArg)) pipeline = buildPipeline({ preset: pipelineArg, chain })
101
+ // the refusal names the presets and never the value it was handed: a
102
+ // message that quoted the path back would still answer "does this exist"
103
+ else if (!allowPipelineFile) throw new Error(`pipeline must be one of the presets (${PRESET_NAMES.join(', ')}) or a list of stations`)
96
104
  else pipeline = buildPipeline({ file: pipelineArg, chain })
97
105
  validatePipeline(pipeline, await loadAdapterModes())
98
106
  } catch (err) {
package/src/chain.mjs CHANGED
@@ -31,6 +31,7 @@ export const TRANSITIONS = [
31
31
  ['running', 'land:bounced', 'queued', 'land red/conflict → bounce to build with the failure attached (phase 7)'],
32
32
  ['running', 'land:failed', 'failed', 'land attempts exhausted (phase 7)'],
33
33
  ['running', 'pause', 'paused', 'human: kill the child, write a bundle'],
34
+ ['*non-terminal*', 'take_over', 'paused', 'human: sits down in the card\'s worktree themselves; the child is killed and the card leaves the runnable set'],
34
35
  ['paused', 'resume', 'queued', 'human: same station and leg; prompt = bundle load + contract'],
35
36
  ['*non-terminal*', 'kill', 'killed', 'human'],
36
37
  ['*non-terminal*', 'reassign', 'queued', 'human: rewrite the current station\'s chain from the current leg'],
@@ -196,6 +197,18 @@ export function transition(card, action, payload = {}) {
196
197
  events.push(ev('paused', 'paused by human'))
197
198
  return { card: { ...card, status: 'paused' }, events }
198
199
  }
200
+ // Take over: a human opens an interactive terminal in this card's worktree.
201
+ // `pause` is legal from `running` alone, so a queued or handing_off card
202
+ // stayed in the set the scheduler starts from and a leg was launched into
203
+ // the checkout the human had just been handed. Legal from every
204
+ // non-terminal status, and it always lands on `paused`, which is the one
205
+ // state that is both out of the scheduler's reach and honest about what
206
+ // the card is doing: nothing, because a person has it.
207
+ case 'take_over': {
208
+ assertStatus(card, action, NON_TERMINAL)
209
+ events.push(ev('taken_over', `taken over by a human at ${card.station} leg ${card.leg} (was ${card.status})`))
210
+ return { card: { ...card, status: 'paused' }, events }
211
+ }
199
212
  case 'resume': {
200
213
  assertStatus(card, action, ['paused'])
201
214
  events.push(ev('resumed', `resumed at ${card.station} leg ${card.leg}`))
package/src/hook.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Leg line.
10
10
  import { appendFileSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
- import { handleHook, handleStatusline } from './taps/claude.mjs'
12
+ import { handleHook, handleStatusline, terminalSequenceFor } from './taps/claude.mjs'
13
13
  import { sessionDir } from './sessions.mjs'
14
14
  import { captureLive } from './live-capture.mjs'
15
15
 
@@ -40,6 +40,12 @@ try {
40
40
  if (payload.hook_event_name === 'StopFailure' && payload.error) {
41
41
  try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
42
42
  }
43
+ // Notification hooks cannot block or modify anything and their
44
+ // systemMessage is discarded, but Claude Code still emits terminalSequence
45
+ // for them (hooks doc 1490, 622). That is the toast, and it is the only
46
+ // thing this process prints on stdout for a hook.
47
+ const seq = terminalSequenceFor(payload)
48
+ if (seq) process.stdout.write(JSON.stringify({ terminalSequence: seq }) + '\n')
43
49
  } else if (kind === 'claude-statusline') {
44
50
  const { text } = handleStatusline(sessionId, payload)
45
51
  try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
package/src/ledger.mjs CHANGED
@@ -16,14 +16,22 @@ import { dashclawConfig, record } from './sync/dashclaw.mjs'
16
16
  export const EVENT_TYPES = ['card_created', 'leg_started', 'leg_progress', 'leg_exited',
17
17
  'limit_detected', 'handoff_written', 'leg_resumed', 'station_done', 'bounced', 'landed',
18
18
  'land_warning', 'land_retry', 'blocked_by', 'scheduler_started', 'scheduler_stopped',
19
- 'approval_needed', 'approved', 'reassigned', 'paused', 'resumed', 'killed', 'done',
19
+ 'approval_needed', 'approved', 'reassigned', 'paused', 'resumed', 'taken_over', 'killed', 'done',
20
20
  'failed', 'error', 'status', 'harness', 'harness_blocked']
21
21
  export const STATUSES = ['backlog', 'queued', 'running', 'handing_off', 'waiting_human',
22
22
  'needs_approval', 'paused', 'done', 'failed', 'killed']
23
23
  const CLOSED = ['done', 'failed', 'killed']
24
24
  // card.json keys `update --patch` may set (everything else goes through a named flag)
25
25
  export const PATCHABLE = ['pipeline', 'leases', 'land_attempts', 'land_mode', 'test_command', 'title', 'trunk',
26
- 'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url', 'harness']
26
+ 'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url', 'harness',
27
+ // where this card came from ({ from: <terminal id> } after "End, and keep
28
+ // going as a card"), and whether its worktree was adopted from that terminal
29
+ // rather than cut for the card: the orchestrator must not cut a second one
30
+ // over the top of it (redesign G4)
31
+ // the branch that checkout is actually on: `leg/<card-id>` for one the card
32
+ // cut, the TERMINAL's branch for one it adopted, which is not a name the
33
+ // board can derive from the card id (redesign C.3's register)
34
+ 'lineage', 'worktree_adopted', 'worktree_branch']
27
35
  const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i
28
36
 
29
37
  export const ROOT = process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg'))
package/src/models.mjs ADDED
@@ -0,0 +1,265 @@
1
+ // models: the model catalog each CLI already publishes, read from where that
2
+ // CLI keeps it rather than from a list Leg maintains by hand.
3
+ //
4
+ // Why not a table in this file: three of the four catalogs move under Leg's
5
+ // feet. codex rewrites ~/.codex/models_cache.json whenever it refreshes from
6
+ // the service, agy and grok print theirs from their own accounts, and a name
7
+ // hard-coded here would be a wrong answer the week after it was written. Only
8
+ // claude's list is static, and it is static because those four words are
9
+ // aliases Claude Code itself resolves (src/buckets.mjs MODEL_ALIASES), not
10
+ // service-side model ids.
11
+ //
12
+ // Sources, each verified on this machine on 2026-09-18:
13
+ // claude MODEL_ALIASES.claude, labelled; no default (Claude Code picks one)
14
+ // codex <CODEX_HOME>/models_cache.json models[] where visibility == "list",
15
+ // default from `model = "..."` at the top of <CODEX_HOME>/config.toml
16
+ // agy `agy models`, tab-separated `id<TAB>label` after a "Fetching" line
17
+ // grok `grok models`, "Default model: X" then " * id (default)" / " - id"
18
+ //
19
+ // agy and grok cost a process each, so their answers are cached under
20
+ // <LEG_HOME>/models/<agent>.json for an hour and a request is never made to
21
+ // wait on one: a stale or missing cache is served as-is and the refresh runs
22
+ // behind the answer.
23
+ import { execFile } from 'node:child_process'
24
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+ import { home } from './store.mjs'
27
+ import { writeJsonAtomic } from './fsx.mjs'
28
+ import { MODEL_ALIASES } from './buckets.mjs'
29
+ import { LAYOUT } from './accounts.mjs'
30
+ import agyAdapter from './adapters/agy.mjs'
31
+ import grokAdapter from './adapters/grok.mjs'
32
+
33
+ export const MODEL_AGENTS = ['claude', 'codex', 'agy', 'grok']
34
+
35
+ // A model id is pushed onto an agent's argv (`-m <id>`), so the shape is the
36
+ // boundary: letters, digits and the four separators the four catalogs actually
37
+ // use. Anything else (a space, a quote, a leading dash, a path separator) is
38
+ // a flag or a path in disguise and is dropped by the parser rather than
39
+ // corrected, because a corrected id is a model nobody published.
40
+ //
41
+ // Lower case only, and that is a constraint on the catalogs rather than a
42
+ // preference: `normalizeRung` in src/preferences.mjs lower-cases a rung's model
43
+ // before it validates it, so an id with a capital in it would be offered in a
44
+ // select here and then handed to the CLI in a spelling the CLI never published.
45
+ // All 23 ids the four catalogs publish on this machine today are lower case
46
+ // (codex slugs, agy ids, grok bullets, claude's aliases), so nothing is lost;
47
+ // if one ever is not, it is missing from the select, which is visible, rather
48
+ // than silently mangled on a command line, which is not.
49
+ const MODEL_ID_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/
50
+
51
+ export function validModelId(id) {
52
+ return MODEL_ID_RE.test(String(id ?? ''))
53
+ }
54
+
55
+ const CACHE_MS = 60 * 60 * 1000
56
+ const PROBE_TIMEOUT_MS = 20_000
57
+
58
+ // ---- parsers: pure, one fixture each under fixtures/models/ ----------------
59
+
60
+ // "fable" -> "Claude Fable". The alias is what the CLI takes; the label is
61
+ // what a human reads in a select.
62
+ function claudeLabel(alias) {
63
+ return `Claude ${alias.charAt(0).toUpperCase()}${alias.slice(1)}`
64
+ }
65
+
66
+ // claude's list never leaves this process: the aliases are Claude Code's own.
67
+ // No default: `claude` with no `--model` picks for itself, and naming one here
68
+ // would be Leg inventing a choice the human did not make.
69
+ export function claudeModels() {
70
+ return MODEL_ALIASES.claude.filter(validModelId).map((id) => ({ id, label: claudeLabel(id), default: false }))
71
+ }
72
+
73
+ // models_cache.json plus config.toml. `visibility` is codex's own word for
74
+ // which models it offers a human: "hide" covers gpt-reserve and the internal
75
+ // codex-auto-review, neither of which is a model to start a leg on.
76
+ export function parseCodexModels(cacheText, configText = '') {
77
+ let parsed = null
78
+ try { parsed = JSON.parse(String(cacheText ?? '')) } catch { return [] }
79
+ const rows = Array.isArray(parsed?.models) ? parsed.models : []
80
+ const preferred = parseCodexDefault(configText)
81
+ const out = []
82
+ for (const m of rows) {
83
+ if (m?.visibility !== 'list') continue
84
+ const id = String(m?.slug ?? '')
85
+ if (!validModelId(id)) continue
86
+ out.push({ id, label: String(m?.display_name || id), default: id === preferred })
87
+ }
88
+ return out
89
+ }
90
+
91
+ // `model = "gpt-6-astra"` at the top level of config.toml. A `model` key inside
92
+ // a `[profiles.x]` table is that profile's, not the one a bare `codex` uses, so
93
+ // the scan stops at the first table header.
94
+ //
95
+ // A trailing `# ...` is ordinary TOML and common in that file, so it is allowed
96
+ // after the closing quote: anchoring at end of line made `model = "gpt-5-codex"
97
+ // # the fast one` read as no default at all, and the board then offered a
98
+ // generic "provider default" for a codex that has one.
99
+ export function parseCodexDefault(configText) {
100
+ for (const raw of String(configText ?? '').split(/\r?\n/)) {
101
+ const line = raw.trim()
102
+ if (line.startsWith('#') || !line) continue
103
+ if (line.startsWith('[')) break
104
+ const m = /^model\s*=\s*["']([^"']+)["']\s*(?:#.*)?$/.exec(line)
105
+ if (m && validModelId(m[1])) return m[1]
106
+ }
107
+ return null
108
+ }
109
+
110
+ // `agy models` prints a progress line first and then one `id<TAB>label` per
111
+ // model. A line with no tab is progress, not a model.
112
+ export function parseAgyModels(stdout) {
113
+ const out = []
114
+ for (const raw of String(stdout ?? '').split(/\r?\n/)) {
115
+ const tab = raw.indexOf('\t')
116
+ if (tab <= 0) continue
117
+ const id = raw.slice(0, tab).trim()
118
+ if (!validModelId(id)) continue
119
+ out.push({ id, label: raw.slice(tab + 1).trim() || id, default: false })
120
+ }
121
+ return out
122
+ }
123
+
124
+ // `grok models` prints "Default model: X", then a bullet per model with the
125
+ // default marked. grok publishes no display name, so the id is the label.
126
+ export function parseGrokModels(stdout) {
127
+ const text = String(stdout ?? '')
128
+ const declared = /^\s*Default model:\s*(\S+)\s*$/m.exec(text)
129
+ const preferred = declared && validModelId(declared[1]) ? declared[1] : null
130
+ const out = []
131
+ for (const raw of text.split(/\r?\n/)) {
132
+ const m = /^\s*[*-]\s+(\S+)\s*(\(default\))?\s*$/.exec(raw)
133
+ if (!m || !validModelId(m[1])) continue
134
+ out.push({ id: m[1], label: m[1], default: m[1] === preferred || Boolean(m[2]) })
135
+ }
136
+ return out
137
+ }
138
+
139
+ // ---- codex: files, read every time (they are two small local reads) --------
140
+
141
+ function codexHome() {
142
+ return process.env.CODEX_HOME || LAYOUT.codex.home()
143
+ }
144
+
145
+ function readIfPresent(file) {
146
+ try { return existsSync(file) ? readFileSync(file, 'utf8') : '' } catch { return '' }
147
+ }
148
+
149
+ export function codexModels() {
150
+ const dir = codexHome()
151
+ return parseCodexModels(readIfPresent(join(dir, 'models_cache.json')), readIfPresent(join(dir, 'config.toml')))
152
+ }
153
+
154
+ // ---- agy and grok: a process, cached ---------------------------------------
155
+
156
+ export function modelsCacheFile(agent) {
157
+ return join(home(), 'models', `${agent}.json`)
158
+ }
159
+
160
+ function readCache(agent) {
161
+ try {
162
+ const value = JSON.parse(readFileSync(modelsCacheFile(agent), 'utf8'))
163
+ if (!Array.isArray(value?.models)) return null
164
+ return { models: value.models.filter((m) => validModelId(m?.id)).map((m) => ({ id: String(m.id), label: String(m.label ?? m.id), default: m.default === true })), observed_at: value.observed_at ?? null, attempted_at: value.attempted_at ?? null }
165
+ } catch { return null }
166
+ }
167
+
168
+ // `observed_at` is when a list was last SEEN, `attempted_at` when the probe was
169
+ // last RUN. They part company on a fruitless probe: an agent that is not
170
+ // installed prints nothing, and without the attempt stamp that answer left no
171
+ // cache entry at all, so every board page load, every New card dialog and every
172
+ // floor load spawned a fresh `agy models` and `grok models` for the same
173
+ // nothing. The attempt is cached; the last list Leg really saw is kept.
174
+ function writeCache(agent, models, { observedAt = new Date().toISOString(), attemptedAt = new Date().toISOString() } = {}) {
175
+ try {
176
+ mkdirSync(join(home(), 'models'), { recursive: true })
177
+ writeJsonAtomic(modelsCacheFile(agent), { models, observed_at: observedAt, attempted_at: attemptedAt })
178
+ } catch { /* a catalog that cannot be cached is still a catalog */ }
179
+ }
180
+
181
+ // The binary each adapter already resolves, so a probe runs the same agy or
182
+ // grok a leg would, including the LEG_AGY_BIN / LEG_GROK_BIN test seams. No
183
+ // shell: the argv is fixed, and a shell here would be one more thing between
184
+ // Leg and a list of names.
185
+ const PROBES = {
186
+ agy: { adapter: agyAdapter, args: ['models'], parse: parseAgyModels },
187
+ grok: { adapter: grokAdapter, args: ['models'], parse: parseGrokModels },
188
+ }
189
+
190
+ function probeArgv(probe) {
191
+ const { bin, viaNode } = probe.adapter.resolve()
192
+ return viaNode ? { bin: process.execPath, args: [bin, ...probe.args] } : { bin, args: [...probe.args] }
193
+ }
194
+
195
+ // One refresh per agent in flight at a time. Two board tabs opening at once
196
+ // used to be two `agy models` processes for the same answer.
197
+ const inFlight = new Map()
198
+
199
+ function stale(entry) {
200
+ if (!entry) return true
201
+ const ms = (v) => { const n = Date.parse(v ?? ''); return Number.isFinite(n) ? n : -Infinity }
202
+ // the newer of the two stamps: a probe that answered nothing an hour ago is
203
+ // due again, and one that answered nothing a second ago is not
204
+ const at = Math.max(ms(entry.observed_at), ms(entry.attempted_at))
205
+ return !Number.isFinite(at) || Date.now() - at >= CACHE_MS
206
+ }
207
+
208
+ // Runs the probe and caches what it prints. Never throws: an agent that is not
209
+ // installed, is not logged in, or hangs leaves the cache exactly as it was.
210
+ export function refreshModels(agent) {
211
+ const probe = PROBES[agent]
212
+ if (!probe) return Promise.resolve(readCache(agent)?.models ?? [])
213
+ if (inFlight.has(agent)) return inFlight.get(agent)
214
+ const run = new Promise((resolve) => {
215
+ let spec
216
+ try { spec = probeArgv(probe) } catch { resolve(readCache(agent)?.models ?? []); return }
217
+ execFile(spec.bin, spec.args, { timeout: PROBE_TIMEOUT_MS, windowsHide: true, encoding: 'utf8', maxBuffer: 1 << 20 }, (_err, stdout) => {
218
+ // a non-zero exit still prints the list when the CLI is only
219
+ // unauthenticated (grok says so and lists anyway), so stdout is parsed
220
+ // before the exit code is believed
221
+ const models = probe.parse(stdout ?? '')
222
+ const prev = readCache(agent)
223
+ // a fruitless probe is still an answer about this machine: it is stamped
224
+ // so the next request is served from here instead of spawning again, and
225
+ // the last list Leg did see is kept until a probe supersedes it
226
+ if (models.length) writeCache(agent, models)
227
+ else writeCache(agent, prev?.models ?? [], { observedAt: prev?.observed_at ?? null })
228
+ resolve(models.length ? models : (prev?.models ?? []))
229
+ })
230
+ }).finally(() => inFlight.delete(agent))
231
+ inFlight.set(agent, run)
232
+ return run
233
+ }
234
+
235
+ // The cached answer, with a refresh kicked off behind it when it is stale.
236
+ // Synchronous on purpose: no board request waits on a child process.
237
+ function cachedModels(agent) {
238
+ const entry = readCache(agent)
239
+ if (stale(entry)) refreshModels(agent).catch(() => {})
240
+ return entry?.models ?? []
241
+ }
242
+
243
+ // ---- the catalog ------------------------------------------------------------
244
+
245
+ // Every model this machine can start `agent` on, strongest or default first as
246
+ // that agent's own source orders them. An empty list is an honest answer: it
247
+ // means Leg could not read a catalog, and the caller offers "provider default".
248
+ export function modelsFor(agent) {
249
+ if (agent === 'claude') return claudeModels()
250
+ if (agent === 'codex') return codexModels()
251
+ if (agent === 'agy' || agent === 'grok') return cachedModels(agent)
252
+ return []
253
+ }
254
+
255
+ export function listModels() {
256
+ const models = {}
257
+ for (const agent of MODEL_AGENTS) models[agent] = modelsFor(agent)
258
+ return { models, observed_at: new Date().toISOString() }
259
+ }
260
+
261
+ // The model a bare `leg <agent>` would run, or null when the agent picks for
262
+ // itself. Used to label the first option of a model select.
263
+ export function defaultModelFor(agent) {
264
+ return modelsFor(agent).find((m) => m.default)?.id ?? null
265
+ }