@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/CHANGELOG.md CHANGED
@@ -1,5 +1,151 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 (2026-09-17)
4
+
5
+ - **grok is a card adapter, not just a terminal.** `--chain grok` works. Its
6
+ flags were read from `grok --help` on grok 1.0.34 (`3736acbc8658`) and its
7
+ result envelope out of the shipped binary, both on 2026-09-17, replacing the
8
+ guessed field names the unregistered adapter carried. The prompt now travels
9
+ by `--prompt-file` (a hand-off prompt carries the whole bundle summary, and
10
+ Windows caps a command line near 32k) and `--cwd` is passed explicitly,
11
+ because grok can run against a shared leader process and a leg must edit its
12
+ own worktree. The probe reached the account and came back with a real wall.
13
+ - **A grok terminal at the wall now hands off.** An exhausted Grok Build
14
+ account answers `402 Payment Required: Grok Build usage balance exhausted`,
15
+ and none of the rate-limit strings the tap looked for appear in it. The
16
+ terminal sat on a dead login instead of moving on. Both the tap
17
+ (`src/taps/grok.mjs`) and the leg classifier (a new observed-live fixture)
18
+ now read it as a limit. Found by running the probe against an exhausted
19
+ account on 2026-09-17.
20
+ - **Any CLI can be a card agent, from a JSON spec and no code.** `leg adapter
21
+ template --name muse > muse.json`, `leg adapter add muse.json`, then
22
+ `--chain muse,claude`. `leg adapter check muse` prints the exact command line
23
+ a leg would run before one does; `leg adapter list|show|rm` are the rest.
24
+ Placeholders (`{{prompt}}`, `{{promptFile}}`, `{{cwd}}`, `{{mode}}`,
25
+ `{{model}}`, `{{resume}}`, `{{maxTurns}}`, `{{runDir}}`) fill the argv, and a
26
+ group in the args is dropped whole when a placeholder inside it is unset, so
27
+ an absent model never leaves a bare `--model`. The flags that turn a
28
+ supervised agent into an unsupervised one are refused whatever a spec says.
29
+ A broken spec is reported by name and reason, never thrown, so one typo
30
+ cannot stop the board. See [docs/adapters.md](docs/adapters.md#custom-adapters).
31
+ - **Hand off now can name its destination.** The button still takes the next
32
+ option in the order; Details → **Hand off now to** lists every destination
33
+ with the reason a greyed one cannot be picked, and `leg sessions handoff
34
+ <id> --to codex` is the same choice from a terminal. A destination that is
35
+ not on the chain, not installed, or at its wall is refused at the click with
36
+ the reset time. If the one you picked walls between the click and the
37
+ hand-off, the work continues down the order and the terminal says which
38
+ agent took it instead, rather than going somewhere else in silence.
39
+ - **TLS for a shared board.** `leg share on --tls-cert <file> --tls-key <file>`,
40
+ or `LEG_TLS_CERT` / `LEG_TLS_KEY`. Leg issues no certificate: a self-signed
41
+ pair teaches everyone to click through a warning, which is worse than
42
+ plaintext on a network that is already private, so it takes a pair you have
43
+ (`tailscale cert <machine>.<tailnet>.ts.net` is one command). Half a pair, a
44
+ missing file or an empty one stops the board with exit 3 rather than quietly
45
+ serving plaintext. The loopback companion stays http, because the certificate
46
+ is for the shared name and that traffic never leaves the machine.
47
+ - **An `operator` role, between owner and guest.** An operator runs the
48
+ pipeline board — adds, runs, approves, reassigns and kills cards — and their
49
+ own terminals, and sees nothing that describes this machine: no settings, no
50
+ home path, no repository paths, no conversation index, no audit. `leg share
51
+ add dana --role operator`. What each role may reach is decided in one place
52
+ (`mayUseCards` / `mayUseMachine` in `src/share.mjs`) instead of per endpoint.
53
+ - **An audit trail: who did what, across every terminal and every card.**
54
+ Settings → **Audit trail** on the board, `GET /api/audit` for a script, owner
55
+ only. Hand-offs, landings, approvals, reassignments and kills, newest first,
56
+ each with the person or agent that did it. It reads what the ledger already
57
+ recorded and stores nothing new, and every answer carries how much it read
58
+ (`14 terminals and 3 cards, 812 events read`) so an empty trail cannot be
59
+ mistaken for a quiet week.
60
+ - **Fixed: a guest's own terminal leaked the owner's reset times.** The new
61
+ hand-off destination list carried the exact reset timestamp of every
62
+ account, and a guest owns their own terminal, so it reached them through it.
63
+ A guest and an operator now get the destinations and the coarse reason
64
+ (`at its usage limit`) without the timestamp. Caught by the share-security
65
+ suite in the same change.
66
+ - **Fixed: the board slowed down as soon as a custom adapter existed.**
67
+ `/api/health` asks every adapter where its binary is, and reading the spec
68
+ directory on each of those calls put a readdir, a read and a JSON parse per
69
+ spec on the event loop the terminals lane is pushed from. The parsed list is
70
+ now cached against the directory's entries with a one-second floor, so a
71
+ spec added or removed is still seen at once.
72
+
73
+ ## 0.10.0 (2026-09-17)
74
+
75
+ - **The board is responsive again, `leg` starts straight away, and the board
76
+ opens every time.** One running terminal was enough to saturate the board
77
+ server: it rewrites its record every few seconds and takes a control lock
78
+ about once a second, and the watcher over the sessions directory rebuilt the
79
+ whole terminals view — over a second of `git` — for every one of those
80
+ touches, including the lock files and the temp files atomic writes leave
81
+ behind. The server spent all its time in that loop, so it answered a
82
+ stylesheet in fourteen seconds and `/api/health` in seven. Four consequences,
83
+ all fixed:
84
+ - The watcher now drops locks and temp files by name, and checks the rest
85
+ against a stat fingerprint of the files the view is actually built from,
86
+ because a lock taken inside a session directory changes that directory's
87
+ own mtime and arrives as an event naming nothing else. What survives is
88
+ floored to one rebuild per interval. `canLand` is cached per record
89
+ revision with staggered expiries, so twenty worktrees are never re-read in
90
+ one pass. The terminals view went from 2,000 ms to 115 ms; on a board with
91
+ 66 sessions `/api/health` went from 14.2 s to 2.4 ms and `/board.css` from
92
+ 14.2 s to 1.5 ms.
93
+ - `leg claude` treated a board too busy to answer a health probe as no board
94
+ at all, spawned a second server that could only die of `EADDRINUSE`, then
95
+ polled the dead child for fifteen seconds. A listener on the port is now a
96
+ board: `leg` attaches to it.
97
+ - The board is opened whether or not this terminal is the one that started
98
+ it. Previously the browser was only opened on the path that spawned the
99
+ server, so with a board already running nothing ever opened.
100
+ - One push rebuilt the terminals grid three times (two listeners registered
101
+ for `leg:sessions` and one for the legacy alias, with the payload parsed
102
+ twice per push and one dispatch outside the staleness guard). Every button
103
+ was destroyed and recreated several times a second. One push now rebuilds
104
+ once, and an open confirm row is left alone until it is answered.
105
+
106
+ - **Remove, Remove record, End and Land work.** Confirming any of them did
107
+ nothing at all. `confirmRow` clears `pendingConfirm` and then calls its
108
+ callback, and the callback read `pendingConfirm.action` — off the variable it
109
+ had just cleared. Every Yes on the Terminals panel threw
110
+ `TypeError: Cannot read properties of null (reading 'action')` into the
111
+ console and never reached the request. The callback now closes over a
112
+ snapshot taken before the row is built.
113
+
114
+ - **Every conversation on this machine (`leg history`, `leg worktrees`, the
115
+ board's Conversations cell).** One read-only index over the conversations
116
+ Claude Code, Codex, Grok, Antigravity and Copilot CLI keep in their own
117
+ stores, plus Leg's own sessions, deduplicated (a session Leg started and
118
+ the same conversation in the agent's store are one row, marked `leg`; the
119
+ rest are `external`). List, filter by agent, repository or text, open one
120
+ (its last messages, read from the tail of the transcript only when asked),
121
+ and `leg history continue <id>` to start a supervised leg on it where the
122
+ installed CLI documents resume-by-id (claude, codex, grok, agy; copilot
123
+ lists and reads but is not an agent Leg runs). Nothing in an agent's store
124
+ is moved or written; Leg writes only `~/.leg/history/index.json`, refreshed
125
+ incrementally by size and mtime, and never opens a SQLite file. Every
126
+ checkout Leg can see, git's and its own and the ones conversations ran in,
127
+ with owner, uncommitted count, orphaned, stale and missing verdicts, read
128
+ only. On a shared board the whole group is the owner's. Redaction learned
129
+ the shapes another agent's transcript carries (Stripe, Google, xAI, npm,
130
+ GitLab, Hugging Face keys, JWTs, private-key blocks, basic auth, URL
131
+ credentials, `password=`), and the board token joined the values it strips.
132
+ Support matrix and every file read: docs/history.md.
133
+ - **History, after its review.** The board's Conversations cell pages past
134
+ 150 rows (the total no longer shrank with each cursor page); the board
135
+ refreshes a stale index in a child process instead of its own event loop;
136
+ a cwd on an unreachable network share no longer stalls a refresh; an
137
+ Antigravity retitle or a touched presence lock is seen by an incremental
138
+ refresh, and only that conversation is re-read; an older Leg session id
139
+ still opens the conversation it was one leg of; `leg history` prints a
140
+ failed refresh whatever the last index still lists, `refresh` fails cleanly
141
+ when another Leg holds the lock, `--limit 0` is a usage error (`--all`),
142
+ `--offset` works, a Leg-only row's id prints whole; a malformed id on
143
+ `/api/history/<id>` is a 400; an account name that is a path is ignored;
144
+ titles are cut to 200 as documented and go through the held-value
145
+ redaction too; the Copilot chip has a colour. Redaction no longer eats
146
+ `Basic authentication/authorization`, `cache_key = ...` or
147
+ `refresh_token: string`, and a value never crosses a line break.
148
+
3
149
  ## 0.9.0 (2026-09-16)
