@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.
- package/CHANGELOG.md +213 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +18 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +68 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +529 -96
- package/docs/cli-contracts.md +241 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +65 -1
- 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/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.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/board-jump-probe.mjs +335 -0
- 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 +378 -93
- package/src/audit.mjs +1 -1
- package/src/board/board.css +203 -11
- package/src/board/board.js +664 -200
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +122 -45
- package/src/board/sessions.js +1569 -141
- package/src/board/strip.js +163 -0
- 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/models.mjs +265 -0
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +278 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +625 -78
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +107 -3
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +439 -12
package/docs/cli-contracts.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
83
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
it
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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`)
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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 |
|