@ucsandman/legcli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +41 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +401 -66
  11. package/docs/cli-contracts.md +199 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +58 -0
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/settings-ladder-1280.png +0 -0
  25. package/docs/screenshots/terminals-1280.png +0 -0
  26. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  27. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  28. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  30. package/fixtures/live/claude/resume-model-probe.json +20 -0
  31. package/fixtures/live/claude/usage-oauth.json +87 -0
  32. package/fixtures/verified.json +1 -1
  33. package/package.json +3 -2
  34. package/scripts/seed-fake-cards.mjs +59 -6
  35. package/scripts/seed-wes-board.mjs +81 -12
  36. package/src/accounts.mjs +6 -1
  37. package/src/attach.mjs +328 -46
  38. package/src/audit.mjs +1 -1
  39. package/src/board/board.css +119 -9
  40. package/src/board/board.js +482 -106
  41. package/src/board/index.html +67 -7
  42. package/src/board/sessions.js +1347 -117
  43. package/src/buckets.mjs +101 -0
  44. package/src/cards.mjs +9 -1
  45. package/src/chain.mjs +13 -0
  46. package/src/hook.mjs +7 -1
  47. package/src/ledger.mjs +10 -2
  48. package/src/orchestrator.mjs +13 -4
  49. package/src/preferences.mjs +214 -5
  50. package/src/scheduler.mjs +24 -1
  51. package/src/server.mjs +546 -47
  52. package/src/sessions.mjs +17 -1
  53. package/src/taps/claude-usage.mjs +91 -2
  54. package/src/taps/claude.mjs +144 -5
  55. package/src/taps/codex.mjs +23 -3
  56. package/src/usage.mjs +407 -12
