@ucsandman/legcli 0.14.0 → 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 CHANGED
@@ -1,5 +1,104 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 (2026-09-18)
4
+
5
+ A second login keeps the conversation, and the runtime does less work per
6
+ minute.
7
+
8
+ - **A hand-off to your other claude login keeps the conversation.** `leg
9
+ accounts add claude <name>` now junctions Claude Code's `projects` store
10
+ into the account beside the harness directories, so the second login sees
11
+ the same conversations and the same auto-memory as the first. At a weekly
12
+ or Fable wall the terminal moves to that login with `claude --resume <id>`
13
+ under its `CLAUDE_CONFIG_DIR` and no bundle prompt, and the timeline says
14
+ `claude/fable → claude/work (kept the conversation)`. One rule decides it
15
+ for the terminal and for the board's picker (`keepsConversation` in
16
+ `src/usage.mjs`): a same-login downshift, or another login whose home can
17
+ see the transcript file, checked at the switch. An account made by an
18
+ older Leg gets the junction the next time it starts. `leg history` lists a
19
+ junctioned store once. codex still takes the bundle across logins.
20
+ - **`leg digest`: what happened while you were away.** The default window is
21
+ 8 hours (`--since 2d`, `--since 30m`, an ISO time; `--json`; `GET
22
+ /api/digest?since=` on the board, owner only). The first line is the volume
23
+ it was read from, then what needs you (a live terminal waiting on a
24
+ question, a card parked for a human, a failed card, a lost terminal, in
25
+ that order), then one block per repository with every terminal, card and
26
+ landing that moved and the events worth a line, and last the walls
27
+ standing now. Read only, over the records already on disk; a window with
28
+ nothing in it says so with its counts.
29
+ - **The runtime does less work per minute, measured before and after.** A
30
+ profiler (`fs` and `child_process` counters plus `--cpu-prof`) sat on every
31
+ path first; the numbers are medians on one Windows 11 box, Node 24, with a
32
+ fake agent, an isolated `LEG_HOME` and a board on 4800–4899, and every
33
+ change carries a test that was seen failing without it.
34
+ - `leg --version` and every other command loaded 68 modules before `main()`
35
+ ran. `bin/leg.mjs` now imports each command group inside its own branch,
36
+ `src/limits.mjs` loads its fixture tree on first use, and the launcher
37
+ asks a new leaf (`src/scheduler-status.mjs`) whether the scheduler runs
38
+ instead of pulling in the orchestrator. `leg --version`: 127 → 52 ms
39
+ wall, 79 → 0 ms CPU after Node's own boot, 254 → 4 fs calls, 68 → 1
40
+ project module; `leg sessions ls` 116 → 75 ms; `leg card ls` 117 → 70 ms
41
+ (`test/cli-lazy.test.mjs`).
42
+ - An idle terminal spawned 59.5 git processes a minute (six per poll round)
43
+ and blocked its own event loop 4.4–10.3 s of every minute doing it. One
44
+ `git status --porcelain=v2 --branch` (`src/git.mjs`) now carries the
45
+ head, the branch, the dirty list and the upstream's own ahead count:
46
+ 10.9 git processes a minute, 2.1 s of git wall time, terminal CPU 1.7 →
47
+ 0.1 s a minute (`test/git-status.test.mjs`, `test/attach-perf.test.mjs`).
48
+ - The first `leg <agent>` of the day waited for the board it had just
49
+ started (polling `/api/health` for about a second) before the agent got
50
+ its first instruction. The agent starts at once and the wait runs behind
51
+ it; a session that exits within a second still claims the board's
52
+ pidfile before it goes. Time to the agent on that launch: 1,220 → 261 ms.
53
+ - The two-minute bundle checkpoint ran the python CLI synchronously inside
54
+ the poll tick, freezing limit detection and every board button for as
55
+ long as it took (up to its 120 s timeout). It runs beside the tick now,
56
+ one at a time, and the hand-off save waits for a checkpoint still
57
+ writing.
58
+ - `installedAgents()` ran `<agent> --version` for every agent on `PATH` at
59
+ every launch (an 8 s budget each); the answer is kept for a day in
60
+ `$LEG_HOME/installed.json`, keyed by the resolved bin. A `LEG_<AGENT>_BIN`
61
+ override is never cached.
62
+ - An idle board with 43 terminals and one page open rebuilt the whole
63
+ sessions view every 10 s whether or not a byte had changed: 7,294 fs
64
+ calls, 8 git processes and 1.1 CPU seconds a minute for nothing. The
65
+ health tick now asks the same stat fingerprint the watcher asks, runs the
66
+ liveness pass itself (a runner that died moves no file), and pushes only
67
+ when the answer changed; a card that starts or stops waiting on a human
68
+ forces the one push the fingerprint cannot see. Idle: 7,294 → 4,824 fs
69
+ calls, 8.2 → 0 git processes, 1.14 → 0.54 CPU seconds and 6 → 0 sessions
70
+ pushes a minute (what remains is the liveness pass and the scheduler's
71
+ own tick). One
72
+ sessions view also read the usage files 143 times (once per rung per
73
+ terminal, then again for capacity and the accounts panel) and probed three
74
+ paths per terminal for a synthesis file; one reader per view and one
75
+ `readdir` per checkout: `/api/sessions` 53 → 34 ms, 511 → 212 fs calls per
76
+ answer, and each row drops seven runner-bookkeeping fields nothing on the
77
+ board read (8% off every push; `GET /api/sessions/<id>` keeps the whole
78
+ record).
79
+ - A cold `/api/worktrees` ran up to forty git processes on the board's one
80
+ event loop: 9–25 s in which no stylesheet, click or SSE frame was served.
81
+ The list is gathered through `execFile`, four at a time, one refresh per
82
+ query, and a caller that arrives while a refresh runs gets the last list.
83
+ Worst `/api/health` during a cold call: 11.8 → 3.2 s; the rest is the
84
+ history index's synchronous `listHistory`, still open. `/api/trunk`
85
+ (polled every 2 s by the floor) is cached 15 s and cleared by any card
86
+ change or landing: 27.5 → 0.5 ms. A terminal's detail drawer ran one `git
87
+ ls-files` per file it touched every 3 s; one process for the list: 1,247 →
88
+ 305 ms.
89
+ - `updateSession` takes the lock budget `run.json` already had (250 tries, a
90
+ 10 s steal) instead of running unlocked after 1.2 s; `control.json` is
91
+ cleared under its lock at exit; `src/handoff.mjs` takes `scrub` from
92
+ `src/redact.mjs` instead of the card runner; the 12-leg stop names the
93
+ bundle and `leg resume` instead of promising a hand-off a fresh launch
94
+ never performed.
95
+ - **The README's hand-off step 3 said one bundle per leg.** The code has
96
+ written one bundle per session, updated in place, since 0.2.0; the sentence
97
+ now says so. `docs/concepts.md` and the `src/sessions.mjs` header also said
98
+ the runner was the only writer of `session.json`; the usage poller, the
99
+ claude hooks and a board action patch it too, under one lock, and both now
100
+ say that.
101
+
3
102
  ## 0.14.0 (2026-09-18)
4
103
 
5
104
  The 14-day trial is back.
package/README.md CHANGED
@@ -179,12 +179,20 @@ presenting it as current.
179
179
  3. **Bundle.** Leg writes structured notes (task, the last messages from the
180
180
  transcript, `git diff --stat`, dirty files, files edited this session, recent
181
181
  commits, why it stopped) and runs `context-handoff-bundle save --repo-local`
182
- with one slug per leg; each save writes its own timestamped bundle next to
183
- the last one. A checkpoint of the same bundle is taken every two minutes
184
- while the session is active. If the session maintained `.leg/SYNTHESIS-<session-id>.md`,
182
+ with one slug per session, updated in place (`--update <slug>`) at every
183
+ checkpoint, about every two minutes while the session is active, and at
184
+ every warning, limit and hand-off. If the session maintained `.leg/SYNTHESIS-<session-id>.md`,
185
185
  Leg inlines it into the resume file as a `## Synthesis` section ahead of the raw dump.
