dsh-model-switch 0.4.35 → 0.4.37

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