dsh-model-switch 0.4.30 → 0.4.32

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.32
2
+
3
+ A hot install could still break every tool call: replacing the plugin (disable → remove → install → enable, **without a DSH restart**) leaves the official `auto-review` row serving while this row's takeover is still trying to displace it, and its unshielded probe fails each call. This release makes that window non-fatal and shortens it.
4
+
5
+ - **P0: a broken reviewer seam no longer fails tool calls (`src/auto-review.ts`).** `installToolCallSafetyNet` registers a `{ prepend: true }` `tools/pre-execute` listener on this row's **own context**, so it is live even when the gate could not mount — the one window the defensive `permissionPresets` shadow cannot cover, because that shadow lives in the review scope while the serving official gate resolves the real service from the profile. It catches only seam failures (`inactive context`, `cannot get required service`, `cannot get property`, `without inject`, `TRANSPORT`), returns `{ kind: 'allow' }` so the body dispatches, and reports once with the fiber-chain dump. It is **inert while this row holds the route** (`isDegraded: () => !presets.registered`), so another plugin's broken listener keeps failing closed; reviewer transport/quota errors (`RATE_LIMIT`, `PI_AI_ERROR`) keep failing closed too — "the reviewer declined" and "the reviewer could not talk" are not broken seams. The waterfall only decides whether the body runs, so allowing here never masks a tool failure.
6
+ - **The takeover budget matches a real disposal (`src/auto-review-takeover.ts`).** 20×10 ms (~200 ms) → 60×50 ms (~3 s), plus `TAKEOVER_LATE_RETRY_DELAYS_MS` = 500/1000/1500 ms: three bounded re-claims after the loop gives up, because `Entry.update({ disabled: true })` starts the displaced fiber's teardown and the loader does not await it. A disposal that unmounts a gate, restores permission presets and unwinds fibers outlasted the old budget, which is exactly the reported hot-swap window. Timers are cleared with the row.
7
+ - **Actionable failure log.** The lost-seam error now says both what happens next ("the reviewer path is shielded instead of failing tool calls") and what the human can do ("Restart DSH to converge the reviewer row — a hot install cannot always displace the official row in time").
8
+ - **Docs.** `docs/seam-gap.md` records the two remaining gaps: no public seam lets a plugin **replace a bundled capability row at runtime** (only disable-and-reclaim through the loader), and the loader cannot address a row nested in a preset's `config.plugins` — the official bundle row is top-level today, but a future nesting would need a config-level bridge like 0.4.27's preset bridge.
9
+ - **Verification.** `pnpm run check` green (55 test files / 494 tests + pack gate). New coverage: the net allows the call and reports exactly once when the seam is broken, stays inert while this row holds the route, rethrows a reviewer quota error, and classifies an inject failure apart from a denial. The late-retry schedule is bounded by the row's lifetime. **Not yet reproduced end-to-end**: the disable → remove → install → enable sequence on the lab plane — this machine cannot reach the GitHub remote, so the release itself is still pending.
10
+
11
+ ## v0.4.31
12
+
13
+ The recurring tool-call outage is closed at both ends it can be closed from: this plugin now **takes the Auto-reviewer seam over** from the official `auto-review` row (yield-then-claim, the session-title pattern), and its own gate's preset probe can no longer fail a tool call even while the preset service is broken — with a fiber-chain dump in the one degradation log so the upstream half of the failure names itself next time.
14
+
15
+ - **P0: the official `auto-review` row runs again and every tool call dies (`src/auto-review-takeover.ts`, `src/auto-review.ts`).** v0.4.30's defensive shadow protected only **this row's** gate; at 15:22:34 the plugin manager wrote `- id: auto-review / disabled: false` into the profile patch layer (it applies after every bundle layer, so this plugin's static `disabled` can never win), the official row re-activated beside this one, and its **unguarded** probe — `permissionPresets.current(session)` is the first `tools/pre-execute` step — turned every tool call into `cannot get required service "sessionProjections" in inactive context` until the plugin was disabled and the Host restarted. Two live rows are not survivable on that seam at all: `registerAuto` is single-registration, and whichever row loses it cannot keep serving. The row now displaces the official one through the loader's released seams (the same choreography as the session-title provider slot): on `loader/patch-context` it releases the Auto registration **synchronously before** `next()` — the loader runs the entry's plugin `apply` in the same turn, so an awaited yield would collide — lets the official row apply cleanly, disables it through `Entry.update({ disabled: true })`, and re-claims the registration with a bounded retry; `loader/partial-dispose` re-claims when a toggled-back-on official row goes away again, and an initial sweep covers rows composed before this one. Manual toggling of the official「自动审查」component is now a logged, self-healing no-op instead of an outage.
16
+ - **The Auto selection survives every displacement.** Disposing a gate moves live `auto` sessions to `danger-full-access` by design (the official teardown runs on both the displaced row and this row's own re-mounts), so every takeover step runs inside a preservation window that remembers the sessions on `auto` and restores them after the registration is live again.
17
+ - **P0: the preset probe can no longer fail a tool call, and the shadow is always on (`src/auto-review.ts`).** `defensivePermissionPresets` now resolves the presets service **lazily per call** (the shadow protects even while the service is still activating), degrades `current()` to `custom` so the gate skips review and runs the tool body, swallows a failing teardown `set()`, and reports the lost capability **once** — now including the exact resolution chain (`uid/state/name/inject` per fiber in the caller's chain). That dump matters because the upstream cause is real and recurring: `internal/get` walks the whole caller fiber chain and throws `… in inactive context` at the first fiber whose `inject` declares `sessionProjections` without a resolved snapshot — and **twenty-four** released rows declare it (`dsh-permission-presets` itself, `dsh-session-title`, `dsh-plan-mode`, `dsh-token-meter`, `dsh-tool-todo`, `dsh-agent-loop`, …), so any one of them mid-reload can break an unguarded probe. The upstream fix and the missing seams are recorded in `docs/seam-gap.md`.
18
+ - **`registerAuto` is brokered, not swallowed.** The gate's registration is held through `releaseForTakeover()` / `reclaimRegistration()` so the takeover can hand the single slot over and take it back **without unmounting the gate** (unmounting is what flips live sessions off Auto). A `registerAuto` conflict now fails the gate's mount loudly and the takeover retries, instead of leaving an Auto preset that silently does nothing — the behavior 0.4.30 shipped and which hid the first recurrence.
19
+ - **Honest correction to the v0.4.30 entry.** That entry's model was half right and half wrong, and the real-package harness settled it: the raw message does come from the official gate's unguarded probe (reproduced exactly), but the "half-applied effect leaks its listener" explanation is **false** — when `registerAuto` throws mid-effect, cordis cleans the already-yielded listener up (verified: zero probe calls after the failed mount). The outage needs the official gate actually **running**, which is why 0.4.30's shadow (covering only this row's gate) did not prevent it and why this release makes the official row unable to run at all.
20
+ - **Visibility.** The settings card's「自动审查」section now says that the official component is replaced by this plugin's configurable reviewer route and is re-disabled with a log line when toggled back on; `docs/seam-gap.md` gains the two missing seams (no supersede declaration for a bundled capability row + two layers of `disabled`; the caller-chain `sessionProjections` resolution), and READMEs point at the takeover behavior.
21
+ - **Verification.** `pnpm run check` green (55 test files / 490 tests + pack gate). New coverage: strict package identity (an id-only lookalike is never disabled), the displacement claims the registration around the window that preserves Auto sessions, the reclaim retries until the displaced registration releases and reports the lost seam otherwise, the patch-context yield is **synchronous before** the loader continues, repeated events join one reclaim per entry, the partial-dispose path re-claims, the sweep catches earlier-composed rows, and the lazy shadow degrades while the service is unresolvable and brokers yield/reclaim with the same admit callback. With the **real published packages** (official `auto-review` gate + cordis): the pre-fix shape's half-applied effect leaves **no** leaked listener (hypothesis disproven), the yield-then-claim shape lets the official row apply cleanly and its failure still fail-closes as a clean deny, and the shipped shadowed gate returns `allow` for a tool call with a broken preset service.
22
+ - **Residual (honest).** If the displaced registration never releases within the bounded retry, the takeover reports the lost seam and the official row keeps serving (unguarded) until the next loader cycle — no silent half-state, but also no reviewer route override. The upstream `sessionProjections` chain break can still fail **other** rows' own reads (anything else that injects it); this plugin cannot defend those, and the fiber-chain dump is the evidence-gathering step for the upstream report. Toggling the official component remains visible in the plugin manager as the user set it — the takeover's in-memory disable is re-applied at every startup rather than written over the user's patch.
23
+
1
24
  ## v0.4.30
2
25
 
3
26
  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.
package/README.md CHANGED
@@ -92,7 +92,9 @@ The card body shows only the Plan's first-line title and first-paragraph summary
92
92
 
93
93
  ## Auto review model
94
94
 
95
- The official `@deepseek-ai/dsh-experimental-auto-review` (the per-call reviewer for the Auto permission preset) hardwires its review requests onto the session's own route. This plugin takes that row over through a `cordis.patch.yml` row replacement: the official row is disabled and `dsh-model-switch/auto-review` mounts the **same official `apply`** under a scoped child context whose `llm.stream` `provider/model` are rewritten to the reviewer route resolved for that session (provider group → flat card → session model). Session correlation rides a `{ prepend: true }` `tools/pre-execute` listener registered on the review scope immediately after `official.apply(scope)`, binding `exec.agent.session` in an `AsyncLocalStorage` scope around `next()` so parallel sessions on different providers never interfere and mid-session provider switches take effect on the very next review. The "Auto review model" card defaults to off = reviews follow the session model; turning it on pins the catalog head as an editable starting point, and saved changes apply to the very next review. When the optional auto-review bundle never started with the profile the row stays unmounted and the Auto preset keeps its official off state; an official row already active elsewhere is reported explicitly instead of producing a second `registerAuto`.
95
+ The official `@deepseek-ai/dsh-experimental-auto-review` (the per-call reviewer for the Auto permission preset) hardwires its review requests onto the session's own route. This plugin takes that row over through a `cordis.patch.yml` row replacement: the official row is disabled and `dsh-model-switch/auto-review` mounts the **same official `apply`** under a scoped child context whose `llm.stream` `provider/model` are rewritten to the reviewer route resolved for that session (provider group → flat card → session model). Session correlation rides a `{ prepend: true }` `tools/pre-execute` listener registered on the review scope immediately after `official.apply(scope)`, binding `exec.agent.session` in an `AsyncLocalStorage` scope around `next()` so parallel sessions on different providers never interfere and mid-session provider switches take effect on the very next review. The "Auto review model" card defaults to off = reviews follow the session model; turning it on pins the catalog head as an editable starting point, and saved changes apply to the very next review. When the optional auto-review bundle never started with the profile the row stays unmounted and the Auto preset keeps its official off state.
96
+
97
+ Since 0.4.31 the row also owns the seam at runtime, not just in the patch file. The plugin manager writes its component toggles into a layer that applies after every bundle layer, so a manual re-enable of the official「自动审查」component used to bring the official, unguarded gate back — and, because `permissionPresets.registerAuto` is single-registration, two live rows on that seam eventually killed every tool call. The row now takes the seam over the way the session-title slot is taken over: when the official row is about to apply it releases the Auto registration **synchronously first** (so the official row applies cleanly instead of colliding), disables the official row through the public `Entry.update`, and re-claims the registration with a bounded retry — preserving live sessions' Auto selection across the displacement. Toggling the official component back on is therefore a logged, self-healing no-op; the settings card's「自动审查」section says so too.
96
98
 
97
99
  ## Compaction model
98
100
 
@@ -123,7 +125,7 @@ The route is read through on every naming request: a saved change applies to the
123
125
 
124
126
  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
127
 
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.
128
+ 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 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. Since 0.4.31 the shadow is **always on** and resolves the service lazily per call (so it protects even while the service is still activating), and its one degradation line carries the exact fiber chain (`uid/state/name/inject` per fiber) that failed resolution — because the upstream cause is a loader lifecycle window: `internal/get` walks the whole caller fiber chain and twenty-four released rows declare `sessionProjections`, so any one of them mid-reload can break an unguarded probe (recorded in `docs/seam-gap.md` with the upstream proposal). The Auto registration itself is brokered — handed over and reclaimed around displacements without unmounting the gate, since unmounting is what flips live `auto` sessions to `danger-full-access` — and a gate re-mounted after a profile reload unloads its previous registration first, so the `auto` preset cannot disappear from the permission picker.
127
129
 
128
130
 
129
131
  ## Provider groups
@@ -181,7 +183,7 @@ Release-aligned search regression: `DSH_RELEASE_ANCHOR=/path/to/official/install
181
183
 
182
184
  If `dsh-composer-picker` is installed, remove it from the profile before enabling Model Switch. Model Switch already owns the Composer Picker and Plan Review seat; two owners produce duplicate or competing UI.
183
185
 
184
- Production profiles must use a released registry version or GitHub tag rather than workspace-local dependencies. Restart the selected DSH profile after installation or route changes.
186
+ Production profiles must use a released registry version or GitHub tag rather than workspace-local dependencies. Restart the selected DSH profile after installation or route changes. **Restart once after installing, updating, enabling or disabling this plugin**: it owns Host rows and takes the official Auto-review row over at runtime (`permissionPresets.registerAuto` is a single registration slot); when a hot enable skips the restart, the official row may still be serving, so the takeover only shields the reviewer path — tools keep running, that call's review is skipped with one log line — and the seam converges on the next start.
185
187
 
186
188
  ## Compatibility
187
189
 
package/README.zh.md CHANGED
@@ -90,7 +90,9 @@ Plan Review 拥有独立于会话当前路由的执行模型草稿。**确认执
90
90
 
91
91
  ## 自动审查模型(Auto review)
92
92
 
93
- 官方 `@deepseek-ai/dsh-experimental-auto-review`(Auto 权限预设的逐调用审查)把审查请求固定在会话自身的路由上。本插件通过 `cordis.patch.yml` 的行替换接管这一行:官方行被禁用,改挂 `dsh-model-switch/auto-review`,它在作用域子上下文里运行**同一份官方 `apply`**,把 `llm.stream` 的 `provider/model` 改写为该会话解析出的审查模型(供应商配置组 → 全局审查卡片 → 会话自身模型)。在 `official.apply(scope)` 之后,本行立即在同一作用域注册 `{ prepend: true }` 的 `tools/pre-execute` 监听器(在 Cordis 瀑布流中排在官方监听器之前),用 `AsyncLocalStorage` 将 `exec.agent.session` 绑定到审查调用链上,因此不同供应商的多个会话并行时互不干扰,会话中途切换供应商也会在下一次审查立即生效。设置页「自动审查模型」默认关闭 = 审查跟随当前会话模型;打开时以目录首位模型作为可再改的固定起点,保存后对下一次审查立即生效。审查未配置官方能力包(`@deepseek-ai/dsh-experimental-auto-review` 未随 profile 启动)时本行不挂载,Auto 预设保持官方的关闭状态;官方行若已在别处注册,本行明确报错而不产生第二个 `registerAuto`。
93
+ 官方 `@deepseek-ai/dsh-experimental-auto-review`(Auto 权限预设的逐调用审查)把审查请求固定在会话自身的路由上。本插件通过 `cordis.patch.yml` 的行替换接管这一行:官方行被禁用,改挂 `dsh-model-switch/auto-review`,它在作用域子上下文里运行**同一份官方 `apply`**,把 `llm.stream` 的 `provider/model` 改写为该会话解析出的审查模型(供应商配置组 → 全局审查卡片 → 会话自身模型)。在 `official.apply(scope)` 之后,本行立即在同一作用域注册 `{ prepend: true }` 的 `tools/pre-execute` 监听器(在 Cordis 瀑布流中排在官方监听器之前),用 `AsyncLocalStorage` 将 `exec.agent.session` 绑定到审查调用链上,因此不同供应商的多个会话并行时互不干扰,会话中途切换供应商也会在下一次审查立即生效。设置页「自动审查模型」默认关闭 = 审查跟随当前会话模型;打开时以目录首位模型作为可再改的固定起点,保存后对下一次审查立即生效。审查未配置官方能力包(`@deepseek-ai/dsh-experimental-auto-review` 未随 profile 启动)时本行不挂载,Auto 预设保持官方的关闭状态。
94
+
95
+ 0.4.31 起本行还在运行时接管这条缝,而不仅靠补丁文件:插件管理器会把组件开关写进"在所有 bundle 层之后生效"的层,手动把官方「自动审查」组件重新打开曾会让无防护的官方 gate 回来——而 `permissionPresets.registerAuto` 是单注册的,同一条缝上的两行最终杀掉每个工具调用。现在本行按接管会话命名槽位的同一手法接管:官方行将要 apply 时**先同步交出** Auto 注册(让官方行干净应用而不是撞车),再通过公开的 `Entry.update` 停用官方行,并以有界重试收回注册——整个位移过程保留会话的 Auto 选择。因此在插件管理器里把官方组件再打开只是一次会记日志、自愈的空操作;设置页「自动审查」卡片里也写明了这一点。
94
96
 
95
97
  ## 压缩模型(Compaction model)
96
98
 
@@ -121,7 +123,7 @@ DSH 会自动为新会话起名;命名的触发节奏由 profile 的官方命
121
123
 
122
124
  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
125
 
124
- 0.4.30 起 Auto 审查行还能在许可服务降级时保住工具调用:DSH 的 Auto gate 在每次工具调用前读取 `permissionPresets.current(session)`,一旦该服务失活,这次读取会在 gate 自己的审查错误包装之外抛出 `cannot get required service "sessionProjections" in inactive context`,导致每个工具调用都在执行前失败。本行现在在审查作用域内遮蔽该服务:探测失败即按「不在 Auto」处理,工具调用照常执行,并只记一行日志说明审查被跳过。配置重载后重新挂载的 gate 会先卸载上一个注册,因此 `auto` 预设不会再从许可选择器里消失。
126
+ 0.4.30 起 Auto 审查行还能在许可服务降级时保住工具调用:DSH 的 Auto gate 在每次工具调用前读取 `permissionPresets.current(session)`,一旦该服务失活,这次读取会在 gate 自己的审查错误包装之外抛出 `cannot get required service "sessionProjections" in inactive context`,导致每个工具调用都在执行前失败。本行在审查作用域内遮蔽该服务:探测失败即按「不在 Auto」处理,工具调用照常执行,并只记一行日志说明审查被跳过。0.4.31 起该遮蔽**常开**且按调用惰性解析服务(服务尚未激活时也受保护),并把失败的解析链(每级 fiber 的 `uid/state/name/inject`)记进唯一那行降级日志——因为上游成因是 loader 生命周期窗口:`internal/get` 会沿调用方 fiber 链整链查找,而有 24 个已发布行在 `inject` 里声明了 `sessionProjections`,其中任何一行处于重载中都可能打断无防护的探测(连同上游建议一并记录在 `docs/seam-gap.md`)。Auto 注册本身改为**托管**:位移时同步交出、事后收回而不卸载 gate(卸载会把在用 `auto` 的会话翻成 `danger-full-access`);配置重载后重新挂载的 gate 会先卸载上一个注册,因此 `auto` 预设不会再从许可选择器里消失。
125
127
 
126
128
 
127
129
  ## 供应商配置组(Provider groups)
@@ -179,7 +181,7 @@ DeepSeek 薄适配器调用官方公开 `DeepSeekSearchProvider`,复用用户
179
181
 
180
182
  如果 profile 已安装 `dsh-composer-picker`,请先移除它。Model Switch 已经拥有 Composer Picker 和 Plan Review 席位;同时安装会产生重复或竞争 UI。
181
183
 
182
- 生产 profile 必须使用已发布的注册表版本或 GitHub tag,不能使用工作区本地依赖。安装或修改路由后重启对应的 DSH profile。
184
+ 生产 profile 必须使用已发布的注册表版本或 GitHub tag,不能使用工作区本地依赖。安装或修改路由后重启对应的 DSH profile。**安装、更新、启用或禁用本插件后都要重启一次**:它持有 Host 侧的行,并在运行时接管官方的自动审查行(`permissionPresets.registerAuto` 只有一个注册位);不重启直接热启用时,官方行可能仍在服务,接管只能先把审查路径屏蔽掉——工具照常运行、本次审查跳过并留一条日志——重启后才会真正收敛。
183
185
 
184
186
  ## 兼容性
185
187
 
@@ -1,5 +1,206 @@
1
1
  import { i as sessionProviderOf, r as resolveCapabilityRoute } from "./session-route-BjNPlliu.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ /**
4
+ * The official package identity — the only handle on the row this plugin
5
+ * trusts. Matching by id alone could reach a group or another plugin's row, and
6
+ * a matched row is disabled (its fiber disposed).
7
+ */
8
+ const OFFICIAL_AUTO_REVIEW_PACKAGE = "@deepseek-ai/dsh-experimental-auto-review";
9
+ /**
10
+ * Delays for the late re-claims after the attempt loop gave up (ms).
11
+ *
12
+ * `Entry.update({ disabled: true })` starts the displaced fiber's disposal but
13
+ * the loader does not await it, and a disposal that tears down a mounted gate,
14
+ * restores permission presets and unwinds fibers can take longer than the
15
+ * attempt loop's budget. These re-claims keep trying for a few more seconds so a
16
+ * hot-swapped install converges instead of needing a restart.
17
+ */
18
+ const TAKEOVER_LATE_RETRY_DELAYS_MS = [
19
+ 500,
20
+ 1e3,
21
+ 1500
22
+ ];
23
+ /** Whether one loader entry is the official Auto-review row (strict package identity). */
24
+ function isOfficialAutoReviewRow(entry) {
25
+ if (entry === void 0) return false;
26
+ const pkg = entry.options?.name;
27
+ return typeof pkg === "string" && pkg === "@deepseek-ai/dsh-experimental-auto-review";
28
+ }
29
+ /** The first composed official Auto-review row, if the profile declares one. */
30
+ function officialAutoReviewEntry(loader) {
31
+ if (loader === void 0 || typeof loader.entries !== "function") return void 0;
32
+ for (const entry of loader.entries()) if (isOfficialAutoReviewRow(entry)) return entry;
33
+ }
34
+ /** The id one entry is known by (the loader's leaf id, not a nested path). */
35
+ function leafId(entry) {
36
+ const raw = entry.options?.id;
37
+ return typeof raw === "string" ? raw : void 0;
38
+ }
39
+ /** A loader entry the loader has not created a fiber for yet. */
40
+ function alreadyDisabled(entry) {
41
+ return entry.disabled === true;
42
+ }
43
+ /**
44
+ * Displace the official Auto-review row and take the registration back.
45
+ *
46
+ * The official row is disabled through the public `Entry.update` (which starts
47
+ * its fiber's disposal — the loader does not await it, so the claim retries
48
+ * until the displaced registration actually released). The Auto selection of
49
+ * live sessions is preserved around the displacement: the official gate's
50
+ * disposal moves `auto` sessions to `danger-full-access` by design, and this
51
+ * row's registration puts them back.
52
+ *
53
+ * @param ctx - this row's context, used only for logging.
54
+ * @param entry - the official row to displace.
55
+ * @param seam - the Auto-registration seam to yield and reclaim.
56
+ * @returns a promise settling once the takeover attempt loop finished.
57
+ */
58
+ async function reclaimOfficialAutoReviewRow(ctx, entry, seam) {
59
+ const label = String(leafId(entry) ?? "auto-review");
60
+ /** One disable-then-claim step; true once this row holds the registration. */
61
+ const attempt = async () => {
62
+ try {
63
+ await entry.update({ disabled: true });
64
+ ctx.logger.info(`model-switch: disabled the official auto-review row ${label} (${OFFICIAL_AUTO_REVIEW_PACKAGE}); Model Switch's auto-review row owns the reviewer seam (configurable reviewer route)`);
65
+ } catch (cause) {
66
+ ctx.logger.info("model-switch: the displaced auto-review row could not be persisted as disabled; the takeover repeats next startup");
67
+ ctx.logger.info(String(cause));
68
+ }
69
+ return await seam.claim();
70
+ };
71
+ await seam.preserveAuto(async () => {
72
+ for (let index = 0; index < 60; index += 1) {
73
+ await new Promise((resolve) => setTimeout(resolve, index === 0 ? 0 : 50));
74
+ if (await attempt()) {
75
+ ctx.logger.info(`model-switch: displaced the official auto-review row ${label}; Auto review runs under the configurable reviewer route`);
76
+ return;
77
+ }
78
+ }
79
+ ctx.logger.error(`model-switch: the official auto-review row ${label} kept the Auto registration; the reviewer route override is unavailable this session`);
80
+ });
81
+ scheduleLateReclaims(ctx, label, seam);
82
+ }
83
+ /**
84
+ * Keep re-claiming for a few seconds after the attempt loop gave up.
85
+ *
86
+ * The displaced fiber's disposal starts on `Entry.update` but is not awaited by
87
+ * the loader, so it can outlast the attempt loop — which is exactly the hot-swap
88
+ * window where the official (unshielded) gate keeps serving. These bounded
89
+ * re-claims converge that window instead of leaving it to a restart. Timers are
90
+ * cleared when this row unloads.
91
+ *
92
+ * @param ctx - this row's context, used for logging and timer disposal.
93
+ * @param label - the official row's id, for diagnostics.
94
+ * @param seam - the Auto-registration seam to reclaim.
95
+ */
96
+ function scheduleLateReclaims(ctx, label, seam) {
97
+ let stopped = false;
98
+ const timers = [];
99
+ const stop = () => {
100
+ stopped = true;
101
+ for (const timer of timers.splice(0)) clearTimeout(timer);
102
+ };
103
+ ctx.effect(function* () {
104
+ yield stop;
105
+ }, "model-switch: auto-review late takeover retries");
106
+ const run = (index) => {
107
+ if (stopped || index >= TAKEOVER_LATE_RETRY_DELAYS_MS.length) return;
108
+ const timer = setTimeout(() => {
109
+ (async () => {
110
+ if (stopped) return;
111
+ if (await seam.claim()) {
112
+ ctx.logger.info(`model-switch: reclaimed the Auto registration late from the official auto-review row ${label}; Auto review runs under the configurable reviewer route`);
113
+ stop();
114
+ return;
115
+ }
116
+ run(index + 1);
117
+ })();
118
+ }, TAKEOVER_LATE_RETRY_DELAYS_MS[index]);
119
+ timers.push(timer);
120
+ };
121
+ run(0);
122
+ }
123
+ /**
124
+ * Install the takeover choreography for an official row that activates later.
125
+ *
126
+ * The plugin manager can re-enable the official row at any time (it writes
127
+ * `disabled: false` into the profile patch layer, which applies after every
128
+ * bundle layer). The `loader/patch-context` listener yields this row's
129
+ * registration **synchronously** before the entry's plugin `apply` runs, so the
130
+ * official row applies cleanly instead of colliding; the reclaim then disables
131
+ * it and takes the registration back.
132
+ *
133
+ * @param ctx - this row's context.
134
+ * @param seam - the Auto-registration seam to yield and reclaim.
135
+ * @returns the guarded reclaim function, so the caller's startup takeover and
136
+ * the listeners share one in-flight guard per entry.
137
+ */
138
+ function installAutoReviewTakeover(ctx, seam) {
139
+ /** Entries already handled, so a repeated patch-context does not loop. */
140
+ const handled = /* @__PURE__ */ new Set();
141
+ /** Reclaims in flight, shared so two triggers for one entry join one cycle. */
142
+ const reclaiming = /* @__PURE__ */ new Map();
143
+ /** Disable the official row and take the registration back, once per entry. */
144
+ const reclaim = (entry) => {
145
+ const running = reclaiming.get(entry);
146
+ if (running !== void 0) return running;
147
+ const run = reclaimOfficialAutoReviewRow(ctx, entry, seam).finally(() => {
148
+ reclaiming.delete(entry);
149
+ });
150
+ reclaiming.set(entry, run);
151
+ return run;
152
+ };
153
+ /**
154
+ * The row an event names.
155
+ *
156
+ * The loader hands the emitting entry as the event's `this`, but cordis does
157
+ * not rebind `this` for listeners added through `ctx.on`, so a listener can
158
+ * also receive the payload as its first argument. Both shapes are accepted;
159
+ * the real Host always provides one of them.
160
+ */
161
+ const entryOfEvent = (self, first) => {
162
+ const candidate = self ?? first;
163
+ return candidate !== null && typeof candidate === "object" && "options" in candidate ? candidate : void 0;
164
+ };
165
+ const listeners = [];
166
+ const listen = (event, listener) => {
167
+ const dispose = ctx.on?.(event, listener);
168
+ if (dispose !== void 0) listeners.push(dispose);
169
+ };
170
+ listen("loader/patch-context", function(first, second) {
171
+ const nextFn = typeof first === "function" ? first : second;
172
+ const entry = entryOfEvent(this, typeof first === "function" ? void 0 : first);
173
+ if (entry === void 0 || typeof nextFn !== "function" || !isOfficialAutoReviewRow(entry) || handled.has(entry)) return nextFn === void 0 ? void 0 : nextFn();
174
+ handled.add(entry);
175
+ seam.yieldRegistration();
176
+ reclaim(entry);
177
+ return nextFn();
178
+ });
179
+ listen("loader/partial-dispose", function(first) {
180
+ const entry = entryOfEvent(this, first);
181
+ if (entry === void 0 || !isOfficialAutoReviewRow(entry)) return;
182
+ handled.delete(entry);
183
+ seam.claim();
184
+ });
185
+ const loader = ctx.get("loader", false);
186
+ if (loader !== void 0 && typeof loader.entries === "function") for (const candidate of loader.entries()) {
187
+ if (!isOfficialAutoReviewRow(candidate)) continue;
188
+ if (alreadyDisabled(candidate)) continue;
189
+ reclaim(candidate);
190
+ }
191
+ const disposable = () => {
192
+ for (const dispose of listeners.splice(0)) dispose();
193
+ };
194
+ const effect = ctx.effect;
195
+ if (effect !== void 0) {
196
+ const registration = effect(function* () {
197
+ yield disposable;
198
+ }, "model-switch: auto-review takeover");
199
+ if (typeof registration === "function") listeners.push(registration);
200
+ }
201
+ return reclaim;
202
+ }
203
+ //#endregion
3
204
  //#region lib/types/auto-review.js
