subconscious-cli 7.0.0 → 8.0.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.
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { RUNBOOK_DIR } from './agent-data.js';
5
5
  import { modelIds } from './launch-values.js';
6
- import { visionModelList } from './model-capabilities.js';
6
+ import { visionModelListFromMetadata } from './model-capabilities.js';
7
7
 
8
8
  /** Resolve a script inside the agent's own runbook directory. */
9
9
  export function runbookScriptPath(agent, relativeScript) {
@@ -95,7 +95,7 @@ export function scriptEnv(env, { strict = true } = {}) {
95
95
  return {
96
96
  ...env,
97
97
  ...(ids === undefined ? {} : { SUBC_MODEL_IDS: ids }),
98
- SUBC_VISION_MODELS: visionModelList(),
98
+ SUBC_VISION_MODELS: visionModelListFromMetadata(env.SUBC_MODEL_METADATA),
99
99
  };
100
100
  }
101
101
 
package/bin/agents.js CHANGED
@@ -327,6 +327,7 @@ export function runbookEnv(
327
327
  profile,
328
328
  agent,
329
329
  models = PACKAGED_MODELS,
330
+ entries = [],
330
331
  ) {
331
332
  const ctx = buildContext(apiKey, model, profile);
332
333
  const extraDirs = [binDir, ...candidateBinDirs()].filter(Boolean);
@@ -343,6 +344,7 @@ export function runbookEnv(
343
344
  ...(keyName ? { [keyName]: apiKey } : {}),
344
345
  MODEL: model,
345
346
  SUBCONSCIOUS_MODELS: models.join('\n'),
347
+ SUBC_MODEL_METADATA: JSON.stringify(entries),
346
348
  ...(claude
347
349
  ? {
348
350
  SUBC_CLAUDE_SETTINGS: JSON.stringify(
@@ -378,7 +380,11 @@ async function resolvedModelsForLaunch(profile, apiKey, selectedModel) {
378
380
  }
379
381
 
380
382
  export function selectLaunchModel(requestedModel, modelSource, catalog) {
381
- const first = catalog.models[0] || requestedModel || DEFAULTS.model;
383
+ const first =
384
+ catalog.defaultModels?.[0] ||
385
+ catalog.models[0] ||
386
+ requestedModel ||
387
+ DEFAULTS.model;
382
388
  if (modelSource === 'catalog' || !requestedModel) {
383
389
  return first;
384
390
  }
@@ -427,7 +433,15 @@ async function runRunbookSetup(agent, argv, profile) {
427
433
  // Setup scripts read no strict-choice setting; only the model list matters.
428
434
  const env = resolveInputs(
429
435
  agent,
430
- runbookEnv(apiKey, model, undefined, profile, agent, catalog.models),
436
+ runbookEnv(
437
+ apiKey,
438
+ model,
439
+ undefined,
440
+ profile,
441
+ agent,
442
+ catalog.defaultModels,
443
+ catalog.entries,
444
+ ),
431
445
  {},
432
446
  { strict: false },
433
447
  );
@@ -542,7 +556,15 @@ async function launchAgent(agent, argv, profile, headless) {
542
556
  (headless ? console.error : console.log)(
543
557
  ` ${c.dim}Launching ${c.reset}${c.bold}${agent.name}${c.reset} ${c.dim}on Subconscious ${c.reset}${c.dim}(${model})${c.reset}\n`,
544
558
  );
545
- const env = runbookEnv(apiKey, model, binDir, profile, agent, catalog.models);
559
+ const env = runbookEnv(
560
+ apiKey,
561
+ model,
562
+ binDir,
563
+ profile,
564
+ agent,
565
+ catalog.defaultModels,
566
+ catalog.entries,
567
+ );
546
568
  // The headless endpoint is the one endpoint for every harness, Claude too.
547
569
  const launchEnv = headless ? { ...env, CLAUDE_GATEWAY_URL: '' } : env;
548
570
  const plan = launchPlan(agent, { env: launchEnv, ...headlessParts(rest) });
package/bin/cli.js CHANGED
@@ -542,6 +542,7 @@ async function main() {
542
542
  });
543
543
  modelsCommand(catalog.models, {
544
544
  selectedModel,
545
+ entries: catalog.entries,
545
546
  error: catalog.error,
546
547
  source: catalog.source,
547
548
  hasApiKey: Boolean(auth?.key),
@@ -1,8 +1,16 @@
1
1
  import { MODEL_CAPABILITIES } from './agent-data.js';
2
2
 
3
- // The models endpoint supplies IDs, not modality metadata, so capabilities
4
- // are listed by exact model ID: similarly named models inherit nothing.
5
- export function modelSupportsVision(modelId) {
3
+ // Capabilities are listed by exact model ID: similarly named models inherit
4
+ // nothing. The canonical catalog's per-model modality metadata is preferred
5
+ // when a launch carries it; the packaged map is the offline fallback.
6
+ export function modelSupportsVision(modelId, entries = []) {
7
+ const entry = entries.find((candidate) => candidate?.id === modelId);
8
+ if (entry?.modalities) {
9
+ const input = Array.isArray(entry.modalities.input)
10
+ ? entry.modalities.input
11
+ : [];
12
+ return input.includes('image');
13
+ }
6
14
  return (
7
15
  Object.hasOwn(MODEL_CAPABILITIES, modelId) &&
8
16
  MODEL_CAPABILITIES[modelId].vision === true
@@ -10,6 +18,38 @@ export function modelSupportsVision(modelId) {
10
18
  }
11
19
 
12
20
  /** Vision model IDs, newline-separated, for the runbooks' shell lookup. */
13
- export function visionModelList() {
14
- return Object.keys(MODEL_CAPABILITIES).filter(modelSupportsVision).join('\n');
21
+ export function visionModelList(entries = []) {
22
+ const ids = entries.length
23
+ ? entries
24
+ .map((entry) => entry.id)
25
+ .filter((id) => modelSupportsVision(id, entries))
26
+ : Object.keys(MODEL_CAPABILITIES).filter((id) =>
27
+ modelSupportsVision(id, entries),
28
+ );
29
+ return ids.join('\n');
30
+ }
31
+
32
+ /**
33
+ * Canonical catalog entries from the launch env's SUBC_MODEL_METADATA (a
34
+ * JSON array), or an empty list when discovery did not run, was skipped,
35
+ * or wrote malformed JSON.
36
+ */
37
+ export function catalogEntriesFromMetadata(metadataJson) {
38
+ if (typeof metadataJson !== 'string' || !metadataJson.trim()) return [];
39
+ try {
40
+ const entries = JSON.parse(metadataJson);
41
+ return Array.isArray(entries) ? entries : [];
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The vision list for a runbook script, from the launch env's catalog
49
+ * metadata with the packaged map as the fallback when discovery did not
50
+ * run or failed.
51
+ */
52
+ export function visionModelListFromMetadata(metadataJson) {
53
+ const entries = catalogEntriesFromMetadata(metadataJson);
54
+ return entries.length ? visionModelList(entries) : visionModelList();
15
55
  }
package/bin/models.js CHANGED
@@ -1,23 +1,24 @@
1
1
  /**
2
2
  * Resolve the model catalog exposed by an OpenAI-compatible gateway.
3
3
  *
4
- * When a profile (or agent) API key is present, prefer the key-scoped
5
- * /v1/models/available list. If that request fails, fall back to the public
6
- * /v1/models fleet catalog. Launches keep working with the packaged registry
7
- * when the gateway is offline or returns nothing usable.
4
+ * A key-scoped GET /v1/models is the canonical source: the gateway filters
5
+ * it by the key's model access and describes each model (lifecycle, limits,
6
+ * modalities, capabilities). Older gateways without the canonical catalog
7
+ * answer with a minimal list, which still names the fleet. Launches keep
8
+ * working with the packaged registry when the gateway is offline or returns
9
+ * nothing usable.
8
10
  */
9
11
 
10
12
  import { proxyAwareFetch } from './network.js';
11
13
 
12
14
  const MODEL_ID_PATTERN = /^[-A-Za-z0-9._:/+]+$/;
13
15
  export const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 3000;
14
- export const AVAILABLE_MODELS_PATH = '/v1/models/available';
15
16
  export const PUBLIC_MODELS_PATH = '/v1/models';
16
17
  export const PUBLIC_CATALOG_FALLBACK_MESSAGE =
17
18
  'Provisioned catalog unavailable; showing the public model list.';
18
19
 
19
20
  export function isLiveModelSource(source) {
20
- return source === 'available' || source === 'public';
21
+ return source === 'catalog' || source === 'public';
21
22
  }
22
23
 
23
24
  export function isAbortError(error) {
@@ -60,6 +61,34 @@ export function normalizeModelIds(modelIds = [], selectedModel) {
60
61
  return models;
61
62
  }
62
63
 
64
+ /**
65
+ * Rich entry fields from a canonical /v1/models response: lifecycle
66
+ * (status, deprecation_date, replacement), limits, modalities, and
67
+ * capabilities. Minimal legacy responses produce entries that carry only
68
+ * the id; every optional field is absent, never guessed.
69
+ */
70
+ export function parseCatalogEntries(data = []) {
71
+ const entries = [];
72
+ for (const model of data) {
73
+ if (model == null || typeof model !== 'object') continue;
74
+ const id = typeof model.id === 'string' ? model.id.trim() : '';
75
+ if (!id || !MODEL_ID_PATTERN.test(id)) continue;
76
+ entries.push({
77
+ id,
78
+ lifecycle: model.lifecycle ?? null,
79
+ limits: model.limits ?? null,
80
+ modalities: model.modalities ?? null,
81
+ capabilities: model.capabilities ?? null,
82
+ });
83
+ }
84
+ return entries;
85
+ }
86
+
87
+ /** True when the response is the canonical catalog, not a minimal list. */
88
+ function isCanonicalResponse(data = []) {
89
+ return data.some((model) => model?.schema_version === '1');
90
+ }
91
+
63
92
  function gatewayOrigin(baseUrl) {
64
93
  if (!baseUrl?.trim()) throw new Error('Gateway URL is not configured');
65
94
  return baseUrl.trim().replace(/\/+$/, '');
@@ -70,12 +99,12 @@ function modelsEndpoint(baseUrl, path) {
70
99
  return `${gatewayOrigin(baseUrl)}${normalizedPath}`;
71
100
  }
72
101
 
73
- export async function fetchGatewayModels({
102
+ async function fetchModelsPayload({
74
103
  baseUrl,
75
104
  apiKey,
76
- path = PUBLIC_MODELS_PATH,
77
- fetchImpl = proxyAwareFetch,
78
- timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
105
+ path,
106
+ fetchImpl,
107
+ timeoutMs,
79
108
  signal,
80
109
  }) {
81
110
  if (typeof fetchImpl !== 'function') {
@@ -125,7 +154,7 @@ export async function fetchGatewayModels({
125
154
  throw new Error('Model discovery returned an invalid response');
126
155
  }
127
156
 
128
- return normalizeModelIds(payload.data.map((model) => model?.id));
157
+ return payload.data;
129
158
  };
130
159
  return await Promise.race([discover(), aborted]);
131
160
  } catch (error) {
@@ -140,16 +169,35 @@ export async function fetchGatewayModels({
140
169
  }
141
170
  }
142
171
 
143
- async function fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal }) {
144
- const models = await fetchGatewayModels({
172
+ /**
173
+ * Fetch one models listing. Returns the id list, the parsed catalog
174
+ * entries, and whether the gateway answered with the canonical catalog.
175
+ */
176
+ export async function fetchGatewayCatalog({
177
+ baseUrl,
178
+ apiKey,
179
+ path = PUBLIC_MODELS_PATH,
180
+ fetchImpl = proxyAwareFetch,
181
+ timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
182
+ signal,
183
+ }) {
184
+ const data = await fetchModelsPayload({
145
185
  baseUrl,
146
- path: PUBLIC_MODELS_PATH,
186
+ apiKey,
187
+ path,
147
188
  fetchImpl,
148
189
  timeoutMs,
149
190
  signal,
150
191
  });
192
+ const entries = parseCatalogEntries(data);
193
+ const models = normalizeModelIds(entries.map((entry) => entry.id));
151
194
  if (!models.length)
152
195
  throw new Error('Model discovery returned no usable model IDs');
196
+ return { models, entries, canonical: isCanonicalResponse(data) };
197
+ }
198
+
199
+ export async function fetchGatewayModels(args) {
200
+ const { models } = await fetchGatewayCatalog(args);
153
201
  return models;
154
202
  }
155
203
 
@@ -159,14 +207,33 @@ function packagedCatalog(selectedModel, fallbackModels, error) {
159
207
  [selectedModel, ...fallbackModels],
160
208
  selectedModel,
161
209
  ),
210
+ defaultModels: normalizeModelIds(
211
+ [selectedModel, ...fallbackModels],
212
+ selectedModel,
213
+ ),
214
+ entries: [],
162
215
  source: 'packaged',
163
216
  error,
164
217
  };
165
218
  }
166
219
 
167
- function liveCatalog(models, selectedModel, source) {
220
+ /**
221
+ * Assemble the catalog views. `models` is everything the caller can invoke,
222
+ * deprecated models included. `defaultModels` drops models the gateway
223
+ * marked deprecated or retired, so new sessions pick a current model while
224
+ * existing configurations that name a deprecated model keep working.
225
+ */
226
+ function liveCatalog(models, selectedModel, source, entries = []) {
227
+ const normalized = normalizeModelIds(models, selectedModel);
228
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]));
229
+ const defaults = normalized.filter((id) => {
230
+ const status = entryById.get(id)?.lifecycle?.status;
231
+ return status !== 'deprecated' && status !== 'retired';
232
+ });
168
233
  return {
169
- models: normalizeModelIds(models, selectedModel),
234
+ models: normalized,
235
+ defaultModels: defaults.length ? defaults : normalized,
236
+ entries,
170
237
  source,
171
238
  error: null,
172
239
  };
@@ -188,6 +255,8 @@ export async function resolveModelCatalog({
188
255
  if (pinned?.length) {
189
256
  return {
190
257
  models: normalizeModelIds(pinned, selectedModel),
258
+ defaultModels: normalizeModelIds(pinned, selectedModel),
259
+ entries: [],
191
260
  source: 'pinned',
192
261
  error: null,
193
262
  };
@@ -196,41 +265,60 @@ export async function resolveModelCatalog({
196
265
  const key = apiKey?.trim() || '';
197
266
 
198
267
  if (key) {
268
+ let catalogError;
199
269
  try {
200
- const discovered = await fetchGatewayModels({
270
+ // The canonical catalog is key-scoped and access-filtered by the
271
+ // gateway, so no second request is needed when it answers.
272
+ const discovered = await fetchGatewayCatalog({
201
273
  baseUrl,
202
274
  apiKey: key,
203
- path: AVAILABLE_MODELS_PATH,
275
+ path: PUBLIC_MODELS_PATH,
204
276
  fetchImpl,
205
277
  timeoutMs,
206
278
  signal,
207
279
  });
208
- return liveCatalog(discovered, selectedModel, 'available');
209
- } catch (availableError) {
210
- if (isAbortError(availableError) || signal?.aborted) throw availableError;
211
- try {
212
- const discovered = await fetchPublicModels({
213
- baseUrl,
214
- fetchImpl,
215
- timeoutMs,
216
- signal,
217
- });
218
- return liveCatalog(discovered, selectedModel, 'public');
219
- } catch (publicError) {
220
- if (isAbortError(publicError) || signal?.aborted) throw publicError;
221
- return packagedCatalog(selectedModel, fallbackModels, availableError);
222
- }
280
+ // An older gateway answers /v1/models with a minimal public list;
281
+ // those ids still name the fleet, so use them rather than the
282
+ // packaged registry.
283
+ return discovered.canonical
284
+ ? liveCatalog(
285
+ discovered.models,
286
+ selectedModel,
287
+ 'catalog',
288
+ discovered.entries,
289
+ )
290
+ : liveCatalog(discovered.models, selectedModel, 'public');
291
+ } catch (error) {
292
+ if (isAbortError(error) || signal?.aborted) throw error;
293
+ catalogError = error;
294
+ }
295
+
296
+ try {
297
+ const discovered = await fetchGatewayCatalog({
298
+ baseUrl,
299
+ fetchImpl,
300
+ timeoutMs,
301
+ signal,
302
+ });
303
+ return liveCatalog(discovered.models, selectedModel, 'public');
304
+ } catch (publicError) {
305
+ if (isAbortError(publicError) || signal?.aborted) throw publicError;
306
+ return packagedCatalog(
307
+ selectedModel,
308
+ fallbackModels,
309
+ catalogError ?? publicError,
310
+ );
223
311
  }
224
312
  }
225
313
 
226
314
  try {
227
- const discovered = await fetchPublicModels({
315
+ const discovered = await fetchGatewayCatalog({
228
316
  baseUrl,
229
317
  fetchImpl,
230
318
  timeoutMs,
231
319
  signal,
232
320
  });
233
- return liveCatalog(discovered, selectedModel, 'public');
321
+ return liveCatalog(discovered.models, selectedModel, 'public');
234
322
  } catch (error) {
235
323
  if (isAbortError(error) || signal?.aborted) throw error;
236
324
  return packagedCatalog(selectedModel, fallbackModels, error);
Binary file
Binary file
Binary file
Binary file
@@ -1,6 +1,9 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { modelIds, positiveInteger } from './launch-values.js';
3
- import { modelSupportsVision } from './model-capabilities.js';
3
+ import {
4
+ catalogEntriesFromMetadata,
5
+ modelSupportsVision,
6
+ } from './model-capabilities.js';
4
7
 
5
8
  export const OPENCODE_PROVIDER_ID = 'subconscious';
6
9
  const OPENCODE_IMAGE_WINDOW_PLUGIN = fileURLToPath(
@@ -40,15 +43,22 @@ export function opencodeModelDisplayName(modelId) {
40
43
  return slug.split(/[-_]/).filter(Boolean).map(formatToken).join(' ');
41
44
  }
42
45
 
43
- export function buildOpenCodeModels(modelIds, { context, output }) {
46
+ export function buildOpenCodeModels(
47
+ modelIds,
48
+ { context, output, entries = [] },
49
+ ) {
50
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]));
44
51
  return Object.fromEntries(
45
52
  modelIds.map((id) => [
46
53
  id,
47
54
  {
48
55
  name: opencodeModelDisplayName(id),
49
56
  tools: true,
50
- limit: { context, output },
51
- ...(modelSupportsVision(id)
57
+ limit: {
58
+ context: entryById.get(id)?.limits?.context_length ?? context,
59
+ output: entryById.get(id)?.limits?.max_output_tokens ?? output,
60
+ },
61
+ ...(modelSupportsVision(id, entries)
52
62
  ? {
53
63
  attachment: true,
54
64
  modalities: { input: ['text', 'image'], output: ['text'] },
@@ -65,6 +75,7 @@ export function buildOpenCodeConfig({
65
75
  modelIds,
66
76
  context,
67
77
  output,
78
+ entries = [],
68
79
  }) {
69
80
  return {
70
81
  $schema: 'https://opencode.ai/config.json',
@@ -82,7 +93,7 @@ export function buildOpenCodeConfig({
82
93
  headers: { 'x-subconscious-client': 'opencode' },
83
94
  modelsDiscovery: { enabled: false },
84
95
  },
85
- models: buildOpenCodeModels(modelIds, { context, output }),
96
+ models: buildOpenCodeModels(modelIds, { context, output, entries }),
86
97
  },
87
98
  },
88
99
  model: `${OPENCODE_PROVIDER_ID}/${model}`,
@@ -100,5 +111,6 @@ export function openCodeConfigFromEnv(env) {
100
111
  'OPENCODE_CONTEXT_LIMIT',
101
112
  ),
102
113
  output: positiveInteger(env.OPENCODE_OUTPUT_LIMIT, 'OPENCODE_OUTPUT_LIMIT'),
114
+ entries: catalogEntriesFromMetadata(env.SUBC_MODEL_METADATA),
103
115
  });
104
116
  }
package/bin/profiles.js CHANGED
@@ -566,11 +566,49 @@ export async function configCommand(
566
566
  export function modelsCommand(models = SUPPORTED_MODELS, options = {}) {
567
567
  const selectedModel =
568
568
  options.selectedModel || models[0] || RUNBOOK_DEFAULTS.MODEL;
569
+ const entryById = new Map(
570
+ (options.entries ?? []).map((entry) => [entry.id, entry]),
571
+ );
572
+ const lifecycleNote = (model) => {
573
+ const lifecycle = entryById.get(model)?.lifecycle;
574
+ if (!lifecycle) return '';
575
+ const date = lifecycle.deprecation_date
576
+ ? ` until ${lifecycle.deprecation_date}`
577
+ : '';
578
+ const replacement = lifecycle.replacement
579
+ ? ` ${c.dim}-> ${lifecycle.replacement}${c.reset}`
580
+ : '';
581
+ if (lifecycle.status === 'deprecated') {
582
+ return ` ${c.yellow}[deprecated${date}]${c.reset}${replacement}`;
583
+ }
584
+ if (lifecycle.status === 'retired') {
585
+ return ` ${c.red}[retired${date}]${c.reset}${replacement}`;
586
+ }
587
+ return '';
588
+ };
569
589
  console.log(`\n ${c.bold}Available models${c.reset}\n`);
570
590
  for (const model of models) {
571
591
  const suffix =
572
592
  model === selectedModel ? ` ${c.dim}(default)${c.reset}` : '';
573
- console.log(` ${c.cyan}${model}${c.reset}${suffix}`);
593
+ console.log(
594
+ ` ${c.cyan}${model}${c.reset}${suffix}${lifecycleNote(model)}`,
595
+ );
596
+ }
597
+ const selectedLifecycle = entryById.get(selectedModel)?.lifecycle;
598
+ if (selectedLifecycle?.status === 'deprecated') {
599
+ const when = selectedLifecycle.deprecation_date
600
+ ? ` on ${selectedLifecycle.deprecation_date}`
601
+ : '';
602
+ const to = selectedLifecycle.replacement
603
+ ? ` Migrate to ${selectedLifecycle.replacement}.`
604
+ : '';
605
+ console.error(
606
+ `\n ${c.yellow}Selected model ${selectedModel} is deprecated${when}.${to}${c.reset}`,
607
+ );
608
+ } else if (selectedLifecycle?.status === 'retired') {
609
+ console.error(
610
+ `\n ${c.yellow}Selected model ${selectedModel} is retired and may stop serving without notice.${c.reset}`,
611
+ );
574
612
  }
575
613
  if (options.source === 'public' && options.hasApiKey) {
576
614
  console.error(`\n ${c.dim}${PUBLIC_CATALOG_FALLBACK_MESSAGE}${c.reset}`);
package/bin/tui.js CHANGED
@@ -330,7 +330,7 @@ export async function loadRemoteTuiUpdates(state, options = {}) {
330
330
  });
331
331
  if (signal?.aborted) return;
332
332
  await enqueuePatch({
333
- models: catalog.models,
333
+ models: catalog.defaultModels,
334
334
  modelError: catalog.error?.message || '',
335
335
  modelSource: catalog.source,
336
336
  modelsLoading: false,
package/bin/usage.js CHANGED
@@ -55,15 +55,6 @@ export function shortModelSlug(slug) {
55
55
  : text;
56
56
  }
57
57
 
58
- export function isLegacyUsagePayload(data) {
59
- if (!data || typeof data !== 'object') return true;
60
- if ('credits' in data && data.plan && typeof data.plan === 'object')
61
- return false;
62
- return (
63
- 'dailyTokenCeiling' in data || 'consumedToday' in data || !('plan' in data)
64
- );
65
- }
66
-
67
58
  export function meterColor(percentage) {
68
59
  if (percentage >= 100) return c.red;
69
60
  if (percentage >= 80) return c.yellow;
@@ -91,27 +82,25 @@ export function formatAllowancePercent(percent) {
91
82
  return `${whole}%`;
92
83
  }
93
84
 
94
- /** New percent payloads, or the pre-basis token shape (consumed / amount). */
85
+ /** Percent-only allowance payloads from the current platform. */
95
86
  export function describeDailyAllowance(allowance) {
96
87
  if (!allowance || typeof allowance !== 'object') return null;
97
88
  if (allowance.basis === 'unavailable') return { kind: 'unavailable' };
98
89
  if (allowance.basis === 'credit' || allowance.basis === 'tokens') {
99
90
  return { kind: allowance.basis, percent: Number(allowance.percent) || 0 };
100
91
  }
101
-
102
- const consumed = Number(allowance.consumed) || 0;
103
- const amount = Number(allowance.amount) || 0;
104
- if (amount <= 0) return null;
105
- return { kind: 'tokens', percent: (consumed / amount) * 100 };
92
+ return null;
106
93
  }
107
94
 
108
95
  export function formatUsageDisplay(data) {
109
96
  const lines = [];
110
97
  lines.push(`\n ${c.bold}Subconscious Usage${c.reset}\n`);
111
98
 
112
- const billingMode = data.billingMode ?? null;
99
+ const billingTerms = data.billingTerms ?? null;
100
+ const invoiced = billingTerms === 'invoiced';
113
101
  const plan = data.plan ?? {};
114
102
  const credits = data.credits ?? {};
103
+ const invoice = invoiced ? (data.invoice ?? {}) : {};
115
104
  const models = Array.isArray(data.models) ? data.models : [];
116
105
 
117
106
  const planLabel = plan.label || 'No plan';
@@ -119,55 +108,81 @@ export function formatUsageDisplay(data) {
119
108
  const planLine = planStatus ? `${planLabel} · ${planStatus}` : planLabel;
120
109
 
121
110
  lines.push(` ${c.dim}Plan${c.reset} ${planLine}`);
122
- if (billingMode) {
123
- lines.push(` ${c.dim}Billing${c.reset} ${billingMode}`);
111
+ if (invoiced) {
112
+ lines.push(` ${c.dim}Billing${c.reset} Invoiced monthly (net 30)`);
124
113
  }
125
114
 
126
115
  const allowance = plan.dailyAllowance;
127
- const unlimitedComp = Boolean(plan.isUnlimitedComp);
128
116
 
129
- if (unlimitedComp) {
117
+ // Invoiced orgs bill against the spend limit, not a daily grant, so the
118
+ // daily allowance does not pertain to them and is never shown.
119
+ const described = invoiced ? null : describeDailyAllowance(allowance);
120
+ if (described) {
130
121
  lines.push('');
131
122
  lines.push(` ${c.dim}Daily allowance${c.reset}`);
132
- lines.push(` ${c.green}Unlimited${c.reset} ${c.dim}(comp)${c.reset}`);
133
- } else {
134
- const described = describeDailyAllowance(allowance);
135
- if (described) {
136
- lines.push('');
137
- lines.push(` ${c.dim}Daily allowance${c.reset}`);
138
- if (described.kind === 'unavailable') {
139
- lines.push(` ${c.dim}Daily credit unavailable${c.reset}`);
140
- lines.push(
141
- ` ${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}`,
142
- );
143
- } else {
144
- const label =
145
- described.kind === 'credit'
146
- ? 'of daily credit used'
147
- : 'of daily tokens used';
123
+ if (described.kind === 'unavailable') {
124
+ lines.push(` ${c.dim}Daily credit unavailable${c.reset}`);
125
+ lines.push(
126
+ ` ${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}`,
127
+ );
128
+ } else {
129
+ const label =
130
+ described.kind === 'credit'
131
+ ? 'of daily credit used'
132
+ : 'of daily tokens used';
133
+ lines.push(
134
+ ` ${renderProgressBar(described.percent)} ${formatAllowancePercent(described.percent)} ${label}`,
135
+ );
136
+ lines.push(` ${c.dim}Resets midnight UTC${c.reset}`);
137
+ if (described.kind === 'credit' && described.percent >= 100) {
148
138
  lines.push(
149
- ` ${renderProgressBar(described.percent)} ${formatAllowancePercent(described.percent)} ${label}`,
139
+ ` ${c.yellow}Past today's allowance - further usage bills from your balance.${c.reset}`,
150
140
  );
151
- lines.push(` ${c.dim}Resets midnight UTC${c.reset}`);
152
- if (described.kind === 'credit' && described.percent >= 100) {
153
- lines.push(
154
- ` ${c.yellow}Past today's allowance - further usage bills from your balance.${c.reset}`,
155
- );
156
- }
157
141
  }
158
142
  }
159
143
  }
160
144
 
161
- lines.push('');
162
- lines.push(
163
- ` ${c.dim}Credits${c.reset} ${formatCurrency(credits.balanceDollars ?? 0)}`,
164
- );
165
- if (billingMode === 'subscription' || billingMode === 'comp' || allowance) {
166
- const overage = Number(credits.overageThisPeriodDollars) || 0;
167
- const overageColor = overage > 0 ? c.yellow : c.reset;
145
+ if (invoiced) {
146
+ const accrued = invoice.accruedThisPeriodDollars;
147
+ const limit = invoice.limitDollars;
148
+ const periodEnd = invoice.periodEnd ? new Date(invoice.periodEnd) : null;
149
+
150
+ lines.push('');
151
+ lines.push(` ${c.dim}Spend this period${c.reset}`);
152
+ if (accrued == null) {
153
+ lines.push(` ${c.dim}Spend could not be read just now.${c.reset}`);
154
+ lines.push(
155
+ ` ${c.dim}Your plan and access are unaffected, and the figure returns on its own.${c.reset}`,
156
+ );
157
+ } else {
158
+ const limitText =
159
+ limit != null ? ` of ${formatCurrency(limit)} limit` : '';
160
+ const spendColor = limit != null && accrued >= limit ? c.red : c.reset;
161
+ lines.push(
162
+ ` ${spendColor}${formatCurrency(accrued)}${c.reset}${c.dim}${limitText}${c.reset}`,
163
+ );
164
+ if (periodEnd && !Number.isNaN(periodEnd.getTime())) {
165
+ const isoDay = periodEnd.toISOString().slice(0, 10);
166
+ lines.push(` ${c.dim}Period ends ${isoDay}${c.reset}`);
167
+ }
168
+ if (limit != null && accrued >= limit) {
169
+ lines.push(
170
+ ` ${c.red}At the spend limit - further requests follow your limit policy.${c.reset}`,
171
+ );
172
+ }
173
+ }
174
+ } else if (credits.balanceDollars != null || data.credits) {
175
+ lines.push('');
168
176
  lines.push(
169
- ` ${c.dim}Overage${c.reset} ${overageColor}${formatCurrency(overage)} this period${c.reset}`,
177
+ ` ${c.dim}Credits${c.reset} ${formatCurrency(credits.balanceDollars ?? 0)}`,
170
178
  );
179
+ const overage = Number(credits.overageThisPeriodDollars) || 0;
180
+ if (overage > 0 || allowance) {
181
+ const overageColor = overage > 0 ? c.yellow : c.reset;
182
+ lines.push(
183
+ ` ${c.dim}Overage${c.reset} ${overageColor}${formatCurrency(overage)} this period${c.reset}`,
184
+ );
185
+ }
171
186
  }
172
187
 
173
188
  if (models.length > 0) {
@@ -251,14 +266,6 @@ export async function usageCommand(argv = [], options = {}) {
251
266
  throw new Error(message);
252
267
  }
253
268
 
254
- if (isLegacyUsagePayload(data)) {
255
- console.log(`\n ${c.yellow}Platform upgrade required.${c.reset}`);
256
- console.log(
257
- ` ${c.dim}This platform host returns a legacy usage shape. Deploy the latest platform or point PLATFORM_URL at a current host.${c.reset}\n`,
258
- );
259
- return;
260
- }
261
-
262
269
  if (jsonOutput) {
263
270
  console.log(JSON.stringify(data, null, 2));
264
271
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subconscious-cli",
3
- "version": "7.0.0",
3
+ "version": "8.0.0",
4
4
  "description": "CLI for Subconscious — run Subconscious Code, Claude Code, Codex, OpenCode, DeepSeek Harness, Copilot, and Pi",
5
5
  "bin": {
6
6
  "subc": "bin/cli.js"