dsh-model-switch 0.4.32 → 0.4.34

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,745 @@
1
+ import { i as sessionProviderOf, r as resolveCapabilityRoute } from "./session-route-D1zoZgf3.js";
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
+ /** Reclaims in flight, shared so two triggers for one entry join one cycle. */
140
+ const reclaiming = /* @__PURE__ */ new Map();
141
+ /** Disable the official row and take the registration back, once per entry. */
142
+ const reclaim = (entry) => {
143
+ const running = reclaiming.get(entry);
144
+ if (running !== void 0) return running;
145
+ const run = reclaimOfficialAutoReviewRow(ctx, entry, seam).finally(() => {
146
+ reclaiming.delete(entry);
147
+ });
148
+ reclaiming.set(entry, run);
149
+ return run;
150
+ };
151
+ /**
152
+ * The row an event names.
153
+ *
154
+ * The loader hands the emitting entry as the event's `this`, but cordis does
155
+ * not rebind `this` for listeners added through `ctx.on`, so a listener can
156
+ * also receive the payload as its first argument. Both shapes are accepted;
157
+ * the real Host always provides one of them.
158
+ */
159
+ const entryOfEvent = (self, first) => {
160
+ const candidate = self ?? first;
161
+ return candidate !== null && typeof candidate === "object" && "options" in candidate ? candidate : void 0;
162
+ };
163
+ const listeners = [];
164
+ const listen = (event, listener) => {
165
+ const dispose = ctx.on?.(event, listener);
166
+ if (dispose !== void 0) listeners.push(dispose);
167
+ };
168
+ listen("loader/patch-context", function(first, second) {
169
+ const nextFn = typeof first === "function" ? first : second;
170
+ const entry = entryOfEvent(this, typeof first === "function" ? void 0 : first);
171
+ if (entry === void 0 || typeof nextFn !== "function" || !isOfficialAutoReviewRow(entry)) return nextFn === void 0 ? void 0 : nextFn();
172
+ seam.yieldRegistration();
173
+ reclaim(entry);
174
+ return nextFn();
175
+ });
176
+ listen("loader/partial-dispose", function(first) {
177
+ const entry = entryOfEvent(this, first);
178
+ if (entry === void 0 || !isOfficialAutoReviewRow(entry)) return;
179
+ seam.claim();
180
+ });
181
+ const loader = ctx.get("loader", false);
182
+ if (loader !== void 0 && typeof loader.entries === "function") for (const candidate of loader.entries()) {
183
+ if (!isOfficialAutoReviewRow(candidate)) continue;
184
+ if (alreadyDisabled(candidate)) continue;
185
+ reclaim(candidate);
186
+ }
187
+ const disposable = () => {
188
+ for (const dispose of listeners.splice(0)) dispose();
189
+ };
190
+ const effect = ctx.effect;
191
+ if (effect !== void 0) {
192
+ const registration = effect(function* () {
193
+ yield disposable;
194
+ }, "model-switch: auto-review takeover");
195
+ if (typeof registration === "function") listeners.push(registration);
196
+ }
197
+ return reclaim;
198
+ }
199
+ //#endregion
200
+ //#region lib/types/auto-review.js
201
+ /**
202
+ * Model Switch-owned Auto reviewer row.
203
+ *
204
+ * The official `@deepseek-ai/dsh-experimental-auto-review` gate hardwires its
205
+ * reviewer to the session's own request route (`header.config`). This row runs
206
+ * the official `apply` under a scoped child context whose `llm` service is
207
+ * shadowed by a wrapper that rewrites only `stream()`'s provider/model — and
208
+ * only when the reviewing session's derived route (its provider group's
209
+ * reviewer pair, else the deployment-wide reviewer card) pins one. With no
210
+ * route pinned the wrapper passes options through by identity, so the reviewer
211
+ * follows the session model exactly.
212
+ *
213
+ * Although the official gate omits `sessionId` and `purpose` from its frozen
214
+ * `GenerateOptions`, its `tools/pre-execute` waterfall listener receives
215
+ * `exec.agent` (`exec.agent.session`). After running `official.apply(scope)`,
216
+ * this module registers a `{ prepend: true }` `tools/pre-execute` listener on
217
+ * the same scope — which Cordis places ahead of the official gate's listener in
218
+ * the waterfall — and binds `exec.agent?.session` in an `AsyncLocalStorage`
219
+ * scope around `next()`. Every `scope.llm.stream()` call from `classifyRisk`
220
+ * therefore resolves the exact session being reviewed, with zero cross-session
221
+ * interference when multiple sessions review tool calls concurrently.
222
+ *
223
+ * The official gate stays an opt-in capability: the row mounts it only when
224
+ * the profile actually started with the experimental-auto-review bundle. Two
225
+ * rows cannot share the seam — `permissionPresets.registerAuto` is a single
226
+ * registration and the official probe is unshielded — so this row also
227
+ * **takes the seam over** (see `./auto-review-takeover.ts`): a composed or
228
+ * re-enabled official row is displaced through the released loader seams, the
229
+ * registration is handed over and reclaimed around that displacement, and the
230
+ * gate this row mounts keeps the defensive `permissionPresets` shadow so a
231
+ * degraded preset service can never fail a tool call again.
232
+ *
233
+ * @module dsh-model-switch/auto-review
234
+ */
235
+ /** Cordis plugin name used by loader diagnostics. */
236
+ const name = "model-switch-auto-review";
237
+ /**
238
+ * Only the services this row's own code touches.
239
+ *
240
+ * The official gate used to run inside this row's fiber, so this list had to
241
+ * mirror every service the official `apply` reads. DSH 0.2.1 added
242
+ * `workingDirectory` to the official gate and the mirror went stale, which made
243
+ * every tool call fail with `cannot get property "workingDirectory" without
244
+ * inject`. The gate is now mounted as its own plugin (see {@link autoReviewGate}),
245
+ * so cordis resolves the official dependency list — including future additions —
246
+ * and this row declares only what it reads itself (`loader` drives the
247
+ * takeover).
248
+ */
249
+ const inject = [
250
+ "llm",
251
+ "modelSwitch",
252
+ "loader"
253
+ ];
254
+ /** Optional bundle whose composition opts a profile into Auto review. */
255
+ const AUTO_REVIEW_BUNDLE = "@deepseek-ai/dsh-experimental-auto-review";
256
+ /** The Auto preset name; `registerAuto` is what publishes it. */
257
+ const AUTO_PRESET = "auto";
258
+ /**
259
+ * Live Auto-reviewer takeover status, read by the runtime-capability snapshot.
260
+ *
261
+ * `held: false` means the official row kept the single `registerAuto` slot (or
262
+ * has not released it yet): the reviewer route is not configurable and the
263
+ * tool-call safety net allows unreviewed calls instead of failing them.
264
+ * `allowedCalls` counts the calls the net let through in that state, so the
265
+ * settings card can tell the human how much review was skipped.
266
+ */
267
+ const autoReviewStatus = {
268
+ held: false,
269
+ allowedCalls: 0
270
+ };
271
+ /**
272
+ * Resolve the pinned reviewer route from live settings.
273
+ * @param settings - Model Switch settings carrying the optional reviewer pair.
274
+ * @returns Both identifiers trimmed and non-empty, or undefined (follow session).
275
+ */
276
+ function autoReviewRouteOf(settings) {
277
+ const provider = settings?.autoReviewProvider?.trim();
278
+ const model = settings?.autoReviewModel?.trim();
279
+ if (provider === void 0 || provider === "" || model === void 0 || model === "") return void 0;
280
+ return {
281
+ provider,
282
+ model
283
+ };
284
+ }
285
+ /**
286
+ * Wrap the real llm service so only `stream()` route fields are rewritten.
287
+ *
288
+ * Non-`stream` members pass through bound to the real service (Proxy reads
289
+ * would otherwise hand methods the proxy as `this`). The route is re-read per
290
+ * call so a settings change applies to the very next review without a reload.
291
+ *
292
+ * @param real - the host's `ctx.llm`.
293
+ * @param route - live route resolver; undefined means "do not rewrite".
294
+ * @returns A structurally compatible llm face for the scoped context.
295
+ */
296
+ function reviewLlm(real, route) {
297
+ return new Proxy(real, { get(target, property, _receiver) {
298
+ if (property === "stream") return (options) => {
299
+ const pinned = route();
300
+ return pinned === void 0 ? target.stream(options) : target.stream({
301
+ ...options,
302
+ provider: pinned.provider,
303
+ model: pinned.model
304
+ });
305
+ };
306
+ const value = Reflect.get(target, property, target);
307
+ return typeof value === "function" ? value.bind(target) : value;
308
+ } });
309
+ }
310
+ /**
311
+ * Resolve the reviewer route for one session (or deployment-wide when no session is bound).
312
+ *
313
+ * @param ctx - host context whose modelSwitch settings and sessionProjections derive the route.
314
+ * @param session - the session whose tool call is being reviewed, when known.
315
+ * @returns The derived reviewer route, or undefined to follow the session model.
316
+ */
317
+ function resolveSessionAutoReviewRoute(ctx, session) {
318
+ let settings;
319
+ try {
320
+ settings = ctx.modelSwitch.currentSettings();
321
+ } catch {
322
+ settings = void 0;
323
+ }
324
+ const provider = sessionProviderOf(ctx, session);
325
+ return resolveCapabilityRoute(settings, provider, "autoReview");
326
+ }
327
+ /**
328
+ * Install the outer `tools/pre-execute` correlation listener ahead of the official gate.
329
+ *
330
+ * Because Cordis `on(..., { prepend: true })` unshifts onto the event hook list,
331
+ * registering this listener AFTER `official.apply(scope)` places it at index 0
332
+ * — directly ahead of the official gate's own prepended listener — so
333
+ * `activeSession.run(exec.agent?.session, next)` wraps the entire official
334
+ * review execution.
335
+ *
336
+ * @param scope - the review scope context where the gate is mounted.
337
+ * @param activeSession - AsyncLocalStorage holding the session under review.
338
+ */
339
+ function bindAutoReviewSessionCorrelation(scope, activeSession) {
340
+ scope.effect(function* () {
341
+ yield scope.on("tools/pre-execute", (exec, next) => activeSession.run(exec.agent?.session, next), { prepend: true });
342
+ }, "model-switch: auto-review session correlation");
343
+ }
344
+ /**
345
+ * Signatures of a broken reviewer seam, never of a real denial.
346
+ *
347
+ * The official gate's `tools/pre-execute` probe resolves its own
348
+ * `sessionProjections` injection before anything can catch it; when that fiber
349
+ * chain breaks it throws `cannot get required service ... in inactive context`
350
+ * (or, on older lines, `cannot get property ... without inject`) straight into
351
+ * the tool call. `TRANSPORT` covers a reviewer that cannot reach its route at
352
+ * all. A denial is a *return* (`{ kind: 'deny' }`), so it never matches.
353
+ */
354
+ const REVIEWER_INFRA_FAILURE_SIGNATURES = [
355
+ "inactive context",
356
+ "cannot get required service",
357
+ "cannot get property",
358
+ "without inject",
359
+ "TRANSPORT"
360
+ ];
361
+ /**
362
+ * Whether a reviewer-path throw is a broken seam rather than a real decision.
363
+ * @param cause - whatever the waterfall threw.
364
+ * @returns true when the reviewer itself could not run.
365
+ */
366
+ function isReviewerInfrastructureFailure(cause) {
367
+ const text = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
368
+ return REVIEWER_INFRA_FAILURE_SIGNATURES.some((signature) => text.includes(signature));
369
+ }
370
+ /**
371
+ * Keep a broken reviewer seam from failing tool calls.
372
+ *
373
+ * Registered on this row's own context through `{ prepend: true }`, so it sits
374
+ * ahead of the official gate's listener and stays live even when the gate could
375
+ * not be mounted (the takeover lost `registerAuto`, or the displaced row has
376
+ * not released it yet). That is the one window the defensive
377
+ * `permissionPresets` shadow cannot cover: the shadow lives in this row's review
378
+ * scope, while the serving gate resolves the real service from the profile.
379
+ *
380
+ * The waterfall only decides whether the body runs; the body dispatches after
381
+ * it returns, so allowing here never masks a tool failure. Reviewer transport
382
+ * and quota errors keep failing closed — only seam breakage is allowed through.
383
+ *
384
+ * @param ctx - this row's context (the listener lives and dies with the plugin).
385
+ * @param options - diagnostics plus the degraded-state predicate.
386
+ */
387
+ function installToolCallSafetyNet(ctx, options = {}) {
388
+ let reported = false;
389
+ const handler = async (_exec, next) => {
390
+ try {
391
+ return await next();
392
+ } catch (cause) {
393
+ if (!isReviewerInfrastructureFailure(cause)) throw cause;
394
+ if (options.isDegraded !== void 0 && !options.isDegraded()) throw cause;
395
+ if (!reported) {
396
+ reported = true;
397
+ const detail = options.fiberChain === void 0 ? "" : `; fiber chain: ${options.fiberChain()}`;
398
+ ctx.logger.warn(`model-switch: the Auto reviewer path is broken (${String(cause)}); tool calls run unreviewed instead of failing${detail}`);
399
+ }
400
+ autoReviewStatus.allowedCalls += 1;
401
+ return { kind: "allow" };
402
+ }
403
+ };
404
+ ctx.effect(function* () {
405
+ yield ctx.on("tools/pre-execute", handler, { prepend: true });
406
+ }, "model-switch: reviewer-path safety net");
407
+ }
408
+ /** The preset name the service reports when no configured preset matches. */
409
+ const CUSTOM_PRESET = "custom";
410
+ /** Read the live permission-preset service without requiring an injection. */
411
+ function permissionPresetsOf(ctx) {
412
+ try {
413
+ const face = ctx.get("permissionPresets", false);
414
+ return face !== null && typeof face === "object" ? face : void 0;
415
+ } catch {
416
+ return;
417
+ }
418
+ }
419
+ /**
420
+ * A compact dump of the fiber chain a service resolution walks.
421
+ *
422
+ * `cannot get required service "sessionProjections" in inactive context` is
423
+ * thrown by the first fiber in the caller's chain that declares the service
424
+ * without a resolved snapshot; the chain names that fiber directly.
425
+ * @param ctx - the context whose chain is walked.
426
+ * @returns One `uid/state/name/inject` segment per fiber, outermost last.
427
+ */
428
+ function fiberChainOf(ctx) {
429
+ const parts = [];
430
+ let node = ctx;
431
+ for (let step = 0; step < 8; step += 1) {
432
+ const fiber = node?.fiber;
433
+ if (fiber === void 0) break;
434
+ const injects = Object.keys(fiber.inject ?? {}).join("|");
435
+ parts.push(`uid=${String(fiber.uid)} state=${String(fiber.state)} name=${String(fiber.name)} inject=[${injects}]`);
436
+ node = fiber.parent;
437
+ }
438
+ return parts.length === 0 ? "no fiber chain" : parts.join(" -> ");
439
+ }
440
+ /**
441
+ * Wrap the permission-preset service so the official gate's per-call probe can
442
+ * never fail a tool call, and broker its single Auto registration.
443
+ *
444
+ * DSH's Auto gate starts every `tools/pre-execute` with
445
+ * `permissionPresets.current(session)`, and that method resolves the presets
446
+ * service's own `sessionProjections` injection. When the presets fiber has
447
+ * turned inactive the probe throws `cannot get required service
448
+ * "sessionProjections" in inactive context` outside the gate's reviewer error
449
+ * wrapper, so every tool call dies. This wrapper resolves the service lazily
450
+ * (so the shadow is in place even while the service is still activating),
451
+ * degrades the probe to `custom` (the gate then skips review and calls
452
+ * `next()`), and reports the lost capability once — with the fiber chain that
453
+ * failed resolution, so the next occurrence names the inactive fiber.
454
+ *
455
+ * `registerAuto` is **brokered**: the gate yields the disposer into its own
456
+ * effect, but the takeover must be able to hand the single registration to a
457
+ * displacing row and take it back without unmounting the gate (the gate's
458
+ * disposal would move live `auto` sessions to `danger-full-access`).
459
+ *
460
+ * @param resolve - resolves the host's permission-preset service per call.
461
+ * @param log - the row logger used for the single degradation report.
462
+ * @param chain - diagnostic dump of the resolution chain; defaults to empty.
463
+ * @returns A managed preset face.
464
+ */
465
+ function defensivePermissionPresets(resolve, log, chain = () => "") {
466
+ let reported = false;
467
+ let admit;
468
+ let release;
469
+ const degrade = (operation, error) => {
470
+ if (reported) return;
471
+ reported = true;
472
+ 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()}]`);
473
+ };
474
+ const requireService = () => {
475
+ const face = resolve();
476
+ if (face === void 0) throw new Error("permission: the permission-preset service is unavailable");
477
+ return face;
478
+ };
479
+ return {
480
+ current(session) {
481
+ try {
482
+ return requireService().current(session);
483
+ } catch (error) {
484
+ degrade("current()", error);
485
+ return CUSTOM_PRESET;
486
+ }
487
+ },
488
+ set(session, preset) {
489
+ try {
490
+ return requireService().set(session, preset);
491
+ } catch (error) {
492
+ degrade("set()", error);
493
+ return;
494
+ }
495
+ },
496
+ registerAuto(next) {
497
+ admit = next;
498
+ release = requireService().registerAuto(next);
499
+ return () => {
500
+ admit = void 0;
501
+ const dispose = release;
502
+ release = void 0;
503
+ return dispose?.();
504
+ };
505
+ },
506
+ releaseForTakeover() {
507
+ const dispose = release;
508
+ release = void 0;
509
+ dispose?.();
510
+ },
511
+ reclaimRegistration() {
512
+ if (release !== void 0) return true;
513
+ if (admit === void 0) return false;
514
+ try {
515
+ release = requireService().registerAuto(admit);
516
+ return true;
517
+ } catch {
518
+ return false;
519
+ }
520
+ },
521
+ get registered() {
522
+ return release !== void 0;
523
+ }
524
+ };
525
+ }
526
+ /**
527
+ * Build the scoped context the official gate runs under.
528
+ * @param ctx - host context whose modelSwitch settings pin the reviewer route.
529
+ * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
530
+ * @param presets - optional managed preset face shared with the row's takeover.
531
+ * @returns A child context shadowing the llm service and the permission-preset
532
+ * service (always: a gate that sees the raw service can fail a tool call).
533
+ */
534
+ function reviewScope(ctx, activeSession = new AsyncLocalStorage(), presets = defensivePermissionPresets(() => permissionPresetsOf(ctx), ctx.logger, () => fiberChainOf(ctx))) {
535
+ return ctx.extend({
536
+ llm: reviewLlm(ctx.llm, () => resolveSessionAutoReviewRoute(ctx, activeSession.getStore())),
537
+ permissionPresets: presets
538
+ });
539
+ }
540
+ /** Structural read of launcher-provided profile facts (the app-boot seam). */
541
+ function autoReviewBundleActive(ctx) {
542
+ let profile;
543
+ try {
544
+ profile = ctx.get("profileContext", false);
545
+ } catch {
546
+ return false;
547
+ }
548
+ if (profile === null || typeof profile !== "object") return false;
549
+ const bundles = profile.startedBundles;
550
+ return Array.isArray(bundles) && bundles.includes(AUTO_REVIEW_BUNDLE);
551
+ }
552
+ /** Structural read of the loader service the takeover drives. */
553
+ function loaderOf(ctx) {
554
+ try {
555
+ const loader = ctx.get("loader", false);
556
+ return loader !== null && typeof loader === "object" ? loader : void 0;
557
+ } catch {
558
+ return;
559
+ }
560
+ }
561
+ /**
562
+ * Wrap the official gate as a plugin that declares its own dependencies.
563
+ *
564
+ * Directly calling `official.apply(scope)` ran the gate's code on this row's
565
+ * fiber, so every service the gate reads had to be mirrored in this row's
566
+ * `inject`. Mounting it instead lets cordis resolve the official list, which is
567
+ * what keeps a released Host's new dependency (0.2.1's `workingDirectory`) from
568
+ * breaking every tool call. The hooks let the caller observe whether the gate
569
+ * actually ran before it binds the session-correlation listener.
570
+ *
571
+ * @param gate - the official module (or an equivalent structural gate).
572
+ * @param hooks - applied/failure observers; the failure is rethrown.
573
+ * @returns A cordis plugin mounting the official gate unchanged.
574
+ */
575
+ function autoReviewGate(gate, hooks = {}) {
576
+ return {
577
+ name: gate.name ?? "experimental-auto-review",
578
+ ...gate.inject === void 0 ? {} : { inject: [...gate.inject] },
579
+ apply(gateCtx) {
580
+ try {
581
+ gate.apply(gateCtx);
582
+ } catch (error) {
583
+ hooks.onFailure?.(error);
584
+ throw error;
585
+ }
586
+ hooks.onApplied?.();
587
+ }
588
+ };
589
+ }
590
+ /**
591
+ * The gate fiber mounted by the current row instance.
592
+ *
593
+ * `registerAuto` is a single live registration owned by the gate's effect
594
+ * lifetime, and its disposer is what clears the Auto preset. A re-applied row
595
+ * must await the previous gate's unloading before the next registration runs,
596
+ * otherwise the new `registerAuto` throws `already registered` and its effect
597
+ * cleanup leaves the catalog without Auto.
598
+ */
599
+ let lastGateFiber;
600
+ /**
601
+ * Mount the official gate under the reviewer-route scope and own the seam.
602
+ *
603
+ * A profile that never enabled the optional bundle keeps no Auto preset and no
604
+ * reviewer; a deployment missing the official package reports the lost
605
+ * capability explicitly instead of guessing another path. A composed (or
606
+ * plugin-manager re-enabled) official row is displaced first — see
607
+ * `./auto-review-takeover.ts` for the choreography and why the yield is
608
+ * synchronous.
609
+ */
610
+ async function apply(ctx) {
611
+ if (!autoReviewBundleActive(ctx)) {
612
+ ctx.logger.info("model-switch: auto-review bundle is not active in this profile; the reviewer route override stays unmounted");
613
+ return;
614
+ }
615
+ let official;
616
+ try {
617
+ official = await import("@deepseek-ai/dsh-experimental-auto-review");
618
+ } catch (cause) {
619
+ ctx.logger.error("model-switch: @deepseek-ai/dsh-experimental-auto-review is unavailable; the Auto reviewer cannot run at all");
620
+ ctx.logger.error(cause);
621
+ return;
622
+ }
623
+ const presets = defensivePermissionPresets(() => permissionPresetsOf(ctx), ctx.logger, () => fiberChainOf(ctx));
624
+ const activeSession = new AsyncLocalStorage();
625
+ const scope = reviewScope(ctx, activeSession, presets);
626
+ installToolCallSafetyNet(ctx, {
627
+ fiberChain: () => fiberChainOf(ctx),
628
+ isDegraded: () => !presets.registered
629
+ });
630
+ let gateReady = false;
631
+ let mountInFlight;
632
+ /** Sessions currently on the Auto preset (best-effort). */
633
+ const rememberAuto = () => {
634
+ const remembered = [];
635
+ try {
636
+ const sessions = ctx.get("sessions", false);
637
+ for (const session of sessions?.list?.() ?? []) if (presets.current(session) === AUTO_PRESET) remembered.push(session);
638
+ } catch {}
639
+ return remembered;
640
+ };
641
+ const restoreAuto = (remembered) => {
642
+ for (const session of remembered) try {
643
+ presets.set(session, AUTO_PRESET);
644
+ } catch {}
645
+ };
646
+ /**
647
+ * Run one displacement step without losing the sessions' Auto selection.
648
+ *
649
+ * Disposing a gate moves live `auto` sessions to `danger-full-access` by
650
+ * design (the official teardown runs on both the displaced row and this
651
+ * row's own re-mounts); the remembered selection is restored afterwards.
652
+ */
653
+ const preserveAuto = async (action) => {
654
+ const remembered = rememberAuto();
655
+ await action();
656
+ restoreAuto(remembered);
657
+ };
658
+ /** Mount this row's shielded gate exactly once; failures dispose and report. */
659
+ const mountGate = () => {
660
+ mountInFlight ??= (async () => {
661
+ let outcome = "failed";
662
+ await preserveAuto(async () => {
663
+ const previous = lastGateFiber;
664
+ lastGateFiber = void 0;
665
+ gateReady = false;
666
+ if (previous !== void 0) try {
667
+ await previous.dispose();
668
+ } catch (cause) {
669
+ ctx.logger.info("model-switch: the previous auto-review gate had already unloaded; continuing with the new one");
670
+ ctx.logger.info(String(cause));
671
+ }
672
+ let failure;
673
+ let applied = false;
674
+ const fiber = scope.plugin(autoReviewGate(official, {
675
+ onApplied: () => {
676
+ applied = true;
677
+ bindAutoReviewSessionCorrelation(scope, activeSession);
678
+ },
679
+ onFailure: (error) => {
680
+ failure = error;
681
+ }
682
+ }));
683
+ try {
684
+ await fiber;
685
+ } catch (cause) {
686
+ failure ??= cause;
687
+ }
688
+ if (failure !== void 0) {
689
+ try {
690
+ await fiber.dispose();
691
+ } catch {}
692
+ ctx.logger.warn(`model-switch: the Auto reviewer gate did not mount (${String(failure)}); the displaced registration may still be releasing`);
693
+ outcome = "failed";
694
+ return;
695
+ }
696
+ if (!applied) {
697
+ ctx.logger.info("model-switch: the official auto-review gate is waiting for its host services; the reviewer route override is not bound yet");
698
+ outcome = "waiting";
699
+ return;
700
+ }
701
+ const gate = fiber;
702
+ const disposeGate = gate.dispose.bind(gate);
703
+ gate.dispose = () => {
704
+ presets.releaseForTakeover();
705
+ return disposeGate();
706
+ };
707
+ lastGateFiber = gate;
708
+ gateReady = true;
709
+ outcome = "applied";
710
+ });
711
+ return outcome;
712
+ })().finally(() => {
713
+ mountInFlight = void 0;
714
+ });
715
+ return mountInFlight;
716
+ };
717
+ /** Make this row's Auto registration live; true once it is held. */
718
+ const claim = async () => {
719
+ let held;
720
+ if (presets.registered) held = true;
721
+ else if (gateReady) held = presets.reclaimRegistration();
722
+ else held = await mountGate() !== "failed";
723
+ autoReviewStatus.held = held;
724
+ return held;
725
+ };
726
+ const reclaim = installAutoReviewTakeover(ctx, {
727
+ yieldRegistration: () => presets.releaseForTakeover(),
728
+ claim,
729
+ preserveAuto
730
+ });
731
+ const entry = officialAutoReviewEntry(loaderOf(ctx));
732
+ if (entry !== void 0 && entry.disabled !== true) await reclaim(entry);
733
+ else await claim();
734
+ if (!presets.registered) {
735
+ autoReviewStatus.held = false;
736
+ 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");
737
+ }
738
+ ctx.effect(function* () {
739
+ yield () => {
740
+ presets.releaseForTakeover();
741
+ };
742
+ }, "model-switch: release the Auto registration before anything else");
743
+ }
744
+ //#endregion
745
+ 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 };