4
205
  /**
5
206
  * Model Switch-owned Auto reviewer row.
@@ -24,9 +225,14 @@ import { AsyncLocalStorage } from "node:async_hooks";
24
225
  * interference when multiple sessions review tool calls concurrently.
25
226
  *
26
227
  * The official gate stays an opt-in capability: the row mounts it only when
27
- * the profile actually started with the experimental-auto-review bundle.
28
- * `cordis.patch.yml` disables the official row (one `registerAuto` per
29
- * context), so this row is its replacement rather than a second registration.
228
+ * the profile actually started with the experimental-auto-review bundle. Two
229
+ * rows cannot share the seam — `permissionPresets.registerAuto` is a single
230
+ * registration and the official probe is unshielded — so this row also
231
+ * **takes the seam over** (see `./auto-review-takeover.ts`): a composed or
232
+ * re-enabled official row is displaced through the released loader seams, the
233
+ * registration is handed over and reclaimed around that displacement, and the
234
+ * gate this row mounts keeps the defensive `permissionPresets` shadow so a
235
+ * degraded preset service can never fail a tool call again.
30
236
  *
31
237
  * @module dsh-model-switch/auto-review
32
238
  */
@@ -41,11 +247,18 @@ const name = "model-switch-auto-review";
41
247
  * every tool call fail with `cannot get property "workingDirectory" without
42
248
  * inject`. The gate is now mounted as its own plugin (see {@link autoReviewGate}),
