@telsoncc/opencode-commandcode 1.0.5 → 1.1.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/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-09-25
11
+
12
+ ## [1.0.6] - 2026-09-24
13
+
10
14
  ## [1.0.5] - 2026-09-24
11
15
 
12
16
  ## [1.0.4] - 2026-09-23
package/_version.txt CHANGED
@@ -1 +1 @@
1
- 1.65.0
1
+ 1.65.2
package/manifest.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-24T11:09:40.846Z",
4
- "pluginVersion": "1.0.5",
5
- "commandCodeVersion": "1.65.0",
6
- "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.65.0.tgz",
3
+ "generatedAt": "2026-09-24T21:10:50.082Z",
4
+ "pluginVersion": "1.1.0",
5
+ "commandCodeVersion": "1.65.2",
6
+ "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.65.2.tgz",
7
7
  "modelCount": 81,
8
8
  "reasoningModelCount": 68,
9
9
  "extraction": {
package/models.json CHANGED
@@ -671,9 +671,9 @@
671
671
  ],
672
672
  "tool_call": true,
673
673
  "cost": {
674
- "input": 0.3,
675
- "output": 1.2,
676
- "cache_read": 0.006
674
+ "input": 0.15,
675
+ "output": 0.6,
676
+ "cache_read": 0.003
677
677
  },
678
678
  "limit": {
679
679
  "context": 1000000,
@@ -2349,7 +2349,7 @@
2349
2349
  "cache_read": 0.02
2350
2350
  },
2351
2351
  "limit": {
2352
- "context": 1000000,
2352
+ "context": 262144,
2353
2353
  "output": 131072
2354
2354
  },
2355
2355
  "attachment": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telsoncc/opencode-commandcode",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code",
5
5
  "keywords": [
6
6
  "ai",
@@ -3,7 +3,13 @@ import {
3
3
  COMMANDCODE_INTEGRATION_ID,
4
4
  applyIntegration,
5
5
  applyProviderInventory,
6
+ attachRemoteRefreshRunner,
7
+ isRemoteSyncDisabled,
6
8
  loadCatalogForV2,
9
+ persistRefreshedModels,
10
+ refreshCatalogFromRemote,
11
+ writeRemoteRefreshSummary,
12
+ type RemoteRefreshResult,
7
13
  type V2SetupContext,
8
14
  } from "./src/opencode2.js";
9
15
  import type { ModelEntry } from "./src/catalog.js";
@@ -15,7 +21,9 @@ import type { ModelEntry } from "./src/catalog.js";
15
21
  * Models register in memory via `ctx.provider.transform` + `editor.add` +
16
22
  * `reload()` — nothing is written into `opencode.json`. Auth registers a
17
23
  * `commandcode` integration (API key via `/connect`, `COMMANDCODE_API_KEY`
18
- * env fallback).
24
+ * env fallback). After startup the inventory refreshes itself from the live
25
+ * availability API every 6h (opt out with `disableModelSync` in
26
+ * `~/.config/opencode/opencode-commandcode.json`).
19
27
  *
20
28
  * Dual export: `{ id, setup, server }`. OpenCode 2.0 loads this module and
21
29
  * calls `setup()`; OpenCode 1.18.29+ prefers `exports["./server"]` and calls
@@ -39,7 +47,9 @@ export async function setup(ctx: V2SetupContext): Promise<() => Promise<void>> {
39
47
  );
40
48
 
41
49
  const catalog = loadCatalogForV2();
42
- const inventory: ModelEntry[] = catalog.models;
50
+ // Reassigned by the remote refresh below; the provider transform closure
51
+ // replays against the current value on every reload().
52
+ let inventory: ModelEntry[] = catalog.models;
43
53
  let sourceConnection: unknown;
