subconscious-cli 6.0.1 → 6.2.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 +167 -43
  2. package/bin/agent-data.js +278 -0
  3. package/bin/agent-help.js +114 -0
  4. package/bin/agent-install.js +238 -0
  5. package/bin/agent-launch.js +199 -0
  6. package/bin/agent-spawn.js +137 -0
  7. package/bin/agents.js +236 -931
  8. package/bin/auth.js +9 -1
  9. package/bin/claude-picker.js +143 -0
  10. package/bin/cli.js +62 -2
  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 +25 -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 +77 -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 +462 -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 +186 -0
  35. package/bin/runbook/copilot/install.sh +45 -63
  36. package/bin/runbook/cursor/agent.json +138 -0
  37. package/bin/runbook/cursor/install.sh +8 -35
  38. package/bin/runbook/deepseek-harness/agent.json +164 -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 +159 -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 +217 -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 +57 -0
  53. package/bin/sessions.js +53 -48
  54. package/bin/tui.js +13 -0
  55. package/bin/update-check.js +35 -1
  56. package/bin/upgrade.js +27 -34
  57. package/bin/usage.js +21 -10
  58. package/bin/windows/agents.js +28 -60
  59. package/bin/windows/common.js +2 -28
  60. package/bin/windows/launch.js +252 -231
  61. package/bin/windows/process.js +12 -1
  62. package/bin/windows/setup.js +29 -9
  63. package/package.json +2 -4
  64. package/bin/registry.generated.json +0 -406
  65. package/bin/runbook/claude-code/run.sh +0 -254
  66. package/bin/runbook/model-capabilities.generated.sh +0 -9
  67. package/bin/runbook/opencode/provider.sh +0 -93
  68. package/bin/runbook/opencode/run.sh +0 -99
  69. package/bin/runbook/opencode/subconscious-compaction.ts +0 -100
  70. package/bin/runbook/subconscious-code/install.sh +0 -154
  71. package/bin/runbook/subconscious-code/run.sh +0 -21
  72. package/bin/windows/claude.js +0 -100
  73. package/bin/windows/install.js +0 -188
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,80 @@ 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:
287
+ 'Newline-separated model catalog. Set by the caller, it pins the catalog and skips live discovery; otherwise it is the live catalog.',
288
+ },
289
+ {
290
+ name: 'PATH',
291
+ value: '{binDir}:<install dirs>:{PATH}',
292
+ description:
293
+ "The caller's PATH with the agent's install directory and common install directories prepended.",
294
+ },
295
+ ],
296
+ runbook_scripts: [
297
+ {
298
+ name: 'SUBC_MODEL_IDS',
299
+ value: '{model} then {catalog}',
300
+ description: 'The launch model first, then the catalog, deduplicated.',
301
+ },
302
+ {
303
+ name: 'SUBC_VISION_MODELS',
304
+ value: 'model IDs',
305
+ description: 'Models that accept images, newline-separated.',
306
+ },
307
+ {
308
+ name: 'SUBC_TEMPLATE_WORDS',
309
+ value: 'count',
310
+ description:
311
+ 'How many leading argv words came from the argv template; only those may hold {tempFile}.',
312
+ },
313
+ ],
314
+ per_harness: { 'claude-code': CLAUDE_PICKER_ENV_DESCRIPTION },
315
+ });
980
316
 