43
249
  * so cordis resolves the official dependency list — including future additions —
44
- * and this row declares only what it reads itself.
250
+ * and this row declares only what it reads itself (`loader` drives the
251
+ * takeover).
45
252
  */
46
- const inject = ["llm", "modelSwitch"];
253
+ const inject = [
254
+ "llm",
255
+ "modelSwitch",
256
+ "loader"
257
+ ];
47
258
  /** Optional bundle whose composition opts a profile into Auto review. */
48
259
  const AUTO_REVIEW_BUNDLE = "@deepseek-ai/dsh-experimental-auto-review";
260
+ /** The Auto preset name; `registerAuto` is what publishes it. */
261
+ const AUTO_PRESET = "auto";
49
262
  /**
50
263
  * Resolve the pinned reviewer route from live settings.
51
264
  * @param settings - Model Switch settings carrying the optional reviewer pair.
@@ -120,17 +333,67 @@ function bindAutoReviewSessionCorrelation(scope, activeSession) {
120
333
  }, "model-switch: auto-review session correlation");
121
334
  }
122
335
  /**
123
- * Build the scoped context the official gate runs under.
124
- * @param ctx - host context whose modelSwitch settings pin the reviewer route.
125
- * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
126
- * @returns A child context shadowing the llm service and, when the permission
127
- * preset service is reachable, that service too.
336
+ * Signatures of a broken reviewer seam, never of a real denial.
337
+ *
338
+ * The official gate's `tools/pre-execute` probe resolves its own
339
+ * `sessionProjections` injection before anything can catch it; when that fiber
340
+ * chain breaks it throws `cannot get required service ... in inactive context`
341
+ * (or, on older lines, `cannot get property ... without inject`) straight into
342
+ * the tool call. `TRANSPORT` covers a reviewer that cannot reach its route at
343
+ * all. A denial is a *return* (`{ kind: 'deny' }`), so it never matches.
128
344
  */
