subconscious-cli 4.1.4 → 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
@@ -906,6 +906,7 @@ export function runbookEnv(
906
906
  ? {
907
907
  SUBC_CLAUDE_SETTINGS: JSON.stringify(claudePickerSettings(models, model)),
908
908
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '0',
909
+ CLAUDE_CODE_AUTO_MODE_SERVER: '0',
909
910
  }
910
911
  : {}),
911
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
+ }
@@ -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}"
@@ -55,6 +55,7 @@ set -euo pipefail
55
55
 
56
56
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
57
57
  source "${SCRIPT_DIR}/../model-capabilities.generated.sh"
58
+ source "${SCRIPT_DIR}/provider.sh"
58
59
 
59
60
  # Load shared env from SUBC_ENV_FILE, or a sibling .env / env.example.
60
61
  SHARED_ENV="${SUBC_ENV_FILE:-${SCRIPT_DIR}/../.env}"
@@ -147,19 +148,7 @@ while IFS= read -r model_id; do
147
148
  add_supported_model "$model_id"
148
149
  done <<< "${SUBCONSCIOUS_MODELS:-$DEFAULT_SUBCONSCIOUS_MODELS}"
149
150
 
150
- MODELS_JSON=""
151
- for model_id in "${SUPPORTED_MODELS[@]}"; do
152
- vision_fields=""
153
- if subc_model_supports_vision "$model_id"; then
154
- vision_fields=',"attachment":true,"modalities":{"input":["text","image"],"output":["text"]}'
155
- fi
156
- model_json="\"${model_id}\":{\"name\":\"${model_id}\",\"tools\":true,\"limit\":{\"context\":${CONTEXT_LIMIT},\"output\":${OUTPUT_LIMIT}}${vision_fields}}"
157
- if [[ -n "$MODELS_JSON" ]]; then
158
- MODELS_JSON="${MODELS_JSON},${model_json}"
159
- else
160
- MODELS_JSON="$model_json"
161
- fi
162
- done
151
+ subc_opencode_build_models_json
163
152
 
164
153
  OPENCODE_DIR="${HOME}/.opencode"
165
154
  OPENCODE_CONFIG="${OPENCODE_DIR}/opencode.json"
