dsh-model-switch 0.4.29 → 0.4.30

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
@@ -1,3 +1,16 @@
1
+ ## v0.4.30
2
+
3
+ One live failure class, three fixes: after a provider-group save every tool call in the session died before its body ran with `cannot get required service "sessionProjections" in inactive context`, the settings page sometimes answered a provider-group save with the raw `settings-rejected` sentinel, and the composer's permission selector sometimes disappeared.
4
+
5
+ - **P0: every tool call failed while the Auto preset was selected (`src/auto-review.ts`).** The official `@deepseek-ai/dsh-experimental-auto-review` gate opens every `tools/pre-execute` waterfall with `permissionPresets.current(session)`. That call resolves the preset service's **own** `sessionProjections` injection, and `sessionProjections` is exactly what `permissionState()` reads; when that fiber has turned inactive the probe throws `cannot get required service "sessionProjections" in inactive context` **outside** the gate's reviewer-error wrapper, so the raw service error becomes the tool result and the body never runs. `reviewScope` now shadows `permissionPresets` next to the already-shadowed `llm` (`defensivePermissionPresets`): a failing `current()` degrades to `custom`, which makes the gate skip review and call `next()` so the tool body still runs; a failing `set()` during teardown is swallowed; a `registerAuto` conflict is reported as one log line instead of failing the gate. The degradation is logged once, not once per tool call.
6
+ - **Auto integration lifecycle (`src/auto-review.ts`).** `registerAuto` is single-registration with an **effect lifetime** — its disposer is what clears the Auto preset, and its absence is what makes `auto` disappear from `permissionPresets.names` (and a restore fail with `cannot restore preset "auto" without its active integration`). A re-applied row now disposes the previously mounted gate fiber and awaits it (`Fiber.dispose` settles after cleanup) before mounting the next gate, so a re-mount cannot hit `preset "auto" is already registered` and leave the catalog without Auto.
7
+ - **Takeover hardening (`src/session-title.ts`).** `isOfficialTitleRow` now keys on the cadence **package name** only, and `reclaim` re-checks that package before `entry.update({ disabled: true })`, logging every disable. The takeover disposes the matched entry's fiber, so an id-only match must never reach a group or another plugin's row.
8
+ - **Provider-group saves are fenced and retried (`src/client/provider-group-save.ts`, `src/client/index.tsx`).** A save now lands as one atomic `mutate(ops, revision)` under the revision it actually read. When the host refuses it (another card, another window, or the runtime's own capture write moved the fence), the save re-reads the snapshot and retries — up to three times — instead of losing the edit, and only then reports failure. The settings page shows a localized "保存被拒绝,请重试" instead of the raw `settings-rejected` sentinel.
9
+ - **Permission selector disappearing.** The official selector renders nothing while the permission catalog has no complete value or the session's permission selection is missing; both states came from the same Auto-integration and preset-service instability above, so the lifecycle fix removes the trigger. `docs/seam-gap.md` records this as official-UI behavior this plugin cannot change, plus the single-registration `registerAuto` seam.
10
+ - **Verification.** `pnpm run check` green (54 test files / 478 tests + pack gate). New coverage: the degraded probe returns `custom` and logs once, a healthy face stays transparent, a registration conflict is reported and its disposer resolves, a failing preset write during teardown is swallowed, the takeover refuses an id-only lookalike and never disables it, and the fenced save writes under the read revision, retries after one refusal, reports `settings-rejected` only after every attempt was refused, and clears the field when the last group is deleted. With the **real published packages** (`@deepseek-ai/dsh-experimental-auto-review` + `@deepseek-ai/dsh-permission-presets` from the 0.2.1 line): the pre-fix shape reproduces the exact live message through the gate's prepended listener, and the shipped shape runs the gate with the same broken preset service while the tool call proceeds.
11
+ - **Residual (honest).** The defensive shadow trades a failed reviewer for a running tool: while the preset service is broken, review is skipped with one explicit log line instead of blocking every call. The single-registration constraint still has no public replace API; the missing seam and the proposed upstream shape are recorded in `docs/seam-gap.md`.
12
+ - **A/B caveat.** The live "plugin off → tools fine" observation was confounded: the permission preset was switched from `auto` to `danger-full-access` in the same minute the plugin was disabled. The controlled matrix (plugin on/off × preset auto/full-access × plan mode on/off × fresh session/resumed session) is the confirmation step after installing this release.
13
+
1
14
  ## v0.4.29
2
15
 
3
16
  The experimental "随对话更新的标题" bundle (`dsh-experimental-session-titles-bundle`) no longer fails to enable while this plugin is active: the naming row now takes over the single provider slot the bundle's row competes for, mirrors that row's cadence, and keeps the configurable naming route working.
package/README.md CHANGED
@@ -121,6 +121,11 @@ DSH names new sessions automatically; the profile's official naming row decides
121
121
 
122
122
  The route is read through on every naming request: a saved change applies to the very next title with no restart, and a naming request already in flight is never interrupted by a save. Each request resolves its route in order — the session's provider group's naming pair, then the card's flat pair, then the session's own model. The request runs through the official naming implementation, which records the exact provider/model in the session's own `session/title-llm-request` event, so the durable record and the real request always agree. The plugin replaces the official `session-title-llm` row instead of forking the naming logic (the seam allows one registered naming provider per context) and mirrors the cadence that row declared; a profile with no official naming row keeps DSH's deterministic fallback title.
123
123
 
124
+ Since 0.4.29 the row also coexists with the experimental "titles that update with the conversation" bundle (`dsh-experimental-session-titles-bundle`, which inserts an all-prompts naming row): enabling that bundle keeps this plugin owning the single naming-provider slot, mirrors the all-prompts cadence so titles refresh with every prompt, and keeps the naming-model and provider-group routes in force (the provider id recorded per title is `session-title-all-prompts-llm`, exactly what the official row would record). Disabling the bundle restores the first-prompt cadence with the same route override. The takeover only uses released loader seams — the `loader/patch-context` waterfall, `Entry.update`, and `sessionTitle.register` plus its disposer — letting the official row register successfully first and then disabling it, so enabling the bundle no longer raises the `already registered` activation-failure toast.
125
+
126
+ Since 0.4.30 the Auto review row also survives a degraded permission service. DSH's Auto gate reads `permissionPresets.current(session)` before every tool call, and when that service has gone inactive the read throws `cannot get required service "sessionProjections" in inactive context` outside the gate's own reviewer-error handling — so every tool call died before its body ran. The row now shadows that service inside its review scope: a failed probe reports "not on Auto", the tool call runs normally, and one log line states that review was skipped. A gate re-mounted after a profile reload unloads its previous registration first, so the `auto` preset cannot disappear from the permission picker.
127
+
128
+
124
129
  ## Provider groups
125
130
 
126
131
  The "Provider groups" card stores one route group per provider — the compaction, auto-review, session-naming, and subagent routes saved together — and a group saved for provider X applies to the sessions whose own provider is X. Groups are **derived, not written**: switching a session's provider writes nothing, so two sessions on different providers can never overwrite or redirect each other's auxiliary routes.
package/README.zh.md CHANGED
@@ -121,6 +121,9 @@ DSH 会自动为新会话起名;命名的触发节奏由 profile 的官方命
121
121
 
122
122
  0.4.29 起与实验包「随对话更新的标题」(`dsh-experimental-session-titles-bundle`,插入 all-prompts 命名行)兼容:启用该实验包时,本插件接管同一个命名 provider 槽位并镜像 all-prompts 节奏,标题随对话更新,同时命名模型路由与供应商组路由继续生效(`session/title` 记录中的 provider id 为 `session-title-all-prompts-llm`,与官方行一致);禁用实验包即回到首条提示节奏与同样的路由覆写。实现只使用 loader 已发布的缝(`loader/patch-context` 瀑布、`Entry.update`、`sessionTitle.register` 及其 disposer),让官方行先成功注册、再被停用并由本行重占槽位,因此启用时不再出现 `already registered` 激活失败弹窗。
123
123
 
124
+ 0.4.30 起 Auto 审查行还能在许可服务降级时保住工具调用:DSH 的 Auto gate 在每次工具调用前读取 `permissionPresets.current(session)`,一旦该服务失活,这次读取会在 gate 自己的审查错误包装之外抛出 `cannot get required service "sessionProjections" in inactive context`,导致每个工具调用都在执行前失败。本行现在在审查作用域内遮蔽该服务:探测失败即按「不在 Auto」处理,工具调用照常执行,并只记一行日志说明审查被跳过。配置重载后重新挂载的 gate 会先卸载上一个注册,因此 `auto` 预设不会再从许可选择器里消失。
125
+
126
+
124
127
  ## 供应商配置组(Provider groups)
125
128
 
126
129
  「供应商配置组」卡片为每个供应商保存一组路由——压缩、自动审查、会话命名与子代理四条路由一起存;为供应商 X 保存的组,作用于自身供应商就是 X 的会话。组是**推导出来的,而不是写进去的**:切换某个会话的供应商不写任何东西,因此不同供应商上的多个会话并行时再也不会互相覆盖、互相改写对方的辅助路由。
@@ -123,10 +123,77 @@ function bindAutoReviewSessionCorrelation(scope, activeSession) {
123
123
  * Build the scoped context the official gate runs under.
124
124
  * @param ctx - host context whose modelSwitch settings pin the reviewer route.
125
125
  * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
126
- * @returns A child context shadowing only the llm service.
126
+ * @returns A child context shadowing the llm service and, when the permission
127
+ * preset service is reachable, that service too.
127
128
  */
128
129
  function reviewScope(ctx, activeSession = new AsyncLocalStorage()) {
129
- return ctx.extend({ llm: reviewLlm(ctx.llm, () => resolveSessionAutoReviewRoute(ctx, activeSession.getStore())) });
130
+ const permissionPresets = permissionPresetsOf(ctx);
131
+ const shadows = { llm: reviewLlm(ctx.llm, () => resolveSessionAutoReviewRoute(ctx, activeSession.getStore())) };
132
+ if (permissionPresets !== void 0) shadows.permissionPresets = defensivePermissionPresets(permissionPresets, ctx.logger);
133
+ return ctx.extend(shadows);
134
+ }
135
+ /** The preset name the service reports when no configured preset matches. */
136
+ const CUSTOM_PRESET = "custom";
137
+ /** Read the live permission-preset service without requiring an injection. */
138
+ function permissionPresetsOf(ctx) {
139
+ try {
140
+ const face = ctx.get("permissionPresets", false);
141
+ return face !== null && typeof face === "object" ? face : void 0;
142
+ } catch {
143
+ return;
144
+ }
145
+ }
146
+ /**
147
+ * Wrap the permission-preset service so the official gate's per-call probe can
148
+ * never fail a tool call.
149
+ *
150
+ * DSH's Auto gate starts every `tools/pre-execute` with
151
+ * `permissionPresets.current(session)`, and that method resolves the presets
152
+ * service's own `sessionProjections` injection. When the presets fiber has
153
+ * turned inactive the probe throws `cannot get required service
154
+ * "sessionProjections" in inactive context` outside the gate's reviewer error
155
+ * wrapper, so every tool call dies. This wrapper degrades the probe to
156
+ * `custom` (the gate then skips review and calls `next()`), reports the lost
157
+ * capability once, and keeps the tool backend alive; the registration and
158
+ * teardown paths degrade the same way instead of throwing.
159
+ *
160
+ * @param real - the host's permission-preset service.
161
+ * @param log - the row logger used for the single degradation report.
162
+ * @returns A structurally compatible preset face.
163
+ */
164
+ function defensivePermissionPresets(real, log) {
165
+ let reported = false;
166
+ const degrade = (operation, error) => {
167
+ if (reported) return;
168
+ reported = true;
169
+ log.warn(`model-switch: the permission-preset service refused ${operation} (${String(error)}); the Auto reviewer passes tool calls through instead of blocking them`);
170
+ };
171
+ return {
172
+ current(session) {
173
+ try {
174
+ return real.current(session);
175
+ } catch (error) {
176
+ degrade("current()", error);
177
+ return CUSTOM_PRESET;
178
+ }
179
+ },
180
+ set(session, preset) {
181
+ try {
182
+ return real.set(session, preset);
183
+ } catch (error) {
184
+ degrade("set()", error);
185
+ return;
186
+ }
187
+ },
188
+ registerAuto(admit) {
189
+ try {
190
+ return real.registerAuto(admit);
191
+ } catch (error) {
192
+ log.warn(`model-switch: the Auto preset integration was not registered (${String(error)}); review keeps the existing integration`);
193
+ return () => Promise.resolve();
194
+ }
195
+ }
196
+ };
130
197
  }
131
198
  /** Structural read of launcher-provided profile facts (the app-boot seam). */
132
199
  function autoReviewBundleActive(ctx) {
@@ -170,6 +237,16 @@ function autoReviewGate(gate, hooks = {}) {
170
237
  };
171
238
  }
172
239
  /**
240
+ * The gate fiber mounted by the current row instance.
241
+ *
242
+ * `registerAuto` is a single live registration owned by the gate's effect
243
+ * lifetime, and its disposer is what clears the Auto preset. A re-applied row
244
+ * must await the previous gate's unloading before the next registration runs,
245
+ * otherwise the new `registerAuto` throws `already registered` and its effect
246
+ * cleanup leaves the catalog without Auto.
247
+ */
248
+ let lastGateFiber;
249
+ /**
173
250
  * Mount the official gate under the reviewer-route scope.
174
251
  *
175
252
  * A profile that never enabled the optional bundle keeps no Auto preset and no
@@ -189,6 +266,14 @@ async function apply(ctx) {
189
266
  ctx.logger.error(cause);
190
267
  return;
191
268
  }
269
+ const previous = lastGateFiber;
270
+ lastGateFiber = void 0;
271
+ if (previous !== void 0) try {
272
+ await previous.dispose();
273
+ } catch (cause) {
274
+ ctx.logger.info("model-switch: the previous auto-review gate had already unloaded; continuing with the new one");
275
+ ctx.logger.info(String(cause));
276
+ }
192
277
  const activeSession = new AsyncLocalStorage();
193
278
  const scope = reviewScope(ctx, activeSession);
194
279
  let failure;
@@ -215,7 +300,8 @@ async function apply(ctx) {
215
300
  ctx.logger.info("model-switch: the official auto-review gate is waiting for its host services; the reviewer route override is not bound yet");
216
301
  return;
217
302
  }
303
+ lastGateFiber = fiber;
218
304
  bindAutoReviewSessionCorrelation(scope, activeSession);
219
305
  }
220
306
  //#endregion
221
- export { apply, autoReviewGate, autoReviewRouteOf, bindAutoReviewSessionCorrelation, inject, name, resolveSessionAutoReviewRoute, reviewLlm, reviewScope };
307
+ export { apply, autoReviewGate, autoReviewRouteOf, bindAutoReviewSessionCorrelation, defensivePermissionPresets, inject, name, resolveSessionAutoReviewRoute, reviewLlm, reviewScope };
package/lib/client.js CHANGED
@@ -87,109 +87,6 @@ window.__ModuleLoader__.load({
87
87
  return route !== void 0 && (route.provider ?? "").trim() !== "" && (route.model ?? "").trim() !== "";
88
88
  }
89
89
  //#endregion
90
- //#region src/provider-group.ts
91
- const ROUTE_FIELDS = Object.freeze([
92
- "compactProvider",
93
- "compactModel",
94
- "autoReviewProvider",
95
- "autoReviewModel",
96
- "subagentProvider",
97
- "subagentModel",
98
- "subagentReasoningEffort",
99
- "titleProvider",
100
- "titleModel"
101
- ]);
102
- /** Own data property, so a literal '__proto__' provider id can never touch a prototype. */
103
- function setKey(target, key, value) {
104
- Object.defineProperty(target, key, {
105
- value,
106
- enumerable: true,
107
- writable: true,
108
- configurable: true
109
- });
110
- }
111
- /** A non-blank string. Blank values behave exactly like an absent field. */
112
- function present(value) {
113
- return typeof value === "string" && value.trim() !== "";
114
- }
115
- /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
116
- function normalizeEntry(value) {
117
- if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
118
- const source = value;
119
- const group = {};
120
- for (const field of ROUTE_FIELDS) {
121
- const item = source[field];
122
- if (present(item)) group[field] = item;
123
- }
124
- return {
125
- provider: present(source.provider) ? source.provider : "",
126
- group
127
- };
128
- }
129
- /**
130
- * Strip a raw stored value to well-formed groups; never throws. Both storage
131
- * shapes are accepted: the stored entry array (keys live in each entry's
132
- * `provider` field) and a hand-written provider → routes map (keys are the
133
- * providers). All-empty entries stay: they pin a provider to every default.
134
- */
135
- function normalizeProviderGroups(raw) {
136
- const groups = {};
137
- if (Array.isArray(raw)) {
138
- for (const entry of raw) {
139
- const view = normalizeEntry(entry);
140
- if (view === void 0 || view.provider === "") continue;
141
- setKey(groups, view.provider, view.group);
142
- }
143
- return groups;
144
- }
145
- if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) {
146
- if (key.trim() === "") continue;
147
- const view = normalizeEntry(value);
148
- if (view === void 0) continue;
149
- setKey(groups, key, view.group);
150
- }
151
- return groups;
152
- }
153
- /**
154
- * The saved group for a provider.
155
- * @returns the normalized group, or undefined when the provider has no group —
156
- * which is what leaves the live routes untouched during a switch.
157
- */
158
- function groupOf(groups, provider) {
159
- if (groups === void 0 || provider.trim() === "") return void 0;
160
- if (!Object.prototype.hasOwnProperty.call(groups, provider)) return void 0;
161
- return groups[provider];
162
- }
163
- /** Storage entry for one provider's group; absent or blank route fields become ''. */
164
- function entryOf(provider, group) {
165
- return {
166
- provider,
167
- compactProvider: group.compactProvider ?? "",
168
- compactModel: group.compactModel ?? "",
169
- autoReviewProvider: group.autoReviewProvider ?? "",
170
- autoReviewModel: group.autoReviewModel ?? "",
171
- subagentProvider: group.subagentProvider ?? "",
172
- subagentModel: group.subagentModel ?? "",
173
- subagentReasoningEffort: group.subagentReasoningEffort ?? "",
174
- titleProvider: group.titleProvider ?? "",
175
- titleModel: group.titleModel ?? ""
176
- };
177
- }
178
- /**
179
- * The stored entry list with one provider's group replaced (an entry) or
180
- * removed (undefined). Every entry is re-normalized and de-duplicated so a
181
- * save can never write junk back; the input is never mutated. A blank
182
- * provider only normalizes.
183
- */
184
- function withProviderGroup(storage, provider, group) {
185
- const groups = { ...normalizeProviderGroups(storage) };
186
- if (provider.trim() !== "") {
187
- if (group === void 0) delete groups[provider];
188
- else setKey(groups, provider, group);
189
- }
190
- return Object.entries(groups).map(([key, value]) => entryOf(key, value));
191
- }
192
- //#endregion
193
90
  //#region src/client/derived-config-form.ts
194
91
  function deriveConfigForm(source, project, fields) {
195
92
  let sourceSnapshot;
@@ -348,6 +245,109 @@ window.__ModuleLoader__.load({
348
245
  }));
349
246
  }
