dsh-model-switch 0.4.28 → 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,26 @@
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
+
14
+ ## v0.4.29
15
+
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.
17
+
18
+ - **What broke.** Enabling the bundle produced `1 entry did not activate optional-session-title-all-prompts (@deepseek-ai/dsh-session-title-all-prompts-llm): session-title provider "session-title-first-prompt-llm" is already registered`. DSH 0.2.1's session-title service accepts exactly one registered provider, and this row already owned that slot with its mirrored first-prompt provider. The bundle's patch inserts its all-prompts row **after** this plugin's patch layer, so the static `- id: optional-session-title-all-prompts` disable this plugin ships can never reach a row that does not exist yet when that layer applies.
19
+ - **Fix: the row now takes the slot over instead of colliding (`src/session-title.ts`).** It mirrors the official row that exists in the tree — all-prompts winning whenever the experimental row is composed, first-prompt otherwise — and it watches `loader/patch-context`, which the loader runs inside the official entry before that entry's plugin `apply`. When an official naming row is about to register, this row yields the slot first (the service lets a registration be replaced once its disposer ran), so the official `register()` **succeeds** and the loader reports no failed activation; it then disables that row through the public `Entry.update({ disabled: true })` (which disposes the row's fiber and closes its registration) and claims the slot back under the row's cadence. A bounded retry absorbs the cases where the loader has not created the row's fiber yet, and an initial sweep handles the reverse composition order (an official row that activated before this row started). Only seams the loader already publishes are used: the `loader/patch-context` waterfall, `Entry.update`, and `sessionTitle.register` plus its disposer.
20
+ - **User-visible behavior.** Enabling the bundle: titles now update with every prompt **and** the configured naming model / provider-group route keeps applying (the provider id recorded with each title is `session-title-all-prompts-llm`, matching what the official row would record). Disabling the bundle: the first-prompt cadence comes back with the same route override. A Host that changes activation timing degrades to "the official provider serves naming, one explicit log line" rather than a crash or a wrong route.
21
+ - **Verification.** `pnpm run check` green (53 test files / 466 tests + pack gate). New coverage: the all-prompts row wins the mirror, the takeover yields before the official registration and reclaims the slot with the mirrored cadence, repeat patch-context events are idempotent, an already-active row is swept, and no official row keeps today's single first-prompt registration. Real published packages on `0.2.1-rc.2` (the real `dsh-session-title` service plus the real `dsh-session-title-all-prompts-llm` apply): the official row registers with **no** `already registered` error, the displaced row is disabled through `Entry.update`, the plugin owns the slot again, and the live provider is `session-title-all-prompts-llm` / `all-prompts`.
22
+ - **Residual (honest).** The takeover choreographs released loader seams rather than a dedicated "supersede this provider" API; `docs/seam-gap.md` records the missing seam and the upstream proposal (a decorate/replace registration or a multi-provider slot). The reverse direction — disabling the bundle at runtime without a profile restart — re-claims through `loader/partial-dispose` where the loader emits it, and otherwise needs the restart DSH already asks for on bundle changes.
23
+
1
24
  ## v0.4.28
2
25
 
3
26
  Two fixes for the same DSH `0.2.1-rc.2` line: re-enabling the plugin on 0.2.1 made **every tool call fail before its body ran**, and the Subagent routing adapter that 0.4.26 had to fail closed is adapted to the release's activation API.
package/PRODUCT.md CHANGED
@@ -33,4 +33,4 @@ Official `settings.section` has no icon field. The Settings nav glyph is a label
33
33
 
34
34
  ## Delivery architecture
35
35
 