package/CHANGELOG.md CHANGED
@@ -1,5 +1,147 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 (2026-09-17)
4
+
5
+ The board is rebuilt around the two questions you actually open it to answer:
6
+ which terminal needs you, and which model is about to run out. Usage stops
7
+ being a region and becomes one strip plus a property of every row; a hand-off
8
+ can now change the model instead of the CLI; and background work comes back as
9
+ rows you can read at a glance instead of a ledger you have to dig through.
10
+
11
+ - **The board top: a capacity strip, and the login panels behind one
12
+ disclosure.** Under the verdict, one strip prints each login's *binding*
13
+ bucket rather than whichever window happened to be stored: `claude 63%
14
+ 6:01 PM`, `codex back Sat 10:11 PM`, `agy no figure`, `grok no reading`. The
15
+ four login panels are not rewritten, they move intact behind **Capacity and
16
+ models** with every gauge, notch and `aria-valuetext` they had, and the
17
+ claude panel head grows a model rail whose chips read `fable 63%` and
18
+ `opus 12%`. Every branch of `verdictLines()` is now asserted under
19
+ `VERDICT_CH` by `test/board-verdict.test.mjs`, so a 52px headline can no
20
+ longer wrap to three lines. Measured on the seeded board at 1280: the first
21
+ terminal row sits at 536px, where it used to sit at 1382px.
22
+ - **A terminal row says what it is doing, on which model.** The register reads
23
+ `waiting on you baton on main dirty 3 ahead 2 claude/fable`, and a quiet
24
+ agent carries `quiet 5m`. `ahead` is one `git rev-list --count` on the
25
+ existing poll; the model is the alias the leg resolved to, updated from the
26
+ transcript for claude so a silent fallback off Fable becomes visible, and
27
+ never a guessed default. Under the prompt the row prints its binding bucket,
28
+ `63% of the fable week`, and past the warn threshold the rung that would keep
29
+ the terminal.
30
+ - **A terminal waiting on a human says so, everywhere.** Leg now wires Claude
31
+ Code's `Notification` hook (`permission_prompt`, `idle_prompt`,
32
+ `agent_needs_input`). The row's status word becomes `waiting on you` and it
33
+ carries the question verbatim: `waiting on you: permission to run Bash(git
34
+ push origin HEAD), asked 2m ago`. That sorts the row first, counts it in the
35
+ region head (`4 running, 2 waiting on you, 4 share the claude login`) and in
36
+ the tab, which becomes `(2) Leg` with a dotted favicon. Codex, agy and grok
37
+ publish no such signal, so their rows say `quiet Nm` and never claim to be
38
+ waiting. `permission_prompt` fires after about six seconds and mostly when
39
+ you look away, so this is a reliable notice, not an instant one.
40
+ - **A hand-off can now change the model, not just the CLI.** Destinations are
41
+ rungs of `(agent, account, model)`. The ladder's default is `claude/fable`,
42
+ `claude/opus`, `claude/sonnet`, then each remaining installed agent, and a
43
+ terminal's expansion prints it: `now: claude / fable, then claude / opus,
44
+ then claude / sonnet, then codex, then agy` with `first eligible now:
45
+ claude / opus` under it. A claude downshift with a known session id starts
46
+ `claude --resume <id> --model <alias>` and skips the bundle, so the
47
+ conversation survives the switch; every other rung is primed from the bundle
48
+ as before. `leg ladder` prints and edits the same thing from a terminal
49
+ (`ls`, `set <n> <agent>[/<account>[/<model>]]`, `rm <n>`, `spend on|off`),
50
+ and `leg sessions handoff <id> --to claude/default/opus` names a rung.
51
+ - **The wall is attributed to what it actually walled.** A Fable limit walls
52
+ Fable, not the login: `walls{}` is keyed by model and `limited_until` is left
53
+ alone, so `claude/sonnet` keeps working. A session, weekly or spend limit is
54
+ account scoped, and a same-login model rung is then refused as a wasted
55
+ switch with the reason on the row. Wording Leg cannot parse walls the whole
56
+ login and prints the evidence it failed on rather than guessing.
57
+ - **Nothing spends money unless you said it could.** `may_spend` is off. An
58
+ automatic hand-off skips any rung that bills credits or a metered balance and
59
+ writes the reason to the ledger; the Settings sentence is *A rung that spends
60
+ usage credits or metered balance may be taken by an automatic hand-off*, and
61
+ today it adds *Usage credits are off, so there is nothing to spend through
62
+ the wall.* rather than offering a dead control. A `reserve` per login holds a
63
+ floor back from automatic hand-offs only: *An automatic hand-off skips a rung
64
+ past the floor; a hand-off you press yourself still takes it, and the picker
65
+ says so.* Climbing back is a radio with the rule printed under it: *Leg never
66
+ interrupts a running turn to climb.*
67
+ - **Cards are terminals you are not sitting at.** Live cards are rows in a
68
+ **Background** panel directly under Terminals, in the terminal row's
69
+ register with a measured work stat and the same button grid; a running card
70
+ says `no message until this leg ends, started 8:15 PM`, because `-p
71
+ --output-format json` is mute until the leg exits. Finished cards fall into
72
+ one ledger line, `10 finished cards, 5 done, 5 failed, last 8:15 PM`, so ten
73
+ done cards are one row and not ten. Starting one is a single field,
74
+ `Run in the background:`, over an inferred sentence whose nouns are buttons
75
+ (*in recruiting-tool on main, with claude/fable then claude/opus then
76
+ claude/sonnet then codex then agy, build only*); the old thirteen-field
77
+ dialog is still there as **More settings**.
78
+ - **Two verbs for leaving and coming back.** The End confirm row grows
79
+ **End, and keep going as a card**: it writes the bundle, hands the
80
+ terminal's own worktree to a card with its lineage, and says where the work
81
+ went. On a card, **Take over** pauses it and hands back the one command a
82
+ browser cannot run, `leg claude --resume-card <id>`, in an interactive
83
+ terminal primed from the card's bundle.
84
+ - **A time figure, or nothing, and never a time without its sample count.**
85
+ `burn()` reads a per-bucket history ring and prints a rate only with at least
86
+ three samples spanning ten minutes inside the current window; the estimate is
87
+ the endpoint slope, capped at the reset, and a flat or falling line prints
88
+ nothing at all. Where it can speak, the row and the verdict say *about 2h 40m
89
+ of Fable left, from 9 samples over 4h*. A reading that comes back unchanged
90
+ more than ten minutes after the last sample is still recorded, so a flat hour
91
+ is a measured zero rather than a starved gate.
92
+
93
+ Fixes:
94
+
95
+ - **Fixed: the only live Claude wall Leg ever captured was classified as no
96
+ progress.** The fixture pattern matched `You've hit your Fable limit` and
97
+ Anthropic now writes `You've reached your Fable limit`, so a real wall on the
98
+ headless path scored `{outcome: "no_progress", signal: "none"}` and the
99
+ terminal sat on a dead login. The pattern takes both wordings, and
100
+ `fixtures/limits/claude/claude-fable-limit.json` is a new `observed-live`
101
+ fixture produced from the captured response rather than from the docs.
102
+ - **Fixed: the board printed the wrong percentage for a claude login.** The tap
103
+ kept only `five_hour` and `seven_day` out of the usage response and threw the
104
+ rest away, so a login whose binding bucket was the Fable week was reported at
105
+ the account-wide figure. The whole `limits[]` array is now read into
106
+ `buckets[]`, and the strip, the verdict and the row all print the bucket that
107
+ will actually stop you.
108
+ - **Fixed: a seeded board's rows were live controls on real repositories.**
109
+ `scripts/seed-wes-board.mjs` named real paths, and a click on a seeded row
110
+ cut a worktree in a real checkout. The seed now names `C:\Projects-seed\...`,
111
+ realistic in shape and impossible in fact. `docs/ERRORS.md` carries the
112
+ entry.
113
+
114
+ Migration:
115
+
116
+ - `preferences.json` keeps `handoff_order` and now derives it from the ladder,
117
+ so `validHandoffOrder`, `requireHandoffOrder` and every older terminal keep
118
+ working unchanged. It gains `handoff_ladder` (rungs of `{agent, account,
119
+ model, when, cost}`), `may_spend` (default `false`), `climb_back` (default
120
+ `next-handoff`, or `never`), `reserve` (`{agent: percent}`),
121
+ `notify_terminal` (default `true`) and `notify_board` (default `false`). A
122
+ bare `handoff_order` expands into one `model: null, when: always` rung per
123
+ agent, so behaviour is unchanged until you edit a rung.
124
+ - Usage records under `$LEG_HOME/usage/` gain `buckets`, `walls`, `history`,
125
+ `extra_usage` and `facts`. An older Leg ignores them; a record without them
126
+ falls back to the two windows it has always had.
127
+ - Session records gain `model`, `waiting` and `ahead`, all nullable. On a
128
+ shared board all three are dropped from someone else's row (`waiting` carries
129
+ the verbatim question, `model` and `ahead` describe this machine's usage and
130
+ someone else's work). Your own terminal is never redacted, so you keep all
131
+ three on it whichever role you hold.
132
+ - `playwright` is a dev dependency now, so `scripts/board-shots.mjs` runs from
133
+ a fresh `npm install`. It is not shipped in the package.
134
+
135
+ Still assumed, and marked as such:
136
+
137
+ - Whether `codex resume <id> -m <model>` composes. The `resume` subcommand and
138
+ the `-m` flag are each verified from `codex --help`; putting them together is
139
+ not. A codex rung therefore ships primed from the bundle, and only the claude
140
+ rungs claim to keep the conversation.
141
+ - Whether codex, agy and grok leave an OSC 2 terminal title alone once the
142
+ child starts drawing. That is why naming the terminal tab is not in this
143
+ release: the browser tab badge needs no such assumption, and ships.
144
+
3
145
  ## 0.11.0 (2026-09-17)