129
- function reviewScope(ctx, activeSession = new AsyncLocalStorage()) {
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);
345
+ const REVIEWER_INFRA_FAILURE_SIGNATURES = [
346
+ "inactive context",
347
+ "cannot get required service",
348
+ "cannot get property",
349
+ "without inject",
350
+ "TRANSPORT"
351
+ ];
352
+ /**
353
+ * Whether a reviewer-path throw is a broken seam rather than a real decision.
354
+ * @param cause - whatever the waterfall threw.
355
+ * @returns true when the reviewer itself could not run.
356
+ */
357
+ function isReviewerInfrastructureFailure(cause) {
358
+ const text = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
359
+ return REVIEWER_INFRA_FAILURE_SIGNATURES.some((signature) => text.includes(signature));
360
+ }
361
+ /**
362
+ * Keep a broken reviewer seam from failing tool calls.
363
+ *
364
+ * Registered on this row's own context through `{ prepend: true }`, so it sits
365
+ * ahead of the official gate's listener and stays live even when the gate could
366
+ * not be mounted (the takeover lost `registerAuto`, or the displaced row has
367
+ * not released it yet). That is the one window the defensive
368
+ * `permissionPresets` shadow cannot cover: the shadow lives in this row's review
369
+ * scope, while the serving gate resolves the real service from the profile.
370
+ *
371
+ * The waterfall only decides whether the body runs; the body dispatches after
372
+ * it returns, so allowing here never masks a tool failure. Reviewer transport
373
+ * and quota errors keep failing closed — only seam breakage is allowed through.
374
+ *
375
+ * @param ctx - this row's context (the listener lives and dies with the plugin).
376
+ * @param options - diagnostics plus the degraded-state predicate.
377
+ */
378
+ function installToolCallSafetyNet(ctx, options = {}) {
379
+ let reported = false;
380
+ const handler = async (_exec, next) => {
381
+ try {
382
+ return await next();
383
+ } catch (cause) {
384
+ if (!isReviewerInfrastructureFailure(cause)) throw cause;
385
+ if (options.isDegraded !== void 0 && !options.isDegraded()) throw cause;
386
+ if (!reported) {
387
+ reported = true;
388
+ const detail = options.fiberChain === void 0 ? "" : `; fiber chain: ${options.fiberChain()}`;
389
+ ctx.logger.warn(`model-switch: the Auto reviewer path is broken (${String(cause)}); tool calls run unreviewed instead of failing${detail}`);
390
+ }
391
+ return { kind: "allow" };
392
+ }
393
+ };
394
+ ctx.effect(function* () {
395
+ yield ctx.on("tools/pre-execute", handler, { prepend: true });
396
+ }, "model-switch: reviewer-path safety net");
134
397
  }
