@ucsandman/legcli 0.10.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 (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  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 +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
@@ -65,6 +65,64 @@ To opt out and keep standard approval prompts:
65
65
  2. Environment variable: set `LEG_AUTO_APPROVE=0` or `LEG_NO_AUTO_APPROVE=1` (or legacy `BATON_AUTO_APPROVE=0` / `BATON_NO_AUTO_APPROVE=1`).
66
66
  3. Persistent preference: set `"auto_approve": false` in `~/.leg/preferences.json`.
67
67
 
68
+ ### The hand-off ladder
69
+
70
+ `~/.leg/preferences.json` also holds the ladder a terminal falls down when its
71
+ login stops (`src/preferences.mjs`), alongside the older `handoff_order`:
72
+
73
+ ```json
74
+ {
75
+ "handoff_order": ["claude", "codex", "agy"],
76
+ "handoff_ladder": [
77
+ { "agent": "claude", "account": "default", "model": "fable", "when": "always", "cost": "plan" },
78
+ { "agent": "claude", "account": "default", "model": "opus", "when": "always", "cost": "plan" },
79
+ { "agent": "claude", "account": "default", "model": "sonnet", "when": "always", "cost": "plan" },
80
+ { "agent": "codex", "account": "default", "model": null, "when": "always", "cost": "plan" },
81
+ { "agent": "agy", "account": "default", "model": null, "when": "always", "cost": "free" }
82
+ ],
83
+ "climb_back": "next-handoff",
84
+ "may_spend": false,
85
+ "reserve": {}
86
+ }
87
+ ```
88
+
89
+ | key | default | meaning |
90
+ |-----|---------|---------|
91
+ | `handoff_ladder` | `claude/fable`, `claude/opus`, `claude/sonnet`, then one rung per remaining agent in `handoff_order` with model `null` | the fallback list, rung 1 first; each rung is `{agent, account, model, when, cost}`. `model` is `null` or one of that agent's names in `src/buckets.mjs` `MODEL_ALIASES` (only claude has any: `fable`, `opus`, `sonnet`, `haiku`). `account` is `default` or a name this machine has for that agent (`leg accounts ls`); anything else is refused with a sentence, because that string becomes the CLI's config dir and the usage record's file name. `when` is `always`, `below:N`, or `walled-only`. `cost` is `free`, `plan`, `credits`, or `metered`, and is a static label; the live cost a rung would spend right now is computed from the agent and the login, never read off this key, so a ladder migrated from an older `handoff_order` still meets the spending gate on `grok` |
92
+ | `climb_back` | `next-handoff` | `next-handoff` picks a recovered higher rung up again at the very next hand-off, with no extra step; `never` keeps a terminal on the rung it downshifted to until a human hands it off there by name |
93
+ | `may_spend` | `false` | while `false`, an automatic hand-off skips any rung whose live cost is `credits` or `metered`, and records why; a human's own pick is not gated by this |
94
+ | `reserve` | `{}` | `{ "<agent>": percent }`; an automatic hand-off will not take a rung on that login once its binding bucket is above `100 - percent`. A human's own pick still reaches it, and the picker names the reserve on that row instead of hiding it |
95
+
96
+ A fresh install with no `preferences.json` starts with `claude/fable`,
97
+ `claude/opus`, `claude/sonnet`, then every other installed agent from
98
+ `handoff_order` with `model: null`.
99
+
100
+ Leg writes `handoff_ladder` and `handoff_order` together and keeps them in
101
+ step: saving a ladder rewrites `handoff_order` from its distinct agent order,
102
+ and saving a bare `handoff_order` rewrites the ladder as one `model: null`
103
+ rung per agent, so an older install behaves exactly as it did until a rung is
104
+ edited. If the file is hand-edited so the two disagree, `handoff_order` wins
105
+ and the ladder is rebuilt from it, because the order is the shape a hand
106
+ edit is more likely to have meant.
107
+
108
+ `leg ladder` (`leg ladder ls`) prints the ladder with each rung's live state;
109
+ `leg ladder set <n> <agent>[/<account>[/<model>]]`, `leg ladder rm <n>` and
110
+ `leg ladder spend on|off` change it. See [cli-contracts.md](cli-contracts.md).
111
+
112
+ ### Notifications
113
+
114
+ `~/.leg/preferences.json` also holds two toggles for a terminal that is
115
+ waiting on you (`src/preferences.mjs` `defaults()`):
116
+
117
+ | key | default | meaning |
118
+ |-----|---------|---------|
119
+ | `notify_terminal` | `true` | "Terminal toast when a terminal waits on you": on Claude Code's own `Notification` hook (`permission_prompt`, `idle_prompt`, `agent_needs_input`), send an OSC 9 toast to the window the agent is already running in |
120
+ | `notify_board` | `false` | "Browser notification when a terminal waits on you": fire a browser `Notification()` the moment a terminal row becomes `needsYou` and the reader is not looking at the board (a waiting card raises the tab badge and the verdict, not the notification); the board reads `window.isSecureContext` at render time and disables the toggle with an explanatory sentence where that is false |
121
+
122
+ Both are set from Settings on the board, or by writing the key directly. The
123
+ tab title badge (`(1) Leg` plus a favicon dot) has no toggle: it needs no
124
+ permission, so it is always on.
125
+
68
126
  ## Core
69
127
 
70
128
  | variable | default | meaning | read in |
@@ -126,7 +184,8 @@ resolves to by default.
126
184
  | `LEG_CLAUDE_BIN` | claude | `src/adapters/claude.mjs` |
127
185
  | `LEG_CODEX_BIN` | codex | `src/adapters/codex.mjs` |
128
186
  | `LEG_AGY_BIN` | agy | `src/adapters/agy.mjs` |
129
- | `LEG_GROK_BIN` | grok (not registered by default) | `src/adapters/grok.mjs` |
187
+ | `LEG_GROK_BIN` | grok | `src/adapters/grok.mjs` |
188
+ | `LEG_<NAME>_BIN` | a custom adapter called `<name>` (dashes become underscores, so `my-agent` reads `LEG_MY_AGENT_BIN`) | `src/adapters/custom.mjs` |
130
189
  | `LEG_GH_BIN` | the `pr` land-mode stub | `src/stations/pr.mjs`; unset, `land_mode: pr` returns an error rather than running a real `gh` |
131
190
  | `LEG_CHB_BIN` | `context-handoff-bundle` | `src/handoff.mjs`; unset, Leg tries `context-handoff-bundle` on PATH, then `python -m context_handoff_bundle` |
132
191
 
@@ -210,9 +269,10 @@ other address without also setting `LEG_TOKEN` makes the server refuse to
210
269
  start, exit code `3` (`src/auth.mjs` `checkBind`/`BindRefused`). With a
211
270
  token set, every `/api/*` request needs an `Authorization: Bearer <token>`
212
271
  header; the event stream (`EventSource`, which cannot set headers) accepts
213
- the same token as a `?token=` query parameter instead. There is no TLS; keep
214
- `LEG_BIND` on loopback unless you are using `leg share`, which gives
215
- each human their own token (see [Share](#share-more-than-one-human)).
272
+ the same token as a `?token=` query parameter instead. Keep `LEG_BIND` on
273
+ loopback unless you are using `leg share`, which gives each human their own
274
+ token (see [Share](#share-more-than-one-human)) and can serve the board over
275
+ TLS.
216
276
 
217
277
  ## Share (more than one human)
218
278
 
@@ -224,7 +284,8 @@ switch and the roster; the env variables below only tune the limits.
224
284
  | `on` | share is on (it also needs a `bind` and at least one person) |
225
285
  | `bind`, `bind_kind`, `port` | where the board listens: the Tailscale address by default, `lan`, or one you named |
226
286
  | `owner` | the name a terminal belongs to when nothing else says (`LEG_PERSON`), and the name a loopback browser is treated as |
227
- | `people[]` | `{ name, role: owner\|guest, token_sha256, created_at }`; the token itself is printed once and never stored |
287
+ | `people[]` | `{ name, role: owner\|operator\|guest, token_sha256, created_at }`; the token itself is printed once and never stored |
288
+ | `tls` | `{ cert, key }`, the paths to a certificate pair; absent means plain http |
228
289
  | `loopback_owner` | default `true`: a browser on this machine is the owner without a token. Set it to `false` to ask for a link even here |
229
290
 
230
291
  | variable | default | meaning | read in |
@@ -234,6 +295,53 @@ switch and the roster; the env variables below only tune the limits.
234
295
 
235
296
  `LEG_TOKEN` is the single-token mode and is ignored while share is on.
236
297
 
298
+ ### Roles
299
+
300
+ | role | terminals | cards | this machine |
301
+ |------|-----------|-------|--------------|
302
+ | `owner` | every one | every one | settings, harness policy, the trunk's repo paths, the history index, the worktree map, the audit trail |
303
+ | `operator` | their own; someone else's is read-only and redacted | adds, runs, approves, reassigns, kills | nothing: `/api/settings`, `/api/history`, `/api/worktrees`, `/api/trunk` and `/api/audit` all answer 403, and `/api/health` omits the home path |
304
+ | `guest` | their own; someone else's is read-only and redacted, with **Request handoff** as the only button | nothing: 403 | nothing |
305
+
306
+ A guest and an operator both see their own terminal's hand-off destinations,
307
+ so they can use the picker on it — but never the reset times behind them,
308
+ which are this machine's usage data. `src/share.mjs` `mayUseCards` and
309
+ `mayUseMachine` are the only place a role is turned into permission.
310
+
311
+ ### TLS
312
+
313
+ Off unless a certificate pair is configured. Leg never issues one: a
314
+ self-signed pair teaches everyone on the board to click through a warning,
315
+ which is worse than plaintext on a network that is already private. On
316
+ Tailscale, `tailscale cert <machine>.<tailnet>.ts.net` issues a pair browsers
317
+ already trust.
318
+
319
+ | variable | meaning | read in |
320
+ |----------|---------|---------|
321
+ | `LEG_TLS_CERT` | path to the certificate; wins over `share.json`'s `tls.cert` | `src/share.mjs` `readTls` |
322
+ | `LEG_TLS_KEY` | path to the private key; wins over `share.json`'s `tls.key` | `src/share.mjs` `readTls` |
323
+
324
+ `leg share on --tls-cert <file> --tls-key <file>` writes the pair into
325
+ `share.json` and reads it once, so a bad pair fails there rather than at the
326
+ next board start. With a pair configured the shared address serves https and
327
+ every link `leg share` prints says `https://`. The companion listener on
328
+ `127.0.0.1` — the one that lets this machine's own browser in without a token
329
+ — stays plain http, because the certificate is for the shared name and
330
+ loopback traffic never leaves the machine. Half a pair, a missing file, an
331
+ unreadable file or an empty one stops the board with exit `3` rather than
332
+ falling back to plaintext. A renewed pair is picked up by `leg down && leg up`.
333
+
334
+ ### The audit trail
335
+
336
+ `GET /api/audit` (owner only) and Settings → **Audit trail** on the board: one
337
+ list across every terminal and every card, newest first, of the actions a
338
+ person or an agent took — hand-offs, landings, approvals, reassignments,
339
+ kills. It reads the events already on disk and stores nothing new. Query
340
+ parameters: `limit` (default 200, max 1000), `since` (an ISO timestamp), `who`
341
+ (a name), `kind` (`human`, `agent` or `leg`). Every answer carries `scanned`
342
+ — how many terminals, cards and events it read — so an empty trail cannot be
343
+ mistaken for a quiet week.
344
+
237
345
  ## Card-level options
238
346
 
239
347
  These configure one card, not the whole install (set with `card add` flags
package/docs/faq.md CHANGED
@@ -57,9 +57,25 @@ same summary). The short version: owning two paid subscriptions is not named
57
57
  as prohibited by Anthropic or OpenAI, but rotating to a second account of the
58
58
  same vendor because the first is rate-limited sits close to OpenAI's
59
59
  "circumvent any rate limits" wording and Anthropic's "circumvent product
60
- guardrails". Leg's default chain switches vendors (claude, codex, agy),
61
- which is plainly fine. Same-vendor rotation only happens after you run
62
- `leg accounts add`.
60
+ guardrails". Leg's default fallback ladder switches vendors (claude, codex,
61
+ agy) only after it has already tried claude's own weaker models (fable, opus,
62
+ sonnet), which is plainly fine. Same-vendor rotation only happens after you
63
+ run `leg accounts add`; switching models on one login, claude/fable to
64
+ claude/opus, is not a second account at all and none of this applies to it.
65
+
66
+ **Can I switch models mid-session, or does a hand-off always mean a different CLI?**
67
+ Yes. `handoff_ladder` in `preferences.json` names an agent, account and model
68
+ per rung (`src/preferences.mjs`), and a fresh install tries claude/fable, then
69
+ claude/opus, then claude/sonnet before it ever leaves the claude login, then
70
+ the other agents. A downshift to a weaker claude model with a known session id
71
+ runs `claude --resume <id> --model <alias>` and keeps the conversation; every
72
+ other rung takes the bundle as today. `may_spend` (default `false`) keeps an
73
+ automatic hand-off off any rung whose live cost is `credits` or `metered`; a
74
+ hand-off you press yourself can still take it. `climb_back` decides whether
75
+ Leg returns to the top rung at the next hand-off (`next-handoff`, the default)
76
+ or waits for you to press **Back to fable** (`never`). Edit the ladder with
77
+ `leg ladder` or in Settings; see
78
+ [configuration.md](configuration.md#the-hand-off-ladder).
63
79
 
64
80
  **What does `leg uninstall` remove?**
65
81
  `~/.leg` and nothing else: sessions, usage files, the extra account
@@ -74,7 +90,7 @@ package gone too.
74
90
  Yes. A parent Claude Code session exports `CLAUDECODE` and `CLAUDE_CODE_*`
75
91
  markers that make a nested Claude refuse to start; `sanitizeEnv`
76
92
  (`src/env.mjs`) strips them along with the API-key variables, so the child
77
- starts normally. It becomes its own session with its own card on the board,
93
+ starts normally. It becomes its own session with its own row on the board,
78
94
  unrelated to the parent's.
79
95
 
80
96
  **Why are `--dangerously-skip-permissions` and similar flags never
@@ -118,7 +134,7 @@ and nothing a terminal has said, read or written: no prompt, no file names, no
118
134
  paths, no bundle, no events, no logs, and none of the pipeline side. The
119
135
  limit line keeps only the reason and the reset time, never the raw limit
120
136
  text. The one thing they can do on your terminal is ask for a hand-off, which
121
- you approve or dismiss on the card. `leg share off` ends it and every link
137
+ you approve or dismiss on the row. `leg share off` ends it and every link
122
138
  stops working. There is no TLS, so keep it on Tailscale or a network you trust.
123
139
 
124
140
  **What if the CLI I want isn't installed?**
@@ -62,10 +62,12 @@ Claude print session. The subscription login is always what runs. Nothing in
62
62
 
63
63
  ## 4. What you get while it runs
64
64
 
65
- - A card on the board for this terminal: agent, account, repo@branch, the first
66
- prompt, turns, the files being touched, 5h and 7d usage.
67
- - A warning at 85 % of either window (`LEG_WARN_PCT`): amber card, an event,
68
- one terminal bell.
65
+ - A row on the board for this terminal: status, repo@branch, dirty/ahead
66
+ counts, agent/model, the first prompt, turns, the files being touched, and
67
+ the capacity phrase for whichever bucket actually binds (a percentage, or a
68
+ forecast like `about 2h 40m of fable left` once there are enough readings).
69
+ - A warning at 85 % of either window (`LEG_WARN_PCT`): the row goes amber, an
70
+ event, one terminal bell.
69
71
  - For Codex, the board and active attach poll the read-only app-server
70
72
  `account/rateLimits/read` response every 60 seconds. Leg maps the returned
71
73
  300- and 10080-minute durations to 5h and 7d, shows `<n>% used`, and labels
@@ -77,13 +79,15 @@ Claude print session. The subscription login is always what runs. Nothing in
77
79
  section before the raw bundle dump.
78
80
  - At the limit: the bundle is saved, the agent is stopped, and the next option
79
81
  starts in the same terminal from `.leg/RESUME-<session-id>.md`, copied to
80
- `.leg/RESUME.md`. Order is other logins of
81
- the same agent first, then the remaining agents (claude, codex, agy). When
82
- every option is out, Leg prints each reset time, soonest first, waits with
83
- a countdown, and starts the first one back from the bundle. Ctrl-C quits.
82
+ `.leg/RESUME.md`. The order is a ladder of rungs, each an agent, account and
83
+ model: a fresh install goes claude/fable, then claude/opus, then
84
+ claude/sonnet before it ever leaves the claude login, then the remaining
85
+ agents (codex, agy). `leg ladder` (or Settings) edits it. When every option
86
+ is out, Leg prints each reset time, soonest first, waits with a countdown,
87
+ and starts the first one back from the bundle. Ctrl-C quits.
84
88
 
85
- Force a hand-off at any time with the **Hand off now** button on the card, or
86
- `leg sessions handoff <id>`.
89
+ Force a hand-off at any time with the **Hand off now** button on the row, or
90
+ `leg sessions handoff <id> [--to <agent>[/<account>[/<model>]]]`.
87
91
 
88
92
  ## Is the resume file still true?
89
93
 
@@ -95,7 +99,7 @@ leg resume # the same verdict, then the pointer itself
95
99
  Leg stamps every resume file with the commit, the working tree and the
96
100
  terminals it was written against, and recomputes freshness from git when you
97
101
  read it. A commit landing, the tree moving, or the terminal it described going
98
- away all make it stale; the terminal card's drawer shows the same verdict under
102
+ away all make it stale; the terminal's expansion shows the same verdict under
99
103
  "What happens next". A session ending rewrites `RESUME.md` to say nothing is in
100
104
  flight, and the board does the same at start for a terminal that crashed, so
101
105
  nothing is left describing work that has moved on.