4
150
 
5
151
  - **The portable harness (`leg harness`), off by default.** A hand-off can now
package/README.md CHANGED
@@ -95,6 +95,7 @@ yourself.
95
95
  - [Two sessions in one repo](#two-sessions-in-one-repo)
96
96
  - [More than one human](#more-than-one-human)
97
97
  - [The board](#the-board)
98
+ - [Every conversation on this machine](#every-conversation-on-this-machine)
98
99
  - [Second accounts, and what the terms say](#second-accounts-and-what-the-terms-say)
99
100
  - [What is and is not touched](#what-is-and-is-not-touched)
100
101
  - [CLI reference](#cli-reference)
@@ -274,12 +275,21 @@ token at all.
274
275
 
275
276
  ```
276
277
  leg share on your own link, printed once
277
- leg share add sam sam's link, printed once
278
+ leg share add sam sam's link, printed once (a guest)
279
+ leg share add dana --role operator dana runs cards, not this machine
278
280
  leg share who is on the board (never a token again)
279
281
  leg share rotate sam sam's old link stops working
280
282
  leg share off back to 127.0.0.1; every link stops working
281
283
  ```
282
284
 
285
+ There are three roles. **owner** is everything: the machine's settings, the
286
+ harness, every terminal, the cards, the history index and the audit trail.
287
+ **operator** is the pipeline board and their own terminals: they add, run,
288
+ approve, reassign and kill cards, and they never see this machine's settings,
289
+ its home path, its repository paths, its conversation index or the audit.
290
+ **guest** is the terminals lane, read-only and redacted, with **Request
291
+ handoff** as their only button.
292
+
283
293
  A token is kept as a sha256 hash, so a lost link is re-issued, never re-read.
284
294
  The board takes the token out of the address bar and keeps it in the browser.
285
295
  Your own browser on this machine needs no token.
@@ -300,12 +310,42 @@ The security pass that goes with it: every `/api` route needs a token, the
300
310
  event stream included; twenty wrong tokens from one address and that address
301
311
  waits a minute; one identity gets 600 requests a minute; a guest gets 403 on
302
312
  everything that is not theirs; and the tests send a bad and a missing token to
303
- every route. There is still no TLS, so keep this on Tailscale or a network you
304
- trust. Verified live on 2026-09-11: two terminals on one machine, one wes's and
313
+ every route. Verified live on 2026-09-11: two terminals on one machine, one wes's and
305
314
  one sam's; sam's board showed wes's card with the prompt hidden and only
306
315
  **Request handoff**, and sam's request reached wes's board (`~/.leg/board.log`:
307
316
  "hand-off requested … by sam").
308
317
 
318
+ ### TLS
319
+
320
+ Off unless you hand it a certificate. Leg does not issue one: a self-signed
321
+ pair teaches everyone on the board to click through a warning, which is worse
322
+ than plaintext on a network that is already private. On Tailscale the pair is
323
+ one command:
324
+
325
+ ```
326
+ tailscale cert <machine>.<tailnet>.ts.net
327
+ leg share on --tls-cert <machine>.<tailnet>.ts.net.crt --tls-key <machine>.<tailnet>.ts.net.key
328
+ ```
329
+
330
+ `LEG_TLS_CERT` and `LEG_TLS_KEY` do the same without writing the paths into
331
+ `share.json`, and they win over it. With a pair configured the shared address
332
+ serves https and every printed link says `https://`; the companion listener on
333
+ `127.0.0.1`, which exists so this machine's own browser needs no token, stays
334
+ plain http, because the certificate is for the shared name and loopback traffic
335
+ never leaves the machine. A pair that is half-configured, missing, unreadable
336
+ or empty stops the board with exit 3 rather than quietly serving plaintext.
337
+ Renew the pair, then `leg down && leg up` to pick it up.
338
+
339
+ ### Who did what
340
+
341
+ Settings → **Audit trail** is one list across every terminal and every card,
342
+ newest first: hand-offs, landings, approvals, reassignments, kills, each with
343
+ the person or agent that did it, the repo and the time. Filter by person or by
344
+ kind. It reads what the ledger already recorded, so nothing new is stored, and
345
+ it prints how much it read beside the answer (`14 terminals and 3 cards, 812
346
+ events read`) so an empty trail cannot be mistaken for a quiet week. Owner
347
+ only: the trail names repositories and people.
348
+
309
349
  ## The board
310
350
 
311
351
  `leg <agent>` opens it; `leg open` reopens it; `leg down` stops it.
@@ -336,12 +376,47 @@ one sam's; sam's board showed wes's card with the prompt hidden and only
336
376
  - **Buttons**, in a fixed order that never reflows: Land, Hand off now,
337
377
  Details, End. Once a session has ended, Remove and Remove record take End's
338
378
  place. Details opens an expansion in flow under the panel.
339
- - Below it, optional **Background tasks** an agent runs in a separate worktree
340
- without joining the terminal conversation (see below), then **Settings**.
379
+ **Hand off now** takes the next option in the order. To name the destination
380
+ instead, open Details and use **Hand off now to**, which lists every
381
+ destination with the reason a greyed one cannot be picked (`at its usage
382
+ limit, back 11:40`, `not installed on this machine`). `leg sessions handoff
383
+ <id> --to codex` is the same choice from a terminal. If the destination you
384
+ picked walls between the click and the hand-off, the work still continues
385
+ down the order and the terminal says which one took it instead.
386
+ - Below it, **Conversations**: every coding-agent conversation on this machine
387
+ as a count that opens, the ones Leg started and the ones Claude Code, Codex,
388
+ Grok, Antigravity and Copilot keep in their own stores. Filter by agent,
389
+ search, repository, or only what Leg started; a row opens its last messages
390
+ in place, with the `leg history continue` command to copy where the agent
391
+ can resume by id. The same drawer lists every checkout Leg can see. Then
392
+ optional **Background tasks** an agent runs in a separate worktree without
393
+ joining the terminal conversation (see below), then **Settings**.
341
394
 
342
395
  The board reads `~/.leg/sessions/*/session.json` over server-sent events; a
343
396
  session whose runner process is gone is marked `lost`, never shown as live.
344
397
 
398
+ ## Every conversation on this machine
399
+
400
+ Claude Code, Codex, Grok, Antigravity and Copilot each keep their history in
401
+ their own place. `leg history` is one list over all of them, whether or not
402
+ Leg started the conversation, and `leg worktrees` is one list over every
403
+ checkout: git's, Leg's own, and the ones those conversations ran in.
404
+
405
+ ```
406
+ leg history newest first, every agent; --provider, --repo, --search, --json
407
+ leg history show claude:0fc5 where it ran, its last messages, whether Leg can continue it
408
+ leg history continue claude:0fc5 leg claude --resume <id> in that folder, supervised like any session
409
+ leg worktrees path, repo, branch, exists, uncommitted, owner, conversations, stale
410
+ ```
411
+
412
+ Nothing moves: each agent's store stays where it was, Leg writes only its own
413
+ `~/.leg/history/index.json`, reads transcripts from their head and tail, and
414
+ opens messages only when you open a conversation. A session Leg started and
415
+ the same conversation in the agent's store are one row, marked `leg`; the rest
416
+ are `external`. On a shared board the whole group is the owner's. The support
417
+ matrix (which agents list, show messages, continue) and every file read are in
418
+ [docs/history.md](docs/history.md).
419
+
345
420
  ## Second accounts, and what the terms say
346
421
 
347
422
  Optional. `leg accounts add claude work` creates
@@ -387,6 +462,10 @@ happens after you run `leg accounts add`; that is your call.
387
462
  `~/.codex/config.toml`, agy's files, your repo's settings. Claude Code gets
388
463
  hooks through a per-session `--settings` file under `~/.leg`; codex and
389
464
  agy get nothing injected.
465
+ - **Read, never written**: each agent's own history (`~/.claude/projects`,
466
+ `~/.codex/sessions`, `~/.grok/sessions`, `~/.gemini/antigravity-cli`,
467
+ `~/.copilot/session-state`) for `leg history`; the index it builds lives
468
+ under `~/.leg/history/`, and no SQLite file is ever opened.
390
469
  - **Written only after `leg harness enable`** ([the portable harness](docs/harness.md),
391
470
  off by default): the destination client's global rules file
392
471
  (`~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.claude/leg-rules.md` plus
@@ -427,8 +506,13 @@ leg claude|codex|agy|grok [agent args…] the interactive agent, board alongside
427
506
  leg sessions ls [--json] every session and its usage
428
507
  leg sessions show|events <id>
429
508
  leg sessions handoff|end <id> same as the board buttons
509
+ leg sessions handoff <id> --to <agent>[/<account>] hand off to a destination you name
430
510
  leg sessions rm <id> forget an ended session
431
511
  leg sessions simulate-limit <id> the real limit path without a real wall (claude, agy, grok)
512
+ leg history [ls] [--provider p] [--repo r] [--search q] [--managed|--external] [--live] [--all] [--json]
513
+ every conversation on this machine, Leg's own and the agents' own (read only)
514
+ leg history show <id> [--messages n] [--json] | continue <id> [agent args…] | refresh [--full] | providers
515
+ leg worktrees [--repo <path>] [--no-dirty] [--json] every checkout: git's, Leg's, the conversations' (read only)
432
516
  leg accounts ls logins and their 5h/7d usage
433
517
  leg accounts add <claude|codex|grok> <name> | rm <agent> <name> | terms
434
518
  leg harness status|inspect|check|explain|history [--json] the portable harness, read-only
@@ -437,9 +521,12 @@ leg harness sync [--to codex,agy] [--force] [--dry-run] | diff <client> | doctor
437
521
  leg harness capture [claude|codex] | source <client> | policy <mode> | disable
438
522
  leg license the license on this machine, or where to buy one
439
523
  leg license activate <key> | deactivate | refresh (refresh renews a Team key)
524
+ leg adapter list|show <n>|check <n> every adapter, built-in and custom
525
+ leg adapter template [--name n] a starter spec to fill in
526
+ leg adapter add <file.json> | rm <n> any CLI as a card agent, from JSON
440
527
  leg share who is on the board (off by default; Team plan)
441
- leg share on [--bind tailscale|lan|<addr>] [--port N] | off
442
- leg share add|rotate|rm <name> one link per human, printed once
528
+ leg share on [--bind tailscale|lan|<addr>] [--port N] [--tls-cert <f> --tls-key <f>] | off
529
+ leg share add|rotate|rm <name> [--role owner|operator|guest] one link per human, printed once
443
530
  leg open | down | status the board
444
531
  leg uninstall [--yes]
445
532
  ```
@@ -451,8 +538,10 @@ open the browser), `LEG_USAGE_POLL_MS` (60000), `LEG_CLAUDE_ARGS` /
451
538
  `LEG_CODEX_ARGS` / `LEG_AGY_ARGS` / `LEG_GROK_ARGS` (extra args for a leg Leg starts after
452
539
  a hand-off, e.g. `-m gpt-5.3-codex-spark`), `LEG_CLAUDE_BIN`,
453
540
  `LEG_CODEX_BIN`, `LEG_AGY_BIN`, `LEG_GROK_BIN`, `LEG_CHB_BIN`, `LEG_PERSON` (whose
454
- terminal this is when the board is shared), `LEG_RATE_MAX` (600 requests a
455
- minute per human) and `LEG_RATE_MAX_FAILURES` (20 wrong tokens per address).
541
+ terminal this is when the board is shared), `LEG_TLS_CERT` / `LEG_TLS_KEY`
542
+ (serve the shared board over https; they win over `share.json`),
543
+ `LEG_RATE_MAX` (600 requests a minute per human) and `LEG_RATE_MAX_FAILURES`
544
+ (20 wrong tokens per address).
456
545
 
457
546
  ## Background tasks: the v0.1 extras
458
547
 
@@ -477,8 +566,17 @@ under Advanced options.
477
566
  - Adapters spawn the CLIs headless as argv, never through a shell, with their
478
567
  own permission modes and never a bypass flag: `claude -p --output-format json
479
568
  --permission-mode <m>`, `codex exec --json -s <m> -C <worktree>`,
480
- `agy -p --output-format json --mode <m> --add-dir <worktree>`; `fake`,
481
- `fake-claude`, `fake-codex`, `fake-agy` for tests and demos.
569
+ `agy -p --output-format json --mode <m> --add-dir <worktree>`,
570
+ `grok --prompt-file <f> --output-format json --permission-mode <m> --cwd
571
+ <worktree>`; `fake`, `fake-claude`, `fake-codex`, `fake-agy` for tests and
572
+ demos.
573
+ - **Any other CLI** is a card adapter too, from a JSON spec and no code:
574
+ `leg adapter template --name muse > muse.json`, fill in the command and its
575
+ flags, `leg adapter add muse.json`, then `--chain muse,claude`. `leg adapter
576
+ check muse` prints the exact command line a leg would run before one does.
577
+ See [docs/adapters.md](docs/adapters.md#custom-adapters). A custom adapter
578
+ runs cards; it is not an interactive `leg <agent>` terminal, because that
579
+ needs a usage tap and a wall signal, which only the four above expose.
482
580
  - A leg that ends on a limit signal, a stall, a crash or exit 0 without
483
581
  `.leg/DONE` hands off with a bundle to the next adapter in the same
484
582
  worktree; a `land` station rebases, tests and fast-forwards trunk or bounces
@@ -534,6 +632,7 @@ More in [docs/faq.md](docs/faq.md).
534
632
  | [Board guide](docs/board-guide.md) | every word, number and button on the board explained |
535
633
  | [Configuration](docs/configuration.md) | environment variables and options |
536
634
  | [Portable harness](docs/harness.md) | carrying rules, hooks, skills, agents, commands and MCP servers to the agent a hand-off lands on: what moves, what does not, policies, ownership, secrets |
635
+ | [History](docs/history.md) | `leg history` and `leg worktrees`: every conversation and checkout on this machine across agents, the support matrix, what is read and what is written |
537
636
  | [Adapters](docs/adapters.md) | what each CLI exposes and how Leg attaches to it |
538
637
  | [CLI contracts](docs/cli-contracts.md) | exact argv per CLI and the limit-signal table with sources |
539
638
  | [FAQ](docs/faq.md) | a question the others did not answer |
package/bin/leg.mjs CHANGED
@@ -19,14 +19,17 @@ import { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } from '../sr
19
19
  import { availableActions } from '../src/chain.mjs'
20
20
  import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
21
21
  import { attach, ensureBoard } from '../src/attach.mjs'
22
- import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed } from '../src/share.mjs'
22
+ import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed, scheme, tlsConfigured, ROLES } from '../src/share.mjs'
23
+ import { normalizeHandoffOrder } from '../src/preferences.mjs'
23
24
  import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
24
- import { addAccount, removeAccount, listAccountRows, LAYOUT } from '../src/accounts.mjs'
25
- import { listUsage, fmtReset } from '../src/usage.mjs'
25
+ import { addAccount, removeAccount, listAccountRows, readAccounts, LAYOUT } from '../src/accounts.mjs'
26
+ import { listUsage, fmtReset, readUsage, isAvailable, candidates } from '../src/usage.mjs'
26
27
  import { home } from '../src/store.mjs'
27
28
  import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
28
29
  import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
29
30
  import { harnessCommand } from '../src/harness/cli.mjs'
31
+ import { adapterCommand } from '../src/adapters/cli.mjs'
32
+ import { historyCommand, worktreesCommand } from '../src/history/cli.mjs'
30
33
 
31
34
  const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
32
35
  // one source of truth for the version, so the help text cannot drift from the package
@@ -158,11 +161,32 @@ async function main() {
158
161
  for (const s of list) out(`${s.session_id} [${s.status}] ${s.agent}${s.account !== 'default' ? '/' + s.account : ''} ${s.repo_name ?? s.cwd}${s.branch ? '@' + s.branch : ''} turns=${s.turns} ${s.limits ? `5h ${s.limits.five_hour?.pct ?? '-'}% 7d ${s.limits.seven_day?.pct ?? '-'}%` : ''} ${String(s.task ?? '').slice(0, 50)}`)
159
162
  return
160
163
  }
161
- const id = args._[0] || die(2, `usage: leg sessions ${cmd} <session-id>`)
164
+ const id = args._[0] || die(2, `usage: leg sessions ${cmd} <session-id>${cmd === 'handoff' ? ' [--to <agent>[/<account>]]' : ''}`)
162
165
  const s = readSession(id) || die(3, `session not found: ${id}`)
163
166
  if (cmd === 'show') return out(JSON.stringify({ session: s, events: readSessionEvents(id) }, null, 2))
164
167
  if (cmd === 'events') { for (const e of readSessionEvents(id)) out(`${e.ts} ${String(e.type).padEnd(18)} ${e.summary}`); return }
165
- if (cmd === 'handoff') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { handoff: true }); return out(`handoff requested for ${id}`) }
168
+ if (cmd === 'handoff') {
169
+ if (!isActive(s)) die(3, `session ${id} is not active`)
170
+ // --to names the destination, the same choice the board's picker makes.
171
+ // Validated here for the same reason it is validated there: a pick that
172
+ // is not a destination, is not installed, or is at its wall must be
173
+ // refused now, not silently turn into "whatever is next".
174
+ if (typeof args.to === 'string') {
175
+ const [wantAgent, wantAccount = 'default'] = args.to.split('/')
176
+ const order = normalizeHandoffOrder(s.handoff_order)
177
+ const chain = candidates({ agent: s.agent, account: s.account, accounts: readAccounts(), order })
178
+ const hit = chain.find((c) => c.agent === wantAgent && c.account === wantAccount)
179
+ const label = `${wantAgent}${wantAccount !== 'default' ? '/' + wantAccount : ''}`
180
+ if (!hit) die(2, `${label} is not a destination for this terminal (${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(', ') || 'none'})`)
181
+ if (s.installed && s.installed[wantAgent] === false) die(3, `${label} is not installed on this machine`)
182
+ const u = readUsage(wantAgent, wantAccount)
183
+ if (!isAvailable(u)) die(3, `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another, or drop --to to take the next option in the order`)
184
+ requestControl(id, { handoff: true, target: hit })
185
+ return out(`handoff to ${label} requested for ${id}`)
186
+ }
187
+ requestControl(id, { handoff: true })
188
+ return out(`handoff requested for ${id}`)
189
+ }
166
190
  if (cmd === 'end') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { end: true }); return out(`end requested for ${id}`) }
167
191
  if (cmd === 'rm') {
168
192
  if (isActive(s)) die(3, `session ${id} is still active; end it first`)
@@ -225,7 +249,9 @@ async function main() {
225
249
  const showLink = (person, token, s) => {
226
250
  out(`${person.name} is on the board (${person.role}). Their link, shown once:`)
227
251
  out(` ${linkFor(s, token)}`)
228
- out(person.role === 'owner' ? 'Open it on this machine, or any machine that can reach that address.' : 'They see the terminals lane read-only: no prompts, no file names, no logs, no bundles. They can ask for a hand-off; you approve it on the card.')
252
+ out(person.role === 'owner' ? 'Open it on this machine, or any machine that can reach that address.'
253
+ : person.role === 'operator' ? 'They get the pipeline board — cards, the floor, the adapters — and their own terminals. Not this machine’s settings, not its history index, not anyone else’s terminal.'
254
+ : 'They see the terminals lane read-only: no prompts, no file names, no logs, no bundles. They can ask for a hand-off; you approve it on the card.')
229
255
  }
230
256
  if (!cmd || cmd === 'ls' || cmd === 'status') {
231
257
  if (!share.on || !share.people.length) {
@@ -233,9 +259,12 @@ async function main() {
233
259
  out('Turn it on: leg share on (the Tailscale address; --bind lan, or --bind <address>)')
234
260
  return
235
261
  }
236
- out(`share is on: http://${share.bind}:${share.port} (${share.bind_kind})`)
237
- for (const p of share.people) out(` ${p.name.padEnd(16)} ${p.role.padEnd(6)} added ${String(p.created_at).slice(0, 10)}${p.last_seen ? ` last seen ${String(p.last_seen).slice(0, 16).replace('T', ' ')}` : ''}`)
262
+ out(`share is on: ${scheme(share)}://${share.bind}:${share.port} (${share.bind_kind})`)
263
+ for (const p of share.people) out(` ${p.name.padEnd(16)} ${p.role.padEnd(9)} added ${String(p.created_at).slice(0, 10)}${p.last_seen ? ` last seen ${String(p.last_seen).slice(0, 16).replace('T', ' ')}` : ''}`)
238
264
  out('')
265
+ out(tlsConfigured(share)
266
+ ? `TLS: certificate ${share.tls?.cert ?? '(from the environment)'}. The board on 127.0.0.1 stays plain http for this machine's own browser.`
267
+ : 'No TLS: keep this on Tailscale or a network you trust. Add one with leg share on --tls-cert <file> --tls-key <file> (tailscale cert <machine>.<tailnet>.ts.net issues a trusted pair).')
239
268
  out('A token is shown once. Lost one? leg share rotate <name>. Everyone out: leg share off')
240
269
  return
241
270
  }
@@ -245,19 +274,26 @@ async function main() {
245
274
  const ent = entitlement()
246
275
  if (!allows(ent, 'share')) die(2, ent.ok ? `leg share is part of the Team plan (per seat); this machine has a ${ent.plan} license. ${BUY_URL}` : describeLicense(ent))
247
276
  try {
248
- const r = await turnOn({ bind: a.bind ?? 'tailscale', port: a.port ? parseInt(a.port, 10) : undefined, owner: a.owner })
277
+ const r = await turnOn({
278
+ bind: a.bind ?? 'tailscale', port: a.port ? parseInt(a.port, 10) : undefined, owner: a.owner,
279
+ tlsCert: typeof a['tls-cert'] === 'string' ? a['tls-cert'] : null,
280
+ tlsKey: typeof a['tls-key'] === 'string' ? a['tls-key'] : null,
281
+ })
249
282
  await restartBoard()
250
- out(`share is on: the board is at http://${r.share.bind}:${r.share.port} (${r.share.bind_kind})`)
283
+ out(`share is on: the board is at ${scheme(r.share)}://${r.share.bind}:${r.share.port} (${r.share.bind_kind})`)
251
284
  if (r.token) showLink(r.owner, r.token, r.share)
252
- out('Add someone: leg share add <name>')
253
- out('No TLS: keep this on Tailscale or a network you trust. Anyone with a link sees that your terminals exist and how much usage is left.')
285
+ out('Add someone: leg share add <name> [--role operator|guest]')
286
+ out(tlsConfigured(r.share)
287
+ ? `TLS is on, from ${r.share.tls?.cert ?? 'the environment'}. Renew the pair and run leg down && leg up to pick up a new one.`
288
+ : 'No TLS: keep this on Tailscale or a network you trust. Anyone with a link sees that your terminals exist and how much usage is left. leg share on --tls-cert <file> --tls-key <file> turns it on; tailscale cert <machine>.<tailnet>.ts.net issues a trusted pair.')
254
289
  } catch (err) { die(2, err.message) }
255
290
  return
256
291
  }
257
292
  if (cmd === 'add') {
258
- const name = args._[0] || die(2, 'usage: leg share add <name> [--role owner|guest]')
293
+ const name = args._[0] || die(2, `usage: leg share add <name> [--role ${ROLES.join('|')}]`)
259
294
  try {
260
- const r = addPerson(name, { role: args.role === 'owner' ? 'owner' : 'guest', share })
295
+ if (args.role !== undefined && !ROLES.includes(String(args.role))) die(2, `bad role "${args.role}" (${ROLES.join('|')})`)
296
+ const r = addPerson(name, { role: typeof args.role === 'string' ? args.role : 'guest', share })
261
297
  showLink(r.person, r.token, r.share)
262
298
  if (!r.share.on) out('share is still off: leg share on')
263
299
  } catch (err) { die(2, err.message) }
@@ -329,6 +365,27 @@ async function main() {
329
365
  const code = await harnessCommand(cmd, args, { out, die })
330
366
  process.exit(code)
331
367
  }
368
+ if (group === 'adapter' || group === 'adapters') {
369
+ // Custom adapters: any CLI as a card agent, from a JSON spec on disk
370
+ // (src/adapters/custom.mjs). The built-ins need none of this.
371
+ const code = await adapterCommand(cmd, args, { out, die })
372
+ process.exit(code)
373
+ }
374
+ if (group === 'history' || group === 'worktrees') {
375
+ // Every conversation on this machine, Leg's own and the ones the agents'
376
+ // stores hold: a read-only index (src/history/index.mjs). `continue`
377
+ // starts a normal supervised leg on one of them. `leg history --json` is
378
+ // `leg history ls --json`: a leading flag names no verb.
379
+ const isHelp = cmd === '--help' || cmd === '-h' || cmd === 'help' || args.help || args.h
380
+ const bare = typeof cmd === 'string' && cmd.startsWith('--')
381
+ const verb = isHelp ? 'help' : (bare ? 'ls' : cmd)
382
+ const a = bare ? parseArgs([cmd, ...rest]) : args
383
+ // `continue <id> [agent args...]`: what follows the id goes to the agent
384
+ // untouched, the way `leg claude [args...]` passes its argv straight through
385
+ const raw = bare ? [cmd, ...rest] : rest
386
+ const code = group === 'history' ? await historyCommand(verb, a, { out, die, raw }) : worktreesCommand(verb, a, { out, die })
387
+ process.exit(code)
388
+ }
332
389
  if (group === 'license') {
333
390
  // The paid gate. Keys verify offline against the public key in
334
391
  // src/license.mjs; nothing here talks to the network except refresh.
@@ -454,13 +511,19 @@ async function main() {
454
511
  out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
455
512
  return
456
513
  }
457
- if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
514
+ if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|history|worktrees|resume|accounts|harness|license|share|up|down|status|open|card|scheduler|uninstall)`)
458
515
  out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
459
516
  claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
460
517
  the board opens once, the session shows as a card, usage is tracked, a limit hands off
461
518
  a second live session in one checkout gets its own worktree (--no-worktree to share)
462
519
  auto-approve mode (--no-auto-approve to opt out)
463
520
  sessions ls|show|events|handoff|end|rm|simulate-limit <id>
521
+ history [ls] [--provider p] [--repo r] [--search q] [--json]
522
+ every conversation on this machine: Leg's own, and the ones Claude Code, Codex,
523
+ Grok, Antigravity and Copilot keep in their own stores (read only, nothing moved)
524
+ history show|continue <id> | refresh | providers
525
+ one conversation, or start leg <agent> on it where the agent can resume by id
526
+ worktrees [--repo r] [--json] every checkout Leg can see: git's, its own, the ones conversations worked in
464
527
  resume [--check] [--json] [--path <dir>] the hand-off waiting in this checkout, and whether it is still true
465
528
  freshness is recomputed from git at read time; --check prints only the verdict
466
529
  exit 0 current, 1 stale or unstamped, 3 no pointer here