135
398
  /** The preset name the service reports when no configured preset matches. */
136
399
  const CUSTOM_PRESET = "custom";
@@ -144,34 +407,69 @@ function permissionPresetsOf(ctx) {
144
407
  }
145
408
  }
146
409
  /**
410
+ * A compact dump of the fiber chain a service resolution walks.
411
+ *
412
+ * `cannot get required service "sessionProjections" in inactive context` is
413
+ * thrown by the first fiber in the caller's chain that declares the service
414
+ * without a resolved snapshot; the chain names that fiber directly.
415
+ * @param ctx - the context whose chain is walked.
416
+ * @returns One `uid/state/name/inject` segment per fiber, outermost last.
417
+ */
418
+ function fiberChainOf(ctx) {
419
+ const parts = [];
420
+ let node = ctx;
421
+ for (let step = 0; step < 8; step += 1) {
422
+ const fiber = node?.fiber;
423
+ if (fiber === void 0) break;
424
+ const injects = Object.keys(fiber.inject ?? {}).join("|");
425
+ parts.push(`uid=${String(fiber.uid)} state=${String(fiber.state)} name=${String(fiber.name)} inject=[${injects}]`);
426
+ node = fiber.parent;
427
+ }
428
+ return parts.length === 0 ? "no fiber chain" : parts.join(" -> ");
429
+ }
430
+ /**
147
431
  * Wrap the permission-preset service so the official gate's per-call probe can
148
- * never fail a tool call.
432
+ * never fail a tool call, and broker its single Auto registration.
149
433
  *
150
434
  * DSH's Auto gate starts every `tools/pre-execute` with
151
435
  * `permissionPresets.current(session)`, and that method resolves the presets
152
436
  * service's own `sessionProjections` injection. When the presets fiber has
153
437
  * turned inactive the probe throws `cannot get required service
154
438
  * "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.
439
+ * wrapper, so every tool call dies. This wrapper resolves the service lazily
440
+ * (so the shadow is in place even while the service is still activating),
441
+ * degrades the probe to `custom` (the gate then skips review and calls
442
+ * `next()`), and reports the lost capability once — with the fiber chain that
443
+ * failed resolution, so the next occurrence names the inactive fiber.
159
444
  *
160
- * @param real - the host's permission-preset service.
445
+ * `registerAuto` is **brokered**: the gate yields the disposer into its own
446
+ * effect, but the takeover must be able to hand the single registration to a
447
+ * displacing row and take it back without unmounting the gate (the gate's
448
+ * disposal would move live `auto` sessions to `danger-full-access`).
449
+ *
450
+ * @param resolve - resolves the host's permission-preset service per call.
161
451
  * @param log - the row logger used for the single degradation report.
162
- * @returns A structurally compatible preset face.
452
+ * @param chain - diagnostic dump of the resolution chain; defaults to empty.
453
+ * @returns A managed preset face.
163
454
  */
