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