@ucsandman/legcli 0.11.0 → 0.13.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 (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  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/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
@@ -243,26 +243,78 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
243
243
 
244
244
  ### claude tap
245
245
 
246
- - Attach: `claude <args> --settings <LEG_HOME>/sessions/<id>/claude-settings.json`
246
+ - Attach: `claude <args> -n "leg#<short id> <repo>/<branch>" --settings <LEG_HOME>/sessions/<id>/claude-settings.json`
247
247
  (source: src/attach.mjs `spawnSpec`; src/taps/claude.mjs `writeSettings`).
248
248
  Hooks in a `--settings` file merge with the user's rather than replacing
249
249
  them; `statusLine` is the one key that replaces, so Leg runs the user's own
250
250
  command first (source: code.claude.com/docs/en/settings;
251
251
  src/taps/claude.mjs `userStatusLine`). observed-live 2026-09-11: a Leg
252
252
  session ran with every user hook still firing.
253
+ - Terminal title: `-n, --name <name>` "Set a display name for this session
254
+ (shown in the prompt box, /resume picker, and terminal title)" —
255
+ fixtures/help/claude.txt line 132. It sits in the general Options block, not
256
+ among the flags marked "only works with --print", and two of its three
257
+ surfaces (the prompt box, the `/resume` picker) exist only in interactive
258
+ mode, so it is the interactive title flag. Leg passes
259
+ `leg#<short id> <repo>/<branch>`; a `-n` or `--name` the human passed is
260
+ never overwritten. codex, agy and grok have no such flag, so Leg writes OSC 2
261
+ (`\x1b]2;leg#7f3a leg/main\x07`) to stdout once before the child spawns, and
262
+ only on a TTY. **ASSUMED**: that the child does not overwrite the title once
263
+ it starts drawing; no terminal has been read either way yet.
253
264
  - Hooks written (source: src/taps/claude.mjs `settingsFor`): `SessionStart`,
254
265
  `UserPromptSubmit`, `PostToolUse` with matcher `Edit|Write|MultiEdit|NotebookEdit`,
266
+ `Notification` with matcher
267
+ `permission_prompt|idle_prompt|agent_needs_input|quota_auto_resume_fired`,
255
268
  `Stop`, `StopFailure`, `SessionEnd`, each
256
269
  `node <src>/hook.mjs claude-hook --session <id>` with a 20 s timeout.
257
- observed-live 2026-09-11 (hook.log in the session directory).
270
+ observed-live 2026-09-11 for the six that shipped then (hook.log in the
271
+ session directory); the `Notification` entry is docs-only until a real
272
+ permission prompt is captured.
273
+ - Waiting on a human: the `Notification` hook. It "matches on notification
274
+ type" and, not being one of the two narrower events (FileChanged,
275
+ StopFailure), takes `|` as its alternation separator
276
+ (https://code.claude.com/docs/en/hooks lines 1424, 173, 165). The payload
277
+ carries `message`, an optional `title` and `notification_type` (line 1480).
278
+ `permission_prompt`, `idle_prompt` and `agent_needs_input` write
279
+ `session.waiting = { type, message (verbatim, 160 chars), since }`;
280
+ `UserPromptSubmit` and `Stop` clear it. `permission_prompt` fires only after
281
+ the prompt has waited about six seconds and shares its timing with desktop
282
+ notifications, so in a terminal it arrives only when the human appears to be
283
+ away (lines 1162, 1443): "waiting on you" is late by design and must not be
284
+ sold as instant.
285
+ - The toast: the hook prints `{"terminalSequence":"\x1b]9;<message>\x07"}`.
286
+ Notification hooks cannot block or modify anything and their `systemMessage`
287
+ and `continue` are discarded, but Claude Code still emits `terminalSequence`
288
+ for them (lines 1490, 622). The field is restricted to OSC 0/1/2/9/99/777 and
289
+ BEL, and is ignored wholesale if anything outside that rides in it (line
290
+ 608), so control bytes in the message are stripped first. OSC 9 is what
291
+ Windows Terminal, iTerm2, ConEmu and WezTerm render (line 617). Gated on
292
+ `preferences.notify_terminal`, default true.
258
293
  - `autoContinueAtUsageLimit: false` in the same file, because Leg owns the
259
- hand-off (source: src/taps/claude.mjs `settingsFor`).
294
+ hand-off (source: src/taps/claude.mjs `settingsFor`). If the human's own
295
+ settings re-enable it, the `quota_auto_resume_fired` notification arrives
296
+ anyway; Leg then writes `waiting = { type: 'quota_auto_resume' }` and the
297
+ automatic hand-off stands down for that terminal (src/attach.mjs
298
+ `handoffStoodDown`). Two waiters on one terminal is the failure to avoid.
299
+ - The model that answered: each assistant line of the transcript carries
300
+ `message.model` (VERIFIED: 29 lines of the newest jsonl for this repo read
301
+ `claude-fable-5-1`). The runner reads the tail of that file on the usage poll
302
+ and says it as its alias (`fable`), so a silent fallback off Fable shows on
303
+ the row; an id matching no alias is kept raw (src/taps/claude.mjs
304
+ `modelFromTranscript`, `modelAlias`).
260
305
  - Usage: `GET https://api.anthropic.com/api/oauth/usage`
261
306
  (`LEG_CLAUDE_USAGE_URL` overrides) with `Authorization: Bearer <accessToken>`
262
307
  from `<CLAUDE_CONFIG_DIR>/.credentials.json` key `claudeAiOauth`, and header
263
308
  `anthropic-beta: oauth-2025-04-20`; response fields `five_hour` and
264
309
  `seven_day`, each `{ utilization, resets_at }`; polled every
265
310
  `LEG_USAGE_POLL_MS` ms, default 60000 (source: src/taps/claude-usage.mjs).
311
+ A `limits[]` array, when the response carries one, becomes the per-model
312
+ buckets; a row's `scope.model.display_name` is joined to the alias the rest of
313
+ Leg uses by matching a known name as a whole word, so `Fable 5.1` and
314
+ `Claude Opus 5` are `fable` and `opus` and a name Leg does not know keeps its
315
+ own lowercased text. A response with no `limits` key is an older endpoint
316
+ answering: the key is omitted rather than sent as `[]`, because that is no
317
+ information about buckets and must not erase the measured ones.
266
318
  observed-live 2026-09-11: real windows came back and were written to
267
319
  `<LEG_HOME>/usage/claude--default.json`; a seven_day window at 93 %
268
320
  raised the amber warning.
@@ -386,7 +438,11 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
386
438
 
387
439
  - `<LEG_HOME>/usage/<agent>--<account>.json`:
388
440
  `{ five_hour: {pct, resets_at}, seven_day: {…}, limited_until,
389
- limited_reason, source, updated_at }` (source: src/usage.mjs).
441
+ limited_reason, source, updated_at, buckets, walls, history, extra_usage,
442
+ facts }` (source: src/usage.mjs). The last five are the per-model half of the
443
+ record: measured buckets, walls attributed from wording, a 24-sample ring per
444
+ bucket, the credits block and agent-measured strings. See
445
+ [concepts.md](concepts.md).
390
446
  - Warning threshold `WARN_PCT`, default 85, from `LEG_WARN_PCT`; the warning
391
447
  is an amber card, an event, and one terminal bell (source: src/usage.mjs,
392
448
  src/attach.mjs).
@@ -410,6 +466,184 @@ in [VOCABULARY.md](VOCABULARY.md). Board routes: `GET /api/sessions`,
410
466
  `POST /api/sessions/:id/end`, `DELETE /api/sessions/:id`, with the list pushed
411
467
  as the SSE `sessions` event (source: src/server.mjs, src/board/sessions.js).
412
468
 
469
+ `session.ahead` is the number of commits the checkout is past its upstream, or
470
+ past `head_at_start` when it tracks nothing. It is counted with `git rev-list
471
+ --count <base>..HEAD` on the runner's git poll and is `null`, never `0`, when
472
+ this is not a repository or the count could not be taken (source:
473
+ src/attach.mjs `aheadCount`). Like the dirty file list, it is owner-only:
474
+ `redactSession` drops it from another human's row.
475
+
476
+ `GET /api/sessions` also carries `cards_waiting`, so the terminals verdict can
477
+ name a waiting card without reading the pipeline board:
478
+ `{count, first: {id, title, station, since}}`, where `count` is the number
479
+ of cards in `needs_approval` or `waiting_human` and `first` is the one that has
480
+ been waiting longest (`since` is its `updated_at`), or `null` when none is. A
481
+ count alone cannot write `card 3e1c has waited on you for 12 minutes.`, which
482
+ is the sentence the field exists for (redesign C.5). It is sent to anyone who
483
+ may use cards, which is the owner and any operator; a guest has no cards and is
484
+ not sent it (source: src/server.mjs `cardsWaiting`).
485
+
486
+ A guest's own row carries no figure that was measured from this machine's
487
+ logins: `limits`, `all_out` and `usage_source` are dropped, `warning` keeps its
488
+ window and loses its percentage and its clock, a
489
+ `limit` or a `waiting` of type `reset` keeps its words and loses its
490
+ `resets_at`, and a `handoff_targets` row carries no `cost` (the word `credits`
491
+ on a `claude/fable` rung is the owner's `extra_usage` setting by another name).
492
+ A row that is available carries no reason at all, because the reserve and
493
+ `below:N` notes are advice for a human press, not a refusal. Someone else's row
494
+ is redacted further: no `limits`, and `warning` is reduced to its window
495
+ (source: src/server.mjs `redactSession`, `scrubOwnerUsage`).
496
+
497
+ ### Making a card over HTTP
498
+
499
+ `POST /api/cards` takes the same body `createCard` takes from the CLI, with one
500
+ difference: `pipeline` must be a preset name (`build`, `build-land`, `factory`)
501
+ or an inline array of stations. A path to a JSON file is a CLI convenience
502
+ (`leg card add --pipeline ./my.json`) and is refused here with `400
503
+ invalid pipeline: pipeline must be one of the presets ... or a list of
504
+ stations`, naming no path and quoting no bytes: an operator may post cards and
505
+ may not read this machine, and the JSON parser's own message quotes the first
506
+ characters of whatever file it opened (source: src/cards.mjs
507
+ `allowPipelineFile`).
508
+
509
+ `chain` is a comma list (`claude,codex`) or an array of entries
510
+ (`[{adapter, model?, mode?, max_turns?}]`). The array form is what the board's
511
+ one-line entry posts, because it is the only one that can put a different model
512
+ on each leg: `[{adapter: 'claude', model: 'fable'}, {adapter: 'claude', model:
513
+ 'opus'}]` is two legs on one login, which `--model claude=fable` (one model per
514
+ adapter) cannot express.
515
+
516
+ ### A terminal becomes a card, and a card becomes a terminal
517
+
518
+ `POST /api/sessions/:id/end-as-card` (owner or operator; a guest is refused)
519
+ writes the terminal's hand-off bundle with `saveSessionBundle`, creates a card
520
+ whose task is the terminal's prompt plus `Continue from the bundle at <path>.`,
521
+ starts the card's chain at the rung the terminal is standing on, records
522
+ `lineage.from`, and then requests `end` on the terminal exactly as
523
+ `POST /api/sessions/:id/end` does. `201` with
524
+ `{card, bundle, carried: {files, adopted}}`. Where the card works, and when it
525
+ starts, depend on what the terminal had:
526
+
527
+ - **Its own worktree.** The card adopts it (`worktree_adopted: true`, so no
528
+ later run cuts a second worktree on that branch) and is created in
529
+ `backlog`, not `queued`: `end` is a request the terminal's runner reads on
530
+ its own poll, and the scheduler ticks once a second, so queuing it at once
531
+ would put a headless agent in the working tree the interactive one is still
532
+ writing to. The board polls the session record and enqueues the card the
533
+ moment it is no longer active; if the terminal is still going ten minutes
534
+ later, the card stays in the backlog with a `blocked_by` line saying so and
535
+ Run is left to the human. `heldByLiveTerminal` in src/scheduler.mjs refuses
536
+ to start such a card whatever queued it.
537
+ - **No worktree of its own** (the ordinary case: a terminal cuts one only when
538
+ a second live session shares the checkout). A checkout of its own is cut from
539
+ the terminal's branch and the uncommitted work is carried into it before the
540
+ terminal is asked to stop: a patch of everything git tracks
541
+ (`git diff HEAD --binary`, applied with `git apply`) plus the bytes of every
542
+ file it does not (`git ls-files --others --exclude-standard`, skipping Leg's
543
+ own directories and `node_modules`). `carried.files` is how many paths moved.
544
+ The card is queued at once, because nothing else is in that checkout.
545
+
546
+ The bundle is written first, and the uncommitted work is read before anything
547
+ is created: if either fails, nothing is created, nothing is ended, and the
548
+ terminal is left running with `409` (source: src/server.mjs).
549
+
550
+ `POST /api/cards/:id/take-over` moves the card to `paused` through the
551
+ `take_over` transition, killing the child of a running one, and answers `200`
552
+ with `{card, command}`, where `command` is `leg <agent> --resume-card <card-id>`
553
+ for the agent on the card's current leg. Every non-terminal status is paused,
554
+ not only `running`: a `queued` or `handing_off` card is in the set the
555
+ scheduler starts from, so leaving it there would launch a leg into the same
556
+ worktree the human was just handed. One `taken_over` event with the caller's
557
+ name is appended whatever the starting status, and it is in the audit trail. A
558
+ card in `done`, `failed` or `killed` is refused with `409` (source:
559
+ src/server.mjs, src/chain.mjs).
560
+
561
+ `leg <agent> --resume-card <id>` is Leg's own flag and never reaches the
562
+ agent's argv. It opens an ordinary interactive terminal in that card's
563
+ worktree, whatever directory it was run from, primed with the card's bundle as
564
+ the first prompt and with `lineage.from` naming the card. The id may be the
565
+ full card id or a unique tail of it; an ambiguous one is refused by name with
566
+ the matches listed, and an unknown one exits `3` (source: src/attach.mjs
567
+ `takeFlagValue`, `resolveCardId`, `takeOverPrompt`).
568
+
569
+ A live card's board payload carries what was measured and nothing else: `work`
570
+ `{files, insertions, deletions}` parsed from `git diff --shortstat
571
+ <trunk>..HEAD` in its worktree, `tests` `{state, at}` and `land` `{state,
572
+ reason, sha}` read from the card's own ledger. A key that could not be measured
573
+ is absent; a finished card carries none of them (source: src/server.mjs
574
+ `parseShortstat`, `cardOutcomes`, `summarize`).
575
+
576
+ ### The hand-off ladder
577
+
578
+ `leg sessions handoff <id> --to <agent>[/<account>[/<model>]]` names the rung
579
+ directly. Three parts are unambiguous; with two, the second is read as an
580
+ account when the agent has one by that name, else as a model when the agent
581
+ has one by that name, else the command is refused by name rather than guessed
582
+ at (source: bin/leg.mjs `parseTarget`). `leg sessions simulate-limit <id>
583
+ --message "<text>"` drives that exact wording through the real classifier
584
+ (`src/hook.mjs` for claude, the session's own log for agy and grok), which is
585
+ how a per-model wall is reached without waiting for a real one; codex has no
586
+ Leg-owned input to simulate through and is refused by name (source:
587
+ bin/leg.mjs `simulateLimit`).
588
+
589
+ `leg ladder` (also `leg ladder ls`) prints the ladder, rung 1 first, each with
590
+ its live state (a wall and its clock, a percentage, or "no figure") and the
591
+ reason it would be skipped right now, then the spending, climb-back, reserve
592
+ and derived-order lines. `leg ladder set <n> <agent>[/<account>[/<model>]]
593
+ [--when always|below:N|walled-only]` writes rung `n` (past the end of the
594
+ ladder, it appends); `leg ladder rm <n>` removes rung `n` and refuses to
595
+ remove the last rung left; `leg ladder spend on|off` flips `may_spend`
596
+ (source: bin/leg.mjs `ladderCommand`).
597
+
598
+ Board routes: `PATCH /api/settings` (owner only) also accepts
599
+ `handoff_ladder`, `climb_back`, `may_spend`, `reserve`, `notify_terminal` and
600
+ `notify_board`. `POST /api/sessions/:id/handoff-order` accepts either
601
+ `{handoff_order}` or `{handoff_ladder}` and rewrites both on the session.
602
+ `POST /api/sessions/:id/handoff` accepts `target.model` alongside
603
+ `target.agent` and `target.account` (source: src/server.mjs).
604
+
605
+ ### GET /api/models
606
+
607
+ The models this machine can start each agent on, so a rung and a card can name
608
+ one. Owner and operator only; a guest gets the same 403 `/api/adapters` and
609
+ `/api/presets` give, because picking a model is spending the owner's plan
610
+ (source: src/server.mjs, src/models.mjs).
611
+
612
+ ```json
613
+ {
614
+ "models": {
615
+ "claude": [{ "id": "fable", "label": "Claude Fable", "default": false }],
616
+ "codex": [{ "id": "gpt-6-astra", "label": "GPT-6-Astra", "default": true }],
617
+ "agy": [{ "id": "gemini-3.8-flash-high", "label": "Gemini 3.8 Flash (High)", "default": false }],
618
+ "grok": [{ "id": "grok-4.6", "label": "grok-4.6", "default": true }]
619
+ },
620
+ "observed_at": "2026-09-18T14:37:01.577Z"
621
+ }
622
+ ```
623
+
624
+ Where each list comes from, all observed live on 2026-09-18 and captured under
625
+ `fixtures/models/`:
626
+
627
+ | agent | source | today's ids | default |
628
+ |-------|--------|-------------|---------|
629
+ | claude | `MODEL_ALIASES.claude` in src/buckets.mjs; four aliases Claude Code resolves itself, not service-side ids | `fable`, `opus`, `sonnet`, `haiku` | none: a bare `claude` picks for itself, and naming one would be Leg making a choice nobody made |
630
+ | codex | `<CODEX_HOME>/models_cache.json`, `models[]` where `visibility` is `"list"` (`"hide"` covers `gpt-reserve` and `codex-auto-review`) | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5` | the top-level `model = "..."` in `<CODEX_HOME>/config.toml`, never a `[profiles.x]` one |
631
+ | agy | `agy models`, one `id<TAB>label` per line after a `Fetching available models...` line | 14, from `gemini-3.8-flash-high` to `gpt-oss-120b-medium`, including `claude-opus-4-6-thinking` | agy publishes none |
632
+ | grok | `grok models`, ` * id (default)` / ` - id` bullets under a `Default model: X` line; it lists them even when it is not logged in | `grok-4.6`, `grok-4.5` | `grok-4.6` |
633
+
634
+ `agy` and `grok` each cost a process, so their answers are cached in
635
+ `<LEG_HOME>/models/<agent>.json` for an hour with a 20s probe timeout. A request
636
+ never waits on one: the cached list is served and the refresh runs behind the
637
+ answer. A probe that fails leaves the cache exactly as it was.
638
+
639
+ An id from any of these lists may go on a ladder rung's `model` and on a card's
640
+ chain entry, and reaches the CLI through `modelFlagFor` in src/buckets.mjs
641
+ (`claude --model`, `agy --model`, `codex -m`, `grok -m`). Rung validation is by
642
+ SHAPE for every agent (`/^[a-z0-9][a-z0-9._:-]{0,63}$/`, so a flag or a path can
643
+ never become an argv element) and by MEMBERSHIP for claude alone, whose list is
644
+ closed; the other three catalogs are live and the CLI itself is the authority on
645
+ its own.
646
+
413
647
  ### History index
