subconscious-cli 4.1.3 → 4.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.
package/bin/agents.js CHANGED
@@ -32,6 +32,15 @@ import { compareVersions } from './update-check.js';
32
32
 
33
33
  export const MIN_CLAUDE_CODE_VERSION = '2.1.242';
34
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
+
35
44
  // --- Registry (single source of truth, generated copy shipped in the package).
36
45
  const registry = JSON.parse(
37
46
  readFileSync(new URL('./registry.generated.json', import.meta.url), 'utf-8'),
@@ -193,10 +202,10 @@ const AGENT_HELP = {
193
202
  codex: {
194
203
  usage: 'subc [-p NAME] codex [help|install|status|uninstall] [Codex arguments...]',
195
204
  behavior:
196
- 'Launches Codex with a temporary Subconscious provider catalog. Compaction hooks are merged into ~/.codex/hooks.json and removed with subc codex uninstall.',
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.',
197
206
  options: [
198
207
  ['help', 'Show this help'],
199
- ['install', 'Install only the Subconscious compaction hooks'],
208
+ ['install', 'Install the Subconscious compaction hooks (then trust with /hooks)'],
200
209
  ['status', 'Inspect the installed compaction hooks'],
201
210
  ['uninstall', 'Remove only the Subconscious Codex hooks'],
202
211
  ['--model MODEL', 'Override the profile model for this launch'],
@@ -205,7 +214,9 @@ const AGENT_HELP = {
205
214
  ['--auto-compact-token-limit N', 'Override the automatic compaction threshold'],
206
215
  ['--reasoning-effort LEVEL', 'Use none, low, medium, high, or max'],
207
216
  ['--external-tools', 'Enable Codex apps/plugins for this launch'],
208
- ['--subagents', 'Use the pinned legacy Codex subagent mode'],
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'],
209
220
  ['-- ARGS...', 'Pass remaining arguments to Codex'],
210
221
  ],
211
222
  },
@@ -625,6 +636,10 @@ export function parseClaudeVersion(text) {
625
636
  }
626
637
 
627
638
  export function claudeVersionNeedsUpgrade(installed, minimum = MIN_CLAUDE_CODE_VERSION) {
639
+ return versionNeedsUpgrade(installed, minimum);
640
+ }
641
+
642
+ export function versionNeedsUpgrade(installed, minimum) {
628
643
  return Boolean(installed && minimum && compareVersions(installed, minimum) < 0);
629
644
  }
630
645
 
@@ -649,13 +664,20 @@ export function readClaudeVersion(bin, binDir, options = {}) {
649
664
  }
650
665
  }
651
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
+
652
673
  async function ensureClaudeCompatible(agent, binDir) {
653
- if (agent.id !== 'claude-code') return;
674
+ const requirement = MINIMUM_VERSIONS[agent.id];
675
+ if (!requirement) return;
654
676
  const version = readClaudeVersion(agent.bin, binDir);
655
- if (!claudeVersionNeedsUpgrade(version)) return;
677
+ if (!versionNeedsUpgrade(version, requirement.minimum)) return;
656
678
 
657
679
  console.error(
658
- `\n Minimum supported Claude Code version is ${MIN_CLAUDE_CODE_VERSION}. Your version is ${version}. Upgrade to get the best experience.\n`,
680
+ `\n Minimum supported ${requirement.label} version is ${requirement.minimum}. Your version is ${version}. Upgrade to get the best experience.\n`,
659
681
  );
660
682
  console.error(` Upgrade it with:`);
661
683
  console.error(` ${c.cyan}${agent.install}${c.reset}`);
@@ -884,6 +906,7 @@ export function runbookEnv(
884
906
  ? {
885
907
  SUBC_CLAUDE_SETTINGS: JSON.stringify(claudePickerSettings(models, model)),
886
908
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '0',
909
+ CLAUDE_CODE_AUTO_MODE_SERVER: '0',
887
910
  }
888
911
  : {}),
889
912
  SUBC_ENV_FILE: os.devNull,
package/bin/cli.js CHANGED
@@ -280,10 +280,20 @@ async function main() {
280
280
  return;
281
281
  }
282
282
 
283
- const update = await showUpdateNotice();
284
- if (update?.action === 'updated' || update?.action === 'cancel') return;
283
+ const launchTui =
284
+ !command &&
285
+ process.stdin.isTTY === true &&
286
+ process.stdout.isTTY === true &&
287
+ process.env.TERM !== 'dumb';
288
+
289
+ // The TUI opens immediately and surfaces catalog, session, and upgrade
290
+ // status itself. Keep the blocking npm prompt for explicit commands.
291
+ if (!launchTui) {
292
+ const update = await showUpdateNotice();
293
+ if (update?.action === 'updated' || update?.action === 'cancel') return;
294
+ }
285
295
 
286
- if (!command && process.stdin.isTTY === true && process.stdout.isTTY === true && process.env.TERM !== 'dumb') {
296
+ if (launchTui) {
287
297
  const selection = await runTui({ profileName });
288
298
  if (!selection?.args?.length) return;
289
299
  if (selection.baseUrl?.trim()) {
package/bin/models.js CHANGED
@@ -18,6 +18,27 @@ export function isLiveModelSource(source) {
18
18
  return source === 'available' || source === 'public';
19
19
  }
20
20
 
21
+ export function isAbortError(error) {
22
+ return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
23
+ }
24
+
25
+ function linkAbortSignal(controller, signal) {
26
+ if (!signal) return () => {};
27
+ if (signal.aborted) {
28
+ controller.abort();
29
+ return () => {};
30
+ }
31
+ const onAbort = () => controller.abort();
32
+ signal.addEventListener('abort', onAbort, { once: true });
33
+ return () => signal.removeEventListener('abort', onAbort);
34
+ }
35
+
36
+ function abortError(message) {
37
+ const error = new Error(message);
38
+ error.name = 'AbortError';
39
+ return error;
40
+ }
41
+
21
42
  export function normalizeModelIds(modelIds = [], selectedModel) {
22
43
  const models = [];
23
44
  const seen = new Set();
@@ -53,6 +74,7 @@ export async function fetchGatewayModels({
53
74
  path = PUBLIC_MODELS_PATH,
54
75
  fetchImpl = globalThis.fetch,
55
76
  timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
77
+ signal,
56
78
  }) {
57
79
  if (typeof fetchImpl !== 'function') {
58
80
  throw new Error('This Node.js version does not support fetch');
@@ -71,14 +93,26 @@ export async function fetchGatewayModels({
71
93
  const controller = new AbortController();
72
94
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
73
95
  timeout.unref?.();
96
+ const unlink = linkAbortSignal(controller, signal);
74
97
 
75
98
  try {
76
- const response = await fetchImpl(endpoint, {
77
- method: 'GET',
78
- headers,
79
- cache: 'no-store',
80
- signal: controller.signal,
99
+ if (signal?.aborted) throw abortError('Model discovery was cancelled');
100
+ const aborted = new Promise((_, reject) => {
101
+ const fail = () => {
102
+ if (signal?.aborted) reject(abortError('Model discovery was cancelled'));
103
+ else reject(new Error(`Model discovery timed out after ${timeoutMs}ms`));
104
+ };
105
+ controller.signal.addEventListener('abort', fail, { once: true });
81
106
  });
107
+ const response = await Promise.race([
108
+ fetchImpl(endpoint, {
109
+ method: 'GET',
110
+ headers,
111
+ cache: 'no-store',
112
+ signal: controller.signal,
113
+ }),
114
+ aborted,
115
+ ]);
82
116
 
83
117
  if (!response.ok) {
84
118
  throw new Error(`Model discovery returned HTTP ${response.status}`);
@@ -91,21 +125,24 @@ export async function fetchGatewayModels({
91
125
 
92
126
  return normalizeModelIds(payload.data.map((model) => model?.id));
93
127
  } catch (error) {
128
+ if (signal?.aborted) throw abortError('Model discovery was cancelled');
94
129
  if (controller.signal.aborted) {
95
130
  throw new Error(`Model discovery timed out after ${timeoutMs}ms`);
96
131
  }
97
132
  throw error;
98
133
  } finally {
134
+ unlink();
99
135
  clearTimeout(timeout);
100
136
  }
101
137
  }
102
138
 
103
- async function fetchPublicModels({ baseUrl, fetchImpl, timeoutMs }) {
139
+ async function fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal }) {
104
140
  const models = await fetchGatewayModels({
105
141
  baseUrl,
106
142
  path: PUBLIC_MODELS_PATH,
107
143
  fetchImpl,
108
144
  timeoutMs,
145
+ signal,
109
146
  });
110
147
  if (!models.length) throw new Error('Model discovery returned no usable model IDs');
111
148
  return models;
@@ -134,6 +171,7 @@ export async function resolveModelCatalog({
134
171
  fallbackModels = [],
135
172
  fetchImpl = globalThis.fetch,
136
173
  timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
174
+ signal,
137
175
  }) {
138
176
  const key = apiKey?.trim() || '';
139
177
 
@@ -145,22 +183,26 @@ export async function resolveModelCatalog({
145
183
  path: AVAILABLE_MODELS_PATH,
146
184
  fetchImpl,
147
185
  timeoutMs,
186
+ signal,
148
187
  });
149
188
  return liveCatalog(discovered, selectedModel, 'available');
150
189
  } catch (availableError) {
190
+ if (isAbortError(availableError) || signal?.aborted) throw availableError;
151
191
  try {
152
- const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs });
192
+ const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal });
153
193
  return liveCatalog(discovered, selectedModel, 'public');
154
- } catch {
194
+ } catch (publicError) {
195
+ if (isAbortError(publicError) || signal?.aborted) throw publicError;
155
196
  return packagedCatalog(selectedModel, fallbackModels, availableError);
156
197
  }
157
198
  }
158
199
  }
159
200
 
160
201
  try {
161
- const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs });
202
+ const discovered = await fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal });
162
203
  return liveCatalog(discovered, selectedModel, 'public');
163
204
  } catch (error) {
205
+ if (isAbortError(error) || signal?.aborted) throw error;
164
206
  return packagedCatalog(selectedModel, fallbackModels, error);
165
207
  }
166
208
  }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,61 @@
1
+ import { modelSupportsVision } from './model-capabilities.js';
2
+
3
+ export const OPENCODE_PROVIDER_ID = 'subconscious';
4
+ export const OPENCODE_PROVIDER_NAME = 'Subconscious Gateway';
5
+
6
+ const ACRONYMS = new Set(['gpt', 'oss', 'api', 'gguf', 'ggml', 'nomic', 'vl', 'it', 'mlx']);
7
+
8
+ function formatToken(token) {
9
+ const lower = token.toLowerCase();
10
+ if (ACRONYMS.has(lower)) return token.toUpperCase();
11
+ if (/^\d+[bkmg]$/i.test(token)) return token.toUpperCase();
12
+ if (/^q\d+$/i.test(token)) return token.toUpperCase();
13
+ if (/^\d+\.\d+/.test(token)) return token;
14
+ if (/^[a-z]\d+[a-z]$/i.test(token) || /^\d+[a-z]$/i.test(token)) return token.toUpperCase();
15
+ return token.charAt(0).toUpperCase() + token.slice(1).toLowerCase();
16
+ }
17
+
18
+ /** Drop the subconscious/ prefix, then capitalize each dash-separated token. */
19
+ export function opencodeModelDisplayName(modelId) {
20
+ const text = String(modelId ?? '').trim();
21
+ const slug = text.startsWith('subconscious/') ? text.slice('subconscious/'.length) : text;
22
+ return slug.split(/[-_]/).filter(Boolean).map(formatToken).join(' ');
23
+ }
24
+
25
+ export function buildOpenCodeModels(modelIds, { context, output }) {
26
+ return Object.fromEntries(
27
+ modelIds.map((id) => [
28
+ id,
29
+ {
30
+ name: opencodeModelDisplayName(id),
31
+ tools: true,
32
+ limit: { context, output },
33
+ ...(modelSupportsVision(id)
34
+ ? { attachment: true, modalities: { input: ['text', 'image'], output: ['text'] } }
35
+ : {}),
36
+ },
37
+ ]),
38
+ );
39
+ }
40
+
41
+ export function buildOpenCodeConfig({ baseUrl, model, modelIds, context, output }) {
42
+ return {
43
+ $schema: 'https://opencode.ai/config.json',
44
+ disabled_providers: ['subconscious-cli'],
45
+ provider: {
46
+ [OPENCODE_PROVIDER_ID]: {
47
+ npm: '@ai-sdk/openai-compatible',
48
+ name: OPENCODE_PROVIDER_NAME,
49
+ whitelist: [...modelIds],
50
+ options: {
51
+ baseURL: `${baseUrl}/v1`,
52
+ apiKey: '{env:SUBCONSCIOUS_API_KEY}',
53
+ headers: { 'x-subconscious-client': 'opencode' },
54
+ modelsDiscovery: { enabled: false },
55
+ },
56
+ models: buildOpenCodeModels(modelIds, { context, output }),
57
+ },
58
+ },
59
+ model: `${OPENCODE_PROVIDER_ID}/${model}`,
60
+ };
61
+ }
package/bin/profiles.js CHANGED
@@ -144,6 +144,28 @@ const SETTINGS = {
144
144
  type: 'integer',
145
145
  min: 1,
146
146
  },
147
+ CODEX_SUBAGENT_REASONING_EFFORT: {
148
+ key: 'CODEX_SUBAGENT_REASONING_EFFORT',
149
+ label: 'Subagent reasoning effort',
150
+ description:
151
+ 'Effort for Codex subagents, set separately from the parent: a marathon model at max thinks for minutes without emitting a token, and a subagent turn is one uninterrupted think, so inheriting max makes every delegated subtask look like a hang.',
152
+ type: 'text',
153
+ },
154
+ CODEX_MULTI_AGENT_VERSION: {
155
+ key: 'CODEX_MULTI_AGENT_VERSION',
156
+ label: 'Codex multi-agent version',
157
+ description:
158
+ 'Which subagent implementation Codex offers: v2 gives the collaboration tools. Leave empty to turn subagents off entirely.',
159
+ type: 'text',
160
+ },
161
+ CODEX_STREAM_IDLE_TIMEOUT_MS: {
162
+ key: 'CODEX_STREAM_IDLE_TIMEOUT_MS',
163
+ label: 'Stream idle timeout (ms)',
164
+ description:
165
+ 'How long Codex waits on a silent stream before giving up. Needs to exceed the longest think the model will take.',
166
+ type: 'integer',
167
+ min: 1,
168
+ },
147
169
  MAX_SUBAGENT_SPAWN_DEPTH: {
148
170
  key: 'MAX_SUBAGENT_SPAWN_DEPTH',
149
171
  label: 'Subagent spawn depth',
@@ -305,6 +327,9 @@ const AGENT_SETTING_KEYS = {
305
327
  'CODEX_AUTO_COMPACT_TOKEN_LIMIT',
306
328
  'CODEX_REASONING_EFFORT',
307
329
  'CODEX_EXTERNAL_TOOLS',
330
+ 'CODEX_STREAM_IDLE_TIMEOUT_MS',
331
+ 'CODEX_MULTI_AGENT_VERSION',
332
+ 'CODEX_SUBAGENT_REASONING_EFFORT',
308
333
  'MAX_CONCURRENT_SUBAGENTS',
309
334
  ],
310
335
  opencode: ['OPENCODE_API_KEY', 'OPENCODE_CONTEXT_LIMIT', 'OPENCODE_OUTPUT_LIMIT'],
@@ -167,7 +167,7 @@
167
167
  "uninstall"
168
168
  ]
169
169
  },
170
- "launch": "codex -c model={model} -c model_provider=subconscious -c web_search=disabled -c model_providers.subconscious.name=Subconscious -c model_providers.subconscious.base_url={baseUrlV1} -c model_providers.subconscious.wire_api=responses -c model_providers.subconscious.env_key=SUBCONSCIOUS_API_KEY -c model_providers.subconscious.stream_idle_timeout_ms=300000",
170
+ "launch": "codex -c model={model} -c model_provider=subconscious -c web_search=disabled -c model_providers.subconscious.name=Subconscious -c model_providers.subconscious.base_url={baseUrlV1} -c model_providers.subconscious.wire_api=responses -c model_providers.subconscious.env_key=SUBCONSCIOUS_API_KEY -c model_providers.subconscious.stream_idle_timeout_ms=900000",
171
171
  "env": {
172
172
  "SUBCONSCIOUS_API_KEY": "{apiKey}",
173
173
  "SUBCONSCIOUS_GATEWAY_URL": "{baseUrl}"
@@ -216,6 +216,11 @@ export ANTHROPIC_DEFAULT_FABLE_MODEL="${ANTHROPIC_DEFAULT_FABLE_MODEL:-$FABLE_MO
216
216
  export ANTHROPIC_DEFAULT_FABLE_MODEL_NAME="${ANTHROPIC_DEFAULT_FABLE_MODEL_NAME:-$FABLE_MODEL_NAME}"
217
217
  export ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION="${ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION:-$FABLE_MODEL_DESCRIPTION}"
218
218
  export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY="${CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY:-0}"
219
+ # Gateway upstreams can't perform Anthropic's server-side auto mode classifier
220
+ # checks; opting out avoids the "isn't eligible for no-charge classifier
221
+ # requests" fallback and keeps classifier requests billed locally.
222
+ # https://code.claude.com/docs/en/auto-mode-classifier-billing
223
+ export CLAUDE_CODE_AUTO_MODE_SERVER="${CLAUDE_CODE_AUTO_MODE_SERVER:-0}"
219
224
  export CLAUDE_CODE_SUBAGENT_MODEL="${CLAUDE_CODE_SUBAGENT_MODEL:-$MODEL}"
220
225
  export CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS="${MAX_CONCURRENT_SUBAGENTS}"
221
226
  export CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH="${MAX_SUBAGENT_SPAWN_DEPTH}"
@@ -61,6 +61,30 @@ EOF
61
61
  chmod 600 "$HOOKS_ENV_FILE"
62
62
  }
63
63
 
64
+ # True when our hooks, hook script and env file are all already in place and
65
+ # current, so there is nothing to write.
66
+ #
67
+ # This matters more than it looks. Codex asks the user to trust a hook before
68
+ # it will run one, and it decides what is "new" from what is on disk. Rewriting
69
+ # an identical hook on every single launch meant the trust prompt never went
70
+ # away and the compaction hooks never actually ran - we were re-introducing
71
+ # ourselves as a stranger several times a day.
72
+ codex_hooks_current() {
73
+ [[ -f "$HOOKS_JSON" ]] || return 1
74
+ codex_hooks_json_valid || return 1
75
+ [[ -x "$HOOK_DST" ]] || return 1
76
+ [[ -f "${HOOK_SRC:-}" ]] || return 1
77
+ cmp -s "$HOOK_SRC" "$HOOK_DST" || return 1
78
+ [[ -f "$HOOKS_ENV_FILE" ]] || return 1
79
+ grep -q "SUBCONSCIOUS_GATEWAY_URL='${GATEWAY_URL%/}'" "$HOOKS_ENV_FILE" || return 1
80
+ grep -q "SUBCONSCIOUS_API_KEY='${API_KEY}'" "$HOOKS_ENV_FILE" || return 1
81
+ # Our entry present under both compaction events, and exactly once each.
82
+ jq -e --arg m "$MARKER" '
83
+ def ours: [ .[]?.hooks[]? | select((.command // "") | tostring | contains($m)) ] | length;
84
+ (.hooks.PreCompact | ours) == 1 and (.hooks.PostCompact | ours) == 1
85
+ ' "$HOOKS_JSON" >/dev/null 2>&1
86
+ }
87
+
64
88
  # codex_ensure_hooks [strict|best-effort]
65
89
  codex_ensure_hooks() {
66
90
  local mode="${1:-strict}"
@@ -91,12 +115,18 @@ codex_ensure_hooks() {
91
115
  if [[ ! -f "$HOOKS_JSON" ]]; then
92
116
  printf '%s\n' '{"hooks":{}}' >"$HOOKS_JSON"
93
117
  fi
118
+ if codex_hooks_current; then
119
+ # Already installed and unchanged. Saying nothing is the point: a message
120
+ # on every launch trains the user to ignore it, and rewriting the file
121
+ # would reset the trust they have already granted.
122
+ return 0
123
+ fi
94
124
  codex_write_hook_script
95
125
  codex_write_hooks_env
96
126
  codex_strip_hook_entries
97
127
  codex_append_hook_groups
98
- echo "Merged Subconscious Codex hooks into $HOOKS_JSON"
99
- echo "Trust new hooks in Codex with /hooks (or --dangerously-bypass-hook-trust)."
128
+ echo "Installed Subconscious compaction hooks in $HOOKS_JSON"
129
+ echo "These do not run until you trust them: type /hooks inside Codex once."
100
130
  }
101
131
 
102
132
  codex_uninstall_hooks() {
@@ -33,6 +33,16 @@ Usage:
33
33
 
34
34
  Merges Subconscious compaction hooks into ~/.codex/hooks.json. Does not write
35
35
  config.toml. Launch Codex with subc codex.
36
+
37
+ The hooks report compaction start and end to the gateway, which is what lets a
38
+ conversation's turns stay correlated across a compaction boundary.
39
+
40
+ Codex will not run a hook until you trust it. After installing, type /hooks
41
+ inside Codex once and approve them; until then they are present but inert.
42
+ Non-interactive runs have nobody to ask, so `codex exec` can only run them with
43
+ --dangerously-bypass-hook-trust, which is yours to opt into and is never passed
44
+ for you. Fleet installs should ship the hooks through Codex's managed
45
+ (MDM) configuration, which is trusted without prompting.
36
46
  EOF
37
47
  }
38
48