350
247
  //#endregion
248
+ //#region src/provider-group.ts
249
+ const ROUTE_FIELDS = Object.freeze([
250
+ "compactProvider",
251
+ "compactModel",
252
+ "autoReviewProvider",
253
+ "autoReviewModel",
254
+ "subagentProvider",
255
+ "subagentModel",
256
+ "subagentReasoningEffort",
257
+ "titleProvider",
258
+ "titleModel"
259
+ ]);
260
+ /** Own data property, so a literal '__proto__' provider id can never touch a prototype. */
261
+ function setKey(target, key, value) {
262
+ Object.defineProperty(target, key, {
263
+ value,
264
+ enumerable: true,
265
+ writable: true,
266
+ configurable: true
267
+ });
268
+ }
269
+ /** A non-blank string. Blank values behave exactly like an absent field. */
270
+ function present(value) {
271
+ return typeof value === "string" && value.trim() !== "";
272
+ }
273
+ /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
274
+ function normalizeEntry(value) {
275
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
276
+ const source = value;
277
+ const group = {};
278
+ for (const field of ROUTE_FIELDS) {
279
+ const item = source[field];
280
+ if (present(item)) group[field] = item;
281
+ }
282
+ return {
283
+ provider: present(source.provider) ? source.provider : "",
284
+ group
285
+ };
286
+ }
287
+ /**
288
+ * Strip a raw stored value to well-formed groups; never throws. Both storage
289
+ * shapes are accepted: the stored entry array (keys live in each entry's
290
+ * `provider` field) and a hand-written provider → routes map (keys are the
291
+ * providers). All-empty entries stay: they pin a provider to every default.
292
+ */
293
+ function normalizeProviderGroups(raw) {
294
+ const groups = {};
295
+ if (Array.isArray(raw)) {
296
+ for (const entry of raw) {
297
+ const view = normalizeEntry(entry);
298
+ if (view === void 0 || view.provider === "") continue;
299
+ setKey(groups, view.provider, view.group);
300
+ }
301
+ return groups;
302
+ }
303
+ if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) {
304
+ if (key.trim() === "") continue;
305
+ const view = normalizeEntry(value);
306
+ if (view === void 0) continue;
307
+ setKey(groups, key, view.group);
308
+ }
309
+ return groups;
310
+ }
311
+ /**
312
+ * The saved group for a provider.
313
+ * @returns the normalized group, or undefined when the provider has no group —
314
+ * which is what leaves the live routes untouched during a switch.
315
+ */
316
+ function groupOf(groups, provider) {
317
+ if (groups === void 0 || provider.trim() === "") return void 0;
318
+ if (!Object.prototype.hasOwnProperty.call(groups, provider)) return void 0;
319
+ return groups[provider];
320
+ }
321
+ /** Storage entry for one provider's group; absent or blank route fields become ''. */
322
+ function entryOf(provider, group) {
323
+ return {
324
+ provider,
325
+ compactProvider: group.compactProvider ?? "",
326
+ compactModel: group.compactModel ?? "",
327
+ autoReviewProvider: group.autoReviewProvider ?? "",
328
+ autoReviewModel: group.autoReviewModel ?? "",
329
+ subagentProvider: group.subagentProvider ?? "",
330
+ subagentModel: group.subagentModel ?? "",
331
+ subagentReasoningEffort: group.subagentReasoningEffort ?? "",
332
+ titleProvider: group.titleProvider ?? "",
333
+ titleModel: group.titleModel ?? ""
334
+ };
335
+ }
336
+ /**
337
+ * The stored entry list with one provider's group replaced (an entry) or
338
+ * removed (undefined). Every entry is re-normalized and de-duplicated so a
339
+ * save can never write junk back; the input is never mutated. A blank
340
+ * provider only normalizes.
341
+ */
342
+ function withProviderGroup(storage, provider, group) {
343
+ const groups = { ...normalizeProviderGroups(storage) };
344
+ if (provider.trim() !== "") {
345
+ if (group === void 0) delete groups[provider];
346
+ else setKey(groups, provider, group);
347
+ }
348
+ return Object.entries(groups).map(([key, value]) => entryOf(key, value));
349
+ }
350
+ //#endregion
351
351
  //#region src/client/plugin-rpc.ts
