@ucsandman/legcli 0.13.1 → 0.15.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 +115 -0
- package/LICENSE +2 -0
- package/README.md +62 -22
- package/bin/leg.mjs +108 -65
- package/docs/DECISIONS.md +10 -1
- package/docs/DEVIATIONS.md +33 -0
- package/docs/ERRORS.md +22 -0
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +2 -2
- 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/launcher.mjs +1 -1
- package/src/license.mjs +38 -13
- 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/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,11 +507,13 @@ 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
|
|
513
|
+
const ent = entitlement({ startTrial: false })
|
|
497
514
|
out(describeLicense(ent))
|
|
498
515
|
if (ent.source === 'license') out(`stored at ${licensePath()}`)
|
|
499
|
-
if (!ent.ok) out(`Buy: ${BUY_URL} then: leg license activate <key>`)
|
|
516
|
+
if (!ent.ok || ent.source === 'trial') out(`Buy: ${BUY_URL} then: leg license activate <key>`)
|
|
500
517
|
return
|
|
501
518
|
}
|
|
502
519
|
if (cmd === 'activate') {
|
|
@@ -508,7 +525,7 @@ async function main() {
|
|
|
508
525
|
} catch (err) { die(2, err.message) }
|
|
509
526
|
return
|
|
510
527
|
}
|
|
511
|
-
if (cmd === 'deactivate') return out(deactivateLicense() ? `removed ${licensePath()}; Leg
|
|
528
|
+
if (cmd === 'deactivate') return out(deactivateLicense() ? `removed ${licensePath()}; Leg is back on the trial if it has days left, otherwise it needs a key` : 'no license was stored')
|
|
512
529
|
if (cmd === 'refresh') {
|
|
513
530
|
try { const p = await refreshLicense(); out(`renewed ${p.plan} license ${p.id}, valid through ${p.expires}`) } catch (err) { die(2, err.message) }
|
|
514
531
|
return
|
|
@@ -518,18 +535,22 @@ async function main() {
|
|
|
518
535
|
if (group === 'uninstall') {
|
|
519
536
|
// Leg never edits ~/.claude or ~/.codex; everything it added lives under
|
|
520
537
|
// $LEG_HOME (sessions, usage, extra-account dirs, cards).
|
|
538
|
+
const { home } = await import('../src/store.mjs')
|
|
521
539
|
const dir = home()
|
|
522
540
|
if (!args.yes) {
|
|
523
541
|
out(`leg uninstall removes ${dir} (sessions, usage, extra-account dirs, cards, board pidfile) and nothing else.`)
|
|
524
542
|
out('Your real ~/.claude, ~/.codex and agy homes are never touched. Re-run with --yes to do it.')
|
|
525
543
|
return
|
|
526
544
|
}
|
|
545
|
+
const { listAccountRows, removeAccount } = await import('../src/accounts.mjs')
|
|
546
|
+
const { down } = await import('../src/launcher.mjs')
|
|
527
547
|
for (const r of listAccountRows()) if (r.name !== 'default') removeAccount(r.agent, r.name)
|
|
528
548
|
await down()
|
|
529
549
|
rmSync(dir, { recursive: true, force: true })
|
|
530
550
|
return out(`removed ${dir}; now: npm rm -g @ucsandman/legcli`)
|
|
531
551
|
}
|
|
532
552
|
if (group === 'card') {
|
|
553
|
+
const { readCard, listCards, readEvents, readRuns, cardDir } = await import('../src/store.mjs')
|
|
533
554
|
if (cmd === 'add') return cardAdd(args)
|
|
534
555
|
if (cmd === 'ls') {
|
|
535
556
|
const cards = listCards()
|
|
@@ -542,6 +563,7 @@ async function main() {
|
|
|
542
563
|
const card = readCard(id) || die(3, `card not found: ${id}`)
|
|
543
564
|
if (cmd === 'show') {
|
|
544
565
|
if (args.json) return out(JSON.stringify({ card, runs: readRuns(id) }, null, 2))
|
|
566
|
+
const { availableActions } = await import('../src/chain.mjs')
|
|
545
567
|
out(fmtCard(card))
|
|
546
568
|
out(` repo: ${card.repo}`)
|
|
547
569
|
out(` worktree: ${card.worktree ?? '(none yet)'}`)
|
|
@@ -556,12 +578,14 @@ async function main() {
|
|
|
556
578
|
return
|
|
557
579
|
}
|
|
558
580
|
if (cmd === 'run') {
|
|
581
|
+
const { runCard } = await import('../src/orchestrator.mjs')
|
|
559
582
|
const final = await runCard(id)
|
|
560
583
|
out(`${final.card_id} ${final.status} at ${final.station}`)
|
|
561
584
|
process.exit(final.status === 'done' ? 0 : 1)
|
|
562
585
|
}
|
|
563
586
|
if (cmd === 'rm') {
|
|
564
587
|
try {
|
|
588
|
+
const { remove: removeWorktree } = await import('../src/worktree.mjs')
|
|
565
589
|
const r = removeWorktree(card.repo, id, { deleteBranch: Boolean(args['delete-branch']), force: Boolean(args.force) })
|
|
566
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)`)
|
|
567
591
|
} catch (err) { die(3, `worktree: ${err.message}`) }
|
|
@@ -570,6 +594,7 @@ async function main() {
|
|
|
570
594
|
}
|
|
571
595
|
const human = { queue: 'enqueue', pause: 'pause', resume: 'resume', kill: 'kill', approve: 'approve', 'handoff-now': 'handoff_now', rerun: 'rerun', reassign: 'reassign' }[cmd]
|
|
572
596
|
if (human) {
|
|
597
|
+
const { humanAction } = await import('../src/orchestrator.mjs')
|
|
573
598
|
const payload = human === 'reassign' ? { adapter: args.adapter || die(2, 'reassign needs --adapter'), mode: args.mode } : {}
|
|
574
599
|
const next = humanAction(id, human, payload, { type: 'human', id: args.actor || 'local' })
|
|
575
600
|
return out(`${next.card_id} ${next.status} at ${next.station} leg ${next.leg}`)
|
|
@@ -577,6 +602,7 @@ async function main() {
|
|
|
577
602
|
die(2, `unknown card command "${cmd}" (add|ls|show|run|rm|events|queue|pause|resume|kill|approve|handoff-now|rerun|reassign)`)
|
|
578
603
|
}
|
|
579
604
|
if (group === 'scheduler') {
|
|
605
|
+
const { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } = await import('../src/scheduler.mjs')
|
|
580
606
|
if (cmd === 'start') {
|
|
581
607
|
const ticks = args.ticks ? parseInt(args.ticks, 10) : Infinity
|
|
582
608
|
const running = schedulerStatus()
|
|
@@ -602,19 +628,34 @@ async function main() {
|
|
|
602
628
|
die(2, `unknown scheduler command "${cmd}" (start|status|stop)`)
|
|
603
629
|
}
|
|
604
630
|
if (group === 'up') {
|
|
631
|
+
const { up } = await import('../src/launcher.mjs')
|
|
605
632
|
const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
|
|
606
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 })
|
|
607
634
|
process.exit(code)
|
|
608
635
|
}
|
|
609
|
-
if (group === 'down') process.exit(await down())
|
|
610
|
-
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()) }
|
|
611
638
|
if (group === 'open') {
|
|
639
|
+
const { openBoard } = await import('../src/launcher.mjs')
|
|
612
640
|
const port = (process.env.LEG_PORT || process.env.BATON_PORT) || 4747
|
|
613
641
|
const url = `http://127.0.0.1:${port}`
|
|
614
642
|
out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
|
|
615
643
|
return
|
|
616
644
|
}
|
|
617
|
-
|
|
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')
|
|
618
659
|
out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
|
|
619
660
|
claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
|
|
620
661
|
the board opens once, the session shows as a card, usage is tracked, a limit hands off
|
|
@@ -633,6 +674,8 @@ async function main() {
|
|
|
633
674
|
history show|continue <id> | refresh | providers
|
|
634
675
|
one conversation, or start leg <agent> on it where the agent can resume by id
|
|
635
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
|
|
636
679
|
resume [--check] [--json] [--path <dir>] the hand-off waiting in this checkout, and whether it is still true
|
|
637
680
|
freshness is recomputed from git at read time; --check prints only the verdict
|
|
638
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.
|
|
@@ -100,7 +108,7 @@ The repository stays private (see the 2026-09-11 license decision). That left ev
|
|
|
100
108
|
## 2026-09-11: Leg is a commercial product; the site stays on the free Vercel address
|
|
101
109
|
|
|
102
110
|
- **License.** Wes: "if we're trying to sell this thing it shouldn't be open source and MIT." The repo stays private and the package ships under the Leg License Agreement (LICENSE): commercial, source readable in the package for inspection and own-use modification, no redistribution, no working around the license check. The FSL option from the pricing research was dropped for the same reason. Versions 0.2.0 and 0.3.0 remain available under MIT.
|
|
103
|
-
- **Pricing.** Personal $79 once with 12 months of releases (Sublime shape); Team $12 per seat per month (adds `leg share`); no trial, and a 30-day money-back guarantee as the risk reversal instead (2026-09-15: a trial suits daily-habit products, and Leg's value is bursty, it pays off in the moment a limit lands, which a fortnight of evaluation does not reliably contain; a buyer already paying for two or three agent subscriptions is not price-sensitive at $79, they are trust-sensitive, and a trial does not answer trust). Keys are Ed25519 tokens signed with a private key that lives only in the seller's `.env` and the site's Vercel env; the public key is in `src/license.mjs`. A Personal key is a window over `RELEASE_DATE`, so every release bumps that constant.
|
|
111
|
+
- **Pricing.** Personal $79 once with 12 months of releases (Sublime shape); Team $12 per seat per month (adds `leg share`); ~~no trial, and a 30-day money-back guarantee as the risk reversal instead~~ superseded 2026-09-18 (2026-09-15: a trial suits daily-habit products, and Leg's value is bursty, it pays off in the moment a limit lands, which a fortnight of evaluation does not reliably contain; a buyer already paying for two or three agent subscriptions is not price-sensitive at $79, they are trust-sensitive, and a trial does not answer trust). 2026-09-18, Wes, ahead of the Hacker News post: the 14-day trial from 0.4.0 is back, started by the first session and recorded in `$LEG_HOME/trial.json` with every gate open, and the 30-day guarantee stays as the second net after buying. A launch audience installs to look, and "buy first" on a repo they found ten minutes ago is a wall the guarantee does not lower. Keys are Ed25519 tokens signed with a private key that lives only in the seller's `.env` and the site's Vercel env; the public key is in `src/license.mjs`. A Personal key is a window over `RELEASE_DATE`, so every release bumps that constant.
|
|
104
112
|
- **Checkout.** Stripe payment links (live) with automatic tax, `site/api/key` and `site/api/webhook` on Vercel functions, Resend from `legcli@practicalsystems.io`. `scripts/stripe-setup.mjs` is idempotent per site origin; a test-mode purchase was run end to end on 2026-09-11 (checkout, thanks page, key activated in the CLI, webhook 200 twice).
|
|
105
113
|
- **Domain.** A `legcli.com` purchase ($11.25) was started and cancelled at Wes's "just deploy it to a free vercel site"; nothing was bought. The site is https://legcli.com/.
|
|
106
114
|
- **Not a lawyer.** The license text was drafted in-session; a review before the first sale outside the US is Wes's call.
|
|
@@ -116,6 +124,7 @@ The repository stays private (see the 2026-09-11 license decision). That left ev
|
|
|
116
124
|
- **Root Directory had to change first.** It was `.`, which is correct when deploying from inside `site/` but would have published the repo root on a git build: no `index.html`, and `api/key` and `api/webhook` gone, so a purchase in flight would not have received its key.
|
|
117
125
|
- ~~**Not every push.** `site/vercel.json` carries `ignoreCommand: git diff --quiet HEAD^ HEAD .`, which Vercel maps to the Ignored Build Step. A commit that touches nothing under `site/` cancels the build. The command failing (a shallow clone with no `HEAD^`) exits non-zero, which builds, so the failure mode is a redundant deploy rather than a missed one.~~ **Superseded 2026-09-15: the failure mode was a MISSED deploy.** `HEAD^ HEAD` compares one commit, not the push. Fast-forwarding five commits to main where only the first touched `site/` left the tip with no `site/` change, so Vercel cancelled the build and the redesigned site never went out while the repo said it had. The `ignoreCommand` is removed: every push to `main` deploys the site. Vercel's own "Skip deployments when there are no changes to the root directory" toggle is the safe version of this idea, because it compares against the last deployment rather than against `HEAD^`.
|
|
118
126
|
- **Agent sessions cannot do this part.** `vercel --prod` and `vercel git connect` are both denied by the harness classifier as production deploys, and the CLI auth token cannot be read. Wes ran both.
|
|
127
|
+
- **2026-09-18: the git deploy did not fire, and the manual fallback has two traps.** The push of 0.13.1 (`19667ba`) produced no `vercel[bot]` deployment on GitHub (commit status stayed `pending`, no statuses), while the pushes an hour earlier had; the account had an overdue invoice at the time, which is the likeliest cause, and the git integration should be re-checked in the Vercel dashboard after a rename (the GitHub repository was renamed since the connection was made; `git remote -v` has the current name and the project still says `baton`). The fallback that works, run from the **repo root**, not `site/`: a root `.vercel/project.json` copied from `site/.vercel/` (gitignored), a root `.vercelignore` of `*`, `!site`, `!site/**`, `site/.vercel`, `site/.env*`, then `vercel --prod --yes --archive=tgz`. Trap one: `vercel --prod --yes` from inside `site/` uploads `site/` as the root, Vercel applies Root Directory `site` on top of it, and the deployment errors with an empty message. Trap two: that same `--yes` auto-linked the repo root to a **new** Vercel project named `leg` (empty, no domain); it is junk and `vercel project rm leg` removes it. Trap three: with no `.vercelignore` the CLI uploads the untracked probe debris too and refuses at 15,000 files.
|
|
119
128
|
|
|
120
129
|
|
|
121
130
|
## 2026-09-15: the board is a dark product surface, and DESIGN.md was the defect
|
package/docs/DEVIATIONS.md
CHANGED
|
@@ -181,3 +181,36 @@ tests. Rows for the shape changes a later reader would otherwise wonder about.
|
|
|
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
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 |
|
|
184
|
+
|
|
185
|
+
## 2026-09-18: product, performance and architecture pass (0.15.0)
|
|
186
|
+
|
|
187
|
+
Measured first (`scratchpad/prof/BASELINE.md`, reproduction commands in the
|
|
188
|
+
CHANGELOG entry), then changed. Rows for the shape changes a later reader
|
|
189
|
+
would otherwise wonder about; the designs not built are in
|
|
190
|
+
`docs/review-2026-09-18.md`.
|
|
191
|
+
|
|
192
|
+
| date | file | old shape | new shape | why |
|
|
193
|
+
|------|------|-----------|-----------|-----|
|
|
194
|
+
| 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 |
|
|
195
|
+
| 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 |
|
|
196
|
+
| 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 |
|
|
197
|
+
| 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 |
|
|
198
|
+
| 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 |
|
|
199
|
+
| 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" |
|
|
200
|
+
| 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 |
|
|
201
|
+
| 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 |
|
|
202
|
+
| 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 |
|
|
203
|
+
| 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 |
|
|
204
|
+
| 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 |
|
|
205
|
+
| 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 |
|
|
206
|
+
| 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 |
|
|
207
|
+
| 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 |
|
|
208
|
+
| 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 |
|
|
209
|
+
| 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 |
|
|
210
|
+
| 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 |
|
|
211
|
+
| 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 |
|
|
212
|
+
| 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 |
|
|
213
|
+
| 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 |
|
|
214
|
+
| 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 |
|
|
215
|
+
| 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 |
|
|
216
|
+
| 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
|
@@ -3,6 +3,28 @@
|
|
|
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-18: 0.14.0 pushed, CI green everywhere, and npm still served 0.13.1
|
|
7
|
+
|
|
8
|
+
**Fixed by putting the `repository` block back into the lockfile root
|
|
9
|
+
(`packages[""]`) and pushing again.** `scripts/npm-publish-gate.mjs` requires
|
|
10
|
+
`package-lock.json`'s root `repository.url` to equal the trusted-publisher
|
|
11
|
+
binding, and npm 10.9 (the local install) does not write that block; npm 11
|
|
12
|
+
does, which is where the 0.13.1 lockfile got it. `npm version 0.14.0` on npm
|
|
13
|
+
10 rewrote the lockfile without it, the test matrix and the site deploy passed,
|
|
14
|
+
and only the `publish-npm` job failed, on the gate, before publishing anything.
|
|
15
|
+
The lesson: a lockfile touched by a different npm major is a release change,
|
|
16
|
+
and "CI green" after a push means the run, not the publish; `npm view
|
|
17
|
+
@ucsandman/legcli version` is the check.
|
|
18
|
+
|
|
19
|
+
## 2026-09-18: `vercel --prod --yes` from `site/` failed the deploy and created a stray Vercel project
|
|
20
|
+
|
|
21
|
+
**Fixed by deploying from the repo root with a root `.vercelignore` and
|
|
22
|
+
`--archive=tgz`; the recipe is in DECISIONS.md under "the site deploys itself
|
|
23
|
+
from git".** The `--yes` also auto-created an empty Vercel project named `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
|
+
Directory set, the CLI must run from the repo root, and `--yes` is a consent to
|
|
26
|
+
create projects, not only to skip a confirmation.
|
|
27
|
+
|
|
6
28
|
## 2026-09-18: the terminal's opening `next:` line named the same agent twice and no model
|
|
7
29
|
|
|
8
30
|
**Fixed in `src/attach.mjs`: the line maps the chain through `rungLabel`, so it
|
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
|