@ucsandman/legcli 0.9.0 → 0.11.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 (52) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +110 -11
  3. package/bin/leg.mjs +78 -15
  4. package/docs/ERRORS.md +187 -0
  5. package/docs/README.md +3 -1
  6. package/docs/ROADMAP-v2.md +24 -11
  7. package/docs/VOCABULARY.md +1 -0
  8. package/docs/adapters.md +93 -11
  9. package/docs/board-guide.md +20 -1
  10. package/docs/cli-contracts.md +50 -17
  11. package/docs/configuration.md +56 -5
  12. package/docs/history.md +172 -0
  13. package/docs/runtime-tap.md +156 -0
  14. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  15. package/fixtures/live/grok/cmd.txt +1 -1
  16. package/fixtures/live/grok/parsed.json +6 -3
  17. package/fixtures/live/grok/run.json +22 -10
  18. package/fixtures/verified.json +8 -1
  19. package/package.json +1 -1
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/probe.mjs +2 -1
  22. package/src/accounts.mjs +5 -2
  23. package/src/adapters/cli.mjs +130 -0
  24. package/src/adapters/custom.mjs +271 -0
  25. package/src/adapters/grok.mjs +51 -10
  26. package/src/adapters/index.mjs +34 -7
  27. package/src/attach.mjs +85 -13
  28. package/src/audit.mjs +118 -0
  29. package/src/board/audit.js +123 -0
  30. package/src/board/board.css +38 -1
  31. package/src/board/board.js +14 -2
  32. package/src/board/history.js +377 -0
  33. package/src/board/index.html +55 -0
  34. package/src/board/sessions.js +49 -7
  35. package/src/history/cli.mjs +159 -0
  36. package/src/history/common.mjs +119 -0
  37. package/src/history/index.mjs +429 -0
  38. package/src/history/providers/agy.mjs +91 -0
  39. package/src/history/providers/claude.mjs +161 -0
  40. package/src/history/providers/codex.mjs +133 -0
  41. package/src/history/providers/copilot.mjs +94 -0
  42. package/src/history/providers/grok.mjs +138 -0
  43. package/src/history/worktrees.mjs +116 -0
  44. package/src/redact.mjs +23 -5
  45. package/src/server.mjs +272 -28
  46. package/src/sessions.mjs +9 -0
  47. package/src/share.mjs +66 -6
  48. package/src/taps/claude.mjs +11 -4
  49. package/src/taps/grok.mjs +4 -0
  50. package/src/taps/mod.mjs +340 -0
  51. package/src/usage.mjs +21 -5
  52. package/src/worktree.mjs +1 -1
package/docs/ERRORS.md CHANGED
@@ -3,6 +3,139 @@
3
3
  What broke, why, and what fixed it. One entry per failure, newest first. A first
4
4
  occurrence has to be written down or a repeat is never countable.
5
5
 