414
648
 
415
649
  `<LEG_HOME>/history/index.json` is the one file `leg history` writes: per
@@ -447,12 +681,13 @@ non-zero exit → `failed`. Every outcome except `completed`, `auth_failed` and
447
681
  `killed` asks the chain to hand off.
448
682
 
449
683
  <!-- limits-table:start -->
450
- Generated by `node scripts/limits-table.mjs` from 22 fixtures (4 observed-live, 18 docs-only). Classification `limit` hands the card to the next agent as a usage limit; `auth` is a failed launch (never a limit); `launch` is a failed launch that the next agent may still try; `budget` is a turn or spend cap set by Leg itself; `info` must never classify as a limit.
684
+ Generated by `node scripts/limits-table.mjs` from 24 fixtures (6 observed-live, 18 docs-only). Classification `limit` hands the card to the next agent as a usage limit; `auth` is a failed launch (never a limit); `launch` is a failed launch that the next agent may still try; `budget` is a turn or spend cap set by Leg itself; `info` must never classify as a limit.
451
685
 
452
686
  | id | adapter | class | where | source | text (excerpt) | produced by |
453
687
  |----|---------|-------|-------|--------|----------------|-------------|
454
688
  | agy-resource-exhausted | agy | limit | any | **docs-only** | resource-exhausted | `agy changelog` ("Fixed personal accounts hitting a resource-exhausted error at startup"); agy --help documents no limit wording; falls back to the generic matchers |
