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
package/bin/agents.js CHANGED
@@ -1,126 +1,65 @@
1
- /**
2
- * Coding-agent launcher.
3
- *
4
- * `subc <agent>` resolves your saved API key and runs the packaged integration.
5
- * Terminal agents launch ephemerally; IDE/config-based
6
- * agents and Pi persist a surgical integration via `subc <agent> install`.
7
- *
8
- * There is NO hardcoded agent data here: everything is read from
9
- * `registry.generated.json` (shipped under `bin/`), which is generated from the
10
- * single source of truth `agents/registry.json`. Run `node scripts/generate-agents.js` to update.
11
- */
12
-
13
- import { execFileSync, spawn } from 'node:child_process';
14
- import { constants as fsConstants, readFileSync } from 'node:fs';
15
- import fs from 'node:fs/promises';
16
- import os from 'node:os';
17
- import path from 'node:path';
18
- import readline from 'node:readline';
19
- import { fileURLToPath } from 'node:url';
1
+ import {
2
+ AGENTS,
3
+ agentCommandName,
4
+ agentKeyName,
5
+ agentSetupActions,
6
+ DEFAULTS,
7
+ PACKAGED_MODELS,
8
+ } from './agent-data.js';
9
+ import { isAgentHelpRequest, printAgentHelp } from './agent-help.js';
10
+ import {
11
+ augmentPath,
12
+ candidateBinDirs,
13
+ claudeVersionNeedsUpgrade,
14
+ ensureInstalled,
15
+ ensureMinimumVersion,
16
+ MIN_CLAUDE_CODE_VERSION,
17
+ parseClaudeVersion,
18
+ } from './agent-install.js';
19
+ import { launchPlan, resolveInputs } from './agent-launch.js';
20
+ import { planCommand, runCommand, scriptCommand } from './agent-spawn.js';
20
21
  import { getApiKey } from './auth.js';
22
+ import {
23
+ applyClaudePickerCatalog,
24
+ CLAUDE_PICKER_ENV_DESCRIPTION,
25
+ claudeModelPickerEnv,
26
+ claudePickerSettings,
27
+ } from './claude-picker.js';
21
28
  import { c } from './colors.js';
29
+ import {
30
+ headlessPromptIndex,
31
+ separatorIndex,
32
+ takesModelValue,
33
+ } from './headless-args.js';
22
34
  import {
23
35
  isLiveModelSource,
24
36
  PUBLIC_CATALOG_FALLBACK_MESSAGE,
25
37
  resolveModelCatalog,
26
38
  } from './models.js';
27
- import {
28
- isUnsetSetting,
29
- profileSettingsForAgent,
30
- resolvedModelSetting,
31
- resolvedProfileValues,
32
- } from './profiles.js';
33
- import { compareVersions } from './update-check.js';
39
+ import { isUnsetSetting, resolvedModelSetting } from './profiles.js';
34
40
  import { runWindowsAgent } from './windows/agents.js';
35
41
 
36
- export const MIN_CLAUDE_CODE_VERSION = '2.1.242';
37
-
38
- // First Codex that honours `multi_agent_version: "v2"` in the model catalog,
39
- // found by installing each release and reading what it advertises: 0.142.5 and
40
- // below offer no subagent namespace at all, 0.143.0 offers `collaboration`.
41
- //
42
- // Below this the catalog field is not rejected, it is ignored - the run simply
43
- // has no subagents and nothing says why. That silence is the reason for a
44
- // version gate rather than a note in the docs.
45
- export const MIN_CODEX_VERSION = '0.143.0';
42
+ export { agentCommandName, agentSetupActions } from './agent-data.js';
43
+ export { isAgentHelpRequest, printAgentHelp } from './agent-help.js';
44
+ export {
45
+ augmentPath,
46
+ claudeVersionNeedsUpgrade,
47
+ MIN_CLAUDE_CODE_VERSION,
48
+ MIN_CODEX_VERSION,
49
+ parseClaudeVersion,
50
+ preferredBinDirsForAgent,
51
+ readClaudeVersion,
52
+ versionNeedsUpgrade,
53
+ } from './agent-install.js';
54
+ export { claudePickerSettings } from './claude-picker.js';
55
+
56
+ const CLAUDE_CODE = 'claude-code';
46
57
 
47
- // --- Registry (single source of truth, generated copy shipped in the package).
48
- const registry = JSON.parse(
49
- readFileSync(new URL('./registry.generated.json', import.meta.url), 'utf-8'),
50
- );
51
- const DEFAULTS = registry.defaults;
52
- const PACKAGED_MODELS =
53
- Array.isArray(DEFAULTS.models) && DEFAULTS.models.length
54
- ? DEFAULTS.models
55
- : [DEFAULTS.model];
56
- const RUNBOOK_DIR = fileURLToPath(new URL('./runbook/', import.meta.url));
57
-
58
- // --- Token substitution — same rules as scripts/lib/registry.js.
59
- // Replaces {apiKey}, {model}, {baseUrl}, {baseUrlV1}. NEVER touches {env:...}.
60
- const TOKENS = ['apiKey', 'model', 'baseUrl', 'baseUrlV1'];
61
-
62
- function substituteString(str, ctx) {
63
- let out = str;
64
- for (const token of TOKENS) {
65
- if (ctx[token] === undefined) continue;
66
- out = out.split(`{${token}}`).join(ctx[token]);
67
- }
68
- return out;
69
- }
70
-
71
- function substitute(value, ctx) {
72
- if (typeof value === 'string') return substituteString(value, ctx);
73
- if (Array.isArray(value)) return value.map((v) => substitute(v, ctx));
74
- if (value && typeof value === 'object') {
75
- const keys = Object.keys(value);
76
- if (keys.length === 1 && keys[0] === '$json') {
77
- return JSON.stringify(substitute(value.$json, ctx));
78
- }
79
- const out = {};
80
- for (const key of keys)
81
- out[substituteString(key, ctx)] = substitute(value[key], ctx);
82
- return out;
83
- }
84
- return value;
85
- }
86
-
87
- /**
88
- * Resolve the install command for the current OS from a per-OS install object.
89
- * Falls back to the linux command, then any string value present, if the exact
90
- * `process.platform` key is missing. Tolerates a legacy plain-string `install`.
91
- * Returns `{ command, fallback }` where `fallback` may be undefined.
92
- */
93
- function resolveInstall(install) {
94
- if (typeof install === 'string')
95
- return { command: install, fallback: undefined };
96
- if (!install || typeof install !== 'object')
97
- return { command: undefined, fallback: undefined };
98
- const command =
99
- install[process.platform] ||
100
- install.linux ||
101
- Object.values(install).find((v) => typeof v === 'string');
102
- return { command, fallback: install.fallback };
103
- }
104
-
105
- // --- Build the in-memory registry + alias index.
106
- // Each agent gets a resolved per-OS `install` (string) plus optional
107
- // `installFallback`, while keeping the original per-OS object available.
108
- const AGENTS = registry.agents
109
- .filter((agent) => agent.cli !== false)
110
- .map((agent) => {
111
- const { command, fallback } = resolveInstall(agent.install);
112
- return {
113
- ...agent,
114
- bin: agent.binByPlatform?.[process.platform] || agent.bin,
115
- install: command,
116
- installFallback: fallback,
117
- };
118
- });
119
58
  const BY_ALIAS = new Map();
