subconscious-cli 6.0.1 → 6.1.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 (73) hide show
  1. package/README.md +119 -0
  2. package/bin/agent-data.js +242 -0
  3. package/bin/agent-help.js +106 -0
  4. package/bin/agent-install.js +354 -0
  5. package/bin/agent-launch.js +199 -0
  6. package/bin/agent-spawn.js +137 -0
  7. package/bin/agents.js +236 -932
  8. package/bin/auth.js +9 -1
  9. package/bin/claude-picker.js +143 -0
  10. package/bin/cli.js +60 -1
  11. package/bin/clipboard-image.js +2 -0
  12. package/bin/feedback.js +14 -3
  13. package/bin/harness-manifest.js +286 -0
  14. package/bin/headless-args.js +35 -0
  15. package/bin/launch-values.js +25 -0
  16. package/bin/model-capabilities.js +10 -9
  17. package/bin/models.js +13 -13
  18. package/bin/native/subc-tui-darwin-amd64 +0 -0
  19. package/bin/native/subc-tui-darwin-arm64 +0 -0
  20. package/bin/native/subc-tui-linux-amd64 +0 -0
  21. package/bin/native/subc-tui-linux-arm64 +0 -0
  22. package/bin/native/subc-tui-windows-amd64.exe +0 -0
  23. package/bin/native/subc-tui-windows-arm64.exe +0 -0
  24. package/bin/opencode-provider.js +21 -0
  25. package/bin/profile-settings.js +129 -0
  26. package/bin/profiles.js +29 -424
  27. package/bin/runbook/README.md +66 -23
  28. package/bin/runbook/claude-code/agent.json +300 -0
  29. package/bin/runbook/claude-code/install.sh +9 -252
  30. package/bin/runbook/codex/agent.json +461 -0
  31. package/bin/runbook/codex/hooks-lib.sh +17 -4
  32. package/bin/runbook/codex/install.sh +0 -4
  33. package/bin/runbook/codex/run.sh +16 -235
  34. package/bin/runbook/copilot/agent.json +185 -0
  35. package/bin/runbook/copilot/install.sh +45 -63
  36. package/bin/runbook/cursor/agent.json +137 -0
  37. package/bin/runbook/cursor/install.sh +8 -35
  38. package/bin/runbook/deepseek-harness/agent.json +163 -0
  39. package/bin/runbook/deepseek-harness/run.sh +20 -59
  40. package/bin/runbook/image-window/index.ts +14 -0
  41. package/bin/runbook/image-window/window.d.ts +19 -0
  42. package/bin/runbook/image-window/window.js +140 -0
  43. package/bin/runbook/lib.sh +41 -0
  44. package/bin/runbook/opencode/agent.json +158 -0
  45. package/bin/runbook/opencode/install.sh +9 -183
  46. package/bin/runbook/opencode/opencode-plugin.d.ts +10 -3
  47. package/bin/runbook/opencode/subconscious-image-window.ts +24 -0
  48. package/bin/runbook/pi/agent.json +216 -0
  49. package/bin/runbook/pi/install.sh +18 -44
  50. package/bin/runbook/pi/run.sh +6 -18
  51. package/bin/runbook/pi/subconscious-compaction.ts +3 -1
  52. package/bin/runbook/shared.json +58 -0
  53. package/bin/runbook/subconscious-code/agent.json +156 -0
  54. package/bin/sessions.js +52 -22
  55. package/bin/tui.js +13 -0
  56. package/bin/update-check.js +5 -1
  57. package/bin/upgrade.js +24 -31
  58. package/bin/usage.js +21 -10
  59. package/bin/windows/agents.js +18 -17
  60. package/bin/windows/common.js +2 -28
  61. package/bin/windows/install.js +11 -17
  62. package/bin/windows/launch.js +252 -231
  63. package/bin/windows/process.js +12 -1
  64. package/bin/windows/setup.js +29 -9
  65. package/package.json +2 -3
  66. package/bin/registry.generated.json +0 -406
  67. package/bin/runbook/claude-code/run.sh +0 -254
  68. package/bin/runbook/model-capabilities.generated.sh +0 -9
  69. package/bin/runbook/opencode/provider.sh +0 -93
  70. package/bin/runbook/opencode/run.sh +0 -99
  71. package/bin/runbook/opencode/subconscious-compaction.ts +0 -100
  72. package/bin/runbook/subconscious-code/run.sh +0 -21
  73. package/bin/windows/claude.js +0 -100
