dsh-model-switch 0.4.34 → 0.4.36

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.
@@ -0,0 +1,855 @@
1
+ import { i as sessionProviderOf, r as resolveCapabilityRoute } from "./session-route-D1zoZgf3.js";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ //#region lib/types/auto-sessions-state.js
7
+ /**
8
+ * Durable snapshot of the sessions that were on the Auto preset when this row
9
+ * released the Auto registration.
10
+ *
11
+ * The official auto-review gate's teardown moves live `auto` sessions to the
12
+ * fallback preset (`danger-full-access`) by design. That teardown runs whenever
13
+ * a gate is disposed — including every hot recompose of this row, where nothing
14
+ * of ours stays mounted long enough to restore the selection. This module owns
15
+ * the pure encode/decode half of the workaround: the ids are written before the
16
+ * release and replayed on the next mount.
17
+ */
18
+ /**
19
+ * How long a snapshot stays replayable. A flip caused by our own release is
20
+ * followed by a re-claim within seconds; anything older is a different restart
21
+ * and must not resurrect a preset the user may have chosen since.
22
+ */
23
+ const AUTO_SESSION_STATE_MAX_AGE_MS = 9e5;
24
+ function uniqueIds(values) {
25
+ const unique = [];
26
+ if (!Array.isArray(values)) return unique;
27
+ for (const value of values) if (typeof value === "string" && value !== "" && !unique.includes(value)) unique.push(value);
28
+ return unique;
29
+ }
30
+ /** Serialize the session ids that were on Auto at `savedAt`. */
31
+ function encodeAutoSessionState(ids, savedAt) {
32
+ const payload = {
33
+ savedAt,
34
+ ids: uniqueIds(ids)
35
+ };
36
+ return JSON.stringify(payload);
37
+ }
38
+ /**
39
+ * Decode a snapshot written by {@link encodeAutoSessionState}.
40
+ *
41
+ * Every malformed or stale shape degrades to `[]`: an unreadable state file
42
+ * must never stop the Auto registration from being released or re-claimed.
43
+ */
44
+ function decodeAutoSessionState(raw, now, maxAgeMs = AUTO_SESSION_STATE_MAX_AGE_MS) {
45
+ if (raw === void 0 || raw.trim() === "") return [];
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(raw);
49
+ } catch {
50
+ return [];
51
+ }
52
+ if (typeof parsed !== "object" || parsed === null) return [];
53
+ const { savedAt, ids } = parsed;
54
+ if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) return [];
55
+ if (now - savedAt > maxAgeMs) return [];
56
+ return uniqueIds(ids);
57
+ }
58
+ /**
59
+ * The official package identity — the only handle on the row this plugin
60
+ * trusts. Matching by id alone could reach a group or another plugin's row, and
61
+ * a matched row is disabled (its fiber disposed).
62
+ */
63
+ const OFFICIAL_AUTO_REVIEW_PACKAGE = "@deepseek-ai/dsh-experimental-auto-review";
64
+ /**
65
+ * Delays for the late re-claims after the attempt loop gave up (ms).
66
+ *
67
+ * `Entry.update({ disabled: true })` starts the displaced fiber's disposal but
68
+ * the loader does not await it, and a disposal that tears down a mounted gate,
69
+ * restores permission presets and unwinds fibers can take longer than the
70
+ * attempt loop's budget. These re-claims keep trying for a few more seconds so a
71
+ * hot-swapped install converges instead of needing a restart.
72
+ */
73
+ const TAKEOVER_LATE_RETRY_DELAYS_MS = [
74
+ 500,
75
+ 1e3,
76
+ 1500
77
+ ];
78
+ /** Whether one loader entry is the official Auto-review row (strict package identity). */
79
+ function isOfficialAutoReviewRow(entry) {
80
+ if (entry === void 0) return false;
81
+ const pkg = entry.options?.name;
82
+ return typeof pkg === "string" && pkg === "@deepseek-ai/dsh-experimental-auto-review";
83
+ }
84
+ /** The first composed official Auto-review row, if the profile declares one. */
85
+ function officialAutoReviewEntry(loader) {
86
+ if (loader === void 0 || typeof loader.entries !== "function") return void 0;
87
+ for (const entry of loader.entries()) if (isOfficialAutoReviewRow(entry)) return entry;
88
+ }
89
+ /** The id one entry is known by (the loader's leaf id, not a nested path). */
90
+ function leafId(entry) {
91
+ const raw = entry.options?.id;
92
+ return typeof raw === "string" ? raw : void 0;
93
+ }
94
+ /** A loader entry the loader has not created a fiber for yet. */
95
+ function alreadyDisabled(entry) {
96
+ return entry.disabled === true;
97
+ }
98
+ /**
99
+ * Displace the official Auto-review row and take the registration back.
100
+ *
101
+ * The official row is disabled through the public `Entry.update` (which starts
102
+ * its fiber's disposal — the loader does not await it, so the claim retries
103
+ * until the displaced registration actually released). The Auto selection of
104
+ * live sessions is preserved around the displacement: the official gate's
105
+ * disposal moves `auto` sessions to `danger-full-access` by design, and this
106
+ * row's registration puts them back.
107
+ *
108
+ * @param ctx - this row's context, used only for logging.
109
+ * @param entry - the official row to displace.
110
+ * @param seam - the Auto-registration seam to yield and reclaim.
111
+ * @returns a promise settling once the takeover attempt loop finished.
112
+ */
113
+ async function reclaimOfficialAutoReviewRow(ctx, entry, seam) {
114
+ const label = String(leafId(entry) ?? "auto-review");
115
+ /** One disable-then-claim step; true once this row holds the registration. */
116
+ const attempt = async () => {
117
+ try {
118
+ await entry.update({ disabled: true });
119
+ 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)`);
120
+ } catch (cause) {
121
+ ctx.logger.info("model-switch: the displaced auto-review row could not be persisted as disabled; the takeover repeats next startup");
122
+ ctx.logger.info(String(cause));
123
+ }
124
+ return await seam.claim();
125
+ };
126
+ await seam.preserveAuto(async () => {
127
+ for (let index = 0; index < 60; index += 1) {
128
+ await new Promise((resolve) => setTimeout(resolve, index === 0 ? 0 : 50));
129
+ if (await attempt()) {
130
+ ctx.logger.info(`model-switch: displaced the official auto-review row ${label}; Auto review runs under the configurable reviewer route`);
131
+ return;
132
+ }
133
+ }
134
+ ctx.logger.error(`model-switch: the official auto-review row ${label} kept the Auto registration; the reviewer route override is unavailable this session`);
135
+ });
136
+ scheduleLateReclaims(ctx, label, seam);
137
+ }
138
+ /**
139
+ * Keep re-claiming for a few seconds after the attempt loop gave up.
140
+ *
141
+ * The displaced fiber's disposal starts on `Entry.update` but is not awaited by
142
+ * the loader, so it can outlast the attempt loop — which is exactly the hot-swap
143
+ * window where the official (unshielded) gate keeps serving. These bounded
144
+ * re-claims converge that window instead of leaving it to a restart. Timers are
145
+ * cleared when this row unloads.
146
+ *
147
+ * @param ctx - this row's context, used for logging and timer disposal.
148
+ * @param label - the official row's id, for diagnostics.
149
+ * @param seam - the Auto-registration seam to reclaim.
150
+ */
151
+ function scheduleLateReclaims(ctx, label, seam) {
152
+ let stopped = false;
153
+ const timers = [];
154
+ const stop = () => {
155
+ stopped = true;
156
+ for (const timer of timers.splice(0)) clearTimeout(timer);
157
+ };
158
+ ctx.effect(function* () {
159
+ yield stop;
160
+ }, "model-switch: auto-review late takeover retries");
161
+ const run = (index) => {
162
+ if (stopped || index >= TAKEOVER_LATE_RETRY_DELAYS_MS.length) return;
163
+ const timer = setTimeout(() => {
164
+ (async () => {
165
+ if (stopped) return;
166
+ if (await seam.claim()) {
167
+ 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`);
168
+ stop();
169
+ return;
170
+ }
171
+ run(index + 1);
172
+ })();
173
+ }, TAKEOVER_LATE_RETRY_DELAYS_MS[index]);
174
+ timers.push(timer);
175
+ };
176
+ run(0);
177
+ }
178
+ /**
179
+ * Install the takeover choreography for an official row that activates later.
180
+ *
181
+ * The plugin manager can re-enable the official row at any time (it writes
182
+ * `disabled: false` into the profile patch layer, which applies after every
183
+ * bundle layer). The `loader/patch-context` listener yields this row's
184
+ * registration **synchronously** before the entry's plugin `apply` runs, so the
185
+ * official row applies cleanly instead of colliding; the reclaim then disables
186
+ * it and takes the registration back.
187
+ *
188
+ * @param ctx - this row's context.
189
+ * @param seam - the Auto-registration seam to yield and reclaim.
190
+ * @returns the guarded reclaim function, so the caller's startup takeover and
191
+ * the listeners share one in-flight guard per entry.
192
+ */
193
+ function installAutoReviewTakeover(ctx, seam) {
194
+ /** Reclaims in flight, shared so two triggers for one entry join one cycle. */
195
+ const reclaiming = /* @__PURE__ */ new Map();
196
+ /** Disable the official row and take the registration back, once per entry. */
197
+ const reclaim = (entry) => {
198
+ const running = reclaiming.get(entry);
199
+ if (running !== void 0) return running;
200
+ const run = reclaimOfficialAutoReviewRow(ctx, entry, seam).finally(() => {
201
+ reclaiming.delete(entry);
202
+ });
203
+ reclaiming.set(entry, run);
204
+ return run;
205
+ };
206
+ /**
207
+ * The row an event names.
208
+ *
209
+ * The loader hands the emitting entry as the event's `this`, but cordis does
210
+ * not rebind `this` for listeners added through `ctx.on`, so a listener can
211
+ * also receive the payload as its first argument. Both shapes are accepted;
212
+ * the real Host always provides one of them.
213
+ */
214
+ const entryOfEvent = (self, first) => {
215
+ const candidate = self ?? first;
216
+ return candidate !== null && typeof candidate === "object" && "options" in candidate ? candidate : void 0;
217
+ };
218
+ const listeners = [];
219
+ const listen = (event, listener) => {
220
+ const dispose = ctx.on?.(event, listener);
221
+ if (dispose !== void 0) listeners.push(dispose);
222
+ };
223
+ listen("loader/patch-context", function(first, second) {
224
+ const nextFn = typeof first === "function" ? first : second;
225
+ const entry = entryOfEvent(this, typeof first === "function" ? void 0 : first);
226
+ if (entry === void 0 || typeof nextFn !== "function" || !isOfficialAutoReviewRow(entry)) return nextFn === void 0 ? void 0 : nextFn();
227
+ seam.yieldRegistration();
228
+ reclaim(entry);
229
+ return nextFn();
230
+ });
231
+ listen("loader/partial-dispose", function(first) {
232
+ const entry = entryOfEvent(this, first);
233
+ if (entry === void 0 || !isOfficialAutoReviewRow(entry)) return;
234
+ seam.claim();
235
+ });
236
+ const loader = ctx.get("loader", false);
237
+ if (loader !== void 0 && typeof loader.entries === "function") for (const candidate of loader.entries()) {
238
+ if (!isOfficialAutoReviewRow(candidate)) continue;
239
+ if (alreadyDisabled(candidate)) continue;
240
+ reclaim(candidate);
241
+ }
242
+ const disposable = () => {
243
+ for (const dispose of listeners.splice(0)) dispose();
244
+ };
245
+ const effect = ctx.effect;
246
+ if (effect !== void 0) {
247
+ const registration = effect(function* () {
248
+ yield disposable;
249
+ }, "model-switch: auto-review takeover");
250
+ if (typeof registration === "function") listeners.push(registration);
251
+ }
252
+ return reclaim;
253
+ }
254
+ //#endregion
255
+ //#region lib/types/auto-review.js
256
+ /**
257
+ * Model Switch-owned Auto reviewer row.
258
+ *
259
+ * The official `@deepseek-ai/dsh-experimental-auto-review` gate hardwires its
260
+ * reviewer to the session's own request route (`header.config`). This row runs
261
+ * the official `apply` under a scoped child context whose `llm` service is
262
+ * shadowed by a wrapper that rewrites only `stream()`'s provider/model — and
263
+ * only when the reviewing session's derived route (its provider group's
264
+ * reviewer pair, else the deployment-wide reviewer card) pins one. With no
265
+ * route pinned the wrapper passes options through by identity, so the reviewer
266
+ * follows the session model exactly.
267
+ *
268
+ * Although the official gate omits `sessionId` and `purpose` from its frozen
269
+ * `GenerateOptions`, its `tools/pre-execute` waterfall listener receives
270
+ * `exec.agent` (`exec.agent.session`). After running `official.apply(scope)`,
271
+ * this module registers a `{ prepend: true }` `tools/pre-execute` listener on
272
+ * the same scope — which Cordis places ahead of the official gate's listener in
273
+ * the waterfall — and binds `exec.agent?.session` in an `AsyncLocalStorage`
274
+ * scope around `next()`. Every `scope.llm.stream()` call from `classifyRisk`
275
+ * therefore resolves the exact session being reviewed, with zero cross-session
276
+ * interference when multiple sessions review tool calls concurrently.
277
+ *
278
+ * The official gate stays an opt-in capability: the row mounts it only when
279
+ * the profile actually started with the experimental-auto-review bundle. Two
280
+ * rows cannot share the seam — `permissionPresets.registerAuto` is a single
281
+ * registration and the official probe is unshielded — so this row also
282
+ * **takes the seam over** (see `./auto-review-takeover.ts`): a composed or
283
+ * re-enabled official row is displaced through the released loader seams, the
284
+ * registration is handed over and reclaimed around that displacement, and the
285
+ * gate this row mounts keeps the defensive `permissionPresets` shadow so a
286
+ * degraded preset service can never fail a tool call again.
287
+ *
288
+ * @module dsh-model-switch/auto-review
289
+ */
290
+ /** Cordis plugin name used by loader diagnostics. */
291
+ const name = "model-switch-auto-review";
292
+ /**
293
+ * Only the services this row's own code touches.
294
+ *
295
+ * The official gate used to run inside this row's fiber, so this list had to
296
+ * mirror every service the official `apply` reads. DSH 0.2.1 added
297
+ * `workingDirectory` to the official gate and the mirror went stale, which made
298
+ * every tool call fail with `cannot get property "workingDirectory" without
299
+ * inject`. The gate is now mounted as its own plugin (see {@link autoReviewGate}),
300
+ * so cordis resolves the official dependency list — including future additions —
301
+ * and this row declares only what it reads itself (`loader` drives the
302
+ * takeover).
303
+ */
304
+ const inject = [
305
+ "llm",
306
+ "modelSwitch",
307
+ "loader"
308
+ ];
309
+ /** Optional bundle whose composition opts a profile into Auto review. */
310
+ const AUTO_REVIEW_BUNDLE = "@deepseek-ai/dsh-experimental-auto-review";
311
+ /** The Auto preset name; `registerAuto` is what publishes it. */
312
+ const AUTO_PRESET = "auto";
313
+ /**
314
+ * Live Auto-reviewer takeover status, read by the runtime-capability snapshot.
315
+ *
316
+ * `held: false` means the official row kept the single `registerAuto` slot (or
317
+ * has not released it yet): the reviewer route is not configurable and the
318
+ * tool-call safety net allows unreviewed calls instead of failing them.
319
+ * `allowedCalls` counts the calls the net let through in that state, so the
320
+ * settings card can tell the human how much review was skipped.
321
+ */
322
+ const autoReviewStatus = {
323
+ held: false,
324
+ allowedCalls: 0
325
+ };
326
+ /**
327
+ * Resolve the pinned reviewer route from live settings.
328
+ * @param settings - Model Switch settings carrying the optional reviewer pair.
329
+ * @returns Both identifiers trimmed and non-empty, or undefined (follow session).
330
+ */
331
+ function autoReviewRouteOf(settings) {
332
+ const provider = settings?.autoReviewProvider?.trim();
333
+ const model = settings?.autoReviewModel?.trim();
334
+ if (provider === void 0 || provider === "" || model === void 0 || model === "") return void 0;
335
+ return {
336
+ provider,
337
+ model
338
+ };
339
+ }
340
+ /**
341
+ * Wrap the real llm service so only `stream()` route fields are rewritten.
342
+ *
343
+ * Non-`stream` members pass through bound to the real service (Proxy reads
344
+ * would otherwise hand methods the proxy as `this`). The route is re-read per
345
+ * call so a settings change applies to the very next review without a reload.
346
+ *
347
+ * @param real - the host's `ctx.llm`.
348
+ * @param route - live route resolver; undefined means "do not rewrite".
349
+ * @returns A structurally compatible llm face for the scoped context.
350
+ */
351
+ function reviewLlm(real, route) {
352
+ return new Proxy(real, { get(target, property, _receiver) {
353
+ if (property === "stream") return (options) => {
354
+ const pinned = route();
355
+ return pinned === void 0 ? target.stream(options) : target.stream({
356
+ ...options,
357
+ provider: pinned.provider,
358
+ model: pinned.model
359
+ });
360
+ };
361
+ const value = Reflect.get(target, property, target);
362
+ return typeof value === "function" ? value.bind(target) : value;
363
+ } });
364
+ }
365
+ /**
366
+ * Resolve the reviewer route for one session (or deployment-wide when no session is bound).
367
+ *
368
+ * @param ctx - host context whose modelSwitch settings and sessionProjections derive the route.
369
+ * @param session - the session whose tool call is being reviewed, when known.
370
+ * @returns The derived reviewer route, or undefined to follow the session model.
371
+ */
372
+ function resolveSessionAutoReviewRoute(ctx, session) {
373
+ let settings;
374
+ try {
375
+ settings = ctx.modelSwitch.currentSettings();
376
+ } catch {
377
+ settings = void 0;
378
+ }
379
+ const provider = sessionProviderOf(ctx, session);
380
+ return resolveCapabilityRoute(settings, provider, "autoReview");
381
+ }
382
+ /**
383
+ * Install the outer `tools/pre-execute` correlation listener ahead of the official gate.
384
+ *
385
+ * Because Cordis `on(..., { prepend: true })` unshifts onto the event hook list,
386
+ * registering this listener AFTER `official.apply(scope)` places it at index 0
387
+ * — directly ahead of the official gate's own prepended listener — so
388
+ * `activeSession.run(exec.agent?.session, next)` wraps the entire official
389
+ * review execution.
390
+ *
391
+ * @param scope - the review scope context where the gate is mounted.
392
+ * @param activeSession - AsyncLocalStorage holding the session under review.
393
+ */
394
+ function bindAutoReviewSessionCorrelation(scope, activeSession) {
395
+ scope.effect(function* () {
396
+ yield scope.on("tools/pre-execute", (exec, next) => activeSession.run(exec.agent?.session, next), { prepend: true });
397
+ }, "model-switch: auto-review session correlation");
398
+ }
399
+ /**
400
+ * Signatures of a broken reviewer seam, never of a real denial.
401
+ *
402
+ * The official gate's `tools/pre-execute` probe resolves its own
403
+ * `sessionProjections` injection before anything can catch it; when that fiber
404
+ * chain breaks it throws `cannot get required service ... in inactive context`
405
+ * (or, on older lines, `cannot get property ... without inject`) straight into
406
+ * the tool call. `TRANSPORT` covers a reviewer that cannot reach its route at
407
+ * all. A denial is a *return* (`{ kind: 'deny' }`), so it never matches.
408
+ */
409
+ const REVIEWER_INFRA_FAILURE_SIGNATURES = [
410
+ "inactive context",
411
+ "cannot get required service",
412
+ "cannot get property",
413
+ "without inject",
414
+ "TRANSPORT"
415
+ ];
416
+ /**
417
+ * Whether a reviewer-path throw is a broken seam rather than a real decision.
418
+ * @param cause - whatever the waterfall threw.
419
+ * @returns true when the reviewer itself could not run.
420
+ */
421
+ function isReviewerInfrastructureFailure(cause) {
422
+ const text = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
423
+ return REVIEWER_INFRA_FAILURE_SIGNATURES.some((signature) => text.includes(signature));
424
+ }
425
+ /**
426
+ * Keep a broken reviewer seam from failing tool calls.
427
+ *
428
+ * Registered on this row's own context through `{ prepend: true }`, so it sits
429
+ * ahead of the official gate's listener and stays live even when the gate could
430
+ * not be mounted (the takeover lost `registerAuto`, or the displaced row has
431
+ * not released it yet). That is the one window the defensive
432
+ * `permissionPresets` shadow cannot cover: the shadow lives in this row's review
433
+ * scope, while the serving gate resolves the real service from the profile.
434
+ *
435
+ * The waterfall only decides whether the body runs; the body dispatches after
436
+ * it returns, so allowing here never masks a tool failure. Reviewer transport
437
+ * and quota errors keep failing closed — only seam breakage is allowed through.
438
+ *
439
+ * @param ctx - this row's context (the listener lives and dies with the plugin).
440
+ * @param options - diagnostics plus the degraded-state predicate.
441
+ */
442
+ function installToolCallSafetyNet(ctx, options = {}) {
443
+ let reported = false;
444
+ const handler = async (_exec, next) => {
445
+ try {
446
+ return await next();
447
+ } catch (cause) {
448
+ if (!isReviewerInfrastructureFailure(cause)) throw cause;
449
+ if (options.isDegraded !== void 0 && !options.isDegraded()) throw cause;
450
+ if (!reported) {
451
+ reported = true;
452
+ const detail = options.fiberChain === void 0 ? "" : `; fiber chain: ${options.fiberChain()}`;
453
+ ctx.logger.warn(`model-switch: the Auto reviewer path is broken (${String(cause)}); tool calls run unreviewed instead of failing${detail}`);
454
+ }
455
+ autoReviewStatus.allowedCalls += 1;
456
+ return { kind: "allow" };
457
+ }
458
+ };
459
+ ctx.effect(function* () {
460
+ yield ctx.on("tools/pre-execute", handler, { prepend: true });
461
+ }, "model-switch: reviewer-path safety net");
462
+ }
463
+ /** The preset name the service reports when no configured preset matches. */
464
+ const CUSTOM_PRESET = "custom";
465
+ /** Read the live permission-preset service without requiring an injection. */
466
+ function permissionPresetsOf(ctx) {
467
+ try {
468
+ const face = ctx.get("permissionPresets", false);
469
+ return face !== null && typeof face === "object" ? face : void 0;
470
+ } catch {
471
+ return;
472
+ }
473
+ }
474
+ /**
475
+ * A compact dump of the fiber chain a service resolution walks.
476
+ *
477
+ * `cannot get required service "sessionProjections" in inactive context` is
478
+ * thrown by the first fiber in the caller's chain that declares the service
479
+ * without a resolved snapshot; the chain names that fiber directly.
480
+ * @param ctx - the context whose chain is walked.
481
+ * @returns One `uid/state/name/inject` segment per fiber, outermost last.
482
+ */
483
+ function fiberChainOf(ctx) {
484
+ const parts = [];
485
+ let node = ctx;
486
+ for (let step = 0; step < 8; step += 1) {
487
+ const fiber = node?.fiber;
488
+ if (fiber === void 0) break;
489
+ const injects = Object.keys(fiber.inject ?? {}).join("|");
490
+ parts.push(`uid=${String(fiber.uid)} state=${String(fiber.state)} name=${String(fiber.name)} inject=[${injects}]`);
491
+ node = fiber.parent;
492
+ }
493
+ return parts.length === 0 ? "no fiber chain" : parts.join(" -> ");
494
+ }
495
+ /**
496
+ * Wrap the permission-preset service so the official gate's per-call probe can
497
+ * never fail a tool call, and broker its single Auto registration.
498
+ *
499
+ * DSH's Auto gate starts every `tools/pre-execute` with
500
+ * `permissionPresets.current(session)`, and that method resolves the presets
501
+ * service's own `sessionProjections` injection. When the presets fiber has
502
+ * turned inactive the probe throws `cannot get required service
503
+ * "sessionProjections" in inactive context` outside the gate's reviewer error
504
+ * wrapper, so every tool call dies. This wrapper resolves the service lazily
505
+ * (so the shadow is in place even while the service is still activating),
506
+ * degrades the probe to `custom` (the gate then skips review and calls
507
+ * `next()`), and reports the lost capability once — with the fiber chain that
508
+ * failed resolution, so the next occurrence names the inactive fiber.
509
+ *
510
+ * `registerAuto` is **brokered**: the gate yields the disposer into its own
511
+ * effect, but the takeover must be able to hand the single registration to a
512
+ * displacing row and take it back without unmounting the gate (the gate's
513
+ * disposal would move live `auto` sessions to `danger-full-access`).
514
+ *
515
+ * @param resolve - resolves the host's permission-preset service per call.
516
+ * @param log - the row logger used for the single degradation report.
517
+ * @param chain - diagnostic dump of the resolution chain; defaults to empty.
518
+ * @returns A managed preset face.
519
+ */
520
+ function defensivePermissionPresets(resolve, log, chain = () => "") {
521
+ let reported = false;
522
+ let admit;
523
+ let release;
524
+ const degrade = (operation, error) => {
525
+ if (reported) return;
526
+ reported = true;
527
+ 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()}]`);
528
+ };
529
+ const requireService = () => {
530
+ const face = resolve();
531
+ if (face === void 0) throw new Error("permission: the permission-preset service is unavailable");
532
+ return face;
533
+ };
534
+ return {
535
+ current(session) {
536
+ try {
537
+ return requireService().current(session);
538
+ } catch (error) {
539
+ degrade("current()", error);
540
+ return CUSTOM_PRESET;
541
+ }
542
+ },
543
+ set(session, preset) {
544
+ try {
545
+ return requireService().set(session, preset);
546
+ } catch (error) {
547
+ degrade("set()", error);
548
+ return;
549
+ }
550
+ },
551
+ registerAuto(next) {
552
+ admit = next;
553
+ release = requireService().registerAuto(next);
554
+ return () => {
555
+ admit = void 0;
556
+ const dispose = release;
557
+ release = void 0;
558
+ return dispose?.();
559
+ };
560
+ },
561
+ releaseForTakeover() {
562
+ const dispose = release;
563
+ release = void 0;
564
+ dispose?.();
565
+ },
566
+ reclaimRegistration() {
567
+ if (release !== void 0) return true;
568
+ if (admit === void 0) return false;
569
+ try {
570
+ release = requireService().registerAuto(admit);
571
+ return true;
572
+ } catch {
573
+ return false;
574
+ }
575
+ },
576
+ get registered() {
577
+ return release !== void 0;
578
+ }
579
+ };
580
+ }
581
+ /**
582
+ * Build the scoped context the official gate runs under.
583
+ * @param ctx - host context whose modelSwitch settings pin the reviewer route.
584
+ * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
585
+ * @param presets - optional managed preset face shared with the row's takeover.
586
+ * @returns A child context shadowing the llm service and the permission-preset
587
+ * service (always: a gate that sees the raw service can fail a tool call).
588
+ */
589
+ function reviewScope(ctx, activeSession = new AsyncLocalStorage(), presets = defensivePermissionPresets(() => permissionPresetsOf(ctx), ctx.logger, () => fiberChainOf(ctx))) {
590
+ return ctx.extend({
591
+ llm: reviewLlm(ctx.llm, () => resolveSessionAutoReviewRoute(ctx, activeSession.getStore())),
592
+ permissionPresets: presets
593
+ });
594
+ }
595
+ /** Structural read of launcher-provided profile facts (the app-boot seam). */
596
+ function autoReviewBundleActive(ctx) {
597
+ let profile;
598
+ try {
599
+ profile = ctx.get("profileContext", false);
600
+ } catch {
601
+ return false;
602
+ }
603
+ if (profile === null || typeof profile !== "object") return false;
604
+ const bundles = profile.startedBundles;
605
+ return Array.isArray(bundles) && bundles.includes(AUTO_REVIEW_BUNDLE);
606
+ }
607
+ /** Structural read of the loader service the takeover drives. */
608
+ function loaderOf(ctx) {
609
+ try {
610
+ const loader = ctx.get("loader", false);
611
+ return loader !== null && typeof loader === "object" ? loader : void 0;
612
+ } catch {
613
+ return;
614
+ }
615
+ }
616
+ /**
617
+ * Wrap the official gate as a plugin that declares its own dependencies.
618
+ *
619
+ * Directly calling `official.apply(scope)` ran the gate's code on this row's
620
+ * fiber, so every service the gate reads had to be mirrored in this row's
621
+ * `inject`. Mounting it instead lets cordis resolve the official list, which is
622
+ * what keeps a released Host's new dependency (0.2.1's `workingDirectory`) from
623
+ * breaking every tool call. The hooks let the caller observe whether the gate
624
+ * actually ran before it binds the session-correlation listener.
625
+ *
626
+ * @param gate - the official module (or an equivalent structural gate).
627
+ * @param hooks - applied/failure observers; the failure is rethrown.
628
+ * @returns A cordis plugin mounting the official gate unchanged.
629
+ */
630
+ function autoReviewGate(gate, hooks = {}) {
631
+ return {
632
+ name: gate.name ?? "experimental-auto-review",
633
+ ...gate.inject === void 0 ? {} : { inject: [...gate.inject] },
634
+ apply(gateCtx) {
635
+ try {
636
+ gate.apply(gateCtx);
637
+ } catch (error) {
638
+ hooks.onFailure?.(error);
639
+ throw error;
640
+ }
641
+ hooks.onApplied?.();
642
+ }
643
+ };
644
+ }
645
+ /**
646
+ * The gate fiber mounted by the current row instance.
647
+ *
648
+ * `registerAuto` is a single live registration owned by the gate's effect
649
+ * lifetime, and its disposer is what clears the Auto preset. A re-applied row
650
+ * must await the previous gate's unloading before the next registration runs,
651
+ * otherwise the new `registerAuto` throws `already registered` and its effect
652
+ * cleanup leaves the catalog without Auto.
653
+ */
654
+ let lastGateFiber;
655
+ /**
656
+ * Mount the official gate under the reviewer-route scope and own the seam.
657
+ *
658
+ * A profile that never enabled the optional bundle keeps no Auto preset and no
659
+ * reviewer; a deployment missing the official package reports the lost
660
+ * capability explicitly instead of guessing another path. A composed (or
661
+ * plugin-manager re-enabled) official row is displaced first — see
662
+ * `./auto-review-takeover.ts` for the choreography and why the yield is
663
+ * synchronous.
664
+ */
665
+ async function apply(ctx) {
666
+ if (!autoReviewBundleActive(ctx)) {
667
+ ctx.logger.info("model-switch: auto-review bundle is not active in this profile; the reviewer route override stays unmounted");
668
+ return;
669
+ }
670
+ let official;
671
+ try {
672
+ official = await import("@deepseek-ai/dsh-experimental-auto-review");
673
+ } catch (cause) {
674
+ ctx.logger.error("model-switch: @deepseek-ai/dsh-experimental-auto-review is unavailable; the Auto reviewer cannot run at all");
675
+ ctx.logger.error(cause);
676
+ return;
677
+ }
678
+ const presets = defensivePermissionPresets(() => permissionPresetsOf(ctx), ctx.logger, () => fiberChainOf(ctx));
679
+ const activeSession = new AsyncLocalStorage();
680
+ const scope = reviewScope(ctx, activeSession, presets);
681
+ installToolCallSafetyNet(ctx, {
682
+ fiberChain: () => fiberChainOf(ctx),
683
+ isDegraded: () => !presets.registered
684
+ });
685
+ let gateReady = false;
686
+ let mountInFlight;
687
+ /** Sessions currently on the Auto preset (best-effort). */
688
+ const rememberAuto = () => {
689
+ const remembered = [];
690
+ try {
691
+ const sessions = ctx.get("sessions", false);
692
+ for (const session of sessions?.list?.() ?? []) if (presets.current(session) === AUTO_PRESET) remembered.push(session);
693
+ } catch {}
694
+ return remembered;
695
+ };
696
+ const restoreAuto = (remembered) => {
697
+ for (const session of remembered) try {
698
+ presets.set(session, AUTO_PRESET);
699
+ } catch {}
700
+ };
701
+ /**
702
+ * Run one displacement step without losing the sessions' Auto selection.
703
+ *
704
+ * Disposing a gate moves live `auto` sessions to `danger-full-access` by
705
+ * design (the official teardown runs on both the displaced row and this
706
+ * row's own re-mounts); the remembered selection is restored afterwards.
707
+ */
708
+ const preserveAuto = async (action) => {
709
+ const remembered = rememberAuto();
710
+ await action();
711
+ restoreAuto(remembered);
712
+ };
713
+ /** Where the pre-release Auto snapshot lives (host-wide, not per profile). */
714
+ const autoStatePath = () => join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "model-switch", "auto-sessions.json");
715
+ /**
716
+ * Snapshot the Auto selection to disk *before* the registration is released.
717
+ *
718
+ * A release can dispose the gate during a hot recompose — exactly when this
719
+ * row is torn down and can no longer restore anything in memory. The next
720
+ * mount replays the file (see `flushAutoSnapshot`), so the user does not have
721
+ * to re-select 「自动审查」 after every reload.
722
+ */
723
+ const persistAutoSnapshot = () => {
724
+ const ids = [];
725
+ for (const session of rememberAuto()) {
726
+ const id = session.id;
727
+ if (typeof id === "string" && id !== "") ids.push(id);
728
+ }
729
+ try {
730
+ const file = autoStatePath();
731
+ if (ids.length === 0) {
732
+ rmSync(file, { force: true });
733
+ return;
734
+ }
735
+ mkdirSync(dirname(file), { recursive: true });
736
+ writeFileSync(file, encodeAutoSessionState(ids, Date.now()), "utf8");
737
+ } catch {}
738
+ };
739
+ /** Replay (and consume) the snapshot written by a previous release. */
740
+ const flushAutoSnapshot = () => {
741
+ try {
742
+ const file = autoStatePath();
743
+ let raw;
744
+ try {
745
+ raw = readFileSync(file, "utf8");
746
+ } catch {
747
+ return;
748
+ }
749
+ rmSync(file, { force: true });
750
+ const ids = decodeAutoSessionState(raw, Date.now());
751
+ if (ids.length === 0) return;
752
+ const wanted = new Set(ids);
753
+ const sessions = ctx.get("sessions", false);
754
+ for (const session of sessions?.list?.() ?? []) {
755
+ const id = session.id;
756
+ if (typeof id !== "string" || !wanted.has(id)) continue;
757
+ try {
758
+ if (presets.current(session) !== AUTO_PRESET) presets.set(session, AUTO_PRESET);
759
+ } catch {}
760
+ }
761
+ } catch {}
762
+ };
763
+ /** Mount this row's shielded gate exactly once; failures dispose and report. */
764
+ const mountGate = () => {
765
+ mountInFlight ??= (async () => {
766
+ let outcome = "failed";
767
+ await preserveAuto(async () => {
768
+ const previous = lastGateFiber;
769
+ lastGateFiber = void 0;
770
+ gateReady = false;
771
+ if (previous !== void 0) try {
772
+ await previous.dispose();
773
+ } catch (cause) {
774
+ ctx.logger.info("model-switch: the previous auto-review gate had already unloaded; continuing with the new one");
775
+ ctx.logger.info(String(cause));
776
+ }
777
+ let failure;
778
+ let applied = false;
779
+ const fiber = scope.plugin(autoReviewGate(official, {
780
+ onApplied: () => {
781
+ applied = true;
782
+ bindAutoReviewSessionCorrelation(scope, activeSession);
783
+ },
784
+ onFailure: (error) => {
785
+ failure = error;
786
+ }
787
+ }));
788
+ try {
789
+ await fiber;
790
+ } catch (cause) {
791
+ failure ??= cause;
792
+ }
793
+ if (failure !== void 0) {
794
+ try {
795
+ await fiber.dispose();
796
+ } catch {}
797
+ ctx.logger.warn(`model-switch: the Auto reviewer gate did not mount (${String(failure)}); the displaced registration may still be releasing`);
798
+ outcome = "failed";
799
+ return;
800
+ }
801
+ if (!applied) {
802
+ ctx.logger.info("model-switch: the official auto-review gate is waiting for its host services; the reviewer route override is not bound yet");
803
+ outcome = "waiting";
804
+ return;
805
+ }
806
+ const gate = fiber;
807
+ const disposeGate = gate.dispose.bind(gate);
808
+ gate.dispose = () => {
809
+ presets.releaseForTakeover();
810
+ return disposeGate();
811
+ };
812
+ lastGateFiber = gate;
813
+ gateReady = true;
814
+ outcome = "applied";
815
+ });
816
+ return outcome;
817
+ })().finally(() => {
818
+ mountInFlight = void 0;
819
+ });
820
+ return mountInFlight;
821
+ };
822
+ /** Make this row's Auto registration live; true once it is held. */
823
+ const claim = async () => {
824
+ let held;
825
+ if (presets.registered) held = true;
826
+ else if (gateReady) held = presets.reclaimRegistration();
827
+ else held = await mountGate() !== "failed";
828
+ autoReviewStatus.held = held;
829
+ if (held) flushAutoSnapshot();
830
+ return held;
831
+ };
832
+ const reclaim = installAutoReviewTakeover(ctx, {
833
+ yieldRegistration: () => {
834
+ persistAutoSnapshot();
835
+ presets.releaseForTakeover();
836
+ },
837
+ claim,
838
+ preserveAuto
839
+ });
840
+ const entry = officialAutoReviewEntry(loaderOf(ctx));
841
+ if (entry !== void 0 && entry.disabled !== true) await reclaim(entry);
842
+ else await claim();
843
+ if (!presets.registered) {
844
+ autoReviewStatus.held = false;
845
+ 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");
846
+ }
847
+ ctx.effect(function* () {
848
+ yield () => {
849
+ persistAutoSnapshot();
850
+ presets.releaseForTakeover();
851
+ };
852
+ }, "model-switch: release the Auto registration before anything else");
853
+ }
854
+ //#endregion
855
+ export { bindAutoReviewSessionCorrelation as a, installToolCallSafetyNet as c, resolveSessionAutoReviewRoute as d, reviewLlm as f, autoReviewStatus as i, isReviewerInfrastructureFailure as l, autoReviewGate as n, defensivePermissionPresets as o, reviewScope as p, autoReviewRouteOf as r, inject as s, apply as t, name as u };