@ucsandman/legcli 0.10.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 +70 -0
- package/README.md +70 -9
- package/bin/leg.mjs +55 -14
- package/docs/ERRORS.md +53 -0
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/adapters.md +93 -11
- package/docs/cli-contracts.md +36 -17
- package/docs/configuration.md +55 -5
- 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 +4 -4
- package/scripts/probe.mjs +2 -1
- 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 +35 -9
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +15 -0
- package/src/board/index.html +22 -0
- package/src/board/sessions.js +29 -1
- package/src/server.mjs +89 -23
- package/src/share.mjs +66 -6
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +21 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
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
|
+
|
|
3
73
|
## 0.10.0 (2026-09-17)
|
|
4
74
|
|
|
5
75
|
- **The board is responsive again, `leg` starts straight away, and the board
|
package/README.md
CHANGED
|
@@ -275,12 +275,21 @@ token at all.
|
|
|
275
275
|
|
|
276
276
|
```
|
|
277
277
|
leg share on your own link, printed once
|
|
278
|
-
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
|
|
279
280
|
leg share who is on the board (never a token again)
|
|
280
281
|
leg share rotate sam sam's old link stops working
|
|
281
282
|
leg share off back to 127.0.0.1; every link stops working
|
|
282
283
|
```
|
|
283
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
|
+
|
|
284
293
|
A token is kept as a sha256 hash, so a lost link is re-issued, never re-read.
|
|
285
294
|
The board takes the token out of the address bar and keeps it in the browser.
|
|
286
295
|
Your own browser on this machine needs no token.
|
|
@@ -301,12 +310,42 @@ The security pass that goes with it: every `/api` route needs a token, the
|
|
|
301
310
|
event stream included; twenty wrong tokens from one address and that address
|
|
302
311
|
waits a minute; one identity gets 600 requests a minute; a guest gets 403 on
|
|
303
312
|
everything that is not theirs; and the tests send a bad and a missing token to
|
|
304
|
-
every route.
|
|
305
|
-
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
|
|
306
314
|
one sam's; sam's board showed wes's card with the prompt hidden and only
|
|
307
315
|
**Request handoff**, and sam's request reached wes's board (`~/.leg/board.log`:
|
|
308
316
|
"hand-off requested … by sam").
|
|
309
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
|
+
|
|
310
349
|
## The board
|
|
311
350
|
|
|
312
351
|
`leg <agent>` opens it; `leg open` reopens it; `leg down` stops it.
|
|
@@ -337,6 +376,13 @@ one sam's; sam's board showed wes's card with the prompt hidden and only
|
|
|
337
376
|
- **Buttons**, in a fixed order that never reflows: Land, Hand off now,
|
|
338
377
|
Details, End. Once a session has ended, Remove and Remove record take End's
|
|
339
378
|
place. Details opens an expansion in flow under the panel.
|
|
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.
|
|
340
386
|
- Below it, **Conversations**: every coding-agent conversation on this machine
|
|
341
387
|
as a count that opens, the ones Leg started and the ones Claude Code, Codex,
|
|
342
388
|
Grok, Antigravity and Copilot keep in their own stores. Filter by agent,
|
|
@@ -460,6 +506,7 @@ leg claude|codex|agy|grok [agent args…] the interactive agent, board alongside
|
|
|
460
506
|
leg sessions ls [--json] every session and its usage
|
|
461
507
|
leg sessions show|events <id>
|
|
462
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
|
|
463
510
|
leg sessions rm <id> forget an ended session
|
|
464
511
|
leg sessions simulate-limit <id> the real limit path without a real wall (claude, agy, grok)
|
|
465
512
|
leg history [ls] [--provider p] [--repo r] [--search q] [--managed|--external] [--live] [--all] [--json]
|
|
@@ -474,9 +521,12 @@ leg harness sync [--to codex,agy] [--force] [--dry-run] | diff <client> | doctor
|
|
|
474
521
|
leg harness capture [claude|codex] | source <client> | policy <mode> | disable
|
|
475
522
|
leg license the license on this machine, or where to buy one
|
|
476
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
|
|
477
527
|
leg share who is on the board (off by default; Team plan)
|
|
478
|
-
leg share on [--bind tailscale|lan|<addr>] [--port N] | off
|
|
479
|
-
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
|
|
480
530
|
leg open | down | status the board
|
|
481
531
|
leg uninstall [--yes]
|
|
482
532
|
```
|
|
@@ -488,8 +538,10 @@ open the browser), `LEG_USAGE_POLL_MS` (60000), `LEG_CLAUDE_ARGS` /
|
|
|
488
538
|
`LEG_CODEX_ARGS` / `LEG_AGY_ARGS` / `LEG_GROK_ARGS` (extra args for a leg Leg starts after
|
|
489
539
|
a hand-off, e.g. `-m gpt-5.3-codex-spark`), `LEG_CLAUDE_BIN`,
|
|
490
540
|
`LEG_CODEX_BIN`, `LEG_AGY_BIN`, `LEG_GROK_BIN`, `LEG_CHB_BIN`, `LEG_PERSON` (whose
|
|
491
|
-
terminal this is when the board is shared), `
|
|
492
|
-
|
|
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).
|
|
493
545
|
|
|
494
546
|
## Background tasks: the v0.1 extras
|
|
495
547
|
|
|
@@ -514,8 +566,17 @@ under Advanced options.
|
|
|
514
566
|
- Adapters spawn the CLIs headless as argv, never through a shell, with their
|
|
515
567
|
own permission modes and never a bypass flag: `claude -p --output-format json
|
|
516
568
|
--permission-mode <m>`, `codex exec --json -s <m> -C <worktree>`,
|
|
517
|
-
`agy -p --output-format json --mode <m> --add-dir <worktree
|
|
518
|
-
`
|
|
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.
|
|
519
580
|
- A leg that ends on a limit signal, a stall, a crash or exit 0 without
|
|
520
581
|
`.leg/DONE` hands off with a bundle to the next adapter in the same
|
|
521
582
|
worktree; a `land` station rebases, tests and fast-forwards trunk or bounces
|
package/bin/leg.mjs
CHANGED
|
@@ -19,14 +19,16 @@ 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'
|
|
30
32
|
import { historyCommand, worktreesCommand } from '../src/history/cli.mjs'
|
|
31
33
|
|
|
32
34
|
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
|
@@ -159,11 +161,32 @@ async function main() {
|
|
|
159
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)}`)
|
|
160
162
|
return
|
|
161
163
|
}
|
|
162
|
-
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>]]' : ''}`)
|
|
163
165
|
const s = readSession(id) || die(3, `session not found: ${id}`)
|
|
164
166
|
if (cmd === 'show') return out(JSON.stringify({ session: s, events: readSessionEvents(id) }, null, 2))
|
|
165
167
|
if (cmd === 'events') { for (const e of readSessionEvents(id)) out(`${e.ts} ${String(e.type).padEnd(18)} ${e.summary}`); return }
|
|
166
|
-
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
|
+
}
|
|
167
190
|
if (cmd === 'end') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { end: true }); return out(`end requested for ${id}`) }
|
|
168
191
|
if (cmd === 'rm') {
|
|
169
192
|
if (isActive(s)) die(3, `session ${id} is still active; end it first`)
|
|
@@ -226,7 +249,9 @@ async function main() {
|
|
|
226
249
|
const showLink = (person, token, s) => {
|
|
227
250
|
out(`${person.name} is on the board (${person.role}). Their link, shown once:`)
|
|
228
251
|
out(` ${linkFor(s, token)}`)
|
|
229
|
-
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.')
|
|
230
255
|
}
|
|
231
256
|
if (!cmd || cmd === 'ls' || cmd === 'status') {
|
|
232
257
|
if (!share.on || !share.people.length) {
|
|
@@ -234,9 +259,12 @@ async function main() {
|
|
|
234
259
|
out('Turn it on: leg share on (the Tailscale address; --bind lan, or --bind <address>)')
|
|
235
260
|
return
|
|
236
261
|
}
|
|
237
|
-
out(`share is on:
|
|
238
|
-
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', ' ')}` : ''}`)
|
|
239
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).')
|
|
240
268
|
out('A token is shown once. Lost one? leg share rotate <name>. Everyone out: leg share off')
|
|
241
269
|
return
|
|
242
270
|
}
|
|
@@ -246,19 +274,26 @@ async function main() {
|
|
|
246
274
|
const ent = entitlement()
|
|
247
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))
|
|
248
276
|
try {
|
|
249
|
-
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
|
+
})
|
|
250
282
|
await restartBoard()
|
|
251
|
-
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})`)
|
|
252
284
|
if (r.token) showLink(r.owner, r.token, r.share)
|
|
253
|
-
out('Add someone: leg share add <name>')
|
|
254
|
-
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.')
|
|
255
289
|
} catch (err) { die(2, err.message) }
|
|
256
290
|
return
|
|
257
291
|
}
|
|
258
292
|
if (cmd === 'add') {
|
|
259
|
-
const name = args._[0] || die(2,
|
|
293
|
+
const name = args._[0] || die(2, `usage: leg share add <name> [--role ${ROLES.join('|')}]`)
|
|
260
294
|
try {
|
|
261
|
-
|
|
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 })
|
|
262
297
|
showLink(r.person, r.token, r.share)
|
|
263
298
|
if (!r.share.on) out('share is still off: leg share on')
|
|
264
299
|
} catch (err) { die(2, err.message) }
|
|
@@ -330,6 +365,12 @@ async function main() {
|
|
|
330
365
|
const code = await harnessCommand(cmd, args, { out, die })
|
|
331
366
|
process.exit(code)
|
|
332
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
|
+
}
|
|
333
374
|
if (group === 'history' || group === 'worktrees') {
|
|
334
375
|
// Every conversation on this machine, Leg's own and the ones the agents'
|
|
335
376
|
// stores hold: a read-only index (src/history/index.mjs). `continue`
|
package/docs/ERRORS.md
CHANGED
|
@@ -3,6 +3,59 @@
|
|
|
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
|
+
|
|
6
59
|
## 2026-09-17: one running terminal saturated the board's event loop, and four separate symptoms came out of it
|
|
7
60
|
|
|
8
61
|
**Fixed in `src/server.mjs` (watcher filter, stat fingerprint, push floor, cached
|
package/docs/ROADMAP-v2.md
CHANGED
|
@@ -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.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
47
|
-
|
|
48
|
-
|
|
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,
|
|
54
|
-
|
|
55
|
-
|
|
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`.)
|
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 (
|
|
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 -
|
|
247
|
-
<mode>`, plus
|
|
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
|
-
- **
|
|
254
|
-
|
|
255
|
-
`
|
|
256
|
-
`
|
|
257
|
-
`
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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):
|