@ucsandman/legcli 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +51 -13
- package/bin/leg.mjs +104 -62
- package/docs/DECISIONS.md +8 -0
- package/docs/DEVIATIONS.md +34 -0
- package/docs/ERRORS.md +1 -1
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/adapters.md +5 -2
- package/docs/cli-contracts.md +14 -6
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/faq.md +4 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/src/accounts.mjs +67 -7
- package/src/attach.mjs +184 -48
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/bundle.mjs +33 -11
- package/src/digest.mjs +197 -0
- package/src/git.mjs +97 -0
- package/src/handoff.mjs +20 -2
- package/src/history/common.mjs +3 -1
- package/src/history/providers/claude.mjs +5 -1
- package/src/history/worktrees.mjs +105 -25
- package/src/hook.mjs +5 -4
- package/src/launcher.mjs +1 -1
- package/src/limits.mjs +19 -1
- package/src/scheduler-status.mjs +22 -0
- package/src/scheduler.mjs +3 -14
- package/src/server.mjs +171 -38
- package/src/session-detail.mjs +25 -2
- package/src/sessions.mjs +18 -3
- package/src/synthesis.mjs +23 -4
- package/src/taps/claude-usage.mjs +5 -4
- package/src/taps/claude.mjs +34 -6
- package/src/usage.mjs +27 -1
package/bin/leg.mjs
CHANGED
|
@@ -5,32 +5,15 @@
|
|
|
5
5
|
// leg card ls [--json] | show <id> | run <id> | rm <id> [--delete-branch] | events <id>
|
|
6
6
|
// leg card <pause|resume|kill|approve|handoff-now|rerun> <id> | reassign <id> --adapter a [--mode m]
|
|
7
7
|
// leg scheduler start [--ticks N] [--interval-ms N] | status | stop
|
|
8
|
+
//
|
|
9
|
+
// Startup cost matters here: every command, `--version` included, paid for
|
|
10
|
+
// loading the whole module graph (orchestrator, scheduler, board, attach…)
|
|
11
|
+
// before main() even ran. Each command group below imports only what it
|
|
12
|
+
// needs, inside its own branch, so `leg --version` and friends stay cheap.
|
|
8
13
|
import { rmSync, appendFileSync, readFileSync } from 'node:fs'
|
|
9
14
|
import { join, dirname, resolve } from 'node:path'
|
|
10
15
|
import { fileURLToPath } from 'node:url'
|
|
11
16
|
import { spawnSync } from 'node:child_process'
|
|
12
|
-
import { PRESET_NAMES } from '../src/presets.mjs'
|
|
13
|
-
import { readCard, listCards, readEvents, readRuns, cardDir } from '../src/store.mjs'
|
|
14
|
-
import { runCard, humanAction } from '../src/orchestrator.mjs'
|
|
15
|
-
import { createCard, CardInputError } from '../src/cards.mjs'
|
|
16
|
-
import { remove as removeWorktree } from '../src/worktree.mjs'
|
|
17
|
-
import { pruneSessionWorktree } from '../src/land.mjs'
|
|
18
|
-
import { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } from '../src/scheduler.mjs'
|
|
19
|
-
import { availableActions } from '../src/chain.mjs'
|
|
20
|
-
import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
|
|
21
|
-
import { attach, ensureBoard } from '../src/attach.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'
|
|
25
|
-
import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.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'
|
|
28
|
-
import { home } from '../src/store.mjs'
|
|
29
|
-
import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
|
|
30
|
-
import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
|
|
31
|
-
import { harnessCommand } from '../src/harness/cli.mjs'
|
|
32
|
-
import { adapterCommand } from '../src/adapters/cli.mjs'
|
|
33
|
-
import { historyCommand, worktreesCommand } from '../src/history/cli.mjs'
|
|
34
17
|
|
|
35
18
|
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
|
36
19
|
// one source of truth for the version, so the help text cannot drift from the package
|
|
@@ -52,6 +35,7 @@ function parseArgs(argv) {
|
|
|
52
35
|
}
|
|
53
36
|
|
|
54
37
|
async function cardAdd(args) {
|
|
38
|
+
const { createCard, CardInputError } = await import('../src/cards.mjs')
|
|
55
39
|
try {
|
|
56
40
|
const card = await createCard({
|
|
57
41
|
repo: args.repo, task: args.task, chain: args.chain, pipeline: args.pipeline,
|
|
@@ -74,7 +58,10 @@ async function cardAdd(args) {
|
|
|
74
58
|
// start the next option in the same terminal. The payload is marked
|
|
75
59
|
// simulated: it is never kept as live evidence, and the wall it records
|
|
76
60
|
// clears after two minutes. codex has no Leg-owned input, so it is refused.
|
|
77
|
-
|
|
61
|
+
// `sessionsApi` is the already-imported src/sessions.mjs namespace: the
|
|
62
|
+
// `sessions` command group loads it once and passes it through.
|
|
63
|
+
function simulateLimit(sessionsApi, s, { message = null } = {}) {
|
|
64
|
+
const { isActive, sessionDir, appendEvent, readSession } = sessionsApi
|
|
78
65
|
if (!isActive(s)) die(3, `session ${s.session_id} is not active`)
|
|
79
66
|
if (['limit', 'handing_off'].includes(s.status)) die(3, `session ${s.session_id} is already ${s.status}`)
|
|
80
67
|
if (s.agent === 'claude') {
|
|
@@ -105,13 +92,16 @@ function simulateLimit(s, { message = null } = {}) {
|
|
|
105
92
|
// unambiguous. Two are not, so the second is read as an account when that
|
|
106
93
|
// account exists and as a model when the agent has one by that name; a word
|
|
107
94
|
// that is neither is refused by name rather than guessed at.
|
|
108
|
-
|
|
95
|
+
// Async so the agent/account lookups (buckets.mjs, accounts.mjs) load only
|
|
96
|
+
// when a two-part target is actually given, not on every CLI invocation.
|
|
97
|
+
export async function parseTarget(value, { die: fail = (code, msg) => { throw new Error(msg) } } = {}) {
|
|
109
98
|
const parts = String(value).split('/').filter(Boolean)
|
|
110
99
|
const agent = parts[0]
|
|
111
100
|
if (!agent) fail(2, 'usage: --to <agent>[/<account>[/<model>]]')
|
|
112
|
-
const models = MODEL_ALIASES[agent] ?? []
|
|
113
101
|
if (parts.length >= 3) return { agent, account: parts[1], model: parts[2].toLowerCase() }
|
|
114
102
|
if (parts.length === 2) {
|
|
103
|
+
const [{ MODEL_ALIASES }, { readAccounts }] = await Promise.all([import('../src/buckets.mjs'), import('../src/accounts.mjs')])
|
|
104
|
+
const models = MODEL_ALIASES[agent] ?? []
|
|
115
105
|
const second = parts[1]
|
|
116
106
|
const accounts = readAccounts()[agent] ?? ['default']
|
|
117
107
|
if (accounts.includes(second)) return { agent, account: second, model: null }
|
|
@@ -124,25 +114,28 @@ export function parseTarget(value, { die: fail = (code, msg) => { throw new Erro
|
|
|
124
114
|
// What a rung is doing right now, in the words the board uses: the wall and its
|
|
125
115
|
// clock, else the percentage of the bucket that binds it, else "no figure".
|
|
126
116
|
// Never a guess: an agent that publishes no number says so.
|
|
127
|
-
|
|
128
|
-
|
|
117
|
+
// `usage` is the already-imported src/usage.mjs namespace.
|
|
118
|
+
function rungState(rung, usage) {
|
|
119
|
+
const u = usage.readUsage(rung.agent, rung.account)
|
|
129
120
|
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)
|
|
121
|
+
if (wall && usage.wallActive(wall)) return `${rung.model} out until ${usage.fmtReset(wall.limited_until)}`
|
|
122
|
+
if (!usage.isAvailable(u)) return `at its limit until ${usage.fmtReset(u.limited_until)}`
|
|
123
|
+
const b = usage.binding(u, rung.model ?? null)
|
|
133
124
|
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
125
|
return 'no figure'
|
|
135
126
|
}
|
|
136
127
|
|
|
137
|
-
|
|
138
|
-
|
|
128
|
+
// `prefsApi`/`usage` are the already-imported src/preferences.mjs and
|
|
129
|
+
// src/usage.mjs namespaces (the `ladder` command group loads them once).
|
|
130
|
+
function printLadder(prefsApi, usage) {
|
|
131
|
+
const prefs = prefsApi.readPreferences()
|
|
139
132
|
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 })
|
|
133
|
+
const rows = usage.evaluateLadder({ from: null, list: ladder, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: true, climbBack: prefs.climb_back, ladder })
|
|
141
134
|
out('The ladder a terminal falls down when its login stops. Rung 1 first, every time.')
|
|
142
135
|
ladder.forEach((rung, i) => {
|
|
143
136
|
const r = rows[i]
|
|
144
137
|
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}`)
|
|
138
|
+
out(` ${String(i + 1).padEnd(2)} ${usage.rungLabel(rung).padEnd(20)} ${rungState(rung, usage).padEnd(34)} ${r.ok ? 'ready' : r.reason}${when}`)
|
|
146
139
|
})
|
|
147
140
|
out('')
|
|
148
141
|
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`)
|
|
@@ -152,38 +145,39 @@ function printLadder() {
|
|
|
152
145
|
out(`order (what older readers see): ${prefs.handoff_order.join(' → ')}`)
|
|
153
146
|
}
|
|
154
147
|
|
|
155
|
-
function ladderCommand(cmd, args) {
|
|
156
|
-
|
|
157
|
-
|
|
148
|
+
async function ladderCommand(cmd, args) {
|
|
149
|
+
const [prefsApi, usage] = await Promise.all([import('../src/preferences.mjs'), import('../src/usage.mjs')])
|
|
150
|
+
if (!cmd || cmd === 'ls' || cmd === 'show') return printLadder(prefsApi, usage)
|
|
151
|
+
const prefs = prefsApi.readPreferences()
|
|
158
152
|
const ladder = prefs.handoff_ladder.map((r) => ({ ...r }))
|
|
159
153
|
if (cmd === 'set') {
|
|
160
154
|
const [nRaw, target] = args._
|
|
161
155
|
const n = parseInt(nRaw, 10)
|
|
162
156
|
if (!Number.isFinite(n) || n < 1) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
|
|
163
157
|
if (!target) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
|
|
164
|
-
const want = parseTarget(target, { die })
|
|
158
|
+
const want = await parseTarget(target, { die })
|
|
165
159
|
const rung = { ...want, when: typeof args.when === 'string' ? args.when : 'always' }
|
|
166
160
|
const at = Math.min(n, ladder.length + 1) - 1
|
|
167
161
|
ladder[at] = rung
|
|
168
162
|
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}` : ''}`)
|
|
163
|
+
const saved = prefsApi.writePreferences({ handoff_ladder: ladder })
|
|
164
|
+
out(`rung ${at + 1} is ${usage.rungLabel(saved.handoff_ladder[at])}${rung.when !== 'always' ? `, when ${rung.when}` : ''}`)
|
|
171
165
|
} catch (err) { die(2, err.message) }
|
|
172
|
-
return printLadder()
|
|
166
|
+
return printLadder(prefsApi, usage)
|
|
173
167
|
}
|
|
174
168
|
if (cmd === 'rm') {
|
|
175
169
|
const n = parseInt(args._[0], 10)
|
|
176
170
|
if (!Number.isFinite(n) || n < 1 || n > ladder.length) die(2, `usage: leg ladder rm <n> (1..${ladder.length})`)
|
|
177
171
|
if (ladder.length === 1) die(2, 'that is the only rung left: a ladder with no rungs has nowhere to hand off to')
|
|
178
172
|
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()
|
|
173
|
+
try { prefsApi.writePreferences({ handoff_ladder: ladder }) } catch (err) { die(2, err.message) }
|
|
174
|
+
out(`removed rung ${n}: ${usage.rungLabel(gone)}`)
|
|
175
|
+
return printLadder(prefsApi, usage)
|
|
182
176
|
}
|
|
183
177
|
if (cmd === 'spend') {
|
|
184
178
|
const v = args._[0]
|
|
185
179
|
if (!['on', 'off'].includes(v)) die(2, 'usage: leg ladder spend on|off')
|
|
186
|
-
const saved = writePreferences({ may_spend: v === 'on' })
|
|
180
|
+
const saved = prefsApi.writePreferences({ may_spend: v === 'on' })
|
|
187
181
|
return out(saved.may_spend
|
|
188
182
|
? 'spending is ON: an unattended hand-off may take a rung that bills credits.'
|
|
189
183
|
: 'spending is OFF: an unattended hand-off skips any rung that bills credits, and says so in the ledger.')
|
|
@@ -238,13 +232,10 @@ async function main() {
|
|
|
238
232
|
out('\nPassing the leg to the next runner when limits hit.')
|
|
239
233
|
return
|
|
240
234
|
}
|
|
241
|
-
if (SUPERVISED_AGENTS.includes(group)) {
|
|
242
|
-
// leg claude|codex|agy|grok [agent args...]: everything after the agent name
|
|
243
|
-
// goes straight through.
|
|
244
|
-
const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: (process.env.LEG_NO_OPEN || process.env.BATON_NO_OPEN) !== '1' })
|
|
245
|
-
process.exit(code)
|
|
246
|
-
}
|
|
247
235
|
if (group === 'sessions') {
|
|
236
|
+
const sessionsApi = await import('../src/sessions.mjs')
|
|
237
|
+
const { listSessions, readSession, isActive, removeSession, readLand, requestControl } = sessionsApi
|
|
238
|
+
const readSessionEvents = sessionsApi.readEvents
|
|
248
239
|
const list = listSessions()
|
|
249
240
|
if (cmd === 'ls' || !cmd) {
|
|
250
241
|
if (args.json) return out(JSON.stringify(list, null, 2))
|
|
@@ -263,17 +254,20 @@ async function main() {
|
|
|
263
254
|
// is not a destination, is not installed, or is at its wall must be
|
|
264
255
|
// refused now, not silently turn into "whatever is next".
|
|
265
256
|
if (typeof args.to === 'string') {
|
|
266
|
-
const
|
|
257
|
+
const [{ normalizeHandoffOrder, ladderFor }, usage, { readAccounts }] = await Promise.all([
|
|
258
|
+
import('../src/preferences.mjs'), import('../src/usage.mjs'), import('../src/accounts.mjs'),
|
|
259
|
+
])
|
|
260
|
+
const want = await parseTarget(args.to, { die })
|
|
267
261
|
const order = normalizeHandoffOrder(s.handoff_order)
|
|
268
262
|
const ladder = ladderFor(s)
|
|
269
|
-
const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: readAccounts(), order, ladder })
|
|
263
|
+
const chain = usage.candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: readAccounts(), order, ladder })
|
|
270
264
|
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'})`)
|
|
265
|
+
const label = usage.rungLabel(want)
|
|
266
|
+
if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => usage.rungLabel(c)).join(', ') || 'none'})`)
|
|
273
267
|
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`)
|
|
268
|
+
const u = usage.readUsage(want.agent, want.account)
|
|
269
|
+
if (!usage.isAvailable(u)) die(3, `${label} is at its usage limit until ${usage.fmtReset(u.limited_until)}; pick another, or drop --to to take the next option in the order`)
|
|
270
|
+
if (hit.model && usage.wallActive(u.walls?.[hit.model])) die(3, `${label} is out until ${usage.fmtReset(u.walls[hit.model].limited_until)}; pick another rung, or drop --to to take the next open one`)
|
|
277
271
|
const target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
|
|
278
272
|
requestControl(id, { handoff: true, target })
|
|
279
273
|
return out(`handoff to ${label} requested for ${id}`)
|
|
@@ -291,6 +285,7 @@ async function main() {
|
|
|
291
285
|
// the CLI twin never orphans a worktree the board can no longer reach
|
|
292
286
|
if (s.worktree) {
|
|
293
287
|
try {
|
|
288
|
+
const { pruneSessionWorktree } = await import('../src/land.mjs')
|
|
294
289
|
const r = pruneSessionWorktree(s)
|
|
295
290
|
out(r.removed
|
|
296
291
|
? `removed worktree ${s.worktree.path}${r.branchDeleted ? ` and branch ${s.worktree.branch}` : `; kept branch ${s.worktree.branch}`}`
|
|
@@ -303,7 +298,7 @@ async function main() {
|
|
|
303
298
|
// is the only way to reach a per-model wall without waiting for one:
|
|
304
299
|
// --message "You've reached your Fable limit." walls fable and leaves the
|
|
305
300
|
// rest of the login open (src/buckets.mjs).
|
|
306
|
-
if (cmd === 'simulate-limit') return simulateLimit(s, { message: typeof args.message === 'string' ? args.message : null })
|
|
301
|
+
if (cmd === 'simulate-limit') return simulateLimit(sessionsApi, s, { message: typeof args.message === 'string' ? args.message : null })
|
|
307
302
|
die(2, `unknown sessions command "${cmd}" (ls|show|events|handoff|end|rm|simulate-limit)`)
|
|
308
303
|
}
|
|
309
304
|
if (group === 'ladder') {
|
|
@@ -311,10 +306,21 @@ async function main() {
|
|
|
311
306
|
// state and the same skip reasons the board's picker shows.
|
|
312
307
|
return ladderCommand(cmd, args)
|
|
313
308
|
}
|
|
309
|
+
if (group === 'digest') {
|
|
310
|
+
// What happened while you were away: terminals, cards, landings and walls
|
|
311
|
+
// in a window, grouped by repository, what needs you first. Read only.
|
|
312
|
+
// Loaded here and not at the top: a command most sessions never run.
|
|
313
|
+
const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
|
|
314
|
+
const { buildDigest, renderDigest, DEFAULT_SINCE } = await import('../src/digest.mjs')
|
|
315
|
+
let d
|
|
316
|
+
try { d = buildDigest({ since: typeof a.since === 'string' ? a.since : DEFAULT_SINCE }) } catch (err) { die(2, err.message) }
|
|
317
|
+
return out(a.json ? JSON.stringify(d, null, 2) : renderDigest(d))
|
|
318
|
+
}
|
|
314
319
|
if (group === 'resume') {
|
|
315
320
|
// The read side of the pointer. Freshness is never read out of the file:
|
|
316
321
|
// it is recomputed from git here, now, so a resume file cannot describe a
|
|
317
322
|
// picture that is no longer true to whoever is standing in the repo.
|
|
323
|
+
const { resumeVerdict, bodyOf, ago } = await import('../src/resume.mjs')
|
|
318
324
|
const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
|
|
319
325
|
const where = typeof a.path === 'string' ? resolve(a.path) : process.cwd()
|
|
320
326
|
const v = resumeVerdict(where)
|
|
@@ -345,6 +351,9 @@ async function main() {
|
|
|
345
351
|
if (group === 'share') {
|
|
346
352
|
// Multiplayer, off by default: the board binds a shared address only once
|
|
347
353
|
// at least one person has a token, and every human has their own.
|
|
354
|
+
const { readShare, addPerson, removePerson, rotate: rotateToken, turnOn, turnOff, linkFor, personNamed, scheme, tlsConfigured, ROLES } = await import('../src/share.mjs')
|
|
355
|
+
const { stopBoard } = await import('../src/launcher.mjs')
|
|
356
|
+
const { ensureBoard } = await import('../src/attach.mjs')
|
|
348
357
|
const share = readShare()
|
|
349
358
|
// only the listener moves: the agents running under it are not part of who
|
|
350
359
|
// may look at the board
|
|
@@ -374,6 +383,7 @@ async function main() {
|
|
|
374
383
|
if (cmd === 'on') {
|
|
375
384
|
const a = parseArgs(rest)
|
|
376
385
|
// more than one human is the Team plan
|
|
386
|
+
const { entitlement, allows, describe: describeLicense, BUY_URL } = await import('../src/license.mjs')
|
|
377
387
|
const ent = entitlement()
|
|
378
388
|
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))
|
|
379
389
|
try {
|
|
@@ -428,6 +438,7 @@ async function main() {
|
|
|
428
438
|
die(2, `unknown share command "${cmd}" (status|on|add|rotate|rm|off)`)
|
|
429
439
|
}
|
|
430
440
|
if (group === 'accounts') {
|
|
441
|
+
const { addAccount, removeAccount, listAccountRows, LAYOUT } = await import('../src/accounts.mjs')
|
|
431
442
|
if (cmd === 'add') {
|
|
432
443
|
const [agent, name] = args._
|
|
433
444
|
if (!agent || !name) die(2, 'usage: leg accounts add <claude|codex> <name>')
|
|
@@ -451,6 +462,7 @@ async function main() {
|
|
|
451
462
|
return out(`removed ${agent} account "${name}" (your real ${LAYOUT[agent]?.home() ?? 'home'} was not touched)`)
|
|
452
463
|
}
|
|
453
464
|
if (cmd === 'ls' || !cmd) {
|
|
465
|
+
const { listUsage, fmtReset } = await import('../src/usage.mjs')
|
|
454
466
|
const usage = Object.fromEntries(listUsage().map((u) => [`${u.agent}--${u.account}`, u]))
|
|
455
467
|
for (const r of listAccountRows()) {
|
|
456
468
|
const u = usage[`${r.agent}--${r.name}`]
|
|
@@ -465,12 +477,14 @@ async function main() {
|
|
|
465
477
|
if (group === 'harness') {
|
|
466
478
|
// The portable harness: the working environment a hand-off carries with
|
|
467
479
|
// the task. Off until `leg harness enable` (src/harness/index.mjs).
|
|
480
|
+
const { harnessCommand } = await import('../src/harness/cli.mjs')
|
|
468
481
|
const code = await harnessCommand(cmd, args, { out, die })
|
|
469
482
|
process.exit(code)
|
|
470
483
|
}
|
|
471
484
|
if (group === 'adapter' || group === 'adapters') {
|
|
472
485
|
// Custom adapters: any CLI as a card agent, from a JSON spec on disk
|
|
473
486
|
// (src/adapters/custom.mjs). The built-ins need none of this.
|
|
487
|
+
const { adapterCommand } = await import('../src/adapters/cli.mjs')
|
|
474
488
|
const code = await adapterCommand(cmd, args, { out, die })
|
|
475
489
|
process.exit(code)
|
|
476
490
|
}
|
|
@@ -479,6 +493,7 @@ async function main() {
|
|
|
479
493
|
// stores hold: a read-only index (src/history/index.mjs). `continue`
|
|
480
494
|
// starts a normal supervised leg on one of them. `leg history --json` is
|
|
481
495
|
// `leg history ls --json`: a leading flag names no verb.
|
|
496
|
+
const { historyCommand, worktreesCommand } = await import('../src/history/cli.mjs')
|
|
482
497
|
const isHelp = cmd === '--help' || cmd === '-h' || cmd === 'help' || args.help || args.h
|
|
483
498
|
const bare = typeof cmd === 'string' && cmd.startsWith('--')
|
|
484
499
|
const verb = isHelp ? 'help' : (bare ? 'ls' : cmd)
|
|
@@ -492,6 +507,7 @@ async function main() {
|
|
|
492
507
|
if (group === 'license') {
|
|
493
508
|
// The paid gate. Keys verify offline against the public key in
|
|
494
509
|
// src/license.mjs; nothing here talks to the network except refresh.
|
|
510
|
+
const { entitlement, describe: describeLicense, activate: activateLicense, deactivate: deactivateLicense, refresh: refreshLicense, licensePath, BUY_URL } = await import('../src/license.mjs')
|
|
495
511
|
if (!cmd || cmd === 'status') {
|
|
496
512
|
// looking does not start the trial clock; the first session does
|
|
497
513
|
const ent = entitlement({ startTrial: false })
|
|
@@ -519,18 +535,22 @@ async function main() {
|
|
|
519
535
|
if (group === 'uninstall') {
|
|
520
536
|
// Leg never edits ~/.claude or ~/.codex; everything it added lives under
|
|
521
537
|
// $LEG_HOME (sessions, usage, extra-account dirs, cards).
|
|
538
|
+
const { home } = await import('../src/store.mjs')
|
|
522
539
|
const dir = home()
|
|
523
540
|
if (!args.yes) {
|
|
524
541
|
out(`leg uninstall removes ${dir} (sessions, usage, extra-account dirs, cards, board pidfile) and nothing else.`)
|
|
525
542
|
out('Your real ~/.claude, ~/.codex and agy homes are never touched. Re-run with --yes to do it.')
|
|
526
543
|
return
|
|
527
544
|
}
|
|
545
|
+
const { listAccountRows, removeAccount } = await import('../src/accounts.mjs')
|
|
546
|
+
const { down } = await import('../src/launcher.mjs')
|
|
528
547
|
for (const r of listAccountRows()) if (r.name !== 'default') removeAccount(r.agent, r.name)
|
|
529
548
|
await down()
|
|
530
549
|
rmSync(dir, { recursive: true, force: true })
|
|
531
550
|
return out(`removed ${dir}; now: npm rm -g @ucsandman/legcli`)
|
|
532
551
|
}
|
|
533
552
|
if (group === 'card') {
|
|
553
|
+
const { readCard, listCards, readEvents, readRuns, cardDir } = await import('../src/store.mjs')
|
|
534
554
|
if (cmd === 'add') return cardAdd(args)
|
|
535
555
|
if (cmd === 'ls') {
|
|
536
556
|
const cards = listCards()
|
|
@@ -543,6 +563,7 @@ async function main() {
|
|
|
543
563
|
const card = readCard(id) || die(3, `card not found: ${id}`)
|
|
544
564
|
if (cmd === 'show') {
|
|
545
565
|
if (args.json) return out(JSON.stringify({ card, runs: readRuns(id) }, null, 2))
|
|
566
|
+
const { availableActions } = await import('../src/chain.mjs')
|
|
546
567
|
out(fmtCard(card))
|
|
547
568
|
out(` repo: ${card.repo}`)
|
|
548
569
|
out(` worktree: ${card.worktree ?? '(none yet)'}`)
|
|
@@ -557,12 +578,14 @@ async function main() {
|
|
|
557
578
|
return
|
|
558
579
|
}
|
|
559
580
|
if (cmd === 'run') {
|
|
581
|
+
const { runCard } = await import('../src/orchestrator.mjs')
|
|
560
582
|
const final = await runCard(id)
|
|
561
583
|
out(`${final.card_id} ${final.status} at ${final.station}`)
|
|
562
584
|
process.exit(final.status === 'done' ? 0 : 1)
|
|
563
585
|
}
|
|
564
586
|
if (cmd === 'rm') {
|
|
565
587
|
try {
|
|
588
|
+
const { remove: removeWorktree } = await import('../src/worktree.mjs')
|
|
566
589
|
const r = removeWorktree(card.repo, id, { deleteBranch: Boolean(args['delete-branch']), force: Boolean(args.force) })
|
|
567
590
|
if (args['delete-branch'] && r.branchUnmerged && !r.branchDeleted) out(`kept branch leg/${id}: it has commits not on its base (rerun with --force to discard them)`)
|
|
568
591
|
} catch (err) { die(3, `worktree: ${err.message}`) }
|
|
@@ -571,6 +594,7 @@ async function main() {
|
|
|
571
594
|
}
|
|
572
595
|
const human = { queue: 'enqueue', pause: 'pause', resume: 'resume', kill: 'kill', approve: 'approve', 'handoff-now': 'handoff_now', rerun: 'rerun', reassign: 'reassign' }[cmd]
|
|
573
596
|
if (human) {
|
|
597
|
+
const { humanAction } = await import('../src/orchestrator.mjs')
|
|
574
598
|
const payload = human === 'reassign' ? { adapter: args.adapter || die(2, 'reassign needs --adapter'), mode: args.mode } : {}
|
|
575
599
|
const next = humanAction(id, human, payload, { type: 'human', id: args.actor || 'local' })
|
|
576
600
|
return out(`${next.card_id} ${next.status} at ${next.station} leg ${next.leg}`)
|
|
@@ -578,6 +602,7 @@ async function main() {
|
|
|
578
602
|
die(2, `unknown card command "${cmd}" (add|ls|show|run|rm|events|queue|pause|resume|kill|approve|handoff-now|rerun|reassign)`)
|
|
579
603
|
}
|
|
580
604
|
if (group === 'scheduler') {
|
|
605
|
+
const { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } = await import('../src/scheduler.mjs')
|
|
581
606
|
if (cmd === 'start') {
|
|
582
607
|
const ticks = args.ticks ? parseInt(args.ticks, 10) : Infinity
|
|
583
608
|
const running = schedulerStatus()
|
|
@@ -603,19 +628,34 @@ async function main() {
|
|
|
603
628
|
die(2, `unknown scheduler command "${cmd}" (start|status|stop)`)
|
|
604
629
|
}
|
|
605
630
|
if (group === 'up') {
|
|
631
|
+
const { up } = await import('../src/launcher.mjs')
|
|
606
632
|
const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
|
|
607
633
|
const code = await up({ dry: Boolean(a.dry), open: !a['no-open'], port: a.port !== undefined ? parseInt(a.port, 10) : undefined, bind: a.bind })
|
|
608
634
|
process.exit(code)
|
|
609
635
|
}
|
|
610
|
-
if (group === 'down') process.exit(await down())
|
|
611
|
-
if (group === 'status') process.exit(await status())
|
|
636
|
+
if (group === 'down') { const { down } = await import('../src/launcher.mjs'); process.exit(await down()) }
|
|
637
|
+
if (group === 'status') { const { status } = await import('../src/launcher.mjs'); process.exit(await status()) }
|
|
612
638
|
if (group === 'open') {
|
|
639
|
+
const { openBoard } = await import('../src/launcher.mjs')
|
|
613
640
|
const port = (process.env.LEG_PORT || process.env.BATON_PORT) || 4747
|
|
614
641
|
const url = `http://127.0.0.1:${port}`
|
|
615
642
|
out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
|
|
616
643
|
return
|
|
617
644
|
}
|
|
618
|
-
|
|
645
|
+
// Everything above is a named command group. What is left is either a
|
|
646
|
+
// supervised agent (`leg claude|codex|agy|grok [args...]`, everything after
|
|
647
|
+
// the agent name goes straight through) or unknown. SUPERVISED_AGENTS and
|
|
648
|
+
// attach() are loaded here, last, so no other command pays for them.
|
|
649
|
+
if (group && group !== '--help' && group !== 'help') {
|
|
650
|
+
const { SUPERVISED_AGENTS } = await import('../src/sessions.mjs')
|
|
651
|
+
if (SUPERVISED_AGENTS.includes(group)) {
|
|
652
|
+
const { attach } = await import('../src/attach.mjs')
|
|
653
|
+
const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: (process.env.LEG_NO_OPEN || process.env.BATON_NO_OPEN) !== '1' })
|
|
654
|
+
process.exit(code)
|
|
655
|
+
}
|
|
656
|
+
die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|ladder|history|worktrees|digest|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
|
|
657
|
+
}
|
|
658
|
+
const { PRESET_NAMES } = await import('../src/presets.mjs')
|
|
619
659
|
out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
|
|
620
660
|
claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
|
|
621
661
|
the board opens once, the session shows as a card, usage is tracked, a limit hands off
|
|
@@ -634,6 +674,8 @@ async function main() {
|
|
|
634
674
|
history show|continue <id> | refresh | providers
|
|
635
675
|
one conversation, or start leg <agent> on it where the agent can resume by id
|
|
636
676
|
worktrees [--repo r] [--json] every checkout Leg can see: git's, its own, the ones conversations worked in
|
|
677
|
+
digest [--since 8h|2d|<iso>] [--json] what happened while you were away: what needs you, then every
|
|
678
|
+
terminal, card, landing and wall in the window, grouped by repository
|
|
637
679
|
resume [--check] [--json] [--path <dir>] the hand-off waiting in this checkout, and whether it is still true
|
|
638
680
|
freshness is recomputed from git at read time; --check prints only the verdict
|
|
639
681
|
exit 0 current, 1 stale or unstamped, 3 no pointer here
|
package/docs/DECISIONS.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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-18: a second claude login shares the conversation store, so an account switch keeps the conversation
|
|
6
|
+
|
|
7
|
+
- **What.** `LAYOUT.claude.share` gains `projects`: `leg accounts add claude <name>` junctions Claude Code's conversation store into the account directory beside the harness directories, and `refreshAccount()` adds a missing junction to an account made before this before every launch. `keepsConversation()` in `src/usage.mjs` is the one rule that decides `--resume` over the bundle, for the terminal's switch and the board picker's `keeps_conversation` alike: a same-login downshift (as before), or another login of the same agent under which the transcript file exists. The history index skips a junctioned `projects` so a conversation is listed once.
|
|
8
|
+
- **Why share rather than copy.** Wes runs two 20x logins and wants a Fable or weekly wall on one to continue on the other without re-explaining anything. Copying the one transcript at hand-off would have to write into `~/.claude/projects` on the way back, and that directory is on the README's "read, never written" list. A junction inside `$LEG_HOME/accounts/` keeps that promise: Claude Code writes its own store, Leg reads it. It also carries the auto-memory under `projects/<cwd>/memory/`, which is the same human's and should follow them.
|
|
9
|
+
- **Why the rule checks the file.** A `--resume` into a login that cannot see the transcript opens an empty conversation and loses the bundle too. So the rule asks whether the file exists under the destination home at the moment of the switch, and says bundle when it does not. A false answer costs one bundle prime; a wrong true would cost the context.
|
|
10
|
+
- **Why a cross-login upshift resumes when a same-login upshift does not.** The same-login rule stays as decided on 2026-09-17: the re-read at the stronger model's rate is paid on the login that is already low. Across logins the destination is a fresh window, which is the reason the human bought it.
|
|
11
|
+
- **What this rules out.** codex across logins: `codex resume` under a second `CODEX_HOME` is unobserved, so codex rungs keep taking the bundle and claim nothing.
|
|
12
|
+
|
|
5
13
|
## 2026-09-18: usage is polled by the board, once per login; a rung's model is a catalog entry validated by shape
|
|
6
14
|
|
|
7
15
|
- **What.** The board process runs one usage poller per login (`src/usage-poll.mjs`): 60s, doubling to ten minutes on any refusal, one status line when a login's reading fails and one when it is back. Terminals poll nothing and receive the windows on their session record. `GET /api/models` lists what each installed CLI can run, read from claude's aliases, codex's `models_cache.json` and `config.toml`, and the `models` commands of agy and grok, cached an hour under `<LEG_HOME>/models/`. A rung's `model` is validated by shape (lower-case id characters) for every provider and by membership for claude only.
|
package/docs/DEVIATIONS.md
CHANGED
|
@@ -104,6 +104,7 @@ shape differs from what the plan assumed. Same columns as the table above.
|
|
|
104
104
|
|------|------|---------------|-------------|-----|
|
|
105
105
|
| 2026-09-11 | bin/leg.mjs `simulate-limit` | "sends a real StopFailure payload with error rate_limit through src/hook.mjs" | the payload carries `baton_simulated: true`; the hook records the wall with a 2-minute reset instead of the default 5 h, never keeps it as live evidence, and the event says `(simulated)`; agy gets the RESOURCE_EXHAUSTED line appended to its Leg-owned session log; codex is refused (its wall lives in a rollout file Leg never writes) with `leg sessions handoff` as the alternative | a test must never wall the real claude login for five hours, and a simulated payload must never flip a docs row to observed-live |
|
|
106
106
|
| 2026-09-11 | src/live-capture.mjs | "save that payload under fixtures/live/" | `fixtures/live/<agent>/limit-<signal>.json` only in a dev clone (the checkout has `.git` and `fixtures/live/`), else `~/.leg/live/`; `LEG_LIVE_DIR` overrides; first arrival per (agent, signal) wins; docs rows flip through `scripts/live-limits.mjs` markers (`<!-- live:<agent>/<signal> -->`) in the same call | an installed package has no writable fixtures directory, and a doc row must flip from evidence on disk, not from memory |
|
|
107
|
+
| 2026-09-19 | src/taps/claude.mjs `writeSettings`, src/hook.mjs | the user's own `statusLine` command runs first (claimed in the file header since baton 0.2.0) | it was read only for its `padding`; the command is now kept on the session record (`user_statusline`) and the status-line hook runs it with the same stdin, printing its rows above Leg's | a baton user reported on Discord (2026-09-19) that baton replaced the status line they had configured; on a build that honours a `--settings` status line, Leg's row displaced theirs |
|
|
107
108
|
| 2026-09-11 | src/attach.mjs `spawnSpec` | the next agent starts with no args | `BATON_<AGENT>_ARGS` (space-separated) is prepended to a leg Leg starts itself after a hand-off; the user's own `baton <agent> …` args still never carry over | the live test had to keep the handed-off leg on a cheap model, and a user has the same need for a chain |
|
|
108
109
|
| 2026-09-11 | src/taps/codex.mjs | the first `role: user` message is the task | a message starting `# AGENTS.md instructions` is skipped like `<environment_context>` | observed live 2026-09-11: codex prepends the AGENTS.md block as the first user message of every rollout, so the card's task read as the instructions file |
|
|
109
110
|
| 2026-09-11 | src/attach.mjs, src/wait.mjs | all out: print the reset times and exit 3 | status `waiting` with `session.waiting = { agent, account, resets_at, since }`; a one-line countdown on stderr (rewritten in place on a TTY, once a minute otherwise); at the reset the chooser runs again and the first option back starts from the bundle; Ctrl-C or a board End quits with exit 3 and an `ended` event "quit while waiting" | the terminal is where the work is; quitting hands the human a restart to type at the reset time |
|
|
@@ -181,3 +182,36 @@ tests. Rows for the shape changes a later reader would otherwise wonder about.
|
|
|
181
182
|
| 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
183
|
| 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
184
|
| 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 |
|
|
185
|
+
|
|
186
|
+
## 2026-09-18: product, performance and architecture pass (0.15.0)
|
|
187
|
+
|
|
188
|
+
Measured first (`scratchpad/prof/BASELINE.md`, reproduction commands in the
|
|
189
|
+
CHANGELOG entry), then changed. Rows for the shape changes a later reader
|
|
190
|
+
would otherwise wonder about; the designs not built are in
|
|
191
|
+
`docs/review-2026-09-18.md`.
|
|
192
|
+
|
|
193
|
+
| date | file | old shape | new shape | why |
|
|
194
|
+
|------|------|-----------|-----------|-----|
|
|
195
|
+
| 2026-09-18 | src/accounts.mjs, src/usage.mjs, src/attach.mjs, src/server.mjs | a hand-off to a second claude login always took the bundle; only a same-login downshift kept the conversation | the account junctions claude's `projects` store; one rule, `keepsConversation()`, decides for the terminal and the picker, and checks the transcript file is reachable under the destination home at the switch | a second 20x login should continue the conversation, not re-read it from a bundle; the junction keeps "~/.claude/projects: read, never written" true |
|
|
196
|
+
| 2026-09-18 | src/history/providers/claude.mjs | every claude home was scanned, junction or not | a home whose `projects` is a link is skipped and reported `shared: true` | the same transcript listed twice from two logins |
|
|
197
|
+
| 2026-09-18 | src/digest.mjs, bin/leg.mjs, src/server.mjs | nothing read the records back as an answer to a person; the audit trail was a flat list | `leg digest [--since]` and `GET /api/digest` (owner only): volume first, what needs you, per repository, walls standing now | the question after eight hours away had no reader; a window with nothing in it must print its counts, not a blank |
|
|
198
|
+
| 2026-09-18 | bin/leg.mjs | 26 static imports; the agent dispatch first; `parseTarget` synchronous | one `await import()` per command group, inside its branch; the agent dispatch after every named group; `parseTarget` async and loads buckets/accounts only for a two-part target | `leg --version` loaded 68 modules for one readFileSync: 117 ms wall, 79 ms CPU, 254 fs calls; now 1 module, 52 ms, 4 fs calls |
|
|
199
|
+
| 2026-09-18 | src/limits.mjs | `SIGNALS = loadSignals()` at import (24 fixture reads, one RegExp each) | a Proxy over the same array that loads on first property access | every command paid for the fixture tree; test/ and scripts/limits-table.mjs read `SIGNALS.length` at their own top level, so a loader function was not an option |
|
|
200
|
+
| 2026-09-18 | src/scheduler-status.mjs (new), src/scheduler.mjs, src/launcher.mjs | `pidfile`, `schedulerStatus`, `MAX_CONCURRENT` lived in scheduler.mjs | a leaf module, re-exported from scheduler.mjs | the launcher pulled orchestrator, land, mergequeue, stations, chain, runner and limits to answer "is the scheduler running" |
|
|
201
|
+
| 2026-09-18 | src/git.mjs (new), src/attach.mjs, src/bundle.mjs | six git processes per poll round (three `rev-parse`, `status --porcelain`, `@{upstream}`, `rev-list --count`), each wrapper private, no timeout, no maxBuffer | one `status --porcelain=v2 --branch` carries head, branch, upstream, ahead/behind and the dirty list; `aheadFromStatus` uses `# branch.ab` or a one-pair head cache; 20 s timeout, 8 MB buffer | an idle terminal spawned 59.5 git processes a minute and blocked its own loop 4.4 to 10.3 s/min; now 10.9/min and 2.1 s/min, CPU 1.7 to 0.1 s/min |
|
|
202
|
+
| 2026-09-18 | src/git.mjs parseStatus | a rename printed the old and new names with the quotes half-stripped | the new name | the file on disk is the one a human recognises; pinned in test/git-status.test.mjs |
|
|
203
|
+
| 2026-09-18 | src/attach.mjs ensureBoard | polled /api/health every 200 ms for up to 15 s before the agent got its first instruction | `wait: false` for a terminal: the agent starts at once, the wait runs behind it, `claimBoardBeforeExit` (2 s at most) still writes the pidfile before a sub-second session exits; `leg share on|off` keeps the blocking form | the one terminal of the day that starts the board waited ~790 ms doing nothing (to-agent 1,280 to 261 ms); a board without a pidfile is one `leg down` cannot stop |
|
|
204
|
+
| 2026-09-18 | src/attach.mjs installedAgents | a `--version` subprocess per bare-name agent per launch, 8 s timeout each | `$LEG_HOME/installed.json`, one day per resolved bin; a `*_BIN` override is asked every time and never cached | a stub pointed at on purpose must be probed; a real CLI on PATH answers the same for a day |
|
|
205
|
+
| 2026-09-18 | src/bundle.mjs, src/handoff.mjs, src/attach.mjs | the periodic checkpoint ran `chb save` synchronously inside the poll tick | `saveSessionBundleAsync` (execFile), one at a time through `checkpointGate`, awaited before the hand-off save; warning, limit and hand-off saves stay synchronous | a python subprocess with a 120 s timeout froze limit detection and every board button for its whole run |
|
|
206
|
+
| 2026-09-18 | src/handoff.mjs | `scrub` imported from runner.mjs | from redact.mjs | the terminal path pulled the card runner's whole graph for one function |
|
|
207
|
+
| 2026-09-18 | src/sessions.mjs updateSession | `withFileLock` defaults: past 1.2 s the read-modify-write ran unlocked | `retries: 250, staleMs: 10000`, run.json's budget | the most-written file in Leg (runner poll, claude hook, usage poller, board actions, every `leg` command); losing a patch is the race the lock exists for |
|
|
208
|
+
| 2026-09-18 | src/sessions.mjs, src/attach.mjs | `rmSync(control.json)` at exit | `clearControl` under `.control.lock` | a bare unlink could delete a board request mid-write |
|
|
209
|
+
| 2026-09-18 | src/attach.mjs | the 12-leg stop said "run leg again in this directory to continue from the bundle" | names the bundle path and `leg resume`; says a fresh `leg <agent>` does not load it | nothing reloads a bundle on a fresh launch; the line promised a hand-off that never happened |
|
|
210
|
+
| 2026-09-18 | src/server.mjs health tick | `pushSessions()` unconditionally every 10 s per client | the stat fingerprint decides; a liveness pass (`reapLost`) runs on the tick because a dead runner moves no file; a card entering or leaving a human-waiting status forces one push | an idle board with 43 terminals did 7,294 fs calls, 8 git processes and 1.1 CPU s a minute for nothing; the first cut of this lost the dead-runner case, and the test that kills a real child now pins it |
|
|
211
|
+
| 2026-09-18 | src/server.mjs sessionsView | `readUsage` per rung per terminal, again for `capacity`, again for the accounts payload (143 reads per answer) | one reader per view | a login is read at most once per answer, and every part of the answer comes from the same reading |
|
|
212
|
+
| 2026-09-18 | src/synthesis.mjs | three `existsSync` per terminal per view | one `readdirSync` per checkout per view (`sessionFileIndex`) | 172 of 512 fs calls per answer, for files that mostly do not exist |
|
|
213
|
+
| 2026-09-18 | src/server.mjs, src/history/worktrees.mjs | `/api/worktrees` ran up to 40 git processes synchronously on the board's loop (9 to 25 s cold) | `listWorktreesAsync` (execFile, four in flight), one refresh per query, the last list served while a refresh runs | the worst `/api/health` during a cold call fell from 11.8 s to 3.2 s; the rest is `listHistory` inside `gather()`, still synchronous |
|
|
214
|
+
| 2026-09-18 | src/server.mjs /api/trunk | every card ledger read on every 2 s floor poll | a 15 s cache keyed by the window, cleared by any card change and by a landing | 27.5 to 0.5 ms p50, 113 to 15 fs calls per request |
|
|
215
|
+
| 2026-09-18 | src/session-detail.mjs | one `git ls-files` per file the terminal touched, every 3 s the drawer is open | one `git ls-files -z -- <paths>` for the list | 7 to 2 processes per detail answer, 1,247 to 305 ms |
|
|
216
|
+
| 2026-09-18 | src/server.mjs rows | every field of session.json on every push | `argv`, `runner_pid`, `head_at_start`, `checkpoints`, `agent_sessions`, `runtime_capabilities`, `files_touched` dropped from the row; `GET /api/sessions/<id>` still hands over the record | nothing under src/board/ read them; 8% off every push |
|
|
217
|
+
| 2026-09-18 | src/history/worktrees.mjs | the git env built by a helper | `MSYS_NO_PATHCONV: '1'` spelled out at each spawn site | test/lessons.test.mjs checks every git spawn line in src/ for it, and the helper hid it from that check |
|
package/docs/ERRORS.md
CHANGED
|
@@ -21,7 +21,7 @@ and "CI green" after a push means the run, not the publish; `npm view
|
|
|
21
21
|
**Fixed by deploying from the repo root with a root `.vercelignore` and
|
|
22
22
|
`--archive=tgz`; the recipe is in DECISIONS.md under "the site deploys itself
|
|
23
23
|
from git".** The `--yes` also auto-created an empty Vercel project named `leg`
|
|
24
|
-
that still needs `vercel project rm leg
|
|
24
|
+
that still needs `vercel project rm leg` (2026-09-18 later: `vercel project ls` no longer lists a `leg` project, so it is gone). The lesson: on a project with a Root
|
|
25
25
|
Directory set, the CLI must run from the repo root, and `--yes` is a consent to
|
|
26
26
|
create projects, not only to skip a confirmation.
|
|
27
27
|
|
package/docs/ROADMAP-v2.md
CHANGED
|
@@ -86,7 +86,18 @@ event names its actor, a board that reads only the ledger, and an auth/bind seam
|
|
|
86
86
|
The same probe shape applies to naming the terminal tab: whether codex, agy
|
|
87
87
|
and grok leave an OSC 2 title alone once the child starts drawing is
|
|
88
88
|
assumed, not known, which is why the tab title is the browser's and not the
|
|
89
|
-
terminal's.
|
|
89
|
+
terminal's. 0.15.0 made the claude half of this real across logins too (a
|
|
90
|
+
second login shares the conversation store, so a login switch keeps the
|
|
91
|
+
conversation): the codex probe now also covers a second `CODEX_HOME` with
|
|
92
|
+
its `sessions` store shared the same way.
|
|
93
|
+
7. **`stalled` and `repeating` on the row.** Two derived states from data the
|
|
94
|
+
record already holds: no turn, file write or commit for N minutes while not
|
|
95
|
+
waiting on a human; the same test red across two legs of one card. Printed
|
|
96
|
+
only, never acted on; a `looping` state waits for a false-positive study on
|
|
97
|
+
real transcripts. Design in `docs/review-2026-09-18.md`.
|
|
98
|
+
8. **The digest on the board.** `leg digest` and `/api/digest` shipped in
|
|
99
|
+
0.15.0; the panel above Terminals on the first load of the day is the
|
|
100
|
+
human surface it still lacks. Same review doc.
|
|
90
101
|
|
|
91
102
|
Also on the list: per-station prompt templates editable from the board, lease
|
|
92
103
|
suggestions from the diff of the previous leg, and a floor view that shows
|
package/docs/adapters.md
CHANGED
|
@@ -55,8 +55,11 @@ documentation say docs-only.
|
|
|
55
55
|
`src/hook.mjs`.
|
|
56
56
|
- **Why not the status line.** Leg writes a `statusLine` entry into the same
|
|
57
57
|
settings file that would record `rate_limits.five_hour.used_percentage` and
|
|
58
|
-
`resets_at`, and
|
|
59
|
-
|
|
58
|
+
`resets_at`, and runs your own `statusLine` command first, its rows above
|
|
59
|
+
Leg's one (since 0.15.1; before that your command was read and never run,
|
|
60
|
+
so a build that honours the key showed Leg's row instead of yours). Claude
|
|
61
|
+
Code 2.1.268 and 2.1.278 did not run it when it was tried on this machine
|
|
62
|
+
(2026-09-11, 2026-09-19): an `echo` command
|
|
60
63
|
passed through `--settings` and again through a project
|
|
61
64
|
`.claude/settings.local.json` left the built-in status line in place, while
|
|
62
65
|
hooks from the same `--settings` file fired. No artifact of that check was
|
package/docs/cli-contracts.md
CHANGED
|
@@ -246,10 +246,16 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
|
|
|
246
246
|
- Attach: `claude <args> -n "leg#<short id> <repo>/<branch>" --settings <LEG_HOME>/sessions/<id>/claude-settings.json`
|
|
247
247
|
(source: src/attach.mjs `spawnSpec`; src/taps/claude.mjs `writeSettings`).
|
|
248
248
|
Hooks in a `--settings` file merge with the user's rather than replacing
|
|
249
|
-
them; `statusLine` is the one key that replaces, so Leg
|
|
250
|
-
command
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
them; `statusLine` is the one key that replaces, so Leg keeps the user's own
|
|
250
|
+
command on the session record (`user_statusline`) and its status-line hook
|
|
251
|
+
runs that command with the same stdin JSON, printing its rows above Leg's
|
|
252
|
+
one (source: code.claude.com/docs/en/settings, docs/en/statusline "Display
|
|
253
|
+
multiple lines"; src/taps/claude.mjs `userStatusLine`, `userStatusLineText`;
|
|
254
|
+
src/hook.mjs). Before 0.15.1 the user's command was read only for its
|
|
255
|
+
`padding` and never run, so a build that honours a `--settings` status line
|
|
256
|
+
showed Leg's row in place of the user's (reported by a baton user,
|
|
257
|
+
2026-09-19). observed-live 2026-09-11: a Leg session ran with every user
|
|
258
|
+
hook still firing.
|
|
253
259
|
- Terminal title: `-n, --name <name>` "Set a display name for this session
|
|
254
260
|
(shown in the prompt box, /resume picker, and terminal title)" —
|
|
255
261
|
fixtures/help/claude.txt line 132. It sits in the general Options block, not
|
|
@@ -327,11 +333,13 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
|
|
|
327
333
|
path can also be run live with `leg sessions simulate-limit` (the same
|
|
328
334
|
payload through `src/hook.mjs`, marked `leg_simulated`, never kept as
|
|
329
335
|
evidence).
|
|
330
|
-
- Status line, not usable on 2.1.268: a custom `statusLine` command passed
|
|
336
|
+
- Status line, not usable on 2.1.268 or 2.1.278: a custom `statusLine` command passed
|
|
331
337
|
through `--settings`, and again through a project
|
|
332
338
|
`.claude/settings.local.json`, was not run at all when it was tried; an
|
|
333
339
|
`echo` command at both levels left the built-in status line in place while
|
|
334
|
-
hooks from the same `--settings` file fired (
|
|
340
|
+
hooks from the same `--settings` file fired (probed again 2026-09-19 on
|
|
341
|
+
2.1.278 with a screenshot of two haiku sessions, not kept; 0 of 87 session
|
|
342
|
+
hook logs on this machine hold a statusline entry; note in the
|
|
335
343
|
src/taps/claude-usage.mjs header, 2026-09-11). Leg still writes the
|
|
336
344
|
`statusLine` entry, which records the
|
|
337
345
|
same `rate_limits.five_hour` / `seven_day` fields
|