352
352
  function callPluginRpc(rpc, method, endpoint, payload, signal) {
353
353
  return rpc.call("/api", method, {
@@ -1233,9 +1233,10 @@ window.__ModuleLoader__.load({
1233
1233
  });
1234
1234
  return true;
1235
1235
  } catch (error) {
1236
+ const raw = error instanceof Error ? error.message : String(error);
1236
1237
  setMessage({
1237
1238
  route,
1238
- text: error instanceof Error ? error.message : props.t("requestFailed")
1239
+ text: raw === "settings-rejected" ? props.t("saveRejected") : raw
1239
1240
  });
1240
1241
  return false;
1241
1242
  } finally {
@@ -2300,6 +2301,7 @@ window.__ModuleLoader__.load({
2300
2301
  providerGroupPick: "选择供应商以编辑其配置组。",
2301
2302
  providerGroupDelete: "删除该组",
2302
2303
  saveTimeout: "保存超时,请重试",
2304
+ saveRejected: "保存被拒绝,请重试",
2303
2305
  "reason.central-subagent-routing": "Alpha.4 没有全局子代理启动路由接口。",
2304
2306
  "reason.packaged-preset-roots": "Alpha.4 不支持插件提供额外 preset root。",
2305
2307
  "reason.tool-owner-suppression": "Alpha.4 没有工具所有者或来源抑制接口。",
@@ -2357,6 +2359,7 @@ window.__ModuleLoader__.load({
2357
2359
  providerGroupPick: "Pick a provider to edit its group.",
2358
2360
  providerGroupDelete: "Delete group",
2359
2361
  saveTimeout: "The save timed out; try again",
2362
+ saveRejected: "The save was rejected; try again",
2360
2363
  "reason.central-subagent-routing": "Alpha.4 exposes no global Subagent start-routing seam.",
2361
2364
  "reason.packaged-preset-roots": "Alpha.4 exposes no plugin-owned preset root.",
2362
2365
  "reason.tool-owner-suppression": "Alpha.4 exposes no tool owner or provenance suppression seam.",
@@ -4722,6 +4725,40 @@ window.__ModuleLoader__.load({
4722
4725
  }, PlanReviewCard));
4723
4726
  });
4724
4727
  }
4728
+ /**
4729
+ * Save one provider group through the revision fence, retrying a refusal.
4730
+ *
4731
+ * Every attempt re-reads the form snapshot: another card, another window, or
4732
+ * the runtime's own capture write can move the fence between the read and the
4733
+ * write, and a refused write must cost one retry instead of a lost save. The
4734
+ * whole update therefore lands as one atomic `set`/`unset` op under the
4735
+ * revision that was actually read.
4736
+ * @param form - the settings form the provider groups live in.
4737
+ * @param provider - provider key whose group is being edited.
4738
+ * @param group - replacement group, or undefined to drop the provider's group.
4739
+ * @returns nothing; rejects with `settings-rejected` after every attempt is refused.
4740
+ */
4741
+ async function saveProviderGroupFenced(form, provider, group) {
4742
+ for (let attempt = 0; attempt < 3; attempt += 1) {
4743
+ const snapshot = form.getSnapshot();
4744
+ const current = snapshot.value?.providerGroups;
4745
+ const next = withProviderGroup(current, provider, group);
4746
+ if (next.length === 0) {
4747
+ if (current === void 0) return;
4748
+ if (await form.mutate([{
4749
+ op: "unset",
4750
+ path: ["providerGroups"]
4751
+ }], snapshot.revision)) return;
4752
+ continue;
4753
+ }
4754
+ if (await form.mutate([{
4755
+ op: "set",
4756
+ path: ["providerGroups"],
4757
+ value: next
4758
+ }], snapshot.revision)) return;
4759
+ }
4760
+ throw new Error("settings-rejected");
4761
+ }
4725
4762
  //#endregion
4726
4763
  //#region src/client/nav-icon.ts
4727
4764
  /** Official settings.section has no icon field. Swap the default gear for a model-switch glyph. */
@@ -4853,14 +4890,7 @@ window.__ModuleLoader__.load({
4853
4890
  return (route === "search" ? search : route === "image" ? image : route === "compaction" ? compaction : route === "title" ? sessionTitle : autoReview).mutate(ops);
4854
4891
  },
4855
4892
  saveProviderGroup: async (provider, group) => {
4856
- const current = owned.getSnapshot().value?.providerGroups;
4857
- const next = withProviderGroup(current, provider, group);
4858
- if (next.length === 0) {
4859
- if (current === void 0) return;
4860
- if (!await owned.unset("providerGroups")) throw new Error("settings-rejected");
4861
- return;
4862
- }
4863
- if (!await owned.set("providerGroups", next)) throw new Error("settings-rejected");
4893
+ await saveProviderGroupFenced(owned, provider, group);
4864
4894
  },
4865
4895
  providerRoleOf,
4866
4896
  loadCatalog: async () => {
@@ -630,10 +630,15 @@ var NamingSeam = class {
630
630
  this.claim(plan);
631
631
  }
632
632
  };
633
- /** Whether one loader entry declares an official naming provider this row supersedes. */
633
+ /**
634
+ * Whether one loader entry declares an official naming provider this row supersedes.
635
+ *
636
+ * The package name is the authoritative identity: the takeover disables the
637
+ * matched entry, which disposes its fiber, so an id-only match could reach a
638
+ * group or an unrelated plugin's row with a recycled id.
639
+ */
634
640
  function isOfficialTitleRow(entry) {
635
641
  if (entry === void 0) return false;
636
- if (entry.id === OFFICIAL_TITLE_ROW || entry.id === ALL_PROMPTS_ROW) return true;
637
642
  const pkg = entry.options?.name;
638
643
  return typeof pkg === "string" && TITLE_CADENCES.some((cadence) => cadence.pkg === pkg);
639
644
  }
@@ -663,6 +668,11 @@ function installTakeover(ctx, module, seam) {
663
668
  const reclaiming = /* @__PURE__ */ new Set();
664
669
  /** After the yield, disable the official row and take the slot back. */
665
670
  const reclaim = async (entry, plan) => {
671
+ const pkg = entry.options?.name;
672
+ if (!(typeof pkg === "string" && TITLE_CADENCES.some((cadence) => cadence.pkg === pkg))) {
673
+ ctx.logger.info(`model-switch: skipped a naming-row candidate without an official cadence package (${String(leafId(entry) ?? "unknown")})`);
674
+ return;
675
+ }
666
676
  if (reclaiming.has(entry)) return;
667
677
  reclaiming.add(entry);
668
678
  try {
@@ -670,6 +680,7 @@ function installTakeover(ctx, module, seam) {
670
680
  await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 0 : 10));
671
681
  try {
672
682
  await entry.update({ disabled: true });
683
+ ctx.logger.info(`model-switch: disabled the official naming row ${String(leafId(entry) ?? "unknown")} (${pkg}) so the naming provider slot stays configurable`);
673
684
  } catch (cause) {
674
685
  ctx.logger.info("model-switch: the displaced naming row could not be persisted as disabled; the takeover repeats next startup");
675
686
  ctx.logger.info(String(cause));
@@ -100,9 +100,37 @@ export declare function bindAutoReviewSessionCorrelation(scope: Context, activeS
100
100
  * Build the scoped context the official gate runs under.
101
101
  * @param ctx - host context whose modelSwitch settings pin the reviewer route.
102
102
  * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
103
- * @returns A child context shadowing only the llm service.
103
+ * @returns A child context shadowing the llm service and, when the permission
104
+ * preset service is reachable, that service too.
104
105
  */
105
106
  export declare function reviewScope(ctx: Context, activeSession?: AsyncLocalStorage<Session | undefined>): Context;
107
+ /** The permission-preset face the official gate reads; structural so a missing package never breaks a build. */
108
+ export interface PermissionPresetsFace {
109
+ current(session: Session): string;
110
+ set(session: Session, preset: string): unknown;
111
+ registerAuto(admit: () => void): unknown;
112
+ }
113
+ /**
114
+ * Wrap the permission-preset service so the official gate's per-call probe can
115
+ * never fail a tool call.
116
+ *
117
+ * DSH's Auto gate starts every `tools/pre-execute` with
118
+ * `permissionPresets.current(session)`, and that method resolves the presets
119
+ * service's own `sessionProjections` injection. When the presets fiber has
120
+ * turned inactive the probe throws `cannot get required service
121
+ * "sessionProjections" in inactive context` outside the gate's reviewer error
122
+ * wrapper, so every tool call dies. This wrapper degrades the probe to
123
+ * `custom` (the gate then skips review and calls `next()`), reports the lost
124
+ * capability once, and keeps the tool backend alive; the registration and
125
+ * teardown paths degrade the same way instead of throwing.
126
+ *
127
+ * @param real - the host's permission-preset service.
128
+ * @param log - the row logger used for the single degradation report.
129
+ * @returns A structurally compatible preset face.
130
+ */
131
+ export declare function defensivePermissionPresets(real: PermissionPresetsFace, log: {
132
+ warn(line: string): void;
133
+ }): PermissionPresetsFace;
106
134
  /**
107
135
  * Structural view of the official gate's plugin face.
108
136
  *
@@ -48,6 +48,7 @@ export declare const zh: {
48
48
  readonly providerGroupPick: "选择供应商以编辑其配置组。";
49
49
  readonly providerGroupDelete: "删除该组";
50
50
  readonly saveTimeout: "保存超时,请重试";
51
+ readonly saveRejected: "保存被拒绝,请重试";
51
52
  readonly 'reason.central-subagent-routing': "Alpha.4 没有全局子代理启动路由接口。";
52
53
  readonly 'reason.packaged-preset-roots': "Alpha.4 不支持插件提供额外 preset root。";
53
54
  readonly 'reason.tool-owner-suppression': "Alpha.4 没有工具所有者或来源抑制接口。";
@@ -0,0 +1,28 @@
1
+ import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client';
2
+ import { type ProviderGroupConfig } from '../provider-group.js';
3
+ /** The revision-fenced settings form one provider-group save writes through. */
4
+ export interface ProviderGroupForm {
5
+ getSnapshot(): {
6
+ readonly revision: number | undefined;
7
+ readonly value: {
8
+ readonly providerGroups?: readonly ProviderGroupConfig[];
9
+ } | undefined;
10
+ };
11
+ mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<boolean>;
12
+ }
13
+ /** How many times one save re-reads and retries after a refused write. */
14
+ export declare const PROVIDER_GROUP_SAVE_ATTEMPTS = 3;
15
+ /**
16
+ * Save one provider group through the revision fence, retrying a refusal.
17
+ *
18
+ * Every attempt re-reads the form snapshot: another card, another window, or
19
+ * the runtime's own capture write can move the fence between the read and the
20
+ * write, and a refused write must cost one retry instead of a lost save. The
21
+ * whole update therefore lands as one atomic `set`/`unset` op under the
22
+ * revision that was actually read.
23
+ * @param form - the settings form the provider groups live in.
24
+ * @param provider - provider key whose group is being edited.
25
+ * @param group - replacement group, or undefined to drop the provider's group.
26
+ * @returns nothing; rejects with `settings-rejected` after every attempt is refused.
27
+ */
28
+ export declare function saveProviderGroupFenced(form: ProviderGroupForm, provider: string, group: ProviderGroupConfig | undefined): Promise<void>;
@@ -279,7 +279,13 @@ export declare function frameTitleInput(messages: readonly SessionTitleUserMessa
279
279
  export declare const TAKEOVER_RETRY_DELAY_MS = 10;
280
280
  /** How many times the takeover re-checks after disabling the official row. */
281
281
  export declare const TAKEOVER_MAX_ATTEMPTS = 20;
282
- /** Whether one loader entry declares an official naming provider this row supersedes. */
282
+ /**
283
+ * Whether one loader entry declares an official naming provider this row supersedes.
284
+ *
285
+ * The package name is the authoritative identity: the takeover disables the
286
+ * matched entry, which disposes its fiber, so an id-only match could reach a
287
+ * group or an unrelated plugin's row with a recycled id.
288
+ */
283
289
  export declare function isOfficialTitleRow(entry: Entry | undefined): boolean;
284
290
  /**
285
291
  * Mount the Model Switch session-naming row for this profile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "description": "Explicit model routing plus composer/Plan picker for DeepSeek Harness",
5
5
  "repository": {
6
6
  "type": "git",