@@ -0,0 +1,354 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import readline from 'node:readline';
7
+ import {
8
+ agentBinary,
9
+ agentById,
10
+ agentCommandName,
11
+ agentInstallDirs,
12
+ installCommands,
13
+ } from './agent-data.js';
14
+ import { runbookScriptPath } from './agent-spawn.js';
15
+ import { c } from './colors.js';
16
+ import { compareVersions } from './update-check.js';
17
+
18
+ /**
19
+ * The install command for this OS, falling back to the Linux one, then any
20
+ * command present; plus the optional fallback command.
21
+ */
22
+ export function resolveInstall(agent) {
23
+ const commands = installCommands(agent);
24
+ if (!commands) return { command: undefined, fallback: undefined };
25
+ const command =
26
+ commands[process.platform] ||
27
+ commands.linux ||
28
+ Object.entries(commands).find(([os]) => os !== 'fallback')?.[1];
29
+ return { command, fallback: commands.fallback };
30
+ }
31
+
32
+ /**
33
+ * Common locations a freshly-installed coding-agent binary lands in but which
34
+ * are often NOT on the current process's PATH (e.g. aider/claude install into
35
+ * `~/.local/bin`; npm globals into the npm prefix bin). Best-effort, deduped.
36
+ */
37
+ export function candidateBinDirs() {
38
+ const home = os.homedir();
39
+ const dirs = [];
40
+
41
+ if (process.platform === 'win32') {
42
+ if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm'));
43
+ if (process.env.USERPROFILE) {
44
+ dirs.push(path.join(process.env.USERPROFILE, '.local', 'bin'));
45
+ }
46
+ if (home) dirs.push(path.join(home, '.local', 'bin'));
47
+ } else {
48
+ dirs.push(path.join(home, '.local', 'bin'));
49
+ dirs.push(path.join(home, '.cargo', 'bin'));
50
+ dirs.push('/opt/homebrew/bin');
51
+ dirs.push('/usr/local/bin');
52
+ }
53
+
54
+ // npm global bin (best-effort — npm may be absent).
55
+ try {
56
+ const prefix = execFileSync('npm', ['prefix', '-g'], {
57
+ encoding: 'utf-8',
58
+ stdio: ['ignore', 'pipe', 'ignore'],
59
+ }).trim();
60
+ if (prefix) {
61
+ dirs.push(
62
+ process.platform === 'win32' ? prefix : path.join(prefix, 'bin'),
63
+ );
64
+ }
65
+ } catch {
66
+ // npm not available — skip.
67
+ }
68
+
69
+ // Dedupe, drop empties.
70
+ return [...new Set(dirs.filter(Boolean))];
71
+ }
72
+
73
+ /** The binary directory the agent's own installer uses, if it declares one. */
74
+ export function preferredBinDirsForAgent(
75
+ agent,
76
+ environment = process.env,
77
+ home = os.homedir(),
78
+ ) {
79
+ return agentInstallDirs(agent, environment, home);
80
+ }
81
+
82
+ /** Executable extensions to probe (Windows uses PATHEXT). */
83
+ function binExts() {
84
+ return process.platform === 'win32'
85
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
86
+ : [''];
87
+ }
88
+
89
+ /**
90
+ * Resolve `bin` against PATH plus the candidate bin dirs. Returns the directory
91
+ * containing the executable if found, otherwise null. Searching the candidate
92
+ * dirs lets us find binaries installed this session that aren't on PATH yet.
93
+ */
94
+ async function resolveBinPath(bin, preferredDirs = []) {
95
+ const pathDirs = (process.env.PATH || '')
96
+ .split(path.delimiter)
97
+ .filter(Boolean);
98
+ const dirs = [
99
+ ...new Set([...preferredDirs, ...pathDirs, ...candidateBinDirs()]),
100
+ ];
101
+ const exts = binExts();
102
+ for (const dir of dirs) {
103
+ for (const ext of exts) {
104
+ const candidate = path.join(dir, bin + ext);
105
+ try {
106
+ await fs.access(candidate, fsConstants.F_OK);
107
+ return dir;
108
+ } catch {
109
+ // keep scanning
110
+ }
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Build a PATH string with `extraDirs` prepended (deduped against PATH).
118
+ * Returns the augmented PATH value for use in a child env.
119
+ */
120
+ export function augmentPath(
121
+ extraDirs,
122
+ preferredDir,
123
+ currentPath = process.env.PATH || '',
124
+ ) {
125
+ const current = currentPath.split(path.delimiter).filter(Boolean);
126
+ const seen = new Set(current);
127
+ const prepend = extraDirs.filter(
128
+ (dir, index) =>
129
+ dir &&
130
+ dir !== preferredDir &&
131
+ !seen.has(dir) &&
132
+ extraDirs.indexOf(dir) === index,
133
+ );
134
+ const remaining = current.filter((dir) => dir !== preferredDir);
135
+ return [
136
+ ...(preferredDir ? [preferredDir] : []),
137
+ ...prepend,
138
+ ...remaining,
139
+ ].join(path.delimiter);
140
+ }
141
+
142
+ /** Ask a yes/no question on the TTY. Empty answer counts as yes. */
143
+ function askYesNo(question) {
144
+ return new Promise((resolve) => {
145
+ const rl = readline.createInterface({
146
+ input: process.stdin,
147
+ output: process.stdout,
148
+ });
149
+ rl.question(question, (answer) => {
150
+ rl.close();
151
+ const a = answer.trim().toLowerCase();
152
+ resolve(a === '' || a === 'y' || a === 'yes');
153
+ });
154
+ });
155
+ }
156
+
157
+ function waitForEnter(prompt) {
158
+ return new Promise((resolve) => {
159
+ const rl = readline.createInterface({
160
+ input: process.stdin,
161
+ output: process.stdout,
162
+ });
163
+ rl.question(prompt, () => {
164
+ rl.close();
165
+ resolve();
166
+ });
167
+ });
168
+ }
169
+
170
+ /** Run the agent's packaged binary installer, or its shell install command. */
171
+ function runInstaller(
172
+ agent,
173
+ install = resolveInstall(agent).command,
174
+ usePackagedScript = true,
175
+ ) {
176
+ return new Promise((resolve) => {
177
+ const installScript =
178
+ usePackagedScript && agent.runbook.binary_install_script;
179
+ const child = installScript
180
+ ? spawn('bash', [runbookScriptPath(agent, installScript), 'install'], {
181
+ stdio: 'inherit',
182
+ })
183
+ : spawn(install, { shell: true, stdio: 'inherit' });
184
+ child.on('error', () => resolve(false));
185
+ child.on('exit', (code) => resolve(code === 0));
186
+ });
187
+ }
188
+
189
+ /** Print the resolved install command (plus any fallback) for an agent. */
190
+ function printInstallCommands(agent) {
191
+ const { command, fallback } = resolveInstall(agent);
192
+ console.error(` ${c.cyan}${command}${c.reset}`);
193
+ if (fallback) {
194
+ console.error(` ${c.dim}or, as a fallback:${c.reset}`);
195
+ console.error(` ${c.cyan}${fallback}${c.reset}`);
196
+ }
197
+ console.error('');
198
+ }
199
+
200
+ /**
201
+ * Ensure the agent's binary is resolvable. If missing:
202
+ * - interactive TTY: offer to run the per-OS installer (with fallback), then
203
+ * re-resolve against PATH + candidate dirs.
204
+ * - non-interactive: print the resolved install command (+ fallback) and
205
+ * exit 127 without running anything.
206
+ *
207
+ * Returns the directory containing the bin (to prepend to the child's PATH) on
208
+ * success. May exit the process on failure or when manual action is needed.
209
+ */
210
+ export async function ensureInstalled(agent, { mayPrompt = true } = {}) {
211
+ const bin = agentBinary(agent);
212
+ const { command: install, fallback } = resolveInstall(agent);
213
+ const preferredDirs = preferredBinDirsForAgent(agent);
214
+ const existing = await resolveBinPath(bin, preferredDirs);
215
+ if (existing) return existing;
216
+
217
+ // Agents without an installer are launch-only. Their setup integration may
218
+ // configure the provider, but `subc <agent>` must never install the binary.
219
+ if (!install) {
220
+ console.error(
221
+ `\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${bin}\` not found on PATH).${c.reset}`,
222
+ );
223
+ console.error(
224
+ ` Install ${agent.name} separately, then re-run ${c.cyan}subc ${agentCommandName(agent)}${c.reset}.\n`,
225
+ );
226
+ process.exit(127);
227
+ }
228
+
229
+ const interactive = mayPrompt && process.stdin.isTTY && process.stdout.isTTY;
230
+
231
+ if (!interactive) {
232
+ console.error(
233
+ `\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${bin}\` not found on PATH).${c.reset}`,
234
+ );
235
+ console.error(` Install it with:\n`);
236
+ printInstallCommands(agent);
237
+ process.exit(127);
238
+ }
239
+
240
+ console.error(`\n ${c.bold}${agent.name}${c.reset} isn't installed.`);
241
+ const ok = await askYesNo(` Install it now? ${c.dim}[Y/n]${c.reset} `);
242
+ if (!ok) {
243
+ console.error(`\n No problem. Install it yourself with:\n`);
244
+ printInstallCommands(agent);
245
+ process.exit(127);
246
+ }
247
+
248
+ console.error(
249
+ `\n ${c.dim}Running ${c.reset}${c.cyan}${install}${c.reset}\n`,
250
+ );
251
+ let installed = await runInstaller(agent);
252
+
253
+ // Primary failed and a fallback exists — try it once.
254
+ if (!installed && fallback) {
255
+ console.error(
256
+ `\n ${c.dim}That didn't work. Trying the fallback: ${c.reset}${c.cyan}${fallback}${c.reset}\n`,
257
+ );
258
+ installed = await runInstaller(agent, fallback, false);
259
+ }
260
+
261
+ if (!installed) {
262
+ console.error(`\n ${c.red}Install failed.${c.reset} Try it manually:\n`);
263
+ printInstallCommands(agent);
264
+ process.exit(127);
265
+ }
266
+
267
+ // PATH hardening: the freshly-installed binary is often not on the current
268
+ // process's PATH. Re-resolve against PATH + candidate dirs.
269
+ const found = await resolveBinPath(bin, preferredDirs);
270
+ if (found) return found;
271
+
272
+ console.error(
273
+ `\n ${c.dim}Installed ${agent.name}, but it isn't on this shell's PATH yet. ` +
274
+ `Open a new terminal (or add a bin dir to PATH) and re-run \`subc ${agentCommandName(agent)}\`.${c.reset}\n`,
275
+ );
276
+ process.exit(0);
277
+ }
278
+
279
+ export function parseClaudeVersion(text) {
280
+ const match = String(text ?? '').match(/v?(\d+\.\d+\.\d+)/);
281
+ return match?.[1] ?? null;
282
+ }
283
+
284
+ export const MIN_CLAUDE_CODE_VERSION =
285
+ agentById('claude-code').install.minimum_version;
286
+ export const MIN_CODEX_VERSION = agentById('codex').install.minimum_version;
287
+
288
+ export function claudeVersionNeedsUpgrade(
289
+ installed,
290
+ minimum = MIN_CLAUDE_CODE_VERSION,
291
+ ) {
292
+ return versionNeedsUpgrade(installed, minimum);
293
+ }
294
+
295
+ export function versionNeedsUpgrade(installed, minimum) {
296
+ return Boolean(
297
+ installed && minimum && compareVersions(installed, minimum) < 0,
298
+ );
299
+ }
300
+
301
+ export function readClaudeVersion(bin, binDir, options = {}) {
302
+ const execFile = options.execFileSync || execFileSync;
303
+ try {
304
+ const stdout = execFile(bin, ['--version'], {
305
+ encoding: 'utf-8',
306
+ stdio: ['ignore', 'pipe', 'pipe'],
307
+ env: {
308
+ ...process.env,
309
+ PATH: augmentPath(
310
+ [binDir, ...candidateBinDirs()].filter(Boolean),
311
+ binDir,
312
+ ),
313
+ },
314
+ timeout: options.timeoutMs ?? 3000,
315
+ });
316
+ return parseClaudeVersion(
317
+ typeof stdout === 'string' ? stdout : stdout?.toString?.(),
318
+ );
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ /** Warn, without blocking, when the agent is older than its minimum version. */
325
+ export async function ensureMinimumVersion(
326
+ agent,
327
+ binDir,
328
+ { mayPrompt = true } = {},
329
+ ) {
330
+ const minimum = agent.install.minimum_version;
331
+ if (!minimum) return;
332
+ const version = readClaudeVersion(agentBinary(agent), binDir);
333
+ if (!versionNeedsUpgrade(version, minimum)) return;
334
+
335
+ console.error(
336
+ `\n Minimum supported ${agent.name} version is ${minimum}. Your version is ${version}. Upgrade to get the best experience.\n`,
337
+ );
338
+ const { command, fallback } = resolveInstall(agent);
339
+ console.error(` Upgrade it with:`);
340
+ console.error(` ${c.cyan}${command}${c.reset}`);
341
+ if (fallback) {
342
+ console.error(` or`);
343
+ console.error(` ${c.cyan}${fallback}${c.reset}`);
344
+ }
345
+ console.error('');
346
+ if (
347
+ mayPrompt &&
348
+ process.stdin.isTTY === true &&
349
+ process.stdout.isTTY === true
350
+ ) {
351
+ await waitForEnter(` ${c.dim}Press Enter to continue.${c.reset} `);
352
+ console.error('');
353
+ }
354
+ }
@@ -0,0 +1,199 @@
1
+ import { agentInputs, agentKeyName } from './agent-data.js';
2
+ import { openCodeConfigFromEnv } from './opencode-provider.js';
3
+ import { isUnsetSetting } from './profiles.js';
4
+
5
+ // One pass, so a substituted value (a prompt, a settings document) is never
6
+ // scanned again for placeholders.
7
+ const PLACEHOLDER = /\$\{([A-Z_][A-Z0-9_]*)\}(\/)?|\{([A-Za-z][A-Za-z0-9]*)\}/g;
8
+ const present = (value) =>
9
+ value !== undefined && value !== null && value !== '';
10
+
11
+ function tokensFor(agent, env, extra) {
12
+ const baseUrl = String(env.GATEWAY_URL ?? '').replace(/\/+$/, '');
13
+ const keyName = agentKeyName(agent);
14
+ return {
15
+ baseUrl,
16
+ baseUrlV1: `${baseUrl}/v1`,
17
+ apiKey: (keyName && env[keyName]) || env.API_KEY || '',
18
+ model: env.MODEL ?? '',
19
+ // Left for the runbook, which writes the file and substitutes its path.
20
+ tempFile: '{tempFile}',
21
+ opencodeConfig: () => JSON.stringify(openCodeConfigFromEnv(env)),
22
+ ...extra,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Fill `{token}` placeholders and `${NAME}` env references. A reference
28
+ * followed by `/` drops the value's trailing slashes, so URLs join cleanly.
29
+ */
30
+ export function render(template, agent, env, extra = {}) {
31
+ const tokens = tokensFor(agent, env, extra);
32
+ return String(template).replace(PLACEHOLDER, (match, ref, slash, token) => {
33
+ if (ref) {
34
+ const value = String(env[ref] ?? '');
35
+ return slash ? `${value.replace(/\/+$/, '')}/` : value;
36
+ }
37
+ if (!(token in tokens) || tokens[token] === undefined)
38
+ throw new Error(`${agent.id}: ${match} has no value here`);
39
+ const value = tokens[token];
40
+ return typeof value === 'function' ? value() : value;
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Pull the agent's declared flags out of `args`, up to the first `--`, which
46
+ * is dropped. Everything else passes through in order.
47
+ */
48
+ export function takeFlags(agent, args, { skip = [] } = {}) {
49
+ const flags = new Map(
50
+ agentInputs(agent)
51
+ .filter((input) => input.flag && !skip.includes(input.name))
52
+ .map((input) => [input.flag, input]),
53
+ );
54
+ const values = {};
55
+ const rest = [];
56
+ for (let i = 0; i < args.length; i++) {
57
+ const arg = args[i];
58
+ if (arg === '--') {
59
+ rest.push(...args.slice(i + 1));
60
+ break;
61
+ }
62
+ const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
63
+ const name = equals > 0 ? arg.slice(0, equals) : arg;
64
+ const input = flags.get(name);
65
+ if (!input) {
66
+ rest.push(arg);
67
+ } else if ('flag_value' in input) {
68
+ if (equals > 0) throw new Error(`${name} does not take a value`);
69
+ values[input.name] = input.flag_value;
70
+ } else if (equals > 0) {
71
+ values[input.name] = arg.slice(equals + 1);
72
+ } else if (i + 1 < args.length) {
73
+ values[input.name] = args[++i];
74
+ } else {
75
+ throw new Error(`${name} requires a value`);
76
+ }
77
+ }
78
+ return { values, rest };
79
+ }
80
+
81
+ function inputValue(input, env, agent) {
82
+ const own = env[input.name];
83
+ const usable = (value) =>
84
+ present(value) && !(input.unset_words && isUnsetSetting(value));
85
+ if (usable(own)) return own;
86
+ const alias = (input.aliases || []).map((name) => env[name]).find(usable);
87
+ if (alias !== undefined) return alias;
88
+ if (own === '' && input.keep_empty) return own;
89
+ if (input.default === undefined) return own;
90
+ return render(input.default, agent, env);
91
+ }
92
+
93
+ /**
94
+ * Apply flags, then each input's aliases and default, in file order. A
95
+ * launch rejects a strict input outside its choices; status and uninstall
96
+ * pass `{ strict: false }` so a bad setting cannot block the way out.
97
+ */
98
+ export function resolveInputs(
99
+ agent,
100
+ env,
101
+ flagged = {},
102
+ { strict = true } = {},
103
+ ) {
104
+ const resolved = { ...env, ...flagged };
105
+ for (const input of agentInputs(agent)) {
106
+ if (input.name in flagged) continue;
107
+ const value = inputValue(input, resolved, agent);
108
+ if (value !== undefined) resolved[input.name] = String(value);
109
+ }
110
+ for (const input of agentInputs(agent)) {
111
+ if (!strict || !input.strict) continue;
112
+ if (input.choices.includes(resolved[input.name])) continue;
113
+ const source = input.name in flagged ? input.flag : input.name;
114
+ throw new Error(`${source} must be one of: ${input.choices.join(', ')}`);
115
+ }
116
+ return resolved;
117
+ }
118
+
119
+ /** Set each env entry: the first non-empty override, otherwise its value. */
120
+ export function resolveAgentEnv(agent, env) {
121
+ const resolved = { ...env };
122
+ for (const entry of agent.env) {
123
+ const override = (entry.override || [])
124
+ .map((name) => resolved[name])
125
+ .find(present);
126
+ resolved[entry.name] =
127
+ override !== undefined ? override : render(entry.value, agent, resolved);
128
+ }
129
+ return resolved;
130
+ }
131
+
132
+ function holds(when, env) {
133
+ return Object.entries(when || {}).every(([name, test]) => {
134
+ const value = env[name] ?? '';
135
+ if (test === 'set') return value !== '';
136
+ if (test === 'empty') return value === '';
137
+ if (test && 'not' in test) return value !== test.not;
138
+ throw new Error(`Unknown condition for ${name}: ${JSON.stringify(test)}`);
139
+ });
140
+ }
141
+
142
+ /** `-c name=value` pairs (or the agent's config flag) for cli-config entries. */
143
+ export function configArgs(agent, env) {
144
+ const flag = agent.launch?.config_flag;
145
+ return agent.config
146
+ .filter((entry) => entry.kind === 'cli-config' && holds(entry.when, env))
147
+ .flatMap((entry) => [
148
+ flag,
149
+ `${entry.name}=${render(entry.value, agent, env)}`,
150
+ ]);
151
+ }
152
+
153
+ function expandArgv(agent, template, env, args, prompt) {
154
+ const argv = [];
155
+ let templateWords;
156
+ for (const word of template) {
157
+ if (word === '{args}') {
158
+ templateWords ??= argv.length;
159
+ argv.push(...args);
160
+ } else if (word === '{config}') {
161
+ argv.push(...configArgs(agent, env));
162
+ } else {
163
+ if (word.includes('{prompt}')) templateWords ??= argv.length;
164
+ argv.push(render(word, agent, env, { prompt }));
165
+ }
166
+ }
167
+ return { argv, templateWords: templateWords ?? argv.length };
168
+ }
169
+
170
+ /**
171
+ * Everything needed to start an agent: its argv, env, and stdin. `args` are
172
+ * the words after the agent name with subc's own flags already removed;
173
+ * `prompt` is set for a headless run. `templateWords` counts the leading argv
174
+ * words that came from the template, the only ones a runbook may rewrite.
175
+ */
176
+ export function launchPlan(agent, { env, args = [], prompt } = {}) {
177
+ const headless = prompt !== undefined;
178
+ const { launch } = agent;
179
+ const words =
180
+ !headless && launch.mode_word && args[0] === launch.mode_word
181
+ ? args.slice(1)
182
+ : args;
183
+ const { values, rest } = takeFlags(agent, words);
184
+ const resolved = resolveAgentEnv(agent, resolveInputs(agent, env, values));
185
+ const template = headless ? launch.headless_argv : launch.argv;
186
+ const { argv, templateWords } = expandArgv(
187
+ agent,
188
+ template,
189
+ resolved,
190
+ rest,
191
+ prompt,
192
+ );
193
+ if (!headless)
194
+ return { argv, env: resolved, stdin: 'inherit', templateWords };
195
+ if (!launch.headless_stdin)
196
+ return { argv, env: resolved, stdin: 'ignore', templateWords };
197
+ const input = render(launch.headless_stdin, agent, resolved, { prompt });
198
+ return { argv, env: resolved, stdin: 'pipe', input, templateWords };
199
+ }
@@ -0,0 +1,137 @@
1
+ import { spawn } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { RUNBOOK_DIR } from './agent-data.js';
5
+ import { modelIds } from './launch-values.js';
6
+ import { visionModelList } from './model-capabilities.js';
7
+
8
+ /** Resolve a script inside the agent's own runbook directory. */
9
+ export function runbookScriptPath(agent, relativeScript) {
10
+ const dir = path.join(RUNBOOK_DIR, agent.id);
11
+ const script = path.resolve(dir, relativeScript);
12
+ const relative = path.relative(dir, script);
13
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
14
+ throw new Error(`Invalid runbook script path for ${agent.name}`);
15
+ }
16
+ return script;
17
+ }
18
+
19
+ function forwardStops(child) {
20
+ // The runbook execs the agent, so it is our direct child: a runner that
21
+ // stops subc must stop the agent too. A terminal already delivers SIGINT
22
+ // to the whole process group, so it is not forwarded.
23
+ const forwarded = ['SIGTERM', 'SIGHUP'];
24
+ const forward = (signal) => {
25
+ if (child.exitCode === null && child.signalCode === null) {
26
+ child.kill(signal);
27
+ }
28
+ };
29
+ for (const signal of forwarded) process.on(signal, forward);
30
+ return () => {
31
+ for (const signal of forwarded) process.off(signal, forward);
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Run `file args`, mirror its exit status or signal, and pass SIGTERM/SIGHUP
37
+ * on to it. `stdin` is 'inherit', 'ignore', or 'pipe' with `input` written
38
+ * then closed. `notFound` builds the error when `file` does not exist.
39
+ */
40
+ export function spawnChild(
41
+ file,
42
+ args,
43
+ { env, stdin = 'inherit', input, notFound },
44
+ ) {
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn(file, args, {
47
+ stdio: [stdin, 'inherit', 'inherit'],
48
+ env,
49
+ });
50
+ if (stdin === 'pipe') {
51
+ // The agent may exit without reading all of it.
52
+ child.stdin.on('error', () => {});
53
+ child.stdin.end(input);
54
+ }
55
+ const stopForwarding = forwardStops(child);
56
+
57
+ child.on('error', (error) => {
58
+ stopForwarding();
59
+ reject(error.code === 'ENOENT' && notFound ? notFound() : error);
60
+ });
61
+
62
+ child.on('exit', (code, signal) => {
63
+ stopForwarding();
64
+ if (signal) {
65
+ // Node ignores some signals (SIGPIPE), so re-raising may not end the
66
+ // process; the shell convention still reports the agent's failure.
67
+ process.exitCode = 128 + (os.constants.signals[signal] ?? 0);
68
+ process.kill(process.pid, signal);
69
+ resolve(process.exitCode);
70
+ return;
71
+ }
72
+ if (code) process.exitCode = code;
73
+ resolve(code ?? 0);
74
+ });
75
+ });
76
+ }
77
+
78
+ const BASH_MISSING = () =>
79
+ new Error(
80
+ 'These coding-agent integrations require `bash`, but it was not found on PATH.',
81
+ );
82
+
83
+ /**
84
+ * What a runbook script reads beyond the launch env. With `strict: false`
85
+ * an invalid model leaves SUBC_MODEL_IDS unset instead of failing, so a
86
+ * script that needs it sees an empty list.
87
+ */
88
+ export function scriptEnv(env, { strict = true } = {}) {
89
+ let ids;
90
+ try {
91
+ ids = modelIds(env).join('\n');
92
+ } catch (error) {
93
+ if (strict) throw error;
94
+ }
95
+ return {
96
+ ...env,
97
+ ...(ids === undefined ? {} : { SUBC_MODEL_IDS: ids }),
98
+ SUBC_VISION_MODELS: visionModelList(),
99
+ };
100
+ }
101
+
102
+ /** The command for one of the agent's runbook scripts, given its env. */
103
+ export function scriptCommand(agent, relativeScript, args, env, options) {
104
+ return {
105
+ file: 'bash',
106
+ args: [runbookScriptPath(agent, relativeScript), ...args],
107
+ env: scriptEnv(env, options),
108
+ notFound: BASH_MISSING,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * The command that starts a launch plan: the agent's runbook script when it
114
+ * has one, otherwise the binary itself, from `binDir` when it is known.
115
+ */
116
+ export function planCommand(agent, plan, binDir) {
117
+ const io = { stdin: plan.stdin, input: plan.input };
118
+ const { script } = agent.runbook;
119
+ if (script) {
120
+ const command = scriptCommand(agent, script, plan.argv, plan.env);
121
+ command.env.SUBC_TEMPLATE_WORDS = String(plan.templateWords);
122
+ return { ...command, ...io };
123
+ }
124
+ const [bin, ...args] = plan.argv;
125
+ return {
126
+ file: binDir ? path.join(binDir, bin) : bin,
127
+ args,
128
+ env: plan.env,
129
+ ...io,
130
+ notFound: () => new Error(`Could not launch \`${bin}\`.`),
131
+ };
132
+ }
133
+
134
+ /** Run a command built by planCommand or scriptCommand. */
135
+ export function runCommand(command) {
136
+ return spawnChild(command.file, command.args, command);
137
+ }