36
- The package foundation and pure deep modules remain the policy/test surface. Runtime delivery disables the independent Subagent Loader row with its official name and inserts a uniquely identified replacement; the experimental auto-review row is disabled the same way and replaced by `dsh-model-switch/auto-review`, which mounts the official gate's `apply` unchanged as a plugin under a scoped `llm` shadow — the Host resolves the gate's own `inject` list (including 0.2.1's `workingDirectory`), one `registerAuto` runs, and only when the profile started with the optional auto-review bundle. Official AgentPresets stay mounted so they share the process `dsh-scope` identity. Search uses the released `ctx.web.registerSearchProvider()` seam so official tool ownership is preserved. Image uses a unique tool name and provider-owned authenticated adapters. Self-maintained Codex/Grok plugins register optional adapters through the public Model Switch service when present; standalone provider behavior is unchanged. Vision remains disabled. Replacement modules preserve the official public interfaces and fail closed when compatibility or capability validation fails.
36
+ The package foundation and pure deep modules remain the policy/test surface. Runtime delivery disables the independent Subagent Loader row with its official name and inserts a uniquely identified replacement; the experimental auto-review row is disabled the same way and replaced by `dsh-model-switch/auto-review`, which mounts the official gate's `apply` unchanged as a plugin under a scoped `llm` shadow — the Host resolves the gate's own `inject` list (including 0.2.1's `workingDirectory`), one `registerAuto` runs, and only when the profile started with the optional auto-review bundle. Session naming replaces the official `session-title-llm` row and owns the service's single provider slot; it mirrors the official cadence the profile declares — first-prompt by default, and the all-prompts cadence whenever the experimental session-titles bundle inserts its row — so enabling that bundle keeps both the per-prompt title refresh and this plugin's configurable naming route, while disabling it restores the first-prompt cadence. The takeover only uses released loader seams (the `loader/patch-context` waterfall, `Entry.update`, and `sessionTitle.register` plus its disposer) and degrades to the official provider with one explicit log line if a future Host changes activation timing. Official AgentPresets stay mounted so they share the process `dsh-scope` identity. Search uses the released `ctx.web.registerSearchProvider()` seam so official tool ownership is preserved. Image uses a unique tool name and provider-owned authenticated adapters. Self-maintained Codex/Grok plugins register optional adapters through the public Model Switch service when present; standalone provider behavior is unchanged. Vision remains disabled. Replacement modules preserve the official public interfaces and fail closed when compatibility or capability validation fails.
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
@@ -119,6 +119,11 @@ DSH 会自动为新会话起名;命名的触发节奏由 profile 的官方命
119
119
 
120
120
  路由在**每次命名请求时现读**:保存后对下一次起名立即生效、无需重启,且进行中的命名请求不会被保存打断。每次请求按顺序解析路由:该会话供应商组里的命名对,其次卡片的部署级对,其次会话自身的模型。请求经由官方命名实现发出,它会把实际使用的 provider/model 记录到该会话自己的 `session/title-llm-request` 事件里,因此持久记录与真实请求永远一致。本插件替换官方的 `session-title-llm` 行而不分叉命名逻辑(该接缝每个上下文只允许注册一个命名 provider),并镜像该行声明的节奏;profile 没有官方命名行时保持 DSH 的确定性兜底标题。
121
121
 
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
+
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
+
122
127
  ## 供应商配置组(Provider groups)
123
128
 
124
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 () => {
@@ -148,19 +148,32 @@ function selectTitleMessages(messages, firstPromptOnly) {
148
148
  if (first === void 0) throw new Error("first-prompt title provider requires one human message");
149
149
  return [first];
150
150
  }
151
+ /** The id the experimental session-titles bundle inserts for all-prompts naming. */
152
+ const ALL_PROMPTS_ROW = "optional-session-title-all-prompts";
151
153
  /**
152
154
  * Locate the official session-title provider row in the loader tree.
155
+ *
153
156
  * Disabled entries stay in the tree, so a name-matched disable still matches.
157
+ * The all-prompts row wins when it is present: that row only exists while the
158
+ * experimental session-titles bundle is composed, and its presence is exactly
159
+ * the user's choice to update titles with every prompt. The default
160
+ * first-prompt row is the fallback when that bundle is off.
154
161
  * @param loader - the host loader, when one is reachable.
155
- * @returns The official entry, or undefined when this profile has no such row.
162
+ * @returns The official entry to mirror, or undefined when this profile has no such row.
156
163
  */
157
164
  function officialTitleEntry(loader) {
158
165
  if (!loader) return void 0;
166
+ const allPrompts = TITLE_CADENCES[1];
167
+ const firstPrompt = TITLE_CADENCES[0];
168
+ let fallback;
159
169
  for (const entry of loader.entries()) {
160
- if (entry.id === OFFICIAL_TITLE_ROW) return entry;
170
+ if (entry.id === ALL_PROMPTS_ROW) return entry;
161
171
  const pkg = entry.options?.name;
162
- if (typeof pkg === "string" && TITLE_CADENCES.some((cadence) => cadence.pkg === pkg)) return entry;
172
+ if (typeof pkg === "string" && allPrompts !== void 0 && pkg === allPrompts.pkg) return entry;
173
+ if (fallback === void 0 && entry.id === OFFICIAL_TITLE_ROW) fallback = entry;
174
+ else if (fallback === void 0 && firstPrompt !== void 0 && pkg === firstPrompt.pkg) fallback = entry;
163
175
  }
176
+ return fallback;
164
177
  }
165
178
  /**
166
179
  * Copy the official row's stored config, ignoring non-object payloads.
@@ -517,6 +530,225 @@ function currentSettings(ctx) {
517
530
  return;
518
531
  }
519
532
  }
533
+ /** Resolve the naming plan for one official row, or the shipped defaults when no row exists. */
534
+ function planFor(ctx, module, entry) {
535
+ const cadence = titleCadence(entry === void 0 ? void 0 : officialTitlePackage(entry)) ?? TITLE_CADENCES[0];
536
+ if (cadence === void 0) return void 0;
537
+ const forwarded = entry === void 0 ? {} : forwardedConfig(entry);
538
+ let policy;
539
+ try {
540
+ policy = resolveNamingPolicy(module, forwarded);
541
+ } catch (cause) {
542
+ ctx.logger.error("model-switch: the official session-title row has no valid naming policy; naming keeps the deterministic fallback");
543
+ ctx.logger.error(cause);
544
+ return;
545
+ }
546
+ return {
547
+ cadence,
548
+ config: policy.config,
549
+ targets: policy.targets
550
+ };
551
+ }
552
+ /** One naming plan bound to a live route resolver. */
553
+ function providerOf(ctx, module, plan) {
554
+ return {
555
+ id: plan.cadence.id,
556
+ automatic: plan.cadence.automatic,
557
+ generate: (request) => generateTitle(ctx, module, plan, request)
558
+ };
559
+ }
560
+ /** Timing knobs for the takeover choreography (exported for tests). */
561
+ const TAKEOVER_RETRY_DELAY_MS = 10;
562
+ /** How many times the takeover re-checks after disabling the official row. */
563
+ const TAKEOVER_MAX_ATTEMPTS = 20;
564
+ /**
565
+ * The single naming-provider slot this row serves.
566
+ *
567
+ * DSH's session-title service accepts exactly one registered provider, and the
568
+ * official rows (`session-title-llm`, and the experimental row the
569
+ * `dsh-experimental-session-titles-bundle` inserts) compete for the same slot.
570
+ * This row therefore mirrors one official row and owns the slot; when another
571
+ * official row activates anyway (the experimental bundle inserts its row after
572
+ * this plugin's patch layer, so a static `disabled` cannot reach it), it yields
573
+ * the slot so that row's own registration succeeds — no activation warning —
574
+ * and then disables that row and takes the slot back with its cadence mirrored.
575
+ * Every step uses released seams: the `loader/patch-context` waterfall, the
576
+ * public `Entry.update`, and `sessionTitle.register` plus its disposer.
577
+ */
578
+ var NamingSeam = class {
579
+ ctx;
580
+ module;
581
+ disposer;
582
+ plan;
583
+ constructor(ctx, module, plan) {
584
+ this.ctx = ctx;
585
+ this.module = module;
586
+ this.plan = plan;
587
+ }
588
+ /** The cadence currently registered. */
589
+ get cadence() {
590
+ return this.plan.cadence;
591
+ }
592
+ /** Whether this row currently owns the slot. */
593
+ get claimed() {
594
+ return this.disposer !== void 0;
595
+ }
596
+ /**
597
+ * Register this row's provider with the given plan.
598
+ * @param plan - the naming plan to serve (defaults to the current one).
599
+ * @returns true when the slot is ours, false when another provider holds it.
600
+ */
601
+ claim(plan = this.plan) {
602
+ this.plan = plan;
603
+ if (this.disposer !== void 0) return true;
604
+ try {
605
+ this.disposer = this.ctx.sessionTitle.register(providerOf(this.ctx, this.module, plan));
606
+ return true;
607
+ } catch {
608
+ return false;
609
+ }
610
+ }
611
+ /** Release the slot (the service lets the next registration replace a closing one). */
612
+ yieldSlot() {
613
+ const disposer = this.disposer;
614
+ this.disposer = void 0;
615
+ if (disposer === void 0) return;
616
+ try {
617
+ disposer();
618
+ } catch (cause) {
619
+ this.ctx.logger.error("model-switch: releasing the session-title provider failed");
620
+ this.ctx.logger.error(cause);
621
+ }
622
+ }
623
+ /** Swap the served plan, re-registering only when the slot is already ours. */
624
+ retarget(plan) {
625
+ if (!this.claimed) {
626
+ this.plan = plan;
627
+ return;
628
+ }
629
+ this.yieldSlot();
630
+ this.claim(plan);
631
+ }
632
+ };
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
+ */
640
+ function isOfficialTitleRow(entry) {
641
+ if (entry === void 0) return false;
642
+ const pkg = entry.options?.name;
643
+ return typeof pkg === "string" && TITLE_CADENCES.some((cadence) => cadence.pkg === pkg);
644
+ }
645
+ /** The id one entry is known by (the loader's leaf id, not a nested path). */
646
+ function leafId(entry) {
647
+ const raw = entry.options?.id;
648
+ return typeof raw === "string" ? raw : void 0;
649
+ }
650
+ /**
651
+ * Install the takeover choreography for naming rows that activate after this one.
652
+ *
653
+ * `loader/patch-context` runs inside the official entry's own `_init`, before its
654
+ * plugin's `apply`, and it receives that entry. At that point the entry's
655
+ * `options` are final, so this row can yield the slot first — the official
656
+ * `register()` then succeeds instead of throwing the "already registered"
657
+ * activation warning — and afterwards disable that row through the public
658
+ * `Entry.update` and take the slot back with its cadence mirrored.
659
+ * @param ctx - this row's context.
660
+ * @param module - the auxiliary-LLM package.
661
+ * @param seam - the slot owner.
662
+ * @returns Nothing; the listeners are effects of this row's fiber.
663
+ */
664
+ function installTakeover(ctx, module, seam) {
665
+ /** Entries already handled, so a repeated patch-context does not loop. */
666
+ const handled = /* @__PURE__ */ new Set();
667
+ /** Entries with a reclaim cycle in flight, so two triggers cannot race. */
668
+ const reclaiming = /* @__PURE__ */ new Set();
669
+ /** After the yield, disable the official row and take the slot back. */
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
+ }
676
+ if (reclaiming.has(entry)) return;
677
+ reclaiming.add(entry);
678
+ try {
679
+ for (let attempt = 0; attempt < 20; attempt += 1) {
680
+ await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 0 : 10));
681
+ try {
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`);
684
+ } catch (cause) {
685
+ ctx.logger.info("model-switch: the displaced naming row could not be persisted as disabled; the takeover repeats next startup");
686
+ ctx.logger.info(String(cause));
687
+ }
688
+ if (seam.claim(plan)) {
689
+ ctx.logger.info(`model-switch: displaced the official naming row ${String(leafId(entry) ?? "unknown")}; its ${plan.cadence.automatic} cadence and policy are mirrored by the configurable naming route`);
690
+ return;
691
+ }
692
+ }
693
+ ctx.logger.error(`model-switch: the official naming row ${String(leafId(entry) ?? "unknown")} kept the session-title provider slot; the configurable naming route is unavailable this session`);
694
+ } finally {
695
+ reclaiming.delete(entry);
696
+ }
697
+ };
698
+ /**
699
+ * The row an event names.
700
+ *
701
+ * The loader hands the emitting entry as the event's `this`, but cordis does
702
+ * not rebind `this` for listeners added through `ctx.on`, so a listener can
703
+ * also receive the payload as its first argument. Both shapes are accepted;
704
+ * the real Host always provides one of them.
705
+ */
706
+ const entryOfEvent = (self, first) => {
707
+ const candidate = self ?? first;
708
+ return candidate !== null && typeof candidate === "object" && "options" in candidate ? candidate : void 0;
709
+ };
710
+ const listeners = [];
711
+ const listen = (event, listener) => {
712
+ const dispose = ctx.on?.(event, listener);
713
+ if (dispose !== void 0) listeners.push(dispose);
714
+ };
715
+ listen("loader/patch-context", function(first, second) {
716
+ const nextFn = typeof first === "function" ? first : second;
717
+ const entry = entryOfEvent(this, typeof first === "function" ? void 0 : first);
718
+ if (entry === void 0 || typeof nextFn !== "function" || !isOfficialTitleRow(entry) || handled.has(entry)) return nextFn === void 0 ? void 0 : nextFn();
719
+ handled.add(entry);
720
+ const plan = planFor(ctx, module, entry);
721
+ if (plan !== void 0) {
722
+ seam.yieldSlot();
723
+ reclaim(entry, plan);
724
+ }
725
+ return nextFn();
726
+ });
727
+ listen("loader/partial-dispose", function(first) {
728
+ const entry = entryOfEvent(this, first);
729
+ if (entry === void 0 || !isOfficialTitleRow(entry)) return;
730
+ handled.delete(entry);
731
+ if (seam.claimed) return;
732
+ const plan = planFor(ctx, module, officialTitleEntry(ctx.get("loader", false)));
733
+ if (plan !== void 0) seam.claim(plan);
734
+ });
735
+ const loader = ctx.get("loader", false);
736
+ if (typeof loader?.entries === "function") for (const candidate of loader.entries()) {
737
+ if (!isOfficialTitleRow(candidate)) continue;
738
+ if (candidate.disabled === true) continue;
739
+ const plan = planFor(ctx, module, candidate);
740
+ if (plan !== void 0) reclaim(candidate, plan);
741
+ }
742
+ const disposable = () => {
743
+ for (const dispose of listeners.splice(0)) dispose();
744
+ };
745
+ const effect = ctx.effect;
746
+ if (effect === void 0) return;
747
+ const registration = effect(function* () {
748
+ yield disposable;
749
+ }, "model-switch: session-title takeover");
750
+ if (typeof registration === "function") listeners.push(registration);
751
+ }
520
752
  /**
521
753
  * Mount the Model Switch session-naming row for this profile.
522
754
  *
@@ -529,11 +761,7 @@ function currentSettings(ctx) {
529
761
  */
530
762
  async function apply(ctx) {
531
763
  const entry = officialTitleEntry(ctx.get("loader", false));
532
- const cadence = titleCadence(entry === void 0 ? void 0 : officialTitlePackage(entry)) ?? TITLE_CADENCES[0];
533
- if (cadence === void 0) return;
534
764
  if (entry === void 0) ctx.logger.info("model-switch: no instantiated official session-title row to mirror; naming uses the first-prompt cadence");
535
- /** The displaced row's naming policy, or the shipped defaults when it is absent. */
536
- const forwarded = entry === void 0 ? {} : forwardedConfig(entry);
537
765
  let module;
538
766
  try {
539
767
  module = await import(__rewriteRelativeImportExtension(TITLE_LLM_PACKAGE));
@@ -542,29 +770,11 @@ async function apply(ctx) {
542
770
  ctx.logger.error(cause);
543
771
  return;
544
772
  }
545
- let policy;
546
- try {
547
- policy = resolveNamingPolicy(module, forwarded);
548
- } catch (cause) {
549
- ctx.logger.error("model-switch: the official session-title row has no valid naming policy; naming keeps the deterministic fallback");
550
- ctx.logger.error(cause);
551
- return;
552
- }
553
- const plan = {
554
- cadence,
555
- config: policy.config,
556
- targets: policy.targets
557
- };
558
- try {
559
- ctx.sessionTitle.register({
560
- id: cadence.id,
561
- automatic: cadence.automatic,
562
- generate: (request) => generateTitle(ctx, module, plan, request)
563
- });
564
- } catch (cause) {
565
- ctx.logger.error("model-switch: another session-title provider is already registered; the configurable naming route is unavailable");
566
- ctx.logger.error(cause);
567
- }
773
+ const plan = planFor(ctx, module, entry);
774
+ if (plan === void 0) return;
775
+ const seam = new NamingSeam(ctx, module, plan);
776
+ if (!seam.claim()) ctx.logger.info("model-switch: another session-title provider already owns the seam; naming follows it while it is active");
777
+ installTakeover(ctx, module, seam);
568
778
  }
569
779
  //#endregion
570
- export { TITLE_CADENCES, apply, composeTitleRoute, forwardedConfig, frameTitleInput, inject, isMaxTokensFailure, isModernTitleModule, name, normalizeTitleText, officialTitleEntry, officialTitlePackage, resolveNamingPolicy, selectTitleMessages, selectTitleReasoningEffort, shouldTryNextCandidate, splitTitlePolicy, titleCadence, titleFromOutput, titleFromResponse, titleRouteCandidates, titleSystemPrompt, widerTitleBudget };
780
+ export { TAKEOVER_MAX_ATTEMPTS, TAKEOVER_RETRY_DELAY_MS, TITLE_CADENCES, apply, composeTitleRoute, forwardedConfig, frameTitleInput, inject, isMaxTokensFailure, isModernTitleModule, isOfficialTitleRow, name, normalizeTitleText, officialTitleEntry, officialTitlePackage, resolveNamingPolicy, selectTitleMessages, selectTitleReasoningEffort, shouldTryNextCandidate, splitTitlePolicy, titleCadence, titleFromOutput, titleFromResponse, titleRouteCandidates, titleSystemPrompt, widerTitleBudget };
@@ -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>;
@@ -138,9 +138,14 @@ export declare function composeTitleRoute(config: TitleLlmPolicy, route: Capabil
138
138
  export declare function selectTitleMessages(messages: readonly SessionTitleUserMessage[], firstPromptOnly: boolean): readonly SessionTitleUserMessage[];
139
139
  /**
140
140
  * Locate the official session-title provider row in the loader tree.
141
+ *
141
142
  * Disabled entries stay in the tree, so a name-matched disable still matches.
143
+ * The all-prompts row wins when it is present: that row only exists while the
144
+ * experimental session-titles bundle is composed, and its presence is exactly
145
+ * the user's choice to update titles with every prompt. The default
146
+ * first-prompt row is the fallback when that bundle is off.
142
147
  * @param loader - the host loader, when one is reachable.
143
- * @returns The official entry, or undefined when this profile has no such row.
148
+ * @returns The official entry to mirror, or undefined when this profile has no such row.
144
149
  */
145
150
  export declare function officialTitleEntry(loader: Loader | undefined): Entry | undefined;
146
151
  /**
@@ -270,6 +275,18 @@ export declare function titleSystemPrompt(targets: TitleLengthTargets, hasCurren
270
275
  * @returns The framed user input.
271
276
  */
272
277
  export declare function frameTitleInput(messages: readonly SessionTitleUserMessage[], currentTitle: string | undefined): string;
278
+ /** Timing knobs for the takeover choreography (exported for tests). */
279
+ export declare const TAKEOVER_RETRY_DELAY_MS = 10;
280
+ /** How many times the takeover re-checks after disabling the official row. */
281
+ export declare const TAKEOVER_MAX_ATTEMPTS = 20;
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
+ */
289
+ export declare function isOfficialTitleRow(entry: Entry | undefined): boolean;
273
290
  /**
274
291
  * Mount the Model Switch session-naming row for this profile.
275
292
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.28",
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",