From 20143c02f08116b6a2c7f9e0000c55b4cc4d4c53 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 18 Oct 2026 19:51:37 +0800 Subject: [PATCH 1/6] perf(build): keep source maps out of the published bundle The tarball carried lib/index.js.map and lib/client.js.map: 1.63 MiB raw, 32.9% of the unpacked payload. Nothing reads them: lib/client.js is a CJS bundle the webview executes, and scripts/verify-engine-load.mjs audits import/require statements rather than comments, so the `//# sourceMappingURL` trailer is inert. `files` now excludes them, and they are untracked as well. They are the one tsdown output that does not rebuild byte-identically on this checkout -- lib/index.js, lib/client.js and lib/index.d.ts all reproduce exactly -- so tracking them only gives the CI `git diff --exit-code -- lib/` guard a platform-dependent failure. Local builds still emit them, so debugging keeps its maps. npm pack --dry-run: tarball 2,948,915 -> 2,479,266 B (-15.9%), unpacked 5,188,371 -> 3,480,662 B (-32.9%), 18 -> 16 files. --- .gitignore | 6 +++++- lib/client.js.map | 1 - lib/index.js.map | 1 - package.json | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) delete mode 100644 lib/client.js.map delete mode 100644 lib/index.js.map diff --git a/.gitignore b/.gitignore index 560ca96..dd1b0ca 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,11 @@ node_modules.bak/ .npm-cache/ # DSH STORE reviews the fixed Git Commit, so the built runtime files ship in # git. `lib/` artifacts are committed explicitly with `git add -f`; the ignore -# stays so map churn and stray local rebuilds do not show up as noise. +# stays so stray local rebuilds do not show up as noise. Source maps are +# deliberately outside both: nothing loads them, `files` keeps them out of the +# tarball, and they are the one tsdown output that does not rebuild +# byte-identically across machines, so tracking them only breaks the CI +# `git diff --exit-code -- lib/` guard. lib/ *.tgz .DS_Store diff --git a/lib/client.js.map b/lib/client.js.map deleted file mode 100644 index a75c224..0000000 --- a/lib/client.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"client.js","names":["record","numberField","booleanField","IDLE","record","stringField","reject","pkg.version","(pkg as { repository?: unknown }).repository","useState","useMemo","Menu","Button","useSyncExternalStore","useCallback","useRef","HIDDEN_STYLE","useState","useRef","money","count","useState","useId","useState","Button","useRef"],"sources":["../src/client/snapshot-store.ts","../src/readable-error.ts","../src/client/settings-scope.ts","../src/timeout-limits.ts","../src/model-visibility.ts","../src/context-window.ts","../src/output-budget.ts","../src/client/settings-write.ts","../src/client/settings.ts","../src/display-format.ts","../src/client/usage.ts","../src/client/prices.ts","../src/client/login-page.ts","../src/client/login.ts","../src/wire-shared.ts","../src/usage-wire.ts","../src/login-wire.ts","../src/enrollment-wire.ts","../src/client/enrollment.ts","../src/client/locales.ts","../src/plan-tiers.ts","../src/client/model-select.ts","../package.json","../src/client/version.ts","../src/client/update.ts","../src/client/section.tsx","../src/client/login-row.tsx","../src/client/card.tsx","../src/client/analytics-copy.ts","../src/client/panel-copy.ts","../src/client/panel.ts","../src/analytics-wire.ts","../src/client/analytics-comparison.ts","../src/client/analytics-view.tsx","../src/client/panel-view.tsx","../src/cost-facts.ts","../src/client/session-cost.ts","../src/client/session-cost-display.ts","../src/client/session-cost-view.tsx","../src/client/page-styles.ts","../src/client/panel-styles.ts","../src/client/analytics.ts","../src/client/index.ts"],"sourcesContent":["/**\n * A tiny observable snapshot store — the `getSnapshot` / `subscribe` / `set`\n * triple React's `useSyncExternalStore` consumes through the harness slot kit\n * (the host builds each slot's `useFoo(selector)` hook from one of these).\n *\n * Vendored on purpose, and small enough to stay that way: the harness's\n * `@deepseek-ai/dsh-client-store` is a seeded platform module, but pulling it in\n * would add a fourth `require()` target (and a client-only peer) to the bundle\n * for the ~30 lines used here, and its `set()` carries engine semantics\n * (dev-mode deep freeze, forced replacement) these callers do not want.\n */\n\n/** Observable snapshot consumed by slot hooks. */\nexport interface SnapshotStore {\n getSnapshot(): T\n subscribe(listener: () => void): () => void\n set(value: T): void\n}\n\n/** Notify every subscriber without letting one faulty UI consumer suppress the rest. */\nfunction notifyListeners(listeners: ReadonlySet<() => void>): void {\n for (const listener of listeners) {\n try {\n listener()\n } catch (error: unknown) {\n console.error('[dsh-commandcode-provider] snapshot subscriber failed:', error)\n }\n }\n}\n\n/** Create one observable snapshot store. */\nexport function createSnapshotStore(initial: T): SnapshotStore {\n let snapshot = initial\n const listeners = new Set<() => void>()\n return {\n getSnapshot: () => snapshot,\n subscribe(listener: () => void) {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n set(value: T) {\n if (Object.is(value, snapshot)) return\n snapshot = value\n notifyListeners(listeners)\n },\n }\n}\n","/**\n * Readable error text for the settings page AND the Host-side failure paths.\n *\n * @module dsh-commandcode-provider/readable-error\n */\n\n/**\n * Turn a caught value into readable text.\n *\n * `error instanceof Error ? error.message : String(error)` degrades to the\n * literal string `[object Object]` for a plain object with no `Error`\n * prototype — which is exactly what a Remote call across the webview/host\n * boundary can hand back: `dsh-typert-protocol` serializes a rejection for\n * the wire, and a structurally-cloned value loses its prototype chain even\n * when the host threw a real `Error` (issue #74). Reading a `message` string\n * off the plain object first recovers the original text in that case; only a\n * value with neither an `Error` prototype nor a usable `message` field falls\n * through to a JSON dump.\n *\n * Shared by both halves on purpose. The browser client needs it for rejections\n * that crossed the RPC boundary, and the Host needs it for values that reached\n * it from a transport, a JSON body, or a user-thrown non-Error — the same\n * degradation, one owner.\n */\nexport function readableErrorText(error: unknown): string {\n if (error instanceof Error) return error.message\n if (typeof error === 'string') return error\n if (error !== null && typeof error === 'object') {\n const message = (error as { message?: unknown }).message\n if (typeof message === 'string' && message !== '') return message\n try {\n return JSON.stringify(error)\n } catch {\n return Object.prototype.toString.call(error)\n }\n }\n return String(error)\n}\n","/**\n * Browser settings scope over the `remote.settings` Typert namespace.\n *\n * The harness's own `settingsScope` client wrapper was REMOVED by the 0.1.7\n * settings rewrite and has no replacement, so this module IS the client half:\n * it speaks the `describe()` / `mutate(namespace, ops, revision)` wire directly\n * and implements the `SettingsScope` face the settings-page controller\n * consumes. `view.writable` is the Host's own authorization fact — the client\n * never infers write permission from localhost, URL shape or origin.\n *\n * **Capture the namespace from an inject-scoped context, never read it off\n * `ctx.remote`.** React-free so `tests/settings-scope.test.ts` can drive the\n * lifecycle over a fake remote.\n *\n * @module dsh-commandcode-provider/client/settings-scope\n */\n\nimport type { SettingsScope, SettingsScopeSnapshot } from './settings.ts'\nimport { createSnapshotStore, type SnapshotStore } from './snapshot-store.ts'\nimport { readableErrorText } from '../readable-error.ts'\n\n/** Result envelope of one current Typert Remote call. */\ntype Envelope = { ok: true; value: T } | { ok: false; error?: { message?: string } | undefined }\n\n/** One path-addressed form edit — the `SettingsPathOp` wire shape. */\nexport interface SettingsPathOp {\n op: 'set' | 'unset'\n path: readonly string[]\n value?: unknown\n}\n\n/** 本地确认结果,不改宿主远端载体;恢复快照不能冒充本次写入成功。 */\nexport type SettingsMutationResult = 'accepted' | 'conflict' | 'failed' | 'cancelled'\n\n/** One entry's row inside a `settings.describe()` view — `SettingsNamespaceView`. */\ninterface SettingsNamespaceRow {\n ns?: unknown\n value?: unknown\n base?: unknown\n user?: unknown\n revision?: unknown\n}\n\n/** The `settings.describe()` view: the directory plus the Host's write posture. */\ninterface SettingsView {\n namespaces?: SettingsNamespaceRow[] | undefined\n writable?: boolean | undefined\n}\n\n/** The `remote.settings` namespace this scope calls. Structural: never imported. */\nexport interface SettingsRemoteNamespace {\n describe(): Promise>\n /** A write answers with the fresh ROW for its namespace, not the directory. */\n mutate(\n namespace: string,\n ops: readonly SettingsPathOp[],\n revision?: number,\n ): Promise>\n}\n\n/**\n * Reads the `remote.settings` namespace object, or `undefined` while it is not\n * mounted.\n *\n * This is a SEAM rather than a property on {@link SettingsScopeContext}\n * because the namespace has to be captured inside a context that declares it\n * (`ctx.inject(['remote.settings'], (settingsCtx) => …)`). Reading it off our\n * own `ctx.remote` throws `cannot get property \"remote.settings\" without\n * inject` inside this module's `try`/`catch` — silently, with no describe ever\n * sent and the scope stuck on its initial `writable: false` snapshot. Do not\n * \"simplify\" this seam away (AGENTS.md has the full incident).\n */\nexport type SettingsRemoteResolver = () => SettingsRemoteNamespace | undefined\n\n/**\n * The client `Context` slice this module consumes, typed structurally so the\n * browser bundle never imports a version-specific module value and node tests\n * can hand in a plain object. Only `remote` itself (a declared service) is read\n * here — never `remote.`, which requires its own inject.\n */\nexport interface SettingsScopeContext {\n remote: {\n $on?: ((event: string, listener: () => void) => (() => void) | undefined) | undefined\n }\n /** Connection lifecycle; older clients and tests may omit it. */\n on?: ((event: string, listener: () => void) => unknown) | undefined\n}\n\n/** Mirror snapshot: one in-flight-or-idle describe read and its held view. */\ninterface MirrorState {\n status: 'idle' | 'loading' | 'ready'\n view: SettingsView | null\n error: string | null\n}\n\n/**\n * Backoff before re-reading a directory whose FIRST read failed, in millis.\n *\n * Only a failure with NOTHING held retries. With no view the scope keeps its\n * initial `{ status: 'loading', writable: false }` snapshot and the settings\n * page renders fully disabled, while the forwarded invalidation signals need a\n * live Host to fire and the namespace only re-mounts on a reload. A failure\n * with a held view retries nothing — the page already shows the last good\n * document (`tests/settings-scope.test.ts` pins both).\n */\nexport const SETTINGS_DESCRIBE_RETRY_MS: readonly number[] = [1000, 2000, 4000]\n\n/** Timer seam for the bounded describe retry; tests drive it without waiting. */\nexport interface SettingsRetryTimer {\n set(callback: () => void, ms: number): unknown\n clear(handle: unknown): void\n}\n\n/** The production timer: a browser/node timeout, no polling. */\nconst REAL_SETTINGS_TIMER: SettingsRetryTimer = {\n set(callback, ms) {\n const handle = setTimeout(callback, ms)\n ;(handle as { unref?: () => void }).unref?.()\n return handle\n },\n clear(handle) { clearTimeout(handle as ReturnType) },\n}\n\n/** Shallow equality over the snapshot fields, so an unchanged derive never churns subscribers. */\nfunction sameSnapshot(a: SettingsScopeSnapshot, b: SettingsScopeSnapshot): boolean {\n return a.status === b.status\n && Object.is(a.value, b.value)\n && Object.is(a.base, b.base)\n && Object.is(a.user, b.user)\n && Object.is(a.revision, b.revision)\n && a.writable === b.writable\n && a.mode === b.mode\n}\n\n/** Accept a decoded row value only when it is the object a settings section is. */\nfunction decodeRow(value: unknown): Record | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined\n return value as Record\n}\n\n/**\n * One shared mirror of the Host `settings.describe()` view.\n *\n * The run loop is the harness's own: serialize behind `inFlight`, fold a\n * concurrent invalidation into exactly one rerun, and hold the previous view\n * across a failed read (status stays `ready` with an `error`, or returns to\n * `idle` when nothing was ever held).\n */\nclass SettingsDescribeMirror {\n private readonly store: SnapshotStore\n private inFlight: Promise | undefined\n private rerun = false\n private generation = 0\n /** Pending retry handle and how many of {@link SETTINGS_DESCRIBE_RETRY_MS} are spent. */\n private retryHandle: unknown\n private retries = 0\n private disposed = false\n\n constructor(\n private readonly resolveRemote: SettingsRemoteResolver,\n private readonly timer: SettingsRetryTimer = REAL_SETTINGS_TIMER,\n ) {\n this.store = createSnapshotStore({\n status: 'idle',\n view: null,\n error: null,\n })\n }\n\n getSnapshot(): MirrorState {\n return this.store.getSnapshot()\n }\n\n subscribe(listener: () => void): () => void {\n return this.store.subscribe(listener)\n }\n\n /** The cheap idempotent entry: kick a first read. */\n ensure(): void {\n void this.load()\n }\n\n /**\n * Refresh from the Host. A call during an in-flight read marks one rerun\n * after it settles instead of racing a second wire read.\n * @returns settlement after this call's freshness is reflected.\n */\n load(): Promise {\n if (this.disposed) return Promise.resolve()\n // A live read supersedes a scheduled retry: the retry exists only to\n // revive a mirror that nothing else would ever re-read.\n this.clearRetry()\n if (this.inFlight !== undefined) {\n this.rerun = true\n return this.inFlight\n }\n const run = Promise.resolve().then(() => this.run())\n this.inFlight = run\n return run\n }\n\n /**\n * Fold one write answer's namespace ROW into the held view without a wire\n * read, and invalidate any read still in flight: a read begun before the\n * write committed must not publish the pre-write document over it. With no\n * held document the answer is not published as a partial document — the\n * next read (or the failed-write recovery) picks it up.\n * @param row - The namespace view a settings write answered with.\n */\n acceptView(row: SettingsNamespaceRow): void {\n const before = this.store.getSnapshot()\n this.generation += 1\n if (this.inFlight !== undefined) this.rerun = true\n if (before.view === null) return\n const held = before.view\n const namespaces = held.namespaces?.some((candidate) => candidate.ns === row.ns)\n ? held.namespaces.map((candidate) => (candidate.ns === row.ns ? row : candidate))\n : [...(held.namespaces ?? []), row]\n this.store.set({ ...before, view: { ...held, namespaces } })\n }\n\n private async run(): Promise {\n try {\n for (;;) {\n const before = this.store.getSnapshot()\n if (before.status === 'idle') this.store.set({ ...before, status: 'loading' })\n this.rerun = false\n const generation = ++this.generation\n let outcome: { view: SettingsView } | { failure: string }\n try {\n const namespace = this.resolveRemote()\n if (namespace === undefined) throw new Error('the settings remote namespace is not mounted')\n const response = await namespace.describe()\n if (response.ok) {\n const value: unknown = response.value\n // The mirror holds a VIEW or nothing: an empty/malformed answer\n // must not be published as `ready` with an undefined view, or the\n // derive below would read through it. (Upstream guards the same way\n // by testing the held view for `undefined`.)\n outcome = typeof value === 'object' && value !== null\n ? { view: value as SettingsView }\n : { failure: 'settings describe answered no view' }\n } else {\n outcome = { failure: response.error?.message ?? 'settings describe failed' }\n }\n } catch (error: unknown) {\n outcome = { failure: readableErrorText(error) }\n }\n if (generation !== this.generation) continue\n if ('view' in outcome) {\n this.retries = 0\n this.store.set({ status: 'ready', view: outcome.view, error: null })\n } else {\n const held = this.store.getSnapshot()\n this.store.set({\n status: held.view === null ? 'idle' : 'ready',\n view: held.view,\n error: outcome.failure,\n })\n // Nothing held → nothing can heal this but a retry (see\n // SETTINGS_DESCRIBE_RETRY_MS); a held view needs none.\n if (held.view === null) this.scheduleRetry()\n }\n if (!this.rerun) break\n }\n } finally {\n this.inFlight = undefined\n }\n }\n\n /**\n * Schedule the next retry of a failed FIRST read ({@link SETTINGS_DESCRIBE_RETRY_MS}).\n * Bounded: once the ladder is spent the scope keeps its degraded snapshot,\n * exactly as before, until an invalidation or a namespace re-mount arrives.\n */\n private scheduleRetry(): void {\n if (this.disposed || this.retries >= SETTINGS_DESCRIBE_RETRY_MS.length) return\n const delay = SETTINGS_DESCRIBE_RETRY_MS[this.retries]!\n this.retries += 1\n this.retryHandle = this.timer.set(() => {\n this.retryHandle = undefined\n void this.load()\n }, delay)\n }\n\n /** Cancel a pending retry (a live read is starting, or the scope is going away). */\n private clearRetry(): void {\n if (this.retryHandle !== undefined) this.timer.clear(this.retryHandle)\n this.retryHandle = undefined\n }\n\n /** Stop the mirror: no further reads, and no retry may fire after disposal. */\n dispose(): void {\n this.disposed = true\n this.clearRetry()\n }\n}\n\n/**\n * One namespace's derived scope over the shared mirror, plus its serialized\n * Host writes — the face `CommandCodeSettingsController` consumes\n * (`getSnapshot` / `subscribe` / `set` / `unset`).\n */\nclass RemoteSettingsScope implements SettingsScope {\n private readonly resolveRemote: SettingsRemoteResolver\n private readonly store: SnapshotStore>\n private readonly mirror: SettingsDescribeMirror\n private readonly namespace: string\n private tail: Promise = Promise.resolve()\n private writeGeneration = 0\n private disposed = false\n private unsubscribe: (() => void) | undefined\n /** Revision answered by a superseded write still ahead of the mirror. */\n private pendingRevision: number | undefined\n\n constructor(\n resolveRemote: SettingsRemoteResolver,\n mirror: SettingsDescribeMirror,\n namespace: string,\n ) {\n this.resolveRemote = resolveRemote\n this.mirror = mirror\n this.namespace = namespace\n this.store = createSnapshotStore>({\n status: 'loading',\n value: undefined,\n base: undefined,\n user: undefined,\n revision: undefined,\n writable: false,\n mode: 'host',\n })\n this.unsubscribe = mirror.subscribe(() => { this.derive() })\n this.derive()\n }\n\n getSnapshot(): SettingsScopeSnapshot {\n return this.store.getSnapshot()\n }\n\n subscribe(listener: () => void): () => void {\n return this.store.subscribe(listener)\n }\n\n /** Queue one field write (the single-path `set` op form). */\n async set(field: string, value: unknown): Promise {\n await this.mutate([{ op: 'set', path: [field], value }])\n }\n\n /** Queue one field clear (the single-path `unset` op form). */\n async unset(field: string): Promise {\n await this.mutate([{ op: 'unset', path: [field] }])\n }\n\n /**\n * Queue one atomic namespace mutation behind the single-flight fence.\n * A rejected write (e.g. `SETTINGS_CONFLICT`) re-reads the Host so the\n * snapshot reports what actually landed; an accepted one folds its answer\n * in directly — unless a newer write already superseded it, in which case\n * that answer's revision becomes the next write's fence.\n */\n mutate(ops: readonly SettingsPathOp[], expectedRevision?: number): Promise {\n const ownedOps = structuredClone(ops)\n const generation = ++this.writeGeneration\n return this.enqueue(async () => {\n const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision\n const response = await this.mutateRemote(ownedOps, revision)\n if (!response.ok) {\n await this.recover(generation)\n return response.error?.message?.includes('SETTINGS_CONFLICT') ? 'conflict' : 'failed'\n }\n const row = response.value\n if (row?.ns !== this.namespace || decodeRow(row.value) === undefined || typeof row.revision !== 'number') {\n await this.recover(generation)\n return 'failed'\n }\n if (this.disposed) return 'accepted'\n if (generation === this.writeGeneration) {\n this.pendingRevision = undefined\n this.mirror.acceptView(row)\n } else {\n this.pendingRevision = typeof row.revision === 'number' ? row.revision : undefined\n }\n return 'accepted'\n })\n }\n\n private async mutateRemote(\n ops: readonly SettingsPathOp[],\n revision: number | undefined,\n ): Promise> {\n try {\n const namespace = this.resolveRemote()\n if (namespace === undefined) {\n return { ok: false, error: { message: 'the settings remote namespace is not mounted' } }\n }\n return await namespace.mutate(this.namespace, ops, revision)\n } catch (error: unknown) {\n return { ok: false, error: { message: readableErrorText(error) } }\n }\n }\n\n /** Reload Host state for the latest failed write; superseded failures leave recovery to it. */\n private async recover(generation: number): Promise {\n if (this.disposed || generation !== this.writeGeneration) return\n this.pendingRevision = undefined\n await this.mirror.load()\n }\n\n /** Derive this namespace's snapshot from the mirror's held view. */\n private derive(): void {\n if (this.disposed) return\n const mirrored = this.mirror.getSnapshot()\n // `undefined` as well as `null`: the held view is only ever the wire's\n // answer, and a malformed one never reaches the store (see `run`).\n if (mirrored.view === null || mirrored.view === undefined) return\n const writable = mirrored.view.writable === true\n const row = mirrored.view.namespaces?.find((candidate) => candidate.ns === this.namespace)\n if (row === undefined) {\n this.publish({ status: 'unavailable', writable })\n return\n }\n const decoded = decodeRow(row.value)\n if (decoded === undefined) return\n this.publish({\n status: 'ready',\n writable,\n value: decoded as T,\n base: row.base,\n user: row.user,\n revision: typeof row.revision === 'number' ? row.revision : undefined,\n })\n }\n\n /** Publish `next` over the current snapshot, skipping the write when nothing changed. */\n private publish(next: {\n status: SettingsScopeSnapshot['status']\n writable: boolean\n value?: T\n base?: unknown\n user?: unknown\n revision?: number | undefined\n }): void {\n const current = this.store.getSnapshot()\n const candidate: SettingsScopeSnapshot = {\n status: next.status,\n value: 'value' in next ? next.value : current.value,\n base: 'base' in next ? next.base : current.base,\n user: 'user' in next ? next.user : current.user,\n revision: 'revision' in next ? next.revision : current.revision,\n writable: next.writable,\n mode: current.mode,\n }\n if (sameSnapshot(candidate as SettingsScopeSnapshot, current as SettingsScopeSnapshot)) return\n this.store.set(candidate)\n }\n\n /** Queue operations one at a time; disposal makes the queue inert. */\n private enqueue(operation: () => Promise): Promise {\n if (this.disposed) return Promise.resolve('cancelled')\n const task = this.tail.then(async () => {\n if (this.disposed) return 'cancelled' as const\n return operation()\n })\n this.tail = task.then(() => {}, () => {})\n return task\n }\n\n /** Stop queued operations, stop deriving, and wait for the current wire call to settle. */\n async dispose(): Promise {\n this.disposed = true\n this.writeGeneration += 1\n this.unsubscribe?.()\n await this.tail\n }\n}\n\n/** The scope plus the handles the plugin entry wires into its fiber lifecycle. */\nexport interface ManagedSettingsScope extends SettingsScope {\n mutate(ops: readonly SettingsPathOp[], expectedRevision?: number): Promise\n /** Re-read the Host — used when the `remote.settings` namespace mounts after boot. */\n refresh(): void\n /** Stop deriving, drop the forwarded-event subscriptions, and settle in-flight work. */\n dispose(): Promise\n}\n\n/**\n * Build the plugin's settings scope for one namespace.\n *\n * Subscribes the two signals that can move the Host view — the forwarded\n * `settings/document-updated` invalidation and (when the client exposes it)\n * `connection/reset` — kicks the first describe read, and derives the namespace\n * row on every mirror change.\n *\n * `resolveRemote` must be the INJECT-CAPTURED namespace (see\n * {@link SettingsRemoteResolver}), and the caller that owns that inject calls\n * {@link ManagedSettingsScope.refresh} once the namespace lands — that is what\n * turns the first, namespace-less read into a served one. A profile that never\n * mounts the namespace leaves the page in its degraded (`unavailable`,\n * read-only) state instead of throwing.\n *\n * @param namespace - Settings namespace (profile entry id) this scope binds.\n */\nexport function createSettingsScope(\n context: SettingsScopeContext,\n namespace: string,\n resolveRemote: SettingsRemoteResolver,\n timer: SettingsRetryTimer = REAL_SETTINGS_TIMER,\n): ManagedSettingsScope {\n const mirror = new SettingsDescribeMirror(resolveRemote, timer)\n const scope = new RemoteSettingsScope(resolveRemote, mirror, namespace)\n const disposers: Array<() => void> = []\n if (typeof context.remote.$on === 'function') {\n const off = context.remote.$on('settings/document-updated', () => { mirror.ensure() })\n if (typeof off === 'function') disposers.push(off)\n }\n if (typeof context.on === 'function') {\n const off = context.on('connection/reset', () => { void mirror.load() })\n if (typeof off === 'function') disposers.push(off as () => void)\n }\n mirror.ensure()\n return {\n getSnapshot: () => scope.getSnapshot(),\n subscribe: (listener) => scope.subscribe(listener),\n set: (field, value) => scope.set(field, value),\n unset: (field) => scope.unset(field),\n mutate: (ops, revision) => scope.mutate(ops, revision),\n refresh: () => { void mirror.load() },\n dispose: async () => {\n for (const dispose of disposers.splice(0)) dispose()\n // Before the scope: a pending describe retry would otherwise re-read a\n // directory nobody is rendering any more.\n mirror.dispose()\n await scope.dispose()\n },\n }\n}\n","/** 两端共用可编辑超时范围,存储仍是整数毫秒;提示不能借用重试退避上限。 */\nexport const MIN_TIMEOUT_SECONDS = 0.001\nexport const MAX_TIMEOUT_SECONDS = 3600\n","/** 网页、终端与模型目录共用的选择规则;显式开关优先于白名单。 */\nexport function modelIsVisible(id: string, allowlist: readonly string[], overrides: unknown): boolean {\n const flag = overrides !== null && typeof overrides === 'object' && !Array.isArray(overrides)\n ? (overrides as Record)[id] : undefined\n return typeof flag === 'boolean' ? flag : allowlist.length === 0 || allowlist.includes(id)\n}\n\n/** 只接收布尔事实,手工配置中的其他值不能成为可见性开关。 */\nexport function readVisibility(raw: unknown): Record {\n if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {}\n return Object.fromEntries(Object.entries(raw).filter(([id, value]) => id !== '' && typeof value === 'boolean'))\n}\n","/** 自定义上下文长度(issue #86):按模型覆盖优先于全局值,全局值优先于目录公布值。\n * 本模块保持纯函数(无 node 依赖),浏览器设置页与 Host 侧共用同一套边界与判定。 */\n\n/** 自定义上下文长度接受的下界(token);0 或负数不是窗口。 */\nexport const CONTEXT_WINDOW_MIN = 1\n/** 自定义上下文长度接受的上界(token);覆盖任何已知模型窗口仍留有余量。 */\nexport const CONTEXT_WINDOW_MAX = 10_000_000\n\n/** 源头可携带自定义上下文长度的配置面(Config 与连接选项结构相同)。 */\nexport interface ContextWindowOptions {\n /** 全局自定义值:覆盖目录公布值,对没有按模型覆盖的模型生效。 */\n contextWindow?: number | undefined\n /** 按模型覆盖,键为目录模型 id;优先于 {@link contextWindow}。 */\n contextWindowOverrides?: Readonly> | undefined\n}\n\n/**\n * 判定一个值能否作为自定义上下文长度:边界内的安全整数,符合宿主模型元数据约束。\n *\n * 程序化构造的连接可能绕过 schemastery 归一化,所以 `resolveAdapterOptions`\n * 也要重新判定——越界或非数值直接丢弃,回落到目录公布值,而不是让请求带着\n * 一个网关会拒绝的窗口上线。\n */\nexport function isValidContextWindow(value: unknown): value is number {\n return typeof value === 'number' && Number.isSafeInteger(value)\n && value >= CONTEXT_WINDOW_MIN && value <= CONTEXT_WINDOW_MAX\n}\n\n/**\n * 只接收合法条目的按模型覆盖表;空表、非对象与非法数值一律视为未设置。\n * 配置校验会拒绝非整数或越界值;本函数额外保护绕过配置校验的程序化调用,\n * 丢弃坏条目,不把非法窗口交给宿主。返回新对象;未设置时为 `undefined`。\n */\nexport function readContextWindowOverrides(raw: unknown): Readonly> | undefined {\n if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined\n const entries = Object.entries(raw).filter(\n (entry): entry is [string, number] => entry[0] !== '' && isValidContextWindow(entry[1]),\n )\n return entries.length === 0 ? undefined : Object.fromEntries(entries)\n}\n\n/**\n * 一次模型解析的有效上下文长度:按模型覆盖 → 全局值 → 目录公布值。\n *\n * 适配器只在有目录条目时调用(未知模型保持既有的「无上下文宣告」形状),\n * 所以 `catalog` 是已知的正数;按模型覆盖与全局值在 `resolveAdapterOptions`\n * 已清洗,这里对程序化构造的连接再判一次,坏值静默回落到目录值。\n */\nexport function effectiveContextWindow(\n catalog: number,\n model: string,\n options: ContextWindowOptions,\n): number {\n const perModel = options.contextWindowOverrides?.[model]\n if (isValidContextWindow(perModel)) return perModel\n if (isValidContextWindow(options.contextWindow)) return options.contextWindow\n return catalog\n}\n","/**\n * Output-budget bounds shared by the Host, the browser settings page and the\n * adapter (issue #74).\n *\n * The module stays pure (no node imports) so the client bundle can inline it,\n * exactly like `context-window.ts`. The report behind issue #74 asked for a\n * knob that stops the adapter from writing `max_tokens` at all; that is the\n * `0` sentinel here rather than a separate `null` spelling, because the value\n * travels through one numeric settings field on both surfaces.\n */\n\n/**\n * The output budget this bundle asks for, and the ceiling it will never exceed.\n *\n * 131 072, chosen against a measured provider limit rather than a round guess.\n * Probing `/alpha/generate` directly on 2026-09-28: 64 000 → 200, 131 072 → 200,\n * 196 608 → 200, **200 000 → 200, 200 704 → 400**, 1 000 000 → 400. The server\n * therefore caps `max_tokens` at 200 000, and it does so identically for a\n * 262 144-window model (`stealth/pixel-canary`, retired by Command Code on\n * 2026-10-01 and named here only as the probe's subject) and a 1 000 000-window\n * one (`stealth/space-bunny-alpha`) — the cap is global, not per model. 131 072\n * keeps a 35 % margin under it while covering the real output ceiling of the\n * models that have one (Claude 64 K, Gemini 64 K, several at 128 K).\n *\n * The old 64 000 was never a provider requirement: it cost nothing in latency\n * (a 64 000 request answered headers in 1.28 s against 1.57 s for 131 072) but\n * it did cap what a model could ever say in one turn.\n *\n * No client-side clamp is applied on top of this: an earlier version pre-shrank\n * `max_tokens` from an estimate of the prompt already sent (`requestContextBudget()`,\n * issue #67), but that estimate over-corrected on ordinary long conversations —\n * exactly the \"output gets cut short as the conversation grows\" symptom issue #74\n * reported — while a live A/B probe (2026-09-30, `stealth/space-bunny-alpha`,\n * ~19.6% over its 1,000,000-token window with no client-side shrink) showed the\n * endpoint answers a genuine overflow with an unambiguous `400 … exceeds the\n * model's context window`, which `isContextOverflowDetail()` classifies as\n * `CONTEXT_WINDOW_EXCEEDED` independently of this constant.\n *\n * A user-set positive budget can only TIGHTEN this, never raise it — a figure\n * above the cap would be refused by the gateway anyway, so the settings field\n * refuses it at the input instead of spending a round trip to learn that.\n *\n * Lives here rather than in `adapter.ts` so the browser settings page can bound\n * its input by the same figure the Host enforces; `adapter.ts` re-exports it\n * under its historical name.\n */\nexport const MAX_OUTPUT_TOKEN_LIMIT = 131_072\n\n/**\n * The `maxOutputTokens` value that asks the adapter to send NO `max_tokens`\n * field at all, leaving the per-turn output size to the platform (issue #74).\n * Only the CLI and OpenAI transports can honour it: Anthropic's Messages\n * endpoint requires the field, so that route keeps sending its derived budget.\n *\n * This is also the field's lower bound, which is why no separate `MIN_` constant\n * exists: zero is not a small budget, it is the instruction.\n */\nexport const OMIT_OUTPUT_TOKENS = 0\n\n/**\n * Whether a value can serve as a user output budget: the omit sentinel, or a\n * whole token count inside the gateway's own ceiling.\n *\n * Programmatically built connections can bypass schemastery normalization, so\n * the resolve step re-judges every value here and drops an out-of-range one\n * back to \"unset\" instead of letting the request carry a budget the gateway\n * refuses.\n */\nexport function isValidMaxOutputTokens(value: unknown): value is number {\n return typeof value === 'number' && Number.isSafeInteger(value)\n && value >= OMIT_OUTPUT_TOKENS && value <= MAX_OUTPUT_TOKEN_LIMIT\n}\n\n/**\n * Apply a user output budget to a derived one.\n *\n * `undefined` means the user set nothing, so the derived ceiling stands\n * unchanged — the default path, and the one every existing profile takes.\n * {@link OMIT_OUTPUT_TOKENS} is a wire instruction rather than a number, so it\n * also leaves the derived budget alone; {@link omitOutputTokens} is what turns\n * it into an absent field.\n */\nexport function applyMaxOutputTokens(derived: number, configured: number | undefined): number {\n if (configured === undefined || configured === OMIT_OUTPUT_TOKENS) return derived\n return Math.min(derived, configured)\n}\n\n/**\n * Whether this attempt must send no `max_tokens` field at all.\n *\n * Two guards narrow this beyond the user's own setting, and both are\n * correctness requirements rather than conveniences:\n *\n * - The Messages route is excluded deliberately: the endpoint rejects a body\n * without the field (`Invalid input: expected number, received undefined at\n * max_tokens`), so omitting it there would not lengthen an answer, it would\n * make the whole Claude family unusable. The CLI and OpenAI transports accept\n * an absent field, which is where the setting does what it promises.\n * - An explicit per-call budget from the host is an instruction, not a default,\n * so it wins over the plugin-wide setting. `session-title` is the concrete\n * case: it asks for 64 tokens precisely because its output must stay bounded,\n * and dropping the field there would let a title generation run long instead\n * of making a long answer fit (issue #88's fix depends on that bound).\n */\nexport function omitOutputTokens(\n configured: number | undefined,\n protocol: string,\n hostRequestedTokens?: number,\n): boolean {\n if (configured !== OMIT_OUTPUT_TOKENS) return false\n if (protocol === 'messages') return false\n return hostRequestedTokens === undefined\n}\n","/** 设置写入过程:同一控制器的意图按顺序执行,配置确认与凭据确认分别保留。\n * 生产使用现有远端设置/凭据,测试替换这些依赖;不提供跨服务事务。 */\nimport { readVisibility } from '../model-visibility.ts'\nimport type { SettingsPageApi, SettingsScope, SettingsScopeSnapshot } from './settings.ts'\nimport type { SettingsMutationResult, SettingsPathOp } from './settings-scope.ts'\n\nexport const DEFAULT_API_KEY_REF = 'COMMANDCODE_API_KEY'\nexport interface StoredRule { models: string[]; account: string }\n\n/** 与宿主路由的第一条匹配规则一致,重复模型只归属于先出现的账号。 */\nexport function accountModelMap(rules: readonly StoredRule[]): Map {\n const claimed = new Set()\n const map = new Map()\n for (const rule of rules) {\n const list = map.get(rule.account) ?? []\n for (const model of rule.models) {\n if (claimed.has(model)) continue\n claimed.add(model)\n list.push(model)\n }\n map.set(rule.account, list)\n }\n return map\n}\n\nexport interface SettingsWriteResult {\n config: 'none' | 'confirmed' | 'conflict' | 'unconfirmed'\n credential: 'none' | 'not-issued' | 'confirmed' | 'unconfirmed'\n issue: 'target-changed' | 'closed' | 'cleanup-pending' | undefined\n refreshFailed: boolean\n /** 实际发出调用的原引用,仅用于核对;从不包含密钥内容。 */\n credentialRef: string | undefined\n}\n\nexport type SettingsWriteIntent =\n | { kind: 'save'; ops: readonly SettingsPathOp[]; visibleModels?: readonly string[]; contextWindowOverrides?: Record; key?: string }\n | { kind: 'rename'; ref: string; label: string }\n | { kind: 'remove' | 'cleanup'; ref: string }\n | { kind: 'key'; target: string; value?: string }\n | { kind: 'active'; id: string }\n | { kind: 'models'; target: string; models: readonly string[] }\n\ntype Snapshot = SettingsScopeSnapshot>\ntype CredentialInfo = { configured: boolean; writable: boolean }\ntype FieldBaseline = { value: unknown; base: unknown; user: unknown; overridden: boolean }\ninterface Command {\n intent: SettingsWriteIntent\n ops: readonly SettingsPathOp[]\n baseline: Map\n defaultRef: string | undefined\n}\ninterface QueuedWrite { command: Command; resolve(result: SettingsWriteResult): void }\n\nconst record = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}\nconst result = (): SettingsWriteResult => ({ config: 'none', credential: 'none', issue: undefined, refreshFailed: false, credentialRef: undefined })\nconst closedResult = (): SettingsWriteResult => ({ ...result(), issue: 'closed' })\nconst fieldBaseline = (snapshot: Snapshot, field: string): FieldBaseline => ({\n value: snapshot.value?.[field], base: record(snapshot.base)[field], user: record(snapshot.user)[field],\n overridden: Object.hasOwn(record(snapshot.user), field),\n})\n\n/** 不用显示文本比较配置;数组顺序与显式覆盖/继承也是写入事实。 */\nfunction sameValue(a: unknown, b: unknown): boolean {\n if (Object.is(a, b)) return true\n if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, i) => sameValue(value, b[i]))\n if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false\n const left = record(a), right = record(b)\n const keys = Object.keys(left)\n return keys.length === Object.keys(right).length && keys.every(key => Object.hasOwn(right, key) && sameValue(left[key], right[key]))\n}\n\nexport function writeCompleted(outcome: SettingsWriteResult): boolean {\n return outcome.issue === undefined && outcome.config !== 'conflict' && outcome.config !== 'unconfirmed'\n && outcome.credential !== 'not-issued' && outcome.credential !== 'unconfirmed'\n}\n\nexport class SettingsWriter {\n private readonly pending: QueuedWrite[] = []\n private running = false\n private closed = false\n private readonly facts = new Map()\n /** 每个引用独立的读写世代,防止旧描述覆盖随后确认的凭据修改。 */\n private readonly factVersions = new Map()\n private refreshGeneration = 0\n private refreshFailed = false\n\n constructor(\n private readonly scope: SettingsScope>,\n private readonly credentials: SettingsPageApi['credentials'],\n private readonly onFactsChanged: () => void,\n ) {}\n\n credential(ref: string): CredentialInfo | undefined { return this.facts.get(ref) }\n factsRefreshFailed(): boolean { return this.refreshFailed }\n\n /** 在入队时捕获意图与目标;执行者不能重新读取页面草稿。 */\n submit(intent: SettingsWriteIntent): Promise {\n if (this.closed) return Promise.resolve(closedResult())\n const owned = structuredClone(intent)\n const snapshot = this.scope.getSnapshot()\n const ops = owned.kind === 'save' ? [...owned.ops] : []\n if (owned.kind === 'save' && owned.visibleModels !== undefined) {\n const list = [...owned.visibleModels]\n const keys = new Set()\n for (const layer of [snapshot.base, snapshot.user, snapshot.value]) {\n for (const id of Object.keys(readVisibility(record(layer).modelVisibility))) keys.add(id)\n }\n ops.push({ op: 'set', path: ['visibleModels'], value: list },\n { op: 'set', path: ['modelVisibility'], value: Object.fromEntries([...keys].map(id => [id, list.length === 0 || list.includes(id)])) })\n }\n // 自定义上下文长度(issue #86):整表一次写入(含清空后的空表,写在\n // 用户层以遮蔽基础层,而不是 unset 让基础层重新露出)。\n if (owned.kind === 'save' && owned.contextWindowOverrides !== undefined) {\n ops.push({ op: 'set', path: ['contextWindowOverrides'], value: owned.contextWindowOverrides })\n }\n const baseline = new Map(ops.map(op => [op.path[0]!, structuredClone(fieldBaseline(snapshot, op.path[0]!))]))\n const defaultRef = (owned.kind === 'save' && owned.key !== undefined) || (owned.kind === 'key' && owned.target === 'default')\n ? this.defaultRef() : undefined\n return new Promise(resolve => {\n this.pending.push({ command: { intent: owned, ops, baseline, defaultRef }, resolve })\n if (!this.running) void this.drain()\n })\n }\n\n /** 销毁不等待远端;未发出的任务立即结束,已发调用只能自然收束。 */\n dispose(): void {\n this.closed = true\n for (const task of this.pending.splice(0)) task.resolve(closedResult())\n this.facts.clear()\n this.factVersions.clear()\n }\n\n private async drain(): Promise {\n this.running = true\n try {\n for (;;) {\n const task = this.pending.shift()\n if (task === undefined) break\n let outcome: SettingsWriteResult\n try { outcome = this.closed ? closedResult() : await this.execute(task.command) }\n catch { outcome = { ...result(), config: 'unconfirmed' } }\n task.resolve(outcome)\n }\n } finally { this.running = false }\n }\n\n private defaultRef(): string {\n const ref = this.scope.getSnapshot().value?.apiKeyEnv\n return typeof ref === 'string' && ref !== '' ? ref : DEFAULT_API_KEY_REF\n }\n private value(field: string): unknown { return this.scope.getSnapshot().value?.[field] }\n private accounts(): Record[] {\n const raw = this.value('accounts')\n return Array.isArray(raw) ? raw.filter(item => item !== null && typeof item === 'object' && !Array.isArray(item)) as Record[] : []\n }\n private rules(): StoredRule[] {\n const raw = this.value('modelAccountRules')\n if (!Array.isArray(raw)) return []\n return raw.flatMap(item => {\n const rule = record(item)\n const models = Array.isArray(rule.models) ? rule.models.filter((id): id is string => typeof id === 'string' && id !== '') : []\n return models.length ? [{ models, account: typeof rule.account === 'string' && rule.account !== '' ? rule.account : 'default' }] : []\n })\n }\n private cleanupRefs(): string[] {\n const raw = this.value('credentialCleanupRefs')\n return Array.isArray(raw) ? [...new Set(raw.filter((ref): ref is string => typeof ref === 'string' && ref !== ''))] : []\n }\n private bump(ref: string): number {\n const next = (this.factVersions.get(ref) ?? 0) + 1\n this.factVersions.set(ref, next)\n return next\n }\n\n /** 只刷新事实;此入口永远不重放凭据修改。缺少条目不能解释为不存在。 */\n async refreshCredentials(extra: readonly string[] = []): Promise {\n if (this.closed) return false\n const refs = [...new Set([this.defaultRef(), ...this.accounts().flatMap(account => typeof account.apiKeyEnv === 'string' ? [account.apiKeyEnv] : []), ...extra])]\n const generation = ++this.refreshGeneration\n const versions = new Map(refs.map(ref => [ref, this.bump(ref)]))\n let success = false\n try {\n const response = await this.credentials.describe(refs)\n if (this.closed) return false\n success = response.ok && refs.every(ref => typeof response.value?.[ref]?.configured === 'boolean')\n success = success && refs.every(ref => this.factVersions.get(ref) === versions.get(ref))\n if (success) for (const ref of refs) {\n if (this.factVersions.get(ref) !== versions.get(ref)) continue\n const view = response.value![ref]!\n this.facts.set(ref, { configured: view.configured, writable: view.writable ?? true })\n }\n } catch { /* 未读到新事实时保留最后已确认结果,不猜测不存在。 */ }\n if (!this.closed && generation === this.refreshGeneration) {\n this.refreshFailed = !success\n this.onFactsChanged()\n }\n return success\n }\n\n private async commit(ops: readonly SettingsPathOp[], baseline?: ReadonlyMap): Promise {\n if (this.closed) return 'cancelled'\n const snapshot = this.scope.getSnapshot()\n if (!snapshot.writable || this.scope.mutate === undefined) return 'failed'\n if (baseline && [...baseline].some(([field, before]) => !sameValue(before, fieldBaseline(snapshot, field)))) return 'conflict'\n try { return await this.scope.mutate(ops, snapshot.revision) }\n catch { return 'failed' }\n }\n\n private async configure(outcome: SettingsWriteResult, ops: readonly SettingsPathOp[], baseline?: ReadonlyMap): Promise {\n if (ops.length === 0) return true\n const confirmation = await this.commit(ops, baseline)\n outcome.config = confirmation === 'accepted' ? 'confirmed' : confirmation === 'conflict' ? 'conflict' : 'unconfirmed'\n if (confirmation === 'cancelled' || this.closed) outcome.issue = 'closed'\n return confirmation === 'accepted' && !this.closed\n }\n\n private async changeCredential(outcome: SettingsWriteResult, ref: string, value: string | undefined, defaultRef?: string): Promise {\n outcome.credential = 'not-issued'\n if (this.closed) { outcome.issue = 'closed'; return }\n if (defaultRef !== undefined && this.defaultRef() !== defaultRef) { outcome.issue = 'target-changed'; return }\n outcome.credentialRef = ref\n this.bump(ref)\n try {\n const response = value === undefined ? await this.credentials.unset(ref) : await this.credentials.set(ref, value)\n outcome.credential = response.ok ? 'confirmed' : 'unconfirmed'\n } catch { outcome.credential = 'unconfirmed' }\n if (this.closed) { outcome.issue = 'closed'; return }\n this.bump(ref)\n // 远端写入确认说明引用已修改;后续读回不提供密钥内容校验。\n if (outcome.credential === 'confirmed') {\n this.facts.set(ref, { configured: value !== undefined, writable: this.facts.get(ref)?.writable ?? true })\n this.onFactsChanged()\n }\n if (defaultRef !== undefined && this.defaultRef() !== defaultRef) outcome.issue = 'target-changed'\n outcome.refreshFailed = !(await this.refreshCredentials([ref]))\n if (defaultRef !== undefined && this.defaultRef() !== defaultRef) outcome.issue = 'target-changed'\n if (this.closed) outcome.issue = 'closed'\n }\n\n private async execute(command: Command): Promise {\n const outcome = result()\n const intent = command.intent\n if (!this.scope.getSnapshot().writable) return {\n ...outcome,\n config: intent.kind === 'key' || (intent.kind === 'save' && command.ops.length === 0) ? 'none' : 'unconfirmed',\n credential: intent.kind === 'key' || (intent.kind === 'save' && intent.key !== undefined) ? 'not-issued' : 'none',\n }\n if (intent.kind === 'save') {\n if (intent.key !== undefined) outcome.credential = 'not-issued'\n if (!(await this.configure(outcome, command.ops, command.baseline))) return outcome\n if (intent.key !== undefined) await this.changeCredential(outcome, command.defaultRef!, intent.key, command.defaultRef)\n } else if (intent.kind === 'key') {\n if (intent.value !== undefined && intent.value.trim() === '') return { ...outcome, credential: 'not-issued' }\n await this.changeCredential(outcome, command.defaultRef ?? intent.target, intent.value, command.defaultRef)\n } else if (intent.kind === 'rename') {\n if (intent.label === '') return { ...outcome, config: 'unconfirmed' }\n await this.configure(outcome, [{ op: 'set', path: ['accounts'], value: this.accounts().map(account => account.apiKeyEnv === intent.ref ? { ...account, label: intent.label } : { ...account }) }])\n } else if (intent.kind === 'active') {\n // 恢复自动轮换与恢复继承不同;基础层有固定账号时必须显式覆盖为空。\n const base = record(this.scope.getSnapshot().base).activeAccount\n const op: SettingsPathOp = intent.id !== '' || (typeof base === 'string' && base !== '')\n ? { op: 'set', path: ['activeAccount'], value: intent.id } : { op: 'unset', path: ['activeAccount'] }\n await this.configure(outcome, [op])\n } else if (intent.kind === 'models') {\n const chosen = [...new Set(intent.models.filter(id => id !== ''))]\n const taken = new Set(chosen)\n const map = accountModelMap(this.rules())\n for (const [account, models] of map) if (account !== intent.target) map.set(account, models.filter(id => !taken.has(id)))\n map.set(intent.target, chosen)\n const rules = [...map].flatMap(([account, models]) => models.length ? [{ account, models }] : [])\n await this.configure(outcome, [{ op: 'set', path: ['modelAccountRules'], value: rules }])\n } else {\n if (intent.kind === 'remove') {\n if (!this.accounts().some(account => account.apiKeyEnv === intent.ref) && !this.cleanupRefs().includes(intent.ref)) return { ...outcome, config: 'unconfirmed' }\n const snapshot = this.scope.getSnapshot()\n const ops: SettingsPathOp[] = [\n { op: 'set', path: ['accounts'], value: this.accounts().filter(account => account.apiKeyEnv !== intent.ref).map(account => ({ ...account })) },\n { op: 'set', path: ['modelAccountRules'], value: this.rules().filter(rule => rule.account !== intent.ref) },\n { op: 'set', path: ['credentialCleanupRefs'], value: [...new Set([...this.cleanupRefs(), intent.ref])] },\n ]\n if (this.value('activeAccount') === intent.ref) ops.push(record(snapshot.base).activeAccount === intent.ref\n ? { op: 'set', path: ['activeAccount'], value: '' } : { op: 'unset', path: ['activeAccount'] })\n if (!(await this.configure(outcome, ops))) return outcome\n }\n await this.cleanRemoved(outcome, intent.ref)\n }\n return outcome\n }\n\n private async cleanRemoved(outcome: SettingsWriteResult, ref: string): Promise {\n if (!this.cleanupRefs().includes(ref)) {\n if (outcome.config !== 'confirmed') outcome.config = 'unconfirmed'\n outcome.issue = 'cleanup-pending'\n return\n }\n if (this.closed) { outcome.issue = 'closed'; return }\n if (!(await this.refreshCredentials([ref]))) { outcome.credential = 'not-issued'; outcome.refreshFailed = true; return }\n if (this.closed) { outcome.issue = 'closed'; return }\n const inherited = record(this.scope.getSnapshot().base).accounts\n const referenced = ref === this.defaultRef() || this.accounts().some(account => account.apiKeyEnv === ref)\n || (Array.isArray(inherited) && inherited.some(account => record(account).apiKeyEnv === ref))\n || this.rules().some(rule => rule.account === ref) || this.value('activeAccount') === ref\n if (!referenced && this.facts.get(ref)?.configured === true) {\n await this.changeCredential(outcome, ref, undefined)\n if (!writeCompleted(outcome) || outcome.refreshFailed) {\n if (outcome.issue === undefined && outcome.credential === 'confirmed') outcome.issue = 'cleanup-pending'\n return\n }\n }\n if (this.closed) { outcome.issue = 'closed'; return }\n // 最后一步失败不能抹掉前面已经确认的账号移除/凭据清理事实。\n const cleanup = result()\n if (!(await this.configure(cleanup, [{ op: 'set', path: ['credentialCleanupRefs'], value: this.cleanupRefs().filter(entry => entry !== ref) }]))) {\n if (outcome.config === 'none') outcome.config = cleanup.config\n outcome.issue = cleanup.issue ?? 'cleanup-pending'\n } else if (outcome.config === 'none') outcome.config = cleanup.config\n }\n}\n","/** 设置页面控制器只负责输入校验、草稿版本与页面投影。\n * 保存及即时账号操作由同一个设置写入过程持有;设置与凭据分别确认。 */\n\nimport { MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS } from '../timeout-limits.ts'\nimport { modelIsVisible, readVisibility } from '../model-visibility.ts'\nimport { CONTEXT_WINDOW_MAX, CONTEXT_WINDOW_MIN, isValidContextWindow, readContextWindowOverrides } from '../context-window.ts'\nimport { MAX_OUTPUT_TOKEN_LIMIT, OMIT_OUTPUT_TOKENS, isValidMaxOutputTokens } from '../output-budget.ts'\nimport type { SettingsMutationResult, SettingsPathOp } from './settings-scope.ts'\nimport { SettingsWriter, DEFAULT_API_KEY_REF, accountModelMap, writeCompleted, type StoredRule, type SettingsWriteIntent, type SettingsWriteResult } from './settings-write.ts'\nexport { DEFAULT_API_KEY_REF, accountModelMap } from './settings-write.ts'\n\n/** The settings namespace the plugin registers (host half, src/index.ts). */\nexport const COMMANDCODE_NS = 'llm-commandcode'\n\n/** The settings-scope snapshot fields consumed by this controller. */\nexport interface SettingsScopeSnapshot {\n status: 'loading' | 'ready' | 'unavailable'\n value: T | undefined\n base: unknown\n user: unknown\n revision: number | undefined\n writable: boolean\n mode: 'host'\n}\n\n/** Current settings-scope service face used without importing a browser plugin value. */\nexport interface SettingsScope {\n getSnapshot(): SettingsScopeSnapshot\n subscribe(listener: () => void): () => void\n set(field: string, value: unknown): Promise\n unset(field: string): Promise\n /** 多字段共用一次宿主版本检查并返回确认;缺少此能力不能提交配置。 */\n mutate?(ops: readonly SettingsPathOp[], expectedRevision?: number): Promise\n}\n\n/** Result envelope returned by one current Typert Remote call. */\ninterface RemoteResult {\n ok: boolean\n value?: T\n error?: { message: string }\n}\n\n/** Credential facts returned without exposing the credential value. */\ninterface CredentialInfo {\n configured: boolean\n writable: boolean\n}\n\n/** The narrow slice of the wire face this controller needs. */\nexport interface SettingsPageApi {\n /** Credential methods exposed by the current Typert Remote namespace. */\n credentials: {\n describe(refs: string[]): Promise>>\n set(ref: string, value: string): Promise>\n unset(ref: string): Promise>\n }\n /**\n * The model catalog for the model editors (Host-side). Optional: a transport\n * without the Remote mount leaves the editors on the empty-catalog state.\n */\n models?(): Promise>\n}\n\n/** One editable text field's staged state (blank = keep stored value). */\nexport interface StagedField {\n /** Live draft text the input shows. */\n text: string\n /** Whether the user explicitly cleared the field (reset to inherited). */\n clear: boolean\n /** Whether the user layer carries this field (marks it overridden). */\n overridden: boolean\n /** Whether the staged draft fails to parse (blocks save). */\n invalid: boolean\n /** `format`, `tooSmall`/`tooLarge`; undefined when valid. */\n invalidReason: InvalidReason | undefined\n}\n\n/** Why a staged draft fails validation (drives the per-field error copy). */\nexport type InvalidReason = 'format' | 'tooSmall' | 'tooLarge'\n\n/**\n * One per-model context-length override row (issue #86): the model's id with\n * the staged token text its input shows. Blank text means \"no override once\n * saved\" (the row drops out); an unparseable draft blocks save.\n */\nexport interface ContextOverrideRow {\n /** Catalog model id; one override per model. */\n model: string\n /** Live draft text the input shows. */\n text: string\n /** Whether the staged draft fails to parse (blocks save). */\n invalid: boolean\n /** `format`, `tooSmall`/`tooLarge`; undefined when valid. */\n invalidReason: InvalidReason | undefined\n}\n\n/**\n * One extra account row. Account management commits immediately (no page\n * save), so a row carries only Host facts — never a staged draft.\n */\nexport interface AccountItemState {\n /** Stable id — the account's credential reference (also its slot id). */\n id: string\n ref: string\n /** Stored label (falls back to the reference). */\n label: string\n configured: boolean\n credentialKnown: boolean\n writable: boolean\n}\n\n/** The immediate account operations, named for the failure copy. */\nexport type AccountOperation = 'rename' | 'remove' | 'key' | 'active' | 'models'\n\n/** One selectable catalog model in the settings page's model editors. */\nexport interface CatalogModelOption {\n id: string\n name: string\n /**\n * Minimum plan-tier key (a Host `KNOWN_PLANS` value), or undefined for\n * models outside the snapshot / older Hosts. Drives the tier headings in\n * the editor dropdowns; absent tiers render unheaded.\n */\n tier?: string\n /**\n * Per-model MONTHLY allowance in USD, already resolved Host-side against the\n * account pool's highest plan (Go, GOAT and Pro publish one). Undefined for\n * plans without an allowance, for models the page has no row for, and for\n * older Hosts — the row then shows no allowance instead of a guess.\n */\n allowance?: number\n}\n\n/** The page's full state face, projected from the scope + drafts + credential. */\nexport interface SettingsPageState {\n /** Whether the namespace snapshot is ready. */\n available: boolean\n /** Whether the Host document accepts writes. */\n writable: boolean\n /** Whether the API key is currently configured (Host-reported). */\n apiKeyConfigured: boolean\n /** 没有描述或写入确认时状态未知,不能显示为未配置。 */\n apiKeyKnown: boolean\n /** Whether ANY account (default or extra) has a stored key — gates the usage card. */\n anyAccountConfigured: boolean\n /** Whether the credentials domain can store the key. */\n apiKeyWritable: boolean\n /**\n * The default API key draft (write-only; starts blank, never echoes the\n * stored key). Only the Models-page card stages it for its own save; the\n * settings page writes keys immediately through `setAccountKey()`.\n */\n apiKey: StagedField\n apiBase: StagedField\n requestTimeoutMs: StagedField\n streamIdleTimeoutMs: StagedField\n /**\n * transportMaxRetries draft: how many transport failures one request absorbs\n * before the failure surfaces (issue #39's second report — the retry policy's\n * 1000-attempt, 15-minute cadence turned a 10-second TCP connect timeout into\n * an ~8-minute stall). Unset means the Host default, so it stages like the\n * timeout fields above rather than as a toggle with an implicit default.\n */\n transportMaxRetries: StagedField\n /** Opt-in durable offload of images after this model has answered. */\n offloadSeenImagesForCache: StagedField\n /** filterModelsByPlan draft; `''` = \"inherit the default\" (on). */\n filterModelsByPlan: StagedField\n /** webSearch draft; `''` = \"inherit the default\" (on — this route serves dsh's web_search). */\n webSearch: StagedField\n /** showSidebarQuota draft; `''` = \"inherit the default\" (off — the card is opt-in). */\n showSidebarQuota: StagedField\n /** zdr draft; `''` = \"inherit the default\" (off — ZDR is opt-in). */\n zdr: StagedField\n /**\n * Whether the STORED document turns the sidebar quota card on\n * (`showSidebarQuota === true`). Deliberately NOT the staged draft above: the\n * page has an explicit Save, and a card that appeared from an unsaved draft\n * would outlive a discarded edit (staging lives as long as the client does)\n * until the next page load.\n */\n sidebarQuota: boolean\n /** The stored pinned account: a slot id; `''` = \"auto — first usable account\". */\n activeAccount: string\n /** Extra accounts (multi-account rotation), in rotation order. */\n accounts: AccountItemState[]\n /**\n * Each account's dedicated models, keyed by slot id, derived from the stored\n * `modelAccountRules` with the runtime's first-match-wins semantics, so a\n * model sits under exactly the one account that would serve it.\n */\n accountModels: Record\n /** Whether an immediate account operation is in flight. */\n accountBusy: boolean\n /** The last immediate account operation that failed (cleared by the next one). */\n accountFailed: AccountOperation | undefined\n /** 已移除账户的待清理凭据引用,来自持久化配置,刷新后仍能重试。 */\n pendingCredentialCleanup: string[]\n /** 宿主开通尚未完成的引用,不展示可操作的账号行。 */\n accountEnrollmentRefs: string[]\n /** Effective visible-model allowlist: staged draft or stored value. Empty = show all. */\n visibleModels: string[]\n /** 区分不受限制与终端显式隐藏全部;不能用空数组推断显示全部。 */\n visibleModelsAll: boolean\n /** Global custom context-length draft; `''` = follow the catalog (issue #86). */\n contextWindow: StagedField\n /** Single-turn output budget draft; `''` = the derived default, `0` = omit the field (issue #74). */\n maxOutputTokens: StagedField\n /** Per-model context-length rows: staged draft or stored values, one per model. */\n contextWindowOverrides: ContextOverrideRow[]\n /** The catalog the model editors offer (Host-side, empty until loaded). */\n catalogModels: CatalogModelOption[]\n /** Whether the catalog fetch failed (editors fall back to typing). */\n catalogFailed: boolean\n /** Whether any staged edit differs from the stored section. */\n dirty: boolean\n /** Whether a staged numeric field fails to parse (save blocked). */\n invalid: boolean\n /** Whether a save is in flight. */\n saving: boolean\n /** Whether the last save failed (drafts retained for correction). */\n failed: boolean\n /** Monotonic counter bumped once per accepted save (the component flashes on it). */\n savedCount: number\n /** 本次写入各阶段的确认事实,不用整体失败推断回滚。 */\n saveResult: SettingsWriteResult | undefined\n accountResult: SettingsWriteResult | undefined\n credentialRefreshFailed: boolean\n}\n\n/** Parsed outcome of one field's draft. */\ntype Parsed =\n | { kind: 'set'; value: string | number | boolean }\n | { kind: 'clear' }\n | { kind: 'invalid'; reason: InvalidReason }\n\n/** One field's staged draft (internal; the public face adds derived flags). */\ninterface Staged {\n text: string\n clear: boolean\n}\n\n/** A field conversion spec. */\ninterface FieldSpec {\n field: string\n format(value: unknown): string\n parse(text: string): Parsed\n}\n\n/** A free-text field; an empty draft clears it. */\nfunction textField(field: string): FieldSpec {\n return {\n field,\n format: (value) => (typeof value === 'string' ? value : ''),\n parse: (text) => {\n const trimmed = text.trim()\n return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }\n },\n }\n}\n\n/**\n * A numeric field; an empty draft clears it, anything non-numeric blocks\n * save, and an optional inclusive `bounds` range rejects out-of-range values\n * with a specific reason (the Host schema would reject them at save time with\n * only a generic failure — catching it here names the problem while typing).\n * Decimals pass, like the Host schema's `z.number()`.\n */\nfunction numberField(field: string, bounds?: { min?: number; max?: number }): FieldSpec {\n return {\n field,\n format: (value) => (typeof value === 'number' ? String(value) : ''),\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return { kind: 'clear' }\n const parsed = Number(trimmed)\n if (!Number.isFinite(parsed)) return { kind: 'invalid', reason: 'format' }\n if (bounds?.min !== undefined && parsed < bounds.min) return { kind: 'invalid', reason: 'tooSmall' }\n if (bounds?.max !== undefined && parsed > bounds.max) return { kind: 'invalid', reason: 'tooLarge' }\n return { kind: 'set', value: parsed }\n },\n }\n}\n\n/**\n * The compact token-count shorthand (`32K`, `1M`) in BINARY units, the way\n * context windows are quoted: 32K → 32 768 (32 × 1024), 128K → 131 072,\n * 1M → 1 048 576 (1024 × 1024), so the common power-of-two windows land on\n * their exact values instead of 1000-based rounding. A leading sign is not part\n * of the grammar, so this path can only produce values ≥ 0.\n */\nconst COMPACT_TOKENS = /^(\\d+(?:\\.\\d+)?)\\s*([kKmM])$/\n\n/**\n * A whole-token count field with optional compact shorthand, shared by the\n * context-length and output-budget inputs (issues #86, #74).\n *\n * Plain text goes through the {@link numberField} parse plus the caller's\n * whole-token validator; shorthand converts to whole tokens first\n * (`Math.round` keeps fractional shorthand like `1.5K` on whole tokens) and is\n * then bounds-checked against the SAME `bounds`, so an overshoot names\n * `tooLarge` here instead of reaching the Host schema, which would reject it at\n * save time with only a generic failure. Empty clears in both paths.\n *\n * `isValid` is what tells the two apart: context lengths start at 1, while the\n * output budget's lower bound IS its omit sentinel (`0` means \"send no\n * `max_tokens`\"), so no single numeric range can serve both.\n */\nfunction tokenCountField(\n field: string,\n bounds: { min: number; max: number },\n isValid: (value: unknown) => boolean,\n): FieldSpec {\n const plain = numberField(field, bounds)\n return {\n field,\n format: plain.format,\n parse: (text) => {\n const compact = COMPACT_TOKENS.exec(text.trim())\n if (compact === null) {\n const parsed = plain.parse(text)\n // 时长字段允许小数,词元计数则必须是整数;不能直接沿用通用数字字段的放行结果。\n return parsed.kind === 'set' && !isValid(parsed.value)\n ? { kind: 'invalid', reason: 'format' }\n : parsed\n }\n const value = Math.round(Number(compact[1]) * (compact[2]!.toLowerCase() === 'k' ? 1024 : 1024 * 1024))\n if (value < bounds.min) return { kind: 'invalid', reason: 'tooSmall' }\n if (value > bounds.max) return { kind: 'invalid', reason: 'tooLarge' }\n // No whole-token re-check here: the grammar cannot produce a negative or a\n // fraction, and `bounds.max` already caps the value, so a validator that\n // only tests integrality-plus-range is settled by the two checks above.\n return { kind: 'set', value }\n },\n }\n}\n\n/**\n * A boolean field, staged as the strings `'true'`/`'false'` (an empty draft\n * clears it). The component renders a toggle and only ever stages these two\n * strings; anything else blocks save.\n */\nfunction booleanField(field: string): FieldSpec {\n return {\n field,\n format: (value) => (typeof value === 'boolean' ? String(value) : ''),\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return { kind: 'clear' }\n if (trimmed === 'true') return { kind: 'set', value: true }\n if (trimmed === 'false') return { kind: 'set', value: false }\n return { kind: 'invalid', reason: 'format' }\n },\n }\n}\n\n/**\n * The single-turn output budget (issue #74).\n *\n * The range INCLUDES `0`, and `0` is not a budget: it is the sentinel asking\n * the adapter to send no `max_tokens` field at all, leaving the per-turn output\n * size to the platform. The upper bound is the gateway's own cap\n * ({@link MAX_OUTPUT_TOKEN_LIMIT}) rather than an arbitrary one — a larger\n * figure would simply be refused, so the input is where that is worth saying. A\n * blank draft clears the setting back to the derived default, which is the\n * behaviour every existing profile has.\n */\nfunction maxOutputTokensField(field: string): FieldSpec {\n return tokenCountField(\n field,\n { min: OMIT_OUTPUT_TOKENS, max: MAX_OUTPUT_TOKEN_LIMIT },\n isValidMaxOutputTokens,\n )\n}\n\n/**\n * The second-based bounds the timeout fields are EDITED in.\n *\n * The stored unit stays milliseconds — that is the Host schema\n * (`z.number().min(1).max(MAX_TIMER_DELAY_MS)` in src/index.ts, dsh-timeout's\n * 2^31-1 timer ceiling), and switching it would break every existing profile —\n * so this is presentation only: the field formats ms ÷ 1000 on the way in and\n * multiplies by 1000 on the way out. 3600 rather than the Host's ceiling (~24.8\n * days), because a header wait longer than an hour is a hung process, not a\n * budget, and the input is the place to refuse it. A hand-written profile can\n * still carry a larger value; `secondsField` displays that true number rather\n * than silently clamping, it just cannot be re-typed here.\n */\nexport { MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS } from '../timeout-limits.ts'\n\n/**\n * 秒制输入保留毫秒精度,兼容已有 1500ms、1ms 等配置。\n *\n * Milliseconds are the wrong unit to show: the defaults are 300000 and the\n * ceiling is 2147483647, so the page asked users to do arithmetic to answer\n * \"how long is a reasonable wait\". `format` divides, `parse` multiplies, and\n * the stored value never leaves milliseconds.\n *\n * 最多三位小数;不接受更多位数后悄悄取整,避免页面与存储不一致。\n */\nfunction secondsField(field: string, bounds: { min: number; max: number }): FieldSpec {\n return {\n field,\n format: (value) => (typeof value === 'number' ? String(value / 1000) : ''),\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return { kind: 'clear' }\n const parsed = Number(trimmed)\n if (!Number.isFinite(parsed) || !/^-?(?:\\d+(?:\\.\\d{1,3})?|\\.\\d{1,3})$/.test(trimmed)) return { kind: 'invalid', reason: 'format' }\n if (parsed < bounds.min) return { kind: 'invalid', reason: 'tooSmall' }\n if (parsed > bounds.max) return { kind: 'invalid', reason: 'tooLarge' }\n return { kind: 'set', value: Math.round(parsed * 1000) }\n },\n }\n}\n\n/** The fields this page edits inside the `llm-commandcode` namespace. */\nconst SECTION_FIELDS: FieldSpec[] = [\n textField('apiBase'),\n secondsField('requestTimeoutMs', { min: MIN_TIMEOUT_SECONDS, max: MAX_TIMEOUT_SECONDS }),\n secondsField('streamIdleTimeoutMs', { min: MIN_TIMEOUT_SECONDS, max: MAX_TIMEOUT_SECONDS }),\n // A COUNT of retries, not a millisecond wait: it shares the Host schema's\n // 0..50 bounds (`MAX_TRANSPORT_MAX_RETRIES` in src/transport-retry.ts, applied\n // to `transportMaxRetries` in src/index.ts) rather than the timer ceiling\n // above, so a draft can never be saved in a shape the Host rejects. The Host\n // stays the final gate, and the client bundle cannot import that node-side\n // module — `tests/transport-retry.test.ts` pins the schema's bound against\n // the constant so this mirrored literal cannot drift unnoticed.\n numberField('transportMaxRetries', { min: 0, max: 50 }),\n // 自定义上下文长度(issue #86):直接复用 Host schema 的同一对边界常量\n // (context-window.ts 是纯模块,客户端可打包导入),不像 transportMaxRetries\n // 那样需要镜像字面量加测试钉住。输入侧额外接受 256K/1M 简写,落盘仍是数字。\n tokenCountField('contextWindow', { min: CONTEXT_WINDOW_MIN, max: CONTEXT_WINDOW_MAX }, isValidContextWindow),\n // 输出预算(issue #74):0 是「不发送 max_tokens」的省略哨兵,因此上界用\n // 网关自己的总上限,而不是套用上下文窗口的范围。\n maxOutputTokensField('maxOutputTokens'),\n booleanField('offloadSeenImagesForCache'),\n booleanField('filterModelsByPlan'),\n booleanField('webSearch'),\n booleanField('showSidebarQuota'),\n booleanField('zdr'),\n]\n\n/** Whether two model-id lists are equal as sets (order-insensitive). */\nfunction sameModels(a: readonly string[], b: readonly string[]): boolean {\n if (a.length !== b.length) return false\n const set = new Set(a)\n return b.every((id) => set.has(id))\n}\n\n/** 共享控制器跨普通切页保存草稿,插件销毁才关闭写入过程。 */\nexport class CommandCodeSettingsController {\n private readonly scope: SettingsScope>\n private readonly api: SettingsPageApi\n private readonly specs = new Map(SECTION_FIELDS.map((spec) => [spec.field, spec]))\n private readonly staged = new Map()\n private readonly listeners = new Set<() => void>()\n private readonly disposers: Array<() => void> = []\n private disposed = false\n private credentialRef = DEFAULT_API_KEY_REF\n private readonly writer: SettingsWriter\n private saveResult: SettingsWriteResult | undefined\n private accountResult: SettingsWriteResult | undefined\n /** Staged visible-model allowlist (undefined = no draft). */\n private visibleModelsDraft: string[] | undefined = undefined\n /** Staged per-model context-length overrides as raw text (undefined = no draft, issue #86). */\n private contextWindowOverridesDraft: Record | undefined = undefined\n private catalogModels: CatalogModelOption[] = []\n private catalogFailed = false\n private saving = false\n private failed = false\n private savedCount = 0\n private accountPending = 0\n private accountFailed: AccountOperation | undefined = undefined\n\n constructor(\n scope: SettingsScope>,\n api: SettingsPageApi,\n ) {\n this.scope = scope\n this.api = api\n this.writer = new SettingsWriter(scope, api.credentials, () => this.publish())\n this.disposers.push(scope.subscribe(() => {\n this.recomputeCredentialRef()\n void this.writer.refreshCredentials()\n this.publish()\n }))\n this.recomputeCredentialRef()\n void this.writer.refreshCredentials()\n this.refreshCatalog()\n }\n\n /** Release every subscription held on external sources. Idempotent. */\n dispose(): void {\n if (this.disposed) return\n this.disposed = true\n this.writer.dispose()\n this.staged.clear()\n this.visibleModelsDraft = undefined\n this.contextWindowOverridesDraft = undefined\n for (const dispose of this.disposers) dispose()\n this.disposers.length = 0\n this.listeners.clear()\n }\n\n /**\n * The credential reference the section names, or the provider default. A\n * user who renamed `apiKeyEnv` in the profile Config gets a page that\n * addresses the renamed ref instead of silently writing the default —\n * 与模型卡共享同一设置引用。\n */\n private recomputeCredentialRef(): void {\n const snapshot = this.scope.getSnapshot()\n const named = typeof snapshot.value?.apiKeyEnv === 'string' && snapshot.value.apiKeyEnv.length > 0\n ? snapshot.value.apiKeyEnv\n : DEFAULT_API_KEY_REF\n if (named === this.credentialRef) return\n this.credentialRef = named\n }\n\n /** Subscribe to state projections. @returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n /** Build the current page state face. */\n state(): SettingsPageState {\n const snapshot = this.scope.getSnapshot()\n const plan = this.plan()\n const credential = this.writer.credential(this.credentialRef)\n const accounts = this.effectiveAccounts()\n const active = this.sectionValue('activeAccount')\n return {\n available: snapshot.status === 'ready',\n writable: snapshot.writable,\n apiKeyConfigured: credential?.configured ?? false,\n apiKeyKnown: credential !== undefined,\n anyAccountConfigured: (credential?.configured ?? false) || accounts.some((account) => account.configured),\n apiKeyWritable: credential?.writable ?? true,\n apiKey: {\n text: this.staged.get('apiKey')?.text ?? '',\n clear: false,\n overridden: false,\n invalid: false,\n invalidReason: undefined,\n },\n apiBase: this.field('apiBase'),\n requestTimeoutMs: this.field('requestTimeoutMs'),\n streamIdleTimeoutMs: this.field('streamIdleTimeoutMs'),\n transportMaxRetries: this.field('transportMaxRetries'),\n offloadSeenImagesForCache: this.field('offloadSeenImagesForCache'),\n filterModelsByPlan: this.field('filterModelsByPlan'),\n webSearch: this.field('webSearch'),\n showSidebarQuota: this.field('showSidebarQuota'),\n zdr: this.field('zdr'),\n sidebarQuota: this.sectionValue('showSidebarQuota') === true,\n activeAccount: typeof active === 'string' ? active : '',\n accounts,\n accountModels: Object.fromEntries(accountModelMap(this.storedRules())),\n accountBusy: this.accountPending > 0,\n accountFailed: this.accountFailed,\n pendingCredentialCleanup: this.cleanupRefs().filter((ref) => !this.enrollmentRefs(true).includes(ref)),\n accountEnrollmentRefs: this.enrollmentRefs(false),\n visibleModels: this.effectiveVisibleModels(),\n visibleModelsAll: this.visibleModelsDraft === undefined ? this.storedShowsAll() : this.visibleModelsDraft.length === 0,\n contextWindow: this.field('contextWindow'),\n maxOutputTokens: this.field('maxOutputTokens'),\n contextWindowOverrides: this.effectiveContextWindowOverrides(),\n catalogModels: this.catalogModels,\n catalogFailed: this.catalogFailed,\n dirty: plan.length > 0 || this.visibleModelsDirty() || this.contextWindowOverridesDirty(),\n invalid: plan.some((item) => item.change === undefined)\n || this.effectiveContextWindowOverrides().some((row) => row.invalid),\n saving: this.saving,\n failed: this.failed,\n savedCount: this.savedCount,\n saveResult: this.saveResult,\n accountResult: this.accountResult,\n credentialRefreshFailed: this.writer.factsRefreshFailed(),\n }\n }\n\n // --- Staged form ---\n\n private enrollmentRefs(includeNamed: boolean): string[] {\n const records = this.sectionValue('accountEnrollmentTasks')\n if (!Array.isArray(records)) return []\n return records.flatMap((item: unknown) => {\n if (typeof item !== 'object' || item === null) return []\n const record = item as Record\n return typeof record.ref === 'string' && (includeNamed || record.phase !== 'naming') ? [record.ref] : []\n })\n }\n\n /** Stage one field's draft text. */\n edit(field: string, text: string): void {\n if (field !== 'apiKey') this.spec(field)\n this.staged.set(field, { text, clear: false })\n this.failed = false\n this.saveResult = undefined\n this.publish()\n }\n\n /** Reset one section field to its inherited (composition) value. */\n resetField(field: string): void {\n if (field === 'apiKey') {\n this.staged.delete('apiKey')\n this.failed = false\n this.publish()\n return\n }\n const spec = this.spec(field)\n this.staged.set(field, { text: spec.format(this.baseValue(field)), clear: true })\n this.failed = false\n this.saveResult = undefined\n this.publish()\n }\n\n /** Discard every staged edit. */\n discard(): void {\n if (this.staged.size === 0 && this.visibleModelsDraft === undefined\n && this.contextWindowOverridesDraft === undefined && !this.failed) return\n this.staged.clear()\n this.visibleModelsDraft = undefined\n this.contextWindowOverridesDraft = undefined\n this.failed = false\n this.saveResult = undefined\n this.publish()\n }\n\n /**\n * Re-read the Host's credential facts without any staged edit. The browser\n * login stores a key Host-side behind the page's back; the plugin entry\n * calls this when a login lands so the configured/writable badges follow.\n */\n async refreshCredentials(): Promise {\n if (!(await this.writer.refreshCredentials()) || this.disposed) return\n if (this.saveResult) this.saveResult = { ...this.saveResult, refreshFailed: false }\n if (this.accountResult) this.accountResult = { ...this.accountResult, refreshFailed: false }\n this.publish()\n }\n\n /** 冻结点击时的草稿;只清理本次已确认且期间未被替换的对象。 */\n async save(): Promise {\n if (this.disposed || this.saving) return\n const submitted = new Map(this.staged)\n const submittedVisible = this.visibleModelsDraft\n const submittedContextOverrides = this.contextWindowOverridesDraft\n const plan = this.plan()\n const visibleDirty = this.visibleModelsDirty()\n const contextOverrides = this.parsedContextWindowOverrides()\n const contextOverridesDirty = this.contextWindowOverridesDirty()\n // 一个无法解析的按模型覆盖行与越界的普通字段一样整体阻止本次保存,\n // 而不是把其余字段写下去、留下这行草稿。\n if ((plan.length === 0 && !visibleDirty && !contextOverridesDirty)\n || plan.some(item => item.change === undefined) || contextOverrides.invalid) return\n const ops = plan.flatMap(item => typeof item.change === 'object' ? [item.change] : [])\n const key = plan.find(item => item.field === 'apiKey')?.change\n this.saving = true\n this.failed = false\n this.saveResult = undefined\n // 入队先于通知订阅者,避免通知回调发起的后续操作插队。\n const completion = this.writer.submit({ kind: 'save', ops,\n ...(visibleDirty ? { visibleModels: submittedVisible ?? [] } : {}),\n ...(contextOverridesDirty ? { contextWindowOverrides: contextOverrides.values } : {}),\n ...(typeof key === 'string' ? { key } : {}),\n })\n this.publish()\n const outcome = await completion\n if (this.disposed) return outcome\n this.saving = false\n this.saveResult = outcome\n this.failed = !writeCompleted(outcome)\n if (!this.failed) this.savedCount += 1\n for (const [field, draft] of submitted) {\n const confirmed = field === 'apiKey'\n ? outcome.credential === 'confirmed' && outcome.issue === undefined\n : outcome.config === 'confirmed' || writeCompleted(outcome)\n if (confirmed && this.staged.get(field) === draft) this.staged.delete(field)\n }\n if ((outcome.config === 'confirmed' || writeCompleted(outcome)) && this.visibleModelsDraft === submittedVisible) this.visibleModelsDraft = undefined\n if ((outcome.config === 'confirmed' || writeCompleted(outcome)) && this.contextWindowOverridesDraft === submittedContextOverrides) this.contextWindowOverridesDraft = undefined\n this.publish()\n return outcome\n }\n\n /** Stage the visible-model allowlist (multi-select). */\n editVisibleModels(models: string[]): void {\n this.visibleModelsDraft = [...models]\n this.failed = false\n this.saveResult = undefined\n this.publish()\n }\n\n /** Stage \"show all models\" (clears the allowlist). */\n clearVisibleModels(): void {\n this.visibleModelsDraft = []\n this.failed = false\n this.saveResult = undefined\n this.publish()\n }\n\n /**\n * Stage the whole per-model context-length map (issue #86): one entry per\n * row, the value the row's input shows verbatim (blank = drop the override\n * on save). The component rebuilds the map from its projected rows, so a\n * model reselect moves the value to its new key in the same edit.\n */\n editContextWindowOverrides(next: Record): void {\n this.contextWindowOverridesDraft = { ...next }\n this.failed = false\n this.saveResult = undefined\n this.publish()\n }\n\n /** The stored per-model overrides, already cleaned by the shared validity rule. */\n private storedContextWindowOverrides(): Record {\n return { ...readContextWindowOverrides(this.sectionValue('contextWindowOverrides')) }\n }\n\n /** Effective rows: the staged draft verbatim, or the stored values formatted. */\n private effectiveContextWindowOverrides(): ContextOverrideRow[] {\n const draft = this.contextWindowOverridesDraft\n const source: Record = draft !== undefined\n ? draft\n : Object.fromEntries(Object.entries(this.storedContextWindowOverrides()).map(([model, value]) => [model, String(value)]))\n return Object.entries(source)\n .filter(([model]) => model !== '')\n .map(([model, text]) => {\n const parsed = this.spec('contextWindow').parse(text)\n return {\n model,\n text,\n invalid: parsed.kind === 'invalid',\n invalidReason: parsed.kind === 'invalid' ? parsed.reason : undefined,\n }\n })\n }\n\n /**\n * The draft parsed for the write: blank rows and unparseable rows drop out\n * (an invalid row blocks save on its own), everything else is the value the\n * writer puts at `contextWindowOverrides`.\n */\n private parsedContextWindowOverrides(): { values: Record; invalid: boolean } {\n const values: Record = {}\n let invalid = false\n for (const row of this.effectiveContextWindowOverrides()) {\n if (row.invalid) {\n invalid = true\n continue\n }\n const parsed = this.spec('contextWindow').parse(row.text)\n if (parsed.kind === 'set' && typeof parsed.value === 'number') values[row.model] = parsed.value\n }\n return { values, invalid }\n }\n\n private contextWindowOverridesDirty(): boolean {\n if (this.contextWindowOverridesDraft === undefined) return false\n const values = this.parsedContextWindowOverrides().values\n const stored = this.storedContextWindowOverrides()\n const keys = new Set([...Object.keys(values), ...Object.keys(stored)])\n return [...keys].some((model) => values[model] !== stored[model])\n }\n\n // --- Immediate account management ---\n\n renameAccount(ref: string, label: string): Promise {\n return this.runAccountOp('rename', { kind: 'rename', ref, label: label.trim() })\n }\n\n /** 配置移除确认后才清理凭据,失败记录留在宿主清理队列。 */\n removeAccount(ref: string): Promise {\n return this.runAccountOp('remove', { kind: 'remove', ref })\n }\n\n retryCredentialCleanup(ref: string): Promise {\n return this.runAccountOp('remove', { kind: 'cleanup', ref })\n }\n\n private cleanupRefs(): string[] {\n const raw = this.sectionValue('credentialCleanupRefs')\n return Array.isArray(raw) ? [...new Set(raw.filter((ref): ref is string => typeof ref === 'string' && ref !== ''))] : []\n }\n\n setAccountKey(target: string, key: string): Promise {\n return this.runAccountOp('key', { kind: 'key', target, value: key.trim() })\n }\n\n clearAccountKey(target: string): Promise {\n return this.runAccountOp('key', { kind: 'key', target })\n }\n\n setActiveAccount(id: string): Promise {\n return this.runAccountOp('active', { kind: 'active', id })\n }\n\n setAccountModels(target: string, models: readonly string[]): Promise {\n return this.runAccountOp('models', { kind: 'models', target, models })\n }\n\n /** 即时入口与保存共享写入过程,只在这里维护页面忙碌投影。 */\n private async runAccountOp(op: AccountOperation, intent: SettingsWriteIntent): Promise {\n this.accountPending += 1\n this.accountFailed = undefined\n this.accountResult = undefined\n const completion = this.writer.submit(intent)\n this.publish()\n const outcome = await completion\n this.accountPending -= 1\n if (this.disposed) return false\n const ok = writeCompleted(outcome)\n this.accountResult = outcome\n this.accountFailed = ok ? undefined : op\n this.publish()\n return ok\n }\n\n private spec(field: string): FieldSpec {\n const spec = this.specs.get(field)\n if (spec === undefined) throw new Error(`commandcode settings page has no field ${field}`)\n return spec\n }\n\n /** One field's rendered state: draft text, whether it is user-overridden, invalid. */\n private field(field: string): StagedField {\n const spec = this.spec(field)\n const staged = this.staged.get(field)\n if (staged === undefined) {\n return {\n text: spec.format(this.sectionValue(field)),\n clear: false,\n overridden: this.stored(field),\n invalid: false,\n invalidReason: undefined,\n }\n }\n const parsed = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)\n return {\n text: staged.text,\n clear: staged.clear,\n overridden: parsed.kind === 'set',\n invalid: parsed.kind === 'invalid',\n invalidReason: parsed.kind === 'invalid' ? parsed.reason : undefined,\n }\n }\n\n private sectionValue(field: string): unknown {\n return this.scope.getSnapshot().value?.[field]\n }\n\n private baseValue(field: string): unknown {\n const base = this.scope.getSnapshot().base\n return typeof base === 'object' && base !== null && !Array.isArray(base)\n ? (base as Record)[field]\n : undefined\n }\n\n private userLayer(): Record | undefined {\n const user = this.scope.getSnapshot().user\n return typeof user === 'object' && user !== null && !Array.isArray(user)\n ? (user as Record)\n : undefined\n }\n\n private stored(field: string): boolean {\n const user = this.userLayer()\n return user !== undefined && Object.prototype.hasOwnProperty.call(user, field)\n }\n\n /** 纯提交计划;不捕获副作用闭包,也不在排队后重新读取草稿。 */\n private plan(): Array<{ field: string; change: SettingsPathOp | string | undefined }> {\n const plan: Array<{ field: string; change: SettingsPathOp | string | undefined }> = []\n for (const [field, staged] of this.staged) {\n if (field === 'apiKey') {\n const value = staged.text.trim()\n if (value !== '') plan.push({ field, change: value })\n continue\n }\n const spec = this.spec(field)\n if (staged.clear) {\n if (this.stored(field)) plan.push({ field, change: { op: 'unset', path: [field] } })\n continue\n }\n if (staged.text === spec.format(this.sectionValue(field))) continue\n const parsed = spec.parse(staged.text)\n const change: SettingsPathOp | undefined = parsed.kind === 'invalid' ? undefined\n : parsed.kind === 'clear' ? { op: 'unset', path: [field] } : { op: 'set', path: [field], value: parsed.value }\n plan.push({ field, change })\n }\n return plan\n }\n\n /**\n * Fetch the model catalog through the Host Remote. Runs once at construction;\n * call again (e.g. from the client entry once the Remote mount lands) to\n * (re)try — a later success clears a prior failure flag, so the editors\n * recover without a page reload.\n */\n refreshCatalog(): void {\n const models = this.api.models\n if (models === undefined) {\n this.catalogFailed = true\n this.publish()\n return\n }\n void models().then((response) => {\n if (response.ok && Array.isArray(response.value?.models)) {\n // Defensive per-entry shaping: the Remote result is untrusted at the\n // boundary, and an older Host predates the tier field.\n const shaped: CatalogModelOption[] = []\n for (const model of response.value.models) {\n if (typeof model !== 'object' || model === null) continue\n const entry = model as unknown as Record\n if (typeof entry.id !== 'string' || typeof entry.name !== 'string') continue\n shaped.push({\n id: entry.id,\n name: entry.name,\n ...(typeof entry.tier === 'string' ? { tier: entry.tier } : {}),\n ...(typeof entry.allowance === 'number' && Number.isFinite(entry.allowance)\n ? { allowance: entry.allowance }\n : {}),\n })\n }\n this.catalogModels = shaped\n this.catalogFailed = false\n } else {\n this.catalogFailed = true\n }\n }, () => {\n this.catalogFailed = true\n }).then(() => this.publish())\n }\n\n // --- Stored accounts and rules ---\n\n /** The raw `accounts` array of the stored section, verbatim. */\n private rawStoredAccounts(): Array> {\n const raw = this.scope.getSnapshot().value?.accounts\n if (!Array.isArray(raw)) return []\n return raw.filter(\n (entry): entry is Record =>\n typeof entry === 'object' && entry !== null && !Array.isArray(entry),\n )\n }\n\n /**\n * The stored extra accounts this page can address: the rows carrying a\n * credential reference. Entries it cannot name (a composition entry with a\n * literal `apiKey`, or an unknown shape) are never dropped — every list\n * write starts from {@link rawStoredAccounts} and copies them verbatim,\n * because the settings layer replaces the whole array.\n */\n private storedExtras(): Array<{ label: string; ref: string }> {\n const out: Array<{ label: string; ref: string }> = []\n const seen = new Set()\n for (const record of this.rawStoredAccounts()) {\n const ref = record.apiKeyEnv\n if (typeof ref !== 'string' || ref === '' || seen.has(ref)) continue\n seen.add(ref)\n const label = record.label\n out.push({ label: typeof label === 'string' && label !== '' ? label : ref, ref })\n }\n return out\n }\n\n private effectiveAccounts(): AccountItemState[] {\n return this.storedExtras().map((extra) => ({\n id: extra.ref,\n ref: extra.ref,\n label: extra.label,\n configured: this.writer.credential(extra.ref)?.configured ?? false,\n credentialKnown: this.writer.credential(extra.ref) !== undefined,\n writable: this.writer.credential(extra.ref)?.writable ?? true,\n }))\n }\n\n /** The stored routing rules from the settings section (`modelAccountRules`). */\n private storedRules(): StoredRule[] {\n const raw = this.scope.getSnapshot().value?.modelAccountRules\n if (!Array.isArray(raw)) return []\n const out: StoredRule[] = []\n for (const entry of raw) {\n if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue\n const record = entry as Record\n const models = Array.isArray(record.models)\n ? record.models.filter((m): m is string => typeof m === 'string' && m !== '')\n : []\n if (models.length === 0) continue\n out.push({\n models,\n account: typeof record.account === 'string' && record.account !== '' ? record.account : 'default',\n })\n }\n return out\n }\n\n /** 原始白名单;空列表延续现有的显示全部语义。 */\n private storedAllowlist(): string[] {\n const raw = this.scope.getSnapshot().value?.visibleModels\n if (!Array.isArray(raw)) return []\n return raw.filter((m): m is string => typeof m === 'string' && m !== '')\n }\n\n private storedShowsAll(): boolean {\n return this.storedAllowlist().length === 0\n && !Object.values(readVisibility(this.scope.getSnapshot().value?.modelVisibility)).includes(false)\n }\n\n /** 目录、白名单与显式显示的旧模型一起投影,目录失败不删除旧选择。 */\n private storedVisibleModels(): string[] {\n const list = this.storedAllowlist()\n const flags = readVisibility(this.scope.getSnapshot().value?.modelVisibility)\n const ids = [...new Set([...list, ...this.catalogModels.map((model) => model.id), ...Object.keys(flags)])]\n return ids.filter((id) => modelIsVisible(id, list, flags))\n }\n\n private effectiveVisibleModels(): string[] {\n if (this.visibleModelsDraft === undefined) return this.storedVisibleModels()\n return this.visibleModelsDraft.length === 0\n ? this.catalogModels.map((model) => model.id)\n : this.visibleModelsDraft\n }\n\n private visibleModelsDirty(): boolean {\n if (this.visibleModelsDraft === undefined) return false\n if (this.visibleModelsDraft.length === 0) return !this.storedShowsAll()\n return this.storedShowsAll() || !sameModels(this.visibleModelsDraft, this.storedVisibleModels())\n }\n\n private publish(): void {\n if (this.disposed) return\n for (const listener of this.listeners) listener()\n }\n}\n","/** Shared, dependency-free formatting for Host commands and browser surfaces. */\n\nexport function formatMoney(value: number): string {\n return `$${value.toFixed(2)}`\n}\n\nexport function formatMoneyExact(value: number): string {\n return `$${value.toFixed(4)}`\n}\n\n/**\n * 紧凑数字:K/M/B 三级,一切档位决策的唯一实现。\n *\n * 档位必须**回头判断**:先除以档位再四舍五入会算出 `999_999 → \"1000.0K\"`、\n * `999_999_999 → \"1000.0M\"` 这类既超长、又落在并不存在的档位上的结果,\n * 因此每个档位算完后若已经够到下一档,就改用下一档重算一次。\n *\n * @param value - 非负计数或词元数。\n * @param keepZeroDecimal - 整数值是否保留一位小数。词元显示沿用 `3.0B` 的旧样式;\n * 计数显示去掉多余的 `.0`,见 {@link formatCompactCount}。\n * @returns 紧凑文本;不足 1000 时原样返回该整数。\n */\nfunction compact(value: number, keepZeroDecimal: boolean): string {\n const rounded = (scaled: number): number => Math.round(scaled * 10) / 10\n const text = (scaled: number): string => {\n const fixed = rounded(scaled)\n return keepZeroDecimal ? fixed.toFixed(1) : String(fixed)\n }\n if (value >= 1e9) return `${text(value / 1e9)}B`\n if (value >= 1e6) return rounded(value / 1e6) >= 1000 ? `${text(value / 1e9)}B` : `${text(value / 1e6)}M`\n if (value >= 1e3) return rounded(value / 1e3) >= 1000 ? `${text(value / 1e6)}M` : `${text(value / 1e3)}K`\n return String(value)\n}\n\n/**\n * 紧凑计数:请求数、成功/失败数这类会被窄卡片截断的整数。\n * `1234` → `1.2K`、`12000` → `12K`、`1500000` → `1.5M`,整数不带多余的 `.0`。\n */\nexport function formatCompactCount(value: number): string {\n return compact(value, false)\n}\n\n/** 紧凑词元数:与 {@link formatCompactCount} 共用档位,但整数值保留一位小数(`3.0B`)。 */\nexport function formatTokensCompact(value: number): string {\n return compact(value, true)\n}\n\n/**\n * 精确值悬停提示:只在紧凑显示确实丢了信息时才返回文本。\n * 未换算时悬停弹出的文字与卡片上写的一模一样,没有意义,因此返回 `undefined` 让调用方\n * 整个省略 `title`,而不是挂一个内容重复的提示。\n *\n * @param parts - 标签与精确数值,按卡片上的显示顺序。\n * @returns 形如 `请求数 1234567 · 成功 1234567 · 失败 123` 的提示,或 `undefined`。\n */\nexport function compactCountTitle(parts: readonly (readonly [string, number])[]): string | undefined {\n return parts.some(([, value]) => formatCompactCount(value) !== String(value))\n ? parts.map(([label, value]) => `${label} ${value}`).join(' · ')\n : undefined\n}\n\n/** The input is already in percent units; callers add the percent sign. */\nexport function formatSuccessRate(value: number): string {\n return String(Number(value.toFixed(2)))\n}\n\n/** One window's fill ratio in [0, 1]; zero when uncapped. */\nexport function windowRatio(used: number, cap: number): number {\n if (cap <= 0) return 0\n return Math.max(0, Math.min(1, used / cap))\n}\n\n/** Local date-time, with the empty state chosen by the caller. */\nexport function formatResetAt(ms: number): string {\n return ms <= 0 ? '' : new Date(ms).toLocaleString()\n}\n\n/** 只有官方汇总与订阅共同确认同一起点,才展示完整账期;旧宿主和部分响应保持未知。 */\nexport function formatUsageBillingPeriod(usageStart: number | undefined, planStart: number | undefined, planEnd: number | undefined): string {\n if (usageStart === undefined || planStart !== usageStart || planEnd === undefined || usageStart <= 0 || planEnd <= usageStart) return ''\n const start = new Date(usageStart)\n const end = new Date(planEnd)\n if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return ''\n return `${start.toLocaleDateString()} – ${end.toLocaleDateString()}`\n}\n","/**\n * Browser controller for the settings page's account-usage card.\n *\n * The card renders the same account/usage/credit facts the `/commandcode`\n * command prints, fetched Host-side through the `commandcode/report` Remote\n * (the browser never holds the API key). This controller owns the fetch\n * lifecycle — idle/loading/ready/error, one in-flight request at a time,\n * stale-response dropping — and the display formatting, so the React\n * component stays a thin renderer and node tests can drive everything.\n *\n * @module dsh-commandcode-provider/client/usage\n */\n\nimport type { CommandCodeAccountsReport, CommandCodeCatalog, CommandCodePriceTable } from '../usage-wire.ts'\nimport type { CommandCodeLoginStatus } from '../login-wire.ts'\nimport type { EnrollmentRemote } from './enrollment.ts'\nimport type { AnalyticsRemote } from './analytics.ts'\nimport type { AnalyticsQuery, AnalyticsReport } from '../analytics-wire.ts'\nimport type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'\nimport { readableErrorText } from '../readable-error.ts'\nexport { compactCountTitle, formatCompactCount, formatMoney, formatMoneyExact, formatTokensCompact, formatSuccessRate, windowRatio, formatResetAt } from '../display-format.ts'\n\n/**\n * Merge the plugin's Remote endpoints into the harness's typed client Remote\n * surface, so `ctx.remote.commandcode.*()` is typed once each contribution is\n * mounted. The `commandcode` namespace member is declared exactly once here\n * (interface merging forbids duplicate members) and therefore carries the usage\n * report, the model catalog, the price table AND the login endpoints.\n */\ndeclare module '@deepseek-ai/dsh-typert-protocol' {\n interface TypertRemoteMap {\n 'commandcode/report': () => Promise>\n 'commandcode/models': () => Promise>\n 'commandcode/prices': () => Promise>\n 'commandcode/analytics': (query: AnalyticsQuery) => Promise>\n }\n interface TypertRemoteNamespaceMap {\n commandcode: Partial & AnalyticsRemote & {\n report: () => Promise>\n models: () => Promise>\n /**\n * Optional because the Host half and this browser bundle can be a\n * cross-version pair: a Host older than the price table mounts no\n * `commandcode/prices` descriptor, so the member is genuinely absent at\n * runtime. The price controller reads a missing method as \"no table\"\n * (see `./prices.ts`) instead of throwing.\n */\n prices?: () => Promise>\n loginBegin: () => Promise>\n loginStatus: () => Promise>\n loginCancel: () => Promise>\n }\n }\n}\n\n/** The narrow slice of the mounted Remote this controller calls. */\nexport interface UsageRemote {\n report(): Promise<\n | { ok: true; value: CommandCodeAccountsReport }\n | { ok: false; error: { message: string } }\n >\n models(): Promise<\n | { ok: true; value: CommandCodeCatalog }\n | { ok: false; error: { message: string } }\n >\n /**\n * Optional for the same cross-version reason as the namespace member above;\n * `CommandCodePricesController` reads the absence as a permanent \"no prices\"\n * state.\n */\n prices?(): Promise<\n | { ok: true; value: CommandCodePriceTable }\n | { ok: false; error: { message: string } }\n >\n}\n\n/** The card's fetch lifecycle. */\nexport type UsageStatus =\n /** Never fetched (no API key configured yet, or not requested). */\n | 'idle'\n /** A fetch is in flight; `report` retains the last good data if any. */\n | 'loading'\n /** The last fetch succeeded. */\n | 'ready'\n /** The last fetch failed (no key, unreachable host, old plugin). */\n | 'error'\n\n/** The card's full state face. */\nexport interface UsagePageState {\n status: UsageStatus\n /** The last successfully fetched report (retained across refetches). */\n report: CommandCodeAccountsReport | undefined\n /** The last failure's message (error status). */\n error: string | undefined\n /** Millis timestamp of the last successful fetch. */\n fetchedAt: number | undefined\n}\n\nconst IDLE: UsagePageState = { status: 'idle', report: undefined, error: undefined, fetchedAt: undefined }\n\n/**\n * Controller bridging the `commandcode/report` Remote onto the card. Public\n * API mirrors {@link CommandCodeSettingsController}: `state()` projections,\n * `subscribe`, and one `refresh()` action.\n */\nexport class CommandCodeUsageController {\n private readonly remote: UsageRemote\n private readonly listeners = new Set<() => void>()\n private current: UsagePageState = IDLE\n private generation = 0\n private inFlight = false\n private disposed = false\n\n constructor(remote: UsageRemote) {\n this.remote = remote\n }\n\n /** Release every subscription. Idempotent; in-flight results are dropped. */\n dispose(): void {\n this.disposed = true\n this.generation += 1\n this.listeners.clear()\n }\n\n /** Subscribe to state projections. @returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n /** The current card state face. */\n state(): UsagePageState {\n return this.current\n }\n\n /**\n * Fetch (or refetch) the report. Concurrent refreshes collapse onto one\n * request; a superseded fetch's late result is dropped, never published.\n */\n async refresh(): Promise {\n if (this.disposed || this.inFlight) return\n const generation = ++this.generation\n this.inFlight = true\n this.current = { ...this.current, status: 'loading', error: undefined }\n this.publish()\n try {\n const response = await this.remote.report()\n if (this.disposed || generation !== this.generation) return\n if (response.ok) {\n this.current = { status: 'ready', report: response.value, error: undefined, fetchedAt: Date.now() }\n } else {\n this.current = { ...this.current, status: 'error', error: response.error.message }\n }\n } catch (error: unknown) {\n if (this.disposed || generation !== this.generation) return\n this.current = {\n ...this.current,\n status: 'error',\n error: readableErrorText(error),\n }\n } finally {\n if (generation === this.generation) this.inFlight = false\n }\n this.publish()\n }\n\n private publish(): void {\n if (this.disposed) return\n for (const listener of this.listeners) listener()\n }\n}\n\n/** Host facts drive the usage card, including keys invisible to browser credentials. */\nexport function usageCardState(usage: UsagePageState) {\n const accounts = usage.report?.accounts ?? []\n return {\n loading: usage.status === 'loading',\n shouldRefresh: usage.status === 'idle',\n noKey: accounts.length > 0 && !accounts.some(account => account.configured),\n }\n}\n","/**\n * Browser controller for the model price table the composer's session-cost\n * figure depends on.\n *\n * The rates are vendored Host-side (`src/model-prices.ts`) and cross the\n * `commandcode/prices` Remote, so the browser never carries its own copy that\n * could drift from the snapshot, and a price update reaches an open page\n * without rebuilding the client bundle.\n *\n * @module dsh-commandcode-provider/client/prices\n */\n\nimport type { CommandCodePriceTable } from '../usage-wire.ts'\nimport { readableErrorText } from '../readable-error.ts'\n\n/** The narrow slice of the mounted Remote this controller calls. */\nexport interface PricesRemote {\n /**\n * Optional because the Host and the browser bundle can be a cross-version\n * pair: a Host half older than this feature serves no `commandcode/prices`\n * endpoint, and the controller must read that as \"no price table\" instead of\n * calling an undefined method.\n */\n prices?(): Promise<\n | { ok: true; value: CommandCodePriceTable }\n | { ok: false; error: { message: string; permanent?: boolean } }\n >\n}\n\n/** The price table's fetch lifecycle. */\nexport type PricesStatus =\n /** Never requested. */\n | 'idle'\n /** A fetch is in flight. */\n | 'loading'\n /** The table is loaded and cached for the page's lifetime. */\n | 'ready'\n /** The last fetch failed; `ensure()` may try again. */\n | 'error'\n\n/** The readout's price-table state face. */\nexport interface SessionCostPricesState {\n status: PricesStatus\n /** The cached table (only ever set once). */\n table: CommandCodePriceTable | undefined\n /** The last failure's message. */\n error: string | undefined\n}\n\nconst IDLE: SessionCostPricesState = { status: 'idle', table: undefined, error: undefined }\n\n/**\n * Timer seam for the bounded retry backoff, so a node test can drive the\n * schedule without waiting on it.\n */\nexport interface PriceRetryTimer {\n set(callback: () => void, ms: number): unknown\n clear(handle: unknown): void\n}\nconst RETRY_TIMER: PriceRetryTimer = {\n set(callback, ms) { const handle = setTimeout(callback, ms); handle.unref?.(); return handle },\n clear(handle) { clearTimeout(handle as ReturnType) },\n}\n\n/**\n * Cache over the `commandcode/prices` Remote, with a bounded transient-retry\n * budget (three retries at 1/2/4 s — bounded, not a poll). Public API mirrors\n * {@link CommandCodeUsageController}: `state()`, `subscribe` and `ensure()`;\n * `reload()` additionally drops the cache and restarts the budget when the\n * Host namespace rebinds. A MANUAL `ensure()` still fetches after the budget\n * is spent, and a PERMANENT failure — the Host serving no such endpoint — is\n * terminal for the binding until `reload()`.\n */\nexport class CommandCodePricesController {\n private readonly remote: PricesRemote\n private readonly listeners = new Set<() => void>()\n private current: SessionCostPricesState = IDLE\n private generation = 0\n private inFlight = false\n private disposed = false\n\n private retryHandle: unknown\n private attempts = 0\n private permanent = false\n private readonly timer: PriceRetryTimer\n\n constructor(remote: PricesRemote, timer: PriceRetryTimer = RETRY_TIMER) {\n this.timer = timer\n this.remote = remote\n }\n\n /** Release every subscription. Idempotent; in-flight results are dropped. */\n dispose(): void {\n this.disposed = true\n this.clearRetry()\n this.listeners.clear()\n }\n\n /** Subscribe to state projections. @returns the disposer. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n /** The current state face. */\n state(): SessionCostPricesState {\n return this.current\n }\n\n /**\n * Fetch the table unless it is already loaded, permanent, or in flight — and\n * unless a bounded retry already owns the next attempt. Only the client\n * entry calls this (on rebind and on a manual refresh), so a mounted readout\n * renders the current state instead of triggering a fetch of its own.\n */\n ensure(): void {\n if (this.disposed || this.permanent || this.inFlight || this.current.status === 'ready') return\n this.clearRetry()\n const call = this.remote.prices\n // An older Host half serves no price endpoint. That is a permanent\n // condition for this page, so it lands in the error state (the readout\n // stays hidden) rather than retrying on every mount.\n if (typeof call !== 'function') {\n this.permanent = true\n this.publish({ status: 'error', table: undefined, error: 'the Host serves no commandcode/prices endpoint' })\n return\n }\n const generation = this.generation\n this.inFlight = true\n this.attempts += 1\n this.publish({ status: 'loading', table: this.current.table, error: undefined })\n let request: ReturnType>\n try { request = call.call(this.remote) } catch (error) { request = Promise.reject(error) }\n void request.then((result) => {\n if (this.disposed || generation !== this.generation) return\n this.inFlight = false\n if (result.ok) {\n this.publish({ status: 'ready', table: result.value, error: undefined })\n return\n }\n this.permanent = result.error.permanent === true\n this.publish({ status: 'error', table: undefined, error: result.error.message })\n this.scheduleRetry()\n }, (error: unknown) => {\n if (this.disposed || generation !== this.generation) return\n this.inFlight = false\n this.publish({\n status: 'error',\n table: undefined,\n error: readableErrorText(error),\n })\n this.scheduleRetry()\n })\n }\n\n /** Rebinding a Host invalidates cached prices and restarts the bounded retry budget. */\n reload(): void {\n if (this.disposed) return\n this.generation += 1\n this.inFlight = false\n this.clearRetry()\n this.permanent = false\n this.attempts = 0\n this.current = IDLE\n this.ensure()\n }\n\n private clearRetry(): void {\n if (this.retryHandle !== undefined) this.timer.clear(this.retryHandle)\n this.retryHandle = undefined\n }\n\n private scheduleRetry(): void {\n if (this.disposed || this.permanent || this.attempts >= 4) return\n this.retryHandle = this.timer.set(() => { this.retryHandle = undefined; this.ensure() }, 1000 * 2 ** (this.attempts - 1))\n }\n\n private publish(next: SessionCostPricesState): void {\n this.current = next\n for (const listener of this.listeners) listener()\n }\n}\n","/** 收到宿主授权地址后才打开页面;浏览器拦截不应中断登录,界面始终保留手动链接。 */\nexport function openLoginPage(authUrl: string): void {\n if (typeof window === 'undefined') return\n try {\n // 授权页不持有设置页引用,也不接收设置页来源地址。\n // 使用 noopener 时成功打开也可能返回 null,因此不据此判断弹窗是否被拦截。\n window.open(authUrl, '_blank', 'noopener,noreferrer')\n } catch { /* 自动打开失败时仍可通过手动链接完成授权。 */ }\n}\n","/**\n * Browser controller for the settings page's login panel: `begin` asks the\n * Host to bind the callback server and returns the Studio URL, the controller\n * polls `loginStatus` while the attempt is live, and `cancel` tears it down.\n * The key itself never crosses to the browser — the Host validates and stores\n * it through the credentials seam.\n *\n * Transport-level failures (no mounted Remote, an older Host without the login\n * endpoints) land in the dedicated `unavailable` phase so the page can point\n * back at manual paste. Deliberately JSX-free, mirroring `./settings.ts` and\n * `./usage.ts`.\n *\n * @module dsh-commandcode-provider/client/login\n */\n\nimport type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'\nimport { readableErrorText } from '../readable-error.ts'\nimport { openLoginPage } from './login-page.ts'\nimport type { CommandCodeLoginFailureReason, CommandCodeLoginStatus } from '../login-wire.ts'\nimport type { Translate } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { SettingsCommandCodeKey } from './locales.ts'\n\n/** The endpoint-level Remote surface this controller calls. */\ndeclare module '@deepseek-ai/dsh-typert-protocol' {\n interface TypertRemoteMap {\n 'commandcode/loginBegin': (targetRef?: string) => Promise>\n 'commandcode/loginStatus': () => Promise>\n 'commandcode/loginCancel': () => Promise>\n }\n}\n\n/** The narrow slice of the mounted Remote this controller calls. */\nexport interface LoginRemote {\n loginBegin(targetRef?: string): Promise\n loginStatus(): Promise\n loginCancel(): Promise\n}\n\n/** One Remote call's outcome as the controller sees it. */\nexport type LoginCallResult =\n | { ok: true; value: CommandCodeLoginStatus }\n | { ok: false; error: { message: string } }\n\n/** The panel's full state face. */\nexport interface LoginPageState {\n /**\n * `idle` — nothing started; `starting` — begin() in flight; `waiting` —\n * the Studio URL is live and polling; `success`/`failed` — terminal;\n * `unavailable` — the Remote itself could not be reached (old Host, mount\n * failure), manual paste is the way.\n */\n phase: 'idle' | 'starting' | 'waiting' | 'success' | 'failed' | 'unavailable'\n /** The saved extra account receiving this attempt, or undefined for default. */\n targetRef?: string\n /** The Studio authorization URL while `waiting`. */\n authUrl: string | undefined\n /** The account display name on `success`. */\n userName: string | undefined\n /** The key's Studio label on `success`. */\n keyName: string | undefined\n /** The stable failure reason when `failed`. */\n reason: CommandCodeLoginFailureReason | undefined\n /** Secondary failure detail when `failed`/`unavailable`. */\n message: string | undefined\n}\n\n/** Keep one account's login status out of every other account's row. */\nexport function loginStateForTarget(\n state: LoginPageState,\n targetRef: string | undefined,\n): { visible: LoginPageState; busyElsewhere: boolean } {\n if (state.targetRef === targetRef) return { visible: state, busyElsewhere: false }\n return {\n visible: { ...state, phase: 'idle', authUrl: undefined },\n busyElsewhere: state.phase === 'starting' || state.phase === 'waiting',\n }\n}\n\n/** How often a live attempt is polled. */\nconst POLL_INTERVAL_MS = 1_000\n\n/**\n * The login panel's fetch/poll lifecycle. One poll loop at a time; a fresh\n * `begin()` supersedes any previous loop via a generation token.\n */\nexport class CommandCodeLoginController {\n private readonly remote: () => LoginRemote | undefined\n private readonly listeners = new Set<() => void>()\n private readonly pollMs: number\n /** Monotonic token; only the latest loop may publish polling results. */\n private generation = 0\n private disposed = false\n\n private phase: LoginPageState['phase'] = 'idle'\n private targetRef: string | undefined\n private authUrl: string | undefined\n private userName: string | undefined\n private keyName: string | undefined\n private reason: CommandCodeLoginFailureReason | undefined\n private message: string | undefined\n private openedPage = false\n\n constructor(remote: () => LoginRemote | undefined, pollMs = POLL_INTERVAL_MS, private readonly openPage = openLoginPage) {\n this.remote = remote\n this.pollMs = pollMs\n }\n\n /** Subscribe to state projections. */\n subscribe(listener: () => void): () => void {\n this.listeners.add(listener)\n return () => this.listeners.delete(listener)\n }\n\n state(): LoginPageState {\n return {\n phase: this.phase,\n ...(this.targetRef === undefined ? {} : { targetRef: this.targetRef }),\n authUrl: this.authUrl,\n userName: this.userName,\n keyName: this.keyName,\n reason: this.reason,\n message: this.message,\n }\n }\n\n /** Start (or rejoin) a login attempt and begin polling its status. */\n async begin(targetRef?: string): Promise {\n if (this.disposed || this.phase === 'starting' || this.phase === 'waiting') return\n const generation = ++this.generation\n this.openedPage = false\n this.targetRef = targetRef\n this.set({ phase: 'starting', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: undefined })\n const remote = this.remote()\n if (remote === undefined) {\n this.set({ phase: 'unavailable', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: 'login remote is not mounted' })\n return\n }\n let result: LoginCallResult\n try {\n result = await remote.loginBegin(targetRef)\n } catch (error: unknown) {\n // A transport throw (gateway hiccup) reads the same as a rejected call.\n result = { ok: false, error: { message: readableErrorText(error) } }\n }\n if (this.superseded(generation)) return\n if (!result.ok) {\n this.set({ phase: 'unavailable', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: result.error.message })\n return\n }\n this.apply(result.value)\n if (this.currentPhase === 'waiting') void this.poll(generation)\n }\n\n /** Cancel a waiting attempt. */\n async cancel(): Promise {\n if (this.disposed || (this.phase !== 'starting' && this.phase !== 'waiting')) return\n const generation = ++this.generation\n const remote = this.remote()\n if (remote === undefined) return\n let result: LoginCallResult\n try {\n result = await remote.loginCancel()\n } catch {\n // The Host may be gone; reflect the local cancel regardless.\n this.set({ phase: 'failed', authUrl: undefined, userName: undefined, keyName: undefined, reason: 'cancelled', message: undefined })\n return\n }\n if (this.superseded(generation)) return\n if (result.ok) this.apply(result.value)\n else this.set({ phase: 'failed', authUrl: undefined, userName: undefined, keyName: undefined, reason: 'cancelled', message: undefined })\n }\n\n /** Stop polling and release listeners. Idempotent. */\n dispose(): void {\n if (this.disposed) return\n this.disposed = true\n this.generation += 1\n this.listeners.clear()\n }\n\n /** Poll until the attempt leaves `waiting` or a newer loop supersedes us. */\n private async poll(generation: number): Promise {\n // `currentPhase` is a getter, read (not the field) so control-flow\n // narrowing across `await` cannot claim the phase is frozen.\n while (!this.disposed && !this.superseded(generation) && this.currentPhase === 'waiting') {\n await sleep(this.pollMs)\n if (this.disposed || this.superseded(generation) || this.currentPhase !== 'waiting') return\n const remote = this.remote()\n if (remote === undefined) {\n this.set({ phase: 'unavailable', authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: 'login remote is not mounted' })\n return\n }\n let result: LoginCallResult\n try {\n result = await remote.loginStatus()\n } catch {\n continue // one missed poll is not an outage; the next tick retries\n }\n if (this.superseded(generation) || this.currentPhase !== 'waiting') return\n if (result.ok) this.apply(result.value)\n }\n }\n\n private get currentPhase(): LoginPageState['phase'] {\n return this.phase\n }\n\n /** Project one Host status onto the panel face. */\n private apply(status: CommandCodeLoginStatus): void {\n const base = { authUrl: undefined, userName: undefined, keyName: undefined, reason: undefined, message: undefined }\n if (status.state === 'waiting') {\n // 在宿主返回地址后打开,每次尝试仅执行一次,状态轮询不重复开页。\n if (status.authUrl && !this.openedPage) {\n this.openedPage = true\n this.openPage(status.authUrl)\n }\n this.set({ ...base, phase: 'waiting', authUrl: status.authUrl })\n return\n }\n if (status.state === 'success') {\n this.set({ ...base, phase: 'success', userName: status.userName, keyName: status.keyName })\n return\n }\n if (status.state === 'failed') {\n this.set({ ...base, phase: 'failed', reason: status.reason, message: status.message })\n return\n }\n // `idle` mid-poll means the Host restarted; the attempt is gone.\n this.set({ ...base, phase: 'failed', reason: 'cancelled', message: 'the login attempt is no longer active' })\n }\n\n /** Replace the whole state face and notify. Explicit over partial patches. */\n private set(state: LoginPageState): void {\n this.phase = state.phase\n this.authUrl = state.authUrl\n this.userName = state.userName\n this.keyName = state.keyName\n this.reason = state.reason\n this.message = state.message\n this.publish()\n }\n\n private superseded(generation: number): boolean {\n return this.disposed || generation !== this.generation\n }\n\n private publish(): void {\n if (this.disposed) return\n for (const listener of [...this.listeners]) listener()\n }\n}\n\nfunction sleep(ms: number): Promise {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n// Hint copy for the login panel / card row, JSX-free so node tests drive it.\n\n/**\n * The per-reason copy for a failed attempt. Shared by the settings page's login\n * panel and the Models-page card's login row — the same reason can surface from\n * either.\n */\nexport function loginFailureCopy(\n reason: CommandCodeLoginFailureReason | undefined,\n t: Translate,\n): string {\n if (reason === 'denied') return t('loginDenied')\n if (reason === 'timeout') return t('loginTimeout')\n if (reason === 'invalid-key') return t('loginInvalidKey')\n if (reason === 'network') return t('loginNetwork')\n if (reason === 'unavailable') return t('loginStoreFailed')\n if (reason === 'cancelled') return t('loginCancelled')\n return t('loginFailedGeneric')\n}\n\n/** One login panel row's computed hint: text, class stem, and optional title. */\nexport interface LoginHint {\n text: string\n className: string\n /** Tooltip shown when the row carries secondary failure detail. */\n title: string | undefined\n}\n\n/**\n * The hint text + class for one login panel state, shared by the settings\n * page's login panel and the Models-page card's login row so both surfaces\n * can never drift apart. Pure: no timers, no state.\n */\nexport function loginHint(\n state: LoginPageState,\n t: Translate,\n): LoginHint {\n if (state.phase === 'starting' || state.phase === 'waiting') {\n return {\n text: t(state.phase === 'starting' ? 'loginStarting' : 'loginWaiting'),\n className: 'cc-hint',\n title: undefined,\n }\n }\n if (state.phase === 'success') {\n const keyName = state.keyName !== undefined && state.keyName !== '' ? ` · ${state.keyName}` : ''\n return {\n text: `${t('loginSuccess')} ${state.userName ?? ''}${keyName}`.trim(),\n className: 'cc-loginDone',\n title: undefined,\n }\n }\n if (state.phase === 'failed') {\n return {\n text: loginFailureCopy(state.reason, t),\n className: 'cc-loginError',\n title: state.message,\n }\n }\n if (state.phase === 'unavailable') {\n return {\n text: `${t('loginUnavailable')} ${state.message ?? ''}`.trim(),\n className: 'cc-loginError',\n title: undefined,\n }\n }\n return { text: t('loginHintIdle'), className: 'cc-hint', title: undefined }\n}\n","/**\n * Shared boundary-validation and Remote-descriptor plumbing for the plugin's\n * hand-rolled Typert wire contracts (`commandcode/report`, `commandcode/models`,\n * `commandcode/prices`, `commandcode/login*`).\n *\n * Every Remote this plugin serves crosses the Typert Gateway with a strict\n * result schema: the Host half registers a descriptor against a Cordis service\n * (`src/usage-remote.ts`) and the browser half mounts the matching contribution\n * on `ctx.remote` (`src/client/index.ts`). This module is dependency-free so\n * the client bundle can inline it, and it holds the schema helpers and\n * descriptor boilerplate the wire files used to duplicate.\n *\n * @module dsh-commandcode-provider/wire-shared\n */\nimport type { InvocationDescriptor, TypertSchema } from '@deepseek-ai/dsh-typert-protocol'\n\n/** The npm package identity every contribution and descriptor claims. */\nexport const REMOTE_PACKAGE = '@mars-sea/dsh-commandcode-provider'\n\n/** The Cordis service key the Gateway resolves every Command Code Remote from. */\nexport const REMOTE_SERVICE = 'commandcodeUsage'\n\n/** The wire namespace all Command Code endpoints share. */\nexport const REMOTE_NAMESPACE = 'commandcode'\n\n/** The read/validate helpers one boundary codec needs. */\nexport interface BoundaryValidator {\n /** Reject one boundary value with a field-naming error. */\n reject(field: string): never\n /** Narrow an unknown value to a plain record, or reject. */\n record(value: unknown, field: string): Record\n /** Read one required string field (`field` is the dotted error label). */\n stringField(source: Record, key: string, field: string): string\n /** Read one required finite number field (`field` is the dotted error label). */\n numberField(source: Record, key: string, field: string): number\n /** Read one required boolean field (`field` is the dotted error label). */\n booleanField(source: Record, key: string, field: string): boolean\n}\n\n/**\n * Build the validator helpers one endpoint uses. `prefix` names the endpoint\n * in the rejection message (e.g. `commandcode/report result:`), so each wire\n * file keeps its own diagnostic phrasing while sharing the helper bodies.\n *\n * The helpers return the reject call directly in the failure branch: since\n * `reject` is typed `never`, the ternary's union collapses to the success type\n * without relying on TypeScript's control-flow analysis of a never-returning\n * call (which only recognizes function declarations, not the destructured\n * arrow `reject` callers receive from this factory). Callers that need the\n * narrowing in an `if` must therefore assign inside the positive branch.\n */\nexport function makeBoundaryValidator(prefix: string): BoundaryValidator {\n const reject = (field: string): never => {\n throw new TypeError(`${prefix} invalid ${field}`)\n }\n const record = (value: unknown, field: string): Record =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n ? value as Record\n : reject(field)\n const stringField = (source: Record, key: string, field: string): string =>\n typeof source[key] === 'string' ? source[key] as string : reject(field)\n const numberField = (source: Record, key: string, field: string): number =>\n typeof source[key] === 'number' && Number.isFinite(source[key] as number)\n ? source[key] as number\n : reject(field)\n const booleanField = (source: Record, key: string, field: string): boolean =>\n typeof source[key] === 'boolean' ? source[key] as boolean : reject(field)\n return { reject, record, stringField, numberField, booleanField }\n}\n\n/**\n * One strict result codec: `mode: 'strict'` carries the LAZY schema factory\n * (`create()`) the registry requires and the Gateway validates with\n * (`codec.create().parse`). There is NO `schema` member — issue #49.\n */\ninterface StrictCodec {\n readonly mode: 'strict'\n readonly typeSymbol: string\n readonly create: () => TypertSchema\n}\n\n/** Use the same strict-codec shape for invocation parameters and results. */\nexport function makeStrictCodec(typeSymbol: string, schema: TypertSchema): StrictCodec {\n return { mode: 'strict', typeSymbol, create: () => schema }\n}\n\n/**\n * Build one strict invocation descriptor. Every Command Code Remote shares the\n * `commandcode` namespace, the `commandcodeUsage` service, and a strict result\n * — only the endpoint, method, result type symbol, and schema differ.\n */\nexport function makeRemoteDescriptor(\n endpoint: string,\n method: string,\n typeSymbol: string,\n schema: TypertSchema,\n): InvocationDescriptor {\n return {\n id: `${REMOTE_PACKAGE}#${endpoint}`,\n service: REMOTE_SERVICE,\n namespace: REMOTE_NAMESPACE,\n method,\n invocation: { kind: 'direct' },\n parameters: [],\n result: makeStrictCodec(typeSymbol, schema),\n }\n}\n","/**\n * Wire contracts for the Command Code Remotes the browser half mounts: the\n * account-usage report (`commandcode/report`), the model catalog\n * (`commandcode/models`) and the price table (`commandcode/prices`).\n *\n * The browser never holds the API key, so every one of these is produced\n * Host-side: the Host half registers a strict invocation descriptor against a\n * Cordis service (`src/usage-remote.ts`) and the browser half mounts the\n * matching contribution on `ctx.remote` (`src/client/index.ts`). This module\n * is the single source both halves share — the hand-rolled result validators\n * and the exact descriptor objects, so neither half can drift. It is\n * dependency-free because the client bundle inlines it.\n *\n * @module dsh-commandcode-provider/usage-wire\n */\n\nimport type { CommandCodeCredits, CommandCodeUsageReport, UsageBlockReason } from './adapter.ts'\n\nexport type { CommandCodeUsageReport, UsageBlockReason }\nimport type { InvocationDescriptor, TypertRemoteContribution, TypertSchema } from '@deepseek-ai/dsh-typert-protocol'\nimport {\n makeBoundaryValidator,\n makeRemoteDescriptor,\n REMOTE_PACKAGE,\n} from './wire-shared.ts'\n\n/** One account's usage entry in the multi-account report. */\nexport interface CommandCodeAccountUsage {\n /** Stable slot id (`default`, `account-2`, …). */\n id: string\n /** Display label (user-provided or generated). */\n label: string\n /** Whether an API key resolved for this account. */\n configured: boolean\n /** Whether this account currently serves requests (first usable slot). */\n active: boolean\n /** Rotation mark: `''` (usable), `'rate-limit'`, or `'invalid-credential'`. */\n mark: string\n /** Known cooldown end in millis; 0 when unknown or not cooling down. */\n cooldownUntil: number\n /** The per-account report; `failures`-only when the fetch itself failed. */\n report: CommandCodeUsageReport\n}\n\n/** The settings page's account card data: one entry per configured account. */\nexport interface CommandCodeAccountsReport {\n accounts: CommandCodeAccountUsage[]\n}\n\n/** The npm package identity both contribution registrations claim. */\nexport const USAGE_REMOTE_PACKAGE = REMOTE_PACKAGE\n\n/** Canonical `/` endpoint of the usage report Remote. */\nexport const USAGE_REPORT_ENDPOINT = 'commandcode/report'\n\n/**\n * The shared read/validate helpers for the usage report endpoint, prefixed\n * so rejection messages name the offending boundary.\n */\nconst { reject, record, stringField, numberField, booleanField } =\n makeBoundaryValidator('commandcode/report result:')\n\n/**\n * Parse one quota window, or undefined when the frame carries no such window.\n *\n * The distinction is load-bearing at this boundary: an ABSENT window means the\n * billing endpoint reported none (an unlimited plan), while a present block with\n * `cap: 0` means uncapped spend that was really reported. Collapsing the two\n * would draw a zeroed quota row for an account that has no such limit.\n */\nfunction windowLimit(\n value: unknown,\n field: string,\n): { used: number; cap: number; exceeded: boolean; resetAt: number } | undefined {\n if (value === undefined) return undefined\n const source = record(value, field)\n return {\n used: numberField(source, 'used', `${field}.used`),\n cap: numberField(source, 'cap', `${field}.cap`),\n exceeded: booleanField(source, 'exceeded', `${field}.exceeded`),\n resetAt: numberField(source, 'resetAt', `${field}.resetAt`),\n }\n}\n\n/**\n * Parse one untrusted boundary value into a {@link CommandCodeUsageReport}.\n * Optional sections stay optional; every present field is shape-checked so a\n * malformed frame fails the boundary instead of rendering garbage.\n */\nfunction parseUsageReport(value: unknown): CommandCodeUsageReport {\n const source = record(value, 'report')\n const failures = source.failures\n if (!Array.isArray(failures) || failures.some((entry) => typeof entry !== 'string')) reject('failures')\n const report: CommandCodeUsageReport = { failures: failures as string[] }\n\n if (source.blocked !== undefined) {\n const blocked = source.blocked\n if (blocked === 'invalid-key' || blocked === 'service-unavailable' || blocked === 'invalid-response' || blocked === 'network') {\n report.blocked = blocked\n } else {\n reject('blocked')\n }\n }\n\n if (source.account !== undefined) {\n const account = record(source.account, 'account')\n report.account = {\n id: stringField(account, 'id', 'account.id'),\n name: stringField(account, 'name', 'account.name'),\n userName: stringField(account, 'userName', 'account.userName'),\n }\n }\n\n if (source.usage !== undefined) {\n const usage = record(source.usage, 'usage')\n report.usage = {\n totalCount: numberField(usage, 'totalCount', 'usage.totalCount'),\n totalCost: numberField(usage, 'totalCost', 'usage.totalCost'),\n successRate: numberField(usage, 'successRate', 'usage.successRate'),\n completedCount: numberField(usage, 'completedCount', 'usage.completedCount'),\n failedCount: numberField(usage, 'failedCount', 'usage.failedCount'),\n totalTokensIn: numberField(usage, 'totalTokensIn', 'usage.totalTokensIn'),\n totalTokensOut: numberField(usage, 'totalTokensOut', 'usage.totalTokensOut'),\n totalCredits: numberField(usage, 'totalCredits', 'usage.totalCredits'),\n periodBasis: stringField(usage, 'periodBasis', 'usage.periodBasis'),\n }\n if (usage.periodStart !== undefined) {\n report.usage.periodStart = numberField(usage, 'periodStart', 'usage.periodStart')\n }\n }\n\n if (source.credits !== undefined) {\n const credits = record(source.credits, 'credits')\n const parsed: CommandCodeCredits = {\n monthlyCredits: numberField(credits, 'monthlyCredits', 'credits.monthlyCredits'),\n purchasedCredits: numberField(credits, 'purchasedCredits', 'credits.purchasedCredits'),\n freeCredits: numberField(credits, 'freeCredits', 'credits.freeCredits'),\n }\n // OPTIONAL on the wire: an explicit `false` means the endpoint omitted the\n // balance, which cannot be told from a real zero — the panel keeps the\n // figure off the dashboard rather than drawing a consumed quota. An absent\n // flag (an older Host) stays unset and reads as \"assume reported\", so a\n // real balance is not lost cross-version. A PRESENT value must still be a\n // boolean, so a malformed frame is rejected here rather than coerced.\n if (credits.monthlyReported !== undefined) {\n parsed.monthlyReported = booleanField(credits, 'monthlyReported', 'credits.monthlyReported')\n }\n for (const flag of ['purchasedReported', 'freeReported'] as const) {\n if (credits[flag] !== undefined) parsed[flag] = booleanField(credits, flag, `credits.${flag}`)\n }\n // Absent means the endpoint reported no such window: the parsers return\n // undefined for an absent block, and an optional member is only set when\n // it was really there.\n const fiveHour = windowLimit(credits.fiveHour, 'credits.fiveHour')\n const weekly = windowLimit(credits.weekly, 'credits.weekly')\n if (fiveHour !== undefined) parsed.fiveHour = fiveHour\n if (weekly !== undefined) parsed.weekly = weekly\n report.credits = parsed\n }\n\n if (source.plan !== undefined) {\n const plan = record(source.plan, 'plan')\n const monthly = plan.monthlyCredits\n if (monthly !== null && (typeof monthly !== 'number' || !Number.isFinite(monthly))) reject('plan.monthlyCredits')\n report.plan = {\n planId: stringField(plan, 'planId', 'plan.planId'),\n name: stringField(plan, 'name', 'plan.name'),\n status: stringField(plan, 'status', 'plan.status'),\n monthlyCredits: monthly as number | null,\n currentPeriodEnd: numberField(plan, 'currentPeriodEnd', 'plan.currentPeriodEnd'),\n }\n if (plan.currentPeriodStart !== undefined) {\n report.plan.currentPeriodStart = numberField(plan, 'currentPeriodStart', 'plan.currentPeriodStart')\n }\n }\n\n return report\n}\n\n/** Parse one untrusted boundary value into a {@link CommandCodeAccountUsage}. */\nfunction parseAccountUsage(value: unknown): CommandCodeAccountUsage {\n const source = record(value, 'account')\n return {\n id: stringField(source, 'id', 'account.id'),\n label: stringField(source, 'label', 'account.label'),\n configured: booleanField(source, 'configured', 'account.configured'),\n active: booleanField(source, 'active', 'account.active'),\n mark: stringField(source, 'mark', 'account.mark'),\n cooldownUntil: numberField(source, 'cooldownUntil', 'account.cooldownUntil'),\n report: parseUsageReport(source.report),\n }\n}\n\n/** Parse the wire result into a {@link CommandCodeAccountsReport}. */\nfunction parseAccountsReport(value: unknown): CommandCodeAccountsReport {\n const source = record(value, 'result')\n const accounts = source.accounts\n if (Array.isArray(accounts)) {\n return { accounts: accounts.map(parseAccountUsage) }\n }\n return reject('accounts')\n}\n\n/**\n * The strict result codec both halves attach to the descriptor. Hand-rolled:\n * the client bundle may not require a schema library, and `TypertSchema` is\n * deliberately minimal so one `parse` function satisfies it.\n */\nexport const usageReportSchema: TypertSchema = {\n parse: parseAccountsReport,\n}\n\n/**\n * The one invocation descriptor, shared verbatim by the Host registration and\n * the Client mount. `service` names the Cordis key the Gateway resolves the\n * receiver from; `namespace`/`method` name the wire endpoint.\n */\nexport const USAGE_REPORT_DESCRIPTOR: InvocationDescriptor =\n makeRemoteDescriptor(\n USAGE_REPORT_ENDPOINT,\n 'report',\n `${USAGE_REMOTE_PACKAGE}#CommandCodeAccountsReport`,\n usageReportSchema,\n )\n\n/** The Host-face contribution registered on `ctx.typert`. */\nexport const USAGE_HOST_CONTRIBUTION = {\n package: USAGE_REMOTE_PACKAGE,\n face: 'host' as const,\n schemas: [],\n // Every Host contribution must carry its reflection model. This hand-written\n // Remote deliberately has no generated reflection exports, so use the\n // official empty-model form rather than a cast that would leave registry\n // inspection with `model: undefined`.\n model: { services: [], events: [], objects: [] },\n invocations: [USAGE_REPORT_DESCRIPTOR],\n}\n\n/** The Client-face contribution mounted on `ctx.remote`. */\nexport const USAGE_REMOTE_CONTRIBUTION: TypertRemoteContribution = {\n package: USAGE_REMOTE_PACKAGE,\n descriptors: [USAGE_REPORT_DESCRIPTOR],\n}\n\n// ---------------------------------------------------------------------------\n// Model catalog Remote (`commandcode/models`)\n// ---------------------------------------------------------------------------\n\n/** One catalog entry the settings page's model editors offer. */\nexport interface CommandCodeCatalogModel {\n /** Catalog model id (e.g. `deepseek/deepseek-v4-pro`). */\n id: string\n /** Display name from the catalog. */\n name: string\n /**\n * Minimum plan-tier key for this model (a `KNOWN_PLANS` value: `go`,\n * `goat`, `pro`, `provider`, `max`), or undefined for models outside the\n * snapshot. The settings page groups the model-editor dropdowns under\n * tier headings from this — the browser cannot import the Host's\n * capability snapshot, so the Host stamps it per entry.\n */\n tier?: string\n /**\n * Per-model MONTHLY allowance in USD: how much of the plan's monthly credit\n * pool this one model may draw, already resolved by the Host against the\n * account pool's highest plan (the pricing page publishes an allowance for\n * Go, GOAT and Pro). Undefined when that plan has no published allowance\n * (Go, Provider, Max, Ultra) or when billing could not be read — the browser\n * shows nothing rather than guessing a neighbouring tier's figure.\n *\n * Deliberately a plain number and not the `{ go, goat, pro }` map: the bracket is\n * a Host decision that already depends on facts the browser does not hold, and\n * shipping both figures would invite a second, divergent rule here. Note this\n * is dollars per MONTH, unlike every rate in `CommandCodePriceTable`.\n */\n allowance?: number\n}\n\n/** The model-catalog Remote result: the full catalog, sorted for picking. */\nexport interface CommandCodeCatalog {\n models: CommandCodeCatalogModel[]\n}\n\n/** Canonical `/` endpoint of the model-catalog Remote. */\nexport const MODELS_ENDPOINT = 'commandcode/models'\n\n/**\n * The shared read/validate helpers for the model-catalog endpoint — a\n * separate instance so catalog boundary errors name `commandcode/models`,\n * not the report endpoint.\n */\nconst {\n record: catalogRecord,\n stringField: catalogString,\n numberField: catalogNumber,\n} = makeBoundaryValidator('commandcode/models result:')\n\n/** Parse one untrusted boundary value into a {@link CommandCodeCatalogModel}. */\nfunction parseCatalogModel(value: unknown): CommandCodeCatalogModel {\n const source = catalogRecord(value, 'model')\n const model: CommandCodeCatalogModel = {\n id: catalogString(source, 'id', 'model.id'),\n name: catalogString(source, 'name', 'model.name'),\n }\n // Tier is optional on the wire (older Hosts predate it); a present\n // non-string is a contract violation, not a silent drop.\n if (source.tier !== undefined) {\n model.tier = catalogString(source, 'tier', 'model.tier')\n }\n // Same rule for the allowance: an older Host simply omits it, but a Host that\n // sends a non-number has broken the contract.\n if (source.allowance !== undefined) {\n model.allowance = catalogNumber(source, 'allowance', 'model.allowance')\n }\n return model\n}\n\n/** Parse the wire result into a {@link CommandCodeCatalog}. */\nfunction parseCatalog(value: unknown): CommandCodeCatalog {\n const source = catalogRecord(value, 'result')\n const models = source.models\n if (Array.isArray(models)) {\n return { models: models.map(parseCatalogModel) }\n }\n throw new TypeError('commandcode/models result: invalid models')\n}\n\n/** The strict result codec for the model-catalog Remote. */\nexport const modelsSchema: TypertSchema = {\n parse: parseCatalog,\n}\n\n/**\n * The model-catalog invocation descriptor, sharing the same `commandcodeUsage`\n * service and `commandcode` namespace as the usage report.\n */\nexport const MODELS_DESCRIPTOR: InvocationDescriptor =\n makeRemoteDescriptor(\n MODELS_ENDPOINT,\n 'models',\n `${USAGE_REMOTE_PACKAGE}#CommandCodeCatalog`,\n modelsSchema,\n )\n\n/** The Client-face contribution for the model-catalog endpoint. */\nexport const MODELS_REMOTE_CONTRIBUTION: TypertRemoteContribution = {\n package: USAGE_REMOTE_PACKAGE,\n descriptors: [MODELS_DESCRIPTOR],\n}\n\n// ---------------------------------------------------------------------------\n// Model price table Remote (`commandcode/prices`)\n// ---------------------------------------------------------------------------\n\n/**\n * One model's per-token rates, in USD per 1,000,000 tokens — the unit the\n * official pricing page publishes in.\n */\nexport interface CommandCodeModelRates {\n /** Uncached (billed) input tokens. */\n inputCost: number\n /** Completion tokens. */\n outputCost: number\n /** Input tokens served from the provider's cache. */\n cacheReadCost: number\n /**\n * Input tokens written into the provider's cache. Present for a minority of\n * models — the page publishes no cache-write rate for the rest, whose\n * cache-write tokens are therefore UNPRICED. Do not substitute a multiple of\n * the input rate for a missing value.\n */\n cacheWriteCost?: number\n}\n\n/** One whole-request context band; maxContext is inclusive, absent on the last band. */\nexport interface CommandCodeContextTier extends CommandCodeModelRates {\n maxContext?: number\n}\n\n/** One model's rates plus the peak-hour override for time-of-day models. */\nexport interface CommandCodeModelPrice extends CommandCodeModelRates {\n /**\n * Lookup key: the catalog model id when a catalog model maps to this row,\n * otherwise the pricing page's own slug. A session reports catalog ids, so\n * this is the primary key the browser looks up by.\n */\n id: string\n /** The pricing page's slug for this row — the secondary lookup key. */\n slug: string\n /**\n * Rates charged inside the peak windows. The row's own top-level rates are\n * the off-peak rates, so a row WITH this block is time-of-day priced and a\n * row without it is flat-priced.\n */\n peak?: CommandCodeModelRates\n contextTiers?: CommandCodeContextTier[]\n /**\n * Whether the model costs nothing on every plan right now (a free deal or a\n * `:free` catalog variant). Served explicitly at zero rates so a surface can\n * say \"free\" rather than showing nothing.\n */\n free?: boolean\n}\n\n/** The price-table Remote result: every known model's rates. */\nexport interface CommandCodePriceTable {\n /**\n * Every priced model, keyed by {@link CommandCodeModelPrice.id} (catalog id\n * first, pricing slug as the fallback) and carrying its slug as a second\n * lookup key. A model absent from this list has no known price and must\n * render no cost at all rather than a guess.\n */\n models: CommandCodeModelPrice[]\n /**\n * Peak-pricing windows as `[startHour, endHour)` in UTC, end-exclusive,\n * applying Monday–Friday only. Shipped with the table so the browser prices\n * against the Host snapshot's schedule instead of restating it.\n */\n peakHours: Array<[number, number]>\n}\n\n/** Canonical `/` endpoint of the price-table Remote. */\nexport const PRICES_ENDPOINT = 'commandcode/prices'\n\n/**\n * The shared read/validate helpers for the price-table endpoint — its own\n * instance so price boundary errors name `commandcode/prices`.\n */\nconst {\n reject: priceReject,\n record: priceRecord,\n stringField: priceString,\n numberField: priceNumber,\n booleanField: priceBoolean,\n} = makeBoundaryValidator('commandcode/prices result:')\n\n/** Parse one rate block (`rates`, or a model's `peak` override). */\nfunction parseRates(source: Record, field: string): CommandCodeModelRates {\n const rates: CommandCodeModelRates = {\n inputCost: priceNumber(source, 'inputCost', `${field}.inputCost`),\n outputCost: priceNumber(source, 'outputCost', `${field}.outputCost`),\n cacheReadCost: priceNumber(source, 'cacheReadCost', `${field}.cacheReadCost`),\n }\n // Optional on the wire: a present non-number is a contract violation rather\n // than a silent zero (only a minority of models publish a cache-write rate).\n if (source.cacheWriteCost !== undefined) {\n rates.cacheWriteCost = priceNumber(source, 'cacheWriteCost', `${field}.cacheWriteCost`)\n }\n return rates\n}\n\n/** Parse one untrusted boundary value into a {@link CommandCodeModelPrice}. */\nfunction parseModelPrice(value: unknown): CommandCodeModelPrice {\n const source = priceRecord(value, 'model')\n const price: CommandCodeModelPrice = {\n id: priceString(source, 'id', 'model.id'),\n slug: priceString(source, 'slug', 'model.slug'),\n ...parseRates(source, 'model'),\n }\n if (source.peak !== undefined) price.peak = parseRates(priceRecord(source.peak, 'model.peak'), 'model.peak')\n if (source.contextTiers !== undefined) {\n if (!Array.isArray(source.contextTiers) || source.contextTiers.length === 0) priceReject('contextTiers')\n let previous = 0\n price.contextTiers = (source.contextTiers as unknown[]).map((value, index, tiers) => {\n const tier = priceRecord(value, 'contextTier')\n const out: CommandCodeContextTier = parseRates(tier, 'contextTier')\n if (tier.maxContext !== undefined) {\n const max = priceNumber(tier, 'maxContext', 'contextTier.maxContext')\n if (!Number.isSafeInteger(max) || max <= previous || index === tiers.length - 1) priceReject('contextTier.maxContext')\n previous = max\n out.maxContext = max\n } else if (index !== tiers.length - 1) priceReject('contextTier.maxContext')\n return out\n })\n }\n if (source.free !== undefined) price.free = priceBoolean(source, 'free', 'model.free')\n return price\n}\n\n/** Parse the wire result into a {@link CommandCodePriceTable}. */\nfunction parsePriceTable(value: unknown): CommandCodePriceTable {\n const source = priceRecord(value, 'result')\n const models = source.models\n if (!Array.isArray(models)) priceReject('models')\n const peakHours = source.peakHours\n if (!Array.isArray(peakHours)) priceReject('peakHours')\n return {\n models: (models as unknown[]).map(parseModelPrice),\n peakHours: (peakHours as unknown[]).map((window) => {\n if (!Array.isArray(window) || window.length !== 2) priceReject('peakHours[]')\n const [start, end] = window as [unknown, unknown]\n if (typeof start !== 'number' || typeof end !== 'number') priceReject('peakHours[]')\n return [start, end] as [number, number]\n }),\n }\n}\n\n/** The strict result codec for the price-table Remote. */\nexport const pricesSchema: TypertSchema = {\n parse: parsePriceTable,\n}\n\n/**\n * The price-table invocation descriptor, sharing the same `commandcodeUsage`\n * service and `commandcode` namespace as the report and catalog endpoints.\n */\nexport const PRICES_DESCRIPTOR: InvocationDescriptor =\n makeRemoteDescriptor(\n PRICES_ENDPOINT,\n 'prices',\n `${USAGE_REMOTE_PACKAGE}#CommandCodePriceTable`,\n pricesSchema,\n )\n\n/** The Client-face contribution for the price-table endpoint. */\nexport const PRICES_REMOTE_CONTRIBUTION: TypertRemoteContribution = {\n package: USAGE_REMOTE_PACKAGE,\n descriptors: [PRICES_DESCRIPTOR],\n}\n","/**\n * Wire contract for the Command Code login Remote endpoints\n * (`commandcode/loginBegin`, `commandcode/loginStatus`,\n * `commandcode/loginCancel`).\n *\n * The settings page can start a browser login against the official Command\n * Code Studio instead of pasting an API key. The loopback server must live\n * in the Host half — it binds a local port and receives the key — so the page\n * drives it through the Typert Gateway exactly like the usage report.\n *\n * This module is the single source both halves share, deliberately\n * dependency-free (`import type` edges only): the strict status validator the\n * client trusts, the three descriptors both halves register, and the two\n * contribution objects. The state shape mirrors the Host-only flow machine in\n * `src/login.ts` as plain JSON.\n *\n * @module dsh-commandcode-provider/login-wire\n */\n\nimport type { InvocationDescriptor, TypertRemoteContribution, TypertSchema } from '@deepseek-ai/dsh-typert-protocol'\nimport {\n makeBoundaryValidator,\n makeRemoteDescriptor,\n makeStrictCodec,\n REMOTE_PACKAGE,\n} from './wire-shared.ts'\n\n/** Why a login attempt ended in `failed` (stable across versions for copy). */\nexport type CommandCodeLoginFailureReason =\n /** The Studio page reported the authorization was denied by the user. */\n | 'denied'\n /** No callback arrived within the flow's timeout window. */\n | 'timeout'\n /** The delivered key failed `/alpha/whoami` validation (401). */\n | 'invalid-key'\n /** The validation request could not reach the API. */\n | 'network'\n /** The key could not be stored (credentials seam unavailable). */\n | 'unavailable'\n /** The attempt was cancelled by the user or torn down with the plugin. */\n | 'cancelled'\n /** Anything else. */\n | 'error'\n\n/** One login attempt's full state face, as carried over the wire. */\nexport interface CommandCodeLoginStatus {\n /**\n * `idle` — no attempt; `waiting` — the loopback server is up and the\n * Studio URL is live; `success` — the key validated and was stored;\n * `failed` — see `reason`/`message`.\n */\n state: 'idle' | 'waiting' | 'success' | 'failed'\n /** The Studio authorization URL while `waiting`. */\n authUrl?: string\n /** The account display name reported by the Studio, on `success`. */\n userName?: string\n /** The key's label from the Studio, on `success`. */\n keyName?: string\n /** Why the attempt failed, when `failed`. */\n reason?: CommandCodeLoginFailureReason\n /** Human-readable failure detail, when `failed` (secondary to `reason`). */\n message?: string\n}\n\n/** The canonical endpoint paths of the three login Remotes. */\nexport const LOGIN_BEGIN_ENDPOINT = 'commandcode/loginBegin'\nexport const LOGIN_STATUS_ENDPOINT = 'commandcode/loginStatus'\nexport const LOGIN_CANCEL_ENDPOINT = 'commandcode/loginCancel'\n\nconst REASONS: readonly CommandCodeLoginFailureReason[] = [\n 'denied', 'timeout', 'invalid-key', 'network', 'unavailable', 'cancelled', 'error',\n]\n\n/** The shared read/validate helpers, prefixed with the login endpoint so\n * rejection messages name the offending boundary. */\nconst { reject, record, stringField } =\n makeBoundaryValidator('commandcode/login result:')\n\n/**\n * Parse one untrusted boundary value into a {@link CommandCodeLoginStatus}.\n * Every field is shape-checked so a malformed frame fails the boundary\n * instead of leaking into the page.\n */\nexport function parseLoginStatus(value: unknown): CommandCodeLoginStatus {\n const source = record(value, 'status')\n const state = source.state\n if (state === 'idle' || state === 'waiting' || state === 'success' || state === 'failed') {\n const status: CommandCodeLoginStatus = { state }\n if (source.authUrl !== undefined) {\n status.authUrl = stringField(source, 'authUrl', 'authUrl')\n }\n if (source.userName !== undefined) {\n status.userName = stringField(source, 'userName', 'userName')\n }\n if (source.keyName !== undefined) {\n status.keyName = stringField(source, 'keyName', 'keyName')\n }\n if (source.reason !== undefined) {\n const reason = source.reason\n if (typeof reason === 'string' && REASONS.includes(reason as CommandCodeLoginFailureReason)) {\n status.reason = reason as CommandCodeLoginFailureReason\n } else {\n reject('reason')\n }\n }\n if (source.message !== undefined) {\n status.message = stringField(source, 'message', 'message')\n }\n return status\n }\n return reject('state')\n}\n\n/** The strict result codec shared by all three login endpoints. */\nexport const loginStatusSchema: TypertSchema = {\n parse: parseLoginStatus,\n}\n\n/** Optional account target; the Host still checks it against saved slots. */\nconst loginTargetSchema: TypertSchema = {\n parse(value: unknown): string | undefined {\n if (value === undefined || typeof value === 'string') return value\n return reject('targetRef')\n },\n}\n\n/** Build one login invocation descriptor. Only begin accepts an account ref. */\nfunction loginDescriptor(endpoint: string, method: string): InvocationDescriptor {\n const descriptor = makeRemoteDescriptor(\n endpoint,\n method,\n `${REMOTE_PACKAGE}#CommandCodeLoginStatus`,\n loginStatusSchema,\n )\n return method === 'loginBegin'\n ? {\n ...descriptor,\n parameters: [{\n name: 'targetRef', wire: 'targetRef', source: 'json',\n codec: makeStrictCodec(`${REMOTE_PACKAGE}#CommandCodeLoginTargetRef`, loginTargetSchema),\n acceptsUndefined: true,\n }],\n }\n : descriptor\n}\n\n/** The three login descriptors, shared verbatim by Host registration and Client mount. */\nexport const LOGIN_DESCRIPTORS: readonly InvocationDescriptor[] = [\n loginDescriptor(LOGIN_BEGIN_ENDPOINT, 'loginBegin'),\n loginDescriptor(LOGIN_STATUS_ENDPOINT, 'loginStatus'),\n loginDescriptor(LOGIN_CANCEL_ENDPOINT, 'loginCancel'),\n]\n\n/** The Host-face contribution fragment registered on `ctx.typert`. */\nexport const LOGIN_HOST_CONTRIBUTION = {\n package: REMOTE_PACKAGE,\n face: 'host' as const,\n schemas: [],\n invocations: LOGIN_DESCRIPTORS,\n}\n\n/** The Client-face contribution fragment mounted on `ctx.remote`. */\nexport const LOGIN_REMOTE_CONTRIBUTION: TypertRemoteContribution = {\n package: REMOTE_PACKAGE,\n descriptors: LOGIN_DESCRIPTORS,\n}\n","/** 账号开通的双方使用契约。状态和恢复记录不携带密钥,密钥只出现在手动开通输入。 */\nimport type { InvocationDescriptor, TypertRemoteContribution, TypertSchema } from '@deepseek-ai/dsh-typert-protocol'\nimport { makeBoundaryValidator, makeRemoteDescriptor, makeStrictCodec, REMOTE_PACKAGE } from './wire-shared.ts'\n\nexport interface EnrollmentInput {\n id: string\n pageId: string\n mode: 'browser' | 'manual'\n label: string\n /** 用户未填写名称时,允许使用浏览器登录返回的账号名。 */\n automaticName: boolean\n key?: string\n}\nexport type EnrollmentPhase = 'creating' | 'waiting' | 'writing' | 'naming' | 'finished' | 'cancelling' | 'cancelled' | 'failed' | 'cleanup-needed'\n/**\n * 账号开通状态的语义消息键。\n *\n * 状态消息传键而不是成品文案:界面语言由浏览器侧持有,宿主没有立场去猜;同一条状态\n * 要在中文与英文界面下各自显示,而 `phase` 不足以唯一确定它——`naming` 既有「正在\n * 保存名称」也有「名称保存失败」,`cleanup-needed` 既有「收尾未完成」也有「清理未\n * 完成」,`cancelled` 既有「已取消」也有「已取消并清理」。客户端拿这些键走 `t()`\n * 渲染,不必再匹配自由文本;`SettingsCommandCodeKey` 收录本类型,因此新增键漏写\n * 中文或英文字典都会被类型检查拦下。\n *\n * 其中十五条由宿主产出(`src/enrollment.ts`),`enrollmentCleanupPending` 也在此列——\n * 它在「收尾未完成」时由宿主与页面两侧共用同一个键;另外七条是浏览器侧控制器的专属键\n * (`src/client/enrollment.ts`),只在传输失败或连接断开时由该侧自己产出。宿主抛出的\n * 异常消息不在此列:它们不上屏,客户端在所有失败路径都用自己这一侧的文案覆盖。\n */\nexport type EnrollmentMessageKey =\n | 'enrollmentCreating'\n | 'enrollmentWaiting'\n | 'enrollmentWriting'\n | 'enrollmentNaming'\n | 'enrollmentNameFailed'\n | 'enrollmentFinished'\n | 'enrollmentCancelling'\n | 'enrollmentCancelled'\n | 'enrollmentCancelledCleaned'\n | 'enrollmentFailed'\n | 'enrollmentFailedCleaned'\n | 'enrollmentCleanupPending'\n | 'enrollmentCleanupFailed'\n | 'enrollmentRecordNaming'\n | 'enrollmentRecordCleanup'\n | 'enrollmentBeginFailed'\n | 'enrollmentCancelUnconfirmed'\n | 'enrollmentNameRequestFailed'\n | 'enrollmentCleanupRequestFailed'\n | 'enrollmentDisconnected'\n | 'enrollmentHostUnavailable'\n | 'enrollmentStatusUnavailable'\nexport const ENROLLMENT_MESSAGE_KEYS = [\n 'enrollmentCreating', 'enrollmentWaiting', 'enrollmentWriting', 'enrollmentNaming', 'enrollmentNameFailed',\n 'enrollmentFinished', 'enrollmentCancelling', 'enrollmentCancelled', 'enrollmentCancelledCleaned', 'enrollmentFailed',\n 'enrollmentFailedCleaned', 'enrollmentCleanupPending', 'enrollmentCleanupFailed', 'enrollmentRecordNaming', 'enrollmentRecordCleanup',\n 'enrollmentBeginFailed', 'enrollmentCancelUnconfirmed', 'enrollmentNameRequestFailed', 'enrollmentCleanupRequestFailed',\n 'enrollmentDisconnected', 'enrollmentHostUnavailable', 'enrollmentStatusUnavailable',\n] as const satisfies readonly EnrollmentMessageKey[]\n/**\n * 键表必须覆盖 {@link EnrollmentMessageKey} 的每一个成员。\n *\n * `satisfies` 只保证数组里不出现类型以外的键,**漏写一项它不会报错**——而漏写的后果\n * 不是少一条文案:`parseEnrollmentState` 会拒绝该键,`enrollmentPending` 的整张表随之\n * 被拒,对应的恢复记录从界面静默消失。所以补一个反方向的编译期断言:数组比类型少时\n * `Exclude` 不为 `never`,这里的 `= true` 直接编译失败。\n */\nexport const ENROLLMENT_MESSAGE_KEYS_ARE_COMPLETE:\n Exclude extends never ? true : never = true\nexport interface EnrollmentState {\n id: string\n ref: string\n phase: EnrollmentPhase\n /** 要显示的语义键;文案由浏览器侧按界面语言渲染,见 {@link EnrollmentMessageKey}。 */\n messageKey: EnrollmentMessageKey\n authUrl?: string\n suggestedName?: string\n}\n/** 配置中的恢复日志:先登记,再写凭据;只保留引用与阶段,不保留密钥或连接身份。 */\nexport interface EnrollmentRecord {\n id: string\n ref: string\n phase: 'pending' | 'naming' | 'cleanup'\n label: string\n}\nconst guard = makeBoundaryValidator('commandcode/enrollment')\nconst phases: readonly EnrollmentPhase[] = ['creating', 'waiting', 'writing', 'naming', 'finished', 'cancelling', 'cancelled', 'failed', 'cleanup-needed']\nexport function parseEnrollmentState(value: unknown): EnrollmentState {\n const source = guard.record(value, 'state')\n const phase = source.phase\n if (!phases.includes(phase as EnrollmentPhase)) return guard.reject('phase')\n const messageKey = source.messageKey\n if (!ENROLLMENT_MESSAGE_KEYS.includes(messageKey as EnrollmentMessageKey)) return guard.reject('messageKey')\n const state: EnrollmentState = {\n id: guard.stringField(source, 'id', 'id'), ref: guard.stringField(source, 'ref', 'ref'),\n phase: phase as EnrollmentPhase, messageKey: messageKey as EnrollmentMessageKey,\n }\n for (const field of ['authUrl', 'suggestedName'] as const) {\n if (source[field] !== undefined) state[field] = guard.stringField(source, field, field)\n }\n return state\n}\nexport function parseEnrollmentInput(value: unknown): EnrollmentInput {\n const source = guard.record(value, 'input')\n const id = guard.stringField(source, 'id', 'id')\n if (!/^[a-zA-Z0-9-]{16,80}$/.test(id)) return guard.reject('id')\n if (source.mode !== 'browser' && source.mode !== 'manual') return guard.reject('mode')\n const input: EnrollmentInput = {\n id, pageId: parsePage(source).pageId, mode: source.mode, label: guard.stringField(source, 'label', 'label').trim(),\n automaticName: guard.booleanField(source, 'automaticName', 'automaticName'),\n }\n if (!input.label || input.label.length > 200) return guard.reject('label')\n if (input.mode === 'manual') {\n input.key = guard.stringField(source, 'key', 'key').trim()\n if (!input.key) return guard.reject('key')\n } else if (source.key !== undefined) return guard.reject('key')\n return input\n}\nexport interface EnrollmentPage { pageId: string }\nfunction parsePage(value: unknown): EnrollmentPage {\n const source = guard.record(value, 'page')\n const pageId = guard.stringField(source, 'pageId', 'pageId')\n if (!/^[a-zA-Z0-9-]{16,80}$/.test(pageId)) return guard.reject('pageId')\n return { pageId }\n}\nexport interface EnrollmentAction extends EnrollmentPage { id: string; name?: string }\nconst actionSchema: TypertSchema = {\n parse(value) {\n const source = guard.record(value, 'action')\n const action: EnrollmentAction = { id: guard.stringField(source, 'id', 'id'), ...parsePage(source) }\n if (!/^[a-zA-Z0-9-]{16,80}$/.test(action.id)) return guard.reject('id')\n if (source.name !== undefined) action.name = guard.stringField(source, 'name', 'name').trim()\n if (action.name !== undefined && (!action.name || action.name.length > 200)) return guard.reject('name')\n return action\n },\n}\nconst stateSchema = { parse: parseEnrollmentState }\nconst listSchema: TypertSchema = {\n parse(value) { return Array.isArray(value) ? value.map(parseEnrollmentState) : guard.reject('list') },\n}\nexport const ENROLLMENT_DESCRIPTORS: readonly InvocationDescriptor[] = [\n ...['enrollmentBegin', 'enrollmentStatus', 'enrollmentCancel', 'enrollmentName', 'enrollmentRetry'].map((method) => ({\n ...makeRemoteDescriptor(`commandcode/${method}`, method, `${REMOTE_PACKAGE}#EnrollmentState`, stateSchema),\n parameters: [{ name: 'input', wire: 'input', source: 'json' as const,\n codec: makeStrictCodec(`${REMOTE_PACKAGE}#${method}Input`, method === 'enrollmentBegin' ? { parse: parseEnrollmentInput } : actionSchema) }],\n })),\n { ...makeRemoteDescriptor('commandcode/enrollmentPending', 'enrollmentPending', `${REMOTE_PACKAGE}#EnrollmentStateList`, listSchema),\n parameters: [{ name: 'input', wire: 'input', source: 'json', codec: makeStrictCodec(`${REMOTE_PACKAGE}#EnrollmentPage`, { parse: parsePage }) }] },\n { ...makeRemoteDescriptor('commandcode/enrollmentWatch', 'enrollmentWatch', `${REMOTE_PACKAGE}#EnrollmentReady`, {\n parse(value: unknown) { return value === true ? true : guard.reject('ready') },\n }), mode: 'stream',\n parameters: [{ name: 'input', wire: 'input', source: 'json', codec: makeStrictCodec(`${REMOTE_PACKAGE}#EnrollmentPage`, { parse: parsePage }) }] },\n]\nexport const ENROLLMENT_REMOTE_CONTRIBUTION: TypertRemoteContribution = { package: REMOTE_PACKAGE, descriptors: ENROLLMENT_DESCRIPTORS }\n","/** 每次设置页挂载独立拥有一个开通控制器;共享设置/用量控制器仍保持插件生命周期。 */\nimport type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol'\nimport type { EnrollmentAction, EnrollmentInput, EnrollmentMessageKey, EnrollmentPage, EnrollmentState } from '../enrollment-wire.ts'\nimport { createSnapshotStore } from './snapshot-store.ts'\nimport { openLoginPage } from './login-page.ts'\n\nexport interface EnrollmentRemote {\n enrollmentBegin(input: EnrollmentInput): Promise>\n enrollmentStatus(input: EnrollmentAction): Promise>\n enrollmentCancel(input: EnrollmentAction): Promise>\n enrollmentName(input: EnrollmentAction): Promise>\n enrollmentRetry(input: EnrollmentAction): Promise>\n enrollmentPending(input: EnrollmentPage): Promise>\n enrollmentWatch(input: EnrollmentPage): RemoteStreamHandle\n}\ndeclare module '@deepseek-ai/dsh-typert-protocol' {\n interface TypertRemoteMap {\n 'commandcode/enrollmentBegin': EnrollmentRemote['enrollmentBegin']\n 'commandcode/enrollmentStatus': EnrollmentRemote['enrollmentStatus']\n 'commandcode/enrollmentCancel': EnrollmentRemote['enrollmentCancel']\n 'commandcode/enrollmentName': EnrollmentRemote['enrollmentName']\n 'commandcode/enrollmentRetry': EnrollmentRemote['enrollmentRetry']\n 'commandcode/enrollmentPending': EnrollmentRemote['enrollmentPending']\n 'commandcode/enrollmentWatch': EnrollmentRemote['enrollmentWatch']\n }\n}\n/**\n * 页面状态。`error` 与宿主状态里的 `messageKey` 一样只存语义键:传输失败时这一侧\n * 产出的也是键(如 `enrollmentDisconnected`),渲染时才按界面语言取文案,所以本\n * 控制器不持有任何成品文本。\n */\nexport interface EnrollmentPageState { active: EnrollmentState | undefined; pending: EnrollmentState[]; error: EnrollmentMessageKey | undefined }\nconst live = (state: EnrollmentState) => ['creating', 'waiting', 'writing', 'cancelling'].includes(state.phase)\nexport class AccountEnrollmentController {\n readonly store = createSnapshotStore({ active: undefined, pending: [], error: undefined })\n private id: string | undefined\n private timer: ReturnType | undefined\n private disposed = false\n private pageId: string\n private watch: RemoteStreamHandle | undefined\n private ready: Promise | undefined\n private openedPage = false\n constructor(private readonly remote: () => EnrollmentRemote, private readonly changed: () => void, private readonly pollMs = 750, private readonly newId = () => crypto.randomUUID(), private readonly openPage = openLoginPage) { this.pageId = newId() }\n /** 支持 React 开发模式的挂载检查;每次挂载仍重新读取宿主事实。 */\n activate(): void { if (this.disposed) this.pageId = this.newId(); this.disposed = false; void this.refresh() }\n reset(): void { if (!this.id) this.store.set({ ...this.store.getSnapshot(), active: undefined, error: undefined }) }\n async refresh(): Promise {\n try {\n const result = await this.remote().enrollmentPending({ pageId: this.pageId })\n if (result.ok && !this.disposed) this.store.set({ ...this.store.getSnapshot(), pending: result.value })\n } catch { /* 旧宿主或连接暂不可用,开始开通时再提供明确提示。 */ }\n }\n async begin(input: Omit): Promise {\n if (this.id || this.disposed) return\n const id = this.newId()\n const pageId = this.pageId\n this.id = id\n this.openedPage = false\n // 本地先行显示的状态与宿主同键,避免同一条状态在两侧各写一份文案。\n this.store.set({ ...this.store.getSnapshot(), error: undefined, active: { id, ref: '', phase: 'creating', messageKey: 'enrollmentCreating' } })\n try {\n await this.ensureWatch()\n if (this.disposed || this.id !== id || this.pageId !== pageId) return\n const result = await this.remote().enrollmentBegin({ ...input, id, pageId })\n if (this.disposed || this.id !== id) {\n // begin 回复晚到时再取消一次,覆盖传输层乱序或早到的取消失败。\n await this.remote().enrollmentCancel({ id, pageId })\n return\n }\n this.receive(id, result)\n } catch {\n if (this.id === id) this.cancel()\n this.failure('enrollmentBeginFailed')\n }\n }\n /** 页面可立即离开;宿主仍等待写入和补偿收尾。 */\n cancel(): void {\n const id = this.id\n const pageId = this.pageId\n this.id = undefined\n this.stopTimer()\n if (!this.disposed) this.store.set({ ...this.store.getSnapshot(), active: undefined })\n if (id) void Promise.resolve().then(() => this.remote().enrollmentCancel({ id, pageId })).then(() => { this.changed(); void this.refresh() }, () => this.failure('enrollmentCancelUnconfirmed'))\n }\n async name(name?: string): Promise {\n const id = this.id\n if (!id) return\n try { this.receive(id, await this.remote().enrollmentName({ id, pageId: this.pageId, ...(name ? { name } : {}) })) }\n catch { this.failure('enrollmentNameRequestFailed') }\n }\n async recover(record: EnrollmentState, name?: string): Promise {\n try {\n const result = await this.remote().enrollmentRetry({ id: record.id, pageId: this.pageId, ...(name ? { name } : {}) })\n if (!result.ok) this.failure('enrollmentCleanupPending')\n else if (!this.disposed && this.store.getSnapshot().active?.id === record.id) {\n this.store.set({ ...this.store.getSnapshot(), active: result.value, error: undefined })\n }\n this.changed()\n await this.refresh()\n } catch { this.failure('enrollmentCleanupRequestFailed') }\n }\n dispose(): void { this.disposed = true; this.cancel(); this.watch?.dispose(); this.watch = undefined; this.ready = undefined }\n private ensureWatch(): Promise {\n if (this.ready) return this.ready\n const handle = this.remote().enrollmentWatch({ pageId: this.pageId })\n const iterator = handle[Symbol.asyncIterator]()\n this.watch = handle\n this.ready = iterator.next().then((first) => {\n if (first.done || first.value !== true || this.watch !== handle) throw new Error('页面观察流未建立')\n // 不重连原观察流;载体断开就是该页面过程结束,由宿主补偿。\n void (async () => {\n try { while (!(await iterator.next()).done) { /* 保持调用直到载体结束。 */ } }\n catch { /* 终止统一在 finally 中处理。 */ }\n finally {\n if (this.watch === handle && !this.disposed) {\n this.watch = undefined\n this.ready = undefined\n this.cancel()\n this.failure('enrollmentDisconnected')\n }\n }\n })()\n }).catch((error: unknown) => { handle.dispose(); if (this.watch === handle) { this.watch = undefined; this.ready = undefined }; throw error })\n return this.ready\n }\n private receive(id: string, result: RemoteResult): void {\n if (this.disposed || this.id !== id) return\n if (!result.ok) { this.failure('enrollmentHostUnavailable'); return }\n const active = result.value\n // 地址可能由后续轮询返回;只接受仍属于当前页面过程的地址,且每次过程只打开一次。\n if (active.phase === 'waiting' && active.authUrl && !this.openedPage) {\n this.openedPage = true\n this.openPage(active.authUrl)\n }\n this.store.set({ ...this.store.getSnapshot(), active, error: undefined })\n if (live(active)) {\n this.stopTimer()\n this.timer = setTimeout(() => { void this.poll(id) }, this.pollMs)\n } else {\n this.changed()\n void this.refresh()\n if (active.phase !== 'naming') this.id = undefined\n }\n }\n private async poll(id: string): Promise {\n if (this.disposed || this.id !== id) return\n try { this.receive(id, await this.remote().enrollmentStatus({ id, pageId: this.pageId })) }\n catch {\n this.failure('enrollmentStatusUnavailable')\n if (!this.disposed && this.id === id) this.timer = setTimeout(() => { void this.poll(id) }, this.pollMs)\n }\n }\n /** 记下要显示的语义键;文案在渲染处按界面语言取,本控制器不接触成品文本。 */\n private failure(key: EnrollmentMessageKey): void { if (!this.disposed) this.store.set({ ...this.store.getSnapshot(), error: key }) }\n private stopTimer(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined }\n}\n","/**\n * Locale copy for the \"Command Code\" settings page, and the declaration that\n * merges the page's namespace into the framework's `LocaleNamespaceMap` so\n * `ctx.locale.register` / `ctx.slots.register(..., { locale })` are typed.\n *\n * zh is the source of truth for the key set (repo convention); en must carry\n * the exact same keys — a mismatch is a compile error at the register site.\n *\n * 账号开通的状态文案也收在这里:宿主与浏览器侧控制器只产出语义键\n * (`EnrollmentMessageKey`),界面语言只有这一侧知道,文案因此只有一个来源。\n */\nimport type { EnrollmentMessageKey } from '../enrollment-wire.ts'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n 'settings.commandcode': SettingsCommandCodeKey\n }\n}\n\n/** Dictionary keys of the Command Code settings page. */\nexport type SettingsCommandCodeKey =\n | 'openAnalytics'\n | 'nav'\n | 'title'\n | 'intro'\n | 'apiKey'\n | 'apiKeyHint'\n | 'apiKeySet'\n | 'apiKeyUnset'\n | 'apiKeyLocked'\n | 'apiBase'\n | 'apiBaseHint'\n | 'requestTimeoutMs'\n | 'requestTimeoutMsHint'\n | 'streamIdleTimeoutMs'\n | 'streamIdleTimeoutMsHint'\n | 'transportMaxRetries'\n | 'transportMaxRetriesHint'\n | 'offloadSeenImagesForCache'\n | 'offloadSeenImagesForCacheHint'\n | 'advancedSettings'\n | 'advancedSettingsHint'\n | 'advancedOverriddenOne'\n | 'advancedOverriddenMany'\n | 'advancedInvalid'\n | 'filterModelsByPlan'\n | 'filterModelsByPlanHint'\n | 'webSearch'\n | 'webSearchHint'\n | 'showSidebarQuota'\n | 'showSidebarQuotaHint'\n | 'zdr'\n | 'zdrHint'\n | 'accountsTitle'\n | 'accountsHint'\n | 'accountAdd'\n | 'accountDefault'\n | 'modelSearchPlaceholder'\n | 'modelSearchEmpty'\n | 'modelStale'\n | 'visibleModelsTitle'\n | 'visibleModelsHint'\n | 'visibleModelsPick'\n | 'visibleModelsCount'\n | 'visibleModelsShowAll'\n | 'visibleModelsStaleHint'\n | 'visibleModelsCleanStale'\n | 'contextWindow'\n | 'contextWindowHint'\n | 'contextOverridesTitle'\n | 'contextOverridesHint'\n | 'contextOverrideAdd'\n | 'contextOverrideRemove'\n | 'contextOverrideModel'\n | 'contextOverrideValue'\n | 'contextOverrideNoCatalog'\n | 'contextWindowTooSmall'\n | 'contextWindowTooLarge'\n | 'contextWindowInvalid'\n | 'maxOutputTokens'\n | 'maxOutputTokensHint'\n | 'maxOutputTokensTooSmall'\n | 'maxOutputTokensTooLarge'\n | 'maxOutputTokensInvalid'\n | 'overridden'\n | 'reset'\n | 'invalidNumber'\n | 'numberTooSmall'\n | 'numberTooLarge'\n | 'durationTooSmall'\n | 'durationTooLarge'\n | 'durationInvalid'\n | 'readOnly'\n | 'save'\n | 'saving'\n | 'saved'\n | 'saveFailed'\n | 'writeConflict'\n | 'writeConfigUnconfirmed'\n | 'writeConfigSaved'\n | 'writeKeyNotIssued'\n | 'writeKeyUnconfirmed'\n | 'writeKeyConfirmed'\n | 'writeTargetBefore'\n | 'writeTargetAfter'\n | 'writeClosed'\n | 'writeCleanupPending'\n | 'writeRefreshFailed'\n | 'writeRefreshRetry'\n | 'writeStateUnknown'\n | 'writeCredentialRefNote'\n | 'unsavedChanges'\n | 'saveInvalid'\n | 'discard'\n | 'cancel'\n | 'show'\n | 'hide'\n | 'usageRefresh'\n | 'usageRefreshing'\n | 'usageLoading'\n | 'usageError'\n | 'usageRequests'\n | 'usageFailed'\n | 'usageCompleted'\n | 'usageBillingPeriod'\n | 'usagePeriodUnknown'\n | 'usageSuccessRate'\n | 'usageCost'\n | 'usageTokens'\n | 'usageTokensIn'\n | 'usageTokensOut'\n | 'usageMonthly'\n | 'usagePurchased'\n | 'usageFree'\n | 'usageFiveHour'\n | 'usageWeekly'\n | 'usageMonthlyLeft'\n | 'usageExceeded'\n | 'usageReset'\n | 'usagePartial'\n | 'usageKeyInvalid'\n | 'usageKeyInvalidHint'\n | 'usageServiceUnavailable'\n | 'usageServiceUnavailableHint'\n | 'usageNetworkError'\n | 'usageNetworkHint'\n | 'usageInvalidResponse'\n | 'usageInvalidResponseHint'\n | 'usageUpdated'\n | 'usagePeriodEnd'\n | 'usageActive'\n | 'usageCooldown'\n | 'usageInvalidKey'\n | 'usageUnconfigured'\n | 'updateAvailable'\n | 'updateHint'\n | 'loginTitle'\n | 'loginHintIdle'\n | 'loginButton'\n | 'loginStarting'\n | 'loginWaiting'\n | 'loginOpenLink'\n | 'loginCancel'\n | 'loginSuccess'\n | 'loginUnavailable'\n | 'loginDenied'\n | 'loginTimeout'\n | 'loginInvalidKey'\n | 'loginNetwork'\n | 'loginStoreFailed'\n | 'loginCancelled'\n | 'loginFailedGeneric'\n | 'cardTitle'\n | 'cardRouteActive'\n | 'cardLoadingHint'\n | 'cardRegistrationHint'\n | 'accountsRotationHint'\n | 'accountModeAuto'\n | 'accountModePinned'\n | 'accountStatusPinned'\n | 'accountActions'\n | 'accountActionPin'\n | 'accountActionUnpin'\n | 'accountActionLogin'\n | 'accountActionKey'\n | 'accountActionRename'\n | 'accountActionClearKey'\n | 'accountActionRemove'\n | 'accountRemoveConfirm'\n | 'accountClearKeyConfirm'\n | 'accountConfirmRemove'\n | 'accountConfirmClear'\n | 'accountApply'\n | 'accountNoKeyHint'\n | 'accountAddHint'\n | 'accountAddLogin'\n | 'accountAddPaste'\n | 'accountAddConfirm'\n | 'accountAddCancel'\n | 'accountAddLinkHint'\n | 'accountAddOpenLink'\n | 'accountNamePlaceholder'\n | 'accountNameN'\n | 'accountNameSaveRetry'\n | 'accountNameAcceptExisting'\n | 'accountKeyPlaceholder'\n | 'accountOpFailed'\n | 'accountCleanupPending'\n | 'accountCleanupRetry'\n | 'accountEnrollmentCleanupRetry'\n | 'accountRefSeparator'\n | 'accountModels'\n | 'accountModelsHint'\n | 'accountModelOwner'\n | 'modelPick'\n | 'modelCount'\n | 'modelAllowance'\n | 'modelAllowanceTitle'\n | 'modelCatalogFailed'\n | 'modelsTitle'\n | 'modelsInvalid'\n | 'privacyTitle'\n | 'integrationsTitle'\n // 账号开通状态的语义键:宿主与浏览器侧控制器共同产出,见 `EnrollmentMessageKey`。\n | EnrollmentMessageKey\n\nexport const zh: Record = {\n openAnalytics: '查看模型用量、成本与性能分析',\n nav: 'Command Code',\n title: 'Command Code',\n intro: '账户操作立即生效;其余设置修改后点击底部「保存」。API 密钥仅保存在本机凭据服务中,不会回显。',\n apiKey: 'API 密钥',\n apiKeyHint: '在 commandcode.ai 控制台创建。留空保存不会覆盖已存储的密钥。',\n apiKeySet: '已配置',\n apiKeyUnset: '未配置',\n apiKeyLocked: '密钥由只读来源提供',\n apiBase: 'API 地址',\n apiBaseHint: '默认 https://api.commandcode.ai,一般无需修改。',\n requestTimeoutMs: '请求超时(秒)',\n requestTimeoutMsHint: '等待响应首个字节的超时;默认 300 秒(与官方 CLI 一致)。Provider API 路由要等上游模型吐出第一个 token 才返回响应头,上游慢时仍可能不够;报超时时可按提示调高。',\n streamIdleTimeoutMs: '流空闲超时(秒)',\n streamIdleTimeoutMsHint: '生成流停滞多久视为断连;默认 300 秒(长思考模型可静默数分钟,默认值刻意放宽)。',\n transportMaxRetries: '网络失败重试次数',\n transportMaxRetriesHint: '连接失败时自动重试几次,默认 5(各次等待 0.5+1+2+4+8 秒,合计约 15 秒)。'\n + '超过后直接报错,不再按指数退避长时间等待;填 0 表示不重试。限流、5xx 等由服务端要求重试的失败不受此项影响。',\n offloadSeenImagesForCache: '已看过的图片不再重复发送',\n offloadSeenImagesForCacheHint: '仅 CLI 传输。模型看过图片并回复后,永久用文字占位替代旧图片,以减少新增图片时的缓存回退。模型如需重看旧图,必须重新读取文件或让用户重新附加。默认关闭。',\n advancedSettings: '高级设置',\n advancedSettingsHint: 'API 地址和网络参数,一般无需修改。',\n advancedOverriddenOne: '已自定义 1 项',\n advancedOverriddenMany: '已自定义 {count} 项',\n advancedInvalid: '高级设置中有未填好的数字,请展开修正后再保存。',\n filterModelsByPlan: '隐藏套餐外模型',\n filterModelsByPlanHint: '只显示套餐内模型;按需余额账户仍显示全部。',\n webSearch: '用 Command Code 承载联网搜索',\n webSearchHint: '使用同一密钥承载 dsh 的 web_search;关闭后恢复原搜索后端。若「网页搜索」设置里已显式选择其他后端,本开关不生效。',\n showSidebarQuota: '在侧边栏显示额度卡片',\n showSidebarQuotaHint: '在侧边栏底部显示套餐与额度卡片,点击可打开完整仪表盘。默认关闭,关闭时不会为它后台刷新用量。',\n zdr: '零数据保留(ZDR)',\n zdrHint: '仅使用不留存、不训练的上游;可用模型可能更少且价格通常更高。',\n accountsTitle: '账户',\n accountsHint: '账户操作立即生效,无需保存。可以添加多个账户:当前账户达到用量限额或密钥失效时,请求会自动切换到下一个。',\n accountAdd: '添加账户',\n accountDefault: '默认账户',\n modelSearchPlaceholder: '搜索模型…',\n modelSearchEmpty: '没有匹配的模型。',\n modelStale: '已下架',\n modelAllowance: '${amount}/月',\n modelAllowanceTitle: '该模型每月可用的额度(美元)。套餐内所有模型共用同一个月度额度池,这不是额外赠送的额度。',\n visibleModelsTitle: '模型白名单',\n visibleModelsHint:\n '勾选要保留的模型,模型选择器就只列出这些;一个都不勾选时则显示全部模型。'\n + '这里显示网页与终端合并后的有效选择;保存后两端一致,套餐权限仍独立过滤。',\n visibleModelsPick: '选择要保留的模型…',\n visibleModelsCount: '已选 {count} 个模型',\n visibleModelsShowAll: '显示全部',\n visibleModelsStaleHint: '有 {count} 个已选模型在目录中找不到了(可能已下架),不影响其他模型;可清理或保留。',\n visibleModelsCleanStale: '清理失效({count})',\n contextWindow: '自定义上下文长度',\n contextWindowHint:\n '覆盖模型目录公布的上下文窗口(对未单独覆盖的模型生效),影响上下文计量与自动压缩;'\n + '留空跟随目录。支持 32K、1M 简写。',\n contextOverridesTitle: '按模型覆盖',\n contextOverridesHint: '为单个模型单独设置上下文长度,优先于上面的全局值;模型下拉只列出模型白名单中的可见模型(显示全部时即整个目录)。',\n contextOverrideAdd: '添加模型',\n contextOverrideRemove: '移除该模型的上下文覆盖',\n contextOverrideModel: '模型',\n contextOverrideValue: '上下文长度',\n contextOverrideNoCatalog: '模型目录尚未加载,暂时无法添加按模型覆盖。',\n contextWindowTooSmall: '上下文长度不能小于 1',\n contextWindowTooLarge: '上下文长度不能大于 10,000,000',\n contextWindowInvalid: '请输入有效的整数 token 数',\n maxOutputTokens: '单轮输出上限',\n maxOutputTokensHint:\n '留空=按模型推导(默认);正整数=不超过模型上限与网关上限 131072;'\n + '0=不发送该字段(仅 CLI 与 OpenAI 有效,Claude 的 Messages 端点必填)。支持 64K 简写。',\n maxOutputTokensTooSmall: '输出上限不能小于 0(0 表示不发送该字段)',\n maxOutputTokensTooLarge: '输出上限不能大于 131072(网关上限)',\n maxOutputTokensInvalid: '请输入 0 或有效的整数 token 数',\n overridden: '已覆盖',\n reset: '重置',\n invalidNumber: '无效数字',\n numberTooSmall: '重试次数不能小于 0',\n numberTooLarge: '重试次数不能大于 50',\n durationTooSmall: '不能小于 0.001 秒(1 毫秒)',\n durationTooLarge: '不能大于 3600 秒',\n durationInvalid: '请输入秒数,最多保留三位小数',\n readOnly: '当前配置为只读。',\n save: '保存',\n saving: '保存中',\n saved: '设置已保存',\n saveFailed: '保存失败,请重试。',\n writeConflict: '待提交字段已被其他操作修改,配置未写入;请核对后再保存。',\n writeConfigUnconfirmed: '配置提交未获确认;密钥未写入,请核对宿主状态后重试。',\n writeConfigSaved: '配置已保存。',\n writeKeyNotIssued: '密钥操作尚未发出;请核对后重试。',\n writeKeyUnconfirmed: '密钥操作未获确认;请核对后手动重试。',\n writeKeyConfirmed: '原引用的密钥操作已获确认。',\n writeTargetBefore: '默认密钥引用已变化,本次密钥操作未发出;请核对当前目标。',\n writeTargetAfter: '原引用的密钥操作已发出,默认引用随后变化;请核对两个引用。',\n writeClosed: '客户端已销毁,未发出的操作已停止;已确认的修改保留。',\n writeCleanupPending: '账号修改已确认,清理记录尚未收束,请重试清理。',\n writeRefreshFailed: '密钥状态刷新失败;已获确认的写入仍有效。',\n writeRefreshRetry: '仅刷新密钥状态',\n writeStateUnknown: '密钥状态尚未确认',\n writeCredentialRefNote: '(原引用:{ref})',\n unsavedChanges: '有未保存的更改',\n saveInvalid: '有字段填写不正确,修正后才能保存',\n discard: '放弃',\n cancel: '取消',\n show: '显示',\n hide: '隐藏',\n usageRefresh: '刷新',\n usageRefreshing: '刷新中…',\n usageLoading: '正在获取账户用量…',\n usageError: '用量获取失败',\n usageRequests: '请求',\n usageFailed: '失败',\n usageSuccessRate: '成功率',\n usageCost: '花费',\n usageCompleted: '成功',\n usageBillingPeriod: '官方账期用量',\n usagePeriodUnknown: '官方用量汇总,统计周期未确认',\n usageTokens: 'Token',\n usageTokensIn: '入',\n usageTokensOut: '出',\n usageMonthly: '月额度',\n usagePurchased: '已购',\n usageFree: '赠送',\n usageFiveHour: '5 小时窗口',\n usageWeekly: '每周窗口',\n usageMonthlyLeft: '月额度剩余',\n usageExceeded: '已超限',\n usageReset: '重置于',\n usagePartial: '部分端点数据不可用',\n usageKeyInvalid: 'API 密钥无效或已过期',\n usageKeyInvalidHint: '服务端拒绝了全部请求(401)。请检查该账户配置的密钥,或到 commandcode.ai 控制台重新生成。',\n usageServiceUnavailable: 'Command Code 服务暂时不可用',\n usageServiceUnavailableHint: '服务端返回了错误(5xx),稍后点击刷新重试。',\n usageNetworkError: '无法连接 Command Code 服务',\n usageNetworkHint: '所有请求都没有到达服务端。请检查网络连接或 API 地址设置。',\n usageInvalidResponse: 'Command Code 响应无法读取',\n usageInvalidResponseHint: '已收到响应,但正文读取或解析失败。下方详情可区分读取超时、中断与格式异常;可先点击刷新重试。',\n usageUpdated: '更新于',\n usagePeriodEnd: '账期截止',\n usageActive: '当前使用',\n usageCooldown: '限额冷却中',\n usageInvalidKey: '密钥无效',\n usageUnconfigured: '该账户尚未配置 API 密钥。',\n updateAvailable: '可更新',\n updateHint: '已发布新版本,点击查看发布说明;更新插件后刷新本页,提示会自动消失。',\n loginTitle: '通过官方登录获取密钥',\n loginHintIdle: '也可通过网页授权自动获取并保存密钥。',\n loginButton: '登录 Command Code',\n loginStarting: '正在启动本地回调服务…',\n loginWaiting: '等待在浏览器中完成授权;若页面未自动打开,请点击授权链接。',\n loginOpenLink: '打开授权页面 ↗',\n loginCancel: '取消登录',\n loginSuccess: '已登录为',\n loginUnavailable: '此环境暂不支持登录流程,请手动粘贴密钥。',\n loginDenied: '授权被拒绝。可重试,或手动粘贴密钥。',\n loginTimeout: '等待超时:未在窗口期内收到授权回调,请重试。',\n loginInvalidKey: '获取到的密钥未通过校验(401),请重试或手动粘贴。',\n loginNetwork: '无法连接 Command Code 服务校验密钥,请检查网络后重试。',\n loginStoreFailed: '密钥无法写入本机凭据服务,请手动粘贴。',\n loginCancelled: '登录已取消。',\n loginFailedGeneric: '登录失败,请重试或手动粘贴密钥。',\n cardTitle: 'Command Code',\n cardRouteActive: '已启用',\n cardLoadingHint: '正在读取 Command Code 配置…',\n cardRegistrationHint: '此卡片由 Command Code 插件提供,正在等待设置页装配,稍后即可显示完整内容。',\n accountsRotationHint: '账户操作立即生效。当前账户达到用量限额或密钥失效时,请求会按列表顺序自动切换到下一个可用账户。',\n accountModeAuto: '使用方式:自动切换(按列表顺序)',\n accountModePinned: '使用方式:固定使用「{name}」,不可用时仍会自动切换',\n accountStatusPinned: '已固定',\n accountActions: '账户操作',\n accountActionPin: '固定使用此账户',\n accountActionUnpin: '恢复自动切换',\n accountActionLogin: '网页登录获取新密钥',\n accountActionKey: '粘贴新密钥',\n accountActionRename: '重命名',\n accountActionClearKey: '删除密钥',\n accountActionRemove: '移除账户',\n accountRemoveConfirm: '移除「{name}」?它保存的密钥和专用模型设置会一起删除。',\n accountClearKeyConfirm: '删除默认账户保存的密钥?之后会回退到环境变量或官方 CLI 的登录信息。',\n accountConfirmRemove: '移除',\n accountConfirmClear: '删除',\n accountApply: '确定',\n accountNoKeyHint: '还没有密钥:通过网页登录自动获取,或粘贴在 commandcode.ai 控制台创建的 API 密钥。',\n accountAddHint: '网页登录会打开 Command Code 授权页,授权完成后账户自动添加;也可以直接粘贴 API 密钥。',\n accountAddLogin: '网页登录',\n accountAddPaste: '粘贴密钥',\n accountAddConfirm: '添加',\n accountAddCancel: '取消开通',\n accountAddLinkHint: '若登录页面未自动打开,请点击下方链接。',\n accountAddOpenLink: '打开浏览器登录',\n accountNamePlaceholder: '备注名(可选,网页登录时默认使用账户名)',\n accountNameN: '账户 {n}',\n accountNameSaveRetry: '重试保存名称',\n accountNameAcceptExisting: '接受已有名称',\n accountKeyPlaceholder: '粘贴 API 密钥',\n accountOpFailed: '账户操作未能完成,请重试。',\n accountCleanupPending: '账户已移除,但有密钥尚未清理。可重试,不影响其他账户。',\n accountCleanupRetry: '重试清理密钥',\n accountEnrollmentCleanupRetry: '重试清理',\n accountRefSeparator: ':',\n accountModels: '专用模型',\n accountModelsHint: '选中的模型优先由此账户处理,此账户不可用时仍会自动切换。一个模型只属于一个账户,选中会把它从其他账户移过来。',\n accountModelOwner: '属于 {name}',\n modelPick: '选择模型…',\n modelCount: '已选 {count} 个模型',\n modelCatalogFailed: '模型目录获取失败,暂时无法选择模型;已保存的设置仍会生效。',\n modelsTitle: '模型与上下文',\n modelsInvalid: '模型与上下文中有未填好的数字,请展开修正后再保存。',\n privacyTitle: '隐私与安全',\n integrationsTitle: '集成与显示',\n enrollmentCreating: '正在创建账号',\n enrollmentWaiting: '等待浏览器登录',\n enrollmentWriting: '正在保存凭据',\n enrollmentNaming: '账号已登录,正在保存名称',\n enrollmentNameFailed: '账号已登录,名称保存失败,请重试或接受已有名称',\n enrollmentFinished: '账号开通完成',\n enrollmentCancelling: '正在等待写入结束并清理',\n enrollmentCancelled: '已取消',\n enrollmentCancelledCleaned: '已取消并清理',\n enrollmentFailed: '账号开通失败,未创建账号',\n enrollmentFailedCleaned: '开通失败,已清理临时账号',\n enrollmentCleanupPending: '收尾未完成,请重试',\n enrollmentCleanupFailed: '清理未完成,请重试',\n enrollmentRecordNaming: '已登录账号等待确认名称',\n enrollmentRecordCleanup: '账号开通收尾未完成,请重试',\n enrollmentBeginFailed: '账号开通请求失败,请检查连接后重试',\n enrollmentCancelUnconfirmed: '取消请求未确认,重新连接后请检查未完成记录',\n enrollmentNameRequestFailed: '名称保存失败,请重试或接受已有名称',\n enrollmentCleanupRequestFailed: '收尾请求失败,请检查连接后重试',\n enrollmentDisconnected: '开通连接已断开,宿主正在收尾;重连后请检查未完成记录',\n enrollmentHostUnavailable: '宿主无法完成账号开通,请检查连接与写入权限',\n enrollmentStatusUnavailable: '开通状态暂不可用,正在等待重新连接',\n}\n\nexport const en: Record = {\n openAnalytics: 'View model usage, cost and performance',\n nav: 'Command Code',\n title: 'Command Code',\n intro: 'Account actions apply immediately; other settings are written by Save at the bottom. The API key stays in the local credential service and is never echoed.',\n apiKey: 'API key',\n apiKeyHint: 'Create one in the commandcode.ai console. Saving with this field'\n + ' blank keeps the stored key.',\n apiKeySet: 'Configured',\n apiKeyUnset: 'Not configured',\n apiKeyLocked: 'Key provided by a read-only source',\n apiBase: 'API base URL',\n apiBaseHint: 'Defaults to https://api.commandcode.ai; usually leave as-is.',\n requestTimeoutMs: 'Request timeout (seconds)',\n requestTimeoutMsHint: 'Time to wait for the first response byte; default 300 s (the official CLI’s). The Provider API route withholds headers until the upstream model emits its first token, so a slow upstream can still outlast the default; raise it when a timeout message suggests it.',\n streamIdleTimeoutMs: 'Stream idle timeout (seconds)',\n streamIdleTimeoutMsHint: 'How long a stalled stream is treated as dead; default 300 s'\n + ' (deliberately generous — long-thinking models can stay silent for minutes).',\n transportMaxRetries: 'Transport retries',\n transportMaxRetriesHint: 'How many times a failed connection is retried automatically; default 5'\n + ' (waits of 0.5+1+2+4+8s, ~15s in total). After that the failure is reported instead of waiting'\n + ' on the exponential backoff. 0 disables the retries. Rate limits and 5xx answers keep their'\n + ' own, longer retry window.',\n offloadSeenImagesForCache: 'Stop replaying images already seen',\n offloadSeenImagesForCacheHint: 'CLI transport only. After this model responds to an image, durably replace older pixels with text to avoid cache rollback when another image arrives. To inspect an old image again, the model must reread its file or the user must attach it again. Off by default.',\n advancedSettings: 'Advanced',\n advancedSettingsHint: 'API base URL and network limits; usually leave as-is.',\n advancedOverriddenOne: '1 customized',\n advancedOverriddenMany: '{count} customized',\n advancedInvalid: 'A number in Advanced settings is not ready to save; expand to fix it.',\n filterModelsByPlan: 'Hide out-of-plan models',\n filterModelsByPlanHint: 'Show only models in your plan; on-demand credit accounts still see the full catalog.',\n webSearch: 'Serve dsh web search with Command Code',\n webSearchHint: 'Use the same key for dsh web search; turning it off restores the previous backend. When the Web search settings already pin another backend, this switch has no effect.',\n showSidebarQuota: 'Show the quota card in the sidebar',\n showSidebarQuotaHint: 'Show a plan and quota card at the bottom of the sidebar that opens the full dashboard. Off by default; while off, no background usage refresh runs for it.',\n zdr: 'Zero data retention (ZDR)',\n zdrHint: 'Use only upstreams that retain no data and do not train on requests; availability and pricing may differ.',\n accountsTitle: 'Accounts',\n accountsHint: 'Account actions apply immediately, no save needed. Add more accounts and requests switch to the next one when the current account hits its usage limit or its key stops working.',\n accountAdd: 'Add account',\n accountDefault: 'Default account',\n modelSearchPlaceholder: 'Search models…',\n modelSearchEmpty: 'No matching models.',\n modelStale: 'Retired',\n modelAllowance: '${amount}/mo',\n modelAllowanceTitle: 'Monthly usage this model can draw, in USD. Every model shares one monthly pool — this is not extra credit on top of the plan.',\n visibleModelsTitle: 'Model allowlist',\n visibleModelsHint:\n 'Check the models you want to keep, and model pickers will list only those. '\n + 'If nothing is checked, every model is shown. After saving, the change '\n + 'applies in both web and terminal settings. This page shows their effective selection; plan access is filtered separately.',\n visibleModelsPick: 'Select models to keep…',\n visibleModelsCount: '{count} model(s) selected',\n visibleModelsShowAll: 'Show all',\n visibleModelsStaleHint: '{count} selected model(s) are no longer in the catalog (possibly retired);'\n + ' other models are unaffected. Clean them up or keep them.',\n visibleModelsCleanStale: 'Clean stale ({count})',\n contextWindow: 'Custom context length',\n contextWindowHint:\n 'Overrides the context window published by the model catalog, for every model without an override '\n + 'of its own below; the context meter and auto-compaction follow it. Empty follows the catalog. '\n + 'Shorthand like 32K or 1M is accepted.',\n contextOverridesTitle: 'Per-model overrides',\n contextOverridesHint:\n 'Set a context length for one model; it wins over the global value above. The picker lists only the '\n + 'models kept by the visible-models allowlist (the whole catalog while showing all).',\n contextOverrideAdd: 'Add model',\n contextOverrideRemove: 'Remove this model’s context override',\n contextOverrideModel: 'Model',\n contextOverrideValue: 'Context length',\n contextOverrideNoCatalog: 'The model catalog has not loaded yet; per-model overrides cannot be added right now.',\n contextWindowTooSmall: 'Context length must be at least 1',\n contextWindowTooLarge: 'Context length must not exceed 10,000,000',\n contextWindowInvalid: 'Enter a valid whole-token count',\n maxOutputTokens: 'Output limit per turn',\n maxOutputTokensHint:\n 'Empty derives it from the model (default). A positive number never exceeds the model ceiling or '\n + 'the gateway’s 131,072. 0 omits the field (CLI and OpenAI only; Claude’s Messages endpoint '\n + 'requires it). 64K shorthand accepted.',\n maxOutputTokensTooSmall: 'Output limit must be at least 0 (0 omits the field)',\n maxOutputTokensTooLarge: 'Output limit must not exceed 131,072 (the gateway cap)',\n maxOutputTokensInvalid: 'Enter 0 or a valid whole-token count',\n overridden: 'Overridden',\n reset: 'Reset',\n invalidNumber: 'Invalid number',\n numberTooSmall: 'Retries must be at least 0',\n numberTooLarge: 'Retries must not exceed 50',\n durationTooSmall: 'Must be at least 0.001 seconds (1 ms)',\n durationTooLarge: 'Must not exceed 3600 seconds',\n durationInvalid: 'Enter seconds with at most three decimal places',\n readOnly: 'Settings are read-only.',\n save: 'Save',\n saving: 'Saving',\n saved: 'Settings saved',\n saveFailed: 'Save failed, please retry.',\n writeConflict: 'Pending fields were modified by another operation; the configuration was not written. Please review and save again.',\n writeConfigUnconfirmed: 'The configuration submission was not confirmed; the key was not written. Please check the host state and retry.',\n writeConfigSaved: 'Configuration saved.',\n writeKeyNotIssued: 'The key operation has not been issued; please review and retry.',\n writeKeyUnconfirmed: 'The key operation was not confirmed; please review and retry manually.',\n writeKeyConfirmed: 'The key operation on the original reference has been confirmed.',\n writeTargetBefore: 'The default key reference has changed; this key operation was not issued. Please check the current target.',\n writeTargetAfter: 'The key operation on the original reference was issued, then the default reference changed. Please check both references.',\n writeClosed: 'The client has been destroyed; unissued operations were stopped. Confirmed changes are kept.',\n writeCleanupPending: 'The account change was confirmed, but cleanup records are still pending. Please retry cleanup.',\n writeRefreshFailed: 'Key status refresh failed; confirmed writes remain valid.',\n writeRefreshRetry: 'Refresh key status only',\n writeStateUnknown: 'Key status not yet confirmed',\n writeCredentialRefNote: '(original ref: {ref})',\n unsavedChanges: 'You have unsaved changes',\n saveInvalid: 'Fix the highlighted field before saving',\n discard: 'Discard',\n cancel: 'Cancel',\n show: 'Show',\n hide: 'Hide',\n usageRefresh: 'Refresh',\n usageRefreshing: 'Refreshing…',\n usageLoading: 'Fetching account usage…',\n usageError: 'Could not fetch usage',\n usageRequests: 'Requests',\n usageFailed: 'failed',\n usageSuccessRate: 'Success rate',\n usageCost: 'Spend',\n usageCompleted: 'Completed',\n usageBillingPeriod: 'Official billing-period usage',\n usagePeriodUnknown: 'Official usage summary; the billing period is unconfirmed',\n usageTokens: 'Tokens',\n usageTokensIn: 'in',\n usageTokensOut: 'out',\n usageMonthly: 'Monthly',\n usagePurchased: 'Purchased',\n usageFree: 'Free',\n usageFiveHour: '5-hour window',\n usageWeekly: 'Weekly window',\n usageMonthlyLeft: 'Monthly left',\n usageExceeded: 'Exceeded',\n usageReset: 'Resets',\n usagePartial: 'Some endpoint data unavailable',\n usageKeyInvalid: 'API key invalid or expired',\n usageKeyInvalidHint: 'The server rejects every request (401). Check the key configured for this account, or generate a new one in the commandcode.ai console.',\n usageServiceUnavailable: 'The Command Code service is temporarily unavailable',\n usageServiceUnavailableHint: 'The server returned errors (5xx); try Refresh again later.',\n usageNetworkError: 'Could not reach the Command Code service',\n usageNetworkHint: 'No request reached the server. Check your network connection or the API base setting.',\n usageInvalidResponse: 'Could not read the Command Code response',\n usageInvalidResponseHint: 'Responses arrived, but reading or parsing their bodies failed. Details below distinguish timeouts, interrupted reads, and invalid formats. Try Refresh again.',\n usageUpdated: 'Updated',\n usagePeriodEnd: 'Period ends',\n usageActive: 'Active',\n usageCooldown: 'Cooling down',\n usageInvalidKey: 'Invalid key',\n usageUnconfigured: 'No API key configured for this account yet.',\n updateAvailable: 'update available',\n updateHint: 'A newer version has been published; click for release notes. The notice disappears once the plugin is updated.',\n loginTitle: 'Sign in to fetch a key',\n loginHintIdle: 'Alternatively, sign in to fetch and store a key automatically.',\n loginButton: 'Sign in to Command Code',\n loginStarting: 'Starting the local callback server…',\n loginWaiting: 'Waiting for authorization in your browser. If the page did not open automatically, use the authorization link.',\n loginOpenLink: 'Open the authorization page ↗',\n loginCancel: 'Cancel sign-in',\n loginSuccess: 'Signed in as',\n loginUnavailable: 'Sign-in is unavailable in this environment; paste the API key instead.',\n loginDenied: 'Authorization was denied. Try again or paste the key manually.',\n loginTimeout: 'Timed out waiting for the authorization callback; try again.',\n loginInvalidKey: 'The delivered key failed validation (401). Try again or paste it manually.',\n loginNetwork: 'Could not reach the Command Code service to validate the key; check your network and retry.',\n loginStoreFailed: 'The key could not be stored in the local credential service; paste it manually.',\n loginCancelled: 'Sign-in cancelled.',\n loginFailedGeneric: 'Sign-in failed; try again or paste the key manually.',\n cardTitle: 'Command Code',\n cardRouteActive: 'Active',\n cardLoadingHint: 'Loading the Command Code configuration…',\n cardRegistrationHint: 'This card is contributed by the Command Code plugin and is waiting for the settings page to finish assembling.',\n accountsRotationHint: 'Account actions apply immediately. When the current account hits its usage limit or its key stops working, requests switch to the next usable account in list order.',\n accountModeAuto: 'Mode: switch automatically (list order)',\n accountModePinned: 'Mode: pinned to \"{name}\", still switching when it is unavailable',\n accountStatusPinned: 'Pinned',\n accountActions: 'Account actions',\n accountActionPin: 'Pin this account',\n accountActionUnpin: 'Switch automatically',\n accountActionLogin: 'Sign in for a new key',\n accountActionKey: 'Paste a new key',\n accountActionRename: 'Rename',\n accountActionClearKey: 'Delete key',\n accountActionRemove: 'Remove account',\n accountRemoveConfirm: 'Remove \"{name}\"? Its stored key and dedicated models are deleted with it.',\n accountClearKeyConfirm: 'Delete the default account\\'s stored key? The provider then falls back to the environment or the official CLI sign-in.',\n accountConfirmRemove: 'Remove',\n accountConfirmClear: 'Delete',\n accountApply: 'Apply',\n accountNoKeyHint: 'No key yet: sign in through the browser, or paste an API key created in the commandcode.ai console.',\n accountAddHint: 'Sign-in opens the Command Code authorization page and adds the account once you approve; you can also paste an API key.',\n accountAddLogin: 'Sign in',\n accountAddPaste: 'Paste key',\n accountAddConfirm: 'Add',\n accountAddCancel: 'Cancel setup',\n accountAddLinkHint: 'If the sign-in page did not open automatically, use the link below.',\n accountAddOpenLink: 'Open the sign-in page',\n accountNamePlaceholder: 'Name (optional; sign-in uses the account name)',\n accountNameN: 'Account {n}',\n accountNameSaveRetry: 'Retry saving the name',\n accountNameAcceptExisting: 'Accept the existing name',\n accountKeyPlaceholder: 'Paste an API key',\n accountOpFailed: 'The account change did not complete. Try again.',\n accountCleanupPending: 'The account was removed, but credential cleanup is still pending. Retry without affecting other accounts.',\n accountCleanupRetry: 'Retry credential cleanup',\n accountEnrollmentCleanupRetry: 'Retry cleanup',\n accountRefSeparator: ': ',\n accountModels: 'Dedicated models',\n accountModelsHint: 'These models prefer this account and still switch when it is unavailable. A model belongs to one account; selecting it here moves it from any other.',\n accountModelOwner: 'on {name}',\n modelPick: 'Choose models…',\n modelCount: '{count} models selected',\n modelCatalogFailed: 'The model catalog could not be loaded; saved settings still apply.',\n modelsTitle: 'Models & context',\n modelsInvalid: 'A number in Models & context is not ready to save; expand to fix it.',\n privacyTitle: 'Privacy & security',\n integrationsTitle: 'Integrations & display',\n enrollmentCreating: 'Creating the account',\n enrollmentWaiting: 'Waiting for the browser sign-in',\n enrollmentWriting: 'Saving the credential',\n enrollmentNaming: 'Signed in. Saving the name',\n enrollmentNameFailed: 'Signed in, but saving the name failed. Retry, or accept the existing name.',\n enrollmentFinished: 'Account setup complete',\n enrollmentCancelling: 'Waiting for the write to finish, then cleaning up',\n enrollmentCancelled: 'Cancelled',\n enrollmentCancelledCleaned: 'Cancelled and cleaned up',\n enrollmentFailed: 'Account setup failed. No account was created.',\n enrollmentFailedCleaned: 'Setup failed; the temporary account was cleaned up.',\n enrollmentCleanupPending: 'Cleanup is not finished. Please retry.',\n enrollmentCleanupFailed: 'Cleanup failed. Please retry.',\n enrollmentRecordNaming: 'Signed-in account waiting for its name to be confirmed',\n enrollmentRecordCleanup: 'Account setup was not finished. Please retry.',\n enrollmentBeginFailed: 'The account setup request failed. Check the connection and retry.',\n enrollmentCancelUnconfirmed: 'The cancel request was not confirmed. After reconnecting, check for unfinished records.',\n enrollmentNameRequestFailed: 'Saving the name failed. Retry, or accept the existing name.',\n enrollmentCleanupRequestFailed: 'The cleanup request failed. Check the connection and retry.',\n enrollmentDisconnected: 'The setup connection dropped; the host is finishing up. After reconnecting, check for unfinished records.',\n enrollmentHostUnavailable: 'The host could not complete the account setup. Check the connection and write permissions.',\n enrollmentStatusUnavailable: 'Setup status is unavailable; waiting to reconnect.',\n}\n\n/** 同一确认结果在设置页和模型卡使用相同措辞,不从 configured 推断写入结果。 */\nexport function settingsWriteNotice(\n result: import('./settings-write.ts').SettingsWriteResult | undefined,\n t: (key: SettingsCommandCodeKey) => string,\n): string {\n if (!result) return ''\n let key: SettingsCommandCodeKey | undefined\n if (result.issue === 'closed') key = 'writeClosed'\n else if (result.issue === 'target-changed') key = result.credential === 'not-issued' ? 'writeTargetBefore' : 'writeTargetAfter'\n else if (result.issue === 'cleanup-pending') key = 'writeCleanupPending'\n else if (result.config === 'conflict') key = 'writeConflict'\n else if (result.config === 'unconfirmed') key = 'writeConfigUnconfirmed'\n else if (result.credential === 'unconfirmed') key = 'writeKeyUnconfirmed'\n else if (result.credential === 'not-issued') key = 'writeKeyNotIssued'\n const messages = key ? [t(key)] : []\n if (result.issue === 'target-changed' && result.credentialRef) {\n messages.push(t(result.credential === 'confirmed' ? 'writeKeyConfirmed' : 'writeKeyUnconfirmed'))\n messages.push(t('writeCredentialRefNote').replace('{ref}', result.credentialRef))\n }\n if (key && result.config === 'confirmed' && result.issue !== 'cleanup-pending') messages.unshift(t('writeConfigSaved'))\n if (result.refreshFailed) messages.push(t('writeRefreshFailed'))\n return messages.join(' ')\n}\n","/** Shared plan-tier presentation facts; safe to inline into the browser bundle. */\n\nexport const PLAN_TIER_ORDER = ['go', 'goat', 'pro', 'provider', 'max'] as const\n\nexport const PLAN_LABELS: Readonly> = {\n go: 'Go',\n goat: 'GOAT',\n pro: 'Pro',\n provider: 'Provider',\n max: 'Max',\n}\n\nexport const PLAN_ORDER: Readonly> = Object.fromEntries(\n PLAN_TIER_ORDER.map((tier, index) => [tier, index]),\n)\n","/**\n * Model-select helpers for the settings page's model editors (browser half).\n *\n * The routing-rule editor and the visible-models filter both pick catalog models\n * through the same checkbox multi-select dropdown (`ModelMultiSelect` in\n * section.tsx). The dropdown's data shaping — search filtering, stale-id\n * detection, tier grouping — lives here, React-free, so node tests can drive\n * it directly.\n *\n * The shared tier module is dependency-free and inlined into the client bundle;\n * importing the Host's full capability snapshot here would bloat that bundle.\n *\n * @module dsh-commandcode-provider/model-select\n */\n\nimport { PLAN_LABELS } from '../plan-tiers.ts'\n\n/**\n * The dropdown section heading for a catalog model id, or undefined for\n * models outside the known plan tiers (unmapped models and stale ids render\n * unheaded). `knownPlans` is the Host-side `KNOWN_PLANS` table, threaded in\n * by the caller so this module stays dependency-free.\n */\nexport function tierHeadingFor(\n modelId: string,\n knownPlans: Readonly>,\n): string | undefined {\n const tier = knownPlans[modelId]\n if (tier === undefined) return undefined\n return PLAN_LABELS[tier] ?? tier\n}\n\n/** One selectable catalog model (mirrors `CatalogModelOption` in settings.ts). */\nexport interface SelectableModel {\n /** Catalog model id (e.g. `deepseek/deepseek-v4-pro`). */\n id: string\n name: string\n /**\n * Per-model monthly allowance in USD, already resolved by the Host. Passed\n * through untouched: this module renders it, it does not interpret it.\n */\n allowance?: number\n}\n\n/** One dropdown row: a live catalog model or a stale selection. */\nexport interface ModelSelectOption {\n /** Catalog model id (stale ids keep their raw id as the value). */\n value: string\n label: string\n /** True when the id is selected but the catalog no longer carries it. */\n stale: boolean\n /**\n * Per-model monthly allowance in USD. Only live catalog rows can carry one —\n * a stale id is no longer priced by the page, so there is nothing to show.\n */\n allowance?: number\n}\n\n/** One dropdown section: a plan-tier heading plus its rows. */\nexport interface ModelSelectGroup {\n /** Section heading, or undefined for models outside the known plan tiers. */\n heading: string | undefined\n /** Rows in this section, in picker order. */\n options: ModelSelectOption[]\n}\n\n/**\n * Whether `text` matches `query` as a case-insensitive substring over the\n * model id AND display name. An empty/blank query matches everything.\n */\nexport function matchesModelQuery(\n model: SelectableModel,\n query: string,\n): boolean {\n const needle = query.trim().toLowerCase()\n if (needle === '') return true\n return model.id.toLowerCase().includes(needle)\n || model.name.toLowerCase().includes(needle)\n}\n\n/**\n * Build the dropdown options: the catalog (already in picker order) plus any\n * selected ids the catalog no longer carries, flagged stale so a saved\n * selection never silently loses an entry. Stale rows survive a non-blank\n * {@link matchesModelQuery} search only while they match too, so a search for\n * a live model does not surface unrelated stale ids.\n */\nexport function buildModelSelectOptions(\n catalog: readonly SelectableModel[],\n selected: readonly string[],\n query = '',\n): ModelSelectOption[] {\n const catalogIds = new Set(catalog.map((model) => model.id))\n const options = catalog\n .filter((model) => matchesModelQuery(model, query))\n .map((model) => ({\n value: model.id,\n label: model.name,\n stale: false,\n ...(model.allowance === undefined ? {} : { allowance: model.allowance }),\n }))\n // Dedupe defensively (order-preserving): hand-edited settings can repeat\n // or blank an id, and duplicate Menu ids would confuse selection state.\n const seen = new Set(catalogIds)\n for (const id of selected) {\n if (id === '' || seen.has(id)) continue\n seen.add(id)\n if (catalogIds.has(id)) continue\n if (!matchesModelQuery({ id, name: id }, query)) continue\n options.push({ value: id, label: id, stale: true })\n }\n return options\n}\n\n/**\n * Group dropdown options under plan-tier headings (`tierOf` maps a model id to\n * its heading, or undefined for unmapped models — see {@link tierHeadingFor}).\n * Live rows keep their relative order and repeats merge, so a catalog\n * interleaving two tiers still renders one section per tier.\n */\nexport function groupModelSelectOptions(\n options: readonly ModelSelectOption[],\n tierOf: (modelId: string) => string | undefined,\n): ModelSelectGroup[] {\n const groups: ModelSelectGroup[] = []\n const byHeading = new Map()\n for (const option of options) {\n // Stale ids always render unheaded (their tier is unknowable); unmapped\n // live rows join the same trailing group so `heading: undefined`\n // appears at most once.\n const heading = option.stale ? undefined : tierOf(option.value)\n let group = byHeading.get(heading)\n if (group === undefined) {\n group = { heading, options: [] }\n byHeading.set(heading, group)\n groups.push(group)\n }\n group.options.push(option)\n }\n return groups\n}\n\n/**\n * Toggle one model id in a selection: remove it when present, append it\n * when absent (append keeps catalog order irrelevant — the picker re-sorts\n * by plan tier on render).\n */\nexport function toggleModelSelection(\n selected: readonly string[],\n modelId: string,\n): string[] {\n return selected.includes(modelId)\n ? selected.filter((value) => value !== modelId)\n : [...selected, modelId]\n}\n\n/**\n * The catalog rows a picker may offer under the visible-models allowlist, in\n * catalog order: the whole catalog while \"show all\" is active (the empty\n * allowlist means exactly that), otherwise only the allowlisted ids. The\n * per-model context override editor uses this so its dropdown agrees with the\n * whitelist on the same card stack — a staged whitelist edit narrows it\n * immediately, since both read the projected page state.\n */\nexport function allowlistedModels(\n catalog: readonly T[],\n allowlist: readonly string[],\n showAll: boolean,\n): T[] {\n if (showAll) return [...catalog]\n const keep = new Set(allowlist)\n return catalog.filter((model) => keep.has(model.id))\n}\n\n/** The catalog facts the visible-models card reads (mirrors `SettingsPageState`). */\nexport interface CatalogReadiness {\n /** Catalog model ids the Host reported (`[]` before the first fetch lands). */\n catalogIds: readonly string[]\n /** Whether the catalog fetch failed (or the Remote is unavailable). */\n catalogFailed: boolean\n}\n\n/**\n * Whether the catalog is trustworthy enough to call an unlisted selection\n * \"retired\". FALSE while the first fetch is in flight, after a failure, and\n * for a loaded-but-empty catalog — an empty list makes every selected id look\n * stale, which would turn the one-click stale cleanup into a button that\n * silently empties the allowlist. Users who really want it cleared have the\n * explicit \"show all\" entry.\n */\nexport function catalogIsReady(readiness: CatalogReadiness): boolean {\n return readiness.catalogIds.length > 0 && !readiness.catalogFailed\n}\n\n/**\n * Selected ids the loaded catalog no longer carries, in selection order.\n * Callers gate user-visible \"stale\" affordances on {@link catalogIsReady} —\n * the list itself is informational.\n */\nexport function staleModelIds(\n selected: readonly string[],\n readiness: CatalogReadiness,\n): string[] {\n const catalogIds = new Set(readiness.catalogIds)\n return selected.filter((id) => !catalogIds.has(id))\n}\n","","/**\n * The plugin's own version, read from package.json at build time. The client\n * bundle inlines the JSON import (rolldown resolves it during the tsdown\n * build; node tests read it through tsx), so the rendered value always matches\n * the published package version with no second constant to keep in sync —\n * rendered as a muted footer line so a user can report the exact build.\n *\n * @module dsh-commandcode-provider/client/version\n */\n\nimport pkg from '../../package.json'\n\n/** The published package version. */\nexport const PLUGIN_VERSION: string = pkg.version\n\n/**\n * This package's GitHub releases page, derived from the repository field so\n * the update hint's link target can never drift from the published home.\n * Tolerates both repository shapes (`{ url }` and the plain string form).\n */\nexport const PLUGIN_RELEASES_URL: string = (() => {\n const repo: unknown = (pkg as { repository?: unknown }).repository\n const url = typeof repo === 'string' ? repo : (repo as { url?: unknown } | undefined)?.url\n return `${typeof url === 'string' ? url.replace(/^git\\+/, '').replace(/\\.git$/, '') : 'https://github.com/Mars-Sea/dsh-commandcode-provider'}/releases`\n})()\n","/**\n * Plugin update hint (browser half).\n *\n * When the \"Command Code\" settings page opens, ask the npm registry for the\n * package's published `latest` version and — only when it is newer than the\n * running build — let the page's footer show a muted \"newer version available\"\n * link to the GitHub releases. Everything here is React-free and side-effect\n * seamed so node tests can drive it.\n *\n * The registry is queried at most once per {@link UPDATE_CHECK_INTERVAL_MS}\n * per browser profile; the learned version is cached in `localStorage` with\n * the attempt time, so re-opening the page is free. A FAILED check records the\n * attempt time too (an offline browser must not hammer the registry) but keeps\n * any previously learned version, so the hint survives transient outages. Every\n * failure mode — blocked network, non-OK status, malformed payload,\n * unavailable storage — degrades to \"no hint\"; nothing ever throws out of\n * {@link checkForUpdate}. The registry serves `access-control-allow-origin: *`,\n * so the plain browser fetch works from the GUI origin with no Host proxy.\n *\n * @module dsh-commandcode-provider/client/update\n */\n\n/** How often the page may hit the registry: once a day. */\nexport const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000\n\n/** Abort a hung registry request rather than keep the footer waiting. */\nexport const FETCH_TIMEOUT_MS = 5000\n\n/**\n * The npm registry document for this package's `latest` dist-tag. The scoped\n * name is path-escaped (`%2F`) so no client normalizes the slash away.\n */\nexport const NPM_LATEST_URL =\n 'https://registry.npmjs.org/@mars-sea%2Fdsh-commandcode-provider/latest'\n\n/**\n * Compare two version strings (`major.minor.patch[-pre]`): negative when `a`\n * sorts before `b`, positive when after, zero when equal.\n *\n * Tolerant by design — a leading `v` is stripped, unparsable numeric parts\n * count as `0`, and semver prerelease rules apply. Enough for release tags,\n * not a full semver validator.\n */\nexport function compareVersions(a: string, b: string): number {\n const left = splitVersion(a)\n const right = splitVersion(b)\n const depth = Math.max(left.core.length, right.core.length)\n for (let index = 0; index < depth; index += 1) {\n const delta = (left.core[index] ?? 0) - (right.core[index] ?? 0)\n if (delta !== 0) return Math.sign(delta)\n }\n // A release outranks any prerelease of the same core version.\n if (left.pre.length === 0 && right.pre.length === 0) return 0\n if (left.pre.length === 0) return 1\n if (right.pre.length === 0) return -1\n const width = Math.max(left.pre.length, right.pre.length)\n for (let index = 0; index < width; index += 1) {\n const l = left.pre[index]\n const r = right.pre[index]\n if (l === undefined) return -1\n if (r === undefined) return 1\n const lNumeric = /^\\d+$/.test(l)\n const rNumeric = /^\\d+$/.test(r)\n let delta: number\n if (lNumeric && rNumeric) delta = Number(l) - Number(r)\n else if (lNumeric) delta = -1 // numeric identifiers sort below alphanumeric\n else if (rNumeric) delta = 1\n else delta = l < r ? -1 : l > r ? 1 : 0\n if (delta !== 0) return Math.sign(delta)\n }\n return 0\n}\n\n/** True when `candidate` is strictly newer than `current`. */\nexport function isNewerVersion(candidate: string, current: string): boolean {\n return compareVersions(candidate, current) > 0\n}\n\n/** Split a tolerant version string into numeric core + prerelease ids. */\nfunction splitVersion(value: string): { core: number[]; pre: string[] } {\n // Split on the FIRST dash only: `1.0.0-alpha-1` has prerelease `alpha-1`,\n // not `alpha` (a split on every dash would drop the `-1`).\n const cleaned = value.trim().replace(/^v/i, '')\n const dash = cleaned.indexOf('-')\n const coreText = dash < 0 ? cleaned : cleaned.slice(0, dash)\n const preText = dash < 0 ? undefined : cleaned.slice(dash + 1)\n const core = coreText === ''\n ? [0]\n : coreText.split('.').map((part) => {\n const parsed = Number.parseInt(part, 10)\n return Number.isFinite(parsed) ? parsed : 0\n })\n const pre = preText === undefined ? [] : preText.split('.')\n return { core, pre }\n}\n\n/**\n * Extract the published version from the registry's `/latest` manifest\n * (`{ name, version, … }`). Throws on anything unexpected so callers treat a\n * shape change as a failed attempt, never as bogus data.\n */\nexport function parseLatestVersion(payload: unknown): string {\n if (typeof payload !== 'object' || payload === null) {\n throw new Error('npm latest payload is not an object')\n }\n const version = (payload as { version?: unknown }).version\n if (typeof version !== 'string' || !/^\\d+\\.\\d+\\./.test(version)) {\n throw new Error('npm latest payload has no usable version')\n }\n return version\n}\n\n/** Fetch and parse the published `latest` version. Rejects on any failure. */\nexport async function fetchLatestVersion(\n fetchImpl: typeof fetch = fetch,\n): Promise {\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)\n try {\n const response = await fetchImpl(NPM_LATEST_URL, { signal: controller.signal })\n if (!response.ok) {\n throw new Error(`registry responded ${response.status}`)\n }\n return parseLatestVersion(await response.json())\n } finally {\n clearTimeout(timer)\n }\n}\n\n/** What the cache remembers about the last check. */\nexport interface UpdateCheckRecord {\n /** When the last attempt completed (success or failure), epoch ms. */\n at: number\n /** The last successfully learned upstream version, if any. */\n version?: string | undefined\n}\n\n/** Storage seam so tests can stand in for `localStorage`. */\nexport interface UpdateCheckStore {\n read(): UpdateCheckRecord | undefined\n write(record: UpdateCheckRecord): void\n}\n\n/** The `localStorage` key holding {@link UpdateCheckRecord}. */\nexport const UPDATE_CHECK_CACHE_KEY = '@mars-sea/dsh-commandcode-provider/update-check'\n\n/**\n * A {@link UpdateCheckStore} backed by `localStorage`. Tolerates a missing or\n * throwing storage (SSR-ish contexts, private modes): reads yield `undefined`,\n * writes are dropped.\n */\nexport function localStorageUpdateStore(\n storage: Storage | undefined = typeof localStorage === 'undefined' ? undefined : localStorage,\n): UpdateCheckStore {\n return {\n read(): UpdateCheckRecord | undefined {\n if (storage === undefined) return undefined\n try {\n const raw = storage.getItem(UPDATE_CHECK_CACHE_KEY)\n if (raw === null) return undefined\n const parsed: unknown = JSON.parse(raw)\n if (typeof parsed !== 'object' || parsed === null) return undefined\n const at = (parsed as { at?: unknown }).at\n if (typeof at !== 'number' || !Number.isFinite(at)) return undefined\n const version = (parsed as { version?: unknown }).version\n return {\n at,\n version: typeof version === 'string' && version !== '' ? version : undefined,\n }\n } catch {\n return undefined\n }\n },\n write(record: UpdateCheckRecord): void {\n if (storage === undefined) return\n try {\n storage.setItem(UPDATE_CHECK_CACHE_KEY, JSON.stringify(record))\n } catch {\n // Quota/private-mode failures must never break the page.\n }\n },\n }\n}\n\n/**\n * Run one throttled update check. Resolves with the newest published version\n * when it is newer than `currentVersion`, otherwise `undefined`.\n *\n * Within the throttle window (or on failure) the cached version answers, so\n * the hint keeps working offline; past the window the registry is consulted\n * again and the attempt time is refreshed either way.\n */\nexport async function checkForUpdate(options: {\n currentVersion: string\n now: number\n store: UpdateCheckStore\n fetchImpl?: typeof fetch\n}): Promise {\n const { currentVersion, now, store } = options\n const hintOf = (version: string | undefined): string | undefined =>\n version !== undefined && isNewerVersion(version, currentVersion) ? version : undefined\n\n const cache = store.read()\n if (cache !== undefined && now - cache.at < UPDATE_CHECK_INTERVAL_MS) {\n return hintOf(cache.version)\n }\n\n let learned: string | undefined\n try {\n learned = await fetchLatestVersion(options.fetchImpl)\n } catch {\n // Degrade silently: record the attempt below and fall back to the cache.\n }\n const version = learned ?? cache?.version\n store.write({ at: now, version })\n return hintOf(version)\n}\n","/**\n * React component for the \"Command Code\" settings page (browser half).\n *\n * Renders as a `settings.section` entry — a page at the same settings-nav\n * level as General / Models / Plugins. All copy comes from the\n * `settings.commandcode` locale namespace; all state comes from the\n * `CommandCodeSettingsController` injected by the slot registration.\n *\n * Layout, top to bottom: Accounts (one card per account, status, quota,\n * actions and dedicated models — every operation commits immediately), Models\n * & context (plan filter + visible-model allowlist, then the context-length\n * rows: the global custom window and the per-model overrides, issue #86),\n * Privacy & security (ZDR), Integrations & display (web search, sidebar quota\n * card), and a collapsed Advanced card (API base + network limits). Everything\n * below Accounts is a flat group of rows (title/description left, control\n * right) inside the staged form the floating save bar writes.\n *\n * Two of those groups fold from their own heading — Models & context (issue\n * #94) and Advanced — and both start collapsed on every visit, with no\n * remembered state. While a group is collapsed its rows are not on screen, so\n * anything inside it that blocks the save is reported on the heading itself\n * (see `CollapsibleGroup`); the save bar alone would leave the disabled Save\n * button unexplained.\n *\n * Styles are injected once by the client entry (see src/client/index.ts) and\n * class-prefixed `cc-` to stay local.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'\nimport { Button, Menu } from '@deepseek-ai/dsh-client-ui-primitives'\nimport type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'\nimport type { Translate } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { CommandCodeCredits } from '../adapter.ts'\nimport type { CommandCodeAccountUsage, CommandCodeUsageReport } from '../usage-wire.ts'\nimport { settingsWriteNotice, type SettingsCommandCodeKey } from './locales.ts'\nimport type { CatalogModelOption, SettingsPageState, StagedField } from './settings.ts'\nimport type { AccountEnrollmentController, EnrollmentPageState } from './enrollment.ts'\nimport type { LoginPageState } from './login.ts'\nimport { loginHint, loginStateForTarget } from './login.ts'\nimport { allowlistedModels, buildModelSelectOptions, catalogIsReady, groupModelSelectOptions, staleModelIds, tierHeadingFor, toggleModelSelection } from './model-select.ts'\nimport type { UsagePageState } from './usage.ts'\nimport { usageCardState } from './usage.ts'\nimport { compactCountTitle, formatCompactCount, formatMoney, formatMoneyExact, formatResetAt, formatSuccessRate, formatTokensCompact, windowRatio, formatUsageBillingPeriod } from '../display-format.ts'\nimport { PLUGIN_RELEASES_URL, PLUGIN_VERSION } from './version.ts'\nimport { checkForUpdate, localStorageUpdateStore } from './update.ts'\n\n/** Props composed by the slot registration: locale seat + injected face. */\nexport interface CommandCodeSettingsProps {\n t: Translate\n useCommandCodeSettings(selector: (state: SettingsPageState) => T): T\n useCommandCodeUsage(selector: (state: UsagePageState) => T): T\n useCommandCodeLogin(selector: (state: LoginPageState) => T): T\n edit(field: string, text: string): void\n resetField(field: string): void\n save(): void\n discard(): void\n refreshUsage(): void\n openAnalytics?(): void\n refreshCredentials(): void\n beginLogin(targetRef?: string): void\n cancelLogin(): void\n createEnrollment(): AccountEnrollmentController\n renameAccount(ref: string, label: string): Promise\n removeAccount(ref: string): Promise\n retryCredentialCleanup(ref: string): Promise\n setAccountKey(target: string, key: string): Promise\n clearAccountKey(target: string): Promise\n setActiveAccount(id: string): Promise\n setAccountModels(target: string, ids: string[]): Promise\n editVisibleModels(ids: string[]): void\n clearVisibleModels(): void\n /** Stage the whole per-model context-length map (one entry per row, issue #86). */\n editContextWindowOverrides(next: Record): void\n}\n\n/** The section fields folded into the collapsible Advanced card. */\ntype AdvancedField = 'apiBase' | 'requestTimeoutMs' | 'streamIdleTimeoutMs' | 'transportMaxRetries' | 'offloadSeenImagesForCache'\nconst ADVANCED_FIELDS: readonly AdvancedField[] = [\n 'apiBase',\n 'requestTimeoutMs',\n 'streamIdleTimeoutMs',\n 'transportMaxRetries',\n 'offloadSeenImagesForCache',\n]\n\n/**\n * The staged drafts the Models & context group owns. `filterModelsByPlan` is a\n * switch and cannot fail through the page's own control, but it sits in the\n * same staged plan the save guard walks and Advanced's equivalent switch is\n * checked the same way — so the heading must not be able to disagree with it.\n */\ntype ModelsField = 'filterModelsByPlan' | 'contextWindow' | 'maxOutputTokens'\nconst MODELS_FIELDS: readonly ModelsField[] = ['filterModelsByPlan', 'contextWindow', 'maxOutputTokens']\n\n/**\n * Whether anything the collapsed Advanced card owns is blocking the save. The\n * card is folded on arrival, so this drives the warning badge on its heading.\n */\nexport function advancedGroupInvalid(state: Pick): boolean {\n return ADVANCED_FIELDS.some((field) => state[field].invalid)\n}\n\n/**\n * Whether anything the collapsed Models & context group owns is blocking the\n * save (issue #94). Besides its staged drafts — the plan switch and the two\n * numbers — that group owns the per-model context rows, whose drafts are\n * validated one by one: a bad row must surface on the heading just like a bad\n * number, or Save would sit disabled with nothing on screen explaining why.\n */\nexport function modelsGroupInvalid(\n state: Pick,\n): boolean {\n return MODELS_FIELDS.some((field) => state[field].invalid)\n || state.contextWindowOverrides.some((row) => row.invalid)\n}\n\n/** The \"customized\" tag and reset link a staged field shows once it differs from the default. */\nfunction FieldOverride({ label, state, disabled, t, onReset }: {\n label: string\n state: StagedField\n disabled: boolean\n t: Translate\n onReset(): void\n}) {\n if (!state.overridden && !state.clear) return null\n return (\n \n )\n}\n\n/**\n * One settings row, laid out like the harness's own General page: the title\n * and description on the left, the control on the right, a hairline between\n * rows.\n */\nfunction SettingRow({ title, titleFor, titleId, tag, description, error, control, className }: {\n title: string\n /** Makes the title a `