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 +1 -0
- package/bin/cli.js +13 -3
- package/bin/models.js +51 -9
- package/bin/native/subc-tui-darwin-amd64 +0 -0
- package/bin/native/subc-tui-darwin-arm64 +0 -0
- package/bin/native/subc-tui-linux-amd64 +0 -0
- package/bin/native/subc-tui-linux-arm64 +0 -0
- package/bin/native/subc-tui-windows-amd64.exe +0 -0
- package/bin/native/subc-tui-windows-arm64.exe +0 -0
- package/bin/opencode-provider.js +61 -0
- package/bin/runbook/claude-code/run.sh +5 -0
- package/bin/runbook/opencode/install.sh +10 -34
- package/bin/runbook/opencode/provider.sh +93 -0
- package/bin/runbook/opencode/run.sh +3 -18
- package/bin/tui.js +247 -51
- package/bin/update-check.js +29 -8
- package/bin/usage.js +51 -19
- package/bin/windows/launch.js +4 -10
- package/package.json +1 -1
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
|
|
284
|
-
|
|
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 (
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 "
|
|
184
|
+
jq --argjson provider "$provider" --arg model "$selected_model" --arg provider_id "$SUBC_OPENCODE_PROVIDER_ID" '
|
|
209
185
|
.provider = (.provider // {})
|
|
210
|
-
| .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 "
|
|
216
|
-
'{ "$schema": "https://opencode.ai/config.json", provider: {
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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:
|
|
152
|
-
modelSource:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 =
|
|
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
|
|
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
|
}
|
package/bin/update-check.js
CHANGED
|
@@ -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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
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
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
package/bin/windows/launch.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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