@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 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. There is still no TLS, so keep this on Tailscale or a network you
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> 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
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), `LEG_RATE_MAX` (600 requests a
492
- 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).
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>`; `fake`,
518
- `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.
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') { 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
+ }
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.' : '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.')
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: http://${share.bind}:${share.port} (${share.bind_kind})`)
238
- 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', ' ')}` : ''}`)
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({ 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
+ })
250
282
  await restartBoard()
251
- 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})`)
252
284
  if (r.token) showLink(r.owner, r.token, r.share)
253
- out('Add someone: leg share add <name>')
254
- 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.')
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, 'usage: leg share add <name> [--role owner|guest]')
293
+ const name = args._[0] || die(2, `usage: leg share add <name> [--role ${ROLES.join('|')}]`)
260
294
  try {
261
- 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 })
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
@@ -1,5 +1,13 @@
1
1
  # Roadmap v2: the software factory
2
2
 
3
+ **Unreleased (2026-09-17) opened the chain and finished the share story.** grok is
4
+ a registered card adapter; any other CLI becomes one from a JSON spec
5
+ (`leg adapter add`, no code); **Hand off now** can name its destination
6
+ instead of taking the next in the order; `leg share` grew TLS from a
7
+ certificate pair you supply, an `operator` role between owner and guest, and
8
+ an audit trail of who did what across every terminal and every card. That
9
+ closes items 1 and 4 below, and the "hand off now to \<adapter>" picker.
10
+
3
11
  **0.3.0 (2026-09-11) shipped item 1 and the first half of continuous landing.**
4
12
  A second live session in one checkout gets its own worktree and branch, and
5
13
  **Land** on its card sends that branch through the merge queue (rebase, tests,
@@ -33,23 +41,28 @@ event names its actor, a board that reads only the ledger, and an auth/bind seam
33
41
 
34
42
  ## Next
35
43
 
36
- 1. **Multi-human network access**, shipped in 0.3.0 as `leg share` (a token
37
- and a name per human, per-human actor ids on every event, rate limits, a
38
- guest's read-only redacted board). What is left: TLS termination, token
39
- scopes finer than owner and guest, and an audit view of who did what across
40
- sessions.
44
+ 1. ~~**Multi-human network access**~~ done. 0.3.0 shipped `leg share`; the
45
+ unreleased work of 2026-09-17 shipped the three that were left: TLS (`leg share on --tls-cert/--tls-key`,
46
+ or `LEG_TLS_CERT`/`LEG_TLS_KEY`, from a pair you supply Leg issues none),
47
+ the `operator` role between owner and guest, and the audit trail
48
+ (`/api/audit`, Settings → Audit trail).
41
49
  2. **Review station with human reviewers**: a `human` station kind that shows
42
50
  the diff, the bundle and the test tail, with Approve / Request changes /
43
51
  Reassign as buttons; reviewer identity from the token.
44
52
  3. **`pr` land mode live**: `gh pr create` argv is built and stub-tested today;
45
53
  run it for real behind an explicit per-card opt-in and a remote allowlist.
46
- 4. **More adapters**: grok (adapter written, unregistered until `grok login` and
47
- a passing probe on the machine) and muse only if a real CLI is verified; a
48
- generic "argv + JSON result" adapter for anything else.
54
+ 4. ~~**More adapters**~~ done. grok is registered (2026-09-17: flags read
55
+ from `grok --help` on 1.0.34, envelope read from the shipped binary, probe
56
+ reached the account and returned a real 402 wall that classified `limit`;
57
+ the success path is still unprobed for want of balance). Anything else,
58
+ muse included, is a custom adapter: a JSON spec in
59
+ `$LEG_HOME/adapters/<name>.json`, `leg adapter add`, no code
60
+ ([adapters.md](adapters.md#custom-adapters)).
49
61
  5. **OpenClaw Workboard mirror** once the bundled plugin is allowed
50
62
  (`plugins.allow`): the verb table in `src/sync/workboard.mjs` is the only
51
63
  thing to check against `openclaw workboard --help`.
52
64
 
53
- Also on the list: per-station prompt templates editable from the board, a
54
- "hand off now to <adapter>" picker, lease suggestions from the diff of the
55
- previous leg, and a floor view that shows lease contention over time.
65
+ Also on the list: per-station prompt templates editable from the board, lease
66
+ suggestions from the diff of the previous leg, and a floor view that shows
67
+ lease contention over time. (The "hand off now to \<adapter>" picker landed
68
+ 2026-09-17, unreleased: Details → **Hand off now to**, or `leg sessions handoff <id> --to`.)
package/docs/adapters.md CHANGED
@@ -239,25 +239,107 @@ These are what a pipeline card's chain spawns. Unchanged since 0.2.0.
239
239
  `emulates` to that CLI's name, so the limit classifier applies that CLI's own
240
240
  fixtures to it and a demo chain reads like a real fallback.
241
241
 
242
- ### grok (built, not registered)
242
+ ### grok (headless)
243
243
 
244
244
  - **Binary**: `LEG_GROK_BIN`, else `~/.grok/bin/grok.exe`, else `grok` on
245
245
  PATH.
246
- - **Argv**: `grok -p "<prompt>" --output-format json --permission-mode
247
- <mode>`, plus `-m <model>`, `-r <session-id>`.
246
+ - **Argv**: `grok --prompt-file <run>/prompt.txt --output-format json
247
+ --permission-mode <mode> --cwd <worktree>`, plus `--max-turns <n>`,
248
+ `-m <model>`, `-r <session-id>`. Every flag was read from `grok --help` on
249
+ grok 1.0.34 (`3736acbc8658`) on 2026-09-17. The prompt travels by file
250
+ rather than on argv because a hand-off prompt carries the whole bundle
251
+ summary and Windows caps one command line at about 32k; with no prompt file
252
+ the adapter falls back to `-p "<prompt>"`. `--cwd` is passed explicitly
253
+ rather than relying on the spawn's working directory: grok can run against a
254
+ shared leader process (`~/.grok/leader.sock`), and a leg must edit its own
255
+ worktree, not whatever directory the leader started in.
248
256
  - **Stdin**: `ignore`.
249
257
  - **Modes**: default `acceptEdits`; allowed `default`, `acceptEdits`,
250
258
  `auto`, `dontAsk`, `plan`.
251
259
  - **Forbidden flags**: `--always-approve`, `bypassPermissions`,
252
260
  `--permission-mode=bypassPermissions`.
253
- - **Status**: `src/adapters/grok.mjs` exists and is unit-tested, but is
254
- **not** in `src/adapters/index.mjs`'s registry. The build machine had no
255
- `grok` login: the probe printed a device-code prompt and exited
256
- `Cancelled`. Register it (add an entry to `REGISTRY` in
257
- `src/adapters/index.mjs`) once `grok login` has been completed and
258
- `node scripts/probe.mjs --adapter grok --repo <toy-repo>` passes.
259
-
260
- ## How to add an adapter
261
+ - **Result**: grok's headless writer emits the Claude Code result envelope.
262
+ The field names (`"type":"result"`, `subtype`, `is_error`, `session_id`,
263
+ `result`, `num_turns`, `stop_reason`, `total_cost`) were read out of the
264
+ shipped `grok.exe` on 2026-09-17. An error is the other envelope,
265
+ `{"type":"error","message":…}`, observed live the same day.
266
+ - **Status**: registered on 2026-09-17. The probe reached the account and came
267
+ back with a real wall — `API error (status 402 Payment Required): Grok Build
268
+ usage balance exhausted`, exit 1, classified `limit` and handed off — so the
269
+ spawn, the argv, the auth and the wall path are all verified live
270
+ (`fixtures/live/grok/`). The **success** path of a grok leg is still
271
+ unprobed: that needs balance on the account. Until it is, a grok leg whose
272
+ envelope does not parse falls back to the `.leg/DONE` marker and the diff,
273
+ which is what every adapter does when `parseResult` returns null. Re-run
274
+ `node scripts/probe.mjs --adapter grok --repo <toy-repo>` with balance to
275
+ close it.
276
+
277
+ ## Custom adapters
278
+
279
+ Any other coding-agent CLI becomes a card adapter through a JSON spec in
280
+ `$LEG_HOME/adapters/<name>.json`, with no code in this package. This is the
281
+ open end of the chain: claude, codex, agy and grok ship with taps and a probe,
282
+ and anything else joins as "argv in, JSON out".
283
+
284
+ A custom adapter runs **cards**: headless, in a worktree, handing off like any
285
+ other leg. It is not an interactive `leg <agent>` terminal, because that needs
286
+ a usage tap and a wall signal, which only the four built-ins expose.
287
+
288
+ ```
289
+ leg adapter template --name muse > muse.json # a starter spec
290
+ leg adapter add muse.json # validate and install it
291
+ leg adapter check muse # the exact command a leg would run
292
+ leg card add --repo <path> --task "<t>" --chain muse,claude --queue
293
+ leg adapter list | show muse | rm muse
294
+ ```
295
+
296
+ The spec:
297
+
298
+ ```json
299
+ {
300
+ "name": "muse",
301
+ "bin": "muse",
302
+ "stdin": "ignore",
303
+ "args": ["run", "--json",
304
+ ["--dir", "{{cwd}}"],
305
+ ["--model", "{{model}}"],
306
+ "--prompt-file", "{{promptFile}}"],
307
+ "modes": { "default": "auto", "allowed": ["auto", "readonly"] },
308
+ "forbiddenFlags": ["--unsafe"],
309
+ "result": { "format": "json", "sessionId": "session_id",
310
+ "message": "result", "stopReason": "stop_reason" }
311
+ }
312
+ ```
313
+
314
+ - **`bin`** is argv[0]. It is spawned directly, never through a shell, so it
315
+ cannot contain `< > | & ;`. `LEG_<NAME>_BIN` overrides it. A `.mjs`, `.cjs`
316
+ or `.js` path runs under this Node.
317
+ - **`args`** is a list of strings and groups. Placeholders: `{{prompt}}`,
318
+ `{{promptFile}}`, `{{cwd}}`, `{{mode}}`, `{{model}}`, `{{resume}}`,
319
+ `{{maxTurns}}`, `{{runDir}}`. A bare string is always kept; a **group** (a
320
+ nested array) is dropped whole when a placeholder inside it has no value, so
321
+ `["--model", "{{model}}"]` disappears rather than passing a bare `--model`.
322
+ Something has to carry the prompt: `{{prompt}}`, `{{promptFile}}`, or
323
+ `"stdin": "pipe"`.
324
+ - **`modes`** is validated before anything spawns, the same way a built-in's
325
+ is: a chain entry naming a mode outside `allowed` throws `forbidden flag`.
326
+ - **`forbiddenFlags`** is yours to add to. The flags that turn a supervised
327
+ agent into an unsupervised one are refused whatever the spec says, in `args`,
328
+ in `modes` and at argv time (`NEVER_ALLOWED`, `src/adapters/custom.mjs`).
329
+ - **`result.format`** is `json` (the first parseable object in stdout),
330
+ `jsonl` (the last line carrying the message field) or `text` (no parsing).
331
+ `sessionId`, `message` and `stopReason` are dotted paths, so
332
+ `"sessionId": "thread.id"` reads `{"thread":{"id":…}}`. With `text`, or when
333
+ nothing parses, the leg is judged by its `.leg/DONE` marker and its diff,
334
+ which is what happens for any adapter whose `parseResult` returns null.
335
+
336
+ A broken spec is reported, never thrown: `leg adapter list` names the file and
337
+ the reason, and the board, the scheduler and `leg card add` carry on without
338
+ it. A spec may not take a built-in's name. The directory is re-read whenever
339
+ the list is asked for, keyed on its entries, so a spec added while the board is
340
+ up appears in the New card form without a restart.
341
+
342
+ ## How to add a built-in adapter
261
343
 
262
344
  An adapter is a plain object (see `src/adapters/common.mjs` for the shared
263
345
  helpers, `src/adapters/fake.mjs` for the simplest full example):