@ucsandman/legcli 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // fake-agent — stands in for a coding-agent CLI in tests and the demo. It acts
3
+ // in the process cwd (the card's worktree). FAKE_MODE selects behaviour:
4
+ // success write the target file and .baton/DONE, print a result JSON, exit 0
5
+ // incomplete write the target file but no DONE marker, exit 0
6
+ // limit print the recorded limit text (FAKE_LIMIT_FIXTURE, default
7
+ // claude-session-limit) to the fixture's stream, exit with its code
8
+ // stall never finish (10 min), so the supervisor's timers fire
9
+ // auth print "another auth source is set" to stderr, exit 1
10
+ // crash print a stack trace to stderr, exit 2
11
+ // no_progress exit 0 touching nothing
12
+ // break-test (land demo) write the target plus a failing test, DONE
13
+ // fix-test (land demo) remove the failing test, DONE
14
+ // resolve-rebase (land demo) git rebase -X theirs <FAKE_TRUNK>, DONE
15
+ // FAKE_CONTENT is the target file's content (default "hi").
16
+ // fail (phase 2 alias of crash with a fake secret in stderr) exit 1
17
+ // sleep (phase 2 alias of stall)
18
+ // envcheck report which forbidden variables leaked into this process
19
+ // FAKE_TARGET names the file success/incomplete write (default hello-fake.txt).
20
+ // FAKE_DELAY_MS waits before acting. Output goes through process.stdout/stderr
21
+ // on purpose: this IS a CLI.
22
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs'
23
+ import { spawnSync } from 'node:child_process'
24
+ import { join, dirname, resolve } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const mode = process.env.FAKE_MODE || 'success'
28
+ const target = process.env.FAKE_TARGET || 'hello-fake.txt'
29
+ const delay = parseInt(process.env.FAKE_DELAY_MS || '0', 10)
30
+ const cwd = process.cwd()
31
+ const FIXTURES = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', 'limits')
32
+
33
+ function readStdin() {
34
+ return new Promise((resolvePromise) => {
35
+ let data = ''
36
+ process.stdin.setEncoding('utf8')
37
+ process.stdin.on('data', (c) => { data += c })
38
+ process.stdin.on('end', () => resolvePromise(data))
39
+ process.stdin.on('error', () => resolvePromise(data))
40
+ process.stdin.resume()
41
+ })
42
+ }
43
+
44
+ const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n')
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
46
+
47
+ function loadFixture(id) {
48
+ for (const d of readdirSync(FIXTURES)) {
49
+ const f = join(FIXTURES, d, `${id}.json`)
50
+ if (existsSync(f)) return JSON.parse(readFileSync(f, 'utf8'))
51
+ }
52
+ throw new Error(`unknown FAKE_LIMIT_FIXTURE ${id}`)
53
+ }
54
+
55
+ function writeTarget() {
56
+ mkdirSync(dirname(join(cwd, target)), { recursive: true })
57
+ writeFileSync(join(cwd, target), (process.env.FAKE_CONTENT ?? 'hi') + '\n')
58
+ }
59
+
60
+ function writeDone(line) {
61
+ mkdirSync(join(cwd, '.baton'), { recursive: true })
62
+ writeFileSync(join(cwd, '.baton', 'DONE'), line + '\n')
63
+ }
64
+
65
+ const prompt = process.stdin.isTTY ? '' : await readStdin()
66
+ if (delay > 0) await sleep(delay)
67
+
68
+ if (mode === 'success') {
69
+ writeTarget()
70
+ writeDone(`wrote ${target}`)
71
+ out({ session_id: 'sess-fake', result: `wrote ${target} and .baton/DONE`, prompt_chars: prompt.length, argv: process.argv.slice(2) })
72
+ process.exit(0)
73
+ } else if (mode === 'break-test') {
74
+ // land demo: ship the change together with a failing test
75
+ writeTarget()
76
+ mkdirSync(join(cwd, 'test'), { recursive: true })
77
+ writeFileSync(join(cwd, 'test', 'broken.test.mjs'), "import { test } from 'node:test'\nimport assert from 'node:assert/strict'\ntest('broken on purpose by fake-agent', () => { assert.equal(1, 2) })\n")
78
+ writeDone(`wrote ${target} and a failing test`)
79
+ out({ session_id: 'sess-fake', result: 'done (with a broken test)' })
80
+ process.exit(0)
81
+ } else if (mode === 'fix-test') {
82
+ // land demo: the bounce told us the tests are red; remove the broken one
83
+ rmSync(join(cwd, 'test', 'broken.test.mjs'), { force: true })
84
+ writeTarget()
85
+ writeDone('fixed the failing test')
86
+ out({ session_id: 'sess-fake', result: 'removed the broken test' })
87
+ process.exit(0)
88
+ } else if (mode === 'resolve-rebase') {
89
+ // land demo: the bounce said the rebase conflicted; rebase onto trunk keeping our side
90
+ const trunk = process.env.FAKE_TRUNK || 'main'
91
+ const r = spawnSync('git', ['rebase', '-X', 'theirs', trunk], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
92
+ if (r.status !== 0) {
93
+ spawnSync('git', ['rebase', '--abort'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
94
+ process.stderr.write(`fake-agent: rebase failed: ${r.stderr}\n`)
95
+ process.exit(1)
96
+ }
97
+ writeDone(`rebased onto ${trunk}`)
98
+ out({ session_id: 'sess-fake', result: `rebased onto ${trunk}` })
99
+ process.exit(0)
100
+ } else if (mode === 'incomplete') {
101
+ writeTarget()
102
+ out({ session_id: 'sess-fake', result: `wrote ${target}, ran out of turns before DONE`, prompt_chars: prompt.length })
103
+ process.exit(0)
104
+ } else if (mode === 'limit') {
105
+ const fx = loadFixture(process.env.FAKE_LIMIT_FIXTURE || 'claude-session-limit')
106
+ const text = fx.text
107
+ if (fx.where === 'stderr') {
108
+ process.stderr.write(text + '\n')
109
+ } else {
110
+ // claude-shaped error result so the real adapter's parseResult reads it too
111
+ out({ type: 'result', subtype: 'error', is_error: true, result: text, session_id: 'sess-fake' })
112
+ }
113
+ process.exit(fx.exit_code ?? 1)
114
+ } else if (mode === 'stall' || mode === 'sleep') {
115
+ await sleep(600000)
116
+ process.exit(0)
117
+ } else if (mode === 'auth') {
118
+ process.stderr.write('Error: another auth source is set (ANTHROPIC_API_KEY); using it instead of your login\n')
119
+ process.exit(1)
120
+ } else if (mode === 'crash') {
121
+ process.stderr.write('TypeError: Cannot read properties of undefined (reading \'plan\')\n at run (file:///fake-agent.mjs:1:1)\n')
122
+ process.exit(2)
123
+ } else if (mode === 'fail') {
124
+ process.stderr.write('boom line one\n')
125
+ process.stderr.write('api_key=sk-abcdefgh12345678 leaked\n')
126
+ process.exit(1)
127
+ } else if (mode === 'no_progress') {
128
+ out({ session_id: 'sess-fake', result: 'I could not find anything to do.', prompt_chars: prompt.length })
129
+ process.exit(0)
130
+ } else if (mode === 'envcheck') {
131
+ const forbidden = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
132
+ 'OPENAI_API_KEY', 'CLAUDECODE', 'CLAUDE_EFFORT', 'CLAUDE_PLUGIN_DATA']
133
+ const leaked = forbidden.filter((k) => process.env[k] !== undefined)
134
+ for (const k of Object.keys(process.env)) {
135
+ if (/^CLAUDE_CODE_/.test(k) && k !== 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS') leaked.push(k)
136
+ }
137
+ if (process.env.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS !== '0') leaked.push('CEILING!=0')
138
+ out({ session_id: leaked.length ? 'LEAKED:' + leaked.join('+') : 'clean-env' })
139
+ process.exit(0)
140
+ } else {
141
+ process.stderr.write(`fake-agent: unknown FAKE_MODE "${mode}"\n`)
142
+ process.exit(2)
143
+ }
package/bin/leg.mjs ADDED
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ // leg, headless CLI. The board (phase 6) is the human surface; this is the
3
+ // agent/script surface and the test seam. Output via process.stdout only.
4
+ // leg card add --repo <p> --task "<t>" --chain claude,codex [--pipeline preset|file] …
5
+ // leg card ls [--json] | show <id> | run <id> | rm <id> [--delete-branch] | events <id>
6
+ // leg card <pause|resume|kill|approve|handoff-now|rerun> <id> | reassign <id> --adapter a [--mode m]
7
+ // leg scheduler start [--ticks N] [--interval-ms N] | status | stop
8
+ import { rmSync, appendFileSync, readFileSync } from 'node:fs'
9
+ import { join, dirname, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { PRESET_NAMES } from '../src/presets.mjs'
13
+ import { readCard, listCards, readEvents, readRuns, cardDir } from '../src/store.mjs'
14
+ import { runCard, humanAction } from '../src/orchestrator.mjs'
15
+ import { createCard, CardInputError } from '../src/cards.mjs'
16
+ import { remove as removeWorktree } from '../src/worktree.mjs'
17
+ import { pruneSessionWorktree } from '../src/land.mjs'
18
+ import { createScheduler, schedulerStatus, pidfile, MAX_CONCURRENT } from '../src/scheduler.mjs'
19
+ import { availableActions } from '../src/chain.mjs'
20
+ import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
21
+ import { attach, ensureBoard } from '../src/attach.mjs'
22
+ import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed } from '../src/share.mjs'
23
+ import { 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'
26
+ import { home } from '../src/store.mjs'
27
+ import { entitlement, allows, describe as describeLicense, activate as activateLicense, deactivate as deactivateLicense, refresh as refreshLicense, licensePath, BUY_URL } from '../src/license.mjs'
28
+ import { resumeVerdict, bodyOf, ago } from '../src/resume.mjs'
29
+
30
+ const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src')
31
+ // one source of truth for the version, so the help text cannot drift from the package
32
+ const VERSION = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8')).version
33
+ const out = (s) => process.stdout.write(s + '\n')
34
+ const die = (code, msg) => { process.stderr.write(msg + '\n'); process.exit(code) }
35
+
36
+ function parseArgs(argv) {
37
+ const args = { _: [] }
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i]
40
+ if (a.startsWith('--')) {
41
+ const key = a.slice(2)
42
+ const next = argv[i + 1]
43
+ if (next === undefined || next.startsWith('--')) { args[key] = true } else { args[key] = next; i++ }
44
+ } else args._.push(a)
45
+ }
46
+ return args
47
+ }
48
+
49
+ async function cardAdd(args) {
50
+ try {
51
+ const card = await createCard({
52
+ repo: args.repo, task: args.task, chain: args.chain, pipeline: args.pipeline,
53
+ mode: args.mode, maxTurns: args['max-turns'], model: args.model, fakeMode: args['fake-mode'], fakeFixture: args['fake-fixture'],
54
+ fakeTarget: args['fake-target'], fakeContent: args['fake-content'],
55
+ approve: args.approve, leases: args.leases, trunk: args.trunk, landMode: args['land-mode'],
56
+ testCommand: args['test-command'], title: args.title, slug: args.slug, queue: Boolean(args.queue),
57
+ }, { type: 'human', id: args.actor || 'local' })
58
+ out(card.card_id)
59
+ } catch (err) {
60
+ if (err instanceof CardInputError) die(2, err.message)
61
+ throw err
62
+ }
63
+ }
64
+
65
+ // Drive the real limit path without a real wall: the same StopFailure payload
66
+ // Claude Code would send goes through src/hook.mjs (claude), or the
67
+ // RESOURCE_EXHAUSTED line lands in the session's own agy log (agy). The
68
+ // runner then does what it does for a real limit: bundle, stop the agent,
69
+ // start the next option in the same terminal. The payload is marked
70
+ // simulated: it is never kept as live evidence, and the wall it records
71
+ // clears after two minutes. codex has no Leg-owned input, so it is refused.
72
+ function simulateLimit(s) {
73
+ if (!isActive(s)) die(3, `session ${s.session_id} is not active`)
74
+ if (['limit', 'handing_off'].includes(s.status)) die(3, `session ${s.session_id} is already ${s.status}`)
75
+ if (s.agent === 'claude') {
76
+ const payload = {
77
+ hook_event_name: 'StopFailure', error: 'rate_limit', session_id: s.agent_session_id ?? undefined, transcript_path: s.transcript_path ?? undefined,
78
+ last_assistant_message: 'API Error: Rate limit reached (simulated by leg sessions simulate-limit)', leg_simulated: true, baton_simulated: true,
79
+ }
80
+ const r = spawnSync(process.execPath, [join(SRC, 'hook.mjs'), 'claude-hook', '--session', s.session_id], { input: JSON.stringify(payload), windowsHide: true, encoding: 'utf8', timeout: 15000 })
81
+ if (r.status !== 0) die(1, `hook exited ${r.status}: ${(r.stderr || '').slice(0, 300)}`)
82
+ const after = readSession(s.session_id)
83
+ if (after?.status !== 'limit') die(1, `hook ran but the session is ${after?.status ?? 'gone'}, not limit`)
84
+ return out(`simulated: StopFailure rate_limit sent through src/hook.mjs; ${s.session_id} is at limit (wall clears in 2 min); the runner hands off within ${(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS) || 2000} ms to ${after.chain?.[0]?.agent ?? 'nothing'}`)
85
+ }
86
+ if (s.agent === 'agy') {
87
+ appendFileSync(join(sessionDir(s.session_id), 'agy.log'), '\nrpc error: code = ResourceExhausted desc = RESOURCE_EXHAUSTED quota (simulated by leg sessions simulate-limit)\n')
88
+ appendEvent(s.session_id, { type: 'status', summary: 'simulated RESOURCE_EXHAUSTED appended to the session log' })
89
+ return out(`simulated: RESOURCE_EXHAUSTED appended to ${join(sessionDir(s.session_id), 'agy.log')}; the runner reads it within ${process.env.BATON_ATTACH_POLL_MS || 2000} ms and hands off to ${s.chain?.[0]?.agent ?? 'nothing'}`)
90
+ }
91
+ die(2, `simulate-limit drives the claude hook path (and the agy log); codex's wall comes from its own rollout file, which Baton never writes. Use "leg sessions handoff ${s.session_id}" to force the switch.`)
92
+ }
93
+
94
+ function fmtCard(c) {
95
+ const st = c.pipeline?.find((s) => s.name === c.station)
96
+ const leg = st?.kind === 'agent' ? ` leg ${c.leg}/${st.chain.length} (${st.chain[c.leg]?.adapter ?? '-'})` : ''
97
+ return `${c.card_id} [${c.status}] ${c.station}${leg} leases=${(c.leases?.length ? c.leases : ['**']).join(',')} ${String(c.title ?? c.task).slice(0, 60)}`
98
+ }
99
+
100
+ const TERMS = `Terms check (fetched 2026-09-11): Anthropic Consumer Terms forbid sharing account credentials and "bypassing any of our systems or protective measures"; the Anthropic Usage Policy forbids coordinating across multiple accounts to circumvent product guardrails; OpenAI's Terms of Use forbid sharing credentials and "circumvent any rate limits or restrictions". Two paid logins you own are not banned by name, but rotating to a second account of the same vendor because the first is rate-limited is close to that wording. Leg's default chain switches vendors (claude -> codex -> agy); a second account of one vendor is your call.`
101
+
102
+ async function main() {
103
+ const [group, cmd, ...rest] = process.argv.slice(2)
104
+ const args = parseArgs(rest)
105
+ if (group === '--version' || group === '-v') return out(VERSION)
106
+ if (group === '🦿' || group === 'prosthetic' || group === 'easter-egg') {
107
+ out(' 🦿 LegCli — The mechanical relay runner for coding agents.')
108
+ out(`
109
+ .--------.
110
+ | ____ |
111
+ | | | |
112
+ | |____| |
113
+ '--------'
114
+ ||
115
+ .--||--.
116
+ | || | knee servo
117
+ '--||--'
118
+ ||
119
+ ||
120
+ ___||___
121
+ |________|
122
+ `)
123
+ out(' Passing the leg to the next runner when limits hit.')
124
+ return
125
+ }
126
+ if (AGENTS.includes(group)) {
127
+ // leg claude|codex|agy [agent args...]: everything after the agent name
128
+ // goes straight through.
129
+ const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: process.env.BATON_NO_OPEN !== '1' })
130
+ process.exit(code)
131
+ }
132
+ if (group === 'sessions') {
133
+ const list = listSessions()
134
+ if (cmd === 'ls' || !cmd) {
135
+ if (args.json) return out(JSON.stringify(list, null, 2))
136
+ if (!list.length) return out('(no sessions)')
137
+ 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)}`)
138
+ return
139
+ }
140
+ const id = args._[0] || die(2, `usage: leg sessions ${cmd} <session-id>`)
141
+ const s = readSession(id) || die(3, `session not found: ${id}`)
142
+ if (cmd === 'show') return out(JSON.stringify({ session: s, events: readSessionEvents(id) }, null, 2))
143
+ if (cmd === 'events') { for (const e of readSessionEvents(id)) out(`${e.ts} ${String(e.type).padEnd(18)} ${e.summary}`); return }
144
+ if (cmd === 'handoff') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { handoff: true }); return out(`handoff requested for ${id}`) }
145
+ if (cmd === 'end') { if (!isActive(s)) die(3, `session ${id} is not active`); requestControl(id, { end: true }); return out(`end requested for ${id}`) }
146
+ if (cmd === 'rm') {
147
+ if (isActive(s)) die(3, `session ${id} is still active; end it first`)
148
+ // a land runs in the board's process: land.json is the only place this
149
+ // terminal can see it, and removing the record drops the result
150
+ if (readLand(id)?.state === 'landing') die(3, `session ${id} is landing right now; wait for it to finish`)
151
+ // prune the worktree and branch too, the way the board's Remove does, so
152
+ // the CLI twin never orphans a worktree the board can no longer reach
153
+ if (s.worktree) {
154
+ try {
155
+ const r = pruneSessionWorktree(s)
156
+ out(r.removed
157
+ ? `removed worktree ${s.worktree.path}${r.branchDeleted ? ` and branch ${s.worktree.branch}` : `; kept branch ${s.worktree.branch}`}`
158
+ : `kept the worktree (${r.reason}); Land it or delete it by hand`)
159
+ } catch (e) { out(`worktree not pruned: ${e.message}`) }
160
+ }
161
+ removeSession(id); return out(`removed ${id}`)
162
+ }
163
+ if (cmd === 'simulate-limit') return simulateLimit(s)
164
+ die(2, `unknown sessions command "${cmd}" (ls|show|events|handoff|end|rm|simulate-limit)`)
165
+ }
166
+ if (group === 'resume') {
167
+ // The read side of the pointer. Freshness is never read out of the file:
168
+ // it is recomputed from git here, now, so a resume file cannot describe a
169
+ // picture that is no longer true to whoever is standing in the repo.
170
+ const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
171
+ const where = typeof a.path === 'string' ? resolve(a.path) : process.cwd()
172
+ const v = resumeVerdict(where)
173
+ if (a.json) { out(JSON.stringify(v, null, 2)); process.exit(v.exit_code) }
174
+ if (v.state === 'missing') {
175
+ out(`no resume pointer in this checkout (looked for .baton/RESUME.md from ${where} upward).`)
176
+ out('Baton writes one when a terminal hands off; `baton claude` in this directory starts one.')
177
+ process.exit(v.exit_code)
178
+ }
179
+ const head = v.head?.now ? `${v.head.now.slice(0, 7)}${v.head.branch ? ` on ${v.head.branch}` : ''}` : 'no commit'
180
+ const line = v.state === 'fresh'
181
+ ? `${v.file} is current: written ${v.written_at ? ago(v.age_ms) : 'at an unrecorded time'}, and the repository is still at ${head}.`
182
+ : v.state === 'unstamped'
183
+ ? `${v.file} is UNSTAMPED: ${v.reasons[0]}. Baton did not write it, or an older Baton did.`
184
+ : `${v.file} is STALE: ${v.reasons.join('; ')}.`
185
+ if (a.check) {
186
+ out(line)
187
+ if (v.state !== 'fresh') out('Read it as history, not as the current picture: check `git status` and `git diff` before acting on it.')
188
+ process.exit(v.exit_code)
189
+ }
190
+ // Printed even when stale: a stale hand-off still beats nothing when the
191
+ // human chooses to read it. The banner and the exit code are what say so.
192
+ out(v.state === 'fresh' ? `# ${line}` : `# !!! ${line}`)
193
+ out('')
194
+ out(bodyOf(readFileSync(v.file, 'utf8')).trimEnd())
195
+ process.exit(v.exit_code)
196
+ }
197
+ if (group === 'share') {
198
+ // Multiplayer, off by default: the board binds a shared address only once
199
+ // at least one person has a token, and every human has their own.
200
+ const share = readShare()
201
+ // only the listener moves: the agents running under it are not part of who
202
+ // may look at the board
203
+ const restartBoard = async () => { await stopBoard(); const b = await ensureBoard({ open: false }); return b }
204
+ const showLink = (person, token, s) => {
205
+ out(`${person.name} is on the board (${person.role}). Their link, shown once:`)
206
+ out(` ${linkFor(s, token)}`)
207
+ 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.')
208
+ }
209
+ if (!cmd || cmd === 'ls' || cmd === 'status') {
210
+ if (!share.on || !share.people.length) {
211
+ out('share is off: the board is on 127.0.0.1 and only this machine can reach it.')
212
+ out('Turn it on: leg share on (the Tailscale address; --bind lan, or --bind <address>)')
213
+ return
214
+ }
215
+ out(`share is on: http://${share.bind}:${share.port} (${share.bind_kind})`)
216
+ 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', ' ')}` : ''}`)
217
+ out('')
218
+ out('A token is shown once. Lost one? leg share rotate <name>. Everyone out: leg share off')
219
+ return
220
+ }
221
+ if (cmd === 'on') {
222
+ const a = parseArgs(rest)
223
+ // more than one human is the Team plan
224
+ const ent = entitlement()
225
+ 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))
226
+ try {
227
+ const r = await turnOn({ bind: a.bind ?? 'tailscale', port: a.port ? parseInt(a.port, 10) : undefined, owner: a.owner })
228
+ await restartBoard()
229
+ out(`share is on: the board is at http://${r.share.bind}:${r.share.port} (${r.share.bind_kind})`)
230
+ if (r.token) showLink(r.owner, r.token, r.share)
231
+ out('Add someone: leg share add <name>')
232
+ 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.')
233
+ } catch (err) { die(2, err.message) }
234
+ return
235
+ }
236
+ if (cmd === 'add') {
237
+ const name = args._[0] || die(2, 'usage: leg share add <name> [--role owner|guest]')
238
+ try {
239
+ const r = addPerson(name, { role: args.role === 'owner' ? 'owner' : 'guest', share })
240
+ showLink(r.person, r.token, r.share)
241
+ if (!r.share.on) out('share is still off: leg share on')
242
+ } catch (err) { die(2, err.message) }
243
+ return
244
+ }
245
+ if (cmd === 'rotate') {
246
+ const name = args._[0] || die(2, 'usage: leg share rotate <name>')
247
+ try {
248
+ const r = rotateToken(name, share)
249
+ out(`${name}'s old link stopped working.`)
250
+ showLink(r.person, r.token, r.share)
251
+ } catch (err) { die(2, err.message) }
252
+ return
253
+ }
254
+ if (cmd === 'rm') {
255
+ const name = args._[0] || die(2, 'usage: leg share rm <name>')
256
+ if (!personNamed(share, name)) die(3, `no one called "${name}" on this board`)
257
+ try { removePerson(name, share) } catch (err) { die(2, err.message) }
258
+ return out(`${name} is off the board; their link stopped working.`)
259
+ }
260
+ if (cmd === 'off') {
261
+ // the shared listener goes first: while it is up and share.json reads off
262
+ // there is nobody for it to check a stranger against
263
+ await stopBoard()
264
+ turnOff()
265
+ await ensureBoard({ open: false })
266
+ return out('share is off: the board is back on 127.0.0.1 and the links stopped working.')
267
+ }
268
+ die(2, `unknown share command "${cmd}" (status|on|add|rotate|rm|off)`)
269
+ }
270
+ if (group === 'accounts') {
271
+ if (cmd === 'add') {
272
+ const [agent, name] = args._
273
+ if (!agent || !name) die(2, 'usage: leg accounts add <claude|codex> <name>')
274
+ try {
275
+ const r = addAccount(agent, name)
276
+ out(`${agent} account "${name}" at ${r.dir}`)
277
+ out(`shared from your real home (junctions): ${r.shared.join(', ') || '(nothing yet)'}; settings copied fresh before every launch`)
278
+ out('')
279
+ out(TERMS)
280
+ out('')
281
+ out('Log in once (paste in PowerShell):')
282
+ out(` ${r.login}`)
283
+ out(`Then: $env:BATON_ACCOUNT='${name}'; baton ${agent} (or let a limit hand off to it)`)
284
+ } catch (err) { die(2, err.message) }
285
+ return
286
+ }
287
+ if (cmd === 'rm') {
288
+ const [agent, name] = args._
289
+ if (!agent || !name || name === 'default') die(2, 'usage: leg accounts rm <claude|codex> <name>')
290
+ removeAccount(agent, name)
291
+ return out(`removed ${agent} account "${name}" (your real ${LAYOUT[agent]?.home() ?? 'home'} was not touched)`)
292
+ }
293
+ if (cmd === 'ls' || !cmd) {
294
+ const usage = Object.fromEntries(listUsage().map((u) => [`${u.agent}--${u.account}`, u]))
295
+ for (const r of listAccountRows()) {
296
+ const u = usage[`${r.agent}--${r.name}`]
297
+ const lim = u?.limited_until && u.limited_until * 1000 > Date.now() ? `LIMITED until ${fmtReset(u.limited_until)}` : u ? `5h ${u.five_hour?.pct ?? '-'}% 7d ${u.seven_day?.pct ?? '-'}%` : 'no usage seen yet'
298
+ out(`${r.agent.padEnd(7)} ${r.name.padEnd(12)} ${lim.padEnd(40)} ${r.dir}${r.env ? ` (${r.env})` : ''}`)
299
+ }
300
+ return
301
+ }
302
+ if (cmd === 'terms') return out(TERMS)
303
+ die(2, `unknown accounts command "${cmd}" (ls|add|rm|terms)`)
304
+ }
305
+ if (group === 'license') {
306
+ // The paid gate. Keys verify offline against the public key in
307
+ // src/license.mjs; nothing here talks to the network except refresh.
308
+ if (!cmd || cmd === 'status') {
309
+ const ent = entitlement()
310
+ out(describeLicense(ent))
311
+ if (ent.source === 'license') out(`stored at ${licensePath()}`)
312
+ if (!ent.ok) out(`Buy: ${BUY_URL} then: leg license activate <key>`)
313
+ return
314
+ }
315
+ if (cmd === 'activate') {
316
+ const key = args._[0] || die(2, 'usage: leg license activate <key>')
317
+ try {
318
+ const p = activateLicense(key)
319
+ out(describeLicense(entitlement()))
320
+ out(`activated ${p.plan} license ${p.id}; stored at ${licensePath()}`)
321
+ } catch (err) { die(2, err.message) }
322
+ return
323
+ }
324
+ if (cmd === 'deactivate') return out(deactivateLicense() ? `removed ${licensePath()}; Leg needs a key again before it will run` : 'no license was stored')
325
+ if (cmd === 'refresh') {
326
+ try { const p = await refreshLicense(); out(`renewed ${p.plan} license ${p.id}, valid through ${p.expires}`) } catch (err) { die(2, err.message) }
327
+ return
328
+ }
329
+ die(2, `unknown license command "${cmd}" (status|activate <key>|deactivate|refresh)`)
330
+ }
331
+ if (group === 'uninstall') {
332
+ // Leg never edits ~/.claude or ~/.codex; everything it added lives under
333
+ // $BATON_HOME (sessions, usage, extra-account dirs, cards).
334
+ const dir = home()
335
+ if (!args.yes) {
336
+ out(`leg uninstall removes ${dir} (sessions, usage, extra-account dirs, cards, board pidfile) and nothing else.`)
337
+ out('Your real ~/.claude, ~/.codex and agy homes are never touched. Re-run with --yes to do it.')
338
+ return
339
+ }
340
+ for (const r of listAccountRows()) if (r.name !== 'default') removeAccount(r.agent, r.name)
341
+ await down()
342
+ rmSync(dir, { recursive: true, force: true })
343
+ return out(`removed ${dir}; now: npm rm -g legcli`)
344
+ }
345
+ if (group === 'card') {
346
+ if (cmd === 'add') return cardAdd(args)
347
+ if (cmd === 'ls') {
348
+ const cards = listCards()
349
+ if (args.json) return out(JSON.stringify(cards, null, 2))
350
+ if (!cards.length) return out('(no cards)')
351
+ for (const c of cards) out(fmtCard(c))
352
+ return
353
+ }
354
+ const id = args._[0] || die(2, `usage: leg card ${cmd} <card-id>`)
355
+ const card = readCard(id) || die(3, `card not found: ${id}`)
356
+ if (cmd === 'show') {
357
+ if (args.json) return out(JSON.stringify({ card, runs: readRuns(id) }, null, 2))
358
+ out(fmtCard(card))
359
+ out(` repo: ${card.repo}`)
360
+ out(` worktree: ${card.worktree ?? '(none yet)'}`)
361
+ out(` pipeline: ${card.pipeline.map((s) => `${s.name}(${s.kind}${s.kind === 'agent' ? ': ' + s.chain.map((e) => e.adapter + (e.mode ? '/' + e.mode : '')).join(' > ') : ''})`).join(' → ')}`)
362
+ out(` trunk: ${card.trunk} land_mode: ${card.land_mode} test_command: ${card.test_command ?? '-'} land_attempts: ${card.land_attempts}`)
363
+ out(` actions: ${availableActions(card).join(', ') || '-'}`)
364
+ for (const r of readRuns(id)) out(` run ${r.run}: ${r.adapter} ${r.status} outcome=${r.outcome ?? '-'} signal=${r.signal ?? '-'} exit=${r.exit_code ?? '-'}`)
365
+ return
366
+ }
367
+ if (cmd === 'events') {
368
+ for (const e of readEvents(id)) out(`${e.ts} ${e.type.padEnd(16)} ${e.actor.type}${e.actor.adapter ? ':' + e.actor.adapter : e.actor.id ? ':' + e.actor.id : ''} ${e.station}/${e.leg} ${e.summary}`)
369
+ return
370
+ }
371
+ if (cmd === 'run') {
372
+ const final = await runCard(id)
373
+ out(`${final.card_id} ${final.status} at ${final.station}`)
374
+ process.exit(final.status === 'done' ? 0 : 1)
375
+ }
376
+ if (cmd === 'rm') {
377
+ try {
378
+ const r = removeWorktree(card.repo, id, { deleteBranch: Boolean(args['delete-branch']), force: Boolean(args.force) })
379
+ if (args['delete-branch'] && r.branchUnmerged && !r.branchDeleted) out(`kept branch leg/${id}: it has commits not on its base (rerun with --force to discard them)`)
380
+ } catch (err) { die(3, `worktree: ${err.message}`) }
381
+ rmSync(cardDir(id), { recursive: true, force: true })
382
+ return out(`removed ${id}`)
383
+ }
384
+ const human = { queue: 'enqueue', pause: 'pause', resume: 'resume', kill: 'kill', approve: 'approve', 'handoff-now': 'handoff_now', rerun: 'rerun', reassign: 'reassign' }[cmd]
385
+ if (human) {
386
+ const payload = human === 'reassign' ? { adapter: args.adapter || die(2, 'reassign needs --adapter'), mode: args.mode } : {}
387
+ const next = humanAction(id, human, payload, { type: 'human', id: args.actor || 'local' })
388
+ return out(`${next.card_id} ${next.status} at ${next.station} leg ${next.leg}`)
389
+ }
390
+ die(2, `unknown card command "${cmd}" (add|ls|show|run|rm|events|queue|pause|resume|kill|approve|handoff-now|rerun|reassign)`)
391
+ }
392
+ if (group === 'scheduler') {
393
+ if (cmd === 'start') {
394
+ const ticks = args.ticks ? parseInt(args.ticks, 10) : Infinity
395
+ const running = schedulerStatus()
396
+ if (running.running) die(3, `scheduler already running (pid ${running.pid}); two schedulers would drive the same cards. leg scheduler stop first`)
397
+ const s = createScheduler({ intervalMs: args['interval-ms'] ? parseInt(args['interval-ms'], 10) : 1000 })
398
+ process.on('SIGINT', () => { s.stop() })
399
+ out(`scheduler: max ${MAX_CONCURRENT} concurrent, pidfile ${pidfile()}${Number.isFinite(ticks) ? `, ${ticks} tick(s)` : ''}`)
400
+ try { await s.run({ ticks }) } catch (err) { die(3, err.message) }
401
+ return out('scheduler: stopped')
402
+ }
403
+ if (cmd === 'status') {
404
+ const st = schedulerStatus()
405
+ return out(st.running ? `scheduler running (pid ${st.pid})` : st.pid ? `scheduler not running (stale pidfile pid ${st.pid})` : 'scheduler not running')
406
+ }
407
+ if (cmd === 'stop') {
408
+ const st = schedulerStatus()
409
+ if (!st.running) return out('scheduler not running')
410
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(st.pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
411
+ else process.kill(st.pid, 'SIGTERM')
412
+ try { rmSync(pidfile(), { force: true }) } catch {}
413
+ return out(`scheduler stopped (pid ${st.pid})`)
414
+ }
415
+ die(2, `unknown scheduler command "${cmd}" (start|status|stop)`)
416
+ }
417
+ if (group === 'up') {
418
+ const a = parseArgs([cmd, ...rest].filter((x) => x !== undefined))
419
+ const code = await up({ dry: Boolean(a.dry), open: !a['no-open'], port: a.port !== undefined ? parseInt(a.port, 10) : undefined, bind: a.bind })
420
+ process.exit(code)
421
+ }
422
+ if (group === 'down') process.exit(await down())
423
+ if (group === 'status') process.exit(await status())
424
+ if (group === 'open') {
425
+ const port = (process.env.LEG_PORT || process.env.BATON_PORT) || 4747
426
+ const url = `http://127.0.0.1:${port}`
427
+ out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
428
+ return
429
+ }
430
+ if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|sessions|resume|accounts|license|share|up|down|status|open|card|scheduler|uninstall)`)
431
+ out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
432
+ claude|codex|agy [args...] the normal interactive agent in this terminal; args pass straight through
433
+ the board opens once, the session shows as a card, usage is tracked, a limit hands off
434
+ a second live session in one checkout gets its own worktree (--no-worktree to share)
435
+ sessions ls|show|events|handoff|end|rm|simulate-limit <id>
436
+ resume [--check] [--json] [--path <dir>] the hand-off waiting in this checkout, and whether it is still true
437
+ freshness is recomputed from git at read time; --check prints only the verdict
438
+ exit 0 current, 1 stale or unstamped, 3 no pointer here
439
+ accounts ls|add <agent> <name>|rm|terms optional second login for claude or codex
440
+ license [status|activate <key>|deactivate|refresh]
441
+ personal or team license status and management
442
+ share status|on|add <name>|rotate <name>|rm <name>|off
443
+ more than one human on the board, off by default
444
+ down | status | open the board
445
+ uninstall [--yes] removes only what Leg added (~/.leg, and legacy ~/.baton)
446
+ extras (v0.1 pipelines): up, card ..., scheduler ... presets: ${PRESET_NAMES.join(', ')}`)
447
+ }
448
+
449
+ await main()