@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.
- package/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- 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.
|
|
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
|
-
|
|
340
|
-
|
|
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>
|
|
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), `
|
|
455
|
-
|
|
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
|
|
481
|
-
`
|
|
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') {
|
|
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.'
|
|
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:
|
|
237
|
-
for (const p of share.people) out(` ${p.name.padEnd(16)} ${p.role.padEnd(
|
|
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({
|
|
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
|
|
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(
|
|
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,
|
|
293
|
+
const name = args._[0] || die(2, `usage: leg share add <name> [--role ${ROLES.join('|')}]`)
|
|
259
294
|
try {
|
|
260
|
-
|
|
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
|