186
- 4. **Switch.** The agent process is stopped, the terminal is restored, and the
187
- next option starts in the same terminal with a short pointer prompt:
186
+ 4. **Switch.** The agent process is stopped and the terminal is restored. Two
187
+ claude destinations keep the conversation itself instead of the bundle: a
188
+ weaker model on the same login (`claude --resume <id> --model <alias>`),
189
+ and another claude login you added with `leg accounts add`, which sees the
190
+ same transcript through the `projects` junction the account carries
191
+ (`claude --resume <id>` under that login's `CLAUDE_CONFIG_DIR`). A weekly
192
+ or Fable wall on one login then continues on the other with the
193
+ conversation it already had, and the timeline says `kept the
194
+ conversation`. Every other rung starts in the same terminal with a short
195
+ pointer prompt:
188
196
  read `.leg/RESUME-<session-id>.md` (the `context-handoff-bundle load`
189
197
  output, the `## Synthesis` section if present, and the reason for the switch),
190
198
  check `git status` and `git diff`, continue, do not ask the human to restate
@@ -361,6 +369,23 @@ it prints how much it read beside the answer (`14 terminals and 3 cards, 812
361
369
  events read`) so an empty trail cannot be mistaken for a quiet week. Owner
362
370
  only: the trail names repositories and people.
363
371
 
372
+ ### What happened while you were away
373
+
374
+ `leg digest` (default window 8 hours; `--since 2d`, `--since 30m`, or an ISO
375
+ time; `--json` for the record; `GET /api/digest?since=` on the board, owner
376
+ only) is the trail read the other way round: grouped by repository, what
377
+ needs you first. The first line is the volume it was read from (`3
378
+ terminals, 2 cards, 1 landing, 412 events read (66 sessions and 9 cards on
379
+ disk)`), then `needs you`: a live terminal waiting on a question, a card
380
+ parked for a human, a card that failed, a terminal that was lost, in that
381
+ order and each with how long ago. Then one block per repository: every
382
+ terminal that moved in the window with its login and model, state, turns,
383
+ files, commits ahead and the events worth a line (a wall, a hand-off and
384
+ whether it kept the conversation, an all-out wait, the end), every card with
385
+ its last event, every landing with who pressed Land, and last the walls
386
+ standing right now with their reset times. Nothing new is recorded; a window
387
+ with nothing in it says so with its counts.
388
+
364
389
  ## The board
365
390
 
366
391
  `leg <agent>` opens it; `leg open` reopens it; `leg down` stops it.
@@ -445,19 +470,30 @@ matrix (which agents list, show messages, continue) and every file read are in
445
470
 
446
471
  Optional. `leg accounts add claude work` creates
447
472
  `~/.leg/accounts/claude/work`, junctions your `hooks`, `skills`, `agents`,
448
- `commands`, `plugins`, `rules`, `scripts`, `output-styles` and `tools` into it,
449
- copies `settings.json`, `CLAUDE.md` and the status-line scripts (refreshed from
450
- your real `~/.claude` before every launch), and prints one line to paste:
473
+ `commands`, `plugins`, `rules`, `scripts`, `output-styles`, `tools` and
474
+ `projects` into it, copies `settings.json`, `CLAUDE.md` and the status-line
475
+ scripts (refreshed from your real `~/.claude` before every launch), and prints
476
+ one line to paste:
451
477
 
452
478
  ```
453
479
  $env:CLAUDE_CONFIG_DIR='C:\Users\you\.leg\accounts\claude\work'; claude auth login
454
480
  ```
455
481
 
482
+ `projects` is Claude Code's conversation store, so the second login sees the
483
+ same conversations and the same auto-memory as the first, and a hand-off from
484
+ one login to the other keeps the conversation: the terminal moves to `work`
485
+ with `claude --resume <id>` and no bundle prompt. That is the whole point of a
486
+ second 20x login when the Fable or the weekly window on the first one is out.
487
+ An account made by an older Leg gets the junction the next time it starts.
488
+ Claude Code writes that directory; Leg only reads it.
489
+
456
490
  Same for codex (`CODEX_HOME`; `config.toml`, `AGENTS.md`, `skills`, `prompts`,
457
- `rules`, `plugins`, `agents`, `hooks`, `memories` shared). agy 1.2.0 has no
458
- config-directory override, so it stays one account. Only the login lives in
459
- the account directory; `leg accounts rm` removes the junctions and the
460
- directory and never touches your real home.
491
+ `rules`, `plugins`, `agents`, `hooks`, `memories` shared; a codex hand-off
492
+ still takes the bundle, because `codex resume` under a second `CODEX_HOME`
493
+ has not been observed). agy 1.2.0 has no config-directory override, so it
494
+ stays one account. Only the login lives in the account directory;
495
+ `leg accounts rm` removes the junctions and the directory and never touches
496
+ your real home.
461
497
 
