@mars-sea/dsh-commandcode-provider 0.11.7 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.9] - 2026-09-22
10
+
11
+ ### Changed
12
+
13
+ - **dsh 0.1.7-alpha.1 is a declared-compatible engine, and the release that rewrote settings needs no user action to keep working.** The engine replaced its settings.yaml document with schema-derived Config forms (`SettingsProvider` → `SettingsForms`, and `installSection` plus the browser's `settingsScope` service are both gone), so this release carries the two-generation adaptation: every Config field except the top-level `apiKey` secret is marked volatile through a feature-detected bridge (`src/config-volatile.ts`) — an unmarked field would be invisible to and unwritable from 0.1.7's settings page — while the ≤0.1.6 registration keeps an unmarked schema and an unwrapped config, exactly as that generation validates and clones it; volatile writes commit in place and re-run the two facts not re-derived per use (the web-search selection and the dsh-TUI section re-registration); and the browser side speaks the `remote.settings` describe/mutate wire directly through the plugin's own scope, so one implementation serves 0.1.2-rc.1 through 0.1.7-alpha.1. The one-time settings.yaml import needs no cooperation from the plugin: the engine looks the section up by profile entry id, which is the same string as the section id (`llm-commandcode`) and must stay that way. `npm run test:engine` passes against 0.1.7-alpha.1 (the bundle links and evaluates there — the request-image policy is the durable one — and the adapter is driven with each engine's own message constructors), so a release installed beside the newest engine is proven to load before it ships.
14
+
15
+ - **The plugin manager now shows a real identity for this plugin on dsh 0.1.7+.** The package declares `icon` (`icon.svg`) and ships `locale/en.json` + `locale/zh.json` under the `locale/*.json` export, so the plugin-management page renders "Command Code" / "Command Code 提供方" with a description instead of a bare package name and a placeholder icon. Both are pure metadata: older engines ignore them, and every other install path is unaffected.
16
+
17
+ ### Fixed
18
+
19
+ - **The account card's "无法连接 Command Code 服务 / 请检查网络连接或 API 地址设置" is no longer a guess, and three of its false causes are gone.** `classifyTotalFailure()` answers `network` whenever all four account endpoints throw without a status, so one verdict covered a real outage, a per-request timeout, a key no HTTP header can carry and an unparseable `apiBase` — while the card hid the only evidence, the four per-endpoint messages. Now: (a) the account path validates the key through the harness's own `assertUsableApiKey`, as chat already did, and answers `blocked: 'invalid-key'` with the credential message instead of four phantom transport failures (a key pasted with an embedded newline or a full-width character makes `fetch` throw a `TypeError` **before any I/O**, once per endpoint — the connection was fine); (b) the four endpoints use the connection's own `requestTimeoutMs` (the SAME 60 s budget as a chat call) instead of the catalog's hidden 10 s cap, which on a slow link made every account query time out while chat kept working; (c) both surfaces now render `report.failures`, so the next occurrence names its actual cause. Refresh keeps working in every case — it was the diagnosis, not the fetch lifecycle, that was wrong.
20
+
21
+ - **Turning the web-search toggle off no longer destroys the user's own `commandcode` pin.** `applyCommandCodeSearchSelection()` remembered "the field already read `commandcode` before we touched it" as `displaced: undefined`, and its disable branch wrote that value back — which is not the no-op its contract promises. The field was cleared, dsh-web reads a cleared `searchProviderId` as auto-select, and with a second usable provider registered (the shipped `deepseek-official` is usable whenever a DeepSeek key resolves) EVERY later search threw `WEB_PROVIDER_AMBIGUOUS`; the same write ran on plugin unload. The tracked selection now carries `preexisting`, so a field we did not take over is left alone, while a field that was genuinely unset at takeover is still cleared (or the toggle would leave Command Code serving). `tests/web-search.test.ts` pins both directions, including an end-to-end boot with `searchProvider: commandcode`.
22
+
23
+ - **A 5xx that proxies a credits/plan rejection rotates the account instead of being retried as a transient outage.** `classifyAccountRejection()` hoisted only `RATE_LIMITED` above its `status >= 500` guard, so `503 {"code":"INSUFFICIENT_CREDITS"}`, `502 MODEL_NOT_IN_PLAN`, `USAGE_EXCEEDED` and `PREMIUM_CREDITS_EXHAUSTED` were classified as "the provider is unavailable": no rotation, and `SERVER` put the byte-identical request on dsh-llm-retry's 1000-attempt cadence while the configured sibling accounts were never consulted. All four structured codes (`ACCOUNT_UNAVAILABLE_CODES`) are now status-independent, exactly like `RATE_LIMITED`; only the prose scan stays confined to 4xx.
24
+
25
+ - **A provider-published rate-limit reset of an implausible magnitude no longer breaks the failure it belongs to.** `error.rateLimit.reset` is seconds multiplied by 1000 and was trusted verbatim: `1e300` reached `new Date(1e303).toISOString()` and threw `RangeError: Invalid time value` out of `stream()` in place of the intended retryable `RATE_LIMIT`, and a mark pinned to it became a `cooldown` that no probe ever revisits (the account left rotation for the life of the process). Resets are now accepted only within `MAX_TRUSTED_RESET_MS` (30 days — the longest window this provider meters, so the bound rejects nonsense rather than second-guessing a schedule) at BOTH seams: the rejection body and the `/alpha/billing/credits` probe. A dropped reset degrades the mark to `unknown`, which the probe pass re-checks.
26
+
27
+ - **An in-band stream error's `statusCode` no longer outvotes a terminal marker or `isRetryable: false`.** `{statusCode: 500, message: 'insufficient credits'}` was reported as retryable `SERVER`, so a permanent quota/plan refusal was replayed on the 1000-attempt cadence. The terminal markers now also match the spaced spelling (the separator is normalized, as the pre-stream classifier already did), and an explicit refusal or a terminal marker outranks a retryable status; an explicit `isRetryable: true` still wins.
28
+
29
+ - **The first usage report no longer sticks as a false "remote is not mounted" error.** The plugin's `remote.commandcode` namespace lands one wire round-trip after the plugin asks for it, and a surface that asked first (the sidebar quota card does, on every boot) stored the synthetic not-mounted failure as `status: 'error'` — and the card's `shouldRefresh` only fires from `idle`, so nothing retried it until the panel's two-minute tick. The mount callback now re-reads the usage report through a seam declared before the effect (a `const` read in its temporal dead zone would throw). `tests/client-boot.test.ts` boots with the namespace deferred by a macrotask and asserts that re-read.
30
+
31
+ - **A settings directory whose FIRST read fails retries instead of leaving the page permanently disabled.** With nothing ever held, the mirror keeps its initial `status: 'loading'`/`writable: false` snapshot, which the settings page renders as a fully disabled form behind a "read-only" banner — and the forwarded invalidations need a live Host to fire, so a Host that was still starting up left the page dead until a reload. `SettingsDescribeMirror` now retries a first-read failure on a bounded 1/2/4 s ladder (`SETTINGS_DESCRIBE_RETRY_MS`, injectable timer, cancelled on disposal); a failure with a HELD view schedules nothing, because the page is already showing the last good document.
32
+
33
+ - **A concurrent `loginBegin` no longer leaks a loopback login server.** `begin()` rejoin read a status that only becomes `waiting` after two awaits, so two GUI tabs (or a Sign-in following a cancel that arrived too early to retire anything) each bound their own server — and only the last one was reachable by `teardown()`, so the orphan kept listening and answering `/callback` for the process's lifetime; ten of those exhausted the login port window. `begin()` is now single-flight across the bind, a `cancel()` in that window sets `cancelPending` (observed after the bind, which closes the fresh server and publishes `failed/cancelled`), and `bindServer` closes a server it does not track.
34
+
35
+ - **`DSH_COMMANDCODE_TRACE=false` (or `0`/`no`/`off`) now disables the trace instead of enabling it.** Only `1`/`true`/`yes`/`on` were recognized as flags and everything else was taken for a path, so an explicit disable switched the trace ON and wrote the conversation to a file named `false` in the process's working directory.
36
+
37
+ - **DSH 0.1.7 tool results survive into the next model request.** That engine uses `role: 'tool'` with message-level `toolCallId` and raw content blocks; the adapter only recognized the older user-role `tool-result` wrapper and silently discarded both results and their paired calls on both transports. The model consequently lost completed work, could repeat tools, and could end with only a statement of intent. Both envelopes now share pairing and serialization, including errors, images and long call ids. Engine smoke tests capture both request bodies using each engine's actual message constructors.
38
+
39
+ - **Reasoning-only replies no longer silently complete a turn.** Both transports now validate that a terminal response contains answer text or a tool call before emitting a success finish. Empty completions use the existing `EMPTY_RESPONSE` retry policy; output-limit completions without an answer report non-retryable `OUTPUT_TOKEN_LIMIT` with the actual budget and available usage, without assuming all tokens went to reasoning. Received usage is retained on failure, and trace terminal diagnostics survive the payload cap.
40
+
41
+ - **A reply cut off mid-generation is a reported failure again, not a turn that quietly finished** (report: 《调用会莫名其妙的自动停止,并没有任何报错信息》). The mechanism was in the stream tail: when the response body ended without its transport's terminal event — the gateway or an intermediary closing the socket mid-generation — the adapter closed the open blocks and yielded a clean `finish: { kind: 'stop' }`. Both transports DO terminate explicitly (the CLI transport with a `finish` event, the Provider API with a final `finish_reason` chunk), so an EOF there is not a normal ending, and reporting it as one is exactly why a truncated answer arrived with no error in the UI and nothing in the session log: the harness saw a completed turn. The tail now classifies. `!sawContent` (no visible text AND no tool call) keeps the pre-existing retryable `EMPTY_RESPONSE`, because such a request is free to repeat and the retry policy's whitelist absorbs it — a cut during a long thinking phase lands there, unchanged. A cut that HAD produced content throws `STREAM_CLOSED`, the code `dsh-llm-deepseek`'s own `translate()` raises for the same condition ("… stream ended before message_stop"); a tool call still in flight when the socket closed is dropped rather than executed with truncated JSON arguments. The code is deliberately OUTSIDE `providerRetryPolicy()`'s whitelist for now: every retry of a content-bearing cut re-generates the whole answer, so auto-retrying is a cost decision rather than an obvious win (adding it to `retryableCodes`, ideally bounded by `src/transport-retry.ts`'s per-agent budget, is the one-line opt-in). `tests/adapter.test.ts` pins both endings on both transports, the in-flight-tool-call refusal, and the untouched `EMPTY_RESPONSE` path.
42
+ - **The Command Code settings page is interactive again on dsh 0.1.7** (report: 《Command Code 设置页面所有的按钮和开关都无法点击,下拉选择模型也无法使用》). The page rendered — with its read-only banner, empty text fields and every switch, button and select disabled — because the plugin's own settings scope read `ctx.remote.settings` directly, and `remote.settings` is a Cordis service NESTED under the `remote` service (api-gateway registers one `Service` per contribution namespace as `remote.<ns>`): a read from a fiber that does not declare that name in its inject list is answered with `Error: cannot get property "remote.settings" without inject`. The throw landed inside the scope's own `try`/`catch`, so nothing was logged and no `settings/describe` ever left the browser; the mirror stayed `idle`, the scope kept its INITIAL snapshot (`status: 'loading'`, `writable: false`, `value: undefined`), and since the page disables its controls on `!state.writable`, everything was inert. The boolean toggles were the confusing part of the report: they fall back to their hardcoded `defaultChecked` while the scope has no value, so they looked "set" over empty text inputs. `createSettingsScope()` now takes the namespace as a resolver, and the client entry fills it from the object `ctx.inject(['remote.settings'], …)` resolves — the same shape the `remote.credentials` and `remote.commandcode` reads have always used. `$host` / `$on` / `$mount` are plain members of the declared `remote` service, so those stay direct reads, and no namespace was added to the exported client `inject` list: gating the whole client half on a service a profile may not serve would silence every surface, which is the failure this scope is built to avoid. Verified end to end against a real dsh 0.1.7-alpha.1 web app (the page converges on the stored row, a toggle marks it dirty, the model multi-select opens its 76-model list, and a save lands in the profile document and survives a reload); `tests/settings-scope.test.ts` now builds its fake context with a `settings` getter that throws the engine's exact error and covers the unmounted → mount + `refresh()` handshake, and `tests/client-boot.test.ts` mounts its namespaces as real `remote.<ns>` Cordis services — with the old direct read restored, the boot suite fails.
43
+
44
+ ### Added
45
+
46
+ - **`DSH_COMMANDCODE_TRACE=<path>`: an opt-in raw-stream trace, so the next "it stopped in the middle" report arrives with evidence** (`src/stream-trace.ts`). A cut stream is diagnosed from the wire or not at all — which events actually arrived, how long the socket then sat silent, and how it ended are the facts that separate "the provider closed it" from "our parser missed the terminal event", and until now the adapter kept none of them. With the variable set (a bare `1`/`true`/`yes`/`on` writes to `$TMPDIR/dsh-commandcode-stream.jsonl`), every request appends JSONL: one record per raw chunk carrying the provider's own payload verbatim, plus `response` / `eof` / `idle-timeout` / `read-error` / `end` / `stream-close`, each stamped with milliseconds since that request's first byte. Records never include the request body, an API key, or a request header; one request is capped at 4 MiB (`trace-capped` names the cap); a path that cannot be written turns the trace off rather than failing the request it is observing; and unset, the whole facility is one environment read and some inert calls. `scripts/probe-stream.mjs` is the companion isolation test — it drives the adapter alone, with no harness and no session, so a cut seen there is the provider's while a clean `finished` puts the cut above the adapter (`PROBE_MODEL` / `PROBE_MAX_TOKENS` / `PROBE_PROMPT` / `PROBE_TRACE` override its defaults). `tests/stream-trace.test.ts` pins switch resolution, the JSONL shape, the cap, the degradation paths, and the adapter wiring on both a finished and a cut stream. Both live probes so far (`deepseek/deepseek-v4.1-flash`, 13 and 244 chunks) reached `finished` on `/provider/v1/chat/completions`, so the gateway does not cut unconditionally.
47
+
48
+ ## [0.11.8] - 2026-09-22
49
+
50
+ ### Changed
51
+
52
+ - **Synced with the official command-code@1.62.0 CLI** (2026-09-22 check of the 2026-09-21 npm `latest`; five releases since the last sync — 1.58.1, then the 1.59.0/1.60.0/1.61.0/1.62.0 train whose changelog entries are "Add Grok 4.7", "Add Step 5 Preview", "Grok 4.7 40% off" and "Add MiMo V2.6 Flash / Add MiMo V2.6 Pro and Pro UltraSpeed"). `COMMAND_CODE_CLI_VERSION` is now `1.62.0`. This train is purely additive: the CLI registry grows 78 → 83 with exactly five additions and no removal or edit, so five models gain their picker annotations and prices. `xai/grok-4.7` (500K context, text+image, `low`/`medium`/`high`/`xhigh` efforts, GOAT and above) launches on a 40% off deal running 2026-09-21 → 2026-09-27 that reverts to $2.00 in / $6.00 out when it lapses; its snapshotted rates are the discounted $1.20 in / $3.60 out / $0.30 cache read, doubling to $2.40 / $7.20 / $0.60 past 200K, and the deal carries the page's own expiry stamp so the badge hides itself after that instant. `stepfun/Step-5-Preview` is StepFun's 600B sparse-MoE agentic coding model (1M context, text+image, `low`/`medium`/`high` efforts, Go, $1.00 / $2.70 / $0.05). The MiMo V2.6 family is text+image with a 1,048,576-token context and no reasoning flag in any source: `xiaomi/mimo-v2.6-flash` ($0.14 / $0.28 / $0.0028) and `xiaomi/mimo-v2.6-pro` ($0.435 / $0.87 / $0.0036) on Go, plus `xiaomi/mimo-v2.6-pro-ultraspeed` on GOAT and above ($4.35 / $8.70 / $0.036). In snapshot terms: the effort map gains exactly two keys (46 → 48), the Vision set the five (52 → 57), the plan map three Go and two GOAT entries without moving a tier (46/52/65/72 → 49/57/70/77), the deals five → six, and the vendored price table exactly the five matching rows (71 → 76) with no rate change to any existing row (`node scripts/sync-model-prices.mjs --check` now reports all 76 rows up to date). Everything else is re-verified unchanged against both bundles and the public sources: the subscription plan maps, the peak/off-peak membership and schedule (01:00–04:00 and 06:00–10:00 UTC, Monday–Friday only), the automatic-reasoning set, and the public catalog — which serves 76 models (71 + the five) with its route mix moving 55 → 60 × chat/completions + responses exactly by those additions, the same 8 Claude ids still messages-only and 8 models still chat/completions-only, which agrees with the adapter's `claude-*` routing rule. Static inspection of the two bundles finds no transport drift: the `/alpha/*` endpoint set, the `/alpha/generate` converters (`toWireMessages`/`toWireTools`/`toWireToolOutput`), the stream event vocabulary, the `x-command-code-version` header, the browser-login call site and the web-search field names differ only by minified variable renames, and the documented Provider API endpoint set is unchanged. Two CLI-local changelog items reach nothing this adapter owns: 1.58.1's "Auto-compact past images and keep their result" (the CLI's own compaction behavior, not a wire change) and 1.59.0's YOLO/accept-edits keybinding rework. The two upstream leftovers stay deliberately un-snapshotted, exactly as the previous check recorded them: `gpt-6-astra` is still a CLI/pricing/docs-only model the public catalog does not serve, and the pricing page still embeds an already-expired (`2026-06-22`) `qwen-3.7-max-2x-usage` deal record its own Deals section does not list.
53
+
9
54
  ## [0.11.7] - 2026-09-20
10
55
 
11
56
  ### Fixed
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Command Code">
2
+ <rect width="64" height="64" rx="14" fill="#0F172A"/>
3
+ <path d="M18 20 L30 32 L18 44" fill="none" stroke="#4ADE80" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <path d="M34 44 L48 44" fill="none" stroke="#94A3B8" stroke-width="6" stroke-linecap="round"/>
5
+ </svg>
package/lib/client.js CHANGED
@@ -36,6 +36,424 @@ window.__ModuleLoader__.load({
36
36
  };
37
37
  }
38
38
  //#endregion
39
+ //#region src/client/settings-scope.ts
40
+ const MEMORY_STATUS = "unavailable";
41
+ /**
42
+ * Backoff before re-reading a directory whose FIRST read failed, in millis.
43
+ *
44
+ * A first read can lose to a Host that is still coming up (the gateway's WS
45
+ * handshake, a settings service mid-start): the failure is transient, but the
46
+ * scope has nothing held, so it keeps its initial `{ status: 'loading',
47
+ * writable: false }` snapshot — and every control on the settings page renders
48
+ * disabled behind a "read-only" banner. Nothing else would ever re-read it:
49
+ * the forwarded `settings/document-updated` and `connection/reset` signals need
50
+ * a live Host to fire, and the namespace only re-mounts on a page reload. So a
51
+ * failure with NOTHING held retries on this bounded ladder; a failure with a
52
+ * held view keeps it (no retry — the page is already showing the last good
53
+ * document, and `tests/settings-scope.test.ts` pins that).
54
+ */
55
+ const SETTINGS_DESCRIBE_RETRY_MS = [
56
+ 1e3,
57
+ 2e3,
58
+ 4e3
59
+ ];
60
+ /** The production timer: a browser/node timeout, no polling. */
61
+ const REAL_SETTINGS_TIMER = {
62
+ set(callback, ms) {
63
+ const handle = setTimeout(callback, ms);
64
+ handle.unref?.();
65
+ return handle;
66
+ },
67
+ clear(handle) {
68
+ clearTimeout(handle);
69
+ }
70
+ };
71
+ /** Shallow equality over the snapshot fields, so an unchanged derive never churns subscribers. */
72
+ function sameSnapshot(a, b) {
73
+ return a.status === b.status && Object.is(a.value, b.value) && Object.is(a.base, b.base) && Object.is(a.user, b.user) && Object.is(a.revision, b.revision) && a.writable === b.writable && a.mode === b.mode;
74
+ }
75
+ /** Accept a decoded row value only when it is the object a settings section is. */
76
+ function decodeRow(value) {
77
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
78
+ return value;
79
+ }
80
+ /**
81
+ * One shared mirror of the Host `settings.describe()` view.
82
+ *
83
+ * The run loop is the harness's own: serialize behind `inFlight`, fold a
84
+ * concurrent invalidation into exactly one rerun, and hold the previous view
85
+ * across a failed read (status stays `ready` with an `error`, or returns to
86
+ * `idle` when nothing was ever held).
87
+ */
88
+ var SettingsDescribeMirror = class {
89
+ resolveRemote;
90
+ timer;
91
+ store;
92
+ persistence;
93
+ inFlight;
94
+ rerun = false;
95
+ generation = 0;
96
+ /** Pending retry handle and how many of {@link SETTINGS_DESCRIBE_RETRY_MS} are spent. */
97
+ retryHandle;
98
+ retries = 0;
99
+ disposed = false;
100
+ /**
101
+ * @param resolveRemote - Reads the inject-captured `remote.settings` namespace.
102
+ * @param persistence - Client-selected Host persistence; non-loopback pages may stay process-local.
103
+ * @param timer - Retry timer seam (see {@link SETTINGS_DESCRIBE_RETRY_MS}).
104
+ */
105
+ constructor(resolveRemote, persistence, timer = REAL_SETTINGS_TIMER) {
106
+ this.resolveRemote = resolveRemote;
107
+ this.timer = timer;
108
+ this.persistence = persistence;
109
+ this.store = createSnapshotStore({
110
+ status: persistence === "host" ? "idle" : MEMORY_STATUS,
111
+ view: null,
112
+ error: null
113
+ });
114
+ }
115
+ getSnapshot() {
116
+ return this.store.getSnapshot();
117
+ }
118
+ subscribe(listener) {
119
+ return this.store.subscribe(listener);
120
+ }
121
+ /** The cheap idempotent entry: kick a first read (a no-op on memory persistence). */
122
+ ensure() {
123
+ this.load();
124
+ }
125
+ /**
126
+ * Refresh from the Host. A call during an in-flight read marks one rerun
127
+ * after it settles instead of racing a second wire read.
128
+ * @returns settlement after this call's freshness is reflected.
129
+ */
130
+ load() {
131
+ if (this.disposed || this.persistence === "memory") return Promise.resolve();
132
+ this.clearRetry();
133
+ if (this.inFlight !== void 0) {
134
+ this.rerun = true;
135
+ return this.inFlight;
136
+ }
137
+ const run = Promise.resolve().then(() => this.run());
138
+ this.inFlight = run;
139
+ return run;
140
+ }
141
+ /**
142
+ * Fold one write answer's namespace ROW into the held view without a wire
143
+ * read, and invalidate any read still in flight: a read begun before the
144
+ * write committed must not publish the pre-write document over it. With no
145
+ * held document the answer is not published as a partial document — the
146
+ * next read (or the failed-write recovery) picks it up.
147
+ * @param row - The namespace view a settings write answered with.
148
+ */
149
+ acceptView(row) {
150
+ const before = this.store.getSnapshot();
151
+ this.generation += 1;
152
+ if (this.inFlight !== void 0) this.rerun = true;
153
+ if (before.view === null) return;
154
+ const held = before.view;
155
+ const namespaces = held.namespaces?.some((candidate) => candidate.ns === row.ns) ? held.namespaces.map((candidate) => candidate.ns === row.ns ? row : candidate) : [...held.namespaces ?? [], row];
156
+ this.store.set({
157
+ ...before,
158
+ view: {
159
+ ...held,
160
+ namespaces
161
+ }
162
+ });
163
+ }
164
+ async run() {
165
+ try {
166
+ for (;;) {
167
+ const before = this.store.getSnapshot();
168
+ if (before.status === "idle") this.store.set({
169
+ ...before,
170
+ status: "loading"
171
+ });
172
+ this.rerun = false;
173
+ const generation = ++this.generation;
174
+ let outcome;
175
+ try {
176
+ const namespace = this.resolveRemote();
177
+ if (namespace === void 0) throw new Error("the settings remote namespace is not mounted");
178
+ const response = await namespace.describe();
179
+ if (response.ok) {
180
+ const value = response.value;
181
+ outcome = typeof value === "object" && value !== null ? { view: value } : { failure: "settings describe answered no view" };
182
+ } else outcome = { failure: response.error?.message ?? "settings describe failed" };
183
+ } catch (error) {
184
+ outcome = { failure: error instanceof Error ? error.message : String(error) };
185
+ }
186
+ if (generation !== this.generation) continue;
187
+ if ("view" in outcome) {
188
+ this.retries = 0;
189
+ this.store.set({
190
+ status: "ready",
191
+ view: outcome.view,
192
+ error: null
193
+ });
194
+ } else {
195
+ const held = this.store.getSnapshot();
196
+ this.store.set({
197
+ status: held.view === null ? "idle" : "ready",
198
+ view: held.view,
199
+ error: outcome.failure
200
+ });
201
+ if (held.view === null) this.scheduleRetry();
202
+ }
203
+ if (!this.rerun) break;
204
+ }
205
+ } finally {
206
+ this.inFlight = void 0;
207
+ }
208
+ }
209
+ /**
210
+ * Schedule the next retry of a failed FIRST read ({@link SETTINGS_DESCRIBE_RETRY_MS}).
211
+ * Bounded: once the ladder is spent the scope keeps its degraded snapshot,
212
+ * exactly as before, until an invalidation or a namespace re-mount arrives.
213
+ */
214
+ scheduleRetry() {
215
+ if (this.disposed || this.retries >= SETTINGS_DESCRIBE_RETRY_MS.length) return;
216
+ const delay = SETTINGS_DESCRIBE_RETRY_MS[this.retries];
217
+ this.retries += 1;
218
+ this.retryHandle = this.timer.set(() => {
219
+ this.retryHandle = void 0;
220
+ this.load();
221
+ }, delay);
222
+ }
223
+ /** Cancel a pending retry (a live read is starting, or the scope is going away). */
224
+ clearRetry() {
225
+ if (this.retryHandle !== void 0) this.timer.clear(this.retryHandle);
226
+ this.retryHandle = void 0;
227
+ }
228
+ /** Stop the mirror: no further reads, and no retry may fire after disposal. */
229
+ dispose() {
230
+ this.disposed = true;
231
+ this.clearRetry();
232
+ }
233
+ };
234
+ /**
235
+ * One namespace's derived scope over the shared mirror, plus its serialized
236
+ * Host writes. This is the face `CommandCodeSettingsController` consumes
237
+ * (`getSnapshot` / `subscribe` / `set` / `unset`), so the controller itself
238
+ * needs no changes for either generation.
239
+ */
240
+ var RemoteSettingsScope = class {
241
+ resolveRemote;
242
+ store;
243
+ mirror;
244
+ namespace;
245
+ persistence;
246
+ tail = Promise.resolve();
247
+ writeGeneration = 0;
248
+ disposed = false;
249
+ unsubscribe;
250
+ /** Revision answered by a superseded write still ahead of the mirror. */
251
+ pendingRevision;
252
+ constructor(resolveRemote, mirror, namespace, persistence) {
253
+ this.resolveRemote = resolveRemote;
254
+ this.mirror = mirror;
255
+ this.namespace = namespace;
256
+ this.persistence = persistence;
257
+ this.store = createSnapshotStore({
258
+ status: persistence === "host" ? "loading" : "unavailable",
259
+ value: void 0,
260
+ base: void 0,
261
+ user: void 0,
262
+ revision: void 0,
263
+ writable: false,
264
+ mode: persistence
265
+ });
266
+ if (persistence === "host") {
267
+ this.unsubscribe = mirror.subscribe(() => {
268
+ this.derive();
269
+ });
270
+ this.derive();
271
+ }
272
+ }
273
+ getSnapshot() {
274
+ return this.store.getSnapshot();
275
+ }
276
+ subscribe(listener) {
277
+ return this.store.subscribe(listener);
278
+ }
279
+ /** Queue one field write (the single-path `set` op form). */
280
+ set(field, value) {
281
+ return this.mutate([{
282
+ op: "set",
283
+ path: [field],
284
+ value
285
+ }]);
286
+ }
287
+ /** Queue one field clear (the single-path `unset` op form). */
288
+ unset(field) {
289
+ return this.mutate([{
290
+ op: "unset",
291
+ path: [field]
292
+ }]);
293
+ }
294
+ /**
295
+ * Queue one atomic namespace mutation behind the single-flight fence.
296
+ * A rejected write (e.g. `SETTINGS_CONFLICT`) re-reads the Host so the
297
+ * snapshot reports what actually landed; an accepted one folds its answer
298
+ * in directly — unless a newer write already superseded it, in which case
299
+ * that answer's revision becomes the next write's fence.
300
+ */
301
+ mutate(ops, expectedRevision) {
302
+ const ownedOps = structuredClone(ops);
303
+ const generation = ++this.writeGeneration;
304
+ return this.enqueue(async () => {
305
+ const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision;
306
+ const response = await this.mutateRemote(ownedOps, revision);
307
+ if (!response.ok) {
308
+ await this.recover(generation);
309
+ return;
310
+ }
311
+ if (this.disposed) return;
312
+ const row = response.value;
313
+ if (generation === this.writeGeneration) {
314
+ this.pendingRevision = void 0;
315
+ this.mirror.acceptView(row);
316
+ } else this.pendingRevision = typeof row.revision === "number" ? row.revision : void 0;
317
+ });
318
+ }
319
+ async mutateRemote(ops, revision) {
320
+ try {
321
+ const namespace = this.resolveRemote();
322
+ if (namespace === void 0) return {
323
+ ok: false,
324
+ error: { message: "the settings remote namespace is not mounted" }
325
+ };
326
+ return await namespace.mutate(this.namespace, ops, revision);
327
+ } catch (error) {
328
+ return {
329
+ ok: false,
330
+ error: { message: error instanceof Error ? error.message : String(error) }
331
+ };
332
+ }
333
+ }
334
+ /** Reload Host state for the latest failed write; superseded failures leave recovery to it. */
335
+ async recover(generation) {
336
+ if (this.disposed || generation !== this.writeGeneration) return;
337
+ this.pendingRevision = void 0;
338
+ await this.mirror.load();
339
+ }
340
+ /** Derive this namespace's snapshot from the mirror's held view. */
341
+ derive() {
342
+ if (this.disposed) return;
343
+ const mirrored = this.mirror.getSnapshot();
344
+ if (mirrored.view === null || mirrored.view === void 0) return;
345
+ const writable = mirrored.view.writable === true;
346
+ const row = mirrored.view.namespaces?.find((candidate) => candidate.ns === this.namespace);
347
+ if (row === void 0) {
348
+ this.publish({
349
+ status: "unavailable",
350
+ writable
351
+ });
352
+ return;
353
+ }
354
+ const decoded = decodeRow(row.value);
355
+ if (decoded === void 0) return;
356
+ this.publish({
357
+ status: "ready",
358
+ writable,
359
+ value: decoded,
360
+ base: row.base,
361
+ user: row.user,
362
+ revision: typeof row.revision === "number" ? row.revision : void 0
363
+ });
364
+ }
365
+ /** Publish `next` over the current snapshot, skipping the write when nothing changed. */
366
+ publish(next) {
367
+ const current = this.store.getSnapshot();
368
+ const candidate = {
369
+ status: next.status,
370
+ value: "value" in next ? next.value : current.value,
371
+ base: "base" in next ? next.base : current.base,
372
+ user: "user" in next ? next.user : current.user,
373
+ revision: "revision" in next ? next.revision : current.revision,
374
+ writable: next.writable,
375
+ mode: current.mode
376
+ };
377
+ if (sameSnapshot(candidate, current)) return;
378
+ this.store.set(candidate);
379
+ }
380
+ /** Queue operations one at a time; memory persistence and disposal are no-ops. */
381
+ enqueue(operation) {
382
+ if (this.persistence === "memory" || this.disposed) return Promise.resolve();
383
+ const task = this.tail.then(async () => {
384
+ if (this.disposed) return;
385
+ await operation();
386
+ });
387
+ this.tail = task.catch(() => {});
388
+ return task;
389
+ }
390
+ /** Stop queued operations, stop deriving, and wait for the current wire call to settle. */
391
+ async dispose() {
392
+ this.disposed = true;
393
+ this.writeGeneration += 1;
394
+ this.unsubscribe?.();
395
+ await this.tail;
396
+ }
397
+ };
398
+ /**
399
+ * Build the plugin's settings scope for one namespace.
400
+ *
401
+ * Subscribes the two signals that can move the Host view — the forwarded
402
+ * `settings/document-updated` invalidation and (when the client exposes it)
403
+ * `connection/reset` — kicks the first describe read, and derives the
404
+ * namespace row on every mirror change.
405
+ *
406
+ * `resolveRemote` is the INJECT-CAPTURED namespace (see
407
+ * {@link SettingsRemoteResolver}): `remote.settings` is a Cordis service nested
408
+ * under `remote`, so a read through our own context throws and has to be
409
+ * replaced by an object captured inside `ctx.inject(['remote.settings'], …)`.
410
+ * The caller that owns that inject calls {@link ManagedSettingsScope.refresh}
411
+ * once the namespace lands, which is what turns the first, namespace-less read
412
+ * into a served one; a profile that never mounts the namespace leaves the page
413
+ * in its degraded (`unavailable`, read-only) state instead of throwing.
414
+ *
415
+ * @param context - The client context slice (only the declared `remote` service is read).
416
+ * @param namespace - Settings namespace (profile entry id) this scope binds.
417
+ * @param resolveRemote - Reads the inject-captured `remote.settings` namespace.
418
+ * @returns The scope plus the refresh/dispose handles the entry wires into its fiber.
419
+ */
420
+ function createSettingsScope(context, namespace, resolveRemote, timer = REAL_SETTINGS_TIMER) {
421
+ const loopback = context.remote.$host?.isLoopback === true;
422
+ const persistence = loopback ? "host" : "memory";
423
+ const mirror = new SettingsDescribeMirror(resolveRemote, persistence, timer);
424
+ const scope = new RemoteSettingsScope(resolveRemote, mirror, namespace, persistence);
425
+ const disposers = [];
426
+ if (loopback) {
427
+ if (typeof context.remote.$on === "function") {
428
+ const off = context.remote.$on("settings/document-updated", () => {
429
+ mirror.ensure();
430
+ });
431
+ if (typeof off === "function") disposers.push(off);
432
+ }
433
+ if (typeof context.on === "function") {
434
+ const off = context.on("connection/reset", () => {
435
+ mirror.load();
436
+ });
437
+ if (typeof off === "function") disposers.push(off);
438
+ }
439
+ mirror.ensure();
440
+ }
441
+ return {
442
+ getSnapshot: () => scope.getSnapshot(),
443
+ subscribe: (listener) => scope.subscribe(listener),
444
+ set: (field, value) => scope.set(field, value),
445
+ unset: (field) => scope.unset(field),
446
+ refresh: () => {
447
+ mirror.load();
448
+ },
449
+ dispose: async () => {
450
+ for (const dispose of disposers.splice(0)) dispose();
451
+ mirror.dispose();
452
+ await scope.dispose();
453
+ }
454
+ };
455
+ }
456
+ //#endregion
39
457
  //#region src/client/settings.ts
40
458
  /**
41
459
  * Browser controller for the "Command Code" settings page.
@@ -56,10 +474,14 @@ window.__ModuleLoader__.load({
56
474
  * - Working dir, request/stream timeouts -> the same namespace.
57
475
  *
58
476
  * The controller mirrors the plugin-card pattern from the harness's own
59
- * settings UI: it binds the `llm-commandcode` namespace through the
60
- * `settingsScope` service, keeps a staged draft of edits, and writes them on
61
- * save through `scope.set` / the credentials domain. The Host stays the
62
- * single fact source; the snapshot is republished after each accepted write.
477
+ * settings UI: it binds the `llm-commandcode` namespace through a
478
+ * `SettingsScope` — this plugin's own binding of `remote.settings` (see
479
+ * `./settings-scope.ts`; the harness's `settingsScope` wrapper existed only
480
+ * through 0.1.6 and was removed with the 0.1.7 settings rewrite, while the
481
+ * wire underneath spans every supported release) — keeps a staged draft of
482
+ * edits, and writes them on save through `scope.set` / the credentials
483
+ * domain. The Host stays the single fact source; the snapshot is republished
484
+ * after each accepted write.
63
485
  *
64
486
  * This module is deliberately free of JSX — it only produces the state face
65
487
  * the React component renders.
@@ -2157,7 +2579,7 @@ window.__ModuleLoader__.load({
2157
2579
  }
2158
2580
  //#endregion
2159
2581
  //#region package.json
2160
- var version = "0.11.7";
2582
+ var version = "0.11.9";
2161
2583
  var repository = {
2162
2584
  "type": "git",
2163
2585
  "url": "git+https://github.com/Mars-Sea/dsh-commandcode-provider.git"
@@ -2812,13 +3234,21 @@ window.__ModuleLoader__.load({
2812
3234
  report.blocked !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2813
3235
  className: "cc-usageBlocked",
2814
3236
  role: "alert",
2815
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2816
- className: "cc-usageBlockedTitle",
2817
- children: blockedTitle(report.blocked, t)
2818
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2819
- className: "cc-usageBlockedHint",
2820
- children: blockedHint(report.blocked, t)
2821
- })]
3237
+ children: [
3238
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3239
+ className: "cc-usageBlockedTitle",
3240
+ children: blockedTitle(report.blocked, t)
3241
+ }),
3242
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3243
+ className: "cc-usageBlockedHint",
3244
+ children: blockedHint(report.blocked, t)
3245
+ }),
3246
+ report.failures.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3247
+ className: "cc-usageBlockedDetail",
3248
+ title: report.failures.join("; "),
3249
+ children: report.failures.join(" · ")
3250
+ }) : null
3251
+ ]
2822
3252
  }) : null,
2823
3253
  report.usage !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2824
3254
  className: "cc-usageStats",
@@ -4122,21 +4552,23 @@ window.__ModuleLoader__.load({
4122
4552
  }
4123
4553
  /** The report-level failure box, or undefined when the report is usable. */
4124
4554
  function failureView(state) {
4125
- const blocked = state.report?.accounts.find((entry) => entry.report.blocked !== void 0)?.report.blocked;
4555
+ const blockedEntry = state.report?.accounts.find((entry) => entry.report.blocked !== void 0);
4556
+ const blocked = blockedEntry?.report.blocked;
4557
+ const detail = (blockedEntry?.report.failures ?? []).join(" · ");
4126
4558
  if (blocked === "invalid-key") return {
4127
4559
  title: "errorInvalidKey",
4128
4560
  hint: "errorInvalidKeyHint",
4129
- detail: ""
4561
+ detail
4130
4562
  };
4131
4563
  if (blocked === "service-unavailable") return {
4132
4564
  title: "errorServiceUnavailable",
4133
4565
  hint: "errorServiceUnavailableHint",
4134
- detail: ""
4566
+ detail
4135
4567
  };
4136
4568
  if (blocked === "network") return {
4137
4569
  title: "errorNetwork",
4138
4570
  hint: "errorNetworkHint",
4139
- detail: ""
4571
+ detail
4140
4572
  };
4141
4573
  if (state.status === "error" && state.report === void 0) return {
4142
4574
  title: "errorGeneric",
@@ -5791,6 +6223,7 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box
5791
6223
  .cc-usageBlocked{border:1px solid var(--dsw-alias-label-error);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:4px}
5792
6224
  .cc-usageBlockedTitle{color:var(--dsw-alias-label-error);margin:0;font-size:13px;font-weight:600;line-height:1.5}
5793
6225
  .cc-usageBlockedHint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:1.5}
6226
+ .cc-usageBlockedDetail{color:var(--dsw-alias-label-secondary);margin:0;font-size:11px;line-height:1.5;word-break:break-word;opacity:.85}
5794
6227
  .cc-version{margin:4px 0 0;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}
5795
6228
  /* The Models-page provider panel root: unstyled by design — the row card the
5796
6229
  * Models page owns supplies the surface, the panel only stacks its controls. */
@@ -6314,9 +6747,11 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box
6314
6747
  const SESSION_COST_ID = "commandcode-session-cost";
6315
6748
  /**
6316
6749
  * Client plugin body. Gates on the services shared by both client generations
6317
- * (`slots`, `locale`, `connection`, `remote`, `settingsScope`). Current clients
6318
- * mount the page after `remote.credentials` appears; legacy clients mount it
6319
- * from the connection ApiProxy credential face.
6750
+ * (`slots`, `locale`, `connection`, `remote`) — never on `settingsScope`,
6751
+ * which 0.1.7 removed and which the settings scope here replaces for every
6752
+ * generation. Current clients mount the page after `remote.credentials`
6753
+ * appears; legacy clients mount it from the connection ApiProxy credential
6754
+ * face.
6320
6755
  */
6321
6756
  function apply(ctx) {
6322
6757
  injectPageCss();
@@ -6341,7 +6776,18 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box
6341
6776
  }
6342
6777
  /** Mount the one shared UI implementation over either credential transport. */
6343
6778
  function applyClientSurfaces(ctx, api, hostDescription) {
6344
- const scope = ctx.settingsScope.bind({ namespace: COMMANDCODE_NS });
6779
+ let settingsNamespace;
6780
+ const scope = createSettingsScope(ctx, COMMANDCODE_NS, () => settingsNamespace);
6781
+ ctx.effect(() => () => {
6782
+ scope.dispose();
6783
+ }, "dsh-commandcode-provider: settings scope");
6784
+ ctx.inject(["remote.settings"], (settingsCtx) => {
6785
+ settingsNamespace = settingsCtx.remote.settings;
6786
+ scope.refresh();
6787
+ settingsCtx.effect(() => () => {
6788
+ settingsNamespace = void 0;
6789
+ }, "dsh-commandcode-provider: settings namespace");
6790
+ });
6345
6791
  let modelsRemote;
6346
6792
  const controller = new CommandCodeSettingsController(scope, {
6347
6793
  ...api,
@@ -6359,6 +6805,7 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box
6359
6805
  let usageNamespace;
6360
6806
  let usageMountError;
6361
6807
  let pricesController;
6808
+ let refreshUsage;
6362
6809
  const contribution = {
6363
6810
  package: USAGE_REMOTE_CONTRIBUTION.package,
6364
6811
  descriptors: [
@@ -6381,6 +6828,7 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box
6381
6828
  usageNamespace = namespaceCtx.remote.commandcode;
6382
6829
  controller.refreshCatalog();
6383
6830
  pricesController?.reload();
6831
+ refreshUsage?.();
6384
6832
  namespaceCtx.effect(() => () => {
6385
6833
  usageNamespace = void 0;
6386
6834
  }, "dsh-commandcode-provider: usage namespace");
@@ -6430,6 +6878,7 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box
6430
6878
  };
6431
6879
  modelsRemote = () => usageRemote.models();
6432
6880
  const usageController = new CommandCodeUsageController(usageRemote);
6881
+ refreshUsage = () => void usageController.refresh();
6433
6882
  ctx.effect(() => () => usageController.dispose(), "dsh-commandcode-provider: usage controller");
6434
6883
  const usageStore = createSnapshotStore(usageController.state());
6435
6884
  usageController.subscribe(() => usageStore.set(usageController.state()));
@@ -6596,8 +7045,7 @@ select.cc-input{appearance:none;-webkit-appearance:none;-moz-appearance:none;box
6596
7045
  "slots",
6597
7046
  "locale",
6598
7047
  "connection",
6599
- "remote",
6600
- "settingsScope"
7048
+ "remote"
6601
7049
  ];
6602
7050
  //#endregion
6603
7051
  exports.apply = apply;