164
- function defensivePermissionPresets(real, log) {
455
+ function defensivePermissionPresets(resolve, log, chain = () => "") {
165
456
  let reported = false;
457
+ let admit;
458
+ let release;
166
459
  const degrade = (operation, error) => {
167
460
  if (reported) return;
168
461
  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`);
462
+ log.warn(`model-switch: the permission-preset service refused ${operation} (${String(error)}); the Auto reviewer passes tool calls through instead of blocking them [chain: ${chain()}]`);
463
+ };
464
+ const requireService = () => {
465
+ const face = resolve();
466
+ if (face === void 0) throw new Error("permission: the permission-preset service is unavailable");
467
+ return face;
170
468
  };
171
469
  return {
172
470
  current(session) {
173
471
  try {
174
- return real.current(session);
472
+ return requireService().current(session);
175
473
  } catch (error) {
176
474
  degrade("current()", error);
177
475
  return CUSTOM_PRESET;
@@ -179,22 +477,56 @@ function defensivePermissionPresets(real, log) {
179
477
  },
180
478
  set(session, preset) {
181
479
  try {
182
- return real.set(session, preset);
480
+ return requireService().set(session, preset);
183
481
  } catch (error) {
184
482
  degrade("set()", error);
185
483
  return;
186
484
  }
187
485
  },
188
- registerAuto(admit) {
486
+ registerAuto(next) {
487
+ admit = next;
488
+ release = requireService().registerAuto(next);
489
+ return () => {
490
+ admit = void 0;
491
+ const dispose = release;
492
+ release = void 0;
493
+ return dispose?.();
494
+ };
495
+ },
496
+ releaseForTakeover() {
497
+ const dispose = release;
498
+ release = void 0;
499
+ dispose?.();
500
+ },
501
+ reclaimRegistration() {
502
+ if (release !== void 0) return true;
503
+ if (admit === void 0) return false;
189
504
  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();
505
+ release = requireService().registerAuto(admit);
506
+ return true;
507
+ } catch {
508
+ return false;
194
509
  }
510
+ },
511
+ get registered() {
512
+ return release !== void 0;
195
513
  }
196
514
  };
197
515
  }
516
+ /**
517
+ * Build the scoped context the official gate runs under.
518
+ * @param ctx - host context whose modelSwitch settings pin the reviewer route.
519
+ * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
520
+ * @param presets - optional managed preset face shared with the row's takeover.
521
+ * @returns A child context shadowing the llm service and the permission-preset
522
+ * service (always: a gate that sees the raw service can fail a tool call).
523
+ */
524
+ function reviewScope(ctx, activeSession = new AsyncLocalStorage(), presets = defensivePermissionPresets(() => permissionPresetsOf(ctx), ctx.logger, () => fiberChainOf(ctx))) {
525
+ return ctx.extend({
526
+ llm: reviewLlm(ctx.llm, () => resolveSessionAutoReviewRoute(ctx, activeSession.getStore())),
527
+ permissionPresets: presets
528
+ });
529
+ }
198
530
  /** Structural read of launcher-provided profile facts (the app-boot seam). */
199
531
  function autoReviewBundleActive(ctx) {
200
532
  let profile;
@@ -207,6 +539,15 @@ function autoReviewBundleActive(ctx) {
207
539
  const bundles = profile.startedBundles;
208
540
  return Array.isArray(bundles) && bundles.includes(AUTO_REVIEW_BUNDLE);
209
541
  }
542
+ /** Structural read of the loader service the takeover drives. */
543
+ function loaderOf(ctx) {
544
+ try {
545
+ const loader = ctx.get("loader", false);
546
+ return loader !== null && typeof loader === "object" ? loader : void 0;
547
+ } catch {
548
+ return;
549
+ }
550
+ }
210
551
  /**
211
552
  * Wrap the official gate as a plugin that declares its own dependencies.
212
553
  *
@@ -247,11 +588,14 @@ function autoReviewGate(gate, hooks = {}) {
247
588
  */
248
589
  let lastGateFiber;
249
590
  /**
250
- * Mount the official gate under the reviewer-route scope.
591
+ * Mount the official gate under the reviewer-route scope and own the seam.
251
592
  *
252
593
  * A profile that never enabled the optional bundle keeps no Auto preset and no
253
594
  * reviewer; a deployment missing the official package reports the lost
254
- * capability explicitly instead of guessing another path.
595
+ * capability explicitly instead of guessing another path. A composed (or
596
+ * plugin-manager re-enabled) official row is displaced first — see
597
+ * `./auto-review-takeover.ts` for the choreography and why the yield is
598
+ * synchronous.
255
599
  */
256
600
  async function apply(ctx) {
257
601
  if (!autoReviewBundleActive(ctx)) {
@@ -266,42 +610,109 @@ async function apply(ctx) {
266
610
  ctx.logger.error(cause);
267
611
  return;
268
612
  }
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
- }
613
+ const presets = defensivePermissionPresets(() => permissionPresetsOf(ctx), ctx.logger, () => fiberChainOf(ctx));
277
614
  const activeSession = new AsyncLocalStorage();
278
- const scope = reviewScope(ctx, activeSession);
279
- let failure;
280
- let applied = false;
281
- const fiber = scope.plugin(autoReviewGate(official, {
282
- onApplied: () => {
283
- applied = true;
284
- },
285
- onFailure: (error) => {
286
- failure = error;
287
- }
288
- }));
289
- try {
290
- await fiber;
291
- } catch (cause) {
292
- failure ??= cause;
293
- }
294
- if (failure !== void 0) {
295
- ctx.logger.error("model-switch: the official auto-review row is already active; the reviewer route override is unavailable");
296
- ctx.logger.error(failure);
297
- return;
298
- }
299
- if (!applied) {
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");
301
- return;
302
- }
303
- lastGateFiber = fiber;
304
- bindAutoReviewSessionCorrelation(scope, activeSession);
615
+ const scope = reviewScope(ctx, activeSession, presets);
616
+ installToolCallSafetyNet(ctx, {
617
+ fiberChain: () => fiberChainOf(ctx),
618
+ isDegraded: () => !presets.registered
619
+ });
620
+ let gateReady = false;
621
+ let mountInFlight;
622
+ /** Sessions currently on the Auto preset (best-effort). */
623
+ const rememberAuto = () => {
624
+ const remembered = [];
625
+ try {
626
+ const sessions = ctx.get("sessions", false);
627
+ for (const session of sessions?.list?.() ?? []) if (presets.current(session) === AUTO_PRESET) remembered.push(session);
628
+ } catch {}
629
+ return remembered;
630
+ };
631
+ const restoreAuto = (remembered) => {
632
+ for (const session of remembered) try {
633
+ presets.set(session, AUTO_PRESET);
634
+ } catch {}
635
+ };
636
+ /**
637
+ * Run one displacement step without losing the sessions' Auto selection.
638
+ *
639
+ * Disposing a gate moves live `auto` sessions to `danger-full-access` by
640
+ * design (the official teardown runs on both the displaced row and this
641
+ * row's own re-mounts); the remembered selection is restored afterwards.
642
+ */
643
+ const preserveAuto = async (action) => {
644
+ const remembered = rememberAuto();
645
+ await action();
646
+ restoreAuto(remembered);
647
+ };
648
+ /** Mount this row's shielded gate exactly once; failures dispose and report. */
649
+ const mountGate = () => {
650
+ mountInFlight ??= (async () => {
651
+ let outcome = "failed";
652
+ await preserveAuto(async () => {
653
+ const previous = lastGateFiber;
654
+ lastGateFiber = void 0;
655
+ gateReady = false;
656
+ if (previous !== void 0) try {
657
+ await previous.dispose();
658
+ } catch (cause) {
659
+ ctx.logger.info("model-switch: the previous auto-review gate had already unloaded; continuing with the new one");
660
+ ctx.logger.info(String(cause));
661
+ }
662
+ let failure;
663
+ let applied = false;
664
+ const fiber = scope.plugin(autoReviewGate(official, {
665
+ onApplied: () => {
666
+ applied = true;
667
+ bindAutoReviewSessionCorrelation(scope, activeSession);
668
+ },
669
+ onFailure: (error) => {
670
+ failure = error;
671
+ }
672
+ }));
673
+ try {
674
+ await fiber;
675
+ } catch (cause) {
676
+ failure ??= cause;
677
+ }
678
+ if (failure !== void 0) {
679
+ try {
680
+ await fiber.dispose();
681
+ } catch {}
682
+ ctx.logger.warn(`model-switch: the Auto reviewer gate did not mount (${String(failure)}); the displaced registration may still be releasing`);
683
+ outcome = "failed";
684
+ return;
685
+ }
686
+ if (!applied) {
687
+ ctx.logger.info("model-switch: the official auto-review gate is waiting for its host services; the reviewer route override is not bound yet");
688
+ outcome = "waiting";
689
+ return;
690
+ }
691
+ lastGateFiber = fiber;
692
+ gateReady = true;
693
+ outcome = "applied";
694
+ });
695
+ return outcome;
696
+ })().finally(() => {
697
+ mountInFlight = void 0;
698
+ });
699
+ return mountInFlight;
700
+ };
701
+ /** Make this row's Auto registration live; true once it is held. */
702
+ const claim = async () => {
703
+ if (presets.registered) return true;
704
+ if (gateReady) return presets.reclaimRegistration();
705
+ return await mountGate() !== "failed";
706
+ };
707
+ const reclaim = installAutoReviewTakeover(ctx, {
708
+ yieldRegistration: () => presets.releaseForTakeover(),
709
+ claim,
710
+ preserveAuto
711
+ });
712
+ const entry = officialAutoReviewEntry(loaderOf(ctx));
713
+ if (entry !== void 0 && entry.disabled !== true) await reclaim(entry);
714
+ else await claim();
715
+ if (!presets.registered) ctx.logger.error("model-switch: the Auto registration is not held by this row, so Auto review runs without the configurable reviewer route; the reviewer path is shielded instead of failing tool calls. Restart DSH to converge the reviewer row — a hot install cannot always displace the official row in time");
305
716
  }
306
717
  //#endregion
307
- export { apply, autoReviewGate, autoReviewRouteOf, bindAutoReviewSessionCorrelation, defensivePermissionPresets, inject, name, resolveSessionAutoReviewRoute, reviewLlm, reviewScope };
718
+ export { apply, autoReviewGate, autoReviewRouteOf, bindAutoReviewSessionCorrelation, defensivePermissionPresets, inject, installToolCallSafetyNet, isReviewerInfrastructureFailure, name, resolveSessionAutoReviewRoute, reviewLlm, reviewScope };
package/lib/client.js CHANGED
@@ -1800,6 +1800,10 @@ window.__ModuleLoader__.load({
1800
1800
  className: ModelSwitchSettings_module_css_default.hint,
1801
1801
  children: props.t("loading")
1802
1802
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1803
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1804
+ className: ModelSwitchSettings_module_css_default.hint,
1805
+ children: props.t("autoReviewTakeoverNote")
1806
+ }),
1803
1807
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1804
1808
  className: ModelSwitchSettings_module_css_default.hint,
1805
1809
  children: props.t("autoReviewRouteHelp")
@@ -2283,8 +2287,9 @@ window.__ModuleLoader__.load({
2283
2287
  catalogFailed: "无法加载模型目录。",
2284
2288
  autoReview: "自动审查模型",
2285
2289
  autoReviewFollow: "跟随会话模型",
2286
- autoReviewHelp: "为官方 auto-review(Auto 权限预设的逐调用审查)指定审查模型;需启用官方 auto-review 能力包。关闭时审查跟随当前会话模型。",
2290
+ autoReviewHelp: "为官方 auto-review(Auto 权限预设的逐调用审查)指定审查模型;需启用官方 auto-review 能力包。关闭时审查跟随当前会话模型。本插件启用时,官方「自动审查」组件由本插件接管。",
2287
2291
  autoReviewRouteHelp: "审查请求固定使用这里的 Provider/模型,与会话模型无关;保存后对下一次审查立即生效。",
2292
+ autoReviewTakeoverNote: "官方「自动审查」组件已由本插件接管(提供可配置审查路由);在插件管理里把它重新打开会被再次关闭,并在日志里留下一条记录。",
2288
2293
  compaction: "压缩模型",
2289
2294
  compactionFollow: "跟随会话模型",
2290
2295
  compactionHelp: "为官方压缩(/compact 与官方自动压缩)指定摘要模型;关闭(两个字段都留空)时使用官方默认,即当前会话的目标模型。",
@@ -2341,8 +2346,9 @@ window.__ModuleLoader__.load({
2341
2346
  catalogFailed: "Could not load the model catalog.",
2342
2347
  autoReview: "Auto review model",
2343
2348
  autoReviewFollow: "Follows the session model",
2344
- autoReviewHelp: "Pin the reviewer model for the official auto-review gate (per-call review under the Auto permission preset). Requires the official auto-review bundle. When off, reviews follow the session model.",
2349
+ autoReviewHelp: "Pin the reviewer model for the official auto-review gate (per-call review under the Auto permission preset). Requires the official auto-review bundle. When off, reviews follow the session model. With this plugin enabled the official auto-review component is taken over.",
2345
2350
  autoReviewRouteHelp: "Review requests use exactly this provider/model regardless of the session model; saved changes apply to the very next review.",
2351
+ autoReviewTakeoverNote: "The official auto-review component is taken over by this plugin (configurable reviewer route); toggling it back on in the plugin manager is re-disabled, with one log line.",
2346
2352
  compaction: "Compaction model",
2347
2353
  compactionFollow: "Follows the conversation model",
2348
2354
  compactionHelp: "Pick the summarization model for official compaction (/compact and official automatic compaction). When off (both fields empty), the official default applies: the conversation target model.",
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Model Switch's takeover of the official Auto-review row.
3
+ *
4
+ * `permissionPresets.registerAuto` is a single registration, and the official
5
+ * gate's `tools/pre-execute` probe is unshielded — it resolves the presets
6
+ * service's `sessionProjections` injection on every tool call and dies with
7
+ * `cannot get required service "sessionProjections" in inactive context` when
8
+ * that chain breaks. Two live rows are therefore not survivable: the row that
9
+ * loses `registerAuto` fails mid-effect (its probe listener half-applied, every
10
+ * later tool call failing on the raw probe error), and the row that wins runs
11
+ * unguarded. The plugin-manager toggle makes this reachable at runtime by
12
+ * writing `disabled: false` for the official row into the profile patch layer.
13
+ *
14
+ * This module keeps exactly one row on the seam by taking the official row out
15
+ * of the composition and letting this plugin's row claim the registration —
16
+ * the same released-seam choreography the session-title takeover uses:
17
+ * `loader/patch-context`, `Entry.update`, `loader/partial-dispose`.
18
+ *
19
+ * The yield is **synchronous** on purpose: `loader/patch-context` is a
20
+ * synchronous waterfall whose `next()` returns straight into the entry's
21
+ * `registry.plugin(...)` mount, so the official `registerAuto` runs in the same
22
+ * turn. An awaited yield would still collide; a synchronous release hands the
23
+ * registration over cleanly (the official row then applies in full and is
24
+ * disposed cleanly, never half-applied).
25
+ *
26
+ * @module dsh-model-switch/auto-review-takeover
27
+ */
28
+ import type { Context } from '@deepseek-ai/cordis';
29
+ import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader';
30
+ /** The loader row id the official Auto-review bundle declares. */
31
+ export declare const OFFICIAL_AUTO_REVIEW_ROW = "auto-review";
32
+ /**
33
+ * The official package identity — the only handle on the row this plugin
34
+ * trusts. Matching by id alone could reach a group or another plugin's row, and
35
+ * a matched row is disabled (its fiber disposed).
36
+ */
37
+ export declare const OFFICIAL_AUTO_REVIEW_PACKAGE = "@deepseek-ai/dsh-experimental-auto-review";
38
+ /** Bounded takeover attempts before the seam is reported lost. */
39
+ export declare const TAKEOVER_MAX_ATTEMPTS = 60;
40
+ /** Delay between takeover attempts; the first attempt is immediate. */
41
+ export declare const TAKEOVER_RETRY_DELAY_MS = 50;
42
+ /**
43
+ * Delays for the late re-claims after the attempt loop gave up (ms).
44
+ *
45
+ * `Entry.update({ disabled: true })` starts the displaced fiber's disposal but
46
+ * the loader does not await it, and a disposal that tears down a mounted gate,
47
+ * restores permission presets and unwinds fibers can take longer than the
48
+ * attempt loop's budget. These re-claims keep trying for a few more seconds so a
49
+ * hot-swapped install converges instead of needing a restart.
50
+ */
51
+ export declare const TAKEOVER_LATE_RETRY_DELAYS_MS: readonly [500, 1000, 1500];
52
+ /** The seam this row's gate owns; the takeover drives it around the official row. */
53
+ export interface AutoReviewSeam {
54
+ /**
55
+ * Synchronously free the Auto registration so a displacing row can register
56
+ * cleanly. Keeping the admit callback is the seam's business; claiming puts
57
+ * the registration back.
58
+ */
59
+ yieldRegistration(): void;
60
+ /** Make this row's Auto registration live; true once it is held. */
61
+ claim(): Promise<boolean>;
62
+ /** Run one takeover step without losing the sessions' Auto selection. */
63
+ preserveAuto(action: () => Promise<void>): Promise<void>;
64
+ }
65
+ /** Whether one loader entry is the official Auto-review row (strict package identity). */
66
+ export declare function isOfficialAutoReviewRow(entry: Entry | undefined): boolean;
67
+ /** The first composed official Auto-review row, if the profile declares one. */
68
+ export declare function officialAutoReviewEntry(loader: Loader | undefined): Entry | undefined;
69
+ /**
70
+ * Displace the official Auto-review row and take the registration back.
71
+ *
72
+ * The official row is disabled through the public `Entry.update` (which starts
73
+ * its fiber's disposal — the loader does not await it, so the claim retries
74
+ * until the displaced registration actually released). The Auto selection of
75
+ * live sessions is preserved around the displacement: the official gate's
76
+ * disposal moves `auto` sessions to `danger-full-access` by design, and this
77
+ * row's registration puts them back.
78
+ *
79
+ * @param ctx - this row's context, used only for logging.
80
+ * @param entry - the official row to displace.
81
+ * @param seam - the Auto-registration seam to yield and reclaim.
82
+ * @returns a promise settling once the takeover attempt loop finished.
83
+ */
84
+ export declare function reclaimOfficialAutoReviewRow(ctx: Context, entry: Entry, seam: AutoReviewSeam): Promise<void>;
85
+ /**
86
+ * Install the takeover choreography for an official row that activates later.
87
+ *
88
+ * The plugin manager can re-enable the official row at any time (it writes
89
+ * `disabled: false` into the profile patch layer, which applies after every
90
+ * bundle layer). The `loader/patch-context` listener yields this row's
91
+ * registration **synchronously** before the entry's plugin `apply` runs, so the
92
+ * official row applies cleanly instead of colliding; the reclaim then disables
93
+ * it and takes the registration back.
94
+ *
95
+ * @param ctx - this row's context.
96
+ * @param seam - the Auto-registration seam to yield and reclaim.
97
+ * @returns the guarded reclaim function, so the caller's startup takeover and
98
+ * the listeners share one in-flight guard per entry.
99
+ */
100
+ export declare function installAutoReviewTakeover(ctx: Context, seam: AutoReviewSeam): (entry: Entry) => Promise<void>;
@@ -21,9 +21,14 @@
21
21
  * interference when multiple sessions review tool calls concurrently.
22
22
  *
23
23
  * The official gate stays an opt-in capability: the row mounts it only when
24
- * the profile actually started with the experimental-auto-review bundle.
25
- * `cordis.patch.yml` disables the official row (one `registerAuto` per
26
- * context), so this row is its replacement rather than a second registration.
24
+ * the profile actually started with the experimental-auto-review bundle. Two
25
+ * rows cannot share the seam — `permissionPresets.registerAuto` is a single
26
+ * registration and the official probe is unshielded — so this row also
27
+ * **takes the seam over** (see `./auto-review-takeover.ts`): a composed or
28
+ * re-enabled official row is displaced through the released loader seams, the
29
+ * registration is handed over and reclaimed around that displacement, and the
30
+ * gate this row mounts keeps the defensive `permissionPresets` shadow so a
31
+ * degraded preset service can never fail a tool call again.
27
32
  *
28
33
  * @module dsh-model-switch/auto-review
29
34
  */
@@ -42,7 +47,8 @@ export declare const name = "model-switch-auto-review";
42
47
  * every tool call fail with `cannot get property "workingDirectory" without
43
48
  * inject`. The gate is now mounted as its own plugin (see {@link autoReviewGate}),
44
49
  * so cordis resolves the official dependency list — including future additions —
45
- * and this row declares only what it reads itself.
50
+ * and this row declares only what it reads itself (`loader` drives the
51
+ * takeover).
46
52
  */
47
53
  export declare const inject: string[];
48
54
  /** Pinned reviewer route; both identifiers are non-empty. */
@@ -97,40 +103,95 @@ export declare function resolveSessionAutoReviewRoute(ctx: Context, session?: Se
97
103
  */
98
104
  export declare function bindAutoReviewSessionCorrelation(scope: Context, activeSession: AsyncLocalStorage<Session | undefined>): void;
99
105
  /**
100
- * Build the scoped context the official gate runs under.
101
- * @param ctx - host context whose modelSwitch settings pin the reviewer route.
102
- * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
103
- * @returns A child context shadowing the llm service and, when the permission
104
- * preset service is reachable, that service too.
106
+ * Whether a reviewer-path throw is a broken seam rather than a real decision.
107
+ * @param cause - whatever the waterfall threw.
108
+ * @returns true when the reviewer itself could not run.
105
109
  */
106
- export declare function reviewScope(ctx: Context, activeSession?: AsyncLocalStorage<Session | undefined>): Context;
110
+ export declare function isReviewerInfrastructureFailure(cause: unknown): boolean;
111
+ /**
112
+ * Keep a broken reviewer seam from failing tool calls.
113
+ *
114
+ * Registered on this row's own context through `{ prepend: true }`, so it sits
115
+ * ahead of the official gate's listener and stays live even when the gate could
116
+ * not be mounted (the takeover lost `registerAuto`, or the displaced row has
117
+ * not released it yet). That is the one window the defensive
118
+ * `permissionPresets` shadow cannot cover: the shadow lives in this row's review
119
+ * scope, while the serving gate resolves the real service from the profile.
120
+ *
121
+ * The waterfall only decides whether the body runs; the body dispatches after
122
+ * it returns, so allowing here never masks a tool failure. Reviewer transport
123
+ * and quota errors keep failing closed — only seam breakage is allowed through.
124
+ *
125
+ * @param ctx - this row's context (the listener lives and dies with the plugin).
126
+ * @param options - diagnostics plus the degraded-state predicate.
127
+ */
128
+ export declare function installToolCallSafetyNet(ctx: Context, options?: {
129
+ /** Diagnostic fiber dump appended to the first report. */
130
+ fiberChain?: (() => string) | undefined;
131
+ /**
132
+ * Whether this row currently knows the reviewer route is not held.
133
+ *
134
+ * The net only fails open in that state: another plugin's broken listener
135
+ * must keep failing closed, and a held route already has the defensive
136
+ * shadow in front of it. Defaults to always degraded (direct callers).
137
+ */
138
+ isDegraded?: (() => boolean) | undefined;
139
+ }): void;
107
140
  /** The permission-preset face the official gate reads; structural so a missing package never breaks a build. */
108
141
  export interface PermissionPresetsFace {
109
142
  current(session: Session): string;
110
143
  set(session: Session, preset: string): unknown;
111
144
  registerAuto(admit: () => void): unknown;
112
145
  }
146
+ /**
147
+ * The managed face: what the gate sees, plus the registration handle the
148
+ * takeover drives.
149
+ */
150
+ export interface ManagedPermissionPresetsFace extends PermissionPresetsFace {
151
+ /** Synchronously release the live Auto registration, keeping the admit callback. */
152
+ releaseForTakeover(): void;
153
+ /** Take the Auto registration back; true once it is live. */
154
+ reclaimRegistration(): boolean;
155
+ /** Whether this face currently holds the Auto registration. */
156
+ readonly registered: boolean;
157
+ }
113
158
  /**
114
159
  * Wrap the permission-preset service so the official gate's per-call probe can
115
- * never fail a tool call.
160
+ * never fail a tool call, and broker its single Auto registration.
116
161
  *
117
162
  * DSH's Auto gate starts every `tools/pre-execute` with
118
163
  * `permissionPresets.current(session)`, and that method resolves the presets
119
164
  * service's own `sessionProjections` injection. When the presets fiber has
120
165
  * turned inactive the probe throws `cannot get required service
121
166
  * "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.
167
+ * wrapper, so every tool call dies. This wrapper resolves the service lazily
168
+ * (so the shadow is in place even while the service is still activating),
169
+ * degrades the probe to `custom` (the gate then skips review and calls
170
+ * `next()`), and reports the lost capability once — with the fiber chain that
171
+ * failed resolution, so the next occurrence names the inactive fiber.
172
+ *
173
+ * `registerAuto` is **brokered**: the gate yields the disposer into its own
174
+ * effect, but the takeover must be able to hand the single registration to a
175
+ * displacing row and take it back without unmounting the gate (the gate's
176
+ * disposal would move live `auto` sessions to `danger-full-access`).
177
+ *
178
+ * @param resolve - resolves the host's permission-preset service per call.
128
179
  * @param log - the row logger used for the single degradation report.
129
- * @returns A structurally compatible preset face.
180
+ * @param chain - diagnostic dump of the resolution chain; defaults to empty.
181
+ * @returns A managed preset face.
130
182
  */
131
- export declare function defensivePermissionPresets(real: PermissionPresetsFace, log: {
183
+ export declare function defensivePermissionPresets(resolve: () => PermissionPresetsFace | undefined, log: {
132
184
  warn(line: string): void;
133
- }): PermissionPresetsFace;
185
+ }, chain?: () => string): ManagedPermissionPresetsFace;
186
+ /**
187
+ * Build the scoped context the official gate runs under.
188
+ * @param ctx - host context whose modelSwitch settings pin the reviewer route.
189
+ * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
190
+ * @param presets - optional managed preset face shared with the row's takeover.
191
+ * @returns A child context shadowing the llm service and the permission-preset
192
+ * service (always: a gate that sees the raw service can fail a tool call).
193
+ */
194
+ export declare function reviewScope(ctx: Context, activeSession?: AsyncLocalStorage<Session | undefined>, presets?: ManagedPermissionPresetsFace): Context;
134
195
  /**
135
196
  * Structural view of the official gate's plugin face.
136
197
  *
@@ -168,10 +229,13 @@ export declare function autoReviewGate(gate: OfficialAutoReviewGate, hooks?: {
168
229
  readonly onFailure?: (error: unknown) => void;
169
230
  }): MountedAutoReviewGate;
170
231
  /**
171
- * Mount the official gate under the reviewer-route scope.
232
+ * Mount the official gate under the reviewer-route scope and own the seam.
172
233
  *
173
234
  * A profile that never enabled the optional bundle keeps no Auto preset and no
174
235
  * reviewer; a deployment missing the official package reports the lost
175
- * capability explicitly instead of guessing another path.
236
+ * capability explicitly instead of guessing another path. A composed (or
237
+ * plugin-manager re-enabled) official row is displaced first — see
238
+ * `./auto-review-takeover.ts` for the choreography and why the yield is
239
+ * synchronous.
176
240
  */
177
241
  export declare function apply(ctx: Context): Promise<void>;
@@ -30,8 +30,9 @@ export declare const zh: {
30
30
  readonly catalogFailed: "无法加载模型目录。";
31
31
  readonly autoReview: "自动审查模型";
32
32
  readonly autoReviewFollow: "跟随会话模型";
33
- readonly autoReviewHelp: "为官方 auto-review(Auto 权限预设的逐调用审查)指定审查模型;需启用官方 auto-review 能力包。关闭时审查跟随当前会话模型。";
33
+ readonly autoReviewHelp: "为官方 auto-review(Auto 权限预设的逐调用审查)指定审查模型;需启用官方 auto-review 能力包。关闭时审查跟随当前会话模型。本插件启用时,官方「自动审查」组件由本插件接管。";
34
34
  readonly autoReviewRouteHelp: "审查请求固定使用这里的 Provider/模型,与会话模型无关;保存后对下一次审查立即生效。";
35
+ readonly autoReviewTakeoverNote: "官方「自动审查」组件已由本插件接管(提供可配置审查路由);在插件管理里把它重新打开会被再次关闭,并在日志里留下一条记录。";
35
36
  readonly compaction: "压缩模型";
36
37
  readonly compactionFollow: "跟随会话模型";
37
38
  readonly compactionHelp: "为官方压缩(/compact 与官方自动压缩)指定摘要模型;关闭(两个字段都留空)时使用官方默认,即当前会话的目标模型。";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "Explicit model routing plus composer/Plan picker for DeepSeek Harness",
5
5
  "repository": {
6
6
  "type": "git",