462
498
  The terms, as published (effective dates below):
463
499
 
@@ -537,6 +573,8 @@ leg history [ls] [--provider p] [--repo r] [--search q] [--managed|--external] [
537
573
  every conversation on this machine, Leg's own and the agents' own (read only)
538
574
  leg history show <id> [--messages n] [--json] | continue <id> [agent args…] | refresh [--full] | providers
539
575
  leg worktrees [--repo <path>] [--no-dirty] [--json] every checkout: git's, Leg's, the conversations' (read only)
576
+ leg digest [--since 8h|2d|<iso>] [--json] what happened while you were away: needs-you first, then every
577
+ terminal, card, landing and wall in the window, by repository (read only)
540
578
  leg accounts ls logins and their 5h/7d usage
541
579
  leg accounts add <claude|codex|grok> <name> | rm <agent> <name> | terms
542
580
  leg harness status|inspect|check|explain|history [--json] the portable harness, read-only
@@ -714,7 +752,7 @@ Leg is commercial software under the [Leg License Agreement](LICENSE).
714
752
  It ships as readable JavaScript so you can see what it does on your machine,
715
753
  and you may modify it for your own use, but not redistribute it or work
716
754
  around the license check. Versions 0.2.0 and 0.3.0 were published under MIT
717
- and remain available. The version in this source tree is 0.14.0; see
755
+ and remain available. The version in this source tree is 0.15.0; see
718
756
  [npm](https://www.npmjs.com/package/legcli) for published versions and
719
757
  [CHANGELOG.md](CHANGELOG.md) for release notes.
720
758
 
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
- function simulateLimit(s, { message = null } = {}) {
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
- export function parseTarget(value, { die: fail = (code, msg) => { throw new Error(msg) } } = {}) {
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
- function rungState(rung) {
128
- const u = readUsage(rung.agent, rung.account)
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
- function printLadder() {
138
- const prefs = readPreferences()
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
- if (!cmd || cmd === 'ls' || cmd === 'show') return printLadder()
157
- const prefs = readPreferences()
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 want = parseTarget(args.to, { die })
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
- if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|ladder|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
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