4
146
 
5
147
  - **grok is a card adapter, not just a terminal.** `--chain grok` works. Its
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  *Claude hits the five-hour wall. The terminal reads `handing off to codex`, and codex carries on there. Nothing is retyped. ([the full 53-second run](https://legcli.com/#handoff))*
13
13
 
14
- ![The Leg board at 1280px: a headline reading "All 4 terminals are on claude, and claude has 5% left", under it the staleness of the reading; a lit claude panel with its 7 day gauge at 95 percent past the reserve notch and its 5 hour gauge at 38; half panels for codex, at the wall, and agy, which publishes no figure; four terminal rows with their prompts and buttons; and counts for finished terminals, what landed and background tasks](https://legcli.com/img/docs/terminals-1280.png)
14
+ ![The Leg board at 1280px: a headline naming the terminal that has waited on you longest; under it a capacity strip with claude at 63 percent of its fable week, codex back on Saturday, agy with no figure and grok with no reading, and a button that opens the login panels; then four terminal rows, each with its status, repo and branch, uncommitted and unpushed counts, agent and model, the prompt, the one thing worth knowing and four buttons; then a Background panel of live cards and a one-line field for starting another; then counts for finished terminals, what landed, conversations and finished cards](https://legcli.com/img/docs/terminals-1280.png)
15
15
 
16
16
  You keep using your coding agents exactly as you do today, in any terminal,
17
17
  from your own config directory: Leg adds its hooks in a separate per-session
@@ -21,11 +21,16 @@ files and regions). `leg claude --model opus` is
21
21
  `claude --model opus` with four things running alongside it:
22
22
 
23
23
  1. **A board.** Opened once in your browser, reused after that. Every Leg
24
- session in every terminal is a card on it: agent, account, repo and branch,
25
- the task, the files it is touching, its 5h and 7d usage, what has landed on
26
- trunk. Two sessions editing the same file in one repo are flagged on both
27
- cards, and a second session in a checkout that already has one gets its
28
- own worktree and a **Land** button instead of writing over the first.
24
+ session in every terminal is a row on it, and the row's register reads
25
+ status, repo on branch, uncommitted and unpushed counts, agent and model,
26
+ and how long it has been quiet: `waiting on you baton on main dirty 3
27
+ ahead 2 claude/fable`. Under that come the prompt, the one thing worth
28
+ knowing, the files it is touching and the usage bucket that will stop it.
29
+ Capacity is one strip at the top rather than a region, with the login
30
+ panels and their gauges behind **Capacity and models**. Two sessions
31
+ editing the same file in one repo are flagged on both rows, and a second
32
+ session in a checkout that already has one gets its own worktree and a
33
+ **Land** button instead of writing over the first.
29
34
  2. **Usage tracking** per agent and account, from what each CLI already
30
35
  exposes: Claude Code's usage endpoint and its `StopFailure` hook, Codex's
31
36
  read-only app-server rate-limit read, agy's log, and Grok's billing proxy endpoint.
@@ -33,12 +38,18 @@ files and regions). `leg claude --model opus` is
33
38
  refreshed as the session goes, so the work is always ready to hand off.
34
39
  4. **The handoff itself.** Near the limit you get a warning. At the limit Leg
35
40
  saves the bundle, stops the agent, and starts the next option in the same
36
- terminal from that bundle: another login of the same agent if you added
37
- one, otherwise the next agent in the order shown on the terminal card.
38
- The default is claude -> codex -> agy (with grok supported in handoff order),
39
- and Settings changes the default for new terminals. Nothing is retyped.
40
- When every option is out, it tells you which resets first and when, waits
41
- for that reset with a countdown, and starts that agent from the bundle.
41
+ terminal from that bundle. The options are a ladder of rungs, and a rung is
42
+ an agent, a login and a model: the default is `claude/fable`,
43
+ `claude/opus`, `claude/sonnet`, then each remaining installed agent. A
44
+ Fable limit walls Fable and not the login, so the first move is usually to
45
+ another model on the same subscription, and for claude that move is
46
+ `claude --resume <id> --model opus`, which keeps the conversation instead
47
+ of replaying the bundle. Only when every claude rung is out does it move to
48
+ another CLI. Settings holds the ladder for new terminals, `leg ladder`
49
+ edits it from a shell, and **Details** on a row edits the copy that
50
+ terminal is running. Nothing is retyped. When every rung is out, Leg tells
51
+ you which resets first and when, waits for that reset with a countdown, and
52
+ starts that agent from the bundle.
42
53
 
43
54
  Subscription logins only: Leg strips `ANTHROPIC_API_KEY`,
44
55
  `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_CUSTOM_HEADERS`,
@@ -183,21 +194,23 @@ presenting it as current.
183
194
  both are stamped with the commit and the live terminals they describe.
184
195
  `claude "<prompt>"`, `codex "<prompt>"` and `agy -i "<prompt>"` all open the
185
196
  normal interactive session with that first turn.
186
- 5. **Order.** Other accounts of the same agent come first, then every other
187
- agent in the saved order, each tried once. The order is a priority list, not
188
- a rotation: put agy at the bottom and agy is the last option from a Claude
189
- terminal and from a Codex terminal alike. The board shows the exact
190
- sequence with the agent running now skipped, plus the preferred option and
191
- the first option eligible from current install and limit state. Use **Change
192
- order** on a terminal card to change that terminal, or Settings to set the
193
- default copied by new terminals. An option whose CLI is missing or whose
194
- wall has not reset is skipped.
197
+ 5. **The ladder.** A rung is an agent, a login and a model, and Leg walks from
198
+ rung 1 every time. Other models on the same login come first, then other
199
+ accounts of the same agent, then every other agent in the saved order, each
200
+ tried once. It is a priority list, not a rotation: put agy at the bottom and
201
+ agy is the last rung from a Claude terminal and from a Codex terminal alike.
202
+ The board prints the exact sequence with the rung running now marked, plus
203
+ the first rung eligible from current install and limit state. Use **Change
204
+ the ladder** in a terminal's expansion to change that terminal, `leg ladder`
205
+ to do it from a shell, or Settings to set the default copied by new
206
+ terminals. A rung whose CLI is missing, whose wall has not reset, or that
207
+ shares the window that is already out, is skipped with its reason.
195
208
  6. **All out.** The terminal prints each option with its reset time, soonest
196
209
  first, then stays open with a countdown to the first reset and starts that
197
- agent from the bundle when it arrives. The card says `waiting for <agent>
198
- at <time>`. Ctrl-C (or End on the card) quits with exit 3 instead.
210
+ agent from the bundle when it arrives. The row says `waiting for <agent>
211
+ at <time>`. Ctrl-C (or End on the row) quits with exit 3 instead.
199
212
 
200
- You can force a handoff any time: the **Hand off now** button on the card, or
213
+ You can force a handoff any time: the **Hand off now** button on the row, or
201
214
  `leg sessions handoff <id>`. Verified on this machine: `leg claude` opened
202
215
  the real Claude Code TUI with Leg's hooks firing into the session log, the
203
216
  usage poll recorded 36 % of the 5h window and 74 % of the 7d window, the
@@ -216,10 +229,12 @@ row flipped to observed-live (`node scripts/live-limits.mjs`). The same
216
229
  capture is wired for codex `usage_limit_exceeded` and agy `RESOURCE_EXHAUSTED`;
217
230
  no payload for either has been kept yet.
218
231
 
219
- Terminals started by this version can change order while they run. An older
220
- terminal stays on the order it started with; its card says a restart is needed
221
- and can save the desired default for the next launch. A normal agent exit ends
222
- the terminal. It does not trigger a handoff.
232
+ Terminals started by this version can change their ladder while they run:
233
+ Settings holds the default for new terminals, and **Details** on a row edits
234
+ the copy that terminal is running. An older terminal stays on the order it
235
+ started with; its row says a restart is needed and can save the desired
236
+ default for the next launch. A normal agent exit ends the terminal. It does
237
+ not trigger a handoff.
223
238
 
224
239
  ### The working environment travels too (optional)
225
240
 
@@ -350,22 +365,28 @@ only: the trail names repositories and people.
350
365
 
351
366
  `leg <agent>` opens it; `leg open` reopens it; `leg down` stops it.
352
367
 
353
- - **Instrument head**: one row per login, sticky at the top of the board and of
354
- the floor. Each row carries the 5h and 7d rails, the percentage, when that
355
- window resets and how long that is, a burn-rate sentence under the 5h rail,
356
- where and when Leg read the number, and one word for the state: `under 60`,
357
- `over 60`, `over 85`, `stale <n>m`, `at the wall` or `no reading`. A login
358
- at its wall keeps both rails and gains `at the wall`, `back <day time>` and
359
- `in <duration>` beside them.
360
- - **Terminals**: one full-width panel per session, the ones that need an answer
361
- first. Agent and session tail, the status word, the first prompt as a button,
362
- exactly one sentence (the highest-ranked thing true about the terminal), an
363
- `also:` disclosure naming the rest, the files as comma-separated text,
364
- `repo@branch`, `own worktree, from main` when the session cut its own
365
- worktree, and an elapsed clock. Two live sessions in one repo touching the
366
- same file print `codex (codex-99ab) is changing src/server.mjs in another
367
- checkout; whoever lands second rebases` on both panels, and a panel that needs
368
- you rises one step and says `waiting on you` in place of its status word.
368
+ - **Verdict and capacity strip**: the largest sentence on the page, then one
369
+ strip with a token per login carrying the binding bucket, a short track, the
370
+ percentage and one state word or clock: `claude 63% 6:01 PM`, `codex back
371
+ Sat 10:11 PM`, `agy no figure`, `grok no reading`. **Capacity and models**
372
+ opens a drawer holding the full login panels: the 5h and 7d rails, the reset
373
+ and how long that is, where and when Leg read the number, `at the wall` with
374
+ its `back <day time>`, and on the claude panel a rail of model chips
375
+ (`fable 63%`, `opus 12%`, a walled one saying `out until 9:14 PM`). The
376
+ sticky instrument head is still what `/floor` puts at the top.
377
+ - **Terminals**: one full-width row per session, the ones that need an answer
378
+ first. The register reads the status word, `repo on branch`, `dirty <n>` and
379
+ `ahead <n>`, `agent/model`, `quiet <n>m`, and `own worktree, from main` when
380
+ the session cut its own worktree; then the first prompt as a
381
+ button, exactly one sentence (the highest-ranked thing true about the
382
+ terminal), an `also:` disclosure naming the rest, the files as
383
+ comma-separated text, the binding bucket as `63% of the fable week`, and an
384
+ elapsed clock with the session tail. Two live sessions in one repo touching
385
+ the same file print `codex (codex-99ab) is changing src/server.mjs in
386
+ another checkout; whoever lands second rebases` on both rows. A Claude row
387
+ parked at a permission or idle prompt rises to the top, says `waiting on
388
+ you` in place of its status word, prints the question verbatim, and puts a
389
+ count in the browser tab (`(2) Leg`, with a dot on the favicon).
369
390
  After a Land the sentence is `landed on <base>, <7-char sha>, <n> files,
370
391
  +<added>/-<removed>`, or `Land was attempted at <time> onto <base> and
371
392
  bounced: <first line of the reason>. The branch still holds every commit;
@@ -376,21 +397,24 @@ only: the trail names repositories and people.
376
397
  - **Buttons**, in a fixed order that never reflows: Land, Hand off now,
377
398
  Details, End. Once a session has ended, Remove and Remove record take End's
378
399
  place. Details opens an expansion in flow under the panel.
379
- **Hand off now** takes the next option in the order. To name the destination
380
- instead, open Details and use **Hand off now to**, which lists every
381
- destination with the reason a greyed one cannot be picked (`at its usage
382
- limit, back 11:40`, `not installed on this machine`). `leg sessions handoff
383
- <id> --to codex` is the same choice from a terminal. If the destination you
384
- picked walls between the click and the hand-off, the work still continues
385
- down the order and the terminal says which one took it instead.
400
+ **Hand off now** takes the first open rung of the ladder. To name the
401
+ destination instead, open Details and use **Hand off now to**, which lists
402
+ every rung with its model, whether it keeps the conversation, and the reason
403
+ a greyed one cannot be picked (`at its usage limit, back 11:40`, `shares the
404
+ window that is out, buys nothing`, `not installed on this machine`).
405
+ `leg sessions handoff <id> --to claude/default/opus` is the same choice from
406
+ a terminal. If the rung you picked walls between the click and the hand-off,
407
+ the work still continues down the ladder and the terminal says which one
408
+ took it instead.
386
409
  - Below it, **Conversations**: every coding-agent conversation on this machine
387
410
  as a count that opens, the ones Leg started and the ones Claude Code, Codex,
388
411
  Grok, Antigravity and Copilot keep in their own stores. Filter by agent,
389
412
  search, repository, or only what Leg started; a row opens its last messages
390
413
  in place, with the `leg history continue` command to copy where the agent
391
414
  can resume by id. The same drawer lists every checkout Leg can see. Then
392
- optional **Background tasks** an agent runs in a separate worktree without
393
- joining the terminal conversation (see below), then **Settings**.
415
+ **Settings**. Background tasks are not down here: a live card is a row in
416
+ the **Background** panel directly under Terminals (see below), and only the
417
+ finished ones collapse into a ledger line beside these counts.
394
418
 
395
419
  The board reads `~/.leg/sessions/*/session.json` over server-sent events; a
396
420
  session whose runner process is gone is marked `lost`, never shown as live.
@@ -547,17 +571,23 @@ terminal this is when the board is shared), `LEG_TLS_CERT` / `LEG_TLS_KEY`
547
571
 
548
572
  Version 0.1 was the other way round: you dropped a task card on the board and
549
573
  Leg ran the agents headless in a git worktree, one per card, with a fallback
550
- chain, path leases, a scheduler and a merge queue. All of that still works and
551
- lives below the terminals lane, but it is no longer the way in.
552
-
553
- The New background card form starts with a repo, task, and real first agent.
554
- **Run now** queues it; turning that off saves a draft in Backlog. The default
555
- **Build only** workflow stops with its changes in the card's worktree and does
556
- not merge them. The Advanced **Build, test, and merge** and **Factory**
557
- workflows include an automatic land station; their labels say so before you
558
- choose them. Fallback agents, permissions, approval gates, turn caps, leases,
559
- trunk, merge method, tests, title, and scripted test/demo adapters are also
560
- under Advanced options.
574
+ chain, path leases, a scheduler and a merge queue. All of that still runs, and
575
+ a card is now a terminal you are not sitting at: same register, same one
576
+ sentence, same ladder, same bundle. Live cards are rows in the **Background**
577
+ panel directly under Terminals; finished ones fall into one ledger line that
578
+ opens.
579
+
580
+ Starting one is a single field. `Run in the background:` takes the task, and
581
+ the sentence under it is inferred with its nouns as buttons: the repo of the
582
+ terminal you were last in, the saved ladder, and the `build` workflow, which
583
+ stops with its changes in the card's worktree and does not merge them.
584
+ **More settings** is the full form, with the **Build, test, and merge** and
585
+ **Factory** workflows that include an automatic land station, plus fallback
586
+ agents, permissions, approval gates, turn caps, leases, trunk, merge method,
587
+ tests, title, and scripted test and demo adapters. **End, and keep going as a
588
+ card** on a terminal's End row makes a card out of the work in front of you;
589
+ **Take over** on a card gives you the command that turns it back into a
590
+ terminal.
561
591
 
562
592
  - `leg up` boots the board with the scheduler and merge queue and streams
563
593
  redacted logs; `leg card add --repo <path> --task "<t>" --chain claude,codex --queue`
@@ -684,7 +714,7 @@ Leg is commercial software under the [Leg License Agreement](LICENSE).
684
714
  It ships as readable JavaScript so you can see what it does on your machine,
685
715
  and you may modify it for your own use, but not redistribute it or work
686
716
  around the license check. Versions 0.2.0 and 0.3.0 were published under MIT
687
- and remain available. The version in this source tree is 0.8.0; see
717
+ and remain available. The version in this source tree is 0.12.0; see
688
718
  [npm](https://www.npmjs.com/package/legcli) for published versions and
689
719
  [CHANGELOG.md](CHANGELOG.md) for release notes.
690
720
 
package/bin/leg.mjs CHANGED
@@ -20,10 +20,11 @@ import { availableActions } from '../src/chain.mjs'
20
20
  import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
21
21
  import { attach, ensureBoard } from '../src/attach.mjs'
22
22
  import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed, scheme, tlsConfigured, ROLES } from '../src/share.mjs'
23
- import { normalizeHandoffOrder } from '../src/preferences.mjs'
23
+ import { normalizeHandoffOrder, ladderFor, readPreferences, writePreferences } from '../src/preferences.mjs'
24
+ import { MODEL_ALIASES } from '../src/buckets.mjs'
24
25
  import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
25
26
  import { addAccount, removeAccount, listAccountRows, readAccounts, LAYOUT } from '../src/accounts.mjs'
26
- import { listUsage, fmtReset, readUsage, isAvailable, candidates } from '../src/usage.mjs'
27
+ import { listUsage, fmtReset, readUsage, isAvailable, candidates, binding, wallActive, evaluateLadder, rungLabel } from '../src/usage.mjs'
27
28
  import { home } from '../src/store.mjs'
28
29
  import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
29
30
  import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
@@ -73,13 +74,13 @@ async function cardAdd(args) {
73
74
  // start the next option in the same terminal. The payload is marked
74
75
  // simulated: it is never kept as live evidence, and the wall it records
75
76
  // clears after two minutes. codex has no Leg-owned input, so it is refused.
76
- function simulateLimit(s) {
77
+ function simulateLimit(s, { message = null } = {}) {
77
78
  if (!isActive(s)) die(3, `session ${s.session_id} is not active`)
78
79
  if (['limit', 'handing_off'].includes(s.status)) die(3, `session ${s.session_id} is already ${s.status}`)
79
80
  if (s.agent === 'claude') {
80
81
  const payload = {
81
82
  hook_event_name: 'StopFailure', error: 'rate_limit', session_id: s.agent_session_id ?? undefined, transcript_path: s.transcript_path ?? undefined,
82
- last_assistant_message: 'API Error: Rate limit reached (simulated by leg sessions simulate-limit)', leg_simulated: true, baton_simulated: true,
83
+ last_assistant_message: message ?? 'API Error: Rate limit reached (simulated by leg sessions simulate-limit)', leg_simulated: true, baton_simulated: true,
83
84
  }
84
85
  const r = spawnSync(process.execPath, [join(SRC, 'hook.mjs'), 'claude-hook', '--session', s.session_id], { input: JSON.stringify(payload), windowsHide: true, encoding: 'utf8', timeout: 15000 })
85
86
  if (r.status !== 0) die(1, `hook exited ${r.status}: ${(r.stderr || '').slice(0, 300)}`)
@@ -100,6 +101,96 @@ function simulateLimit(s) {
100
101
  die(2, `simulate-limit drives the claude hook path (and the agy/grok log); codex's wall comes from its own rollout file, which Leg never writes. Use "leg sessions handoff ${s.session_id}" to force the switch.`)
101
102
  }
102
103
 
104
+ // `claude`, `claude/work`, `claude/opus`, `claude/work/opus`. Three parts are
105
+ // unambiguous. Two are not, so the second is read as an account when that
106
+ // account exists and as a model when the agent has one by that name; a word
107
+ // that is neither is refused by name rather than guessed at.
108
+ export function parseTarget(value, { die: fail = (code, msg) => { throw new Error(msg) } } = {}) {
109
+ const parts = String(value).split('/').filter(Boolean)
110
+ const agent = parts[0]
111
+ if (!agent) fail(2, 'usage: --to <agent>[/<account>[/<model>]]')
112
+ const models = MODEL_ALIASES[agent] ?? []
113
+ if (parts.length >= 3) return { agent, account: parts[1], model: parts[2].toLowerCase() }
114
+ if (parts.length === 2) {
115
+ const second = parts[1]
116
+ const accounts = readAccounts()[agent] ?? ['default']
117
+ if (accounts.includes(second)) return { agent, account: second, model: null }
118
+ if (models.includes(second.toLowerCase())) return { agent, account: 'default', model: second.toLowerCase() }
119
+ fail(2, `"${second}" is neither a ${agent} account (${accounts.join(', ')}) nor a ${agent} model (${models.join(', ') || 'none known'})`)
120
+ }
121
+ return { agent, account: 'default', model: null }
122
+ }
123
+
124
+ // What a rung is doing right now, in the words the board uses: the wall and its
125
+ // clock, else the percentage of the bucket that binds it, else "no figure".
126
+ // Never a guess: an agent that publishes no number says so.
127
+ function rungState(rung) {
128
+ const u = readUsage(rung.agent, rung.account)
129
+ const wall = rung.model ? u.walls?.[rung.model] : null
130
+ if (wall && wallActive(wall)) return `${rung.model} out until ${fmtReset(wall.limited_until)}`
131
+ if (!isAvailable(u)) return `at its limit until ${fmtReset(u.limited_until)}`
132
+ const b = binding(u, rung.model ?? null)
133
+ if (b && Number.isFinite(b.percent)) return `${Math.round(b.percent)}% of the ${b.model ? b.model + ' ' : ''}${b.kind === 'session' || b.kind === 'five_hour' ? '5h' : 'week'} window`
134
+ return 'no figure'
135
+ }
136
+
137
+ function printLadder() {
138
+ const prefs = readPreferences()
139
+ const ladder = prefs.handoff_ladder
140
+ const rows = evaluateLadder({ from: null, list: ladder, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: true, climbBack: prefs.climb_back, ladder })
141
+ out('The ladder a terminal falls down when its login stops. Rung 1 first, every time.')
142
+ ladder.forEach((rung, i) => {
143
+ const r = rows[i]
144
+ const when = rung.when === 'always' ? '' : ` when ${rung.when}`
145
+ out(` ${String(i + 1).padEnd(2)} ${rungLabel(rung).padEnd(20)} ${rungState(rung).padEnd(34)} ${r.ok ? 'ready' : r.reason}${when}`)
146
+ })
147
+ out('')
148
+ out(`spending: ${prefs.may_spend ? 'on (a credits or metered rung may be taken unattended)' : 'off (a credits or metered rung is skipped unattended)'} · leg ladder spend on|off`)
149
+ out(`climb back: ${prefs.climb_back === 'never' ? 'never (stay on the lower rung until you press Back)' : 'at the next hand-off'}`)
150
+ const reserve = Object.entries(prefs.reserve ?? {})
151
+ out(`reserve: ${reserve.length ? reserve.map(([a, p]) => `${a} ${p}%`).join(', ') : 'none'}`)
152
+ out(`order (what older readers see): ${prefs.handoff_order.join(' → ')}`)
153
+ }
154
+
155
+ function ladderCommand(cmd, args) {
156
+ if (!cmd || cmd === 'ls' || cmd === 'show') return printLadder()
157
+ const prefs = readPreferences()
158
+ const ladder = prefs.handoff_ladder.map((r) => ({ ...r }))
159
+ if (cmd === 'set') {
160
+ const [nRaw, target] = args._
161
+ const n = parseInt(nRaw, 10)
162
+ if (!Number.isFinite(n) || n < 1) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
163
+ if (!target) die(2, 'usage: leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]')
164
+ const want = parseTarget(target, { die })
165
+ const rung = { ...want, when: typeof args.when === 'string' ? args.when : 'always' }
166
+ const at = Math.min(n, ladder.length + 1) - 1
167
+ ladder[at] = rung
168
+ try {
169
+ const saved = writePreferences({ handoff_ladder: ladder })
170
+ out(`rung ${at + 1} is ${rungLabel(saved.handoff_ladder[at])}${rung.when !== 'always' ? `, when ${rung.when}` : ''}`)
171
+ } catch (err) { die(2, err.message) }
172
+ return printLadder()
173
+ }
174
+ if (cmd === 'rm') {
175
+ const n = parseInt(args._[0], 10)
176
+ if (!Number.isFinite(n) || n < 1 || n > ladder.length) die(2, `usage: leg ladder rm <n> (1..${ladder.length})`)
177
+ if (ladder.length === 1) die(2, 'that is the only rung left: a ladder with no rungs has nowhere to hand off to')
178
+ const [gone] = ladder.splice(n - 1, 1)
179
+ try { writePreferences({ handoff_ladder: ladder }) } catch (err) { die(2, err.message) }
180
+ out(`removed rung ${n}: ${rungLabel(gone)}`)
181
+ return printLadder()
182
+ }
183
+ if (cmd === 'spend') {
184
+ const v = args._[0]
185
+ if (!['on', 'off'].includes(v)) die(2, 'usage: leg ladder spend on|off')
186
+ const saved = writePreferences({ may_spend: v === 'on' })
187
+ return out(saved.may_spend
188
+ ? 'spending is ON: an unattended hand-off may take a rung that bills credits.'
189
+ : 'spending is OFF: an unattended hand-off skips any rung that bills credits, and says so in the ledger.')
190
+ }
191
+ die(2, `unknown ladder command "${cmd}" (ls|set <n> <agent>[/<account>[/<model>]]|rm <n>|spend on|off)`)
192
+ }
193
+
103
194
  function fmtCard(c) {
104
195
  const st = c.pipeline?.find((s) => s.name === c.station)
105
196
  const leg = st?.kind === 'agent' ? ` leg ${c.leg}/${st.chain.length} (${st.chain[c.leg]?.adapter ?? '-'})` : ''
@@ -172,16 +263,19 @@ async function main() {
172
263
  // is not a destination, is not installed, or is at its wall must be
173
264
  // refused now, not silently turn into "whatever is next".
174
265
  if (typeof args.to === 'string') {
175
- const [wantAgent, wantAccount = 'default'] = args.to.split('/')
266
+ const want = parseTarget(args.to, { die })
176
267
  const order = normalizeHandoffOrder(s.handoff_order)
177
- const chain = candidates({ agent: s.agent, account: s.account, accounts: readAccounts(), order })
178
- const hit = chain.find((c) => c.agent === wantAgent && c.account === wantAccount)
179
- const label = `${wantAgent}${wantAccount !== 'default' ? '/' + wantAccount : ''}`
180
- if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(', ') || 'none'})`)
181
- if (s.installed && s.installed[wantAgent] === false) die(3, `${label} is not installed on this machine`)
182
- const u = readUsage(wantAgent, wantAccount)
268
+ const ladder = ladderFor(s)
269
+ const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: readAccounts(), order, ladder })
270
+ const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
271
+ const label = rungLabel(want)
272
+ if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})`)
273
+ if (s.installed && s.installed[want.agent] === false) die(3, `${label} is not installed on this machine`)
274
+ const u = readUsage(want.agent, want.account)
183
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`)
184
- requestControl(id, { handoff: true, target: hit })
276
+ if (hit.model && wallActive(u.walls?.[hit.model])) die(3, `${label} is out until ${fmtReset(u.walls[hit.model].limited_until)}; pick another rung, or drop --to to take the next open one`)
277
+ const target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
278
+ requestControl(id, { handoff: true, target })
185
279
  return out(`handoff to ${label} requested for ${id}`)
186
280
  }
187
281
  requestControl(id, { handoff: true })
@@ -205,9 +299,18 @@ async function main() {
205
299
  }
206
300
  removeSession(id); return out(`removed ${id}`)
207
301
  }
208
- if (cmd === 'simulate-limit') return simulateLimit(s)
302
+ // --message drives a particular wording through the real classifier, which
303
+ // is the only way to reach a per-model wall without waiting for one:
304
+ // --message "You've reached your Fable limit." walls fable and leaves the
305
+ // rest of the login open (src/buckets.mjs).
306
+ if (cmd === 'simulate-limit') return simulateLimit(s, { message: typeof args.message === 'string' ? args.message : null })
209
307
  die(2, `unknown sessions command "${cmd}" (ls|show|events|handoff|end|rm|simulate-limit)`)
210
308
  }
309
+ if (group === 'ladder') {
310
+ // The fallback ladder, in the terminal: the same rungs, the same live
311
+ // state and the same skip reasons the board's picker shows.
312
+ return ladderCommand(cmd, args)
313
+ }
211
314
  if (group === 'resume') {
212
315
  // The read side of the pointer. Freshness is never read out of the file:
213
316
  // it is recomputed from git here, now, so a resume file cannot describe a
@@ -511,13 +614,19 @@ async function main() {
511
614
  out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
512
615
  return
513
616
  }
514
- if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
617
+ if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|ladder|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
515
618
  out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
516
619
  claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
517
620
  the board opens once, the session shows as a card, usage is tracked, a limit hands off
518
621
  a second live session in one checkout gets its own worktree (--no-worktree to share)
519
622
  auto-approve mode (--no-auto-approve to opt out)
623
+ --resume-card <id> takes over a background card: this terminal opens in that card's
624
+ worktree, primed from its bundle (Take over on the board pauses it and prints this)
520
625
  sessions ls|show|events|handoff|end|rm|simulate-limit <id>
626
+ handoff --to <agent>[/<account>[/<model>]] names the rung; simulate-limit --message "<text>"
627
+ ladder [ls] the fallback ladder: every rung, what it costs, and what it is doing right now
628
+ ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only]
629
+ ladder rm <n> | ladder spend on|off
521
630
  history [ls] [--provider p] [--repo r] [--search q] [--json]
522
631
  every conversation on this machine: Leg's own, and the ones Claude Code, Codex,
523
632
  Grok, Antigravity and Copilot keep in their own stores (read only, nothing moved)
package/docs/DECISIONS.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  Durable product and design decisions that the code does not explain on its own. One entry per decision, newest first.
4
4
 
5
+ ## 2026-09-17: a hand-off destination is a rung of (agent, account, model), and usage is a property of a row
6
+
7
+ - **What.** `preferences.json` keeps `handoff_order` and derives it from a new `handoff_ladder` of rungs, each `{agent, account, model, when, cost}`. `chooseNext` walks the ladder from rung 1 every time. `spawnSpec` pushes the rung's model flag, so the interactive path finally carries `--model` the way every headless adapter already did. A claude downshift with a known session id starts `claude --resume <id> --model <alias>` and skips the bundle; every other rung is primed from the bundle. Usage stops being a region on the board: the binding bucket is one strip token at the top and one phrase on each row, and the four login panels move intact behind a **Capacity and models** disclosure. Live cards become rows in a **Background** panel directly under Terminals; finished cards collapse into one ledger line.
8
+ - **Why a model is part of the destination.** Anthropic's session and weekly limits are shared across every model, and the Opus and Sonnet limits are family scoped: switching outside that family keeps you working (costs doc, line 132). So the cheapest hand-off available is usually not another CLI at all, it is another model on the subscription you already pay for, and until now Leg could not express it. Making the model part of the destination is what lets a Fable wall be a wall on Fable rather than on claude.
9
+ - **Why downshift by `--resume` and upshift by bundle.** A same-login model change can hold the conversation, and holding it is worth more than anything the bundle carries, because the bundle is a summary and the transcript is the thing itself. `--resume` and `--model` are each verified in `claude --help`; composing them was probed and recorded at `fixtures/live/claude/resume-model-probe.json`, which is also where the cost of the other direction is written down: the resumed context is re-read at the new model's rates, so an upshift pays for that re-read at the higher price. Every other rung crosses a CLI boundary where no shared conversation exists, so it takes the bundle, which is the mechanism Leg already had. `codex resume <id> -m <model>` is the same shape and is not probed, so codex rungs take the bundle and do not claim to keep anything.
10
+ - **Why `may_spend` is off by default.** An interactive Fable-on-credits request shows a consent prompt; with nobody at the terminal that prompt is held five minutes and the turn then ends; in `-p` mode, which is how every card runs, Claude Code never shows it and bills without asking (model-config doc, lines 68, 72 and 77). A stalled turn and a silent charge are both worse than a skipped rung, so an automatic hand-off refuses a `credits` or `metered` rung until a human says otherwise, and writes the reason to the ledger. A hand-off a human presses is not automatic and is not gated.
11
+ - **Why usage became a strip and a row property.** On the seeded board the first terminal row sat at 1382px: about a thousand pixels of gauges before the thing the board is for. The gauges are not deleted, they move behind a disclosure, because every notch, degrade path and `aria-valuetext` in them is earned. What replaces them at the top is smaller and says more: the *binding* bucket per login rather than whichever window happened to be stored. The same figure on a row is per model and therefore real; the shared-login caveat is said once, at the region head, where adding three rows' figures together is stopped.
12
+ - **Why cards live under Terminals.** A card is a terminal you are not sitting at: same register, same one sentence, same ladder, same bundle. Putting live cards in a drawer while live terminals are rows made the same object two shapes and taught nobody anything. Liveness, not kind, decides the surface, so ten finished cards are one ledger line and one running card is a row. The honest differences are printed rather than hidden: a `-p` leg is mute until it exits, so its sentence says so.
13
+ - **What this rules out.** A number for agy (it publishes none), dollars for a subscription session (no transcript carries a cost field), and per-terminal attribution of a shared login (nothing publishes it).
14
+
5
15
  ## 2026-09-16: the portable harness is an opt-in subsystem over a vendored, hash-pinned engine
6
16
 
7
17
  - **What.** `leg harness` carries the source agent's working environment (rules, identity, hooks, skills, subagents, commands, MCP servers, permissions) to the agent a hand-off lands on. The capture, neutral bundle and apply engine is the Agnostic AI port engine (MIT), embedded byte for byte under `src/harness/vendor/agnostic-ai/` and driven through its library entry; Leg owns consent, policy, the client registry, state, the fingerprint, the trail and the hand-off decision (`src/harness/*.mjs`).