@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.
- package/CHANGELOG.md +213 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +18 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +68 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +529 -96
- package/docs/cli-contracts.md +241 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +65 -1
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/verified.json +1 -1
- package/package.json +3 -2
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +378 -93
- package/src/audit.mjs +1 -1
- package/src/board/board.css +203 -11
- package/src/board/board.js +664 -200
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +122 -45
- package/src/board/sessions.js +1569 -141
- package/src/board/strip.js +163 -0
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/models.mjs +265 -0
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +278 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +625 -78
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +107 -3
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage-poll.mjs +260 -0
- 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
|
+
})()
|
package/src/buckets.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
+
}
|