44
54
  const refreshConnection = async (): Promise<void> => {
45
55
  try {
@@ -61,6 +71,40 @@ export async function setup(ctx: V2SetupContext): Promise<() => Promise<void>> {
61
71
  // Keep the last-good inventory when the reload fails.
62
72
  }
63
73
 
74
+ // Live availability refresh: prune ids the API retired and surface new
75
+ // ones without waiting for an npm publish. The bundled catalog stays as
76
+ // the offline fallback; models unknown locally are reported, not
77
+ // published, until a catalog sync extracts their cost data.
78
+ const sameInventory = (next: ModelEntry[]): boolean =>
79
+ next.length === inventory.length && next.every((m, i) => m.id === inventory[i]?.id);
80
+
81
+ const syncFromRemote = async (): Promise<void> => {
82
+ if (isRemoteSyncDisabled()) return;
83
+ let result: RemoteRefreshResult;
84
+ try {
85
+ result = await refreshCatalogFromRemote(loadCatalogForV2().models);
86
+ } catch {
87
+ return;
88
+ }
89
+ writeRemoteRefreshSummary(result, catalog.commandCodeVersion);
90
+ if (result.models.length === 0 || sameInventory(result.models)) return;
91
+ inventory = result.models;
92
+ persistRefreshedModels(inventory);
93
+ try {
94
+ await ctx.provider.reload();
95
+ } catch {
96
+ // Keep serving the last-good inventory; the next tick retries.
97
+ }
98
+ };
99
+
100
+ let detachRefresh: (() => void) | null = null;
101
+ if (!isRemoteSyncDisabled()) {
102
+ detachRefresh = attachRemoteRefreshRunner(() => syncFromRemote());
103
+ void syncFromRemote().catch(() => {
104
+ // Startup already published the bundled inventory.
105
+ });
106
+ }
107
+
64
108
  // Rebind the inventory when credentials switch; the transform closure reads
65
109
  // the current `inventory`/`sourceConnection` on every replay.
66
110
  let stopped = false;
@@ -88,6 +132,7 @@ export async function setup(ctx: V2SetupContext): Promise<() => Promise<void>> {
88
132
 
89
133
  return async () => {
90
134
  stopped = true;
135
+ detachRefresh?.();
91
136
  for (const registration of registrations.reverse()) {
92
137
  try {
93
138
  await registration.dispose();
package/src/opencode2.ts CHANGED
@@ -2,7 +2,14 @@ import { readFileSync, existsSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { toConfigKey, loadCatalogFromLocalCommandCode, type ModelEntry } from "./catalog.js";
5
+ import {
6
+ MODELS_API_URL,
7
+ filterCatalogByAvailability,
8
+ parseAvailabilityIds,
9
+ toConfigKey,
10
+ loadCatalogFromLocalCommandCode,
11
+ type ModelEntry,
12
+ } from "./catalog.js";
6
13
  import {
7
14
  readCatalogCache,
8
15
  writeCatalogCache,
@@ -241,7 +248,7 @@ function readBundledVersion(): string | null {
241
248
  }
242
249
  }
243
250
 
244
- export type V2CatalogSource = "bundled" | "cache" | "opt-in-local";
251
+ export type V2CatalogSource = "bundled" | "cache" | "opt-in-local" | "remote";
245
252
 
246
253
  export interface LoadedV2Catalog {
247
254
  models: ModelEntry[];
@@ -334,3 +341,123 @@ export function loadCatalogForV2(): LoadedV2Catalog {
334
341
 
335
342
  return { models, source, commandCodeVersion, degraded, degradedReason };
336
343
  }
344
+
345
+ export const REMOTE_SYNC_TIMEOUT_MS = 15_000;
346
+ export const REMOTE_SYNC_INTERVAL_MS = 6 * 3600_000;
347
+
348
+ export interface RemoteRefreshResult {
349
+ models: ModelEntry[];
350
+ unavailable: string[];
351
+ pendingNew: string[];
352
+ remoteCount: number;
353
+ }
354
+
355
+ /** Live availability ids from the Provider API. No auth required. */
356
+ export async function fetchRemoteAvailability(
357
+ timeoutMs = REMOTE_SYNC_TIMEOUT_MS,
358
+ ): Promise<string[]> {
359
+ const response = await fetch(MODELS_API_URL, {
360
+ headers: { accept: "application/json" },
361
+ signal: AbortSignal.timeout(timeoutMs),
362
+ });
363
+ if (!response.ok) {
364
+ throw new Error(`availability request failed: HTTP ${response.status}`);
365
+ }
366
+ return parseAvailabilityIds(await response.json());
367
+ }
368
+
369
+ /**
370
+ * Reconcile a local catalog with live availability: keep the ids the API
371
+ * still serves, report retired ones, and list remote ids missing locally.
372
+ * Missing ids are *not* published — they carry no cost/limit data until a
373
+ * catalog sync extracts them.
374
+ */
375
+ export function applyRemoteAvailability(
376
+ base: ModelEntry[],
377
+ remoteIds: string[],
378
+ ): RemoteRefreshResult {
379
+ const { retained, unavailable } = filterCatalogByAvailability(base, remoteIds);
380
+ const known = new Set(base.map((m) => m.id));
381
+ const pendingNew = [...new Set(remoteIds)].filter((id) => !known.has(id)).sort();
382
+ return { models: retained, unavailable, pendingNew, remoteCount: remoteIds.length };
383
+ }
384
+
385
+ export async function refreshCatalogFromRemote(
386
+ base: ModelEntry[],
387
+ timeoutMs = REMOTE_SYNC_TIMEOUT_MS,
388
+ ): Promise<RemoteRefreshResult> {
389
+ return applyRemoteAvailability(base, await fetchRemoteAvailability(timeoutMs));
390
+ }
391
+
392
+ export function isRemoteSyncDisabled(): boolean {
393
+ return loadPluginFileConfig().disableModelSync === true;
394
+ }
395
+
396
+ /** Overwrite the last-good disk cache with a refreshed inventory. */
397
+ export function persistRefreshedModels(models: ModelEntry[]): void {
398
+ if (models.length === 0) return;
399
+ try {
400
+ writeCatalogCache(pluginStateDir(), models);
401
+ } catch {
402
+ // ignore cache write
403
+ }
404
+ }
405
+
406
+ export function writeRemoteRefreshSummary(
407
+ result: RemoteRefreshResult,
408
+ commandCodeVersion: string | null,
409
+ ): void {
410
+ const summary: StartupSummary = {
411
+ catalogSource: "remote",
412
+ commandCodeVersion,
413
+ modelCount: result.models.length,
414
+ reasoningModelCount: result.models.filter((m) => m.reasoning).length,
415
+ degraded: result.models.length === 0,
416
+ degradedReason:
417
+ result.models.length === 0 ? "remote availability matched no local models" : null,
418
+ pendingNewCount: result.pendingNew.length,
419
+ };
420
+ try {
421
+ writeStartupSummary(pluginStateDir(), summary);
422
+ } catch {
423
+ // ignore
424
+ }
425
+ }
426
+
427
+ const remoteRefreshRunners = new Set<() => Promise<void>>();
428
+ let remoteRefreshTimer: ReturnType<typeof setInterval> | null = null;
429
+
430
+ export function remoteRefreshRunnerCount(): number {
431
+ return remoteRefreshRunners.size;
432
+ }
433
+
434
+ /**
435
+ * Share one interval across setups in the process. Returns a detach
436
+ * function; the timer stops after the last runner detaches.
437
+ */
438
+ export function attachRemoteRefreshRunner(
439
+ run: () => Promise<void>,
440
+ intervalMs = REMOTE_SYNC_INTERVAL_MS,
441
+ ): () => void {
442
+ remoteRefreshRunners.add(run);
443
+ if (!remoteRefreshTimer) {
444
+ remoteRefreshTimer = setInterval(() => {
445
+ for (const runner of remoteRefreshRunners) {
446
+ void runner().catch(() => {
447
+ // A failed refresh keeps the last inventory; the next tick retries.
448
+ });
449
+ }
450
+ }, intervalMs);
451
+ remoteRefreshTimer.unref?.();
452
+ }
453
+ let detached = false;
454
+ return () => {
455
+ if (detached) return;
456
+ detached = true;
457
+ remoteRefreshRunners.delete(run);
458
+ if (remoteRefreshRunners.size === 0 && remoteRefreshTimer) {
459
+ clearInterval(remoteRefreshTimer);
460
+ remoteRefreshTimer = null;
461
+ }
462
+ };
463
+ }
package/src/startup.ts CHANGED
@@ -6,12 +6,14 @@ import type { ModelEntry } from "./catalog.js";
6
6
  export type { ModelEntry } from "./catalog.js";
7
7
 
8
8
  export type StartupSummary = {
9
- catalogSource: "bundled" | "cache" | "opt-in-local";
9
+ catalogSource: "bundled" | "cache" | "opt-in-local" | "remote";
10
10
  commandCodeVersion: string | null;
11
11
  modelCount: number;
12
12
  reasoningModelCount: number;
13
13
  degraded: boolean;
14
14
  degradedReason: string | null;
15
+ /** Remote ids with no local cost data yet — next catalog sync picks them up. */
16
+ pendingNewCount?: number;
15
17
  };
16
18
 
17
19
  export function pluginStateDir(): string {