@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.
- package/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
package/src/accounts.mjs
ADDED
|
@@ -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
|
+
}
|