@ucsandman/legcli 0.12.0 → 0.13.1

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.
@@ -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
+ })()
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
+ }
@@ -73,6 +73,29 @@ export function validRungAccount(agent, account) {
73
73
  return null
74
74
  }
75
75
 
76
+ // A rung's model reaches the agent's argv as `--model <id>` or `-m <id>`, so
77
+ // the first question is shape, not membership: a value with a space, a quote,
78
+ // a leading dash or a path separator in it is a flag or a path in disguise.
79
+ //
80
+ // Membership is asked of claude alone. MODEL_ALIASES.claude is a CLOSED list:
81
+ // four words Claude Code resolves itself, not service-side ids, so a fifth
82
+ // word there is a typo and saying so is help. The other three catalogs are
83
+ // live (src/models.mjs reads codex's cache file and asks agy and grok), they
84
+ // gain and lose names between Leg releases, and a list frozen in this file
85
+ // would refuse tomorrow's model with "Leg knows no model names for it" while
86
+ // the CLI next to it ran it happily. Their ids are checked for shape and then
87
+ // believed: the CLI itself is the authority on its own catalog, and it answers
88
+ // an id it does not have in one line on the leg's own log.
89
+ const RUNG_MODEL_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/
90
+
91
+ export function validRungModel(agent, model) {
92
+ const id = String(model ?? '')
93
+ if (!RUNG_MODEL_RE.test(id)) return `rung "model" must be a model id of letters, digits, . _ : and - (got "${model}")`
94
+ const closed = MODEL_ALIASES[agent] ?? []
95
+ if (closed.length && !closed.includes(id)) return `${agent} has no model "${id}" (${closed.join(', ')})`
96
+ return null
97
+ }
98
+
76
99
  export function normalizeRung(value) {
77
100
  const agent = String(value?.agent ?? '')
78
101
  const model = value?.model === undefined || value?.model === null || value?.model === '' ? null : String(value.model).toLowerCase()
@@ -119,10 +142,8 @@ export function requireHandoffLadder(value) {
119
142
  const rung = normalizeRung(raw)
120
143
  const badAccount = validRungAccount(rung.agent, rung.account)
121
144
  if (badAccount) throw new TypeError(badAccount)
122
- if (rung.model && !(MODEL_ALIASES[rung.agent] ?? []).includes(rung.model)) {
123
- const known = (MODEL_ALIASES[rung.agent] ?? []).join(', ')
124
- throw new TypeError(`${rung.agent} has no model "${rung.model}"${known ? ` (${known})` : ': Leg knows no model names for it'}`)
125
- }
145
+ const badModel = rung.model ? validRungModel(rung.agent, rung.model) : null
146
+ if (badModel) throw new TypeError(badModel)
126
147
  if (raw.when !== undefined && !(typeof raw.when === 'string' && WHEN_RE.test(raw.when))) throw new TypeError(`rung "when" must be always, below:N or walled-only (got "${raw.when}")`)
127
148
  if (raw.cost !== undefined && !RUNG_COSTS.includes(raw.cost)) throw new TypeError(`rung "cost" must be one of ${RUNG_COSTS.join(', ')}`)
128
149
  const key = rungKey(rung)
@@ -144,11 +165,38 @@ export function orderFromLadder(ladder) {
144
165
  return out.filter((a) => wanted.includes(a))
145
166
  }
146
167
 
168
+ // One bad rung is one bad rung. `requireHandoffLadder` throws on the first
169
+ // problem it meets, and catching that threw the whole ladder away: a user who
170
+ // mistyped one claude alias in a file docs/configuration.md invites them to
171
+ // hand-edit lost their three good rungs with it, silently, and their terminals
172
+ // then handed off somewhere they never asked for. The same swallow fired for a
173
+ // rung naming an account that has since been removed, which is a live check.
174
+ //
175
+ // → { ladder, dropped }, where a dropped rung keeps the reason it was refused.
176
+ export function salvageHandoffLadder(value) {
177
+ const ladder = []
178
+ const dropped = []
179
+ const seen = new Set()
180
+ for (const raw of Array.isArray(value) ? value : []) {
181
+ let rung = null
182
+ try { [rung] = requireHandoffLadder([raw]) } catch (err) { dropped.push({ rung: raw, why: err.message }); continue }
183
+ const k = rungKey(rung)
184
+ if (seen.has(k)) { dropped.push({ rung: raw, why: `handoff_ladder names ${rung.agent}/${rung.account}${rung.model ? '/' + rung.model : ''} twice` }); continue }
185
+ seen.add(k)
186
+ ladder.push(rung)
187
+ }
188
+ return { ladder, dropped }
189
+ }
190
+
147
191
  // The ladder a preferences object means: its own, else the long-hand form of
148
192
  // its `handoff_order`, else the default ladder.
149
193
  export function normalizeHandoffLadder(prefs) {
150
194
  const value = Array.isArray(prefs) ? prefs : prefs?.handoff_ladder
151
- if (Array.isArray(value) && value.length) { try { return requireHandoffLadder(value) } catch { /* fall through to the order */ } }
195
+ if (Array.isArray(value) && value.length) {
196
+ const { ladder } = salvageHandoffLadder(value)
197
+ if (ladder.length) return ladder
198
+ /* nothing readable left: fall through to the order */
199
+ }
152
200
  if (!Array.isArray(prefs) && validHandoffOrder(prefs?.handoff_order)) return ladderFromOrder(prefs.handoff_order)
153
201
  if (Array.isArray(prefs)) return defaultLadder()
154
202
  return defaultLadder()
@@ -258,6 +306,12 @@ const defaults = () => {
258
306
  return { handoff_order: orderFromLadder(ladder), handoff_ladder: ladder, climb_back: 'next-handoff', may_spend: false, reserve: {}, auto_approve: true, notify_terminal: true, notify_board: false, harness: { ...HARNESS_DEFAULTS } }
259
307
  }
260
308
 
309
+ // The file verbatim, or null when there is not one Leg can parse. Used by
310
+ // writePreferences to leave alone what it could not read.
311
+ function rawPreferences() {
312
+ try { return JSON.parse(readFileSync(preferencesFile(), 'utf8')) } catch { return null }
313
+ }
314
+
261
315
  export function readPreferences() {
262
316
  const file = preferencesFile()
263
317
  if (!existsSync(file)) return defaults()
@@ -295,6 +349,16 @@ export function writePreferences(patch) {
295
349
  return withFileLock(preferencesFile() + '.lock', () => {
296
350
  const current = readPreferences()
297
351
  const next = { ...current }
352
+ // A ladder Leg could only read part of stays on disk exactly as the human
353
+ // wrote it. readPreferences drops the rung it cannot parse so the machine
354
+ // keeps walking the rest, but that reading is not a decision to delete
355
+ // anything: a save about may_spend must never be what removes a rung
356
+ // somebody typed into a file the docs call hand-editable.
357
+ const raw = ladder === undefined && order === undefined ? rawPreferences() : null
358
+ if (Array.isArray(raw?.handoff_ladder) && raw.handoff_ladder.length && !validHandoffLadder(raw.handoff_ladder)) {
359
+ next.handoff_ladder = raw.handoff_ladder
360
+ if (validHandoffOrder(raw.handoff_order)) next.handoff_order = [...raw.handoff_order]
361
+ }
298
362
  if (ladder !== undefined) { next.handoff_ladder = ladder; next.handoff_order = orderFromLadder(ladder) }
299
363
  if (order !== undefined) { next.handoff_order = order; if (ladder === undefined) next.handoff_ladder = ladderFromOrder(order) }
300
364
  if (climbBack !== undefined) next.climb_back = climbBack