317
+ /**
318
+ * The env every launch and setup starts from: profile values, then the
319
+ * caller's env, then the resolved gateway, key, model, and catalog. Claude
320
+ * Code also gets its model picker, kept inside the live catalog.
321
+ */
981
322
  export function runbookEnv(
982
323
  apiKey,
983
324
  model,
@@ -988,8 +329,9 @@ export function runbookEnv(
988
329
  ) {
989
330
  const ctx = buildContext(apiKey, model, profile);
990
331
  const extraDirs = [binDir, ...candidateBinDirs()].filter(Boolean);
991
- const specificApiKey = agentApiKeySetting(agent)?.key;
992
- const picker = claudeModelPickerEnv(agent, ctx, models);
332
+ const keyName = agentKeyName(agent);
333
+ const claude = agent.id === CLAUDE_CODE;
334
+ const picker = claude ? claudeModelPickerEnv(model, models) : {};
993
335
  return applyClaudePickerCatalog(
994
336
  {
995
337
  ...picker,
@@ -997,19 +339,16 @@ export function runbookEnv(
997
339
  ...process.env,
998
340
  GATEWAY_URL: ctx.baseUrl,
999
341
  API_KEY: apiKey,
1000
- ...(specificApiKey ? { [specificApiKey]: apiKey } : {}),
342
+ ...(keyName ? { [keyName]: apiKey } : {}),
1001
343
  MODEL: model,
1002
344
  SUBCONSCIOUS_MODELS: models.join('\n'),
1003
- ...(agent.id === 'claude-code'
345
+ ...(claude
1004
346
  ? {
1005
347
  SUBC_CLAUDE_SETTINGS: JSON.stringify(
1006
348
  claudePickerSettings(models, model),
1007
349
  ),
1008
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '0',
1009
- CLAUDE_CODE_AUTO_MODE_SERVER: '0',
1010
350
  }
1011
351
  : {}),
1012
- SUBC_ENV_FILE: os.devNull,
1013
352
  PATH: augmentPath(extraDirs, binDir),
1014
353
  },
1015
354
  picker,
@@ -1024,6 +363,7 @@ async function resolvedModelsForLaunch(profile, apiKey, selectedModel) {
1024
363
  apiKey,
1025
364
  selectedModel,
1026
365
  fallbackModels: PACKAGED_MODELS,
366
+ pinnedModels: process.env.SUBCONSCIOUS_MODELS,
1027
367
  });
1028
368
  if (catalog.source === 'public' && apiKey) {
1029
369
  console.error(` ${c.dim}${PUBLIC_CATALOG_FALLBACK_MESSAGE}${c.reset}\n`);
@@ -1049,23 +389,15 @@ export function selectLaunchModel(requestedModel, modelSource, catalog) {
1049
389
  return useLiveDefault ? first : requestedModel;
1050
390
  }
1051
391
 
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
- );
392
+ async function runRunbookSetup(agent, argv, profile) {
393
+ const script = agent.runbook.setup_script;
394
+ if (isSetupWithoutAuth(argv) || agent.runbook.setup_needs_auth === false) {
395
+ // Status and uninstall read no model or
396
+ // agent setting, so an invalid saved one must not block them.
397
+ const lenient = { strict: false };
398
+ const base = { ...(profile?.values || {}), ...process.env };
399
+ const env = resolveInputs(agent, base, {}, lenient);
400
+ return runCommand(scriptCommand(agent, script, argv, env, lenient));
1069
401
  }
1070
402
 
1071
403
  const {
@@ -1087,29 +419,56 @@ async function runRunbookSetup(
1087
419
  ` ${c.yellow}Configured model ${requestedModel} is not in the live catalog; using ${model}.${c.reset}\n`,
1088
420
  );
1089
421
  }
1090
- const ctx = buildContext(apiKey, model, profile);
1091
- const authArgs = substitute(agent.runbook.authArgs || [], ctx);
1092
422
 
1093
423
  console.log(
1094
424
  ` ${c.dim}Configuring ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}for Subconscious (${model})${c.reset}\n`,
1095
425
  );
1096
- const code = await spawnRunbook(
426
+ // Setup scripts read no strict-choice setting; only the model list matters.
427
+ const env = resolveInputs(
1097
428
  agent,
1098
- [...authArgs, ...rest],
1099
429
  runbookEnv(apiKey, model, undefined, profile, agent, catalog.models),
1100
- relativeScript,
430
+ {},
431
+ { strict: false },
1101
432
  );
433
+ const code = await runCommand(scriptCommand(agent, script, rest, env));
1102
434
  const installed = !['status', 'uninstall'].includes(rest[0]);
1103
435
  if (code !== 0 || !installed) return code;
1104
436
 
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
- );
437
+ if (agent.runbook.after_install) {
438
+ console.log(`\n ${c.dim}${agent.runbook.after_install}${c.reset}\n`);
439
+ }
440
+ return code;
441
+ }
442
+
443
+ async function runSetupAction(agent, parsed, profile) {
444
+ if (!agent.runbook.setup_script) {
445
+ throw new Error(`No persistent integration is available for ${agent.name}`);
446
+ }
447
+ const setupArgs =
448
+ parsed.args[0] === parsed.action
449
+ ? parsed.args
450
+ : [parsed.action, ...parsed.args];
451
+ const code = await runRunbookSetup(agent, setupArgs, profile);
452
+ if (code === 0) {
453
+ const message =
454
+ parsed.action === 'status'
455
+ ? `${agent.name} status check complete.`
456
+ : parsed.action === 'uninstall'
457
+ ? `${agent.name} integration removed.`
458
+ : `${agent.name} setup complete.`;
459
+ console.log(`\n ${c.green}${c.bold}✓ ${message}${c.reset}\n`);
1109
460
  }
1110
461
  return code;
1111
462
  }
1112
463
 
464
+ /** Split `headless PROMPT args...` from the words after subc's own flags. */
465
+ function headlessParts(rest) {
466
+ const index = headlessPromptIndex(rest);
467
+ return index < 0
468
+ ? { args: rest, prompt: undefined }
469
+ : { args: rest.slice(index + 1), prompt: rest[index] };
470
+ }
471
+
1113
472
  /**
1114
473
  * Launch a coding agent against Subconscious. `argv` is everything after the
1115
474
  * agent name; unknown flags pass straight through to the underlying CLI.
@@ -1121,6 +480,13 @@ export async function runAgent(agent, argv, options = {}) {
1121
480
  return 0;
1122
481
  }
1123
482
 
483
+ const headless = isHeadlessRequest(agent, argv);
484
+ if (headless && !agent.launch.headless_platforms.includes(process.platform)) {
485
+ throw new Error(
486
+ `${agent.name} headless mode is not available on ${process.platform === 'win32' ? 'Windows' : process.platform} yet.`,
487
+ );
488
+ }
489
+
1124
490
  if (process.platform === 'win32') {
1125
491
  return runWindowsAgent(agent, argv, {
1126
492
  profile,
@@ -1131,50 +497,34 @@ export async function runAgent(agent, argv, options = {}) {
1131
497
  selectLaunchModel,
1132
498
  runbookEnv,
1133
499
  minimumClaudeVersion: MIN_CLAUDE_CODE_VERSION,
500
+ headless,
1134
501
  parseClaudeVersion,
1135
502
  claudeVersionNeedsUpgrade,
1136
503
  });
1137
504
  }
1138
505
 
1139
506
  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;
507
+ if (parsed.action !== 'launch' && !headless) {
508
+ return runSetupAction(agent, parsed, profile);
1166
509
  }
1167
510
 
511
+ return launchAgent(agent, argv, profile, headless);
512
+ }
513
+
514
+ async function launchAgent(agent, argv, profile, headless) {
515
+ // Arguments after -- belong to the agent, including its own --model.
516
+ const boundary = separatorIndex(argv);
1168
517
  const {
1169
518
  model: requestedModel,
1170
519
  modelSource,
1171
- rest,
1172
- } = extractModel(argv, profile);
520
+ rest: subcRest,
521
+ } = extractModel(boundary < 0 ? argv : argv.slice(0, boundary), profile);
522
+ const rest = boundary < 0 ? subcRest : [...subcRest, ...argv.slice(boundary)];
1173
523
  const apiKey = await requireApiKey(profile, agent);
1174
524
  if (!apiKey) return 1;
1175
525
 
1176
526
  const binDir = await ensureInstalled(agent);
1177
- await ensureClaudeCompatible(agent, binDir);
527
+ await ensureMinimumVersion(agent, binDir, { mayPrompt: !headless });
1178
528
  const catalog = await resolvedModelsForLaunch(
1179
529
  profile,
1180
530
  apiKey,
@@ -1187,58 +537,13 @@ export async function runAgent(agent, argv, options = {}) {
1187
537
  );
1188
538
  }
1189
539
 
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(
540
+ // Headless stdout belongs to the harness alone.
541
+ (headless ? console.error : console.log)(
1219
542
  ` ${c.dim}Launching ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}on Subconscious ${c.reset}${c.dim}(${model})${c.reset}\n`,
1220
543
  );
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
- });
544
+ const env = runbookEnv(apiKey, model, binDir, profile, agent, catalog.models);
545
+ // The headless endpoint is the one endpoint for every harness, Claude too.
546
+ const launchEnv = headless ? { ...env, CLAUDE_GATEWAY_URL: '' } : env;
547
+ const plan = launchPlan(agent, { env: launchEnv, ...headlessParts(rest) });
548
+ return runCommand(planCommand(agent, plan, binDir));
1244
549
  }