@@ -186,34 +175,21 @@ require_cmds() {
186
175
  write_config() {
187
176
  mkdir -p "$OPENCODE_DIR"
188
177
  local base_url="${GATEWAY_URL%/}/v1"
189
- local provider
190
- provider=$(cat <<EOF
191
- {
192
- "npm": "@ai-sdk/openai-compatible",
193
- "name": "Subconscious Gateway",
194
- "options": {
195
- "baseURL": "${base_url}",
196
- "apiKey": "{env:SUBCONSCIOUS_API_KEY}",
197
- "headers": {
198
- "x-subconscious-client": "opencode"
199
- }
200
- },
201
- "models": {${MODELS_JSON}}
202
- }
203
- EOF
204
- )
178
+ local provider selected_model
179
+ provider="$(subc_opencode_build_provider_json "$base_url")"
180
+ selected_model="${SUBC_OPENCODE_PROVIDER_ID}/${MODEL}"
205
181
  if [[ -f "$OPENCODE_CONFIG" ]]; then
206
182
  local tmp
207
183
  tmp="$(mktemp)"
208
- jq --argjson provider "$provider" --arg model "subconscious/${MODEL}" '
184
+ jq --argjson provider "$provider" --arg model "$selected_model" --arg provider_id "$SUBC_OPENCODE_PROVIDER_ID" '
209
185
  .provider = (.provider // {})
210
- | .provider.subconscious = $provider
186
+ | .provider[$provider_id] = $provider
211
187
  | .model = $model
212
188
  ' "$OPENCODE_CONFIG" >"$tmp"
213
189
  mv "$tmp" "$OPENCODE_CONFIG"
214
190
  else
215
- jq -n --argjson provider "$provider" --arg model "subconscious/${MODEL}" \
216
- '{ "$schema": "https://opencode.ai/config.json", provider: {subconscious: $provider}, model: $model }' \
191
+ jq -n --argjson provider "$provider" --arg model "$selected_model" --arg provider_id "$SUBC_OPENCODE_PROVIDER_ID" \
192
+ '{ "$schema": "https://opencode.ai/config.json", provider: {($provider_id): $provider}, model: $model }' \
217
193
  >"$OPENCODE_CONFIG"
218
194
  fi
219
195
  local env_file="${OPENCODE_DIR}/subconscious.env"
@@ -240,7 +216,7 @@ uninstall_config() {
240
216
  local tmp
241
217
  tmp="$(mktemp)"
242
218
  jq '
243
- del(.provider.subconscious)
219
+ del(.provider.subconscious, .provider["subconscious-cli"])
244
220
  | if ((.model // "") | tostring | startswith("subconscious/")) then del(.model) else . end
245
221
  ' "$OPENCODE_CONFIG" >"$tmp"
246
222
  mv "$tmp" "$OPENCODE_CONFIG"
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # Shared OpenCode provider metadata for subc launch and install paths.
3
+
4
+ # OpenCode groups /models entries by provider.name. Keep this stable and human
5
+ # readable.
6
+ SUBC_OPENCODE_PROVIDER_ID="subconscious"
7
+ SUBC_OPENCODE_PROVIDER_NAME="Subconscious Gateway"
8
+
9
+ subc_opencode_model_display_name() {
10
+ local slug="${1:-}"
11
+ slug="${slug#subconscious/}"
12
+ echo "$slug" | awk -F '[-_]' '
13
+ function format_token(token, lower) {
14
+ lower = tolower(token)
15
+ if (lower ~ /^(gpt|oss|api|gguf|ggml|nomic|vl|it|mlx)$/) return toupper(token)
16
+ if (token ~ /^[0-9]+[bBkKmMgG]$/) return toupper(token)
17
+ if (token ~ /^[qQ][0-9]+$/) return toupper(token)
18
+ if (token ~ /^[0-9]+\.[0-9]+/) return token
19
+ if (token ~ /^[A-Za-z][0-9]+[A-Za-z]$/ || token ~ /^[0-9]+[A-Za-z]$/) return toupper(token)
20
+ return toupper(substr(token, 1, 1)) tolower(substr(token, 2))
21
+ }
22
+ {
23
+ out = ""
24
+ for (i = 1; i <= NF; i++) {
25
+ if ($i == "") continue
26
+ out = out (out == "" ? "" : " ") format_token($i)
27
+ }
28
+ print out
29
+ }
30
+ '
31
+ }
32
+
33
+ subc_opencode_build_whitelist_json() {
34
+ local model_id
35
+ WHITELIST_JSON=""
36
+ for model_id in "${SUPPORTED_MODELS[@]}"; do
37
+ if [[ -n "$WHITELIST_JSON" ]]; then
38
+ WHITELIST_JSON="${WHITELIST_JSON},\"${model_id}\""
39
+ else
40
+ WHITELIST_JSON="\"${model_id}\""
41
+ fi
42
+ done
43
+ WHITELIST_JSON="[${WHITELIST_JSON}]"
44
+ }
45
+
46
+ subc_opencode_build_models_json() {
47
+ local model_id display_name vision_fields model_json
48
+ MODELS_JSON=""
49
+ for model_id in "${SUPPORTED_MODELS[@]}"; do
50
+ display_name="$(subc_opencode_model_display_name "$model_id")"
51
+ vision_fields=""
52
+ if subc_model_supports_vision "$model_id"; then
53
+ vision_fields=',"attachment":true,"modalities":{"input":["text","image"],"output":["text"]}'
54
+ fi
55
+ model_json="\"${model_id}\":{\"name\":\"${display_name}\",\"tools\":true,\"limit\":{\"context\":${CONTEXT_LIMIT},\"output\":${OUTPUT_LIMIT}}${vision_fields}}"
56
+ if [[ -n "$MODELS_JSON" ]]; then
57
+ MODELS_JSON="${MODELS_JSON},${model_json}"
58
+ else
59
+ MODELS_JSON="$model_json"
60
+ fi
61
+ done
62
+ subc_opencode_build_whitelist_json
63
+ }
64
+
65
+ subc_opencode_build_config_json() {
66
+ local base_url="${1:?base url required}"
67
+ local model="${2:?model required}"
68
+ cat <<EOF
69
+ {"\$schema":"https://opencode.ai/config.json","disabled_providers":["subconscious-cli"],"provider":{"${SUBC_OPENCODE_PROVIDER_ID}":{"npm":"@ai-sdk/openai-compatible","name":"${SUBC_OPENCODE_PROVIDER_NAME}","whitelist":${WHITELIST_JSON},"options":{"baseURL":"${base_url}","apiKey":"{env:SUBCONSCIOUS_API_KEY}","headers":{"x-subconscious-client":"opencode"},"modelsDiscovery":{"enabled":false}},"models":{${MODELS_JSON}}}},"model":"${SUBC_OPENCODE_PROVIDER_ID}/${model}"}
70
+ EOF
71
+ }
72
+
73
+ subc_opencode_build_provider_json() {
74
+ local base_url="${1:?base url required}"
75
+ cat <<EOF
76
+ {
77
+ "npm": "@ai-sdk/openai-compatible",
78
+ "name": "${SUBC_OPENCODE_PROVIDER_NAME}",
79
+ "whitelist": ${WHITELIST_JSON},
80
+ "options": {
81
+ "baseURL": "${base_url}",
82
+ "apiKey": "{env:SUBCONSCIOUS_API_KEY}",
83
+ "headers": {
84
+ "x-subconscious-client": "opencode"
85
+ },
86
+ "modelsDiscovery": {
87
+ "enabled": false
88
+ }
89
+ },
90
+ "models": {${MODELS_JSON}}
91
+ }
92
+ EOF
93
+ }
@@ -19,6 +19,7 @@ set -euo pipefail
19
19
 
20
20
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
21
  source "${SCRIPT_DIR}/../model-capabilities.generated.sh"
22
+ source "${SCRIPT_DIR}/provider.sh"
22
23
 
23
24
  # Load shared env from SUBC_ENV_FILE, or a sibling .env / env.example.
24
25
  SHARED_ENV="${SUBC_ENV_FILE:-${SCRIPT_DIR}/../.env}"
@@ -58,19 +59,7 @@ while IFS= read -r model_id; do
58
59
  add_supported_model "$model_id"
59
60
  done <<< "${SUBCONSCIOUS_MODELS:-$DEFAULT_SUBCONSCIOUS_MODELS}"
60
61
 
61
- MODELS_JSON=""
62
- for model_id in "${SUPPORTED_MODELS[@]}"; do
63
- vision_fields=""
64
- if subc_model_supports_vision "$model_id"; then
65
- vision_fields=',"attachment":true,"modalities":{"input":["text","image"],"output":["text"]}'
66
- fi
67
- model_json="\"${model_id}\":{\"name\":\"${model_id}\",\"tools\":true,\"limit\":{\"context\":${CONTEXT_LIMIT},\"output\":${OUTPUT_LIMIT}}${vision_fields}}"
68
- if [[ -n "$MODELS_JSON" ]]; then
69
- MODELS_JSON="${MODELS_JSON},${model_json}"
70
- else
71
- MODELS_JSON="$model_json"
72
- fi
73
- done
62
+ subc_opencode_build_models_json
74
63
 
75
64
  if [[ -z "$GATEWAY_URL" || -z "$API_KEY" ]]; then
76
65
  echo "error: GATEWAY_URL and API_KEY must be set in ../.env" >&2
@@ -96,15 +85,11 @@ if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then
96
85
  fi
97
86
 
98
87
  BASE_URL="${GATEWAY_URL%/}/v1"
99
- RUNTIME_PROVIDER_ID="subconscious-cli"
100
88
 
101
89
  export SUBCONSCIOUS_API_KEY="$API_KEY"
102
90
  # Compaction reporting needs the plugin on disk; this launch path writes nothing.
103
91
  export SUBCONSCIOUS_GATEWAY_URL="${GATEWAY_URL%/}"
104
- export OPENCODE_CONFIG_CONTENT=$(cat <<EOF
105
- {"\$schema":"https://opencode.ai/config.json","disabled_providers":["subconscious"],"provider":{"${RUNTIME_PROVIDER_ID}":{"npm":"@ai-sdk/openai-compatible","name":"Subconscious Gateway","options":{"baseURL":"${BASE_URL}","apiKey":"{env:SUBCONSCIOUS_API_KEY}","headers":{"x-subconscious-client":"opencode"}},"models":{${MODELS_JSON}}}},"model":"${RUNTIME_PROVIDER_ID}/${MODEL}"}
106
- EOF
107
- )
92
+ export OPENCODE_CONFIG_CONTENT="$(subc_opencode_build_config_json "$BASE_URL" "$MODEL")"
108
93
 
109
94
  # If sourced, just export env and return.
110
95
  if [[ "${BASH_SOURCE[0]:-$0}" != "${0}" ]]; then
package/bin/tui.js CHANGED
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url';
6
6
 
7
7
  import { agentList } from './agents.js';
8
8
  import { DEFAULT_PLATFORM_URL, getApiKey, getPlatformUrl } from './auth.js';
9
- import { resolveModelCatalog } from './models.js';
9
+ import { isAbortError, normalizeModelIds, resolveModelCatalog } from './models.js';
10
10
  import { discoverSessions, SESSION_HARNESSES } from './sessions.js';
11
+ import { compareVersions, fetchLatestVersion } from './update-check.js';
11
12
  import {
12
13
  DEFAULT_PROFILE,
13
14
  listProfiles,
@@ -47,27 +48,43 @@ async function pathExists(file) {
47
48
  }
48
49
  }
49
50
 
51
+ export async function tuiSourceIsNewerThan(binary, sourceFile = path.join(TUI_SOURCE_DIR, 'cmd/subc-tui/main.go')) {
52
+ try {
53
+ const [binaryStat, sourceStat] = await Promise.all([fs.stat(binary), fs.stat(sourceFile)]);
54
+ return sourceStat.mtimeMs > binaryStat.mtimeMs;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function sourceTuiCommand() {
61
+ return {
62
+ command: 'go',
63
+ args: ['run', './cmd/subc-tui'],
64
+ cwd: TUI_SOURCE_DIR,
65
+ };
66
+ }
67
+
50
68
  export async function resolveTuiExecutable(options = {}) {
51
69
  const override = options.binary || process.env.SUBC_TUI_BIN?.trim();
52
- if (override) return { command: override, args: [], cwd: undefined };
70
+ if (override) {
71
+ return { command: override, args: options.binaryArgs || [], cwd: undefined };
72
+ }
53
73
 
74
+ const hasSource = await pathExists(path.join(TUI_SOURCE_DIR, 'go.mod'));
54
75
  const target = nativeTargetName(options.platform, options.arch);
55
76
  if (target) {
56
77
  const packaged = path.join(BIN_DIR, 'native', target);
57
- if (await pathExists(packaged)) {
78
+ // Source checkouts keep a cached host binary for speed. If TUI source is
79
+ // newer, that cache is stale (for example it will reject --updates).
80
+ if (await pathExists(packaged) && !(hasSource && (await tuiSourceIsNewerThan(packaged)))) {
58
81
  return { command: packaged, args: [], cwd: undefined };
59
82
  }
60
83
  }
61
84
 
62
85
  // Source checkouts can run the TUI without committing native build output.
63
86
  // Published packages always contain a prebuilt platform binary.
64
- if (await pathExists(path.join(TUI_SOURCE_DIR, 'go.mod'))) {
65
- return {
66
- command: 'go',
67
- args: ['run', './cmd/subc-tui'],
68
- cwd: TUI_SOURCE_DIR,
69
- };
70
- }
87
+ if (hasSource) return sourceTuiCommand();
71
88
  return null;
72
89
  }
73
90
 
@@ -98,7 +115,71 @@ async function packageVersion() {
98
115
  return pkg.version;
99
116
  }
100
117
 
101
- export async function createTuiState(profileName = DEFAULT_PROFILE, options = {}) {
118
+ function serializeSessions(sessions = []) {
119
+ return sessions.map((session) => ({
120
+ key: session.key,
121
+ harness: session.harness,
122
+ harnessName: session.harnessName,
123
+ title: session.title,
124
+ cwd: session.cwd,
125
+ updatedAt: session.updatedAt,
126
+ model: session.model,
127
+ portable: session.portable,
128
+ }));
129
+ }
130
+
131
+ function sessionHarnessList() {
132
+ return Object.entries(SESSION_HARNESSES).map(([id, harness]) => ({
133
+ id,
134
+ name: harness.name,
135
+ portable: harness.portable,
136
+ }));
137
+ }
138
+
139
+ function agentMenuItems() {
140
+ return agentList().map((agent) => ({
141
+ command: agent.alias,
142
+ name: agent.name,
143
+ action: agent.action,
144
+ description: agent.description,
145
+ launch: agent.launch,
146
+ }));
147
+ }
148
+
149
+ const ATOMIC_REPLACE_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
150
+
151
+ function delay(ms) {
152
+ return new Promise((resolve) => setTimeout(resolve, ms));
153
+ }
154
+
155
+ async function replaceFile(tmp, file) {
156
+ try {
157
+ await fs.rename(tmp, file);
158
+ } catch (error) {
159
+ if (!ATOMIC_REPLACE_CODES.has(error.code)) throw error;
160
+ // Windows rejects a rename over a file another process still has open.
161
+ await fs.rm(file, { force: true });
162
+ await fs.rename(tmp, file);
163
+ }
164
+ }
165
+
166
+ export async function writeAtomicJson(file, value) {
167
+ const payload = `${JSON.stringify(value)}\n`;
168
+ for (let attempt = 0; attempt < 8; attempt++) {
169
+ const tmp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
170
+ try {
171
+ await fs.writeFile(tmp, payload, { mode: 0o600 });
172
+ await replaceFile(tmp, file);
173
+ return;
174
+ } catch (error) {
175
+ await fs.rm(tmp, { force: true }).catch(() => {});
176
+ if (!ATOMIC_REPLACE_CODES.has(error.code) || attempt === 7) throw error;
177
+ await delay(20 * (attempt + 1));
178
+ }
179
+ }
180
+ }
181
+
182
+ export async function createLocalTuiState(profileName = DEFAULT_PROFILE, options = {}) {
102
183
  const activeProfile = options.profile || (await loadProfile(profileName));
103
184
  const names = [...new Set([profileName, ...(await listProfiles())])].sort((a, b) => {
104
185
  if (a === profileName) return -1;
@@ -118,26 +199,17 @@ export async function createTuiState(profileName = DEFAULT_PROFILE, options = {}
118
199
  }),
119
200
  );
120
201
 
121
- const auth = await getApiKey(activeProfile);
122
202
  const requestedModel = selectedModelFor(activeProfile);
123
203
  const gatewayUrl = gatewayFor(activeProfile);
124
204
  const platformUrl = platformFor(activeProfile);
125
- const catalog = await resolveModelCatalog({
126
- baseUrl: gatewayUrl,
127
- apiKey: auth?.key,
128
- selectedModel: requestedModel,
129
- fallbackModels: PACKAGED_MODELS,
130
- });
131
- const selectedModel = requestedModel;
132
- const sessions = await discoverSessions({ max: options.maxSessions || 500 });
133
205
 
134
206
  return {
135
207
  version: await packageVersion(),
136
208
  activeProfile: profileName,
137
209
  profilePath: activeProfile.path,
138
210
  profiles,
139
- models: catalog.models,
140
- selectedModel,
211
+ models: normalizeModelIds([requestedModel, ...PACKAGED_MODELS], requestedModel),
212
+ selectedModel: requestedModel,
141
213
  subagentModel: subagentModelFor(activeProfile),
142
214
  gatewayUrl,
143
215
  savedGatewayUrl:
@@ -148,39 +220,138 @@ export async function createTuiState(profileName = DEFAULT_PROFILE, options = {}
148
220
  savedPlatformUrl:
149
221
  activeProfile.values.PLATFORM_URL?.trim().replace(/\/+$/, '') || DEFAULT_PLATFORM_URL,
150
222
  platformOverridden: Boolean(process.env.SUBCONSCIOUS_URL?.trim()),
151
- modelError: catalog.error?.message || '',
152
- modelSource: catalog.source,
153
- sessions: sessions.map((session) => ({
154
- key: session.key,
155
- harness: session.harness,
156
- harnessName: session.harnessName,
157
- title: session.title,
158
- cwd: session.cwd,
159
- updatedAt: session.updatedAt,
160
- model: session.model,
161
- portable: session.portable,
162
- })),
163
- sessionHarnesses: Object.entries(SESSION_HARNESSES).map(([id, harness]) => ({
164
- id,
165
- name: harness.name,
166
- portable: harness.portable,
167
- })),
168
- agents: agentList().map((agent) => ({
169
- command: agent.alias,
170
- name: agent.name,
171
- action: agent.action,
172
- description: agent.description,
173
- launch: agent.launch,
174
- })),
223
+ modelError: '',
224
+ modelSource: 'packaged',
225
+ modelsLoading: true,
226
+ sessionsLoading: true,
227
+ sessions: [],
228
+ sessionHarnesses: sessionHarnessList(),
229
+ agents: agentMenuItems(),
230
+ updateAvailable: false,
231
+ latestVersion: '',
175
232
  };
176
233
  }
177
234
 
235
+ function whenAborted(signal) {
236
+ return new Promise((_, reject) => {
237
+ const fail = () => {
238
+ const error = new Error('cancelled');
239
+ error.name = 'AbortError';
240
+ reject(error);
241
+ };
242
+ if (signal.aborted) {
243
+ fail();
244
+ return;
245
+ }
246
+ signal.addEventListener('abort', fail, { once: true });
247
+ });
248
+ }
249
+
250
+ async function raceAbort(signal, promise) {
251
+ if (!signal) return promise;
252
+ return Promise.race([promise, whenAborted(signal)]);
253
+ }
254
+
255
+ export async function loadRemoteTuiUpdates(state, options = {}) {
256
+ const signal = options.signal;
257
+ const writePatch = options.writePatch || (async () => {});
258
+ const resolveCatalog = options.resolveCatalog || resolveModelCatalog;
259
+ const discover = options.discoverSessions || discoverSessions;
260
+ const fetchLatest = options.fetchLatestVersion || fetchLatestVersion;
261
+ const readApiKey = options.getApiKey || getApiKey;
262
+ const updateDisabled =
263
+ options.disableUpdateCheck ?? process.env.SUBC_DISABLE_UPDATE_CHECK?.trim() === '1';
264
+
265
+ const tasks = [
266
+ (async () => {
267
+ const profile = options.profile || (await loadProfile(state.activeProfile));
268
+ if (signal?.aborted) return;
269
+ const auth = await readApiKey(profile);
270
+ if (signal?.aborted) return;
271
+ const catalog = await resolveCatalog({
272
+ baseUrl: state.gatewayUrl,
273
+ apiKey: auth?.key,
274
+ selectedModel: state.selectedModel,
275
+ fallbackModels: PACKAGED_MODELS,
276
+ fetchImpl: options.fetchImpl,
277
+ timeoutMs: options.timeoutMs,
278
+ signal,
279
+ });
280
+ if (signal?.aborted) return;
281
+ await writePatch({
282
+ models: catalog.models,
283
+ modelError: catalog.error?.message || '',
284
+ modelSource: catalog.source,
285
+ modelsLoading: false,
286
+ });
287
+ })(),
288
+ (async () => {
289
+ const sessions = await raceAbort(
290
+ signal,
291
+ discover({
292
+ max: options.maxSessions || 500,
293
+ home: options.home,
294
+ roots: options.roots,
295
+ indexes: options.indexes,
296
+ execute: options.execute,
297
+ signal,
298
+ }),
299
+ );
300
+ if (signal?.aborted) return;
301
+ await writePatch({
302
+ sessions: serializeSessions(sessions),
303
+ sessionsLoading: false,
304
+ });
305
+ })(),
306
+ ];
307
+
308
+ if (!updateDisabled) {
309
+ tasks.push(
310
+ (async () => {
311
+ try {
312
+ const latest = await fetchLatest({
313
+ fetchImpl: options.updateFetchImpl || options.fetchImpl,
314
+ timeoutMs: options.updateTimeoutMs,
315
+ signal,
316
+ });
317
+ if (signal?.aborted || !latest) return;
318
+ if (compareVersions(latest, state.version) > 0) {
319
+ await writePatch({ updateAvailable: true, latestVersion: latest });
320
+ }
321
+ } catch (error) {
322
+ if (isAbortError(error) || signal?.aborted) return;
323
+ }
324
+ })(),
325
+ );
326
+ }
327
+
328
+ const results = await Promise.allSettled(tasks);
329
+ for (const result of results) {
330
+ if (result.status !== 'rejected') continue;
331
+ if (isAbortError(result.reason) || signal?.aborted) continue;
332
+ throw result.reason;
333
+ }
334
+ }
335
+
336
+ export async function createTuiState(profileName = DEFAULT_PROFILE, options = {}) {
337
+ const state = await createLocalTuiState(profileName, options);
338
+ let next = { ...state };
339
+ await loadRemoteTuiUpdates(state, {
340
+ ...options,
341
+ writePatch: async (partial) => {
342
+ next = { ...next, ...partial };
343
+ },
344
+ });
345
+ return next;
346
+ }
347
+
178
348
  function spawnAndWait(command, args, options = {}) {
349
+ const spawnImpl = options.spawn || spawn;
179
350
  return new Promise((resolve, reject) => {
180
- const child = spawn(command, args, {
351
+ const child = spawnImpl(command, args, {
181
352
  cwd: options.cwd,
182
353
  env: process.env,
183
- stdio: 'inherit',
354
+ stdio: options.stdio ?? 'inherit',
184
355
  });
185
356
  child.once('error', reject);
186
357
  child.once('exit', (code, signal) => {
@@ -197,10 +368,28 @@ export async function runTui(options = {}) {
197
368
  const executable = await resolveTuiExecutable(options);
198
369
  if (!executable) return null;
199
370
 
200
- const state = options.state || (await createTuiState(options.profileName, options));
371
+ const providedState = options.state;
372
+ const state = providedState || (await createLocalTuiState(options.profileName, options));
201
373
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'subc-tui-'));
202
374
  const statePath = path.join(tempDir, 'state.json');
203
375
  const resultPath = path.join(tempDir, 'result.json');
376
+ const updatesPath = path.join(tempDir, 'updates.json');
377
+ const controller = new AbortController();
378
+
379
+ let patch = {};
380
+ const writePatch = async (partial) => {
381
+ if (controller.signal.aborted) return;
382
+ patch = { ...patch, ...partial };
383
+ try {
384
+ await writeAtomicJson(updatesPath, patch);
385
+ } catch {
386
+ // The TUI may have already exited and removed the working directory.
387
+ }
388
+ };
389
+
390
+ const remote = providedState
391
+ ? Promise.resolve()
392
+ : loadRemoteTuiUpdates(state, { ...options, signal: controller.signal, writePatch });
204
393
 
205
394
  try {
206
395
  // This state intentionally contains only display data. API keys are used
@@ -208,9 +397,11 @@ export async function runTui(options = {}) {
208
397
  await fs.writeFile(statePath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
209
398
  const code = await spawnAndWait(
210
399
  executable.command,
211
- [...executable.args, '--state', statePath, '--result', resultPath],
212
- { cwd: executable.cwd },
400
+ [...executable.args, '--state', statePath, '--result', resultPath, '--updates', updatesPath],
401
+ { cwd: executable.cwd, spawn: options.spawn, stdio: options.stdio },
213
402
  );
403
+ controller.abort();
404
+ await remote.catch(() => {});
214
405
  if (code !== 0) throw new Error(`Subconscious TUI exited with status ${code}`);
215
406
 
216
407
  try {
@@ -220,7 +411,12 @@ export async function runTui(options = {}) {
220
411
  if (error.code === 'ENOENT') return null;
221
412
  throw error;
222
413
  }
414
+ } catch (error) {
415
+ controller.abort();
416
+ await remote.catch(() => {});
417
+ throw error;
223
418
  } finally {
419
+ controller.abort();
224
420
  await fs.rm(tempDir, { recursive: true, force: true });
225
421
  }
226
422
  }
@@ -116,27 +116,48 @@ export async function currentVersion() {
116
116
  export async function fetchLatestVersion(options = {}) {
117
117
  const fetchImpl = options.fetchImpl || globalThis.fetch;
118
118
  const timeoutMs = options.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS;
119
+ const signal = options.signal;
119
120
  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable');
120
121
 
121
122
  const controller = new AbortController();
122
123
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
123
124
  timeout.unref?.();
125
+ const onAbort = () => controller.abort();
126
+ if (signal?.aborted) {
127
+ const error = new Error('Update check was cancelled');
128
+ error.name = 'AbortError';
129
+ clearTimeout(timeout);
130
+ throw error;
131
+ }
132
+ signal?.addEventListener('abort', onAbort, { once: true });
124
133
 
125
134
  try {
126
- const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
127
- headers: {
128
- Accept: 'application/json',
129
- 'Cache-Control': 'no-cache, no-store',
130
- Pragma: 'no-cache',
131
- },
132
- cache: 'no-store',
133
- signal: controller.signal,
135
+ const aborted = new Promise((_, reject) => {
136
+ const fail = () => {
137
+ const error = new Error(signal?.aborted ? 'Update check was cancelled' : 'Update check timed out');
138
+ error.name = 'AbortError';
139
+ reject(error);
140
+ };
141
+ controller.signal.addEventListener('abort', fail, { once: true });
134
142
  });
143
+ const response = await Promise.race([
144
+ fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
145
+ headers: {
146
+ Accept: 'application/json',
147
+ 'Cache-Control': 'no-cache, no-store',
148
+ Pragma: 'no-cache',
149
+ },
150
+ cache: 'no-store',
151
+ signal: controller.signal,
152
+ }),
153
+ aborted,
154
+ ]);
135
155
  if (!response.ok) throw new Error(`npm returned HTTP ${response.status}`);
136
156
  const payload = await response.json();
137
157
  if (!parseVersion(payload?.version)) throw new Error('npm returned an invalid version');
138
158
  return payload.version;
139
159
  } finally {
160
+ signal?.removeEventListener('abort', onAbort);
140
161
  clearTimeout(timeout);
141
162
  }
142
163
  }
package/bin/usage.js CHANGED
@@ -56,6 +56,33 @@ export function renderProgressBar(percentage, width = 20) {
56
56
  return `${color}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}`;
57
57
  }
58
58
 
59
+ /**
60
+ * Whole percent. Never rounds up to 100 while allowance remains — a meter
61
+ * that reads 100% claims the day is spent.
62
+ */
63
+ export function formatAllowancePercent(percent) {
64
+ const n = Number(percent);
65
+ if (!Number.isFinite(n) || n <= 0) return '0%';
66
+ const whole = Math.round(n);
67
+ if (n >= 100) return '100%';
68
+ if (whole >= 100) return '99%';
69
+ return `${whole}%`;
70
+ }
71
+
72
+ /** New percent payloads, or the pre-basis token shape (consumed / amount). */
73
+ export function describeDailyAllowance(allowance) {
74
+ if (!allowance || typeof allowance !== 'object') return null;
75
+ if (allowance.basis === 'unavailable') return { kind: 'unavailable' };
76
+ if (allowance.basis === 'credit' || allowance.basis === 'tokens') {
77
+ return { kind: allowance.basis, percent: Number(allowance.percent) || 0 };
78
+ }
79
+
80
+ const consumed = Number(allowance.consumed) || 0;
81
+ const amount = Number(allowance.amount) || 0;
82
+ if (amount <= 0) return null;
83
+ return { kind: 'tokens', percent: (consumed / amount) * 100 };
84
+ }
85
+
59
86
  export function formatUsageDisplay(data) {
60
87
  const lines = [];
61
88
  lines.push(`\n ${c.bold}Subconscious Usage${c.reset}\n`);
@@ -81,24 +108,29 @@ export function formatUsageDisplay(data) {
81
108
  lines.push('');
82
109
  lines.push(` ${c.dim}Daily allowance${c.reset}`);
83
110
  lines.push(` ${c.green}Unlimited${c.reset} ${c.dim}(comp)${c.reset}`);
84
- } else if (allowance && allowance.amount > 0) {
85
- const consumed = Number(allowance.consumed) || 0;
86
- const amount = Number(allowance.amount) || 0;
87
- const remaining = Number(allowance.remaining) || 0;
88
- const pct = amount > 0 ? Math.min((consumed / amount) * 100, 100) : 0;
89
- const overage = Math.max(0, consumed - amount);
90
-
91
- lines.push('');
92
- lines.push(` ${c.dim}Daily allowance${c.reset}`);
93
- lines.push(
94
- ` ${renderProgressBar(pct)} ${formatTokens(consumed)} / ${formatTokens(amount)} tokens`,
95
- );
96
- const resetNote = allowance.resetAt
97
- ? `${formatTokens(remaining)} remaining · resets midnight UTC`
98
- : `${formatTokens(remaining)} remaining`;
99
- lines.push(` ${c.dim}${resetNote}${c.reset}`);
100
- if (overage > 0) {
101
- lines.push(` ${c.yellow}+${formatTokens(overage)} over daily allowance${c.reset}`);
111
+ } else {
112
+ const described = describeDailyAllowance(allowance);
113
+ if (described) {
114
+ lines.push('');
115
+ lines.push(` ${c.dim}Daily allowance${c.reset}`);
116
+ if (described.kind === 'unavailable') {
117
+ lines.push(` ${c.dim}Daily credit unavailable${c.reset}`);
118
+ lines.push(
119
+ ` ${c.dim}Today's credit usage could not be read just now. Your plan and access are unaffected, and the figure returns on its own.${c.reset}`,
120
+ );
121
+ } else {
122
+ const label =
123
+ described.kind === 'credit' ? 'of daily credit used' : 'of daily tokens used';
124
+ lines.push(
125
+ ` ${renderProgressBar(described.percent)} ${formatAllowancePercent(described.percent)} ${label}`,
126
+ );
127
+ lines.push(` ${c.dim}Resets midnight UTC${c.reset}`);
128
+ if (described.kind === 'credit' && described.percent >= 100) {
129
+ lines.push(
130
+ ` ${c.yellow}Past today's allowance - further usage bills from your balance.${c.reset}`,
131
+ );
132
+ }
133
+ }
102
134
  }
103
135
  }
104
136
 
@@ -221,7 +253,7 @@ Usage:
221
253
  subc usage --json
222
254
  subc usage help
223
255
 
224
- Show billing mode, daily token allowance, credit balance, and per-model usage
256
+ Show billing mode, daily allowance, credit balance, and per-model usage
225
257
  for the authenticated organization.
226
258
 
227
259
  --json Print the raw platform JSON response
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { claudeNativeLaunch } from './claude.js';
6
+ import { buildOpenCodeConfig } from '../opencode-provider.js';
6
7
  import { modelSupportsVision } from '../model-capabilities.js';
7
8
  import { defaults, value, origin, modelIds, positiveInteger, parseOptions, writeJson } from './common.js';
8
9
 
@@ -26,16 +27,9 @@ export async function windowsLaunch(id, argv, env, { tempRoot = os.tmpdir() } =
26
27
  if (id === 'opencode') {
27
28
  const context = positiveInteger(value(env, 'OPENCODE_CONTEXT_LIMIT', '5000000'), 'OPENCODE_CONTEXT_LIMIT');
28
29
  const output = positiveInteger(value(env, 'OPENCODE_OUTPUT_LIMIT', '65536'), 'OPENCODE_OUTPUT_LIMIT');
29
- const models = Object.fromEntries(modelIds(env).map(id => [id, {
30
- name: id, tools: true, limit: { context, output },
31
- ...(modelSupportsVision(id) ? { attachment: true, modalities: { input: ['text', 'image'], output: ['text'] } } : {}),
32
- }]));
33
- childEnv.OPENCODE_CONFIG_CONTENT = JSON.stringify({
34
- $schema: 'https://opencode.ai/config.json', disabled_providers: ['subconscious'],
35
- provider: { 'subconscious-cli': { npm: '@ai-sdk/openai-compatible', name: 'Subconscious Gateway', options: {
36
- baseURL: `${base}/v1`, apiKey: '{env:SUBCONSCIOUS_API_KEY}', headers: { 'x-subconscious-client': 'opencode' },
37
- }, models } }, model: `subconscious-cli/${model}`,
38
- });
30
+ childEnv.OPENCODE_CONFIG_CONTENT = JSON.stringify(
31
+ buildOpenCodeConfig({ baseUrl: base, model, modelIds: modelIds(env), context, output }),
32
+ );
39
33
  return { command: 'opencode', args: parseOptions(argv, {}).rest, env: childEnv };
40
34
  }
41
35
  if (id === 'codex') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subconscious-cli",
3
- "version": "4.1.4",
3
+ "version": "4.2.0",
4
4
  "description": "CLI for Subconscious — run Subconscious Code, Claude Code, Codex, OpenCode, DeepSeek Harness, Cursor, Copilot, and Pi",
5
5
  "bin": {
6
6
  "subc": "bin/cli.js"