@ucsandman/legcli 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +212 -0
- package/README.md +158 -67
- package/bin/leg.mjs +168 -18
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +94 -0
- package/docs/ROADMAP-v2.md +69 -11
- package/docs/VOCABULARY.md +27 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +235 -22
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +113 -5
- 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/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/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +3 -2
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +350 -42
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +134 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +89 -7
- package/src/board/sessions.js +1371 -113
- 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/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +615 -50
- package/src/sessions.mjs +17 -1
- package/src/share.mjs +66 -6
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +424 -13
package/bin/leg.mjs
CHANGED
|
@@ -19,14 +19,17 @@ import { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } from '../sr
|
|
|
19
19
|
import { availableActions } from '../src/chain.mjs'
|
|
20
20
|
import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
|
|
21
21
|
import { attach, ensureBoard } from '../src/attach.mjs'
|
|
22
|
-
import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed } from '../src/share.mjs'
|
|
22
|
+
import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed, scheme, tlsConfigured, ROLES } from '../src/share.mjs'
|
|
23
|
+
import { normalizeHandoffOrder, ladderFor, readPreferences, writePreferences } from '../src/preferences.mjs'
|
|
24
|
+
import { MODEL_ALIASES } from '../src/buckets.mjs'
|
|
23
25
|
import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
|
|
24
|
-
import { addAccount, removeAccount, listAccountRows, LAYOUT } from '../src/accounts.mjs'
|
|
25
|
-
import { listUsage, fmtReset } from '../src/usage.mjs'
|
|
26
|
+
import { addAccount, removeAccount, listAccountRows, readAccounts, LAYOUT } from '../src/accounts.mjs'
|
|
27
|
+
import { listUsage, fmtReset, readUsage, isAvailable, candidates, binding, wallActive, evaluateLadder, rungLabel } from '../src/usage.mjs'
|
|
26
28
|
import { home } from '../src/store.mjs'
|
|
27
29
|
import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
|
|
28
30
|
import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
|
|
29
31
|
import { harnessCommand } from '../src/harness/cli.mjs'
|
|
32
|
+
import { adapterCommand } from '../src/adapters/cli.mjs'
|
|
30
33
|
import { historyCommand, worktreesCommand } from '../src/history/cli.mjs'
|
|
31
34
|
|
|
32
35
|
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
|
@@ -71,13 +74,13 @@ async function cardAdd(args) {
|
|
|
71
74
|
// start the next option in the same terminal. The payload is marked
|
|
72
75
|
// simulated: it is never kept as live evidence, and the wall it records
|
|
73
76
|
// clears after two minutes. codex has no Leg-owned input, so it is refused.
|
|
74
|
-
function simulateLimit(s) {
|
|
77
|
+
function simulateLimit(s, { message = null } = {}) {
|
|
75
78
|
if (!isActive(s)) die(3, `session ${s.session_id} is not active`)
|
|
76
79
|
if (['limit', 'handing_off'].includes(s.status)) die(3, `session ${s.session_id} is already ${s.status}`)
|
|
77
80
|
if (s.agent === 'claude') {
|
|
78
81
|
const payload = {
|
|
79
82
|
hook_event_name: 'StopFailure', error: 'rate_limit', session_id: s.agent_session_id ?? undefined, transcript_path: s.transcript_path ?? undefined,
|
|
80
|
-
last_assistant_message: 'API Error: Rate limit reached (simulated by leg sessions simulate-limit)', leg_simulated: true, baton_simulated: true,
|
|
83
|
+
last_assistant_message: message ?? 'API Error: Rate limit reached (simulated by leg sessions simulate-limit)', leg_simulated: true, baton_simulated: true,
|
|
81
84
|
}
|
|
82
85
|
const r = spawnSync(process.execPath, [join(SRC, 'hook.mjs'), 'claude-hook', '--session', s.session_id], { input: JSON.stringify(payload), windowsHide: true, encoding: 'utf8', timeout: 15000 })
|
|
83
86
|
if (r.status !== 0) die(1, `hook exited ${r.status}: ${(r.stderr || '').slice(0, 300)}`)
|
|
@@ -98,6 +101,96 @@ function simulateLimit(s) {
|
|
|
98
101
|
die(2, `simulate-limit drives the claude hook path (and the agy/grok log); codex's wall comes from its own rollout file, which Leg never writes. Use "leg sessions handoff ${s.session_id}" to force the switch.`)
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
// `claude`, `claude/work`, `claude/opus`, `claude/work/opus`. Three parts are
|
|
105
|
+
// unambiguous. Two are not, so the second is read as an account when that
|
|
106
|
+
// account exists and as a model when the agent has one by that name; a word
|
|
107
|
+
// that is neither is refused by name rather than guessed at.
|
|
108
|
+
export function parseTarget(value, { die: fail = (code, msg) => { throw new Error(msg) } } = {}) {
|
|
109
|
+
const parts = String(value).split('/').filter(Boolean)
|
|
110
|
+
const agent = parts[0]
|
|
111
|
+
if (!agent) fail(2, 'usage: --to <agent>[/<account>[/<model>]]')
|
|
112
|
+
const models = MODEL_ALIASES[agent] ?? []
|
|
113
|
+
if (parts.length >= 3) return { agent, account: parts[1], model: parts[2].toLowerCase() }
|
|
114
|
+
if (parts.length === 2) {
|
|
115
|
+
const second = parts[1]
|
|
116
|
+
const accounts = readAccounts()[agent] ?? ['default']
|
|
117
|
+
if (accounts.includes(second)) return { agent, account: second, model: null }
|
|
118
|
+
if (models.includes(second.toLowerCase())) return { agent, account: 'default', model: second.toLowerCase() }
|
|
119
|
+
fail(2, `"${second}" is neither a ${agent} account (${accounts.join(', ')}) nor a ${agent} model (${models.join(', ') || 'none known'})`)
|
|
120
|
+
}
|
|
121
|
+
return { agent, account: 'default', model: null }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// What a rung is doing right now, in the words the board uses: the wall and its
|
|
125
|
+
// clock, else the percentage of the bucket that binds it, else "no figure".
|
|
126
|
+
// Never a guess: an agent that publishes no number says so.
|
|
127
|
+
function rungState(rung) {
|
|
128
|
+
const u = readUsage(rung.agent, rung.account)
|
|
129
|
+
const wall = rung.model ? u.walls?.[rung.model] : null
|
|
130
|
+
if (wall && wallActive(wall)) return `${rung.model} out until ${fmtReset(wall.limited_until)}`
|
|
131
|
+
if (!isAvailable(u)) return `at its limit until ${fmtReset(u.limited_until)}`
|
|
132
|
+
const b = binding(u, rung.model ?? null)
|
|
133
|
+
if (b && Number.isFinite(b.percent)) return `${Math.round(b.percent)}% of the ${b.model ? b.model + ' ' : ''}${b.kind === 'session' || b.kind === 'five_hour' ? '5h' : 'week'} window`
|
|
134
|
+
return 'no figure'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function printLadder() {
|
|
138
|
+
const prefs = readPreferences()
|
|
139
|
+
const ladder = prefs.handoff_ladder
|
|
140
|
+
const rows = evaluateLadder({ from: null, list: ladder, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: true, climbBack: prefs.climb_back, ladder })
|
|
141
|
+
out('The ladder a terminal falls down when its login stops. Rung 1 first, every time.')
|
|
142
|
+
ladder.forEach((rung, i) => {
|
|
143
|
+
const r = rows[i]
|
|
144
|
+
const when = rung.when === 'always' ? '' : ` when ${rung.when}`
|
|
145
|
+
out(` ${String(i + 1).padEnd(2)} ${rungLabel(rung).padEnd(20)} ${rungState(rung).padEnd(34)} ${r.ok ? 'ready' : r.reason}${when}`)
|
|
146
|
+
})
|
|
147
|
+
out('')
|
|
148
|
+
out(`spending: ${prefs.may_spend ? 'on (a credits or metered rung may be taken unattended)' : 'off (a credits or metered rung is skipped unattended)'} · leg ladder spend on|off`)
|
|
149
|
+
out(`climb back: ${prefs.climb_back === 'never' ? 'never (stay on the lower rung until you press Back)' : 'at the next hand-off'}`)
|
|
150
|
+
const reserve = Object.entries(prefs.reserve ?? {})
|
|
151
|
+
out(`reserve: ${reserve.length ? reserve.map(([a, p]) => `${a} ${p}%`).join(', ') : 'none'}`)
|
|
152
|
+
out(`order (what older readers see): ${prefs.handoff_order.join(' → ')}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ladderCommand(cmd, args) {
|
|
156
|
+
if (!cmd || cmd === 'ls' || cmd === 'show') return printLadder()
|
|
157
|
+
const prefs = readPreferences()
|
|
158
|
+
const ladder = prefs.handoff_ladder.map((r) => ({ ...r }))
|
|
159
|
+
if (cmd === 'set') {
|
|
160
|
+
const [nRaw, target] = args._
|
|
161
|
+
const n = parseInt(nRaw, 10)
|
|
162
|
+
if (!Number.isFinite(n) || n < 1) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
|
|
163
|
+
if (!target) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
|
|
164
|
+
const want = parseTarget(target, { die })
|
|
165
|
+
const rung = { ...want, when: typeof args.when === 'string' ? args.when : 'always' }
|
|
166
|
+
const at = Math.min(n, ladder.length + 1) - 1
|
|
167
|
+
ladder[at] = rung
|
|
168
|
+
try {
|
|
169
|
+
const saved = writePreferences({ handoff_ladder: ladder })
|
|
170
|
+
out(`rung ${at + 1} is ${rungLabel(saved.handoff_ladder[at])}${rung.when !== 'always' ? `, when ${rung.when}` : ''}`)
|
|
171
|
+
} catch (err) { die(2, err.message) }
|
|
172
|
+
return printLadder()
|
|
173
|
+
}
|
|
174
|
+
if (cmd === 'rm') {
|
|
175
|
+
const n = parseInt(args._[0], 10)
|
|
176
|
+
if (!Number.isFinite(n) || n < 1 || n > ladder.length) die(2, `usage: leg ladder rm <n> (1..${ladder.length})`)
|
|
177
|
+
if (ladder.length === 1) die(2, 'that is the only rung left: a ladder with no rungs has nowhere to hand off to')
|
|
178
|
+
const [gone] = ladder.splice(n - 1, 1)
|
|
179
|
+
try { writePreferences({ handoff_ladder: ladder }) } catch (err) { die(2, err.message) }
|
|
180
|
+
out(`removed rung ${n}: ${rungLabel(gone)}`)
|
|
181
|
+
return printLadder()
|
|
182
|
+
}
|
|
183
|
+
if (cmd === 'spend') {
|
|
184
|
+
const v = args._[0]
|
|
185
|
+
if (!['on', 'off'].includes(v)) die(2, 'usage: leg ladder spend on|off')
|
|
186
|
+
const saved = writePreferences({ may_spend: v === 'on' })
|
|
187
|
+
return out(saved.may_spend
|
|
188
|
+
? 'spending is ON: an unattended hand-off may take a rung that bills credits.'
|
|
189
|
+
: 'spending is OFF: an unattended hand-off skips any rung that bills credits, and says so in the ledger.')
|
|
190
|
+
}
|
|
191
|
+
die(2, `unknown ladder command "${cmd}" (ls|set <n> <agent>[/<account>[/<model>]]|rm <n>|spend on|off)`)
|
|
192
|
+
}
|
|
193
|
+
|
|
101
194
|
function fmtCard(c) {
|
|
102
195
|
const st = c.pipeline?.find((s) => s.name === c.station)
|
|
103
196
|
const leg = st?.kind === 'agent' ? ` leg ${c.leg}/${st.chain.length} (${st.chain[c.leg]?.adapter ?? '-'})` : ''
|
|
@@ -159,11 +252,35 @@ async function main() {
|
|
|
159
252
|
for (const s of list) out(`${s.session_id} [${s.status}] ${s.agent}${s.account !== 'default' ? '/' + s.account : ''} ${s.repo_name ?? s.cwd}${s.branch ? '@' + s.branch : ''} turns=${s.turns} ${s.limits ? `5h ${s.limits.five_hour?.pct ?? '-'}% 7d ${s.limits.seven_day?.pct ?? '-'}%` : ''} ${String(s.task ?? '').slice(0, 50)}`)
|
|
160
253
|
return
|
|
161
254
|
}
|
|
162
|
-
const id = args._[0] || die(2, `usage: leg sessions ${cmd} <session-id
|
|
255
|
+
const id = args._[0] || die(2, `usage: leg sessions ${cmd} <session-id>${cmd === 'handoff' ? ' [--to <agent>[/<account>]]' : ''}`)
|
|
163
256
|
const s = readSession(id) || die(3, `session not found: ${id}`)
|
|
164
257
|
if (cmd === 'show') return out(JSON.stringify({ session: s, events: readSessionEvents(id) }, null, 2))
|
|
165
258
|
if (cmd === 'events') { for (const e of readSessionEvents(id)) out(`${e.ts} ${String(e.type).padEnd(18)} ${e.summary}`); return }
|
|
166
|
-
if (cmd === 'handoff') {
|
|
259
|
+
if (cmd === 'handoff') {
|
|
260
|
+
if (!isActive(s)) die(3, `session ${id} is not active`)
|
|
261
|
+
// --to names the destination, the same choice the board's picker makes.
|
|
262
|
+
// Validated here for the same reason it is validated there: a pick that
|
|
263
|
+
// is not a destination, is not installed, or is at its wall must be
|
|
264
|
+
// refused now, not silently turn into "whatever is next".
|
|
265
|
+
if (typeof args.to === 'string') {
|
|
266
|
+
const want = parseTarget(args.to, { die })
|
|
267
|
+
const order = normalizeHandoffOrder(s.handoff_order)
|
|
268
|
+
const ladder = ladderFor(s)
|
|
269
|
+
const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: readAccounts(), order, ladder })
|
|
270
|
+
const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
|
|
271
|
+
const label = rungLabel(want)
|
|
272
|
+
if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})`)
|
|
273
|
+
if (s.installed && s.installed[want.agent] === false) die(3, `${label} is not installed on this machine`)
|
|
274
|
+
const u = readUsage(want.agent, want.account)
|
|
275
|
+
if (!isAvailable(u)) die(3, `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another, or drop --to to take the next option in the order`)
|
|
276
|
+
if (hit.model && wallActive(u.walls?.[hit.model])) die(3, `${label} is out until ${fmtReset(u.walls[hit.model].limited_until)}; pick another rung, or drop --to to take the next open one`)
|
|
277
|
+
const target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
|
|
278
|
+
requestControl(id, { handoff: true, target })
|
|
279
|
+
return out(`handoff to ${label} requested for ${id}`)
|
|
280
|
+
}
|
|
281
|
+
requestControl(id, { handoff: true })
|
|
282
|
+
return out(`handoff requested for ${id}`)
|
|
283
|
+
}
|
|
167
284
|
if (cmd === 'end') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { end: true }); return out(`end requested for ${id}`) }
|
|
168
285
|
if (cmd === 'rm') {
|
|
169
286
|
if (isActive(s)) die(3, `session ${id} is still active; end it first`)
|
|
@@ -182,9 +299,18 @@ async function main() {
|
|
|
182
299
|
}
|
|
183
300
|
removeSession(id); return out(`removed ${id}`)
|
|
184
301
|
}
|
|
185
|
-
|
|
302
|
+
// --message drives a particular wording through the real classifier, which
|
|
303
|
+
// is the only way to reach a per-model wall without waiting for one:
|
|
304
|
+
// --message "You've reached your Fable limit." walls fable and leaves the
|
|
305
|
+
// rest of the login open (src/buckets.mjs).
|
|
306
|
+
if (cmd === 'simulate-limit') return simulateLimit(s, { message: typeof args.message === 'string' ? args.message : null })
|
|
186
307
|
die(2, `unknown sessions command "${cmd}" (ls|show|events|handoff|end|rm|simulate-limit)`)
|
|
187
308
|
}
|
|
309
|
+
if (group === 'ladder') {
|
|
310
|
+
// The fallback ladder, in the terminal: the same rungs, the same live
|
|
311
|
+
// state and the same skip reasons the board's picker shows.
|
|
312
|
+
return ladderCommand(cmd, args)
|
|
313
|
+
}
|
|
188
314
|
if (group === 'resume') {
|
|
189
315
|
// The read side of the pointer. Freshness is never read out of the file:
|
|
190
316
|
// it is recomputed from git here, now, so a resume file cannot describe a
|
|
@@ -226,7 +352,9 @@ async function main() {
|
|
|
226
352
|
const showLink = (person, token, s) => {
|
|
227
353
|
out(`${person.name} is on the board (${person.role}). Their link, shown once:`)
|
|
228
354
|
out(` ${linkFor(s, token)}`)
|
|
229
|
-
out(person.role === 'owner' ? 'Open it on this machine, or any machine that can reach that address.'
|
|
355
|
+
out(person.role === 'owner' ? 'Open it on this machine, or any machine that can reach that address.'
|
|
356
|
+
: person.role === 'operator' ? 'They get the pipeline board — cards, the floor, the adapters — and their own terminals. Not this machine’s settings, not its history index, not anyone else’s terminal.'
|
|
357
|
+
: 'They see the terminals lane read-only: no prompts, no file names, no logs, no bundles. They can ask for a hand-off; you approve it on the card.')
|
|
230
358
|
}
|
|
231
359
|
if (!cmd || cmd === 'ls' || cmd === 'status') {
|
|
232
360
|
if (!share.on || !share.people.length) {
|
|
@@ -234,9 +362,12 @@ async function main() {
|
|
|
234
362
|
out('Turn it on: leg share on (the Tailscale address; --bind lan, or --bind <address>)')
|
|
235
363
|
return
|
|
236
364
|
}
|
|
237
|
-
out(`share is on:
|
|
238
|
-
for (const p of share.people) out(` ${p.name.padEnd(16)} ${p.role.padEnd(
|
|
365
|
+
out(`share is on: ${scheme(share)}://${share.bind}:${share.port} (${share.bind_kind})`)
|
|
366
|
+
for (const p of share.people) out(` ${p.name.padEnd(16)} ${p.role.padEnd(9)} added ${String(p.created_at).slice(0, 10)}${p.last_seen ? ` last seen ${String(p.last_seen).slice(0, 16).replace('T', ' ')}` : ''}`)
|
|
239
367
|
out('')
|
|
368
|
+
out(tlsConfigured(share)
|
|
369
|
+
? `TLS: certificate ${share.tls?.cert ?? '(from the environment)'}. The board on 127.0.0.1 stays plain http for this machine's own browser.`
|
|
370
|
+
: 'No TLS: keep this on Tailscale or a network you trust. Add one with leg share on --tls-cert <file> --tls-key <file> (tailscale cert <machine>.<tailnet>.ts.net issues a trusted pair).')
|
|
240
371
|
out('A token is shown once. Lost one? leg share rotate <name>. Everyone out: leg share off')
|
|
241
372
|
return
|
|
242
373
|
}
|
|
@@ -246,19 +377,26 @@ async function main() {
|
|
|
246
377
|
const ent = entitlement()
|
|
247
378
|
if (!allows(ent, 'share')) die(2, ent.ok ? `leg share is part of the Team plan (per seat); this machine has a ${ent.plan} license. ${BUY_URL}` : describeLicense(ent))
|
|
248
379
|
try {
|
|
249
|
-
const r = await turnOn({
|
|
380
|
+
const r = await turnOn({
|
|
381
|
+
bind: a.bind ?? 'tailscale', port: a.port ? parseInt(a.port, 10) : undefined, owner: a.owner,
|
|
382
|
+
tlsCert: typeof a['tls-cert'] === 'string' ? a['tls-cert'] : null,
|
|
383
|
+
tlsKey: typeof a['tls-key'] === 'string' ? a['tls-key'] : null,
|
|
384
|
+
})
|
|
250
385
|
await restartBoard()
|
|
251
|
-
out(`share is on: the board is at
|
|
386
|
+
out(`share is on: the board is at ${scheme(r.share)}://${r.share.bind}:${r.share.port} (${r.share.bind_kind})`)
|
|
252
387
|
if (r.token) showLink(r.owner, r.token, r.share)
|
|
253
|
-
out('Add someone: leg share add <name>')
|
|
254
|
-
out(
|
|
388
|
+
out('Add someone: leg share add <name> [--role operator|guest]')
|
|
389
|
+
out(tlsConfigured(r.share)
|
|
390
|
+
? `TLS is on, from ${r.share.tls?.cert ?? 'the environment'}. Renew the pair and run leg down && leg up to pick up a new one.`
|
|
391
|
+
: 'No TLS: keep this on Tailscale or a network you trust. Anyone with a link sees that your terminals exist and how much usage is left. leg share on --tls-cert <file> --tls-key <file> turns it on; tailscale cert <machine>.<tailnet>.ts.net issues a trusted pair.')
|
|
255
392
|
} catch (err) { die(2, err.message) }
|
|
256
393
|
return
|
|
257
394
|
}
|
|
258
395
|
if (cmd === 'add') {
|
|
259
|
-
const name = args._[0] || die(2,
|
|
396
|
+
const name = args._[0] || die(2, `usage: leg share add <name> [--role ${ROLES.join('|')}]`)
|
|
260
397
|
try {
|
|
261
|
-
|
|
398
|
+
if (args.role !== undefined && !ROLES.includes(String(args.role))) die(2, `bad role "${args.role}" (${ROLES.join('|')})`)
|
|
399
|
+
const r = addPerson(name, { role: typeof args.role === 'string' ? args.role : 'guest', share })
|
|
262
400
|
showLink(r.person, r.token, r.share)
|
|
263
401
|
if (!r.share.on) out('share is still off: leg share on')
|
|
264
402
|
} catch (err) { die(2, err.message) }
|
|
@@ -330,6 +468,12 @@ async function main() {
|
|
|
330
468
|
const code = await harnessCommand(cmd, args, { out, die })
|
|
331
469
|
process.exit(code)
|
|
332
470
|
}
|
|
471
|
+
if (group === 'adapter' || group === 'adapters') {
|
|
472
|
+
// Custom adapters: any CLI as a card agent, from a JSON spec on disk
|
|
473
|
+
// (src/adapters/custom.mjs). The built-ins need none of this.
|
|
474
|
+
const code = await adapterCommand(cmd, args, { out, die })
|
|
475
|
+
process.exit(code)
|
|
476
|
+
}
|
|
333
477
|
if (group === 'history' || group === 'worktrees') {
|
|
334
478
|
// Every conversation on this machine, Leg's own and the ones the agents'
|
|
335
479
|
// stores hold: a read-only index (src/history/index.mjs). `continue`
|
|
@@ -470,13 +614,19 @@ async function main() {
|
|
|
470
614
|
out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
|
|
471
615
|
return
|
|
472
616
|
}
|
|
473
|
-
if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
|
|
617
|
+
if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|ladder|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
|
|
474
618
|
out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
|
|
475
619
|
claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
|
|
476
620
|
the board opens once, the session shows as a card, usage is tracked, a limit hands off
|
|
477
621
|
a second live session in one checkout gets its own worktree (--no-worktree to share)
|
|
478
622
|
auto-approve mode (--no-auto-approve to opt out)
|
|
623
|
+
--resume-card <id> takes over a background card: this terminal opens in that card's
|
|
624
|
+
worktree, primed from its bundle (Take over on the board pauses it and prints this)
|
|
479
625
|
sessions ls|show|events|handoff|end|rm|simulate-limit <id>
|
|
626
|
+
handoff --to <agent>[/<account>[/<model>]] names the rung; simulate-limit --message "<text>"
|
|
627
|
+
ladder [ls] the fallback ladder: every rung, what it costs, and what it is doing right now
|
|
628
|
+
ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]
|
|
629
|
+
ladder rm <n> | ladder spend on|off
|
|
480
630
|
history [ls] [--provider p] [--repo r] [--search q] [--json]
|
|
481
631
|
every conversation on this machine: Leg's own, and the ones Claude Code, Codex,
|
|
482
632
|
Grok, Antigravity and Copilot keep in their own stores (read only, nothing moved)
|
package/docs/DECISIONS.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Durable product and design decisions that the code does not explain on its own. One entry per decision, newest first.
|
|
4
4
|
|
|
5
|
+
## 2026-09-17: a hand-off destination is a rung of (agent, account, model), and usage is a property of a row
|
|
6
|
+
|
|
7
|
+
- **What.** `preferences.json` keeps `handoff_order` and derives it from a new `handoff_ladder` of rungs, each `{agent, account, model, when, cost}`. `chooseNext` walks the ladder from rung 1 every time. `spawnSpec` pushes the rung's model flag, so the interactive path finally carries `--model` the way every headless adapter already did. A claude downshift with a known session id starts `claude --resume <id> --model <alias>` and skips the bundle; every other rung is primed from the bundle. Usage stops being a region on the board: the binding bucket is one strip token at the top and one phrase on each row, and the four login panels move intact behind a **Capacity and models** disclosure. Live cards become rows in a **Background** panel directly under Terminals; finished cards collapse into one ledger line.
|
|
8
|
+
- **Why a model is part of the destination.** Anthropic's session and weekly limits are shared across every model, and the Opus and Sonnet limits are family scoped: switching outside that family keeps you working (costs doc, line 132). So the cheapest hand-off available is usually not another CLI at all, it is another model on the subscription you already pay for, and until now Leg could not express it. Making the model part of the destination is what lets a Fable wall be a wall on Fable rather than on claude.
|
|
9
|
+
- **Why downshift by `--resume` and upshift by bundle.** A same-login model change can hold the conversation, and holding it is worth more than anything the bundle carries, because the bundle is a summary and the transcript is the thing itself. `--resume` and `--model` are each verified in `claude --help`; composing them was probed and recorded at `fixtures/live/claude/resume-model-probe.json`, which is also where the cost of the other direction is written down: the resumed context is re-read at the new model's rates, so an upshift pays for that re-read at the higher price. Every other rung crosses a CLI boundary where no shared conversation exists, so it takes the bundle, which is the mechanism Leg already had. `codex resume <id> -m <model>` is the same shape and is not probed, so codex rungs take the bundle and do not claim to keep anything.
|
|
10
|
+
- **Why `may_spend` is off by default.** An interactive Fable-on-credits request shows a consent prompt; with nobody at the terminal that prompt is held five minutes and the turn then ends; in `-p` mode, which is how every card runs, Claude Code never shows it and bills without asking (model-config doc, lines 68, 72 and 77). A stalled turn and a silent charge are both worse than a skipped rung, so an automatic hand-off refuses a `credits` or `metered` rung until a human says otherwise, and writes the reason to the ledger. A hand-off a human presses is not automatic and is not gated.
|
|
11
|
+
- **Why usage became a strip and a row property.** On the seeded board the first terminal row sat at 1382px: about a thousand pixels of gauges before the thing the board is for. The gauges are not deleted, they move behind a disclosure, because every notch, degrade path and `aria-valuetext` in them is earned. What replaces them at the top is smaller and says more: the *binding* bucket per login rather than whichever window happened to be stored. The same figure on a row is per model and therefore real; the shared-login caveat is said once, at the region head, where adding three rows' figures together is stopped.
|
|
12
|
+
- **Why cards live under Terminals.** A card is a terminal you are not sitting at: same register, same one sentence, same ladder, same bundle. Putting live cards in a drawer while live terminals are rows made the same object two shapes and taught nobody anything. Liveness, not kind, decides the surface, so ten finished cards are one ledger line and one running card is a row. The honest differences are printed rather than hidden: a `-p` leg is mute until it exits, so its sentence says so.
|
|
13
|
+
- **What this rules out.** A number for agy (it publishes none), dollars for a subscription session (no transcript carries a cost field), and per-terminal attribution of a shared login (nothing publishes it).
|
|
14
|
+
|
|
5
15
|
## 2026-09-16: the portable harness is an opt-in subsystem over a vendored, hash-pinned engine
|
|
6
16
|
|
|
7
17
|
- **What.** `leg harness` carries the source agent's working environment (rules, identity, hooks, skills, subagents, commands, MCP servers, permissions) to the agent a hand-off lands on. The capture, neutral bundle and apply engine is the Agnostic AI port engine (MIT), embedded byte for byte under `src/harness/vendor/agnostic-ai/` and driven through its library entry; Leg owns consent, policy, the client registry, state, the fingerprint, the trail and the hand-off decision (`src/harness/*.mjs`).
|
package/docs/DEMO.md
CHANGED
|
@@ -79,16 +79,19 @@ It prints a preflight table, then
|
|
|
79
79
|
address in your browser. The board polls every three seconds, so nothing below
|
|
80
80
|
needs a reload.
|
|
81
81
|
|
|
82
|
-
The page is one column, read top to bottom: the
|
|
83
|
-
login,
|
|
84
|
-
**
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
The page is one column, read top to bottom: the verdict, the capacity strip
|
|
83
|
+
(one token per login, with the login panels behind its `Capacity and models`
|
|
84
|
+
disclosure), **Terminals**, **Background** (live cards only, hidden while none
|
|
85
|
+
are running), the ledger (finished terminals, landed commits, conversations
|
|
86
|
+
and finished cards, as four counts), and **Settings**. This demo happens
|
|
87
|
+
entirely in **Background**. On a fresh board home the capacity strip reads
|
|
88
|
+
`no reading` for every login, because no agent has reported usage into this
|
|
89
|
+
home yet.
|
|
87
90
|
|
|
88
91
|
### 3. Queue the card
|
|
89
92
|
|
|
90
|
-
Click **New card
|
|
91
|
-
background card**.
|
|
93
|
+
Click **New card**, in the ledger's finished-cards cell below Terminals and
|
|
94
|
+
Background. The dialog opens on **New background card**.
|
|
92
95
|
|
|
93
96
|
1. **Repo path**: `C:\baton-demo\toy-demo`.
|
|
94
97
|
2. **Task**: `Add a file greeting.txt containing 'hello from baton'`.
|
|
@@ -112,8 +115,9 @@ The recorded chain is `fake-claude>fake-codex`.
|
|
|
112
115
|
|
|
113
116
|
### 4. Leg 1, on fake-claude
|
|
114
117
|
|
|
115
|
-
The card appears as a row in **Background
|
|
116
|
-
title reads `1 running`, and the row reads left to
|
|
118
|
+
The card appears as a row in **Background**, directly under Terminals. The
|
|
119
|
+
line beside the region title reads `1 running`, and the row reads left to
|
|
120
|
+
right:
|
|
117
121
|
|
|
118
122
|
- `fake-claude`, `build`, `running`
|
|
119
123
|
- the title, and under it the one sentence
|
|
@@ -173,11 +177,13 @@ the bundle Leg wrote. The status word is `running` again, the run is `run=2`,
|
|
|
173
177
|
and the chain reads `fake-claude · handed off · fake-codex · running`. This is
|
|
174
178
|
`demo-4-codex-running.png`.
|
|
175
179
|
|
|
176
|
-
Twenty seconds later the station finishes. The
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
Twenty seconds later the station finishes. The card leaves **Background**,
|
|
181
|
+
which goes back to empty and hides itself, and the ledger's finished-cards
|
|
182
|
+
cell reads `1 finished card, 1 done, last <time>`. Clicking **View** there
|
|
183
|
+
opens the one line the drawer keeps for it: `done after 2 runs, card done:
|
|
184
|
+
all 1 station(s) complete`. A finished card carries no buttons; Rerun and
|
|
185
|
+
Remove apply to a live row, and this one no longer is one. This is
|
|
186
|
+
`demo-5-done.png`.
|
|
181
187
|
|
|
182
188
|
The worktree named in **Where** now holds the file the fake agent wrote,
|
|
183
189
|
`hello-fake.txt`, and `.leg/DONE`.
|
package/docs/DEVIATIONS.md
CHANGED
|
@@ -180,3 +180,4 @@ tests. Rows for the shape changes a later reader would otherwise wonder about.
|
|
|
180
180
|
| 2026-09-14 | src/resume.mjs `refreshPointers()` | the board was to skip any checkout with a live terminal, so a live hand-off is never clobbered | it skips only when the pointer's OWN stamped session is live | the case that started this had a hand-written three-day-old `RESUME.md` and a different terminal live in the same checkout; the first rule would have left it exactly as it was |
|
|
181
181
|
| 2026-09-14 | src/resume.mjs idle pointer | a checkpoint bundle was described as "the last hand-off", and the per-session file was named unconditionally | `lineage.to` decides hand-off vs checkpoint, and the file is named only when it exists on disk | seen in the live pointer for this repo: it claimed a hand-off that never happened and pointed at a `RESUME-<id>.md` that was never written, which is the same class of lie the module exists to stop |
|
|
182
182
|
| 2026-09-14 | src/board/sessions.js `renderDrawer()` | the whole panel is rebuilt every 3 s poll so relative timestamps stay honest | still rebuilt, but every scrollable box carries a stable `data-scroll-key` and its offset is carried across | the rebuild reset the task box, each message and the timeline to the top every three seconds, so a long message could not be read at all |
|
|
183
|
+
| 2026-09-17 | src/board/sessions.js `verdictLines()` | spec A.5 lists "a model bucket came back and a terminal is still downshifted" below the two branches that print a standing percentage | the came-back branch is tested before them, right after the model-walled branch | as written it can never fire: "one login carries every live terminal" and "several logins carry work" both return for any login that has a figure at all, so the only state change worth telling a downshifted reader about was unreachable |
|
package/docs/ERRORS.md
CHANGED
|
@@ -3,6 +3,59 @@
|
|
|
3
3
|
What broke, why, and what fixed it. One entry per failure, newest first. A first
|
|
4
4
|
occurrence has to be written down or a repeat is never countable.
|
|
5
5
|
|
|
6
|
+
## 2026-09-17: a new field on the session view leaked the owner's reset times to a guest, through the guest's own terminal
|
|
7
|
+
|
|
8
|
+
**Fixed in `src/server.mjs` (`sessionsView` decides `guest` before the map and
|
|
9
|
+
blanks `handoff_targets[].resets_at` for one). Caught by
|
|
10
|
+
`test/share-security.test.mjs`, which was already asserting it.**
|
|
11
|
+
|
|
12
|
+
The hand-off picker needed each destination's availability, so `handoff_targets`
|
|
13
|
+
went onto every session in `sessionsView` carrying `resets_at`. The obvious
|
|
14
|
+
mental model was "a guest gets `redactSession`, which lists its fields
|
|
15
|
+
explicitly, so a new field is invisible to them". That is only true of someone
|
|
16
|
+
else's terminal. `mine(s)` is true for a guest's **own** terminal, and that path
|
|
17
|
+
spreads the whole object. So a guest's own card carried the exact reset
|
|
18
|
+
timestamp of every account on the machine, including the owner's, which is
|
|
19
|
+
precisely the usage data the share design keeps off a guest's board.
|
|
20
|
+
|
|
21
|
+
The lesson is about where redaction lives, not about this field. There are two
|
|
22
|
+
paths out of `sessionsView`: `redactSession` (allow-list, safe by default) and
|
|
23
|
+
the `mine(s)` spread (deny-list, unsafe by default). **Any field added to a
|
|
24
|
+
session object is visible to whoever owns that session, and a guest owns one.**
|
|
25
|
+
A field that carries machine-level data has to be blanked where it is built, not
|
|
26
|
+
left to a redactor that never sees it.
|
|
27
|
+
|
|
28
|
+
What made this cheap: the security suite already asserted the whole guest
|
|
29
|
+
response text contains no reset time, so the leak failed a test in the same run
|
|
30
|
+
that introduced it. The test was written against the property ("a guest board
|
|
31
|
+
carries no reset time"), not against the fields that existed when it was
|
|
32
|
+
written, which is why it still caught a field invented months later.
|
|
33
|
+
|
|
34
|
+
Two smaller ones from the same change, both worth the line:
|
|
35
|
+
|
|
36
|
+
- Registering `grok` broke three tests that asserted `unknown adapter "grok"`.
|
|
37
|
+
A test that encodes "not supported yet" as an assertion becomes a tripwire on
|
|
38
|
+
the day support lands. Assert the refusal with a name nothing will ever
|
|
39
|
+
provide (`no-such-agent`), so the test outlives the gap it was describing.
|
|
40
|
+
- `names()` started reading `$LEG_HOME/adapters` from disk, and `/api/health`
|
|
41
|
+
calls it once for the list and once per adapter. That put a readdir, a read
|
|
42
|
+
and a JSON parse per spec on the same event loop the terminals lane is pushed
|
|
43
|
+
from, and `/api/health` went over its 1 s budget in
|
|
44
|
+
`test/board-responsiveness.test.mjs`. Cached against the directory's entry
|
|
45
|
+
list with a one-second floor. The board's hot path is `/api/health` plus the
|
|
46
|
+
sessions view; anything new they call has to be counted, not assumed cheap.
|
|
47
|
+
(This is the same event-loop failure as the entry below, from the other end.)
|
|
48
|
+
- `test/board-responsiveness.test.mjs` failed the ship twice at 1055 ms and
|
|
49
|
+
1140 ms against a hard `< 1000 ms`, and passed three times out of three when
|
|
50
|
+
run alone. An absolute millisecond budget on a four-way-concurrent runner
|
|
51
|
+
measures the machine, not the code. It now takes an idle baseline in the same
|
|
52
|
+
process and asserts the busy request is not 20x it, with a 3 s ceiling for the
|
|
53
|
+
symptom the test is named for. **It was only trusted after being made to
|
|
54
|
+
fail**: putting the original shape back (no floor, no fingerprint, a 1.2 s
|
|
55
|
+
blocking view on every watcher event) made it report 4,810 ms and fail both
|
|
56
|
+
assertions, which is the "four to fourteen seconds" the entry below describes.
|
|
57
|
+
A perf test that has never been watched failing is a number, not a guard.
|
|
58
|
+
|
|
6
59
|
## 2026-09-17: one running terminal saturated the board's event loop, and four separate symptoms came out of it
|
|
7
60
|
|
|
8
61
|
**Fixed in `src/server.mjs` (watcher filter, stat fingerprint, push floor, cached
|
|
@@ -529,3 +582,44 @@ it), and check whether a board was listening on 4747 at the time.
|
|
|
529
582
|
in the hand-off as in flight, or the next agent ships without it. And a
|
|
530
583
|
cache keyed on a directory's mtime sees files added and removed, never a
|
|
531
584
|
file rewritten in place.
|
|
585
|
+
|
|
586
|
+
## The 0.12.0 redesign review confirmed 51 findings before the fix pass, 23 of them high (2026-09-18)
|
|
587
|
+
|
|
588
|
+
- **What happened.** The eight-step redesign (per-model buckets, the ladder,
|
|
589
|
+
the capacity strip, cards reborn) was reviewed before commit by six finders
|
|
590
|
+
and three refuters per finding: 174 agents, 51 findings confirmed, 23 high.
|
|
591
|
+
The highs were all in code that had passed its own tests: `binding()`
|
|
592
|
+
short-circuited on the first active bucket instead of the one that stops
|
|
593
|
+
the requested model, `summarize()` parsed the ledger once per card per
|
|
594
|
+
refresh, the take-over route handed out a command for a card with no
|
|
595
|
+
checkout, `cardWorkRoot()` fell back to the main checkout, and the guest
|
|
596
|
+
payload leaked buckets, walls and hand-off reasons through the new fields.
|
|
597
|
+
- **Fix.** Three parallel fix passes, one owner per file group, every finding
|
|
598
|
+
closed with a regression test in the same change; the suite went from 783
|
|
599
|
+
to 830 tests. The lockfile's root `repository` field, lost when Playwright
|
|
600
|
+
was installed, was restored so `scripts/npm-publish-gate.mjs` passes.
|
|
601
|
+
- **The lesson that generalises.** A feature's own tests prove the feature's
|
|
602
|
+
own model of itself. The defects a review finds sit where two new pieces
|
|
603
|
+
meet (a new field and an old redaction list, a new route and an old helper),
|
|
604
|
+
and the review has to run on the uncommitted tree, before the commit exists
|
|
605
|
+
to be pushed by someone else. A `npm i` that regenerates the lockfile is a
|
|
606
|
+
release change and gets the publish gate run in the same turn.
|
|
607
|
+
|
|
608
|
+
## A seeded board's End-as-card button wrote into a real repo (2026-09-17)
|
|
609
|
+
|
|
610
|
+
- **What happened.** Driving the new "End, and keep going as a card" verb in
|
|
611
|
+
a browser against `scripts/seed-wes-board.mjs`'s board, the click on the
|
|
612
|
+
recruiting-tool row hit `POST /api/sessions/:id/end-as-card`, which did
|
|
613
|
+
exactly its job: it wrote a hand-off bundle under
|
|
614
|
+
`C:\Projects\recruiting-tool\.context-handoffs\` and cut a worktree and a
|
|
615
|
+
`leg/card-...` branch there. The seed's rows named real repositories on this
|
|
616
|
+
machine because long real paths were what the layout had to be measured
|
|
617
|
+
against. Nothing ran in the worktree (the server was stopped within a
|
|
618
|
+
minute); the worktree was deregistered and the branch deleted, and the
|
|
619
|
+
directory and bundle were left for a hand delete.
|
|
620
|
+
- **Fix.** The seed's repo paths now live under `C:\Projects-seed\...`, which
|
|
621
|
+
does not exist, so every git-backed action on a seeded row answers 409
|
|
622
|
+
instead of touching a checkout. The row still prints the same length.
|
|
623
|
+
- **The lesson that generalises.** A seeded board is safe to look at and not
|
|
624
|
+
safe to click: any row that names a path that exists is a live control on
|
|
625
|
+
that path. Seed paths must be realistic in shape and impossible in fact.
|
package/docs/ROADMAP-v2.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Roadmap v2: the software factory
|
|
2
2
|
|
|
3
|
+
**0.12.0 (2026-09-17) added the model dimension and made cards a first-class
|
|
4
|
+
surface.** A hand-off destination is now a rung of (agent, login, model), not
|
|
5
|
+
just an agent, so a Fable wall moves the terminal to opus on the same
|
|
6
|
+
subscription before it moves to another CLI, and for claude that move keeps
|
|
7
|
+
the conversation (`claude --resume <id> --model <alias>`). The **Hand off now
|
|
8
|
+
to** picker lists those rungs with their models, whether each keeps the
|
|
9
|
+
conversation, and why a greyed one buys nothing; the same list is a ladder
|
|
10
|
+
editor in Settings, in a terminal's expansion and behind `leg ladder`. Live
|
|
11
|
+
cards left the drawer and became rows in a **Background** panel directly under
|
|
12
|
+
Terminals, with a one-line entry and the thirteen-field dialog demoted to
|
|
13
|
+
**More settings**; `End, and keep going as a card` and `Take over` are the two
|
|
14
|
+
doors between a terminal and a card. The board top became a capacity strip
|
|
15
|
+
over a **Capacity and models** drawer, a row says `waiting on you` with the
|
|
16
|
+
question when Claude Code's `Notification` hook fires, and a burn-rate figure
|
|
17
|
+
prints only with its sample count. That closes the model dimension, the
|
|
18
|
+
picker's second half, and the cards item below.
|
|
19
|
+
|
|
20
|
+
**0.11.0 (2026-09-17) opened the chain and finished the share story.** grok is
|
|
21
|
+
a registered card adapter; any other CLI becomes one from a JSON spec
|
|
22
|
+
(`leg adapter add`, no code); **Hand off now** can name its destination
|
|
23
|
+
instead of taking the next in the order; `leg share` grew TLS from a
|
|
24
|
+
certificate pair you supply, an `operator` role between owner and guest, and
|
|
25
|
+
an audit trail of who did what across every terminal and every card. That
|
|
26
|
+
closes items 1 and 4 below, and the "hand off now to \<adapter>" picker.
|
|
27
|
+
|
|
3
28
|
**0.3.0 (2026-09-11) shipped item 1 and the first half of continuous landing.**
|
|
4
29
|
A second live session in one checkout gets its own worktree and branch, and
|
|
5
30
|
**Land** on its card sends that branch through the merge queue (rebase, tests,
|
|
@@ -33,23 +58,56 @@ event names its actor, a board that reads only the ledger, and an auth/bind seam
|
|
|
33
58
|
|
|
34
59
|
## Next
|
|
35
60
|
|
|
36
|
-
1.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
1. ~~**Multi-human network access**~~ — done. 0.3.0 shipped `leg share`; 0.11.0
|
|
62
|
+
shipped the three that were left: TLS (`leg share on --tls-cert/--tls-key`,
|
|
63
|
+
or `LEG_TLS_CERT`/`LEG_TLS_KEY`, from a pair you supply — Leg issues none),
|
|
64
|
+
the `operator` role between owner and guest, and the audit trail
|
|
65
|
+
(`/api/audit`, Settings → Audit trail).
|
|
41
66
|
2. **Review station with human reviewers**: a `human` station kind that shows
|
|
42
67
|
the diff, the bundle and the test tail, with Approve / Request changes /
|
|
43
68
|
Reassign as buttons; reviewer identity from the token.
|
|
44
69
|
3. **`pr` land mode live**: `gh pr create` argv is built and stub-tested today;
|
|
45
70
|
run it for real behind an explicit per-card opt-in and a remote allowlist.
|
|
46
|
-
4.
|
|
47
|
-
|
|
48
|
-
|
|
71
|
+
4. ~~**More adapters**~~ — done. grok is registered (2026-09-17: flags read
|
|
72
|
+
from `grok --help` on 1.0.34, envelope read from the shipped binary, probe
|
|
73
|
+
reached the account and returned a real 402 wall that classified `limit`;
|
|
74
|
+
the success path is still unprobed for want of balance). Anything else,
|
|
75
|
+
muse included, is a custom adapter: a JSON spec in
|
|
76
|
+
`$LEG_HOME/adapters/<name>.json`, `leg adapter add`, no code
|
|
77
|
+
([adapters.md](adapters.md#custom-adapters)).
|
|
49
78
|
5. **OpenClaw Workboard mirror** once the bundled plugin is allowed
|
|
50
79
|
(`plugins.allow`): the verb table in `src/sync/workboard.mjs` is the only
|
|
51
80
|
thing to check against `openclaw workboard --help`.
|
|
52
81
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
82
|
+
6. **Probe `codex resume <id> -m <model>`.** The `resume` subcommand and the
|
|
83
|
+
`-m` flag are each verified from `codex --help`; composing them is not, so
|
|
84
|
+
a codex rung ships primed from the bundle and only the claude rungs claim
|
|
85
|
+
to keep the conversation. One real run on a live codex session settles it.
|
|
86
|
+
The same probe shape applies to naming the terminal tab: whether codex, agy
|
|
87
|
+
and grok leave an OSC 2 title alone once the child starts drawing is
|
|
88
|
+
assumed, not known, which is why the tab title is the browser's and not the
|
|
89
|
+
terminal's.
|
|
90
|
+
|
|
91
|
+
Also on the list: per-station prompt templates editable from the board, lease
|
|
92
|
+
suggestions from the diff of the previous leg, and a floor view that shows
|
|
93
|
+
lease contention over time. (The "hand off now to \<adapter>" picker landed in
|
|
94
|
+
0.11.0: Details → **Hand off now to**, or `leg sessions handoff <id> --to`.
|
|
95
|
+
0.12.0 put models on its rows.)
|
|
96
|
+
|
|
97
|
+
## Ruled out, with the reason
|
|
98
|
+
|
|
99
|
+
- **Phone or push notifications.** They need a relay, which means a server
|
|
100
|
+
that is not this machine holding a token that can reach you. Leg is
|
|
101
|
+
local-first, so the notice surfaces are the ones the machine already owns:
|
|
102
|
+
the browser tab badge (always on, no permission), a browser toast on the
|
|
103
|
+
board (off by default, gated on a secure context), and a terminal toast
|
|
104
|
+
through Claude Code's `Notification` hook (on by default).
|
|
105
|
+
- **Percentages for agy.** Antigravity CLI publishes no usage figure at all,
|
|
106
|
+
so there is nothing to read. agy's token says `no figure`, and its terminals
|
|
107
|
+
are shown by elapsed time instead. A number here could only be invented.
|
|
108
|
+
- **Dollars for subscription sessions.** No transcript Leg reads carries a
|
|
109
|
+
cost field. codex's `credits.balance` can be printed as a measured fact with
|
|
110
|
+
the word `credits`, and is never summed with an estimate.
|
|
111
|
+
- **Per-terminal attribution of a shared login.** Nothing publishes which
|
|
112
|
+
terminal spent which part of a window. The board says it once, at the
|
|
113
|
+
Terminals head (`4 share the claude login`), rather than guessing per row.
|