@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.
- package/CHANGELOG.md +142 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +41 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +199 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +58 -0
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/verified.json +1 -1
- package/package.json +3 -2
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +328 -46
- package/src/audit.mjs +1 -1
- package/src/board/board.css +119 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +67 -7
- package/src/board/sessions.js +1347 -117
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +546 -47
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- 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
|
-

|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
and
|
|
40
|
-
|
|
41
|
-
|
|
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. **
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
198
|
-
at <time>`. Ctrl-C (or End on the
|
|
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
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
- **
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
`
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
`
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
380
|
-
instead, open Details and use **Hand off now to**, which lists
|
|
381
|
-
|
|
382
|
-
limit, back 11:40`, `
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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.
|
|
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
|
|
266
|
+
const want = parseTarget(args.to, { die })
|
|
176
267
|
const order = normalizeHandoffOrder(s.handoff_order)
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`).
|