6
+ ## 2026-09-17: a new field on the session view leaked the owner's reset times to a guest, through the guest's own terminal
7
+
8
+ **Fixed in `src/server.mjs` (`sessionsView` decides `guest` before the map and
9
+ blanks `handoff_targets[].resets_at` for one). Caught by
10
+ `test/share-security.test.mjs`, which was already asserting it.**
11
+
12
+ The hand-off picker needed each destination's availability, so `handoff_targets`
13
+ went onto every session in `sessionsView` carrying `resets_at`. The obvious
14
+ mental model was "a guest gets `redactSession`, which lists its fields
15
+ explicitly, so a new field is invisible to them". That is only true of someone
16
+ else's terminal. `mine(s)` is true for a guest's **own** terminal, and that path
17
+ spreads the whole object. So a guest's own card carried the exact reset
18
+ timestamp of every account on the machine, including the owner's, which is
19
+ precisely the usage data the share design keeps off a guest's board.
20
+
21
+ The lesson is about where redaction lives, not about this field. There are two
22
+ paths out of `sessionsView`: `redactSession` (allow-list, safe by default) and
23
+ the `mine(s)` spread (deny-list, unsafe by default). **Any field added to a
24
+ session object is visible to whoever owns that session, and a guest owns one.**
25
+ A field that carries machine-level data has to be blanked where it is built, not
26
+ left to a redactor that never sees it.
27
+
28
+ What made this cheap: the security suite already asserted the whole guest
29
+ response text contains no reset time, so the leak failed a test in the same run
30
+ that introduced it. The test was written against the property ("a guest board
31
+ carries no reset time"), not against the fields that existed when it was
32
+ written, which is why it still caught a field invented months later.
33
+
34
+ Two smaller ones from the same change, both worth the line:
35
+
36
+ - Registering `grok` broke three tests that asserted `unknown adapter "grok"`.
37
+ A test that encodes "not supported yet" as an assertion becomes a tripwire on
38
+ the day support lands. Assert the refusal with a name nothing will ever
39
+ provide (`no-such-agent`), so the test outlives the gap it was describing.
40
+ - `names()` started reading `$LEG_HOME/adapters` from disk, and `/api/health`
41
+ calls it once for the list and once per adapter. That put a readdir, a read
42
+ and a JSON parse per spec on the same event loop the terminals lane is pushed
43
+ from, and `/api/health` went over its 1 s budget in
44
+ `test/board-responsiveness.test.mjs`. Cached against the directory's entry
45
+ list with a one-second floor. The board's hot path is `/api/health` plus the
46
+ sessions view; anything new they call has to be counted, not assumed cheap.
47
+ (This is the same event-loop failure as the entry below, from the other end.)
48
+ - `test/board-responsiveness.test.mjs` failed the ship twice at 1055 ms and
49
+ 1140 ms against a hard `< 1000 ms`, and passed three times out of three when
50
+ run alone. An absolute millisecond budget on a four-way-concurrent runner
51
+ measures the machine, not the code. It now takes an idle baseline in the same
52
+ process and asserts the busy request is not 20x it, with a 3 s ceiling for the
53
+ symptom the test is named for. **It was only trusted after being made to
54
+ fail**: putting the original shape back (no floor, no fingerprint, a 1.2 s
55
+ blocking view on every watcher event) made it report 4,810 ms and fail both
56
+ assertions, which is the "four to fourteen seconds" the entry below describes.
57
+ A perf test that has never been watched failing is a number, not a guard.
58
+
59
+ ## 2026-09-17: one running terminal saturated the board's event loop, and four separate symptoms came out of it
60
+
61
+ **Fixed in `src/server.mjs` (watcher filter, stat fingerprint, push floor, cached
62
+ `canLand`), `src/attach.mjs` (a taken port is a board; always open it) and
63
+ `src/board/{board,sessions}.js` (one render per push, hold an open confirm row).
64
+ Regression tests: `test/board-responsiveness.test.mjs`.**
65
+
66
+ Reported as four bugs: `leg claude` took a long time to start, the board never
67
+ opened by itself, the board was slow to load and to react, and Remove under
68
+ Terminals did nothing. All four were one cause. The board server had burned
69
+ 17,080 seconds of CPU — it answered `/api/health` in 7 to 14 seconds and a
70
+ 40 KB stylesheet in 14. A live agent rewrites its session record every six
71
+ seconds or so and takes a control lock about once a second; the recursive
72
+ watcher over the sessions directory answered every one of those — including
73
+ `.control.lock`, `.session.lock` and the `session.json.*.tmp` files atomic
74
+ writes leave behind — with a full rebuild of the terminals view, which costs
75
+ about two seconds (1.1 s of it `canLand` shelling out to git across twenty
76
+ worktrees). 105 filesystem events in 30 seconds against a 300 ms debounce and a
77
+ 2,000 ms rebuild: the queue could never drain.
78
+
79
+ What that one stall produced:
80
+
81
+ - **Slow start.** `ensureBoard`'s health probe times out at 2 s, so a merely
82
+ busy board read as no board. `leg` spawned a second server, which died of
83
+ `EADDRINUSE` (the evidence was already in `~/.baton/board.log`), then polled
84
+ the dead child every 200 ms for the full 15 s.
85
+ - **The board never opened.** `openBoard(url)` sat only on the branch that
86
+ successfully started a server. With one already running, or on the 15 s
87
+ give-up path, nothing was ever opened.
88
+ - **Slow to react.** `board.js` dispatched `leg:sessions` and `baton:sessions`
89
+ per push, parsing the quarter-megabyte payload twice, with the second
90
+ dispatch outside the `state.es === es` staleness guard (missing braces on a
91
+ one-line `if`). `sessions.js` was registered on `leg:sessions` twice and on
92
+ the alias once. One push rebuilt the whole grid three times.
93
+ - **Slow to react, part two.** `renderSessions` starts with
94
+ `grid.textContent = ''`, so with three rebuilds per push every button was
95
+ destroyed and recreated several times a second.
96
+
97
+ **Remove was a second, independent bug, and fixing the first did not fix it.**
98
+ Reported again as "it's still not removing them" after all of the above
99
+ shipped. `confirmRow` does `pendingConfirm = null; onYes(yes)`, and the call
100
+ site passed `(btn) => act(s.session_id, pendingConfirm.action, btn)` — a
101
+ closure over the module variable, not over its value. Every Yes on the
102
+ Terminals panel threw `TypeError: Cannot read properties of null (reading
103
+ 'action')` before reaching `act()`, so Remove, Remove record, End and Land had
104
+ never worked from the confirm row; the exception went to the console and
105
+ nowhere the reader would see it. Fixed by snapshotting
106
+ `const pending = pendingConfirm` before building the row. Caught by driving the
107
+ real page: the click produced zero network requests, which pointed at the
108
+ handler rather than at anything server-side. The instrument that found it was
109
+ five lines — wrap `window.fetch`, listen for `window.onerror`, click, read
110
+ both.
111
+
112
+ Three lessons. First, a name filter is not enough to classify a filesystem event:
113
+ taking a lock inside a directory changes that directory's own mtime, and the
114
+ event arrives naming only the directory. The watcher now treats an event as a
115
+ hint and compares a stat fingerprint of the files the view is really built from
116
+ — sub-millisecond, against a 2 s rebuild. Second, the CPU counter on a
117
+ long-lived process names a stall in one command, before any code is read.
118
+
119
+ Third, and the one that cost a round trip: several symptoms reported together
120
+ are not thereby one bug. Three of these four were, and finding that cause made
121
+ the fourth *look* explained — the render churn is a real way to drop a click,
122
+ so "Remove doesn't work" fit the story. It was a different bug in a different
123
+ file, and it was still there after the fix shipped. A symptom is only closed
124
+ once it has been driven and watched, not once a plausible cause for it has been
125
+ found and fixed. Clicking Remove in the real page would have taken two minutes
126
+ and would have shown zero network requests and a `TypeError` in the console.
127
+
128
+ Verified by running the new tests against `da453b7`: 7 of 8 fail there, 8 of 8
129
+ pass after, and the busy-board test takes 21 s before versus 4 s after. On the
130
+ live board, same 66 sessions: `/api/health` 14.19 s → 2.4 ms, `/board.css`
131
+ 14.24 s → 1.5 ms, `/api/sessions` 15.64 s → 109 ms, view rebuild 2,049 ms →
132
+ 115 ms, renders per push 3 → 1.
133
+
134
+ Also found while verifying: `npm run lint` reported "ESLint: No issues found"
135
+ with `node_modules/.bin` empty and eslint not installed — a wrapper was printing
136
+ it. Run `npm install` first and check that ESLint really ran; a lint result from
137
+ a tool that is not on disk is not a lint result.
138
+
6
139
  ## 2026-09-16: a hand-edited `package.json` version left `package-lock.json` behind, and the publish gate refused 0.9.0
7
140
 
8
141
  **Fixed by bumping the lockfile root version; the gate is `scripts/npm-publish-gate.mjs`.**
@@ -395,3 +528,57 @@ it), and check whether a board was listening on 4747 at the time.
395
528
  module that reads the environment at import time cannot be imported from a
396
529
  helper that runs before the environment is set, no matter how small the thing
397
530
  you wanted from it.