120
59
  for (const agent of AGENTS) {
121
60
  BY_ALIAS.set(agent.id, agent);
122
- if (agent.command) BY_ALIAS.set(agent.command, agent);
123
- for (const alias of agent.aliases || []) BY_ALIAS.set(alias, agent);
61
+ BY_ALIAS.set(agentCommandName(agent), agent);
62
+ for (const alias of agent.aliases) BY_ALIAS.set(alias, agent);
124
63
  }
125
64
 
126
65
  export function resolveAgent(name) {
@@ -131,26 +70,16 @@ export function agentList() {
131
70
  return AGENTS.map((a) => ({
132
71
  id: a.id,
133
72
  name: a.name,
134
- alias: a.command || a.id,
135
- action: a.runbook?.mode === 'setup' ? 'Configure' : 'Launch',
136
- description: a.description || '',
137
- launch: a.runbook?.mode !== 'setup',
73
+ alias: agentCommandName(a),
74
+ action: a.runbook.mode === 'setup' ? 'Configure' : 'Launch',
75
+ description: a.description,
76
+ launch: a.runbook.mode !== 'setup',
138
77
  }));
139
78
  }
140
79
 
141
80
  const SETUP_ACTIONS = new Set(['install', 'status', 'uninstall']);
142
81
  const DROPPED_SETUP_HELPERS = new Set(['use', 'env', 'unset']);
143
82
 
144
- export function agentSetupActions(agent) {
145
- return Array.isArray(agent.runbook?.setupActions)
146
- ? agent.runbook.setupActions
147
- : [];
148
- }
149
-
150
- export function agentCommandName(agent) {
151
- return agent.command || agent.id;
152
- }
153
-
154
83
  export function parseAgentAction(agent, argv = []) {
155
84
  const command = agentCommandName(agent);
156
85
  const first = argv[0];
@@ -163,7 +92,7 @@ export function parseAgentAction(agent, argv = []) {
163
92
  }
164
93
 
165
94
  if (SETUP_ACTIONS.has(first)) {
166
- if (!agent.runbook?.setupScript || !actions.includes(first)) {
95
+ if (!agent.runbook.setup_script || !actions.includes(first)) {
167
96
  if (first === 'install') {
168
97
  const uninstallHint = actions.includes('uninstall')
169
98
  ? ` To remove leftover files: subc ${command} uninstall.`
@@ -179,234 +108,37 @@ export function parseAgentAction(agent, argv = []) {
179
108
  return { action: first, args: argv };
180
109
  }
181
110
 
182
- if (agent.runbook?.mode === 'setup') {
111
+ if (first === 'headless') {
112
+ if (!agent.launch?.headless_argv) {
113
+ throw new Error(`${agent.name} does not support headless mode.`);
114
+ }
115
+ if (!argv[1]?.trim() || ['-h', '--help'].includes(argv[1])) {
116
+ throw new Error(`Usage: subc ${command} headless PROMPT [args...]`);
117
+ }
118
+ if (argv.slice(2).some((arg) => arg === '-h' || arg === '--help')) {
119
+ throw new Error('help is not available in a headless run');
120
+ }
121
+ return { action: 'headless', args: argv };
122
+ }
123
+
124
+ if (agent.runbook.mode === 'setup') {
183
125
  return { action: 'install', args: argv };
184
126
  }
185
127
 
186
128
  return { action: 'launch', args: argv };
187
129
  }
188
130
 
189
- const AGENT_HELP = {
190
- 'subconscious-code': {
191
- usage: 'subc [-p NAME] marathon [help|install] [Marathon arguments...]',
192
- behavior:
193
- 'Launches the native Subconscious coding agent with the selected profile gateway, credential, and model.',
194
- options: [
195
- ['help', 'Show this help'],
196
- [
197
- 'install',
198
- process.platform === 'win32'
199
- ? 'Install a verified Windows release, when published upstream'
200
- : 'Install the latest precompiled Linux or macOS release',
201
- ],
202
- ['--model MODEL', 'Override the profile model for this launch'],
203
- ['ARGS...', 'Pass arguments directly to Marathon'],
204
- ],
205
- },
206
- 'claude-code': {
207
- usage:
208
- 'subc [-p NAME] claude [help|status|uninstall] [Claude arguments...]',
209
- behavior:
210
- 'Launches Claude Code with the active Subconscious profile and model picker. Persistent files are leftover-only: subc claude uninstall.',
211
- options: [
212
- ['help', 'Show this help'],
213
- ['status', 'Inspect leftover ~/.claude/subconscious-gateway.env'],
214
- ['uninstall', 'Remove leftover ~/.claude/subconscious-gateway.env'],
215
- ['--model MODEL', 'Override the profile model for this launch'],
216
- ['--compact-window N', 'Override the Claude auto-compact window'],
217
- ['--max-context-tokens N', 'Override the maximum context tokens'],
218
- ['-- ARGS...', 'Pass remaining arguments to Claude Code'],
219
- ],
220
- },
221
- codex: {
222
- usage:
223
- 'subc [-p NAME] codex [help|install|status|uninstall] [Codex arguments...]',
224
- behavior:
225
- 'Launches Codex with a temporary Subconscious provider catalog. Compaction hooks are merged into ~/.codex/hooks.json on first launch and removed with subc codex uninstall; Codex will not run them until you trust them with /hooks once.',
226
- options: [
227
- ['help', 'Show this help'],
228
- [
229
- 'install',
230
- 'Install the Subconscious compaction hooks (then trust with /hooks)',
231
- ],
232
- ['status', 'Inspect the installed compaction hooks'],
233
- ['uninstall', 'Remove only the Subconscious Codex hooks'],
234
- ['--model MODEL', 'Override the profile model for this launch'],
235
- ['--context-window N', 'Override catalog context_window'],
236
- ['--max-context-window N', 'Override catalog max_context_window'],
237
- [
238
- '--auto-compact-token-limit N',
239
- 'Override the automatic compaction threshold',
240
- ],
241
- ['--reasoning-effort LEVEL', 'Use none, low, medium, high, or max'],
242
- ['--external-tools', 'Enable Codex apps/plugins for this launch'],
243
- [
244
- '--stream-idle-timeout MS',
245
- 'Allow a longer silent think before timing out',
246
- ],
247
- ['--max-subagents N', 'Subagents allowed to run at once (default 4)'],
248
- [
249
- '--subagent-effort LEVEL',
250
- 'Effort for subagents, separate from the parent',
251
- ],
252
- ['-- ARGS...', 'Pass remaining arguments to Codex'],
253
- ],
254
- },
255
- opencode: {
256
- usage:
257
- 'subc [-p NAME] opencode [help|status|uninstall] [OpenCode arguments...]',
258
- behavior:
259
- 'Launches OpenCode with an ephemeral provider containing every Subconscious model. Persistent files are leftover-only: subc opencode uninstall.',
260
- options: [
261
- ['help', 'Show this help'],
262
- ['status', 'Inspect leftover OpenCode Subconscious config'],
263
- [
264
- 'uninstall',
265
- 'Remove only the Subconscious OpenCode provider and plugin',
266
- ],
267
- ['--model MODEL', 'Override the profile model for this launch'],
268
- ['ARGS...', 'Pass arguments directly to OpenCode'],
269
- ],
270
- },
271
- cursor: {
272
- usage: 'subc [-p NAME] cursor [help|install|status|uninstall]',
273
- behavior:
274
- 'Manages Cursor correlation hooks; model endpoint setup is completed in Cursor Settings.',
275
- options: [
276
- ['help', 'Show this help'],
277
- ['install', 'Install or update the Cursor hooks (default action)'],
278
- ['status', 'Inspect the installed hook configuration'],
279
- ['uninstall', 'Remove only the Subconscious Cursor hooks'],
280
- ],
281
- },
282
- copilot: {
283
- usage: 'subc [-p NAME] copilot [help|install|status|uninstall]',
284
- behavior:
285
- 'Manages the VS Code model provider and Copilot correlation hooks.',
286
- options: [
287
- ['help', 'Show this help'],
288
- ['install', 'Install or update the provider and hooks (default action)'],
289
- ['status', 'Inspect the installed provider and hooks'],
290
- ['uninstall', 'Remove the Subconscious provider and hooks'],
291
- ],
292
- },
293
- pi: {
294
- usage:
295
- 'subc [-p NAME] pi [help|install|status|uninstall] [Pi arguments...]',
296
- behavior:
297
- 'Refreshes the persistent Subconscious provider from the live catalog, then launches Pi.',
298
- options: [
299
- ['help', 'Show this help'],
300
- ['install', 'Refresh the Subconscious provider without launching Pi'],
301
- ['status', 'Inspect the persistent Pi provider'],
302
- ['uninstall', 'Remove only the Subconscious Pi provider and extension'],
303
- ['--model MODEL', 'Override the profile model for this launch'],
304
- ['ARGS...', 'Pass arguments directly to Pi'],
305
- ],
306
- },
307
- 'deepseek-harness': {
308
- usage:
309
- 'subc [-p NAME] dsh [web|headless] [--model MODEL] [Harness arguments...]',
310
- behavior:
311
- 'Launches DeepSeek Harness with a temporary Subconscious provider containing every live gateway model. Web mode is the default.',
312
- options: [
313
- ['help', 'Show this help'],
314
- ['web', 'Launch the DeepSeek Harness Web UI (default)'],
315
- ['headless PROMPT', 'Run one headless task and exit'],
316
- ['--model MODEL', 'Set the initial model for new Harness sessions'],
317
- ['ARGS...', 'Pass remaining arguments to the selected Harness mode'],
318
- ],
319
- },
320
- };
321
-
322
- export function isAgentHelpRequest(argv = []) {
323
- return ['help', '-h', '--help'].includes(argv[0]);
324
- }
325
-
326
- function displayProfileValue(setting, value, values) {
327
- if (setting.type === 'secret') {
328
- if (value) return '(set)';
329
- return setting.key !== 'API_KEY' && values.API_KEY
330
- ? '(shared key)'
331
- : '(not set)';
332
- }
333
- if (
334
- !value &&
335
- (setting.key === 'MODEL' || setting.key === 'CLAUDE_CODE_SUBAGENT_MODEL')
336
- ) {
337
- return 'UNSET';
338
- }
339
- return value || '(auto)';
340
- }
341
-
342
- export function printAgentHelp(agent, profile) {
343
- const details = AGENT_HELP[agent.id] || {
344
- usage: `subc [--profile NAME] ${agent.command || agent.id} [arguments...]`,
345
- behavior: agent.description,
346
- options: [],
347
- };
348
- const settings = profileSettingsForAgent(agent.id);
349
- const values = resolvedProfileValues(profile);
350
- const optionWidth = Math.max(
351
- 0,
352
- ...details.options.map(([option]) => option.length),
353
- );
354
- const settingWidth = Math.max(
355
- 0,
356
- ...settings.map((setting) => setting.key.length),
357
- );
358
-
359
- console.log(`\n ${c.bold}${agent.name} + Subconscious${c.reset}\n`);
360
- console.log(` ${details.behavior}\n`);
361
- console.log(` ${c.bold}Usage${c.reset}\n ${details.usage}\n`);
362
- if (details.options.length) {
363
- console.log(` ${c.bold}Commands and options${c.reset}`);
364
- for (const [option, description] of details.options) {
365
- console.log(
366
- ` ${c.cyan}${option.padEnd(optionWidth)}${c.reset} ${description}`,
367
- );
368
- }
369
- console.log();
370
- }
371
- console.log(
372
- ` ${c.bold}Profile settings${c.reset} ${c.dim}(${profile?.name || 'default'})${c.reset}`,
373
- );
374
- for (const setting of settings) {
375
- const value = displayProfileValue(setting, values[setting.key], values);
376
- console.log(
377
- ` ${c.cyan}${setting.key.padEnd(settingWidth)}${c.reset} ${value}`,
378
- );
379
- console.log(
380
- ` ${' '.repeat(settingWidth)} ${c.dim}${setting.description}${c.reset}`,
381
- );
382
- }
383
- const command = agentCommandName(agent);
384
- const profileFlag = profile?.name || 'default';
385
- console.log(
386
- `\n Edit the env file with ${c.cyan}subc -p ${profileFlag} config edit${c.reset}.`,
387
- );
388
- const actions = agentSetupActions(agent);
389
- if (actions.includes('install')) {
390
- const installDescription = agent.runbook?.binaryInstallScript
391
- ? 'Install the agent binary with'
392
- : 'Install the persistent integration with';
393
- console.log(
394
- ` ${installDescription} ${c.cyan}subc ${command} install${c.reset}.`,
395
- );
396
- }
397
- if (actions.includes('uninstall')) {
398
- console.log(
399
- ` Remove it with ${c.cyan}subc ${command} uninstall${c.reset}.`,
400
- );
401
- }
402
- console.log();
131
+ /** True when argv asks for a headless run; throws when that run is invalid. */
132
+ export function isHeadlessRequest(agent, argv = []) {
133
+ const prompt = headlessPromptIndex(argv);
134
+ if (prompt < 0) return false;
135
+ parseAgentAction(agent, argv.slice(prompt - 1));
136
+ return true;
403
137
  }
404
138
 
405
139
  /**
406
- * Resolve the substitution context for a launch:
407
- * model — --model flag → SUBCONSCIOUS_MODEL → registry default
408
- * baseUrl — SUBCONSCIOUS_BASE_URL → registry default
409
- * baseUrlV1 — `${baseUrl}/v1` (so an override flows to both)
140
+ * Resolve the gateway for a launch: SUBCONSCIOUS_BASE_URL, then the profile
141
+ * GATEWAY_URL, then the packaged default, without trailing slashes.
410
142
  */
411
143
  function buildContext(apiKey, model, profile) {
412
144
  const baseUrl = (
@@ -414,7 +146,7 @@ function buildContext(apiKey, model, profile) {
414
146
  profile?.values?.GATEWAY_URL?.trim() ||
415
147
  DEFAULTS.baseUrl
416
148
  ).replace(/\/+$/, '');
417
- return { apiKey, model, baseUrl, baseUrlV1: `${baseUrl}/v1` };
149
+ return { apiKey, model, baseUrl };
418
150
  }
419
151
 
420
152
  /**
@@ -433,12 +165,18 @@ export function extractModel(argv, profile) {
433
165
  ? 'profile'
434
166
  : 'catalog';
435
167
  const rest = [];
168
+ // The headless prompt is opaque, even when it looks like --model.
169
+ const promptIndex = headlessPromptIndex(argv);
436
170
  for (let i = 0; i < argv.length; i++) {
437
171
  const a = argv[i];
172
+ if (i === promptIndex) {
173
+ rest.push(a);
174
+ continue;
175
+ }
438
176
  if (a === '--model') {
439
177
  const v = argv[i + 1];
440
- if (v && !v.startsWith('-')) {
441
- if (isUnsetSetting(v)) {
178
+ if (takesModelValue(v)) {
179
+ if (v === '' || isUnsetSetting(v)) {
442
180
  model = '';
443
181
  modelSource = 'catalog';
444
182
  } else {
@@ -465,363 +203,6 @@ export function extractModel(argv, profile) {
465
203
  return { model, modelSource, rest };
466
204
  }
467
205
 
468
- /**
469
- * Common locations a freshly-installed coding-agent binary lands in but which
470
- * are often NOT on the current process's PATH (e.g. aider/claude install into
471
- * `~/.local/bin`; npm globals into the npm prefix bin). Best-effort, deduped.
472
- */
473
- function candidateBinDirs() {
474
- const home = os.homedir();
475
- const dirs = [];
476
-
477
- if (process.platform === 'win32') {
478
- if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm'));
479
- if (process.env.USERPROFILE) {
480
- dirs.push(path.join(process.env.USERPROFILE, '.local', 'bin'));
481
- }
482
- if (home) dirs.push(path.join(home, '.local', 'bin'));
483
- } else {
484
- dirs.push(path.join(home, '.local', 'bin'));
485
- dirs.push(path.join(home, '.cargo', 'bin'));
486
- dirs.push('/opt/homebrew/bin');
487
- dirs.push('/usr/local/bin');
488
- }
489
-
490
- // npm global bin (best-effort — npm may be absent).
491
- try {
492
- const prefix = execFileSync('npm', ['prefix', '-g'], {
493
- encoding: 'utf-8',
494
- stdio: ['ignore', 'pipe', 'ignore'],
495
- }).trim();
496
- if (prefix) {
497
- dirs.push(
498
- process.platform === 'win32' ? prefix : path.join(prefix, 'bin'),
499
- );
500
- }
501
- } catch {
502
- // npm not available — skip.
503
- }
504
-
505
- // Dedupe, drop empties.
506
- return [...new Set(dirs.filter(Boolean))];
507
- }
508
-
509
- /** Resolve an agent-managed binary directory declared by the registry. */
510
- export function preferredBinDirsForAgent(
511
- agent,
512
- environment = process.env,
513
- home = os.homedir(),
514
- ) {
515
- const envName = agent.runbook?.installDirEnv;
516
- const configured = envName ? environment[envName]?.trim() : '';
517
- if (configured) return [path.resolve(configured)];
518
-
519
- const installDir = agent.runbook?.installDir?.trim();
520
- return installDir ? [path.resolve(home, installDir)] : [];
521
- }
522
-
523
- /** Executable extensions to probe (Windows uses PATHEXT). */
524
- function binExts() {
525
- return process.platform === 'win32'
526
- ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
527
- : [''];
528
- }
529
-
530
- /**
531
- * Resolve `bin` against PATH plus the candidate bin dirs. Returns the directory
532
- * containing the executable if found, otherwise null. Searching the candidate
533
- * dirs lets us find binaries installed this session that aren't on PATH yet.
534
- */
535
- async function resolveBinPath(bin, preferredDirs = []) {
536
- const pathDirs = (process.env.PATH || '')
537
- .split(path.delimiter)
538
- .filter(Boolean);
539
- const dirs = [
540
- ...new Set([...preferredDirs, ...pathDirs, ...candidateBinDirs()]),
541
- ];
542
- const exts = binExts();
543
- for (const dir of dirs) {
544
- for (const ext of exts) {
545
- const candidate = path.join(dir, bin + ext);
546
- try {
547
- await fs.access(candidate, fsConstants.F_OK);
548
- return dir;
549
- } catch {
550
- // keep scanning
551
- }
552
- }
553
- }
554
- return null;
555
- }
556
-
557
- /**
558
- * Build a PATH string with `extraDirs` prepended (deduped against PATH).
559
- * Returns the augmented PATH value for use in a child env.
560
- */
561
- export function augmentPath(
562
- extraDirs,
563
- preferredDir,
564
- currentPath = process.env.PATH || '',
565
- ) {
566
- const current = currentPath.split(path.delimiter).filter(Boolean);
567
- const seen = new Set(current);
568
- const prepend = extraDirs.filter(
569
- (dir, index) =>
570
- dir &&
571
- dir !== preferredDir &&
572
- !seen.has(dir) &&
573
- extraDirs.indexOf(dir) === index,
574
- );
575
- const remaining = current.filter((dir) => dir !== preferredDir);
576
- return [
577
- ...(preferredDir ? [preferredDir] : []),
578
- ...prepend,
579
- ...remaining,
580
- ].join(path.delimiter);
581
- }
582
-
583
- /** Ask a yes/no question on the TTY. Empty answer counts as yes. */
584
- function askYesNo(question) {
585
- return new Promise((resolve) => {
586
- const rl = readline.createInterface({
587
- input: process.stdin,
588
- output: process.stdout,
589
- });
590
- rl.question(question, (answer) => {
591
- rl.close();
592
- const a = answer.trim().toLowerCase();
593
- resolve(a === '' || a === 'y' || a === 'yes');
594
- });
595
- });
596
- }
597
-
598
- function waitForEnter(prompt) {
599
- return new Promise((resolve) => {
600
- const rl = readline.createInterface({
601
- input: process.stdin,
602
- output: process.stdout,
603
- });
604
- rl.question(prompt, () => {
605
- rl.close();
606
- resolve();
607
- });
608
- });
609
- }
610
-
611
- /** Run the agent's packaged binary installer, or its shell install command. */
612
- function runInstaller(
613
- agent,
614
- install = agent.install,
615
- usePackagedScript = true,
616
- ) {
617
- return new Promise((resolve) => {
618
- const installScript =
619
- usePackagedScript && agent.runbook?.binaryInstallScript;
620
- const child = installScript
621
- ? spawn('bash', [runbookScriptPath(agent, installScript), 'install'], {
622
- stdio: 'inherit',
623
- })
624
- : spawn(install, { shell: true, stdio: 'inherit' });
625
- child.on('error', () => resolve(false));
626
- child.on('exit', (code) => resolve(code === 0));
627
- });
628
- }
629
-
630
- /** Print the resolved install command (plus any fallback) for an agent. */
631
- function printInstallCommands(agent) {
632
- console.error(` ${c.cyan}${agent.install}${c.reset}`);
633
- if (agent.installFallback) {
634
- console.error(` ${c.dim}or, as a fallback:${c.reset}`);
635
- console.error(` ${c.cyan}${agent.installFallback}${c.reset}`);
636
- }
637
- console.error('');
638
- }
639
-
640
- /**
641
- * Ensure the agent's binary is resolvable. If missing:
642
- * - interactive TTY: offer to run the per-OS installer (with fallback), then
643
- * re-resolve against PATH + candidate dirs.
644
- * - non-interactive: print the resolved install command (+ fallback) and
645
- * exit 127 without running anything.
646
- *
647
- * Returns the directory containing the bin (to prepend to the child's PATH) on
648
- * success. May exit the process on failure or when manual action is needed.
649
- */
650
- async function ensureInstalled(agent) {
651
- const preferredDirs = preferredBinDirsForAgent(agent);
652
- const existing = await resolveBinPath(agent.bin, preferredDirs);
653
- if (existing) return existing;
654
-
655
- // Agents without an installer are launch-only. Their setup integration may
656
- // configure the provider, but `subc <agent>` must never install the binary.
657
- if (!agent.install) {
658
- console.error(
659
- `\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${agent.bin}\` not found on PATH).${c.reset}`,
660
- );
661
- console.error(
662
- ` Install ${agent.name} separately, then re-run ${c.cyan}subc ${agent.command || agent.id}${c.reset}.\n`,
663
- );
664
- process.exit(127);
665
- }
666
-
667
- const interactive = process.stdin.isTTY && process.stdout.isTTY;
668
-
669
- if (!interactive) {
670
- console.error(
671
- `\n ${c.red}${agent.name} isn't installed${c.reset} ${c.dim}(\`${agent.bin}\` not found on PATH).${c.reset}`,
672
- );
673
- console.error(` Install it with:\n`);
674
- printInstallCommands(agent);
675
- process.exit(127);
676
- }
677
-
678
- console.error(`\n ${c.bold}${agent.name}${c.reset} isn't installed.`);
679
- const ok = await askYesNo(` Install it now? ${c.dim}[Y/n]${c.reset} `);
680
- if (!ok) {
681
- console.error(`\n No problem. Install it yourself with:\n`);
682
- printInstallCommands(agent);
683
- process.exit(127);
684
- }
685
-
686
- console.error(
687
- `\n ${c.dim}Running ${c.reset}${c.cyan}${agent.install}${c.reset}\n`,
688
- );
689
- let installed = await runInstaller(agent);
690
-
691
- // Primary failed and a fallback exists — try it once.
692
- if (!installed && agent.installFallback) {
693
- console.error(
694
- `\n ${c.dim}That didn't work. Trying the fallback: ${c.reset}${c.cyan}${agent.installFallback}${c.reset}\n`,
695
- );
696
- installed = await runInstaller(agent, agent.installFallback, false);
697
- }
698
-
699
- if (!installed) {
700
- console.error(`\n ${c.red}Install failed.${c.reset} Try it manually:\n`);
701
- printInstallCommands(agent);
702
- process.exit(127);
703
- }
704
-
705
- // PATH hardening: the freshly-installed binary is often not on the current
706
- // process's PATH. Re-resolve against PATH + candidate dirs.
707
- const found = await resolveBinPath(agent.bin, preferredDirs);
708
- if (found) return found;
709
-
710
- console.error(
711
- `\n ${c.dim}Installed ${agent.name}, but it isn't on this shell's PATH yet. ` +
712
- `Open a new terminal (or add a bin dir to PATH) and re-run \`subc ${agent.command || agent.id}\`.${c.reset}\n`,
713
- );
714
- process.exit(0);
715
- }
716
-
717
- export function parseClaudeVersion(text) {
718
- const match = String(text ?? '').match(/v?(\d+\.\d+\.\d+)/);
719
- return match?.[1] ?? null;
720
- }
721
-
722
- export function claudeVersionNeedsUpgrade(
723
- installed,
724
- minimum = MIN_CLAUDE_CODE_VERSION,
725
- ) {
726
- return versionNeedsUpgrade(installed, minimum);
727
- }
728
-
729
- export function versionNeedsUpgrade(installed, minimum) {
730
- return Boolean(
731
- installed && minimum && compareVersions(installed, minimum) < 0,
732
- );
733
- }
734
-
735
- export function readClaudeVersion(bin, binDir, options = {}) {
736
- const execFile = options.execFileSync || execFileSync;
737
- try {
738
- const stdout = execFile(bin, ['--version'], {
739
- encoding: 'utf-8',
740
- stdio: ['ignore', 'pipe', 'pipe'],
741
- env: {
742
- ...process.env,
743
- PATH: augmentPath(
744
- [binDir, ...candidateBinDirs()].filter(Boolean),
745
- binDir,
746
- ),
747
- },
748
- timeout: options.timeoutMs ?? 3000,
749
- });
750
- return parseClaudeVersion(
751
- typeof stdout === 'string' ? stdout : stdout?.toString?.(),
752
- );
753
- } catch {
754
- return null;
755
- }
756
- }
757
-
758
- const MINIMUM_VERSIONS = {
759
- 'claude-code': { minimum: MIN_CLAUDE_CODE_VERSION, label: 'Claude Code' },
760
- // Below this, subagents are silently absent rather than broken.
761
- codex: { minimum: MIN_CODEX_VERSION, label: 'Codex' },
762
- };
763
-
764
- async function ensureClaudeCompatible(agent, binDir) {
765
- const requirement = MINIMUM_VERSIONS[agent.id];
766
- if (!requirement) return;
767
- const version = readClaudeVersion(agent.bin, binDir);
768
- if (!versionNeedsUpgrade(version, requirement.minimum)) return;
769
-
770
- console.error(
771
- `\n Minimum supported ${requirement.label} version is ${requirement.minimum}. Your version is ${version}. Upgrade to get the best experience.\n`,
772
- );
773
- console.error(` Upgrade it with:`);
774
- console.error(` ${c.cyan}${agent.install}${c.reset}`);
775
- if (agent.installFallback) {
776
- console.error(` or`);
777
- console.error(` ${c.cyan}${agent.installFallback}${c.reset}`);
778
- }
779
- console.error('');
780
- if (process.stdin.isTTY === true && process.stdout.isTTY === true) {
781
- await waitForEnter(` ${c.dim}Press Enter to continue.${c.reset} `);
782
- console.error('');
783
- }
784
- }
785
-
786
- /** Resolve and validate a script inside the packaged runbook directory. */
787
- function runbookScriptPath(agent, relativeScript = agent.runbook.script) {
788
- const script = path.resolve(RUNBOOK_DIR, relativeScript);
789
- const relative = path.relative(RUNBOOK_DIR, script);
790
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
791
- throw new Error(`Invalid runbook script path for ${agent.name}`);
792
- }
793
- return script;
794
- }
795
-
796
- /** Spawn a runbook script and mirror its exit status/signals. */
797
- function spawnRunbook(agent, args, env, relativeScript) {
798
- return new Promise((resolve, reject) => {
799
- const script = runbookScriptPath(agent, relativeScript);
800
- const child = spawn('bash', [script, ...args], { stdio: 'inherit', env });
801
-
802
- child.on('error', (error) => {
803
- if (error.code === 'ENOENT') {
804
- reject(
805
- new Error(
806
- 'These coding-agent integrations require `bash`, but it was not found on PATH.',
807
- ),
808
- );
809
- return;
810
- }
811
- reject(error);
812
- });
813
-
814
- child.on('exit', (code, signal) => {
815
- if (signal) {
816
- process.kill(process.pid, signal);
817
- return;
818
- }
819
- if (code) process.exitCode = code;
820
- resolve(code ?? 0);
821
- });
822
- });
823
- }
824
-
825
206
  function isSetupWithoutAuth(argv) {
826
207
  return ['status', 'uninstall', '-h', '--help', 'help'].includes(argv[0]);
827
208
  }
@@ -831,15 +212,8 @@ function optionValue(argv, name) {
831
212
  return index >= 0 ? argv[index + 1]?.trim() || null : null;
832
213
  }
833
214
 
834
- function agentApiKeySetting(agent) {
835
- return profileSettingsForAgent(agent.id).find(
836
- (setting) => setting.key !== 'API_KEY' && setting.key.endsWith('_API_KEY'),
837
- );
838
- }
839
-
840
215
  export async function getAgentApiKey(profile, agent) {
841
- const specificSetting = agentApiKeySetting(agent);
842
- const specificKey = specificSetting?.key;
216
+ const specificKey = agentKeyName(agent);
843
217
  const specificEnvKey = specificKey && process.env[specificKey]?.trim();
844
218
  if (specificEnvKey)
845
219
  return { key: specificEnvKey, source: `${specificKey} env var` };
@@ -871,113 +245,79 @@ async function requireApiKey(profile, agent) {
871
245
  return null;
872
246
  }
873
247
 
874
- const CLAUDE_MODEL_PICKER_KEYS = [
875
- 'ANTHROPIC_DEFAULT_OPUS_MODEL',
876
- 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME',
877
- 'ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION',
878
- 'ANTHROPIC_DEFAULT_SONNET_MODEL',
879
- 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME',
880
- 'ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION',
881
- 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
882
- 'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME',
883
- 'ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION',
884
- 'ANTHROPIC_DEFAULT_FABLE_MODEL',
885
- 'ANTHROPIC_DEFAULT_FABLE_MODEL_NAME',
886
- 'ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION',
887
- 'ANTHROPIC_CUSTOM_MODEL_OPTION',
888
- 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME',
889
- 'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION',
890
- ];
891
-
892
- const CLAUDE_MODEL_PICKER_ID_KEYS = [
893
- 'ANTHROPIC_DEFAULT_OPUS_MODEL',
894
- 'ANTHROPIC_DEFAULT_SONNET_MODEL',
895
- 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
896
- 'ANTHROPIC_DEFAULT_FABLE_MODEL',
897
- 'ANTHROPIC_CUSTOM_MODEL_OPTION',
898
- ];
899
-
900
- function uniqueCatalogModels(models, fallbackModel) {
901
- const unique = [];
902
- for (const model of models) {
903
- if (model && !unique.includes(model)) unique.push(model);
904
- }
905
- if (unique.length === 0 && fallbackModel) unique.push(fallbackModel);
906
- return unique;
907
- }
908
-
909
- export function claudePickerSettings(models, fallbackModel) {
910
- const unique = uniqueCatalogModels(models, fallbackModel);
911
- return {
912
- availableModels: unique,
913
- modelPicker: {
914
- replaceBuiltInOptions: true,
915
- options: unique.map((model) => ({
916
- model,
917
- label: model,
918
- description: `Subconscious model ${model}`,
919
- })),
248
+ export const RUNBOOK_ENV_DESCRIPTION = Object.freeze({
249
+ order:
250
+ 'Later layers win: Claude picker env (claude-code only), then profile values, then process.env, then the fixed values. For claude-code, picker slots whose value is not in the live catalog are then reset to the catalog. Each agent then applies its own inputs and env entries.',
251
+ inherited: [
252
+ {
253
+ source: 'profile',
254
+ description:
255
+ 'Every key in the selected profile env file, for example CODEX_CONTEXT_WINDOW.',
920
256
  },
921
- };
922
- }
923
-
924
- function claudeModelPickerEnv(agent, ctx, models) {
925
- if (agent.id !== 'claude-code') return {};
926
- // Only the live catalog belongs in Claude's picker. Packaged registry slots
927
- // (Haiku → DeepSeek, etc.) must not be unioned back in — that advertised
928
- // models the key cannot call.
929
- //
930
- // Pad through Fable so the built-in `fable` alias cannot resolve to Anthropic
931
- // Fable 5. The /model menu itself is replaced via claudePickerSettings.
932
- const pickerModels = uniqueCatalogModels(models, ctx.model);
933
- while (pickerModels.length < 4)
934
- pickerModels.push(pickerModels.at(-1) || ctx.model);
935
-
936
- const roles = ['OPUS', 'SONNET', 'HAIKU', 'FABLE'];
937
- const env = {};
938
- for (let index = 0; index < roles.length; index++) {
939
- const role = roles[index];
940
- const model = pickerModels[index];
941
- if (!model) continue;
942
- env[`ANTHROPIC_DEFAULT_${role}_MODEL`] = model;
943
- env[`ANTHROPIC_DEFAULT_${role}_MODEL_NAME`] = model;
944
- env[`ANTHROPIC_DEFAULT_${role}_MODEL_DESCRIPTION`] =
945
- `Subconscious model ${model}`;
946
- }
947
- const customModel = pickerModels[4];
948
- if (customModel) {
949
- env.ANTHROPIC_CUSTOM_MODEL_OPTION = customModel;
950
- env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = customModel;
951
- env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = `Subconscious model ${customModel}`;
952
- }
953
- return Object.fromEntries(
954
- CLAUDE_MODEL_PICKER_KEYS.map((key) => [key, env[key]]).filter(
955
- ([, value]) => value,
956
- ),
957
- );
958
- }
959
-
960
- function applyClaudePickerCatalog(env, picker, models) {
961
- if (Object.keys(picker).length === 0) return env;
962
- const allowed = new Set(models);
963
-
964
- for (const key of CLAUDE_MODEL_PICKER_KEYS) {
965
- if (!picker[key]) {
966
- delete env[key];
967
- }
968
- }
969
-
970
- for (const key of CLAUDE_MODEL_PICKER_ID_KEYS) {
971
- if (!picker[key] || !env[key] || allowed.has(env[key])) continue;
972
- env[key] = picker[key];
973
- const nameKey = `${key}_NAME`;
974
- const descriptionKey = `${key}_DESCRIPTION`;
975
- if (picker[nameKey]) env[nameKey] = picker[nameKey];
976
- if (picker[descriptionKey]) env[descriptionKey] = picker[descriptionKey];
977
- }
978
- return env;
979
- }
257
+ {
258
+ source: 'process.env',
259
+ description:
260
+ "The caller's whole environment. It overrides profile values.",
261
+ },
262
+ ],
263
+ fixed: [
264
+ {
265
+ name: 'GATEWAY_URL',
266
+ value: '{baseUrl}',
267
+ description:
268
+ 'SUBCONSCIOUS_BASE_URL, then profile GATEWAY_URL, then the packaged default. Trailing slashes are removed.',
269
+ },
270
+ { name: 'API_KEY', value: '{apiKey}', description: 'Gateway credential.' },
271
+ {
272
+ name: '<agent key>',
273
+ value: '{apiKey}',
274
+ description:
275
+ 'The agent-specific key input (for example CODEX_API_KEY), set to the same credential.',
276
+ },
277
+ {
278
+ name: 'MODEL',
279
+ value: '{model}',
280
+ description:
281
+ '--model, then SUBCONSCIOUS_MODEL, then profile MODEL; UNSET or blank uses the first catalog model. A profile or default model missing from a live catalog is replaced by the first live model.',
282
+ },
283
+ {
284
+ name: 'SUBCONSCIOUS_MODELS',
285
+ value: '{catalog}',
286
+ description: 'Newline-separated live model catalog.',
287
+ },
288
+ {
289
+ name: 'PATH',
290
+ value: '{binDir}:<install dirs>:{PATH}',
291
+ description:
292
+ "The caller's PATH with the agent's install directory and common install directories prepended.",
293
+ },
294
+ ],
295
+ runbook_scripts: [
296
+ {
297
+ name: 'SUBC_MODEL_IDS',
298
+ value: '{model} then {catalog}',
299
+ description: 'The launch model first, then the catalog, deduplicated.',
300
+ },
301
+ {
302
+ name: 'SUBC_VISION_MODELS',
303
+ value: 'model IDs',
304
+ description: 'Models that accept images, newline-separated.',
305
+ },
306
+ {
307
+ name: 'SUBC_TEMPLATE_WORDS',
308
+ value: 'count',
309
+ description:
310
+ 'How many leading argv words came from the argv template; only those may hold {tempFile}.',
311
+ },
312
+ ],
313
+ per_harness: { 'claude-code': CLAUDE_PICKER_ENV_DESCRIPTION },
314
+ });
980
315
 
316
+ /**
317
+ * The env every launch and setup starts from: profile values, then the
318
+ * caller's env, then the resolved gateway, key, model, and catalog. Claude
319
+ * Code also gets its model picker, kept inside the live catalog.
320
+ */
981
321
  export function runbookEnv(
982
322
  apiKey,
983
323
  model,
@@ -988,8 +328,9 @@ export function runbookEnv(
988
328
  ) {
989
329
  const ctx = buildContext(apiKey, model, profile);
990
330
  const extraDirs = [binDir, ...candidateBinDirs()].filter(Boolean);
991
- const specificApiKey = agentApiKeySetting(agent)?.key;
992
- const picker = claudeModelPickerEnv(agent, ctx, models);
331
+ const keyName = agentKeyName(agent);
332
+ const claude = agent.id === CLAUDE_CODE;
333
+ const picker = claude ? claudeModelPickerEnv(model, models) : {};
993
334
  return applyClaudePickerCatalog(
994
335
  {
995
336
  ...picker,
@@ -997,19 +338,16 @@ export function runbookEnv(
997
338
  ...process.env,
998
339
  GATEWAY_URL: ctx.baseUrl,
999
340
  API_KEY: apiKey,
1000
- ...(specificApiKey ? { [specificApiKey]: apiKey } : {}),
341
+ ...(keyName ? { [keyName]: apiKey } : {}),
1001
342
  MODEL: model,
1002
343
  SUBCONSCIOUS_MODELS: models.join('\n'),
1003
- ...(agent.id === 'claude-code'
344
+ ...(claude
1004
345
  ? {
1005
346
  SUBC_CLAUDE_SETTINGS: JSON.stringify(
1006
347
  claudePickerSettings(models, model),
1007
348
  ),
1008
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '0',
1009
- CLAUDE_CODE_AUTO_MODE_SERVER: '0',
1010
349
  }
1011
350
  : {}),
1012
- SUBC_ENV_FILE: os.devNull,
1013
351
  PATH: augmentPath(extraDirs, binDir),
1014
352
  },
1015
353
  picker,
@@ -1049,23 +387,15 @@ export function selectLaunchModel(requestedModel, modelSource, catalog) {
1049
387
  return useLiveDefault ? first : requestedModel;
1050
388
  }
1051
389
 
1052
- async function runRunbookSetup(
1053
- agent,
1054
- argv,
1055
- profile,
1056
- relativeScript = agent.runbook.script,
1057
- ) {
1058
- if (isSetupWithoutAuth(argv) || agent.runbook?.setupNeedsAuth === false) {
1059
- return spawnRunbook(
1060
- agent,
1061
- argv,
1062
- {
1063
- ...(profile?.values || {}),
1064
- ...process.env,
1065
- SUBC_ENV_FILE: os.devNull,
1066
- },
1067
- relativeScript,
1068
- );
390
+ async function runRunbookSetup(agent, argv, profile) {
391
+ const script = agent.runbook.setup_script;
392
+ if (isSetupWithoutAuth(argv) || agent.runbook.setup_needs_auth === false) {
393
+ // Status, uninstall, and Marathon's binary install read no model or
394
+ // agent setting, so an invalid saved one must not block them.
395
+ const lenient = { strict: false };
396
+ const base = { ...(profile?.values || {}), ...process.env };
397
+ const env = resolveInputs(agent, base, {}, lenient);
398
+ return runCommand(scriptCommand(agent, script, argv, env, lenient));
1069
399
  }
1070
400
 
1071
401
  const {
@@ -1087,29 +417,56 @@ async function runRunbookSetup(
1087
417
  ` ${c.yellow}Configured model ${requestedModel} is not in the live catalog; using ${model}.${c.reset}\n`,
1088
418
  );
1089
419
  }
1090
- const ctx = buildContext(apiKey, model, profile);
1091
- const authArgs = substitute(agent.runbook.authArgs || [], ctx);
1092
420
 
1093
421
  console.log(
1094
422
  ` ${c.dim}Configuring ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}for Subconscious (${model})${c.reset}\n`,
1095
423
  );
1096
- const code = await spawnRunbook(
424
+ // Setup scripts read no strict-choice setting; only the model list matters.
425
+ const env = resolveInputs(
1097
426
  agent,
1098
- [...authArgs, ...rest],
1099
427
  runbookEnv(apiKey, model, undefined, profile, agent, catalog.models),
1100
- relativeScript,
428
+ {},
429
+ { strict: false },
1101
430
  );
431
+ const code = await runCommand(scriptCommand(agent, script, rest, env));
1102
432
  const installed = !['status', 'uninstall'].includes(rest[0]);
1103
433
  if (code !== 0 || !installed) return code;
1104
434
 
1105
- if (agent.id === 'pi') {
1106
- console.log(
1107
- `\n ${c.dim}Start a fresh session with ${c.reset}${c.cyan}subc pi${c.reset}${c.dim}.${c.reset}\n`,
1108
- );
435
+ if (agent.runbook.after_install) {
436
+ console.log(`\n ${c.dim}${agent.runbook.after_install}${c.reset}\n`);
437
+ }
438
+ return code;
439
+ }
440
+
441
+ async function runSetupAction(agent, parsed, profile) {
442
+ if (!agent.runbook.setup_script) {
443
+ throw new Error(`No persistent integration is available for ${agent.name}`);
444
+ }
445
+ const setupArgs =
446
+ parsed.args[0] === parsed.action
447
+ ? parsed.args
448
+ : [parsed.action, ...parsed.args];
449
+ const code = await runRunbookSetup(agent, setupArgs, profile);
450
+ if (code === 0) {
451
+ const message =
452
+ parsed.action === 'status'
453
+ ? `${agent.name} status check complete.`
454
+ : parsed.action === 'uninstall'
455
+ ? `${agent.name} integration removed.`
456
+ : `${agent.name} setup complete.`;
457
+ console.log(`\n ${c.green}${c.bold}✓ ${message}${c.reset}\n`);
1109
458
  }
1110
459
  return code;
1111
460
  }
1112
461
 
462
+ /** Split `headless PROMPT args...` from the words after subc's own flags. */
463
+ function headlessParts(rest) {
464
+ const index = headlessPromptIndex(rest);
465
+ return index < 0
466
+ ? { args: rest, prompt: undefined }
467
+ : { args: rest.slice(index + 1), prompt: rest[index] };
468
+ }
469
+
1113
470
  /**
1114
471
  * Launch a coding agent against Subconscious. `argv` is everything after the
1115
472
  * agent name; unknown flags pass straight through to the underlying CLI.
@@ -1121,6 +478,13 @@ export async function runAgent(agent, argv, options = {}) {
1121
478
  return 0;
1122
479
  }
1123
480
 
481
+ const headless = isHeadlessRequest(agent, argv);
482
+ if (headless && !agent.launch.headless_platforms.includes(process.platform)) {
483
+ throw new Error(
484
+ `${agent.name} headless mode is not available on ${process.platform === 'win32' ? 'Windows' : process.platform} yet.`,
485
+ );
486
+ }
487
+
1124
488
  if (process.platform === 'win32') {
1125
489
  return runWindowsAgent(agent, argv, {
1126
490
  profile,
@@ -1131,50 +495,35 @@ export async function runAgent(agent, argv, options = {}) {
1131
495
  selectLaunchModel,
1132
496
  runbookEnv,
1133
497
  minimumClaudeVersion: MIN_CLAUDE_CODE_VERSION,
498
+ headless,
1134
499
  parseClaudeVersion,
1135
500
  claudeVersionNeedsUpgrade,
1136
501
  });
1137
502
  }
1138
503
 
1139
504
  const parsed = parseAgentAction(agent, argv);
1140
- if (parsed.action !== 'launch') {
1141
- if (!agent.runbook?.setupScript) {
1142
- throw new Error(
1143
- `No persistent integration is available for ${agent.name}`,
1144
- );
1145
- }
1146
- const setupArgs =
1147
- parsed.args[0] === parsed.action
1148
- ? parsed.args
1149
- : [parsed.action, ...parsed.args];
1150
- const code = await runRunbookSetup(
1151
- agent,
1152
- setupArgs,
1153
- profile,
1154
- agent.runbook.setupScript,
1155
- );
1156
- if (code === 0) {
1157
- const message =
1158
- parsed.action === 'status'
1159
- ? `${agent.name} status check complete.`
1160
- : parsed.action === 'uninstall'
1161
- ? `${agent.name} integration removed.`
1162
- : `${agent.name} setup complete.`;
1163
- console.log(`\n ${c.green}${c.bold}✓ ${message}${c.reset}\n`);
1164
- }
1165
- return code;
505
+ if (parsed.action !== 'launch' && !headless) {
506
+ return runSetupAction(agent, parsed, profile);
1166
507
  }
1167
508
 
509
+ return launchAgent(agent, argv, profile, headless);
510
+ }
511
+
512
+ async function launchAgent(agent, argv, profile, headless) {
513
+ // Arguments after -- belong to the agent, including its own --model.
514
+ const boundary = separatorIndex(argv);
1168
515
  const {
1169
516
  model: requestedModel,
1170
517
  modelSource,
1171
- rest,
1172
- } = extractModel(argv, profile);
518
+ rest: subcRest,
519
+ } = extractModel(boundary < 0 ? argv : argv.slice(0, boundary), profile);
520
+ const rest = boundary < 0 ? subcRest : [...subcRest, ...argv.slice(boundary)];
1173
521
  const apiKey = await requireApiKey(profile, agent);
1174
522
  if (!apiKey) return 1;
1175
523
 
1176
- const binDir = await ensureInstalled(agent);
1177
- await ensureClaudeCompatible(agent, binDir);
524
+ const mayPrompt = !headless;
525
+ const binDir = await ensureInstalled(agent, { mayPrompt });
526
+ await ensureMinimumVersion(agent, binDir, { mayPrompt });
1178
527
  const catalog = await resolvedModelsForLaunch(
1179
528
  profile,
1180
529
  apiKey,
@@ -1187,58 +536,13 @@ export async function runAgent(agent, argv, options = {}) {
1187
536
  );
1188
537
  }
1189
538
 
1190
- if (agent.runbook?.mode === 'launch') {
1191
- console.log(
1192
- ` ${c.dim}Launching ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}on Subconscious ${c.reset}${c.dim}(${model})${c.reset}\n`,
1193
- );
1194
- return spawnRunbook(
1195
- agent,
1196
- rest,
1197
- runbookEnv(apiKey, model, binDir, profile, agent, catalog.models),
1198
- );
1199
- }
1200
-
1201
- const ctx = buildContext(apiKey, model, profile);
1202
- const launch = substituteString(agent.launch, ctx);
1203
- const [bin, ...launchArgs] = launch.split(' ').filter(Boolean);
1204
- const envMap = substitute(agent.env, ctx);
1205
-
1206
- // Prepend the resolved bin dir + candidate dirs to the child's PATH so the
1207
- // agent (and any subprocess it spawns) resolves correctly this session, even
1208
- // if it was installed into a dir not yet on the parent shell's PATH.
1209
- const extraDirs = [binDir, ...candidateBinDirs()].filter(Boolean);
1210
- const env = {
1211
- ...envMap,
1212
- ...(profile?.values || {}),
1213
- ...process.env,
1214
- PATH: augmentPath(extraDirs, binDir),
1215
- };
1216
- const args = [...launchArgs, ...rest];
1217
-
1218
- console.log(
539
+ // Headless stdout belongs to the harness alone.
540
+ (headless ? console.error : console.log)(
1219
541
  ` ${c.dim}Launching ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}on Subconscious ${c.reset}${c.dim}(${model})${c.reset}\n`,
1220
542
  );
1221
-
1222
- const child = spawn(bin, args, { stdio: 'inherit', env });
1223
-
1224
- child.on('error', (err) => {
1225
- if (err.code === 'ENOENT') {
1226
- console.error(
1227
- `\n ${c.red}Could not launch \`${bin}\`.${c.reset} Install it with:\n`,
1228
- );
1229
- printInstallCommands(agent);
1230
- process.exit(127);
1231
- }
1232
- console.error(`\n ${c.red}${err.message}${c.reset}\n`);
1233
- process.exit(1);
1234
- });
1235
-
1236
- // Mirror the child's exit status so callers/scripts see the real result.
1237
- child.on('exit', (code, signal) => {
1238
- if (signal) {
1239
- process.kill(process.pid, signal);
1240
- return;
1241
- }
1242
- process.exit(code ?? 0);
1243
- });
543
+ const env = runbookEnv(apiKey, model, binDir, profile, agent, catalog.models);
544
+ // The headless endpoint is the one endpoint for every harness, Claude too.
545
+ const launchEnv = headless ? { ...env, CLAUDE_GATEWAY_URL: '' } : env;
546
+ const plan = launchPlan(agent, { env: launchEnv, ...headlessParts(rest) });
547
+ return runCommand(planCommand(agent, plan, binDir));
1244
548
  }