@mars-sea/dsh-commandcode-provider 0.10.3 → 0.10.5

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
@@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.10.5] - 2026-09-11
8
+
9
+ ### Added
10
+ - **The terminal settings page lists every model as a checkbox.** The TUI's model allowlist was a text field expecting comma-separated catalog ids typed from memory; it now renders the whole catalog under plan-tier headings (Go → GOAT → Pro → Provider), free models first, each row carrying its plan/deal/context summary as the footer hint. Everything reads as checked until something is excluded, matching the adapter's "an unset allowlist shows every model" rule, and a model this build does not recognize (retired upstream, or added after this release) still gets a row of its own instead of becoming impossible to switch from the terminal.
11
+ - **`Config.modelVisibility`, a per-model override layer beside `visibleModels`.** The TUI cannot add an id to the array it edits, so each checkbox records `modelVisibility.<id>` (true listed / false hidden) instead; an id absent from the map keeps following `visibleModels`, and the subscription-tier filter still applies on top. A hand-edited document carrying a non-boolean entry falls back to the array rather than hiding the model.
12
+
13
+ ### Fixed
14
+ - **The terminal model checkboxes now save the model you toggled.** dsh-TUI keys a staged edit by the field's PATH, so the whole list of checkboxes sharing one `visibleModels` path shared one draft: each of them parsed that same draft on save, every write op addressed the same path, and only the last field's op survived — silently replacing the allowlist with what the last catalog model's row evaluated to instead of the row the user had switched. Each checkbox now addresses a path of its own, and a toggle writes only when it disagrees with the inherited state, so switching a model back leaves no residue in the document.
15
+
16
+ ### Changed
17
+ - **Per-release compatibility now covers dsh `0.1.5-rc.2`.** `dsh.compatibility.dshReleases` declares it `compatible` alongside `0.1.2-rc.1`, `0.1.3-alpha.1`, `0.1.3-alpha.2`, `0.1.5-alpha.1` and `0.1.5-rc.1`, so the DSH STORE can list the plugin for the current Harness release. Verified on a dsh-TUI profile running that engine: the `/settings` → Command Code page, the model checkbox list, and the picker's catalog all behave as they do on 0.1.5-rc.1. An older release keeps its own record rather than being replaced, so a user who has not moved to the newest engine still sees the listing.
18
+
19
+ ### Documentation
20
+ - **The install and update instructions now say which profile receives the plugin and how to escape pnpm's release-age gate** (both READMEs). A `dsh-tui` profile owns its own plugin list, so the web install does not reach the terminal: the terminal UI needs its own `dsh plugin --profile dsh-tui add`, and it now shows an exact version because a bare package name — or `@latest` — is silently resolved to the previous release for the first 24 hours after a publish (pnpm 11's `minimumReleaseAge`) and the install still exits successfully, which is how a fresh terminal install can end up with no Command Code settings page and no `commandcode` models at all.
21
+
22
+ ## [0.10.4] - 2026-09-11
23
+
24
+ ### Added
25
+ - **A Command Code page in the dsh-TUI settings screen** ([#28](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/28)). The terminal front door now lists Command Code under `/settings`, with the API key, the API base, the out-of-plan model filter, the model allowlist, the active account, and the command language. The key is a write-only credential field: the literal goes to the credential store and never into a settings document, and the field shows only whether one is configured. Until now a TUI-only user had no way to enter a key at all — dsh-TUI's own `/provider` wizard manages its `llm-pi-ai` routes exclusively, and the web Models page was the only surface that could write it. The page appears only on a profile that mounts dsh-TUI; everywhere else the plugin stays a plain LLM-provider bundle. Every model list and account choice reads live settings, so a change lands on the next request with no restart.
26
+
27
+ ### Changed
28
+ - **Per-release compatibility now covers dsh `0.1.5-rc.1`.** `dsh.compatibility.dshReleases` declares it `compatible` alongside `0.1.2-rc.1`, `0.1.3-alpha.1`, `0.1.3-alpha.2` and `0.1.5-alpha.1`, so the DSH STORE can list the plugin for the current Harness release. Verified on both surfaces: the web profile runs the plugin on this engine (settings page, Models-page card, chat and the `/commandcode` dashboard), and a dsh-TUI 0.10.0 profile selects and serves Command Code there. An older release keeps its own record rather than being replaced, so a user who has not moved to the newest engine still sees the listing.
29
+
30
+ ### Security
31
+ - **A literal API key in the composition config no longer rides a settings response.** The `apiKey` fields (top-level and per extra account) are now declared `role('secret')`, so the harness strips them from every settings descriptor it serves. The settings page was already writing keys through the credentials seam — which is what keeps them out of a settings document — but a key written by hand into `cordis.patch.yml` or `settings.yaml` was returned to the browser verbatim, including on a remote-Host setup where that means another machine.
32
+ - **A web page can no longer cancel a login in progress.** The loopback callback's denial branch now checks the login's state token before it ends the attempt, matching the official CLI. A denial is terminal, and the browser sends a `text/plain` POST as a CORS simple request regardless of the origin allowlist, so any open page could previously kill the attempt (and show "authorization denied") by blindly posting `{"error":"access_denied"}` to the loopback port. A denial that carries the real token still ends the attempt, as the Studio's own flow does.
33
+
34
+ ### Fixed
35
+ - **Cancelling a login now really cancels it.** The key delivered by the callback is validated against `/alpha/whoami` before it is stored, and cancelling during that check was ignored: the credential was written anyway and the state flipped back to success while the page (which had already stopped polling) kept showing "cancelled". The completion now re-checks that it still owns the attempt — before writing the key and before publishing any status — so a cancel or a newly started attempt wins.
36
+ - **"Sign in" after a completed callback starts a fresh attempt.** Once the callback is consumed the loopback server is closed, but the status still read `waiting`, so the next sign-in handed back the previous attempt's URL — a link to a dead port. A `waiting` status whose server is gone is now retired instead of reused.
37
+ - **Saving the settings page no longer deletes account entries it cannot show.** The accounts list is written whole, and the page rebuilt it from the rows it displays — so a composition-config entry carrying a literal `apiKey` (which has no row) was silently deleted by any save, and the literal key of the entries it could show was stripped. The write now starts from the stored list and only rewrites the rows the page manages, so unknown entries and literal keys survive.
38
+ - **A failed save no longer throws away what you typed.** Two staged edits were cleared even when the failing write never ran: an account's label reverted to the generated name (and was then persisted that way on the retry), and a routing-rule edit was silently reverted with the page left clean — no dirty marker and no way to retry. Both are now kept until the stored section proves the write landed. Rule drafts are still dropped once the rules write itself lands, because the positional row ids shift and a kept draft would be applied to the wrong row.
39
+ - **The "clean up stale models" button no longer empties the allowlist while the catalog is missing.** Before the first catalog fetch lands — and after a failure — the catalog is empty, so every selected model looked retired and one click cleared the whole list. The cleanup, and its hint, are now shown only when a catalog is actually in hand; the explicit "show all" action remains available.
40
+ - **Whitespace around a stored API key no longer breaks multi-account rotation.** The account pool marked a rejected key exactly as it was stored, while the request path trims it (a key from the credentials seam, a `.env` line, or a shell export picks up surrounding whitespace), so a 429/401 mark landed on a string nothing looked up again: the same account was offered again, the account card showed no mark, and the usage endpoints failed while chat kept working. Keys are now normalized once, where a slot's key enters the pool.
41
+ - **Images returned by a tool are no longer invisible to Vision models** ([#30](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/30)). When the agent read a local image with `read_image` (an absolute path in the prompt) instead of receiving it as an attachment or pasted image, the request replayed only the tool's metadata text — the nested `image` block inside the tool result was flattened away, so the model answered that it could see no pixels. Both transports now carry the image's bytes in a user message right after the tool message, introduced by an `Attached image(s) from tool result:` note: the image stays outside the tool result, where neither `/alpha/generate`'s text-only tool output nor Chat Completions' `role: 'tool'` content could hold it. A tool result that repeats one attachment sends it once, an image-only result no longer produces an empty tool message, and text-only tool results are byte-for-byte unchanged.
42
+ - **Test coverage for the above**, plus the previously unpinned paths they exposed: account writes preserving composition entries, label/rule drafts across every failure order, the login callback's authentication and attempt ownership, credential normalization in the pool, and the catalog-readiness gate (each verified by reverting its fix and watching the new test fail).
43
+
7
44
  ## [0.10.3] - 2026-09-10
8
45
 
9
46
  ### Changed
package/README.md CHANGED
@@ -18,6 +18,7 @@ Unofficial [DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harnes
18
18
 
19
19
  - **Plugin bundle** — install into any dsh profile with `dsh plugin add`; registers a `commandcode` provider route with a live model catalog.
20
20
  - **Dedicated settings page** — API key, connection options, a live account-usage card, and a "Hide out-of-plan models" toggle.
21
+ - **Works in the terminal too** — the same install serves a [dsh-TUI](#terminal-ui-dsh-tui) profile, with its own **`/settings` → Command Code** page for the API key and the model controls.
21
22
  - **Models-page key card** — the **Settings → Models → Command Code** card carries the key status, a paste field, and the sign-in button inline.
22
23
  - **In-browser sign-in for keys** — start the official authorization flow (the same one `cmd login` runs) from the settings page; the approved key lands in the local credential service automatically. Manual paste remains the fallback.
23
24
  - **Multi-account rotation** — when one account hits its usage limit, requests switch to the next account automatically. See [Account rotation](#account-rotation).
@@ -46,7 +47,15 @@ Pick the release line that matches your DeepSeek Harness version:
46
47
  dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.9.1
47
48
  ```
48
49
 
49
- > The `latest` tag now always points at the current 0.1.2-line plugin release, so a plain `@latest` install gets the newest stable release for dsh 0.1.2+. Users on the old 0.5.0-era Harness line must pin `@0.9.1` explicitly.
50
+ > The `latest` tag points at the current 0.1.2-line plugin release. Users on the old 0.5.0-era Harness line must pin `@0.9.1` explicitly.
51
+
52
+ **pnpm 11 holds back new releases.** Its `minimumReleaseAge` defaults to 1440 minutes, so a version published less than a day ago is skipped and `@latest` resolves to the *previous* release — silently, with a success exit code. To install a release from the last 24 hours, name it exactly:
53
+
54
+ ```sh
55
+ dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.10.5
56
+ ```
57
+
58
+ The same applies to every profile you install into, including the terminal UI below.
50
59
 
51
60
  Fresh pnpm 10 marketplace generations are supported directly. Do not add a separate `@deepseek-ai/dsh-invariants` dependency; the plugin declares it as a Host peer so the active dsh profile remains the owner of Harness packages.
52
61
 
@@ -59,6 +68,14 @@ dsh plugin --profile web update @mars-sea/dsh-commandcode-provider@latest #
59
68
  dsh plugin --profile web update @mars-sea/dsh-commandcode-provider@0.9.1 # older dsh (0.5.0 line, unmaintained)
60
69
  ```
61
70
 
71
+ Each profile updates separately — the terminal UI owns its own plugin list (see below):
72
+
73
+ ```sh
74
+ dsh plugin --profile dsh-tui update @mars-sea/dsh-commandcode-provider@0.10.5
75
+ ```
76
+
77
+ To move to a version published less than 24 hours ago, name it exactly as in Install above; pnpm 11's age gate resolves `@latest` to the previous release instead.
78
+
62
79
  Then restart the web app.
63
80
 
64
81
  ## Getting an API key
@@ -78,6 +95,51 @@ Or skip the CLI: click **Sign in to Command Code** under **Settings → Command
78
95
 
79
96
  After restart, enter your API key in **Settings → Command Code** and save; **Settings → Models** shows a **Command Code** card, and the model picker lists the live catalog under **commandcode**. Send a message with a model your plan includes.
80
97
 
98
+ ## Terminal UI (dsh-TUI)
99
+
100
+ The plugin also works under a terminal front door. **Each dsh profile owns its own plugin list**, so the web install above does not reach the terminal — add the plugin to the `dsh-tui` profile as well:
101
+
102
+ ```sh
103
+ dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider@0.10.5
104
+ ```
105
+
106
+ Pin the exact version here. For the first 24 hours after a release, a bare package name (or `@latest`) is silently resolved to the previous one: the install succeeds, but the profile gets the older build — which is how a fresh terminal install ends up with no **`/settings` → Command Code** page and no `commandcode` models at all.
107
+
108
+ Then pick the provider in the model selector, or name it directly:
109
+
110
+ ```text
111
+ /model commandcode/deepseek/deepseek-v4.1-flash
112
+ ```
113
+
114
+ `/model` lists every registered provider, with Command Code's live catalog and its plan/deal/context annotations. The `/commandcode` usage dashboard works there too.
115
+
116
+ **Entering the key.** The TUI has no web Models page, so the plugin declares its own page in the TUI settings screen — **`/settings` → Command Code** — with the API key, the API base, the out-of-plan model filter, the active account, and the command language. The key field is write-only: it shows whether a key is configured and writes what you type to the credential store, never to a settings document. The same page is the only place a TUI-only user needs to visit.
117
+
118
+ **Picking models.** That page lists the whole catalog as checkboxes, grouped by plan tier (Go → GOAT → Pro → Provider) with the free models first, so nothing has to be typed from memory. Everything starts checked, because an unset allowlist means "show every model" — uncheck the ones you do not want in the picker. The choice is stored as per-model overrides next to the `visibleModels` list the web page edits, so both surfaces can be used interchangeably and a plain `visibleModels` written by hand still works.
119
+
120
+ Alternatively, set the key outside the TUI — any of these work, in this order of precedence:
121
+
122
+ ```sh
123
+ export COMMANDCODE_API_KEY="user_..." # launching environment
124
+ cmd login # writes ~/.commandcode/auth.json
125
+ ```
126
+
127
+ **Making Command Code the default.** dsh-TUI pins its own agent route, and its `agent-default-model` setting does not override it. To start every session on Command Code, override the `agent-loop` row in your profile patch (`$DSH_HOME/profiles/dsh-tui/cordis.patch.yml`):
128
+
129
+ ```yaml
130
+ - id: agent-loop
131
+ inject: [tuiStartup]
132
+ config:
133
+ agents:
134
+ - id: main
135
+ provider: commandcode
136
+ model: deepseek/deepseek-v4.1-flash
137
+ reasoningEffort: max
138
+ cwd: !!js process.cwd()
139
+ ```
140
+
141
+ **Engine version.** The plugin needs a dsh engine that exports `ToolCallId` from `@deepseek-ai/dsh-llm` — **dsh 0.1.2-alpha.3 or later**. That includes the engine dsh-TUI recommends (0.1.2-rc.1) and every later release, but *not* the oldest engines its peer range nominally allows: on dsh 0.1.0-rc.6 or 0.1.1-rc.2 the plugin's module import fails and the TUI will not start. Upgrade the engine, or use the web profile.
142
+
81
143
  ## Usage dashboard
82
144
 
83
145
  The plugin registers a `/commandcode` slash command showing per-account usage:
package/README.zh-CN.md CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  - **插件包**:一条 `dsh plugin add` 命令安装到任意 dsh 配置,注册 `commandcode` provider 路由,带实时模型目录。
20
20
  - **专属设置页**:API key 输入、连接参数、实时「账户用量」卡片和「隐藏套餐外模型」开关。
21
+ - **终端界面同样可用**:同一次安装即可服务 [dsh-TUI](#终端界面dsh-tui) 配置,终端里有自己的 **`/settings` → Command Code** 页面来填 key 和调模型。
21
22
  - **Models 页快捷卡片**:**设置 → Models → Command Code** 卡片内直接显示 key 状态、粘贴输入框和登录按钮。
22
23
  - **浏览器内登录获取 key**:设置页一键发起官方授权(与 `cmd login` 同一流程),完成后密钥自动写入本机凭据服务,无需手动创建或粘贴;不可用时随时退回手动粘贴。
23
24
  - **多账户轮换**:一个账户用量打满后,请求自动切换到下一个账户。详见[多账户轮换](#多账户轮换)。
@@ -44,7 +45,15 @@
44
45
  dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.9.1
45
46
  ```
46
47
 
47
- > `latest` tag 现在始终指向当前 0.1.2 线的插件版本,普通 `@latest` 安装即可获得 dsh 0.1.2+ 的最新稳定版。旧 0.5.0 时代的 Harness 用户必须显式钉住 `@0.9.1`。
48
+ > `latest` tag 指向当前 0.1.2 线的插件版本。旧 0.5.0 时代的 Harness 用户必须显式钉住 `@0.9.1`。
49
+
50
+ **pnpm 11 会拦下刚发布的新版本。** 它的 `minimumReleaseAge` 默认为 1440 分钟,发布不足一天的版本会被跳过,`@latest` 解析到**上一个**版本 —— 而且是静默的,命令照样以成功退出。想装 24 小时内发布的版本,必须写精确版本号:
51
+
52
+ ```sh
53
+ dsh plugin --profile web add @mars-sea/dsh-commandcode-provider@0.10.5
54
+ ```
55
+
56
+ 这一点对每个 profile 都成立,包括下面的终端界面。
48
57
 
49
58
  插件可直接在 pnpm 10 的全新插件市场 generation 中安装。不要另行添加 `@deepseek-ai/dsh-invariants` dependency;插件已将其声明为 Host peer,Harness 包仍由当前 dsh profile 统一管理。
50
59
 
@@ -57,6 +66,14 @@ dsh plugin --profile web update @mars-sea/dsh-commandcode-provider@latest #
57
66
  dsh plugin --profile web update @mars-sea/dsh-commandcode-provider@0.9.1 # 更早的 dsh(0.5.0 线,不再维护)
58
67
  ```
59
68
 
69
+ 每个 profile 各自更新 —— 终端界面有独立的插件列表(见下文):
70
+
71
+ ```sh
72
+ dsh plugin --profile dsh-tui update @mars-sea/dsh-commandcode-provider@0.10.5
73
+ ```
74
+
75
+ 要更新到发布不足 24 小时的版本,和上面的安装一样写精确版本号;pnpm 11 的年龄门禁会把 `@latest` 解析成上一个版本。
76
+
60
77
  然后重启 Web 应用。
61
78
 
62
79
  ## 获取 API key
@@ -76,6 +93,51 @@ cmd login # macOS/Linux;Windows 原生版:cmdc login
76
93
 
77
94
  重启后,在 **设置 → Command Code** 填入 API key 并保存;**设置 → Models** 出现 **Command Code** 卡片,模型选择器在 **commandcode** 下列出实时目录。选择套餐内包含的模型发送消息即可。
78
95
 
96
+ ## 终端界面(dsh-TUI)
97
+
98
+ 插件同样支持终端前端。**每个 dsh profile 有独立的插件列表**,所以上面那条 Web 安装命令不会装到终端里 —— 还要把插件装进 `dsh-tui` profile:
99
+
100
+ ```sh
101
+ dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider@0.10.5
102
+ ```
103
+
104
+ 这里请写精确版本号。新版本发布后的 24 小时内,只写包名(或 `@latest`)会被静默解析到上一个版本 —— 安装命令照样成功,但 profile 里拿到的是旧版本,结果就是全新的终端安装里既没有 **`/settings` → Command Code** 页面,也看不到任何 `commandcode` 模型。
105
+
106
+ 之后在模型选择器里选,或者直接指定:
107
+
108
+ ```text
109
+ /model commandcode/deepseek/deepseek-v4.1-flash
110
+ ```
111
+
112
+ `/model` 会列出所有已注册的 provider,Command Code 的实时目录连同套餐/优惠/上下文标注一起出现,`/commandcode` 用量面板在终端里同样可用。
113
+
114
+ **填写 API key。** 终端没有网页版 Models 页面,所以插件会在终端设置页里声明自己的页面 —— **`/settings` → Command Code** —— 包含 API key、API 地址、隐藏套餐外模型、当前账号和命令语言。key 字段是只写的:它只显示"是否已配置",输入的内容写进凭据库,不会写进任何 settings 文档。只用终端的用户只需要访问这一个页面。
115
+
116
+ **选择模型。** 同一页面把整个模型目录列成勾选框,按套餐档位分组(Go → GOAT → Pro → Provider),免费模型排在最前,不需要手打任何模型 id。默认全部勾选——未设置白名单就等于"显示全部模型";把不想在选择器里看到的取消勾选即可。选择会以「单模型覆盖」的形式保存在网页端编辑的 `visibleModels` 列表旁边,两个界面可以混用,手写的 `visibleModels` 也照常生效。
117
+
118
+ 也可以在终端外配置 key,以下三种方式按优先级生效:
119
+
120
+ ```sh
121
+ export COMMANDCODE_API_KEY="user_..." # 启动环境变量
122
+ cmd login # 写入 ~/.commandcode/auth.json
123
+ ```
124
+
125
+ **把 Command Code 设为默认模型。** dsh-TUI 写死了自己的 agent 路由,它的 `agent-default-model` 设置不会覆盖它。要让每个会话默认走 Command Code,请在自己的 profile patch(`$DSH_HOME/profiles/dsh-tui/cordis.patch.yml`)里覆盖 `agent-loop` 行:
126
+
127
+ ```yaml
128
+ - id: agent-loop
129
+ inject: [tuiStartup]
130
+ config:
131
+ agents:
132
+ - id: main
133
+ provider: commandcode
134
+ model: deepseek/deepseek-v4.1-flash
135
+ reasoningEffort: max
136
+ cwd: !!js process.cwd()
137
+ ```
138
+
139
+ **引擎版本要求。** 插件需要导出了 `ToolCallId` 的 dsh 引擎,即 **dsh 0.1.2-alpha.3 或更高**。这覆盖了 dsh-TUI 推荐的引擎(0.1.2-rc.1)以及之后的所有版本,但不包含它 peer 范围里名义上允许的最老引擎:在 dsh 0.1.0-rc.6 / 0.1.1-rc.2 上,插件的模块导入会失败,TUI 无法启动。请升级引擎,或改用 web profile。
140
+
79
141
  ## 用量面板
80
142
 
81
143
  插件注册了 `/commandcode` 斜杠命令,显示各账户的用量状态:
package/lib/client.js CHANGED
@@ -312,6 +312,14 @@ window.__ModuleLoader__.load({
312
312
  return b.every((id) => set.has(id));
313
313
  }
314
314
  /**
315
+ * Order-sensitive content fingerprint of the stored routing rules. Stored rule
316
+ * ids are positional (`rule-<index>`), so equality of this fingerprint across a
317
+ * save is exactly the statement "no rules write landed and no row shifted".
318
+ */
319
+ function ruleFingerprint(rules) {
320
+ return JSON.stringify(rules.map((rule) => [rule.models, rule.account]));
321
+ }
322
+ /**
315
323
  * Controller bridging the `llm-commandcode` scope and the credentials domain
316
324
  * onto the page. Public API mirrors the harness's CardForm actions, so the
317
325
  * component stays thin.
@@ -351,6 +359,12 @@ window.__ModuleLoader__.load({
351
359
  * would delete the wrong row).
352
360
  */
353
361
  removedRuleIds = /* @__PURE__ */ new Map();
362
+ /**
363
+ * Fingerprint of the stored routing rules when the last `save()` started;
364
+ * `undefined` outside a save. Lets reconcile distinguish a landed rules
365
+ * write from a save that failed before reaching it.
366
+ */
367
+ rulesBeforeSave = void 0;
354
368
  /** Staged visible-model allowlist (undefined = no draft). */
355
369
  visibleModelsDraft = void 0;
356
370
  /** The catalog the model editors offer (Host-side). */
@@ -646,6 +660,7 @@ window.__ModuleLoader__.load({
646
660
  }
647
661
  this.saving = true;
648
662
  this.failed = false;
663
+ this.rulesBeforeSave = ruleFingerprint(this.storedRules());
649
664
  this.publish();
650
665
  let landed = true;
651
666
  for (const run of [
@@ -682,10 +697,10 @@ window.__ModuleLoader__.load({
682
697
  }
683
698
  /**
684
699
  * Drop account staging the stored section already reflects: additions whose
685
- * ref is now stored, removals whose ref is gone, and label drafts matching
686
- * the stored label. Key drafts are kept — a landed key write is idempotent
687
- * on retry, and the draft carries the user's intent when it was the
688
- * accounts write that failed.
700
+ * ref is now stored, removals whose ref is gone, and label drafts that the
701
+ * stored label proves landed. Key drafts are kept — a landed key write is
702
+ * idempotent on retry, and the draft carries the user's intent when it was
703
+ * the accounts write that failed.
689
704
  */
690
705
  reconcileAccountStaging() {
691
706
  const stored = new Set(this.storedExtras().map((extra) => extra.ref));
@@ -693,7 +708,7 @@ window.__ModuleLoader__.load({
693
708
  for (const ref of [...this.removedRefs]) if (!stored.has(ref)) this.removedRefs.delete(ref);
694
709
  for (const [ref, text] of [...this.labelDrafts]) {
695
710
  const storedLabel = this.storedExtras().find((extra) => extra.ref === ref)?.label;
696
- if (storedLabel === void 0 || storedLabel === text.trim()) this.labelDrafts.delete(ref);
711
+ if (storedLabel !== void 0 && storedLabel === text.trim()) this.labelDrafts.delete(ref);
697
712
  }
698
713
  for (const ref of [...this.keyClears]) if (this.credentialStates.get(ref)?.configured !== true) this.keyClears.delete(ref);
699
714
  }
@@ -861,14 +876,25 @@ window.__ModuleLoader__.load({
861
876
  this.catalogFailed = true;
862
877
  }).then(() => this.publish());
863
878
  }
864
- /** The stored extra accounts from the settings section (`accounts`). */
865
- storedExtras() {
879
+ /** The raw `accounts` array of the stored section, verbatim. */
880
+ rawStoredAccounts() {
866
881
  const raw = this.scope.getSnapshot().value?.accounts;
867
882
  if (!Array.isArray(raw)) return [];
883
+ return raw.filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry));
884
+ }
885
+ /**
886
+ * The stored extra accounts from the settings section (`accounts`): the rows
887
+ * this page can address, i.e. the ones carrying a credential reference.
888
+ *
889
+ * Entries the page cannot name are deliberately NOT listed here but are also
890
+ * never dropped — `writeAccounts()` rebuilds the stored list from
891
+ * {@link rawStoredAccounts} and only rewrites the reference-carrying entries
892
+ * it manages (see the note there), so a literal-key entry stays in the
893
+ * document and simply has no row.
894
+ */
895
+ storedExtras() {
868
896
  const out = [];
869
- for (const entry of raw) {
870
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
871
- const record = entry;
897
+ for (const record of this.rawStoredAccounts()) {
872
898
  const ref = record.apiKeyEnv;
873
899
  if (typeof ref !== "string" || ref === "") continue;
874
900
  const label = record.label;
@@ -945,20 +971,54 @@ window.__ModuleLoader__.load({
945
971
  runs.push(() => this.writeAccounts());
946
972
  return runs;
947
973
  }
948
- /** Persist the staged accounts list into the settings section. */
974
+ /**
975
+ * Persist the staged accounts list into the settings section.
976
+ *
977
+ * The stored list is the base — NOT a list rebuilt from this page's rows.
978
+ * A composition-config entry may carry a literal `apiKey` (or a shape this
979
+ * page does not know), and the settings layer replaces the whole array, so a
980
+ * rebuilt list would silently delete every entry the page cannot name along
981
+ * with the literal keys of the entries it can. Entries are therefore carried
982
+ * over verbatim and only the reference-carrying rows are rewritten (label
983
+ * draft applied, staged removals dropped, staged additions appended).
984
+ */
949
985
  async writeAccounts() {
950
- const base = [...this.storedExtras().filter((extra) => !this.removedRefs.has(extra.ref)), ...this.addedAccounts];
951
- const seen = /* @__PURE__ */ new Set();
952
- const list = base.filter((extra) => !seen.has(extra.ref) && (seen.add(extra.ref), true)).map((extra) => {
953
- const draft = this.labelDrafts.get(extra.ref)?.trim();
954
- return {
955
- label: draft !== void 0 && draft !== "" ? draft : extra.label,
956
- apiKeyEnv: extra.ref
957
- };
958
- });
986
+ const removed = this.removedRefs;
987
+ const written = /* @__PURE__ */ new Set();
988
+ const list = [];
989
+ for (const entry of this.rawStoredAccounts()) {
990
+ const ref = entry.apiKeyEnv;
991
+ if (typeof ref !== "string" || ref === "") {
992
+ list.push({ ...entry });
993
+ continue;
994
+ }
995
+ if (removed.has(ref) || written.has(ref)) continue;
996
+ written.add(ref);
997
+ list.push(this.accountEntry(ref, entry));
998
+ }
999
+ for (const extra of this.addedAccounts) {
1000
+ if (written.has(extra.ref)) continue;
1001
+ written.add(extra.ref);
1002
+ list.push(this.accountEntry(extra.ref, extra));
1003
+ }
959
1004
  await this.scope.set("accounts", list);
960
- const after = this.storedExtras();
961
- return after.length === list.length && list.every((item, index) => after[index]?.ref === item.apiKeyEnv);
1005
+ const after = this.rawStoredAccounts();
1006
+ return after.length === list.length && list.every((item, index) => after[index]?.apiKeyEnv === item.apiKeyEnv);
1007
+ }
1008
+ /**
1009
+ * One written account entry: the stored/added facts plus the label draft.
1010
+ * `fallback` contributes the non-managed fields (a stored entry's literal
1011
+ * `apiKey`, or any future field) so a rewrite never strips them.
1012
+ */
1013
+ accountEntry(ref, fallback) {
1014
+ const draft = this.labelDrafts.get(ref)?.trim();
1015
+ const base = "ref" in fallback ? { label: fallback.label } : { ...fallback };
1016
+ const storedLabel = base.label;
1017
+ return {
1018
+ ...base,
1019
+ label: draft !== void 0 && draft !== "" ? draft : typeof storedLabel === "string" && storedLabel !== "" ? storedLabel : ref,
1020
+ apiKeyEnv: ref
1021
+ };
962
1022
  }
963
1023
  /** The stored routing rules from the settings section (`modelAccountRules`). */
964
1024
  storedRules() {
@@ -1034,6 +1094,7 @@ window.__ModuleLoader__.load({
1034
1094
  const stored = this.storedRules();
1035
1095
  this.addedRules = this.addedRules.filter((added) => !stored.some((rule) => sameModels(added.models, rule.models) && added.account === rule.account));
1036
1096
  for (const [id, snapshot] of [...this.removedRuleIds]) if (!stored.some((rule) => sameModels(snapshot.models, rule.models) && snapshot.account === rule.account)) this.removedRuleIds.delete(id);
1097
+ if (this.rulesBeforeSave !== void 0 && ruleFingerprint(stored) === this.rulesBeforeSave) return;
1037
1098
  this.ruleDrafts.clear();
1038
1099
  }
1039
1100
  /** The stored visible-model allowlist (`visibleModels`); empty = show all. */
@@ -1966,9 +2027,31 @@ window.__ModuleLoader__.load({
1966
2027
  function toggleModelSelection(selected, modelId) {
1967
2028
  return selected.includes(modelId) ? selected.filter((value) => value !== modelId) : [...selected, modelId];
1968
2029
  }
2030
+ /**
2031
+ * Whether the catalog is trustworthy enough to call an unlisted selection
2032
+ * "retired". FALSE while the first fetch is still in flight and after a
2033
+ * failure, because the catalog is empty then and every selected id would look
2034
+ * stale — which turns the one-click stale cleanup into a button that silently
2035
+ * empties the allowlist. A successfully loaded but empty catalog is treated as
2036
+ * untrustworthy too: an empty list is far more likely a Host problem than every
2037
+ * model being retired at once, and the explicit "show all" entry covers the
2038
+ * user who really wants to clear the list.
2039
+ */
2040
+ function catalogIsReady(readiness) {
2041
+ return readiness.catalogIds.length > 0 && !readiness.catalogFailed;
2042
+ }
2043
+ /**
2044
+ * Selected ids the loaded catalog no longer carries, in selection order.
2045
+ * Callers gate user-visible "stale" affordances on {@link catalogIsReady} —
2046
+ * the list itself is informational.
2047
+ */
2048
+ function staleModelIds(selected, readiness) {
2049
+ const catalogIds = new Set(readiness.catalogIds);
2050
+ return selected.filter((id) => !catalogIds.has(id));
2051
+ }
1969
2052
  //#endregion
1970
2053
  //#region package.json
1971
- var version = "0.10.3";
2054
+ var version = "0.10.5";
1972
2055
  var repository = {
1973
2056
  "type": "git",
1974
2057
  "url": "git+https://github.com/Mars-Sea/dsh-commandcode-provider.git"
@@ -3190,8 +3273,12 @@ window.__ModuleLoader__.load({
3190
3273
  if (key === "ruleModelCount") return t("visibleModelsCount", params);
3191
3274
  return t(key, params);
3192
3275
  };
3193
- const catalogIds = new Set(state.catalogModels.map((model) => model.id));
3194
- const staleIds = state.visibleModels.filter((id) => !catalogIds.has(id));
3276
+ const readiness = {
3277
+ catalogIds: state.catalogModels.map((model) => model.id),
3278
+ catalogFailed: state.catalogFailed
3279
+ };
3280
+ const staleIds = staleModelIds(state.visibleModels, readiness);
3281
+ const catalogReady = catalogIsReady(readiness);
3195
3282
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3196
3283
  className: "cc-card",
3197
3284
  "aria-label": t("visibleModelsTitle"),
@@ -3205,11 +3292,11 @@ window.__ModuleLoader__.load({
3205
3292
  children: t("visibleModelsTitle")
3206
3293
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3207
3294
  className: "cc-badges",
3208
- children: [staleIds.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3295
+ children: [catalogReady && staleIds.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3209
3296
  type: "button",
3210
3297
  className: "cc-reset",
3211
3298
  disabled,
3212
- onClick: () => onSelect(state.visibleModels.filter((id) => catalogIds.has(id))),
3299
+ onClick: () => onSelect(state.visibleModels.filter((id) => !staleIds.includes(id))),
3213
3300
  children: t("visibleModelsCleanStale", { count: staleIds.length })
3214
3301
  }) : null, count > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3215
3302
  type: "button",
@@ -3228,7 +3315,7 @@ window.__ModuleLoader__.load({
3228
3315
  className: "cc-invalid",
3229
3316
  children: t("rulesCatalogFailed")
3230
3317
  }) : null,
3231
- staleIds.length > 0 && !state.catalogFailed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3318
+ staleIds.length > 0 && catalogReady ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3232
3319
  className: "cc-hint",
3233
3320
  children: t("visibleModelsStaleHint", { count: staleIds.length })
3234
3321
  }) : null,