531
+
532
+ ## A 3,000-line feature landed with red CI on Windows and no docs page (2026-09-16)
533
+
534
+ - **Symptom.** Commit `c7f24a0` (the history index, committed by the agy
535
+ session that took over after a Claude usage limit) went green on Ubuntu and
536
+ macOS and red on Windows in two `history.test.mjs` cases, and the `docs` job
537
+ failed with "site/ is out of date". `/docs/history` did not exist and every
538
+ link to `history.md` pointed at the npm package page.
539
+ - **Root cause, tests.** GitHub's Windows runner hands out an 8.3 short TEMP
540
+ path (`RUNNER~1`) while git reports the long form (`runneradmin`). The two
541
+ assertions compared a `mkdtemp` path with a path that had been through git.
542
+ The repo already had the seam for this (`canonPath` in `src/fsx.mjs`,
543
+ used the same way in `handoff-order.test.mjs`); the new tests did not use it.
544
+ It cannot be seen on a developer machine with 8.3 names disabled.
545
+ - **Root cause, docs.** `scripts/build-docs-site.mjs` builds from a hard-coded
546
+ `PAGES` list. A new `docs/*.md` that is not in it gets no page, and
547
+ `rewriteHref` sends its links to the npm URL by design. Nobody ran
548
+ `npm run docs` before pushing, so the docs job was the first to notice.
549
+ - **Fix.** `ebda794`: both sides of the four path assertions through
550
+ `canonPath`; reproduced locally by pointing `TEMP` at a case-altered path
551
+ (seen failing, then 16/16); `history` registered in `PAGES`; site rebuilt.
552
+ - **The lesson that generalises.** A test that compares two filesystem paths
553
+ on Windows compares two spellings of the same folder unless both go through
554
+ `canonPath`. And a new `docs/*.md` is two edits, the file and the `PAGES`
555
+ entry, then `npm run docs`; the docs job exists because the second and third
556
+ get forgotten.
557
+
558
+ ## The review that was in flight when the limit hit never ran; the feature shipped with 38 defects (2026-09-17)
559
+
560
+ - **Symptom.** `c7f24a0` (history discovery) was committed and pushed by the
561
+ agent that took over after a Claude usage limit. The nine-lens adversarial
562
+ review the Claude session had launched died with that session. Re-run the
563
+ next day it confirmed 38 findings, 3 high: the board's Conversations cell
564
+ could never page past 150 rows (the total was counted after the cursor
565
+ slice), an unreachable UNC cwd blocked a refresh for seconds per record,
566
+ and an Antigravity retitle was invisible to every incremental refresh
567
+ (the directory's mtime was the cache key; an in-place rewrite never moves
568
+ it).
569
+ - **Root cause.** The hand-off bundle carried "what was established" and
570
+ "next moves" but not "what was in flight": a background workflow is not a
571
+ file, so the next agent saw a clean-looking task and shipped it. Nothing
572
+ in the ship path asks whether the change was reviewed.
573
+ - **Fix.** `582dfe5`: 20 findings closed, each with a test that fails on the
574
+ previous source (proved on a detached worktree of HEAD before the fix).
575
+ - **Left open, by choice.** Coverage-only findings (live markers, the
576
+ write-scope guard, the flag surfaces, extra accounts), a low-severity
577
+ dedup edge (a conversation split over two Claude transcript files when Leg
578
+ recorded only the older path), the `sessions ls` row format, and the
579
+ pretty-printed index file.
580
+ - **The lesson that generalises.** A review is part of the change, not a
581
+ step after it: launch it before the feature is committable and record it
582
+ in the hand-off as in flight, or the next agent ships without it. And a
583
+ cache keyed on a directory's mtime sees files added and removed, never a
584
+ file rewritten in place.
package/docs/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  - [board-guide.md](board-guide.md): the instrument head (a row per login, two window rails each, the 85 percent post), the Terminals panels, overlap flags, Landed on main, Background tasks, Settings, and the floor view.
8
8
  - [configuration.md](configuration.md): every environment variable, the accounts layout, `.env`, network exposure, card-level options.
9
9
  - [harness.md](harness.md): the portable harness, off by default: what moves between agents and what does not, the first run, policies, ownership and backups, secrets, the evidence trail, and how Leg relates to the Agnostic AI engine it embeds.
10
+ - [history.md](history.md): `leg history` and `leg worktrees`, one read-only index over every agent's own conversation store and every checkout, the support matrix per provider, dedup against Leg's own sessions, privacy, and how to add a provider.
10
11
  - [adapters.md](adapters.md): what Leg reads from each CLI in an interactive session, each adapter's headless argv, modes, forbidden flags, gotchas, and how to add a new one.
11
12
  - [faq.md](faq.md): short answers to real questions (the status line, codex's missing hook, agy's missing percentage, second accounts, uninstall, limits, secrets, Windows support).
12
13
 
