@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,119 @@
1
+ // accounts — optional second (third…) subscription logins per agent.
2
+ // `default` is the CLI's own home (~/.claude, ~/.codex). An extra account is a
3
+ // directory under $BATON_HOME/accounts/<agent>/<name>/ that the CLI is pointed
4
+ // at with its config-dir variable (CLAUDE_CONFIG_DIR, CODEX_HOME); the
5
+ // harness (hooks, skills, agents, commands, plugins, rules, prompts…) is shared
6
+ // back into it with directory junctions, and the settings file is refreshed
7
+ // from the real home before every launch. Only the login lives in the
8
+ // account dir. agy 1.2.0 has no config-dir override, so it stays one account.
9
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, readdirSync, lstatSync, symlinkSync, rmSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { homedir } from 'node:os'
12
+ import { home } from './store.mjs'
13
+ import { writeJsonAtomic } from './fsx.mjs'
14
+
15
+ // What each CLI keeps in its home. `share` dirs are junctioned into the
16
+ // account dir; `copy` files are refreshed from the real home at launch;
17
+ // everything else (credentials, sessions, history, caches) stays per account.
18
+ export const LAYOUT = {
19
+ claude: {
20
+ env: 'CLAUDE_CONFIG_DIR',
21
+ home: () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
22
+ share: ['hooks', 'skills', 'agents', 'commands', 'plugins', 'rules', 'scripts', 'output-styles', 'tools'],
23
+ copy: ['settings.json', 'settings.local.json', 'CLAUDE.md', 'keybindings.json', 'statusline.ps1', 'statusline-combined.ps1'],
24
+ login: (dir) => `$env:CLAUDE_CONFIG_DIR='${dir}'; claude auth login`,
25
+ },
26
+ codex: {
27
+ env: 'CODEX_HOME',
28
+ home: () => process.env.CODEX_HOME || join(homedir(), '.codex'),
29
+ share: ['skills', 'prompts', 'rules', 'plugins', 'agents', 'hooks', 'memories', 'superpowers'],
30
+ copy: ['config.toml', 'AGENTS.md'],
31
+ login: (dir) => `$env:CODEX_HOME='${dir}'; codex login`,
32
+ },
33
+ agy: { env: null, home: () => join(homedir(), '.gemini', 'antigravity-cli'), share: [], copy: [], login: null },
34
+ }
35
+
36
+ export function accountsFile() { return join(home(), 'accounts.json') }
37
+ export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
38
+
39
+ export function readAccounts() {
40
+ const base = { claude: ['default'], codex: ['default'], agy: ['default'] }
41
+ if (!existsSync(accountsFile())) return base
42
+ try {
43
+ const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
44
+ for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => n !== 'default')]
45
+ } catch {}
46
+ return base
47
+ }
48
+
49
+ function writeAccounts(acc) {
50
+ mkdirSync(home(), { recursive: true })
51
+ writeJsonAtomic(accountsFile(), Object.fromEntries(Object.entries(acc).map(([k, v]) => [k, v.filter((n) => n !== 'default')])))
52
+ }
53
+
54
+ export function envFor(agent, account) {
55
+ if (account === 'default') return {}
56
+ const l = LAYOUT[agent]
57
+ if (!l?.env) return {}
58
+ return { [l.env]: accountDir(agent, account) }
59
+ }
60
+
61
+ function junction(target, link) {
62
+ if (existsSync(link)) return false
63
+ if (!existsSync(target)) return false
64
+ symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir')
65
+ return true
66
+ }
67
+
68
+ // Create the account dir, junction the shared harness in, copy the settings.
69
+ export function addAccount(agent, name) {
70
+ if (!/^[a-z0-9][a-z0-9_-]{0,29}$/i.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
71
+ const l = LAYOUT[agent]
72
+ if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy)`)
73
+ if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
74
+ const dir = accountDir(agent, name)
75
+ mkdirSync(dir, { recursive: true })
76
+ const src = l.home()
77
+ const shared = []
78
+ for (const d of l.share) if (junction(join(src, d), join(dir, d))) shared.push(d)
79
+ refreshAccount(agent, name)
80
+ const acc = readAccounts()
81
+ if (!acc[agent].includes(name)) { acc[agent].push(name); writeAccounts(acc) }
82
+ return { dir, shared, login: l.login(dir), env: l.env }
83
+ }
84
+
85
+ // Before each launch: bring the copied files up to date with the real home.
86
+ export function refreshAccount(agent, name) {
87
+ if (name === 'default') return []
88
+ const l = LAYOUT[agent]
89
+ const dir = accountDir(agent, name)
90
+ const copied = []
91
+ for (const f of l.copy) {
92
+ const s = join(l.home(), f)
93
+ if (!existsSync(s)) continue
94
+ try { copyFileSync(s, join(dir, f)); copied.push(f) } catch {}
95
+ }
96
+ return copied
97
+ }
98
+
99
+ export function removeAccount(agent, name) {
100
+ const dir = accountDir(agent, name)
101
+ if (existsSync(dir)) {
102
+ // junctions are removed as links, never followed
103
+ for (const n of readdirSync(dir)) {
104
+ const p = join(dir, n)
105
+ if (lstatSync(p).isSymbolicLink()) rmSync(p, { force: true })
106
+ }
107
+ rmSync(dir, { recursive: true, force: true })
108
+ }
109
+ const acc = readAccounts()
110
+ acc[agent] = acc[agent].filter((n) => n !== name)
111
+ writeAccounts(acc)
112
+ }
113
+
114
+ export function listAccountRows() {
115
+ const acc = readAccounts()
116
+ const rows = []
117
+ for (const agent of Object.keys(acc)) for (const name of acc[agent]) rows.push({ agent, name, dir: name === 'default' ? LAYOUT[agent].home() : accountDir(agent, name), env: name === 'default' ? null : LAYOUT[agent].env })
118
+ return rows
119
+ }
@@ -0,0 +1,56 @@
1
+ // agy adapter — agy CLI headless (`agy -p <prompt> --output-format json`).
2
+ // Facts and sources: docs/cli-contracts.md § agy.
3
+ import { join } from 'node:path'
4
+ import { sanitizeEnv } from '../env.mjs'
5
+ import { assertAllowed, firstExisting, goDuration } from './common.mjs'
6
+
7
+ const adapter = {
8
+ name: 'agy',
9
+ stdin: 'ignore',
10
+ modes: {
11
+ default: 'accept-edits',
12
+ allowed: ['accept-edits', 'plan'],
13
+ },
14
+ forbiddenFlags: ['--dangerously-skip-permissions'],
15
+ resolve() {
16
+ const bin = (process.env.LEG_AGY_BIN || process.env.BATON_AGY_BIN) || firstExisting([
17
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'agy', 'bin', 'agy.exe') : null,
18
+ ], 'agy')
19
+ return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
20
+ },
21
+ argv(opts = {}) {
22
+ const mode = assertAllowed(adapter, opts)
23
+ const { bin, viaNode } = adapter.resolve()
24
+ // agy does not work in the process cwd: with no project it writes into its
25
+ // own scratch workspace (probe 2026-09-10 put hello-agy.txt under
26
+ // ~/.gemini/antigravity-cli/scratch/). --add-dir puts the worktree in the
27
+ // workspace and the prompt names it as the working directory.
28
+ // --print-timeout defaults to 5m; tie it to the card's kill timer so the
29
+ // supervisor, not agy, decides when a leg is a runaway.
30
+ const cwd = opts.cwd ?? process.cwd()
31
+ const prompt = `Working directory: ${cwd}\nEvery relative path in this task is relative to that directory; create files there, never in a scratch workspace.\n\n${opts.prompt ?? ''}`
32
+ const args = ['-p', prompt, '--output-format', 'json', '--mode', mode,
33
+ '--add-dir', cwd, '--print-timeout', goDuration(opts.killMs ?? 5400000)]
34
+ if (opts.model) args.push('--model', opts.model)
35
+ if (opts.resume) args.push('--conversation', opts.resume)
36
+ return viaNode ? { bin: process.execPath, args: [bin, ...args] } : { bin, args }
37
+ },
38
+ env(base) { return sanitizeEnv(base) },
39
+ parseResult(text) {
40
+ const s = String(text)
41
+ for (let i = s.indexOf('{'); i !== -1; i = s.indexOf('{', i + 1)) {
42
+ try {
43
+ const j = JSON.parse(s.slice(i))
44
+ return {
45
+ session_id: j.conversation_id ?? j.conversationId ?? j.session_id ?? null,
46
+ last_message: typeof j.response === 'string' ? j.response : (typeof j.result === 'string' ? j.result : null),
47
+ stop_reason: j.stop_reason ?? j.status ?? null,
48
+ raw: j,
49
+ }
50
+ } catch { continue }
51
+ }
52
+ return null
53
+ },
54
+ }
55
+
56
+ export default adapter
@@ -0,0 +1,54 @@
1
+ // claude adapter — Claude Code headless (`claude -p --output-format json`).
2
+ // Facts and sources: docs/cli-contracts.md § claude.
3
+ import { join } from 'node:path'
4
+ import { homedir } from 'node:os'
5
+ import { sanitizeEnv } from '../env.mjs'
6
+ import { assertAllowed, firstExisting } from './common.mjs'
7
+
8
+ const adapter = {
9
+ name: 'claude',
10
+ stdin: 'pipe', // the prompt goes to stdin; argv stays short
11
+ modes: {
12
+ default: 'acceptEdits',
13
+ // claude 2.1.268 --permission-mode choices; the bypass mode is never allowed.
14
+ allowed: ['acceptEdits', 'auto', 'plan', 'manual', 'dontAsk'],
15
+ },
16
+ forbiddenFlags: ['--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--permission-mode=bypassPermissions', 'bypassPermissions'],
17
+ resolve() {
18
+ const bin = (process.env.LEG_CLAUDE_BIN || process.env.BATON_CLAUDE_BIN) || firstExisting([
19
+ join(homedir(), '.local', 'bin', process.platform === 'win32' ? 'claude.exe' : 'claude'),
20
+ ], 'claude')
21
+ return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
22
+ },
23
+ argv(opts = {}) {
24
+ const mode = assertAllowed(adapter, opts)
25
+ const { bin, viaNode } = adapter.resolve()
26
+ const args = ['-p', '--output-format', 'json', '--permission-mode', mode]
27
+ if (opts.maxTurns) args.push('--max-turns', String(opts.maxTurns))
28
+ if (opts.resume) args.push('--resume', opts.resume)
29
+ if (opts.model) args.push('--model', opts.model)
30
+ if (opts.allowedTools?.length) args.push('--allowedTools', opts.allowedTools.join(','))
31
+ // The Agent tool is never denied: a lead without it degrades to solo (LESSONS 07-10).
32
+ return viaNode ? { bin: process.execPath, args: [bin, ...args] } : { bin, args }
33
+ },
34
+ env(base) { return sanitizeEnv(base) },
35
+ parseResult(text) {
36
+ let j
37
+ try { j = JSON.parse(text) } catch { return null }
38
+ if (!j || typeof j !== 'object') return null
39
+ return {
40
+ session_id: j.session_id ?? null,
41
+ last_message: typeof j.result === 'string' ? j.result : null,
42
+ stop_reason: j.stop_reason ?? null,
43
+ subtype: j.subtype ?? null,
44
+ is_error: j.is_error ?? null,
45
+ num_turns: j.num_turns ?? null,
46
+ terminal_reason: j.terminal_reason ?? null,
47
+ api_error_status: j.api_error_status ?? null,
48
+ permission_denials: Array.isArray(j.permission_denials) ? j.permission_denials.length : null,
49
+ raw: j,
50
+ }
51
+ },
52
+ }
53
+
54
+ export default adapter
@@ -0,0 +1,73 @@
1
+ // codex adapter — OpenAI Codex CLI headless (`codex exec --json`).
2
+ // Facts and sources: docs/cli-contracts.md § codex.
3
+ // stdin is 'ignore' on purpose: codex exec reads stdin whenever it is not a
4
+ // TTY and hangs on an open pipe ("Reading additional input from stdin...").
5
+ import { join } from 'node:path'
6
+ import { sanitizeEnv } from '../env.mjs'
7
+ import { resolveNpmCliEntry } from './resolve.mjs'
8
+ import { assertAllowed, firstExisting } from './common.mjs'
9
+
10
+ const PKG = '@openai/codex'
11
+
12
+ const adapter = {
13
+ name: 'codex',
14
+ stdin: 'ignore',
15
+ modes: {
16
+ default: 'workspace-write',
17
+ allowed: ['read-only', 'workspace-write'],
18
+ },
19
+ forbiddenFlags: ['danger-full-access', '--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-hook-trust', '--full-auto', '--approve-for-me'],
20
+ resolve() {
21
+ if ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN)) {
22
+ const bin = process.env.BATON_CODEX_BIN
23
+ return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
24
+ }
25
+ // The npm package's bin/codex.js only spawns the platform package's native
26
+ // exe; spawning that exe directly keeps one process to kill.
27
+ const pkgDir = process.env.APPDATA ? join(process.env.APPDATA, 'npm', 'node_modules', PKG) : null
28
+ const native = pkgDir ? firstExisting([
29
+ join(pkgDir, 'node_modules', '@openai', 'codex-win32-x64', 'vendor', 'x86_64-pc-windows-msvc', 'bin', 'codex.exe'),
30
+ join(pkgDir, 'node_modules', '@openai', 'codex-win32-arm64', 'vendor', 'aarch64-pc-windows-msvc', 'bin', 'codex.exe'),
31
+ ], null) : null
32
+ if (native) return { bin: native, viaNode: false, entry: null }
33
+ const entry = resolveNpmCliEntry(PKG, 'codex')
34
+ if (entry) return { bin: process.execPath, viaNode: true, entry }
35
+ return { bin: 'codex', viaNode: false, entry: null }
36
+ },
37
+ argv(opts = {}) {
38
+ const mode = assertAllowed(adapter, opts)
39
+ const { bin, viaNode, entry } = adapter.resolve()
40
+ const args = ['exec', '--json', '-s', mode, '-C', opts.cwd ?? process.cwd(),
41
+ '-c', `sandbox_workspace_write.network_access=${opts.network ? 'true' : 'false'}`]
42
+ if (opts.model) args.push('-m', opts.model)
43
+ if (opts.runDir) args.push('-o', join(opts.runDir, 'last.md'))
44
+ if (opts.resume) args.push('resume', opts.resume)
45
+ args.push(opts.prompt ?? '')
46
+ return viaNode ? { bin, args: [entry, ...args] } : { bin, args }
47
+ },
48
+ env(base) { return sanitizeEnv(base) },
49
+ parseResult(text) {
50
+ const events = []
51
+ for (const line of String(text).split('\n')) {
52
+ const t = line.trim()
53
+ if (!t) continue
54
+ try { events.push(JSON.parse(t)) } catch { continue }
55
+ }
56
+ if (!events.length) return null
57
+ const started = events.find((e) => e.type === 'thread.started')
58
+ const messages = events.filter((e) => e.type === 'item.completed' && e.item?.type === 'agent_message')
59
+ const errors = events.filter((e) => e.type === 'item.completed' && e.item?.type === 'error').map((e) => e.item.message)
60
+ const turnDone = events.find((e) => e.type === 'turn.completed')
61
+ const turnFailed = events.find((e) => e.type === 'turn.failed' || e.type === 'error')
62
+ return {
63
+ session_id: started?.thread_id ?? null,
64
+ last_message: messages.length ? messages[messages.length - 1].item.text ?? null : null,
65
+ stop_reason: turnDone ? 'turn.completed' : turnFailed ? (turnFailed.type) : null,
66
+ usage: turnDone?.usage ?? null,
67
+ errors,
68
+ raw: events,
69
+ }
70
+ },
71
+ }
72
+
73
+ export default adapter
@@ -0,0 +1,32 @@
1
+ // Shared adapter helpers. An adapter exports:
2
+ // name, stdin ('pipe' | 'ignore'), modes {default, allowed}, forbiddenFlags,
3
+ // resolve() → {bin, viaNode, entry}, argv(opts) → {bin, args},
4
+ // env(base) → sanitized copy, parseResult(text) → {session_id, last_message, stop_reason, raw} | null
5
+ // argv() throws `forbidden flag: …` before anything is spawned when the chain
6
+ // entry names a mode outside `modes.allowed` or smuggles a forbidden flag.
7
+ import { existsSync } from 'node:fs'
8
+
9
+ export function assertAllowed(adapter, opts = {}) {
10
+ const mode = opts.mode ?? adapter.modes.default
11
+ if (!adapter.modes.allowed.includes(mode)) {
12
+ throw new Error(`forbidden flag: ${adapter.name} mode "${mode}" is not allowed (allowed: ${adapter.modes.allowed.join(', ')})`)
13
+ }
14
+ for (const flag of opts.extraArgs ?? []) {
15
+ if (adapter.forbiddenFlags.some((f) => flag === f || flag.startsWith(`${f}=`))) {
16
+ throw new Error(`forbidden flag: ${flag}`)
17
+ }
18
+ }
19
+ return mode
20
+ }
21
+
22
+ // First existing candidate path, else the bare command name (PATH lookup).
23
+ export function firstExisting(candidates, fallback) {
24
+ for (const c of candidates) if (c && existsSync(c)) return c
25
+ return fallback
26
+ }
27
+
28
+ // Go-style duration for CLIs that want "90m" rather than milliseconds.
29
+ export function goDuration(ms) {
30
+ const s = Math.max(1, Math.ceil(ms / 1000))
31
+ return s % 60 === 0 ? `${s / 60}m` : `${s}s`
32
+ }
@@ -0,0 +1,48 @@
1
+ // fake adapter — drives bin/fake-agent.mjs, a stand-in CLI for tests and the
2
+ // demo. FAKE_MODE in the environment selects its behaviour (see the bin).
3
+ // Registered as `fake`, and as `fake-claude` / `fake-codex` so demo events read
4
+ // like a real chain; `fake-nostdin` is the same agent with stdin ignored.
5
+ import { join, dirname } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { sanitizeEnv } from '../env.mjs'
8
+
9
+ const FAKE_AGENT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'fake-agent.mjs')
10
+
11
+ export function makeFake(name = 'fake', stdin = 'pipe') {
12
+ const m = /^fake-(claude|codex|agy)$/.exec(name)
13
+ return {
14
+ name,
15
+ stdin,
16
+ // The classifier treats fake-<cli> as that CLI (its limit fixtures apply).
17
+ emulates: m ? m[1] : null,
18
+ modes: { default: 'acceptEdits', allowed: ['acceptEdits', 'plan', 'workspace-write', 'read-only', 'accept-edits', 'auto_edit'] },
19
+ forbiddenFlags: ['--dangerously-skip-permissions', '--yolo'],
20
+ resolve() { return { bin: process.execPath, viaNode: true, entry: FAKE_AGENT } },
21
+ argv({ mode, maxTurns, resume, model, network } = {}) {
22
+ const args = [FAKE_AGENT]
23
+ if (mode) args.push('--mode', mode)
24
+ if (maxTurns) args.push('--max-turns', String(maxTurns))
25
+ if (resume) args.push('--resume', resume)
26
+ if (model) args.push('--model', model)
27
+ if (network) args.push('--network')
28
+ return { bin: process.execPath, args }
29
+ },
30
+ env(base) { return sanitizeEnv(base) },
31
+ parseResult(text) {
32
+ try {
33
+ const j = JSON.parse(text)
34
+ return {
35
+ session_id: j.session_id ?? null,
36
+ last_message: typeof j.result === 'string' ? j.result : null,
37
+ stop_reason: j.is_error ? 'error' : 'end_turn',
38
+ is_error: j.is_error ?? false,
39
+ raw: j,
40
+ }
41
+ } catch {
42
+ return null
43
+ }
44
+ },
45
+ }
46
+ }
47
+
48
+ export default makeFake('fake')
@@ -0,0 +1,51 @@
1
+ // grok adapter — Grok CLI headless (`grok -p <prompt> --output-format json`).
2
+ // NOT REGISTERED in index.mjs: the phase-3 probe could not verify it because
3
+ // the build machine had no grok login (device-code prompt, exit "Cancelled").
4
+ // Built from `grok --help` only; see docs/cli-contracts.md § grok. Register it
5
+ // after `grok` is logged in and `node scripts/probe.mjs --adapter grok` passes.
6
+ import { join } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { sanitizeEnv } from '../env.mjs'
9
+ import { assertAllowed, firstExisting } from './common.mjs'
10
+
11
+ const adapter = {
12
+ name: 'grok',
13
+ stdin: 'ignore',
14
+ modes: {
15
+ default: 'acceptEdits',
16
+ allowed: ['default', 'acceptEdits', 'auto', 'dontAsk', 'plan'],
17
+ },
18
+ forbiddenFlags: ['--always-approve', 'bypassPermissions', '--permission-mode=bypassPermissions'],
19
+ resolve() {
20
+ const bin = (process.env.LEG_GROK_BIN || process.env.BATON_GROK_BIN) || firstExisting([
21
+ join(homedir(), '.grok', 'bin', process.platform === 'win32' ? 'grok.exe' : 'grok'),
22
+ ], 'grok')
23
+ return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
24
+ },
25
+ argv(opts = {}) {
26
+ const mode = assertAllowed(adapter, opts)
27
+ const { bin, viaNode } = adapter.resolve()
28
+ const args = ['-p', opts.prompt ?? '', '--output-format', 'json', '--permission-mode', mode]
29
+ if (opts.model) args.push('-m', opts.model)
30
+ if (opts.resume) args.push('-r', opts.resume)
31
+ return viaNode ? { bin: process.execPath, args: [bin, ...args] } : { bin, args }
32
+ },
33
+ env(base) { return sanitizeEnv(base) },
34
+ parseResult(text) {
35
+ const s = String(text)
36
+ for (let i = s.indexOf('{'); i !== -1; i = s.indexOf('{', i + 1)) {
37
+ try {
38
+ const j = JSON.parse(s.slice(i))
39
+ return {
40
+ session_id: j.session_id ?? j.sessionId ?? null,
41
+ last_message: typeof j.response === 'string' ? j.response : (typeof j.result === 'string' ? j.result : null),
42
+ stop_reason: j.stop_reason ?? null,
43
+ raw: j,
44
+ }
45
+ } catch { continue }
46
+ }
47
+ return null
48
+ },
49
+ }
50
+
51
+ export default adapter
@@ -0,0 +1,27 @@
1
+ // Adapter registry. Adapters are loaded lazily so a typo fails with a named
2
+ // error before anything is spawned. Every real CLI here was probed live
3
+ // through the runner in phase 3 (fixtures/live/<name>/, docs/cli-contracts.md).
4
+ // grok.mjs exists but is NOT registered: the build machine had no grok login
5
+ // (the probe printed a device-code prompt and exited "Cancelled"). Register it
6
+ // after `grok` is logged in and scripts/probe.mjs --adapter grok passes.
7
+ const REGISTRY = {
8
+ fake: { path: './fake.mjs' },
9
+ 'fake-claude': { path: './fake.mjs', fake: ['fake-claude', 'pipe'] },
10
+ 'fake-codex': { path: './fake.mjs', fake: ['fake-codex', 'ignore'] },
11
+ 'fake-agy': { path: './fake.mjs', fake: ['fake-agy', 'ignore'] },
12
+ 'fake-nostdin': { path: './fake.mjs', fake: ['fake-nostdin', 'ignore'] },
13
+ claude: { path: './claude.mjs' },
14
+ codex: { path: './codex.mjs' },
15
+ agy: { path: './agy.mjs' },
16
+ }
17
+
18
+ export function names() { return Object.keys(REGISTRY) }
19
+
20
+ export function isFake(name) { return Boolean(REGISTRY[name]?.fake) || name === 'fake' }
21
+
22
+ export async function get(name) {
23
+ const entry = REGISTRY[name]
24
+ if (!entry) throw new Error(`unknown adapter: ${name}`)
25
+ const mod = await import(entry.path)
26
+ return entry.fake ? mod.makeFake(...entry.fake) : mod.default
27
+ }
@@ -0,0 +1,40 @@
1
+ // Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
2
+ // resolveNpmCliEntry — the real JS entry of a globally installed npm CLI, so
3
+ // the runner can spawn(node, [entry, ...]) with NO shell. The global npm .cmd
4
+ // shim needs a shell on Windows, and a shell may resolve to the WSL bash shim,
5
+ // which drops Windows variables and misresolves node. A bin override that
6
+ // already points at a .mjs/.cjs/.js short-circuits the resolution (tests).
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { join, dirname } from 'node:path'
9
+
10
+ export function resolveNpmCliEntry(pkgName, binName = pkgName, { binOverride, pkgDir, execPath = process.execPath, log = () => {} } = {}) {
11
+ if (binOverride && /\.(mjs|cjs|js)$/.test(binOverride)) return binOverride
12
+ const candidates = []
13
+ if (pkgDir) candidates.push(pkgDir)
14
+ if (process.env.APPDATA) {
15
+ candidates.push(join(process.env.APPDATA, 'npm', 'node_modules', pkgName))
16
+ }
17
+ // Derived from the running node, not guessed: POSIX installs put the binary
18
+ // at <prefix>/bin/node and its global packages at <prefix>/lib/node_modules.
19
+ // The absolute paths below only cover a system install under /usr; Homebrew,
20
+ // nvm, asdf and the GitHub runners' hostedtoolcache all live elsewhere, and
21
+ // on macOS none of them are under /usr at all — which is why `npm test`
22
+ // during a Land failed there with "cannot resolve npm's JS entry" while the
23
+ // same code passed on Linux, where /usr/local/lib/node_modules happens to
24
+ // exist. (2026-09-15, first CI run with macOS in the matrix.)
25
+ if (execPath) candidates.push(join(dirname(execPath), '..', 'lib', 'node_modules', pkgName))
26
+ candidates.push(`/usr/local/lib/node_modules/${pkgName}`)
27
+ candidates.push(`/usr/lib/node_modules/${pkgName}`)
28
+ for (const dir of candidates) {
29
+ const pkgPath = join(dir, 'package.json')
30
+ if (!existsSync(pkgPath)) continue
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
33
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[binName]
34
+ if (bin) return join(dir, bin)
35
+ } catch (err) {
36
+ log(`${pkgName} package.json unreadable at ${pkgPath}: ${err.message}`)
37
+ }
38
+ }
39
+ return null
40
+ }