455
689
  | claude-budget-limit | claude | budget | any | **docs-only** | Budget limit reached | https://code.claude.com/docs/en/cli-reference (--max-budget-usd) |
690
+ | claude-fable-limit | claude | limit | any | **observed-live** | You've reached your Fable limit. Run /usage-credits to continue or switch models with /mod | fixtures/live/claude/limit-rate_limit.json: StopFailure error rate_limit, last_assistant_message on the real run, 2026-09-11 (a per-model wall: the account's session and weekly windows were open) |
456
691
  | claude-max-turns | claude | budget | stdout | **observed-live** | {"type":"result","subtype":"error_max_turns","is_error":true,"stop_reason":"tool_use","ter | fixtures/real-run/leg1/out.log: `claude -p --max-turns 2` on the real run, 2026-09-10 (docs: https://code.claude.com/docs/en/cli-reference --max-turns "Exits with an error when the limit is reached") |
457
692
  | claude-model-limit | claude | limit | any | **docs-only** | You’ve hit your Opus limit | https://code.claude.com/docs/en/costs ("You’ve hit your Opus limit" / "You’ve hit your Sonnet limit") |
458
693
  | claude-session-limit | claude | limit | any | **docs-only** | You’ve hit your session limit | https://code.claude.com/docs/en/costs (section: When a developer asks about a limit) |
@@ -468,6 +703,7 @@ Generated by `node scripts/limits-table.mjs` from 22 fixtures (4 observed-live,
468
703
  | generic-rate-limit | * | limit | any | **docs-only** | rate limit | generic matcher; lowest priority |
469
704
  | generic-resource-exhausted | * | limit | any | **docs-only** | RESOURCE_EXHAUSTED | generic matcher (gRPC RESOURCE_EXHAUSTED); lowest priority |
470
705
  | generic-usage-limit | * | limit | any | **docs-only** | usage limit | generic matcher; lowest priority |
706
+ | grok-balance-exhausted | grok | limit | any | **observed-live** | API error (status 402 Payment Required): Grok Build usage balance exhausted | grok 1.0.34 -p --output-format json on an account with no balance left, 2026-09-17: stdout {"type":"error","message":"Internal error: { \"message\": \"API error (status 402 Payment Required): Grok Build usage balance exhausted\", \"http_status\": 402 }"}, exit 1 |
471
707
  | grok-not-logged-in | grok | auth | stderr | **observed-live** | To sign in, open this URL in your browser: https://accounts.x.ai/oauth2/device?user_cod | fixtures/live/grok/err.log from scripts/probe.mjs --adapter grok, 2026-09-10 (stdout JSON stopReason: Cancelled, exit 0) |
472
708
  | grok-rate-limit | grok | limit | any | **docs-only** | You've hit the rate limit for your plan. Try again later. | crates/codegen/xai-grok-shell/src/sampling/error.rs:18 (RATE_LIMITED_USER_MESSAGE_OAUTH: "You've hit the rate limit for your plan. Try again later.") |
473
709
  | auth-source-set | * | auth | stderr | **docs-only** | another auth source is set | project brief (Wes, 2026-09-10): stderr saying "another auth source is set" counts as a failed launch; wording not yet observed live |
package/docs/concepts.md CHANGED
@@ -33,6 +33,28 @@ A session's status is one of `starting`, `running`, `warning`, `limit`,
33
33
  that owned the terminal is gone (closed window, crash); the board never shows
34
34
  it as live.
35
35
 
36
+ Two fields on `session.json` say what the row is running and what it is stuck
37
+ on. `model` is the model this leg resolved to: the `--model` or `-m` the human
38
+ passed, else null. It is never a guessed default, because a model token nobody
39
+ chose is a wrong number in disguise. For claude the runner corrects it from the
40
+ transcript's per-message `model` while the agent works, so a silent fallback
41
+ off Fable is visible instead of being reported as the model you asked for.
42
+
43
+ `waiting` is null, or what this terminal is waiting for, in one of two shapes
44
+ told apart by `type`. `{ type: 'reset', agent, account, resets_at, since }` is
45
+ the all-out countdown: every option is walled and the terminal is waiting for
46
+ the first one back. `{ type, message, since }` with `type` one of
47
+ `permission_prompt`, `idle_prompt`, `agent_needs_input` or `quota_auto_resume`
48
+ is a human being waited on, from Claude Code's `Notification` hook (claude
49
+ sessions only); `message` is the question verbatim, cut at 160 characters, and
50
+ the next prompt or the end of the turn clears it. `quota_auto_resume` means
51
+ Claude Code is waiting at the limit by itself, and Leg's automatic hand-off
52
+ stands down for that terminal rather than making it wait twice.
53
+
54
+ Both are owner-only on a shared board, and a guest keeps both on their own
55
+ terminal: a terminal stopped at a permission prompt is useless to the person
56
+ sitting at it if the board will not say so, and it is nobody else's business.
57
+
36
58
  The board is started detached on `127.0.0.1:4747` by the first session that
37
59
  finds it down, and opened once. Later sessions reuse it.
38
60
 
@@ -69,9 +91,32 @@ keeps the latest reading per (agent, account) in
69
91
 
70
92
  ```
71
93
  { five_hour: {pct, resets_at}, seven_day: {pct, resets_at},
72
- limited_until, limited_reason, source, updated_at }
94
+ limited_until, limited_reason, source, updated_at,
95
+ buckets, walls, history, extra_usage, facts }
73
96
  ```
74
97
 
98
+ A login can be limited per model as well as per account, so the record carries
99
+ both kinds of fact and keeps them apart. `buckets` is measured: one row per
100
+ bucket the agent publishes (`{kind, group, model, percent, resets_at,
101
+ is_active, severity}`, from Claude's `limits[]`), and `binding(u, model)` picks
102
+ the row that will actually stop a terminal. `walls` is attributed from wording
103
+ rather than measured: `bucketFromWall()` in `src/buckets.mjs` reads the wall
104
+ message, and a model-scoped wall goes to `walls[model]` while the login stays
105
+ open, so a Fable wall never stops `claude/sonnet`; a model wall with no clock of
106
+ its own is dated from that model's bucket, else from the weekly window, because
107
+ a per-model limit is a weekly fact and the five-hour clock would hand the model
108
+ back within the hour. `history` is a ring per bucket, capped by the clock first
109
+ and by 24 samples second: at most one sample a minute, none older than the
110
+ window it was measured in, and started again whenever that window resets (each
111
+ sample carries its own `resets_at`, so a bucket that is missing from one reading
112
+ cannot carry its old samples into the next window). Capping by count alone made
113
+ the forecast vanish from any login with three terminals on it, because three
114
+ pollers fill the ring three times as fast. `extra_usage` is the credits
115
+ sentence, and `facts` holds strings the
116
+ agent measured itself (codex's `plan_type` and `credits_balance`). An older Leg
117
+ reading this file ignores all five, and an agent that publishes no buckets
118
+ leaves them empty.
119
+
75
120
  Where each number comes from is per agent, and is in
76
121
  [adapters.md](adapters.md). The rules on top of them are shared:
77
122
 
@@ -79,15 +124,34 @@ Where each number comes from is per agent, and is in
79
124
  highest percentage across the known windows is the pressure; the hottest
80
125
  window names the warning.
81
126
  - **Wall.** `markLimited()` records `limited_until` from the reset time the CLI
82
- itself reported. With no reset time it uses the soonest known window reset,
83
- and with neither it assumes five hours.
127
+ itself reported. With no reset time, an account wall takes the window that
128
+ actually walled (the highest used percentage, so a weekly wall is not
129
+ recorded as the five-hour window's near reset) and a model wall takes that
130
+ model's own bucket, else the weekly window. With neither it assumes five
131
+ hours.
84
132
  - **Clearing.** A usage reading that arrives after `limited_until` has passed
85
133
  clears the wall.
86
134
 
87
135
  ## Handoff (interactive)
88
136
 
137
+ A hand-off destination is a **rung**, not just an agent: `{agent, account,
138
+ model, when, cost}`. A rung can name a model, so a hand-off can move from
139
+ `claude/fable` to `claude/opus` without leaving the login, or it can leave the
140
+ model out and behave exactly like the plain agent destinations Leg has always
141
+ had. `preferences.json` keeps an ordered list of rungs, the **ladder**, tried
142
+ top first (`src/preferences.mjs`; the keys are documented in
143
+ [configuration.md](configuration.md)). `handoff_order`, the older list of
144
+ agents, is never removed: it is derived from the ladder's distinct agent order
145
+ every time the ladder is saved, so a reader that has never heard of rungs sees
146
+ the same order it always did.
147
+
89
148
  When a session hits its limit, or you press **Hand off now**, Leg does four
90
- things in order (`src/attach.mjs`, `src/bundle.mjs`):
149
+ things in order (`src/attach.mjs`, `src/bundle.mjs`). Which of the two it was
150
+ decides the gates below: a hand-off nobody asked for (the usage limit, and a
151
+ card the scheduler is driving) is **automatic** and keeps the `reserve` and the
152
+ `climb_back` policy; pressing **Hand off now** is a human pick and is not held
153
+ back by either, with or without a named destination, because the default option
154
+ in that picker ("the next option in the order") is still a press of the button.
91
155
 
92
156
  1. **Bundle.** `sessionNotes()` writes the six sections
93
157
  `context-handoff-bundle` parses (Scope, Projects mentioned, Findings,
@@ -99,17 +163,44 @@ things in order (`src/attach.mjs`, `src/bundle.mjs`):
99
163
  in place. A checkpoint runs about every two minutes while the session has
100
164
  turns, and at every warning, limit and hand-off.
101
165
  2. **Choose.** `candidates()` lists the other accounts of the same agent first,
102
- then every other agent in the terminal's saved order. That order is an
103
- absolute priority list, not a rotation anchored on the agent running now:
104
- an agent placed last is tried last whichever agent the terminal started on,
105
- and every option is still tried once. The default order is claude, codex,
106
- agy. `chooseNext()` skips a missing CLI or an option whose wall has not
107
- reset. The board can save a new order for an active terminal; the wrapper reads
108
- it again at the transition and during all-out waiting. Machine Settings is
109
- copied only when a new terminal starts.
110
- 3. **Switch.** The agent process is stopped and the terminal restored. The
111
- bundle's `context-handoff-bundle load <id>` output (with the `## Synthesis`
112
- section prepended if `.leg/SYNTHESIS-<session-id>.md` is present) is written to
166
+ then walks the ladder, and after each rung, the other accounts of that
167
+ rung's agent. `evaluateLadder()` walks that list top to bottom and takes the
168
+ first rung that clears every gate, checked in this order: the agent is
169
+ installed on this machine; the rung was not excluded for this hand-off; its
170
+ live cost (`rungCost()`, computed from usage, never trusted from disk) is
171
+ `free` or `plan`, or `may_spend` is on, else the rung is skipped with the
172
+ reason `it spends usage credits and you have not allowed that`; the rung
173
+ sits on the same login the terminal just fell off of and what stopped that
174
+ login is its own account-wide window, in which case another model there
175
+ cannot help and the rung is skipped as `shares the window that is out, buys
176
+ nothing`, whatever model it names; the account itself is not walled; the
177
+ model, if the rung names one, is not walled; on an automatic hand-off with
178
+ `climb_back` set to `never`, the rung is not a stronger model of the login
179
+ the terminal just left (a human's own pick still reaches it; only an
180
+ automatic climb is held back); the rung's login is not past its `reserve`
181
+ floor; and its `when` is satisfied (`always`; `below:N`, which needs the
182
+ rung's own bucket to read under N percent and is skipped with a reason when
183
+ there is no reading, because a threshold on a login with no figure is a
184
+ wrong number in disguise; `walled-only`, which only opens once no rung above
185
+ it could take the hand-off anyway: walled, at 100 percent, not installed on
186
+ this machine, or refused for this hand-off, but not merely slow and not
187
+ merely dearer than `may_spend` allows). Every rung the walk passes before the one it picks is
188
+ named in the session's ledger with its reason, one line each, for example
189
+ `skipped claude/fable: it spends usage credits and you have not allowed
190
+ that`; a rung is never passed over silently. The board can save a new
191
+ ladder for an active terminal; the wrapper reads it again at the transition
192
+ and during all-out waiting. Machine Settings is copied only when a new
193
+ terminal starts.
194
+ 3. **Switch.** The agent process is stopped and the terminal restored. A
195
+ same-login move to a weaker claude model (a downshift, by the
196
+ `fable, opus, sonnet, haiku` order) with the agent's own session id on the
197
+ record keeps the conversation instead: Leg runs
198
+ `claude --resume <agent_session_id> --model <alias>`, no bundle is written
199
+ into a prompt, and the ledger says so. Every other rung, including an
200
+ upshift back to a stronger model, a different account, or a different
201
+ agent, takes the bundle: the `context-handoff-bundle load <id>` output
202
+ (with the `## Synthesis` section prepended if
203
+ `.leg/SYNTHESIS-<session-id>.md` is present) is written to
113
204
  `.leg/RESUME-<session-id>.md` and copied to `.leg/RESUME.md`, and the next
114
205
  agent starts in the same terminal with a short pointer prompt as its first
115
206
  positional argument: `claude "<prompt>"`, `codex "<prompt>"`,
@@ -125,6 +216,13 @@ things in order (`src/attach.mjs`, `src/bundle.mjs`):
125
216
  (`{ agent, account, resets_at, since }`) and shows status `waiting`. Ctrl-C
126
217
  in the terminal, or End on the card, quits with exit 3.
127
218
 
219
+ `climb_back` decides what happens once a lower rung's own login recovers.
220
+ `next-handoff`, the default, needs no extra step: `chooseNext()` always walks
221
+ from rung 1, so the next time this terminal hands off it is offered the
222
+ higher rung again. `never` holds a terminal on the rung it downshifted to
223
+ until a human hands it off there by name; an automatic hand-off will not walk
224
+ back up on its own.
225
+
128
226
  ## The portable harness
129
227
 
130
228
  Off by default. `leg harness enable` adds a step to the hand-off, between
@@ -218,8 +316,54 @@ working; `leg share rotate <name>` replaces one.
218
316
 
219
317
  ## Cards, stations and pipelines
220
318
 
221
- Everything from here down is the v0.1 pipeline: headless agents in a git
222
- worktree, one per card. It has not changed since 0.2.0 and is not the way in.
319
+ A **card is a terminal you are not sitting at.** It has the same register
320
+ (state, where, model), the same one sentence, the same buttons, the same
321
+ fallback ladder and the same hand-off bundle as a terminal. Two differences
322
+ are real, and the board prints both: a card runs `-p --output-format json`,
323
+ which says nothing until the leg exits, so its sentence reads `no message
324
+ until this leg ends, started 11:04 PM`; and it never waits on a permission
325
+ prompt, because its permissions are decided before it starts.
326
+
327
+ Liveness decides where a card is drawn. A card in `backlog`, `queued`,
328
+ `running`, `handing_off`, `needs_approval`, `waiting_human` or `paused` is a
329
+ row in the Background panel under Terminals. A card in `done`, `failed` or
330
+ `killed` is one line in the ledger: `3 finished cards, 2 done, 1 failed, last
331
+ 11:02 PM`. Ten finished cards are one row, not eleven.
332
+
333
+ A live card's row carries a **work stat**, and only the parts of it that were
334
+ measured: `4 files, +212 -18, tests green 6m ago`. The diff comes from `git
335
+ diff --shortstat <trunk>..HEAD` run in the card's own worktree; the test and
336
+ land verdicts come from that card's ledger. A part that was not measured is
337
+ left off, never estimated.
338
+
339
+ ### A terminal becomes a card, and a card becomes a terminal
340
+
341
+ `End, and keep going as a card` (the second verb on a terminal's End confirm
342
+ row, `POST /api/sessions/<id>/end-as-card`) is for "I have to leave, keep
343
+ going". It writes the terminal's hand-off bundle at the path every hand-off
344
+ uses, creates a card whose task is the terminal's prompt plus `Continue from
345
+ the bundle at <path>.`, and then ends the terminal exactly the way End does.
346
+ The card **continues in the terminal's own worktree** rather than a fresh one:
347
+ two worktrees on one branch is a conflict machine, and the card carries
348
+ `worktree_adopted: true` so no later run cuts a second one over the top. Its
349
+ chain starts at the rung the terminal was standing on, not at the top of the
350
+ ladder, and `lineage.from` names the terminal it came from.
351
+
352
+ `Take over` in a card's expansion (`POST /api/cards/<id>/take-over`) is the way
353
+ back. It pauses the card, which kills its child and writes its bundle, then
354
+ hands back one command:
355
+
356
+ ```
357
+ leg claude --resume-card card-20260917-2234-add-the-audit-csv-export
358
+ ```
359
+
360
+ That opens an ordinary interactive terminal in the card's worktree, primed
361
+ from the card's bundle, with `lineage.from` naming the card. This is the one
362
+ place Leg hands a human a command to paste, because a terminal cannot be
363
+ opened from a browser tab, and the board says so on the line above it.
364
+
365
+ Everything from here down is the v0.1 pipeline mechanism: headless agents in a
366
+ git worktree, one per card.
223
367
 
224
368
  A **card** is one task moving through a **pipeline**: an ordered list of
225
369
  **stations**. A station has a `kind`:
@@ -345,7 +489,9 @@ Alongside raw state, Leg supports an agent-maintained judgment record in `.leg/S
345
489
  ## Worktrees
346
490
 
347
491
  Every card runs in its own git worktree: `<repo>/.leg-worktrees/<card-id>`
348
- on branch `leg/<card-id>` (`src/worktree.mjs`). The repo root is never
492
+ on branch `leg/<card-id>` (`src/worktree.mjs`), with one exception: a card born
493
+ from `End, and keep going as a card` adopts the terminal's worktree and works
494
+ there, exactly where the terminal stopped. The repo root is never
349
495
  touched by an agent directly. Every git call sets `MSYS_NO_PATHCONV=1` so
350
496
  Git Bash on Windows does not rewrite absolute path arguments. Leg never
351
497
  pushes, opens a remote, or removes a path outside
@@ -423,7 +569,9 @@ status (`backlog`, `queued`, `running`, `handing_off`, `waiting_human`,
423
569
  `needs_approval`, `paused`) straight to `killed`, and `rerun` moves any
424
570
  terminal status (`done`, `failed`, `killed`) back to `queued` (station 0,
425
571
  leg 0). `reassign` also applies to any non-terminal status when the current
426
- station is an agent station, staying in `queued`.
572
+ station is an agent station, staying in `queued`, and `take_over` moves any
573
+ non-terminal status to `paused` (a human is taking the card's checkout, so the
574
+ scheduler must not start a leg in it).
427
575
 
428
576
  ```mermaid
429
577
  stateDiagram-v2
@@ -33,7 +33,8 @@ These apply to `leg claude|codex|agy|grok`.
33
33
  | `LEG_NO_OPEN` | (unset, opens once) | set to `1` to start the board without opening a browser | `bin/leg.mjs` |
34
34
  | `LEG_NO_BOARD` | (unset) | set to `1` to run a session with no board at all (the record under `$LEG_HOME/sessions/` is still kept; the test suite uses this) | `src/attach.mjs` |
35
35
  | `LEG_WAIT_TICK_MS` | `1000` | how often the all-out countdown redraws and re-checks Ctrl-C / End while waiting for the first reset | `src/attach.mjs` |
36
- | `LEG_USAGE_POLL_MS` | `60000` | how often an active attach polls its usage source; Claude uses its usage endpoint and Codex uses read-only app-server rate limits | `src/attach.mjs` |
36
+ | `LEG_USAGE_POLL_MS` | `60000` | how often the board asks each login's usage source (one poller per login, not one per terminal); Claude uses its usage endpoint, Codex read-only app-server rate limits, Grok its billing proxy | `src/usage-poll.mjs` |
37
+ | `LEG_USAGE_POLL_MAX_MS` | `600000` | the longest the board waits between readings for one login: a refusal (429, timeout, no login) doubles the wait up to this, and the first good reading puts it back to `LEG_USAGE_POLL_MS` | `src/usage-poll.mjs` |
37
38
  | `LEG_ATTACH_POLL_MS` | `2000` | how often the session loop re-reads the taps; git is re-read every third poll | `src/attach.mjs` |
38
39
  | `LEG_CLAUDE_USAGE_URL` | `https://api.anthropic.com/api/oauth/usage` | the usage endpoint, for a test double | `src/taps/claude-usage.mjs` |
39
40
  | `LEG_CLAUDE_ARGS`, `LEG_CODEX_ARGS`, `LEG_AGY_ARGS` | (none) | space-separated extra arguments for a leg Leg starts on its own after a hand-off (your own `leg <agent> …` args never apply to the next agent); e.g. `LEG_CODEX_ARGS="-m gpt-5.3-codex-spark"` keeps a test chain on cheap models | `src/attach.mjs` |
@@ -65,6 +66,69 @@ To opt out and keep standard approval prompts:
65
66
  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
67
  3. Persistent preference: set `"auto_approve": false` in `~/.leg/preferences.json`.
67
68
 
69
+ ### The hand-off ladder
70
+
71
+ `~/.leg/preferences.json` also holds the ladder a terminal falls down when its
72
+ login stops (`src/preferences.mjs`), alongside the older `handoff_order`:
73
+
74
+ ```json
75
+ {
76
+ "handoff_order": ["claude", "codex", "agy"],
77
+ "handoff_ladder": [
78
+ { "agent": "claude", "account": "default", "model": "fable", "when": "always", "cost": "plan" },
79
+ { "agent": "claude", "account": "default", "model": "opus", "when": "always", "cost": "plan" },
80
+ { "agent": "claude", "account": "default", "model": "sonnet", "when": "always", "cost": "plan" },
81
+ { "agent": "codex", "account": "default", "model": null, "when": "always", "cost": "plan" },
82
+ { "agent": "agy", "account": "default", "model": null, "when": "always", "cost": "free" }
83
+ ],
84
+ "climb_back": "next-handoff",
85
+ "may_spend": false,
86
+ "reserve": {}
87
+ }
88
+ ```
89
+
90
+ | key | default | meaning |
91
+ |-----|---------|---------|
92
+ | `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 a model id that agent publishes: claude's four aliases are a closed list (`fable`, `opus`, `sonnet`, `haiku`, from `src/buckets.mjs` `MODEL_ALIASES`) and a fifth word is refused by name, while codex, agy and grok take any id from their own live catalog (`src/models.mjs` reads each CLI's, and the board's model picker offers it) as long as it matches the shape gate: lower-case letters, digits, `.`, `_`, `:` and `-`, 64 characters at most. `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` |
93
+ | `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 |
94
+ | `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 |
95
+ | `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 |
96
+
97
+ A fresh install with no `preferences.json` starts with `claude/fable`,
98
+ `claude/opus`, `claude/sonnet`, then every other installed agent from
99
+ `handoff_order` with `model: null`.
100
+
101
+ Leg writes `handoff_ladder` and `handoff_order` together and keeps them in
102
+ step: saving a ladder rewrites `handoff_order` from its distinct agent order,
103
+ and saving a bare `handoff_order` rewrites the ladder as one `model: null`
104
+ rung per agent, so an older install behaves exactly as it did until a rung is
105
+ edited. If the file is hand-edited so the two disagree, `handoff_order` wins
106
+ and the ladder is rebuilt from it, because the order is the shape a hand
107
+ edit is more likely to have meant.
108
+
109
+ A rung Leg cannot read (a misspelt claude alias, an account this machine no
110
+ longer has) is dropped on its own and the rest of the ladder is kept, and Leg
111
+ never writes that shortened ladder back: the file keeps the rungs you typed
112
+ until you save a ladder yourself, from the board or with `leg ladder set`.
113
+
114
+ `leg ladder` (`leg ladder ls`) prints the ladder with each rung's live state;
115
+ `leg ladder set <n> <agent>[/<account>[/<model>]]`, `leg ladder rm <n>` and
116
+ `leg ladder spend on|off` change it. See [cli-contracts.md](cli-contracts.md).
117
+
118
+ ### Notifications
119
+
120
+ `~/.leg/preferences.json` also holds two toggles for a terminal that is
121
+ waiting on you (`src/preferences.mjs` `defaults()`):
122
+
123
+ | key | default | meaning |
124
+ |-----|---------|---------|
125
+ | `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 |
126
+ | `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 |
127
+
128
+ Both are set from Settings on the board, or by writing the key directly. The
129
+ tab title badge (`(1) Leg` plus a favicon dot) has no toggle: it needs no
130
+ permission, so it is always on.
131
+
68
132
  ## Core
69
133
 
70
134
  | variable | default | meaning | read in |