@@ -25,7 +26,8 @@
25
26
  | interactive sessions | `src/attach.mjs` (the `leg <agent>` runner), `src/sessions.mjs` (the session store), `src/usage.mjs` (usage windows and the chooser), `src/accounts.mjs` (extra logins), `src/bundle.mjs` (the per-session bundle), `src/hook.mjs` (what Claude Code's hooks run) |
26
27
  | portable harness | `src/harness/index.mjs` (capture, compare, apply, status, the hand-off decision), `src/harness/registry.mjs` (which clients, where their files are), `src/harness/fingerprint.mjs`, `src/harness/cli.mjs` (`leg harness`), `src/harness/vendor/agnostic-ai/` (the engine, verbatim; `scripts/sync-harness-engine.mjs` is the only writer) |
27
28
  | taps | `src/taps/claude.mjs`, `src/taps/claude-usage.mjs`, `src/taps/codex.mjs`, `src/taps/agy.mjs` |
28
- | board | `src/server.mjs`, `src/board/sessions.js` (Terminals lane), `src/board/board.js` and `src/board/floor.js` (pipelines) |
29
+ | history | `src/history/index.mjs` (the index, dedup, resume), `src/history/providers/*.mjs` (one per agent store, read only), `src/history/worktrees.mjs` (the unified checkout view), `src/history/cli.mjs` (`leg history`, `leg worktrees`) |
30
+ | board | `src/server.mjs`, `src/board/sessions.js` (Terminals lane), `src/board/history.js` (Conversations), `src/board/board.js` and `src/board/floor.js` (pipelines) |
29
31
  | pipelines | `src/orchestrator.mjs`, `src/scheduler.mjs`, `src/chain.mjs`, `src/pipeline.mjs`, `src/runner.mjs`, `src/ledger.mjs`, `src/leases.mjs`, `src/mergequeue.mjs`, `src/adapters/*.mjs` |
30
32
 
31
33
  ## History
@@ -1,5 +1,13 @@
1
1
  # Roadmap v2: the software factory
2
2
 
3
+ **Unreleased (2026-09-17) opened the chain and finished the share story.** grok is
4
+ a registered card adapter; any other CLI becomes one from a JSON spec
5
+ (`leg adapter add`, no code); **Hand off now** can name its destination
6
+ instead of taking the next in the order; `leg share` grew TLS from a
7
+ certificate pair you supply, an `operator` role between owner and guest, and
8
+ an audit trail of who did what across every terminal and every card. That
9
+ closes items 1 and 4 below, and the "hand off now to \<adapter>" picker.
10
+
3
11
  **0.3.0 (2026-09-11) shipped item 1 and the first half of continuous landing.**
4
12
  A second live session in one checkout gets its own worktree and branch, and
5
13
  **Land** on its card sends that branch through the merge queue (rebase, tests,
@@ -33,23 +41,28 @@ event names its actor, a board that reads only the ledger, and an auth/bind seam
33
41
 
34
42
  ## Next
35
43
 
36
- 1. **Multi-human network access**, shipped in 0.3.0 as `leg share` (a token
37
- and a name per human, per-human actor ids on every event, rate limits, a
38
- guest's read-only redacted board). What is left: TLS termination, token
39
- scopes finer than owner and guest, and an audit view of who did what across
40
- sessions.
44
+ 1. ~~**Multi-human network access**~~ done. 0.3.0 shipped `leg share`; the
45
+ unreleased work of 2026-09-17 shipped the three that were left: TLS (`leg share on --tls-cert/--tls-key`,
46
+ or `LEG_TLS_CERT`/`LEG_TLS_KEY`, from a pair you supply Leg issues none),
47
+ the `operator` role between owner and guest, and the audit trail
48
+ (`/api/audit`, Settings → Audit trail).
41
49
  2. **Review station with human reviewers**: a `human` station kind that shows
42
50
  the diff, the bundle and the test tail, with Approve / Request changes /
43
51
  Reassign as buttons; reviewer identity from the token.
44
52
  3. **`pr` land mode live**: `gh pr create` argv is built and stub-tested today;
45
53
  run it for real behind an explicit per-card opt-in and a remote allowlist.
46
- 4. **More adapters**: grok (adapter written, unregistered until `grok login` and
47
- a passing probe on the machine) and muse only if a real CLI is verified; a
48
- generic "argv + JSON result" adapter for anything else.
54
+ 4. ~~**More adapters**~~ done. grok is registered (2026-09-17: flags read
55
+ from `grok --help` on 1.0.34, envelope read from the shipped binary, probe
56
+ reached the account and returned a real 402 wall that classified `limit`;
57
+ the success path is still unprobed for want of balance). Anything else,
58
+ muse included, is a custom adapter: a JSON spec in
59
+ `$LEG_HOME/adapters/<name>.json`, `leg adapter add`, no code
60
+ ([adapters.md](adapters.md#custom-adapters)).
49
61
  5. **OpenClaw Workboard mirror** once the bundled plugin is allowed
50
62
  (`plugins.allow`): the verb table in `src/sync/workboard.mjs` is the only
51
63
  thing to check against `openclaw workboard --help`.
52
64
 
53
- Also on the list: per-station prompt templates editable from the board, a
54
- "hand off now to <adapter>" picker, lease suggestions from the diff of the
55
- previous leg, and a floor view that shows lease contention over time.
65
+ Also on the list: per-station prompt templates editable from the board, lease
66
+ suggestions from the diff of the previous leg, and a floor view that shows
67
+ lease contention over time. (The "hand off now to \<adapter>" picker landed
68
+ 2026-09-17, unreleased: Details → **Hand off now to**, or `leg sessions handoff <id> --to`.)
@@ -47,6 +47,7 @@ Source: the `appendEvent`/`updateSession` call sites in `src/attach.mjs`,
47
47
  | `handoff` | the switch happened: from, to, reason, bundle id |
48
48
  | `all_out` | every option is walled; the resets are printed and the terminal waits for the first one (`ended` with "quit while waiting" if Ctrl-C or End cuts the wait short, exit 3) |
49
49
  | `agent_exit` | the agent process exited, with its code |
50
+ | `continued` | the session was started by `leg history continue` on a conversation the agent's own store holds; `session.json` carries `continued_from: { id, provider, native_id }` |
50
51
  | `ended` | the session ended |
51
52
  | `lost` | the runner pid is gone; the session was marked `lost` |
52
53
  | `error` | a spawn error, a tap error, a failed bundle checkpoint, or an error the agent reported |
package/docs/adapters.md CHANGED
@@ -239,25 +239,107 @@ These are what a pipeline card's chain spawns. Unchanged since 0.2.0.
239
239
  `emulates` to that CLI's name, so the limit classifier applies that CLI's own
240
240
  fixtures to it and a demo chain reads like a real fallback.
241
241
 
242
- ### grok (built, not registered)
242
+ ### grok (headless)
243
243
 
244
244
  - **Binary**: `LEG_GROK_BIN`, else `~/.grok/bin/grok.exe`, else `grok` on
245
245
  PATH.
246
- - **Argv**: `grok -p "<prompt>" --output-format json --permission-mode
247
- <mode>`, plus `-m <model>`, `-r <session-id>`.
246
+ - **Argv**: `grok --prompt-file <run>/prompt.txt --output-format json
247
+ --permission-mode <mode> --cwd <worktree>`, plus `--max-turns <n>`,
248
+ `-m <model>`, `-r <session-id>`. Every flag was read from `grok --help` on
249
+ grok 1.0.34 (`3736acbc8658`) on 2026-09-17. The prompt travels by file
250
+ rather than on argv because a hand-off prompt carries the whole bundle
251
+ summary and Windows caps one command line at about 32k; with no prompt file
252
+ the adapter falls back to `-p "<prompt>"`. `--cwd` is passed explicitly
253
+ rather than relying on the spawn's working directory: grok can run against a
254
+ shared leader process (`~/.grok/leader.sock`), and a leg must edit its own
255
+ worktree, not whatever directory the leader started in.
248
256
  - **Stdin**: `ignore`.
249
257
  - **Modes**: default `acceptEdits`; allowed `default`, `acceptEdits`,
250
258
  `auto`, `dontAsk`, `plan`.
251
259
  - **Forbidden flags**: `--always-approve`, `bypassPermissions`,
252
260
  `--permission-mode=bypassPermissions`.
253
- - **Status**: `src/adapters/grok.mjs` exists and is unit-tested, but is
254
- **not** in `src/adapters/index.mjs`'s registry. The build machine had no
255
- `grok` login: the probe printed a device-code prompt and exited
256
- `Cancelled`. Register it (add an entry to `REGISTRY` in
257
- `src/adapters/index.mjs`) once `grok login` has been completed and
258
- `node scripts/probe.mjs --adapter grok --repo <toy-repo>` passes.
259
-
260
- ## How to add an adapter
261
+ - **Result**: grok's headless writer emits the Claude Code result envelope.
262
+ The field names (`"type":"result"`, `subtype`, `is_error`, `session_id`,
263
+ `result`, `num_turns`, `stop_reason`, `total_cost`) were read out of the
264
+ shipped `grok.exe` on 2026-09-17. An error is the other envelope,
265
+ `{"type":"error","message":…}`, observed live the same day.
266
+ - **Status**: registered on 2026-09-17. The probe reached the account and came
267
+ back with a real wall — `API error (status 402 Payment Required): Grok Build
268
+ usage balance exhausted`, exit 1, classified `limit` and handed off — so the
269
+ spawn, the argv, the auth and the wall path are all verified live
270
+ (`fixtures/live/grok/`). The **success** path of a grok leg is still
271
+ unprobed: that needs balance on the account. Until it is, a grok leg whose
272
+ envelope does not parse falls back to the `.leg/DONE` marker and the diff,
273
+ which is what every adapter does when `parseResult` returns null. Re-run
274
+ `node scripts/probe.mjs --adapter grok --repo <toy-repo>` with balance to
275
+ close it.
276
+
277
+ ## Custom adapters
278
+
279
+ Any other coding-agent CLI becomes a card adapter through a JSON spec in
280
+ `$LEG_HOME/adapters/<name>.json`, with no code in this package. This is the
281
+ open end of the chain: claude, codex, agy and grok ship with taps and a probe,
282
+ and anything else joins as "argv in, JSON out".
283
+
284
+ A custom adapter runs **cards**: headless, in a worktree, handing off like any
285
+ other leg. It is not an interactive `leg <agent>` terminal, because that needs
286
+ a usage tap and a wall signal, which only the four built-ins expose.
287
+
288
+ ```
289
+ leg adapter template --name muse > muse.json # a starter spec
290
+ leg adapter add muse.json # validate and install it
291
+ leg adapter check muse # the exact command a leg would run
292
+ leg card add --repo <path> --task "<t>" --chain muse,claude --queue
293
+ leg adapter list | show muse | rm muse
294
+ ```
295
+
296
+ The spec:
297
+
298
+ ```json
299
+ {
300
+ "name": "muse",
301
+ "bin": "muse",
302
+ "stdin": "ignore",
303
+ "args": ["run", "--json",
304
+ ["--dir", "{{cwd}}"],
305
+ ["--model", "{{model}}"],
306
+ "--prompt-file", "{{promptFile}}"],
307
+ "modes": { "default": "auto", "allowed": ["auto", "readonly"] },
308
+ "forbiddenFlags": ["--unsafe"],
309
+ "result": { "format": "json", "sessionId": "session_id",
310
+ "message": "result", "stopReason": "stop_reason" }
311
+ }
312
+ ```
313
+
314
+ - **`bin`** is argv[0]. It is spawned directly, never through a shell, so it
315
+ cannot contain `< > | & ;`. `LEG_<NAME>_BIN` overrides it. A `.mjs`, `.cjs`
316
+ or `.js` path runs under this Node.
317
+ - **`args`** is a list of strings and groups. Placeholders: `{{prompt}}`,
318
+ `{{promptFile}}`, `{{cwd}}`, `{{mode}}`, `{{model}}`, `{{resume}}`,
319
+ `{{maxTurns}}`, `{{runDir}}`. A bare string is always kept; a **group** (a
320
+ nested array) is dropped whole when a placeholder inside it has no value, so
321
+ `["--model", "{{model}}"]` disappears rather than passing a bare `--model`.
322
+ Something has to carry the prompt: `{{prompt}}`, `{{promptFile}}`, or
323
+ `"stdin": "pipe"`.
324
+ - **`modes`** is validated before anything spawns, the same way a built-in's
325
+ is: a chain entry naming a mode outside `allowed` throws `forbidden flag`.
326
+ - **`forbiddenFlags`** is yours to add to. The flags that turn a supervised
327
+ agent into an unsupervised one are refused whatever the spec says, in `args`,
328
+ in `modes` and at argv time (`NEVER_ALLOWED`, `src/adapters/custom.mjs`).
329
+ - **`result.format`** is `json` (the first parseable object in stdout),
330
+ `jsonl` (the last line carrying the message field) or `text` (no parsing).
331
+ `sessionId`, `message` and `stopReason` are dotted paths, so
332
+ `"sessionId": "thread.id"` reads `{"thread":{"id":…}}`. With `text`, or when
333
+ nothing parses, the leg is judged by its `.leg/DONE` marker and its diff,
334
+ which is what happens for any adapter whose `parseResult` returns null.
335
+
336
+ A broken spec is reported, never thrown: `leg adapter list` names the file and
337
+ the reason, and the board, the scheduler and `leg card add` carry on without
338
+ it. A spec may not take a built-in's name. The directory is re-read whenever
339
+ the list is asked for, keyed on its entries, so a spec added while the board is
340
+ up appears in the New card form without a restart.
341
+
342
+ ## How to add a built-in adapter
261
343
 
262
344
  An adapter is a plain object (see `src/adapters/common.mjs` for the shared
263
345
  helpers, `src/adapters/fake.mjs` for the simplest full example):
@@ -317,7 +317,7 @@ before saving.
317
317
 
318
318
  ## The ledger
319
319
 
320
- Below the terminals, three counts sitting on the ground with no panel, because a
320
+ Below the terminals, four counts sitting on the ground with no panel, because a
321
321
  raised surface here would compete with the terminals that are live. Each is a
322
322
  heading, a line of detail and a button that opens the detail below the row.
323
323
 
@@ -334,6 +334,25 @@ heading, a line of detail and a button that opens the detail below the row.
334
334
  visual weight as the live terminals, so the loudest thing on the page was a
335
335
  commit from eleven days ago. With nothing landed it reads `Nothing landed yet`
336
336
  and explains what Land does.
337
+ - **N conversations**, every coding-agent conversation on this machine, `799
338
+ claude, 198 codex, 92 agy, 14 grok, 5 copilot; looked 2m ago`: the sessions
339
+ Leg started and the ones the agents keep in their own stores, read from
340
+ there and never moved ([history.md](history.md)). **Browse N** opens a
341
+ drawer with filters (agent, search, repository, only what Leg started), a
342
+ page of fifty rows newest first and **Show 50 more**. A row is the title as a
343
+ button, then the agent chip, `leg` or `external` (`, live` when a process is
344
+ still on it), `repo on branch`, the prompt count and the short id. The title
345
+ opens the conversation in place: agent, who started it, folder, repository,
346
+ worktree, times, prompts, transcript path, id, the `leg history continue
347
+ <id>` command with **Copy the continue command** where the agent can resume
348
+ by id (or the reason it cannot), **Copy folder path**, and the last eight
349
+ messages newest first, each redacted the way the terminal drawer's are. Below
350
+ the rows, **Checkouts**: every worktree Leg can see with its repo and branch,
351
+ owner (the checkout itself, a Leg session, a Leg card, or not Leg's), how
352
+ many conversations point at it, and `missing`, `orphaned`, `stale`, `clean`
353
+ or `N uncommitted`. Nothing here is pushed over the live stream; the count
354
+ re-reads itself once a minute while the drawer is closed. On a shared board
355
+ a guest sees `The owner of this machine sees them.` and nothing else.
337
356
  - **N background tasks**, the card runtime below, as a count with **View N
338
357
  cards**, plus **New card**. With none it reads `Nothing is queued. Leg starts
339
358
  the next login only when a terminal hands off.`
@@ -25,7 +25,7 @@ questions."
25
25
  | claude | 2.1.268 | exit 0, file yes, DONE yes, 20 s | `src/adapters/claude.mjs` |
26
26
  | codex | codex-cli 0.153.4 | exit 0, file yes, DONE yes, 28 s | `src/adapters/codex.mjs` |
27
27
  | agy | 1.2.0 | attempt 1 exit 0 but wrote to its scratch workspace; attempt 2 (`--add-dir`) exit 0, file yes, DONE yes, 41 s | `src/adapters/agy.mjs` |
28
- | grok | 0.2.51 | exit 0, `stopReason: Cancelled`, no file: not logged in (device-code prompt) | `src/adapters/grok.mjs` exists, NOT registered |
28
+ | grok | 1.0.34 | exit 1, no file, no DONE: the account answered `402 Payment Required: Grok Build usage balance exhausted`, classified `limit` (re-probed 2026-09-17; the 2026-09-11 run on 0.2.51 was not logged in) | `src/adapters/grok.mjs` |
29
29
 
30
30
  The auth-source check: the build shell carried `ANTHROPIC_API_KEY` and
31
31
  `OPENAI_API_KEY` (injected by the shell profile). Every adapter's `env()` deletes
@@ -165,29 +165,48 @@ stderr are 0 bytes, and codex's is the one stdin notice. observed-live.
165
165
 
166
166
  ## grok
167
167
 
168
- - Version grok 0.2.51 (f4f85a649) [stable] (source: `grok --version`).
168
+ - Version grok 1.0.34 (3736acbc8658) [stable] (source: `grok --version`,
169
+ 2026-09-17). The 2026-09-11 sweep saw 0.2.51 on a machine with no login; every
170
+ line below was re-read on 1.0.34.
169
171
  - Binary: `~/.grok/bin/grok.exe` (native; also an npm shim on PATH);
170
172
  `LEG_GROK_BIN` overrides (source: `where grok`).
171
- - Headless argv (from cmd.txt): `grok.exe -p "<prompt>" --output-format json --permission-mode acceptEdits`; stdin `ignore`.
172
- `--prompt-file <path>` also exists (source: `grok --help`).
173
- - Output: one JSON object `{text, stopReason, sessionId, requestId, thought}`
174
- (observed-live, fixtures/live/grok/out.log).
173
+ - Headless argv (from cmd.txt): `grok.exe --prompt-file <run>/prompt.txt
174
+ --output-format json --permission-mode acceptEdits --cwd <worktree>`; stdin
175
+ `ignore`. The file form is used whenever the runner has written a prompt file,
176
+ because a hand-off prompt carries the whole bundle summary and Windows caps a
177
+ command line near 32k; with none, the adapter falls back to `-p "<prompt>"`
178
+ (source: `grok --help`, `-p, --single <PROMPT>` and `--prompt-file <PATH>`).
179
+ `--cwd <CWD>` is passed explicitly rather than trusting the spawn's working
180
+ directory, because grok can run against a shared leader process
181
+ (`~/.grok/leader.sock`) and a leg must edit its own worktree.
182
+ - Output: the Claude Code result envelope. `"type":"result"`, `subtype`,
183
+ `is_error`, `session_id`, `result`, `num_turns`, `stop_reason`, `total_cost`,
184
+ read out of the shipped `grok.exe` on 2026-09-17 alongside the streaming
185
+ types (`assistant`, `system`, `text`, `usage`, `end`, `error`). An error is
186
+ the other envelope, `{"type":"error","message":…}` (observed-live,
187
+ `fixtures/live/grok/out.log`).
175
188
  - Exit codes:
176
189
 
177
190
  | exit | meaning | source |
178
191
  |------|---------|--------|
179
- | 0 | printed JSON with `stopReason:"Cancelled"` and did no work: the CLI was not logged in, printed a device-code prompt on stderr (`https://accounts.x.ai/oauth2/device?user_code=…`, "Waiting for authorization...") and gave up after ~58 s | observed-live, fixtures/live/grok/err.log |
180
-
181
- A zero exit with no DONE marker and no diff is exactly the `no_progress` class
182
- the completion contract exists for.
183
- - Permission modes: `default`, `acceptEdits`, `auto`, `dontAsk`,
184
- `bypassPermissions`, `plan` (source: `grok --help`). Leg: default
185
- `acceptEdits`; forbidden `bypassPermissions`, `--always-approve`.
192
+ | 1 | printed `{"type":"error","message":"Internal error: API error (status 402 Payment Required): Grok Build usage balance exhausted"}` and did no work: the account had no balance left | observed-live 2026-09-17, fixtures/live/grok/out.log |
193
+ | 0 | printed JSON with `stopReason:"Cancelled"` and did no work: the CLI was not logged in, printed a device-code prompt on stderr and gave up after ~58 s | observed-live 2026-09-11 on 0.2.51 |
194
+
195
+ The 402 is a wall, not a failure: `fixtures/limits/grok/grok-balance-exhausted.json`
196
+ classifies it `limit`, so the card hands off instead of stopping. None of the
197
+ rate-limit strings the tap watched for appear in a 402, which is why an
198
+ exhausted grok terminal used to sit there; `src/taps/grok.mjs` reads it too now.
199
+ - Permission modes: `default`, `acceptEdits`, `auto`, `dontAsk`, `plan`, and a
200
+ bypass mode Leg never passes (source: `grok --help`). Leg: default
201
+ `acceptEdits`; forbidden the bypass mode and `--always-approve`.
186
202
  - Login: `grok login` (source: `grok --help` Commands).
187
- - **Verdict: not verified, no adapter registered.** `src/adapters/grok.mjs` is
188
- built from `--help` and unit-tested for shape and forbidden flags, but stays
189
- out of `src/adapters/index.mjs` until `grok login` has been completed on the
190
- machine and `node scripts/probe.mjs --adapter grok --repo <toy>` passes.
203
+ - **Verdict: registered, wall path verified, success path not.** The probe
204
+ reached the account through the real runner, so the binary resolution, the
205
+ argv, the auth and the limit classification are all observed-live. A leg that
206
+ completes its task has not been seen, because that needs balance on the
207
+ account; until it is, a grok leg whose envelope does not parse is judged by
208
+ its `.leg/DONE` marker and its diff, as every adapter is. Re-run
209
+ `node scripts/probe.mjs --adapter grok --repo <toy>` with balance to close it.
191
210
 
192
211
  ## `leg harness` (the portable harness)
193
212
 
@@ -391,6 +410,20 @@ in [VOCABULARY.md](VOCABULARY.md). Board routes: `GET /api/sessions`,
391
410
  `POST /api/sessions/:id/end`, `DELETE /api/sessions/:id`, with the list pushed
392
411
  as the SSE `sessions` event (source: src/server.mjs, src/board/sessions.js).
393
412
 
413
+ ### History index
414
+
415
+ `<LEG_HOME>/history/index.json` is the one file `leg history` writes: per
416
+ provider, per transcript, size, mtime and a small scrubbed record (source:
417
+ src/history/index.mjs). What each provider reads from its CLI's own store,
418
+ with the observed file and field names, is the header of each
419
+ `src/history/providers/<name>.mjs`; the support matrix and the resume
420
+ commands quoted from each CLI's `--help` (observed-live 2026-09-16: `claude
421
+ --resume <id>`, `codex resume <id>`, `grok --resume <id>`, `agy --conversation
422
+ <id>`; copilot has none Leg has verified) are in [history.md](history.md).
423
+ Board routes, owner only: `GET /api/history`, `GET /api/history/:id`,
424
+ `GET /api/history/providers`, `POST /api/history/refresh`,
425
+ `GET /api/worktrees` (source: src/server.mjs, src/board/history.js).
426
+
394
427
  ## Limit signals
395
428
 
396
429
  Recorded in `fixtures/limits/<group>/<id>.json` and classified by
@@ -126,7 +126,8 @@ resolves to by default.
126
126
  | `LEG_CLAUDE_BIN` | claude | `src/adapters/claude.mjs` |
127
127
  | `LEG_CODEX_BIN` | codex | `src/adapters/codex.mjs` |
128
128
  | `LEG_AGY_BIN` | agy | `src/adapters/agy.mjs` |
129
- | `LEG_GROK_BIN` | grok (not registered by default) | `src/adapters/grok.mjs` |
129
+ | `LEG_GROK_BIN` | grok | `src/adapters/grok.mjs` |
130
+ | `LEG_<NAME>_BIN` | a custom adapter called `<name>` (dashes become underscores, so `my-agent` reads `LEG_MY_AGENT_BIN`) | `src/adapters/custom.mjs` |
130
131
  | `LEG_GH_BIN` | the `pr` land-mode stub | `src/stations/pr.mjs`; unset, `land_mode: pr` returns an error rather than running a real `gh` |
131
132
  | `LEG_CHB_BIN` | `context-handoff-bundle` | `src/handoff.mjs`; unset, Leg tries `context-handoff-bundle` on PATH, then `python -m context_handoff_bundle` |
132
133
 
@@ -200,6 +201,7 @@ Not meant for normal use; documented for completeness.
200
201
  |----------|---------|---------|
201
202
  | `LEG_SKIP_KILL` | set to `1` to make the supervisor skip actually killing a leg's process (used by tests that need an "unkillable agent") | `src/runner.mjs` |
202
203
  | `LEG_SERVER_SCRIPT` | override the server script path the launcher spawns | `src/launcher.mjs` |
204
+ | `COPILOT_HOME` | where `leg history` looks for the Copilot CLI store instead of `~/.copilot` (Claude, Codex and Grok use their own `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME`; Antigravity has no variable and is found under the OS home) | `src/history/providers/copilot.mjs` |
203
205
 
204
206
  ## Network exposure
205
207
 
@@ -209,9 +211,10 @@ other address without also setting `LEG_TOKEN` makes the server refuse to
209
211
  start, exit code `3` (`src/auth.mjs` `checkBind`/`BindRefused`). With a
210
212
  token set, every `/api/*` request needs an `Authorization: Bearer <token>`
211
213
  header; the event stream (`EventSource`, which cannot set headers) accepts
212
- the same token as a `?token=` query parameter instead. There is no TLS; keep
213
- `LEG_BIND` on loopback unless you are using `leg share`, which gives
214
- each human their own token (see [Share](#share-more-than-one-human)).
214
+ the same token as a `?token=` query parameter instead. Keep `LEG_BIND` on
215
+ loopback unless you are using `leg share`, which gives each human their own
216
+ token (see [Share](#share-more-than-one-human)) and can serve the board over
217
+ TLS.
215
218
 
216
219
  ## Share (more than one human)
217
220
 
@@ -223,7 +226,8 @@ switch and the roster; the env variables below only tune the limits.
223
226
  | `on` | share is on (it also needs a `bind` and at least one person) |
224
227
  | `bind`, `bind_kind`, `port` | where the board listens: the Tailscale address by default, `lan`, or one you named |
225
228
  | `owner` | the name a terminal belongs to when nothing else says (`LEG_PERSON`), and the name a loopback browser is treated as |
226
- | `people[]` | `{ name, role: owner\|guest, token_sha256, created_at }`; the token itself is printed once and never stored |
229
+ | `people[]` | `{ name, role: owner\|operator\|guest, token_sha256, created_at }`; the token itself is printed once and never stored |
230
+ | `tls` | `{ cert, key }`, the paths to a certificate pair; absent means plain http |
227
231
  | `loopback_owner` | default `true`: a browser on this machine is the owner without a token. Set it to `false` to ask for a link even here |
228
232
 
229
233
  | variable | default | meaning | read in |
@@ -233,6 +237,53 @@ switch and the roster; the env variables below only tune the limits.
233
237
 
234
238
  `LEG_TOKEN` is the single-token mode and is ignored while share is on.
235
239
 
240
+ ### Roles
241
+
242
+ | role | terminals | cards | this machine |
243
+ |------|-----------|-------|--------------|
244
+ | `owner` | every one | every one | settings, harness policy, the trunk's repo paths, the history index, the worktree map, the audit trail |
245
+ | `operator` | their own; someone else's is read-only and redacted | adds, runs, approves, reassigns, kills | nothing: `/api/settings`, `/api/history`, `/api/worktrees`, `/api/trunk` and `/api/audit` all answer 403, and `/api/health` omits the home path |
246
+ | `guest` | their own; someone else's is read-only and redacted, with **Request handoff** as the only button | nothing: 403 | nothing |
247
+
248
+ A guest and an operator both see their own terminal's hand-off destinations,
249
+ so they can use the picker on it — but never the reset times behind them,
250
+ which are this machine's usage data. `src/share.mjs` `mayUseCards` and
251
+ `mayUseMachine` are the only place a role is turned into permission.
252
+
253
+ ### TLS
254
+
255
+ Off unless a certificate pair is configured. Leg never issues one: a
256
+ self-signed pair teaches everyone on the board to click through a warning,
257
+ which is worse than plaintext on a network that is already private. On
258
+ Tailscale, `tailscale cert <machine>.<tailnet>.ts.net` issues a pair browsers
259
+ already trust.
260
+
261
+ | variable | meaning | read in |
262
+ |----------|---------|---------|
263
+ | `LEG_TLS_CERT` | path to the certificate; wins over `share.json`'s `tls.cert` | `src/share.mjs` `readTls` |
264
+ | `LEG_TLS_KEY` | path to the private key; wins over `share.json`'s `tls.key` | `src/share.mjs` `readTls` |
265
+
266
+ `leg share on --tls-cert <file> --tls-key <file>` writes the pair into
267
+ `share.json` and reads it once, so a bad pair fails there rather than at the
268
+ next board start. With a pair configured the shared address serves https and
269
+ every link `leg share` prints says `https://`. The companion listener on
270
+ `127.0.0.1` — the one that lets this machine's own browser in without a token
271
+ — stays plain http, because the certificate is for the shared name and
272
+ loopback traffic never leaves the machine. Half a pair, a missing file, an
273
+ unreadable file or an empty one stops the board with exit `3` rather than
274
+ falling back to plaintext. A renewed pair is picked up by `leg down && leg up`.
275
+
276
+ ### The audit trail
277
+
278
+ `GET /api/audit` (owner only) and Settings → **Audit trail** on the board: one
279
+ list across every terminal and every card, newest first, of the actions a
280
+ person or an agent took — hand-offs, landings, approvals, reassignments,
281
+ kills. It reads the events already on disk and stores nothing new. Query
282
+ parameters: `limit` (default 200, max 1000), `since` (an ISO timestamp), `who`
283
+ (a name), `kind` (`human`, `agent` or `leg`). Every answer carries `scanned`
284
+ — how many terminals, cards and events it read — so an empty trail cannot be
285
+ mistaken for a quiet week.
286
+
236
287
  ## Card-level options
237
288
 
238
289
  These configure one card, not the whole install (set with `card add` flags