dsh-loop-engine 0.1.5-rc3 → 0.1.5-rc5

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.
Files changed (54) hide show
  1. package/README.md +44 -187
  2. package/README.zh.md +45 -103
  3. package/lib/client.js +887 -266
  4. package/lib/index.js +2216 -915
  5. package/lib/invariant.js +43 -45
  6. package/lib/types/agent-preset-ids.d.ts +303 -0
  7. package/lib/types/client/LoopEngineBadge.d.ts +44 -17
  8. package/lib/types/client/LoopEngineComposerSelect.d.ts +79 -13
  9. package/lib/types/client/LoopEngineSection.d.ts +5 -4
  10. package/lib/types/client/locales.d.ts +133 -7
  11. package/lib/types/client/reload.d.ts +135 -0
  12. package/lib/types/client/session-engine.d.ts +474 -0
  13. package/lib/types/client/store.d.ts +1 -1
  14. package/lib/types/client/turn-status.d.ts +112 -10
  15. package/lib/types/client/use-session-engine.d.ts +66 -0
  16. package/lib/types/commands.d.ts +11 -3
  17. package/lib/types/driver-core/host-servers.d.ts +106 -0
  18. package/lib/types/driver-core/hosted-engine-runtime.d.ts +190 -0
  19. package/lib/types/driver-core/hosted-tool-vocabulary.d.ts +72 -0
  20. package/lib/types/driver-core/model-handover.d.ts +134 -0
  21. package/lib/types/driver-core/ownership.d.ts +6 -5
  22. package/lib/types/driver-core/prompt.d.ts +32 -0
  23. package/lib/types/driver-core/session-lifetime.d.ts +62 -0
  24. package/lib/types/driver-core/session-model.d.ts +82 -0
  25. package/lib/types/engine-claude/agent.d.ts +23 -3
  26. package/lib/types/engine-claude/loop.d.ts +16 -15
  27. package/lib/types/engine-codex/agent.d.ts +22 -3
  28. package/lib/types/engine-codex/appserver/client.d.ts +15 -2
  29. package/lib/types/engine-codex/loop.d.ts +13 -15
  30. package/lib/types/engine-codex/model-handover.d.ts +56 -0
  31. package/lib/types/engine-kimi/acp/client.d.ts +10 -0
  32. package/lib/types/engine-kimi/agent.d.ts +19 -2
  33. package/lib/types/engine-kimi/commands.d.ts +18 -14
  34. package/lib/types/engine-kimi/loop.d.ts +14 -16
  35. package/lib/types/engine-kimi/model-handover.d.ts +32 -0
  36. package/lib/types/engine-kimi/process.d.ts +2 -2
  37. package/lib/types/engine-kimi/types.d.ts +1 -1
  38. package/lib/types/engine-of-session.d.ts +97 -0
  39. package/lib/types/engine-pi/agent.d.ts +25 -23
  40. package/lib/types/engine-pi/loop.d.ts +13 -23
  41. package/lib/types/engine-pi/model-handover.d.ts +39 -0
  42. package/lib/types/engine-pi/types.d.ts +2 -2
  43. package/lib/types/engine-remote.d.ts +192 -0
  44. package/lib/types/engine-surface.d.ts +36 -0
  45. package/lib/types/index.d.ts +51 -50
  46. package/lib/types/invariant.d.ts +8 -5
  47. package/lib/types/model-selection-reset.d.ts +271 -0
  48. package/lib/types/patch-manager.d.ts +57 -39
  49. package/lib/types/preset.d.ts +39 -26
  50. package/lib/types/provider-route.d.ts +83 -36
  51. package/lib/types/router-loop.d.ts +406 -0
  52. package/lib/types/session-engine-store.d.ts +138 -0
  53. package/lib/types/settings.d.ts +12 -11
  54. package/package.json +109 -104
package/lib/index.js CHANGED
@@ -1,7 +1,51 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
1
4
  var __knownSymbol = (name2, symbol) => (symbol = Symbol[name2]) ? symbol : /* @__PURE__ */ Symbol.for("Symbol." + name2);
2
5
  var __typeError = (msg) => {
3
6
  throw TypeError(msg);
4
7
  };
8
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
9
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
10
+ var __decoratorStart = (base) => [, , , __create(base?.[__knownSymbol("metadata")] ?? null)];
11
+ var __decoratorStrings = ["class", "method", "getter", "setter", "accessor", "field", "value", "get", "set"];
12
+ var __expectFn = (fn) => fn !== void 0 && typeof fn !== "function" ? __typeError("Function expected") : fn;
13
+ var __decoratorContext = (kind, name2, done, metadata, fns) => ({ kind: __decoratorStrings[kind], name: name2, metadata, addInitializer: (fn) => done._ ? __typeError("Already initialized") : fns.push(__expectFn(fn || null)) });
14
+ var __decoratorMetadata = (array, target) => __defNormalProp(target, __knownSymbol("metadata"), array[3]);
15
+ var __runInitializers = (array, flags, self, value) => {
16
+ for (var i = 0, fns = array[flags >> 1], n = fns && fns.length; i < n; i++) flags & 1 ? fns[i].call(self) : value = fns[i].call(self, value);
17
+ return value;
18
+ };
19
+ var __decorateElement = (array, flags, name2, decorators, target, extra) => {
20
+ var fn, it, done, ctx, access, k = flags & 7, s = !!(flags & 8), p = !!(flags & 16);
21
+ var j = k > 3 ? array.length + 1 : k ? s ? 1 : 2 : 0, key = __decoratorStrings[k + 5];
22
+ var initializers = k > 3 && (array[j - 1] = []), extraInitializers = array[j] || (array[j] = []);
23
+ var desc = k && (!p && !s && (target = target.prototype), k < 5 && (k > 3 || !p) && __getOwnPropDesc(k < 4 ? target : { get [name2]() {
24
+ return __privateGet(this, extra);
25
+ }, set [name2](x) {
26
+ return __privateSet(this, extra, x);
27
+ } }, name2));
28
+ k ? p && k < 4 && __name(extra, (k > 2 ? "set " : k > 1 ? "get " : "") + name2) : __name(target, name2);
29
+ for (var i = decorators.length - 1; i >= 0; i--) {
30
+ ctx = __decoratorContext(k, name2, done = {}, array[3], extraInitializers);
31
+ if (k) {
32
+ ctx.static = s, ctx.private = p, access = ctx.access = { has: p ? (x) => __privateIn(target, x) : (x) => name2 in x };
33
+ if (k ^ 3) access.get = p ? (x) => (k ^ 1 ? __privateGet : __privateMethod)(x, target, k ^ 4 ? extra : desc.get) : (x) => x[name2];
34
+ if (k > 2) access.set = p ? (x, y) => __privateSet(x, target, y, k ^ 4 ? extra : desc.set) : (x, y) => x[name2] = y;
35
+ }
36
+ it = (0, decorators[i])(k ? k < 4 ? p ? extra : desc[key] : k > 4 ? void 0 : { get: desc.get, set: desc.set } : target, ctx), done._ = 1;
37
+ if (k ^ 4 || it === void 0) __expectFn(it) && (k > 4 ? initializers.unshift(it) : k ? p ? extra = it : desc[key] = it : target = it);
38
+ else if (typeof it !== "object" || it === null) __typeError("Object expected");
39
+ else __expectFn(fn = it.get) && (desc.get = fn), __expectFn(fn = it.set) && (desc.set = fn), __expectFn(fn = it.init) && initializers.unshift(fn);
40
+ }
41
+ return k || __decoratorMetadata(array, target), desc && __defProp(target, name2, desc), p ? k ^ 4 ? extra : desc : target;
42
+ };
43
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
44
+ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
45
+ var __privateIn = (member, obj) => Object(obj) !== obj ? __typeError('Cannot use the "in" operator on this value') : member.has(obj);
46
+ var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
47
+ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
48
+ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
5
49
  var __using = (stack, value, async) => {
6
50
  if (value != null) {
7
51
  if (typeof value !== "object" && typeof value !== "function") __typeError("Object expected");
@@ -45,12 +89,135 @@ var __callDispose = (stack, error, hasError) => {
45
89
  };
46
90
 
47
91
  // src/index.ts
48
- import { mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
92
+ import { readFileSync as readFileSync4 } from "node:fs";
49
93
  import { mkdir as mkdir2, readFile as readFile5, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
50
- import { randomUUID as randomUUID2 } from "node:crypto";
51
- import { dirname as dirname5, join as join12 } from "node:path";
94
+ import { randomUUID as randomUUID3 } from "node:crypto";
95
+ import { dirname as dirname6, join as join14 } from "node:path";
52
96
  import z6 from "@deepseek-ai/schemastery";
53
- import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
97
+ import { resolveDshHome as resolveDshHome2 } from "@deepseek-ai/dsh-home-paths";
98
+
99
+ // src/agent-preset-ids.ts
100
+ var LOOP_ENGINE_IDS = ["in-process", "claude-code", "codex", "pi", "kimi"];
101
+ var HOSTED_ENGINE_IDS = LOOP_ENGINE_IDS.filter(
102
+ (id) => id !== "in-process"
103
+ );
104
+ var HOSTED_ROUTE_LABEL = "external";
105
+ var HOSTED_ROUTE_NAME = "external";
106
+ var HOSTED_DEFAULT_MODEL = "default";
107
+ var HOSTED_PRESET_PREFIX = "loop-engine-";
108
+ var SOURCE_PRESET_ID = "standard";
109
+ var LEGACY_HOSTED_PRESET_ID = "loop-engine";
110
+ function enginePresetId(engine) {
111
+ return engine === "in-process" ? SOURCE_PRESET_ID : `${HOSTED_PRESET_PREFIX}${engine}`;
112
+ }
113
+ function isLoopEngineId(value) {
114
+ return LOOP_ENGINE_IDS.includes(value);
115
+ }
116
+ function engineOfPreset(presetId) {
117
+ if (presetId === void 0) return void 0;
118
+ const engine = presetId.startsWith(HOSTED_PRESET_PREFIX) ? presetId.slice(HOSTED_PRESET_PREFIX.length) : "";
119
+ return HOSTED_ENGINE_IDS.includes(engine) ? engine : void 0;
120
+ }
121
+ function sessionEngineOf(presetId) {
122
+ if (typeof presetId !== "string") return { kind: "unset" };
123
+ if (presetId === LEGACY_HOSTED_PRESET_ID) return { kind: "legacy" };
124
+ return { kind: "engine", engine: engineOfPreset(presetId) ?? "in-process" };
125
+ }
126
+ function hostedEngineOf(session) {
127
+ if (session.kind !== "engine" || session.engine === "in-process") return void 0;
128
+ return session.engine;
129
+ }
130
+
131
+ // src/engine-of-session.ts
132
+ async function engineOfSession(ctx, sessionId, records) {
133
+ var _stack = [];
134
+ try {
135
+ const recorded = records.engineOf(sessionId);
136
+ if (recorded !== void 0) return { kind: "engine", engine: recorded };
137
+ const query = ctx.get("sessionQuery");
138
+ if (query === void 0) return { kind: "unset" };
139
+ const observation = __using(_stack, await query.observeSession(sessionId, { projectionMode: "all" }));
140
+ return sessionEngineOf(observation.projections?.values.agentPreset);
141
+ } catch (_) {
142
+ var _error = _, _hasError = true;
143
+ } finally {
144
+ __callDispose(_stack, _error, _hasError);
145
+ }
146
+ }
147
+ async function engineReportOfSession(ctx, sessionId, records, liveEngineOf) {
148
+ const live = liveEngineOf(sessionId);
149
+ if (live === void 0) return { engine: await engineOfSession(ctx, sessionId, records) };
150
+ const engine = { kind: "engine", engine: live };
151
+ const recorded = records.engineOf(sessionId);
152
+ return recorded === void 0 || recorded === live ? { engine } : { engine, pending: recorded };
153
+ }
154
+
155
+ // src/engine-remote.ts
156
+ import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
157
+ import { SessionId } from "@deepseek-ai/dsh-session";
158
+ var LOOP_ENGINE_REMOTE_KEY = "loopEngine";
159
+ var LOOP_ENGINE_REMOTE_METHOD = "engine";
160
+ var LOOP_ENGINE_REMOTE_SELECT_METHOD = "select";
161
+ var _select_dec, _engine_dec, _a, _init;
162
+ var LoopEngineRemote = class extends (_a = TypertRemoteService, _engine_dec = [Remote(LOOP_ENGINE_REMOTE_METHOD)], _select_dec = [Remote(LOOP_ENGINE_REMOTE_SELECT_METHOD)], _a) {
163
+ /**
164
+ * @param ctx - the context the service (and its lifetime) belongs to.
165
+ * @param resolve - the router-less read; normally
166
+ * `engineOfSession(ctx, sessionId, records)`.
167
+ * @param router - where both endpoints find the mounted loop router.
168
+ * @param warn - diagnostic sink for a session that could not be read.
169
+ */
170
+ constructor(ctx, resolve5, router, warn) {
171
+ super(ctx, LOOP_ENGINE_REMOTE_KEY);
172
+ __runInitializers(_init, 5, this);
173
+ __publicField(this, "resolve");
174
+ __publicField(this, "router");
175
+ __publicField(this, "warn");
176
+ this.resolve = resolve5;
177
+ this.router = router;
178
+ this.warn = warn;
179
+ }
180
+ async engine(request) {
181
+ const sessionId = request?.sessionId;
182
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
183
+ throw new RemoteError("gateway/bad-request", "sessionId must be a non-empty string", {});
184
+ }
185
+ const router = this.router.current;
186
+ try {
187
+ return router === void 0 ? { engine: await this.resolve(SessionId(sessionId)) } : await router.reportEngine(SessionId(sessionId));
188
+ } catch (error) {
189
+ this.warn(`loop-engine: could not read the engine of session "${sessionId}": ${String(error)}`);
190
+ return { engine: { kind: "unset" } };
191
+ }
192
+ }
193
+ async select(request) {
194
+ const sessionId = request?.sessionId;
195
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
196
+ throw new RemoteError("gateway/bad-request", "sessionId must be a non-empty string", {});
197
+ }
198
+ const engine = request?.engine;
199
+ if (!isLoopEngineId(engine)) {
200
+ throw new RemoteError(
201
+ "gateway/bad-request",
202
+ `engine must be one of ${LOOP_ENGINE_IDS.join(", ")}`,
203
+ {}
204
+ );
205
+ }
206
+ const router = this.router.current;
207
+ if (router === void 0) {
208
+ return {
209
+ ok: false,
210
+ code: "router-unmounted",
211
+ reason: "this process cannot switch engines yet: the loop router is not mounted"
212
+ };
213
+ }
214
+ return await router.selectEngine(SessionId(sessionId), engine);
215
+ }
216
+ };
217
+ _init = __decoratorStart(_a);
218
+ __decorateElement(_init, 1, "engine", _engine_dec, LoopEngineRemote);
219
+ __decorateElement(_init, 1, "select", _select_dec, LoopEngineRemote);
220
+ __decoratorMetadata(_init, LoopEngineRemote);
54
221
 
55
222
  // src/engine-claude/loop.ts
56
223
  import z from "@deepseek-ai/schemastery";
@@ -60,7 +227,7 @@ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
60
227
  import { agentEvents } from "@deepseek-ai/dsh-agent";
61
228
  import {
62
229
  AssistantStreamAccumulator as AssistantStreamAccumulator2,
63
- LlmError,
230
+ LlmError as LlmError2,
64
231
  createAssistantMessage,
65
232
  createUserMessage,
66
233
  errorChain
@@ -243,6 +410,19 @@ function renderToolResult(message) {
243
410
  const tag = block.isError === true ? "tool-result-error" : "tool-result";
244
411
  return frame(tag, body || "(no content)");
245
412
  }
413
+ var ENGINE_SLASH_LINE = /^\/[^\s/]+(?:[ \t]+.*)?$/;
414
+ function engineSlashPrompt(messages) {
415
+ const last = messages.at(-1);
416
+ if (last === void 0 || last.role !== "user") return void 0;
417
+ const user = last;
418
+ if (user.source.kind !== "user") return void 0;
419
+ if (user.content.length !== 1) return void 0;
420
+ const block = user.content[0];
421
+ if (block?.type !== "text") return void 0;
422
+ const text = block.text;
423
+ if (text.includes("\n")) return void 0;
424
+ return ENGINE_SLASH_LINE.test(text) ? text : void 0;
425
+ }
246
426
  function serializeHistory(messages) {
247
427
  const sections = [];
248
428
  for (const message of messages) {
@@ -444,6 +624,141 @@ var DriverInbox = class {
444
624
  }
445
625
  };
446
626
 
627
+ // src/provider-route.ts
628
+ import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
629
+ function hostedRouteLabelOf(_engine) {
630
+ return HOSTED_ROUTE_LABEL;
631
+ }
632
+ var HOSTED_PROVIDER_LABELS = [HOSTED_ROUTE_LABEL, "claude-code", "codex", "pi", "kimi"];
633
+ function isHostedProviderRoute(provider) {
634
+ return HOSTED_PROVIDER_LABELS.includes(provider);
635
+ }
636
+ var HostedEngineRouteAdapter = class extends LlmAdapter {
637
+ /**
638
+ * Name the route's one entry.
639
+ *
640
+ * `id` must equal the provider the adapter was registered for (the registry
641
+ * rejects a mismatch — `packages/llm/llm/src/index.ts` `prepareRoutes`), so it
642
+ * is echoed back; `name` is the one localized string the catalog carries.
643
+ * @param provider - the route this adapter is registered for.
644
+ * @returns the route's display metadata.
645
+ */
646
+ providerInfo(provider) {
647
+ return { id: provider, name: HOSTED_ROUTE_NAME };
648
+ }
649
+ /**
650
+ * Advertise the route's single entry.
651
+ *
652
+ * The id is the model label the engine logs, so the picker resolves a hosted
653
+ * session's selection to this entry (and highlights its row) rather than
654
+ * falling through to the raw composite string; the name is the same word,
655
+ * rendered as-is.
656
+ * @returns the one entry this route advertises.
657
+ */
658
+ listModels(_provider) {
659
+ return Promise.resolve([{ provider: HOSTED_ROUTE_LABEL, id: HOSTED_DEFAULT_MODEL, name: HOSTED_DEFAULT_MODEL }]);
660
+ }
661
+ stream(_options) {
662
+ throw new LlmError(
663
+ `provider "${HOSTED_ROUTE_LABEL}" is a hosted loop engine route, not a model endpoint`,
664
+ "HOSTED_ENGINE_ROUTE"
665
+ );
666
+ }
667
+ };
668
+
669
+ // src/driver-core/session-model.ts
670
+ function currentSelection(session, projections) {
671
+ const pending = projections?.stateOf(session, "modelSelection")?.pending ?? null;
672
+ if (pending !== null) return pending;
673
+ const header = session.requestHeader()?.config;
674
+ return header === void 0 ? void 0 : { provider: header.provider, model: header.model };
675
+ }
676
+ function sessionModelOverride(selection) {
677
+ if (selection === void 0) return void 0;
678
+ if (isHostedProviderRoute(selection.provider)) return void 0;
679
+ return { provider: selection.provider, model: selection.model };
680
+ }
681
+ function sessionModelOverrideOf(ctx, session) {
682
+ return sessionModelOverride(
683
+ currentSelection(session, ctx.get("sessionProjections"))
684
+ );
685
+ }
686
+
687
+ // src/driver-core/model-handover.ts
688
+ var SHIPPED_ROUTE_APIS = {
689
+ "deepseek-official": "openai-completions"
690
+ };
691
+ var warned = /* @__PURE__ */ new WeakMap();
692
+ function warnOnce(ctx, key, message) {
693
+ let seen = warned.get(ctx);
694
+ if (seen === void 0) {
695
+ seen = /* @__PURE__ */ new Set();
696
+ warned.set(ctx, seen);
697
+ }
698
+ if (seen.has(key)) return;
699
+ seen.add(key);
700
+ ctx.logger.warn(message);
701
+ }
702
+ function settingsAddress(ctx, provider) {
703
+ const llm = ctx.get("llm");
704
+ return llm?.listConfigurableProviders?.().find((entry) => entry.provider === provider);
705
+ }
706
+ function readProviderProfile(ctx, address) {
707
+ const settings = ctx.get("settings");
708
+ let node = settings?.get?.(address.settingsNs);
709
+ for (const segment of address.settingsPath) {
710
+ if (node === null || typeof node !== "object") return void 0;
711
+ node = node[segment];
712
+ }
713
+ if (node === null || typeof node !== "object") return void 0;
714
+ const record = node;
715
+ const { baseURL, api, apiKeyEnv } = record;
716
+ if (typeof baseURL !== "string" || baseURL.length === 0) return void 0;
717
+ return {
718
+ baseURL,
719
+ api: typeof api === "string" && api.length > 0 ? api : void 0,
720
+ apiKeyEnv: typeof apiKeyEnv === "string" && apiKeyEnv.length > 0 ? apiKeyEnv : void 0
721
+ };
722
+ }
723
+ async function readApiKey(ctx, apiKeyEnv) {
724
+ if (apiKeyEnv === void 0) return void 0;
725
+ const credentials = ctx.get("credentials");
726
+ const stored = credentials === void 0 ? void 0 : (await credentials.resolve(apiKeyEnv))?.value;
727
+ if (stored !== void 0 && stored.length > 0) return stored;
728
+ const ambient = process.env[apiKeyEnv];
729
+ return ambient !== void 0 && ambient.length > 0 ? ambient : void 0;
730
+ }
731
+ async function resolveModelHandover(ctx, override) {
732
+ if (override === void 0) return void 0;
733
+ const address = settingsAddress(ctx, override.provider);
734
+ if (address === void 0) {
735
+ return refuse(ctx, override, `no llm registry route maps it to a settings namespace`);
736
+ }
737
+ const profile = readProviderProfile(ctx, address);
738
+ if (profile === void 0) {
739
+ return refuse(ctx, override, `the "${address.settingsNs}" settings section names no baseURL for it`);
740
+ }
741
+ const apiKey = await readApiKey(ctx, profile.apiKeyEnv);
742
+ if (apiKey === void 0) {
743
+ return refuse(ctx, override, "no credential resolves for its apiKeyEnv");
744
+ }
745
+ return {
746
+ provider: override.provider,
747
+ model: override.model,
748
+ baseURL: profile.baseURL,
749
+ api: profile.api ?? SHIPPED_ROUTE_APIS[override.provider],
750
+ apiKey
751
+ };
752
+ }
753
+ function refuse(ctx, override, reason) {
754
+ warnOnce(
755
+ ctx,
756
+ `${override.provider}/${override.model}:${reason}`,
757
+ `loop-engine: the session selected the dsh model "${override.provider}/${override.model}", but its endpoint could not be resolved (${reason}); the engine keeps its own provider configuration`
758
+ );
759
+ return void 0;
760
+ }
761
+
447
762
  // src/driver-core/assistant-stream.ts
448
763
  import {
449
764
  AssistantStreamAccumulator,
@@ -549,6 +864,95 @@ var DriverAssistantStream = class {
549
864
  }
550
865
  };
551
866
 
867
+ // src/driver-core/hosted-tool-vocabulary.ts
868
+ var TODO_STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed"]);
869
+ function recordOf(argumentsJson) {
870
+ let parsed;
871
+ try {
872
+ parsed = JSON.parse(argumentsJson);
873
+ } catch {
874
+ return void 0;
875
+ }
876
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
877
+ }
878
+ function stringField(value) {
879
+ return typeof value === "string" ? value : void 0;
880
+ }
881
+ var CLAUDE_NAMES = {
882
+ Write: "write",
883
+ Edit: "edit",
884
+ Read: "read",
885
+ Bash: "bash",
886
+ TodoWrite: "todo_write"
887
+ };
888
+ var KIMI_NAMES = {
889
+ Bash: "bash",
890
+ Read: "read",
891
+ Write: "write",
892
+ Edit: "edit"
893
+ };
894
+ var CODEX_NAMES = {
895
+ command_execution: "bash"
896
+ };
897
+ function withoutPath(args) {
898
+ const rest = { ...args };
899
+ delete rest.path;
900
+ return rest;
901
+ }
902
+ function piReshape(engineName, args) {
903
+ const path = stringField(args.path);
904
+ if (path === void 0) return void 0;
905
+ if (engineName === "write") {
906
+ const content = stringField(args.content);
907
+ return content === void 0 ? void 0 : { file_path: path, ...withoutPath(args), content };
908
+ }
909
+ if (engineName === "read") return { file_path: path, ...withoutPath(args) };
910
+ if (engineName !== "edit") return void 0;
911
+ const edits = args.edits;
912
+ if (!Array.isArray(edits) || edits.length !== 1) return void 0;
913
+ const edit = edits[0];
914
+ if (typeof edit !== "object" || edit === null) return void 0;
915
+ const oldText = stringField(edit.oldText);
916
+ const newText = stringField(edit.newText);
917
+ return oldText === void 0 || newText === void 0 ? void 0 : { file_path: path, old_string: oldText, new_string: newText };
918
+ }
919
+ function claudePlan(engineName, args) {
920
+ if (engineName !== "TodoWrite") return void 0;
921
+ const todos = args.todos;
922
+ if (!Array.isArray(todos)) return void 0;
923
+ const entries = [];
924
+ for (const entry of todos) {
925
+ if (typeof entry !== "object" || entry === null) continue;
926
+ const content = stringField(entry.content);
927
+ const status = entry.status;
928
+ if (content === void 0 || typeof status !== "string" || !TODO_STATUSES.has(status)) continue;
929
+ entries.push({ content, status });
930
+ }
931
+ return entries;
932
+ }
933
+ var VOCABULARIES = {
934
+ "claude-code": { names: CLAUDE_NAMES, plan: claudePlan },
935
+ codex: { names: CODEX_NAMES },
936
+ pi: { names: {}, reshape: piReshape },
937
+ kimi: { names: KIMI_NAMES }
938
+ };
939
+ function normalizeHostedToolCall(engine, name2, argumentsJson) {
940
+ const vocabulary = VOCABULARIES[engine];
941
+ const dshName = vocabulary.names[name2] ?? name2;
942
+ const args = recordOf(argumentsJson);
943
+ const reshaped = args === void 0 ? void 0 : vocabulary.reshape?.(name2, args);
944
+ return {
945
+ name: dshName,
946
+ arguments: reshaped === void 0 ? argumentsJson : JSON.stringify(reshaped)
947
+ };
948
+ }
949
+ function planTodosOfHostedTool(engine, name2, argumentsJson) {
950
+ const plan = VOCABULARIES[engine].plan;
951
+ if (plan === void 0) return void 0;
952
+ const args = recordOf(argumentsJson);
953
+ return args === void 0 ? void 0 : plan(name2, args);
954
+ }
955
+
552
956
  // src/driver-core/permission-knobs.ts
553
957
  var SANDBOX_MODES = ["read-only", "workspace-write", "danger-full-access"];
554
958
  var APPROVAL_POLICIES = ["ask", "never"];
@@ -798,8 +1202,7 @@ function invokedSkillNames(messages) {
798
1202
  }
799
1203
 
800
1204
  // src/engine-claude/agent.ts
801
- var PROVIDER = "claude-code";
802
- var NATIVE_MODEL_LABEL = "claude-code-native";
1205
+ var PROVIDER = HOSTED_ROUTE_LABEL;
803
1206
  function failureCode(subtype) {
804
1207
  switch (subtype) {
805
1208
  case "error_during_execution":
@@ -812,12 +1215,13 @@ function failureCode(subtype) {
812
1215
  }
813
1216
  }
814
1217
  var ClaudeCodeAgent = class {
815
- constructor(loopCtx, id, options, session, config) {
1218
+ constructor(loopCtx, id, options, session, config, spawn4) {
816
1219
  this.loopCtx = loopCtx;
817
1220
  this.id = id;
818
1221
  this.options = options;
819
1222
  this.session = session;
820
1223
  this.config = config;
1224
+ this.spawn = spawn4;
821
1225
  this.dispatch = agentEvents(loopCtx, this);
822
1226
  this.inbox = new DriverInbox(session, {
823
1227
  inserted: (message) => {
@@ -840,6 +1244,7 @@ var ClaudeCodeAgent = class {
840
1244
  options;
841
1245
  session;
842
1246
  config;
1247
+ spawn;
843
1248
  inbox;
844
1249
  phase;
845
1250
  activityDone = Promise.resolve();
@@ -1126,7 +1531,7 @@ var ClaudeCodeAgent = class {
1126
1531
  }
1127
1532
  turnEnds = {
1128
1533
  kind: "error",
1129
- error: error instanceof LlmError ? error.failure : { message: errorChain(error), code: "UNKNOWN" }
1534
+ error: error instanceof LlmError2 ? error.failure : { message: errorChain(error), code: "UNKNOWN" }
1130
1535
  };
1131
1536
  this.throwError(error);
1132
1537
  } finally {
@@ -1162,7 +1567,7 @@ var ClaudeCodeAgent = class {
1162
1567
  }
1163
1568
  /** Model label recorded in the request header for one lifecycle. */
1164
1569
  modelLabel() {
1165
- return this.config.model ?? NATIVE_MODEL_LABEL;
1570
+ return this.config.model ?? HOSTED_DEFAULT_MODEL;
1166
1571
  }
1167
1572
  /** Append the request header snapshot once per loop instance. */
1168
1573
  assertRequestHeader() {
@@ -1190,7 +1595,7 @@ var ClaudeCodeAgent = class {
1190
1595
  throw new Error(`agent "${this.id}": no working directory \u2014 start the session with cwd metadata`);
1191
1596
  }
1192
1597
  const history = this.session.deriveMessages();
1193
- const prompt = serializeHistory(history);
1598
+ const prompt = engineSlashPrompt(history) ?? serializeHistory(history);
1194
1599
  if (prompt.length === 0) {
1195
1600
  throw new Error(`agent "${this.id}": cannot derive a prompt from an empty session log`);
1196
1601
  }
@@ -1206,14 +1611,21 @@ var ClaudeCodeAgent = class {
1206
1611
  const diagnostics = [];
1207
1612
  let live;
1208
1613
  try {
1614
+ const override = sessionModelOverrideOf(this.loopCtx, this.session);
1615
+ const handover = await resolveModelHandover(this.loopCtx, override);
1616
+ const model = handover?.model ?? override?.model ?? this.config.model;
1209
1617
  const options = claudeQueryOptions({
1210
1618
  cwd,
1211
1619
  ...this.queryPermission(),
1212
- env: this.config.env,
1620
+ env: handover === void 0 ? this.config.env : {
1621
+ ...this.config.env,
1622
+ ANTHROPIC_BASE_URL: handover.baseURL,
1623
+ ANTHROPIC_AUTH_TOKEN: handover.apiKey
1624
+ },
1213
1625
  disposeGraceMs: this.config.disposeGraceMs,
1214
- ...this.config.model === void 0 ? {} : { model: this.config.model },
1626
+ ...model === void 0 ? {} : { model },
1215
1627
  ...this.config.maxTurns === void 0 ? {} : { maxTurns: this.config.maxTurns },
1216
- spawn: (spec) => this.loopCtx.subprocess.spawn(spec),
1628
+ spawn: this.spawn,
1217
1629
  onUnattended: (line) => {
1218
1630
  diagnostics.push(line);
1219
1631
  }
@@ -1297,13 +1709,16 @@ var ClaudeCodeAgent = class {
1297
1709
  }
1298
1710
  }
1299
1711
  for (const call of mapped.toolCalls) {
1712
+ const normalized = normalizeHostedToolCall("claude-code", call.name, call.arguments);
1300
1713
  this.session.append("tool/call", {
1301
1714
  turn: phase.turn,
1302
1715
  step: phase.step,
1303
1716
  callId: call.callId,
1304
- name: call.name,
1305
- arguments: call.arguments
1717
+ name: normalized.name,
1718
+ arguments: normalized.arguments
1306
1719
  });
1720
+ const todos = planTodosOfHostedTool("claude-code", call.name, call.arguments);
1721
+ if (todos !== void 0) this.session.append("todo/write", { todos: [...todos] });
1307
1722
  }
1308
1723
  break;
1309
1724
  }
@@ -1325,7 +1740,7 @@ var ClaudeCodeAgent = class {
1325
1740
  step: phase.step,
1326
1741
  message: createAssistantMessage({
1327
1742
  content: trailing,
1328
- source: { provider: PROVIDER, model: NATIVE_MODEL_LABEL }
1743
+ source: { provider: PROVIDER, model: HOSTED_DEFAULT_MODEL }
1329
1744
  }),
1330
1745
  ...pendingUsage === void 0 ? {} : { usage: pendingUsage },
1331
1746
  stream: attempt?.stream ?? []
@@ -1352,7 +1767,7 @@ var ClaudeCodeAgent = class {
1352
1767
  finished = true;
1353
1768
  } else {
1354
1769
  const summary = message.errors[0] ?? `claude code query failed (${message.subtype})`;
1355
- throw new LlmError(summary, failureCode(message.subtype));
1770
+ throw new LlmError2(summary, failureCode(message.subtype));
1356
1771
  }
1357
1772
  break;
1358
1773
  }
@@ -1361,7 +1776,7 @@ var ClaudeCodeAgent = class {
1361
1776
  }
1362
1777
  }
1363
1778
  if (!finished) {
1364
- throw new LlmError(
1779
+ throw new LlmError2(
1365
1780
  `agent "${this.id}": claude-code query ended without a result message`,
1366
1781
  "CLAUDE_CODE_NO_RESULT"
1367
1782
  );
@@ -1376,8 +1791,7 @@ var ClaudeCodeAgent = class {
1376
1791
  }
1377
1792
  };
1378
1793
 
1379
- // src/driver-core/hosted-loop-factory.ts
1380
- import { Service } from "@deepseek-ai/cordis";
1794
+ // src/driver-core/hosted-engine-runtime.ts
1381
1795
  import { emitAgentEvent } from "@deepseek-ai/dsh-agent";
1382
1796
  import { interruptedTurnClosers, SessionLogOffset, SessionPreparation } from "@deepseek-ai/dsh-session";
1383
1797
 
@@ -1469,35 +1883,83 @@ async function raceAbortCall(operation, signal, id, releaseAbandoned) {
1469
1883
  }
1470
1884
  }
1471
1885
 
1472
- // src/driver-core/hosted-loop-factory.ts
1473
- var HostedLoopFactory = class extends Service {
1474
- /** Validated configuration owned by the loop plugin. */
1886
+ // src/driver-core/session-lifetime.ts
1887
+ var SessionLifetime = class {
1888
+ /** The live session these resources belong to. */
1889
+ session;
1890
+ /** The write handle this lifetime owns, or `undefined` when no persistence backend is mounted. */
1891
+ handle;
1892
+ /** The store's detacher; `undefined` until the entry is bound by the transaction that entered the session. */
1893
+ detach;
1894
+ /**
1895
+ * @param session - the live session whose entry and write handle these are.
1896
+ * @param handle - the write handle the transaction acquired, if a backend is mounted.
1897
+ */
1898
+ constructor(session, handle) {
1899
+ this.session = session;
1900
+ this.handle = handle;
1901
+ }
1902
+ /**
1903
+ * Whether the session's store entry is already held: true for a lifetime a
1904
+ * retiring machine handed over, false for one whose transaction still has to
1905
+ * enter the session it prepared.
1906
+ */
1907
+ get entered() {
1908
+ return this.detach !== void 0;
1909
+ }
1910
+ /**
1911
+ * Bind the store entry this lifetime owns. Called once, by the transaction
1912
+ * that enters the session; a joined session's entry is already bound.
1913
+ * @param detach - the detacher the session's own `enter` returned.
1914
+ */
1915
+ bind(detach) {
1916
+ this.detach = detach;
1917
+ }
1918
+ /** Drain and close the write handle — the durability barrier that precedes leaving the store. */
1919
+ async closeHandle() {
1920
+ await this.handle?.close();
1921
+ }
1922
+ /** Remove the session from the store, publishing its paired disposal exactly once. */
1923
+ leaveStore() {
1924
+ this.detach?.();
1925
+ }
1926
+ };
1927
+
1928
+ // src/driver-core/hosted-engine-runtime.ts
1929
+ var HostedEngineRuntime = class {
1930
+ /** Validated configuration owned by this engine instance. */
1475
1931
  config;
1476
- ownership;
1932
+ /** Effect-label prefix; also identifies the engine in diagnostics. */
1933
+ label;
1477
1934
  /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
1478
1935
  runtime;
1479
- /** Cordis service name, also the prefix of every effect label. */
1480
- label;
1936
+ ownership;
1937
+ /**
1938
+ * @param ctx - the owning context; its fiber's unload tears every live agent down.
1939
+ * @param label - effect-label prefix, e.g. `agentLoopKimi`.
1940
+ * @param config - already-resolved engine configuration.
1941
+ */
1481
1942
  constructor(ctx, label, config) {
1482
- super(ctx, label);
1483
1943
  this.label = label;
1484
1944
  this.config = config;
1485
1945
  this.ownership = new FactoryOwnership(ctx.fiber);
1486
1946
  this.runtime = { ctx };
1947
+ ctx.reflect.provide(label, this);
1487
1948
  ctx.effect(() => () => this.ownership.dispose(), `${label}.transactions()`);
1488
- ctx.effect(() => ctx.agents.setFactory(this), `${label}.setFactory()`);
1489
- ctx.systemPrompt.variable("provider", (context) => context.agent?.options.provider);
1490
- ctx.systemPrompt.variable("model", (context) => context.agent?.options.model);
1491
- ctx.systemPrompt.variable("cwd", (context) => context.agent?.session.header.cwd);
1492
1949
  }
1493
1950
  /**
1494
1951
  * Construct the driver, scope, and one memoized reverse teardown for a new
1495
1952
  * agent. The teardown is registered with the factory and the owner fiber
1496
1953
  * BEFORE publication, so a mid-setup unload rolls everything back; `signal`
1497
1954
  * fuses caller cancellation with lifecycle teardown for setup awaits.
1955
+ *
1956
+ * `lifetime` carries the session's store entry and write handle rather than
1957
+ * this body owning them: a fresh transaction binds them when it publishes,
1958
+ * while a swap publishes a session whose entry another machine already bound
1959
+ * and whose write handle another machine already opened.
1498
1960
  */
1499
- /* jscpd:ignore-start -- ownership/transaction machinery mirrors the default agent-loop factory; depending on agent-loop is forbidden. */
1500
- prepare(ownerCtx, id, options, session, callerSignal, handle, parentAgent) {
1961
+ /* jscpd:ignore-start -- ownership/transaction machinery mirrors the default agent-loop factory: the harness exports the loop as a plugin, not as reusable transaction helpers, so the body is replicated rather than delegated to. */
1962
+ prepare(ownerCtx, id, options, session, callerSignal, lifetime, parentAgent) {
1501
1963
  ownerCtx.fiber.assertActive();
1502
1964
  if (!this.ownership.isActive()) throw new Error("agent loop is not active");
1503
1965
  if (callerSignal?.aborted) {
@@ -1514,9 +1976,9 @@ var HostedLoopFactory = class extends Service {
1514
1976
  callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1515
1977
  this.ownership.signal.addEventListener("abort", onFactoryTeardown, { once: true });
1516
1978
  let machine;
1517
- let detachSession;
1518
1979
  let detachAgent;
1519
1980
  let disposing;
1981
+ let handedOver = false;
1520
1982
  const machineReady = Promise.withResolvers();
1521
1983
  const dispose = (ownerTriggered = false) => disposing ??= (async () => {
1522
1984
  abort.abort(new Error(`agent "${id}" lifecycle disposed`));
@@ -1531,14 +1993,17 @@ var HostedLoopFactory = class extends Service {
1531
1993
  }
1532
1994
  } finally {
1533
1995
  try {
1534
- await handle?.close();
1996
+ if (!handedOver) await lifetime.closeHandle();
1535
1997
  } finally {
1536
1998
  try {
1537
1999
  detachAgent?.();
1538
- detachSession?.();
1539
2000
  } finally {
1540
- untrack();
1541
- if (!ownerTriggered) await unfollowOwner();
2001
+ try {
2002
+ if (!handedOver) lifetime.leaveStore();
2003
+ } finally {
2004
+ untrack();
2005
+ if (!ownerTriggered) await unfollowOwner();
2006
+ }
1542
2007
  }
1543
2008
  }
1544
2009
  }
@@ -1561,6 +2026,10 @@ var HostedLoopFactory = class extends Service {
1561
2026
  if (!abort.signal.aborted) return;
1562
2027
  throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason));
1563
2028
  };
2029
+ const retire = async () => {
2030
+ handedOver = true;
2031
+ await dispose();
2032
+ };
1564
2033
  try {
1565
2034
  const agent = machine = this.buildAgent(loopCtx, id, options, session);
1566
2035
  machineReady.resolve();
@@ -1570,15 +2039,16 @@ var HostedLoopFactory = class extends Service {
1570
2039
  signal: abort.signal,
1571
2040
  publish: (source) => {
1572
2041
  assertLive();
1573
- detachSession = agent.ctx.sessions.enter(session);
2042
+ const joining = lifetime.entered;
2043
+ if (!joining) lifetime.bind(agent.ctx.sessions.enter(session));
1574
2044
  detachAgent = loopCtx.agents.enter(agent, parentAgent);
1575
- agent.ctx.sessions.announce(session);
2045
+ if (!joining) agent.ctx.sessions.announce(session);
1576
2046
  assertLive();
1577
2047
  loopCtx.agents.announce(agent);
1578
2048
  assertLive();
1579
2049
  emitAgentEvent(loopCtx, agent, "agent/session-start", { source });
1580
2050
  assertLive();
1581
- return { agent, dispose };
2051
+ return { agent, dispose, retire, lifetime };
1582
2052
  },
1583
2053
  dispose
1584
2054
  };
@@ -1589,33 +2059,24 @@ var HostedLoopFactory = class extends Service {
1589
2059
  }
1590
2060
  }
1591
2061
  /** Prepare one Agent around an acquired Session, run setup, and publish it. */
1592
- async setupAndPublish(ownerCtx, id, preparation, agentOptions, setup, signal, source, stored, parentAgent) {
1593
- var _stack = [];
2062
+ async setupAndPublish(ownerCtx, id, session, agentOptions, setup, signal, source, lifetime, parentAgent, stored) {
2063
+ let prepared;
1594
2064
  try {
1595
- const ownedPreparation = __using(_stack, preparation);
1596
- const session = ownedPreparation.session;
1597
- let prepared;
1598
- try {
1599
- prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle, parentAgent);
1600
- } catch (error) {
1601
- await stored?.handle.close().catch(() => {
1602
- });
1603
- throw error;
1604
- }
1605
- try {
1606
- const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id);
1607
- setupCommit?.commit();
1608
- await this.appendUnstoredSuffix(stored, session);
1609
- return prepared.publish(source);
1610
- } catch (error) {
1611
- await prepared.dispose().catch(() => {
1612
- });
1613
- throw error;
1614
- }
1615
- } catch (_) {
1616
- var _error = _, _hasError = true;
1617
- } finally {
1618
- __callDispose(_stack, _error, _hasError);
2065
+ prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, lifetime, parentAgent);
2066
+ } catch (error) {
2067
+ await stored?.handle.close().catch(() => {
2068
+ });
2069
+ throw error;
2070
+ }
2071
+ try {
2072
+ const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id);
2073
+ setupCommit?.commit();
2074
+ await this.appendUnstoredSuffix(stored, session);
2075
+ return prepared.publish(source);
2076
+ } catch (error) {
2077
+ await prepared.dispose().catch(() => {
2078
+ });
2079
+ throw error;
1619
2080
  }
1620
2081
  }
1621
2082
  /**
@@ -1644,21 +2105,21 @@ var HostedLoopFactory = class extends Service {
1644
2105
  });
1645
2106
  }
1646
2107
  );
1647
- } catch (error) {
2108
+ return await this.setupAndPublish(
2109
+ ownerCtx,
2110
+ options.sessionId,
2111
+ preparation.session,
2112
+ options.agentOptions ?? {},
2113
+ options.setup,
2114
+ options.signal,
2115
+ "startup",
2116
+ new SessionLifetime(preparation.session, stored?.handle),
2117
+ options.parentAgent,
2118
+ stored
2119
+ );
2120
+ } finally {
1648
2121
  preparation[Symbol.dispose]();
1649
- throw error;
1650
2122
  }
1651
- return this.setupAndPublish(
1652
- ownerCtx,
1653
- options.sessionId,
1654
- preparation,
1655
- options.agentOptions ?? {},
1656
- options.setup,
1657
- options.signal,
1658
- "startup",
1659
- stored,
1660
- options.parentAgent
1661
- );
1662
2123
  })();
1663
2124
  this.ownership.trackWrapper(published);
1664
2125
  return published;
@@ -1710,6 +2171,37 @@ var HostedLoopFactory = class extends Service {
1710
2171
  }
1711
2172
  return this.resumeWith(ownerCtx, persistence, options);
1712
2173
  }
2174
+ /**
2175
+ * Build this engine's agent onto a LIVE session another engine's agent left,
2176
+ * taking the session's lifetime over.
2177
+ *
2178
+ * The counterpart of {@link HostedAgentHandle.retire}: together they are an
2179
+ * in-place engine swap, and the harness's own factory contract has neither.
2180
+ * Nothing is read from persistence and nothing is created in the store — the
2181
+ * successor drives the Session object that is already live and already
2182
+ * entered, so a browser half attached to that session sees no lifecycle edge
2183
+ * at all. The lifetime carries the still-open write handle, so the session
2184
+ * keeps being stored through the same channel it was already using.
2185
+ * @param ownerCtx - caller context that structurally owns the lifecycle.
2186
+ * @param options - the live session's lifetime, loop options, setup, and parent.
2187
+ * @returns the published handle, which owns the session from here on.
2188
+ */
2189
+ async swap(ownerCtx, options) {
2190
+ const session = options.lifetime.session;
2191
+ const published = (async () => await this.setupAndPublish(
2192
+ ownerCtx,
2193
+ session.id,
2194
+ session,
2195
+ options.agentOptions ?? {},
2196
+ options.setup,
2197
+ void 0,
2198
+ "resume",
2199
+ options.lifetime,
2200
+ options.parentAgent
2201
+ ))();
2202
+ this.ownership.trackWrapper(published);
2203
+ return published;
2204
+ }
1713
2205
  /** Resume through an explicit persistence service. */
1714
2206
  resumeWith(ownerCtx, persistence, options) {
1715
2207
  const id = options.resumeSessionId;
@@ -1759,13 +2251,14 @@ var HostedLoopFactory = class extends Service {
1759
2251
  return await this.setupAndPublish(
1760
2252
  ownerCtx,
1761
2253
  id,
1762
- preparation,
2254
+ preparation.session,
1763
2255
  options.agentOptions ?? {},
1764
2256
  options.setup,
1765
2257
  options.signal,
1766
2258
  "resume",
1767
- owned,
1768
- options.parentAgent
2259
+ new SessionLifetime(preparation.session, owned?.handle),
2260
+ options.parentAgent,
2261
+ owned
1769
2262
  );
1770
2263
  } finally {
1771
2264
  preparation?.[Symbol.dispose]();
@@ -1811,15 +2304,21 @@ function resolveConfig(config) {
1811
2304
  maxTurns: config.maxTurns
1812
2305
  };
1813
2306
  }
1814
- var ClaudeCodeLoop = class extends HostedLoopFactory {
1815
- /** Services the loop resolves through its own fiber; blessed identically to the package-level entry inject. */
1816
- static inject = ["agents", "sessions", "systemPrompt", "subprocess"];
2307
+ var CLAUDE_CODE_ENGINE_LABEL = "agentLoopClaudeCode";
2308
+ var ClaudeCodeLoop = class extends HostedEngineRuntime {
2309
+ /** Process-spawn capability handed to every agent, sandboxed by the subprocess seam. */
2310
+ spawn;
1817
2311
  constructor(ctx, config) {
1818
- super(ctx, "agentLoopClaudeCode", resolveConfig(config));
2312
+ super(ctx, CLAUDE_CODE_ENGINE_LABEL, resolveConfig(config));
2313
+ const subprocess = ctx.get("subprocess");
2314
+ if (subprocess === void 0) {
2315
+ throw new Error("loop-engine: the claude-code engine needs the dsh subprocess service on this context");
2316
+ }
2317
+ this.spawn = (spec) => subprocess.spawn(spec);
1819
2318
  }
1820
2319
  /** Construct the Claude Code driver for one prepared session. */
1821
2320
  buildAgent(loopCtx, id, options, session) {
1822
- return new ClaudeCodeAgent(loopCtx, id, options, session, this.config);
2321
+ return new ClaudeCodeAgent(loopCtx, id, options, session, this.config, this.spawn);
1823
2322
  }
1824
2323
  };
1825
2324
 
@@ -1828,10 +2327,38 @@ import z2 from "@deepseek-ai/schemastery";
1828
2327
 
1829
2328
  // src/engine-codex/agent.ts
1830
2329
  import { agentEvents as agentEvents2 } from "@deepseek-ai/dsh-agent";
1831
- import { LlmError as LlmError2, createAssistantMessage as createAssistantMessage2, createUserMessage as createUserMessage2, errorChain as errorChain2 } from "@deepseek-ai/dsh-llm";
2330
+ import { LlmError as LlmError3, createAssistantMessage as createAssistantMessage2, createUserMessage as createUserMessage2, errorChain as errorChain2 } from "@deepseek-ai/dsh-llm";
1832
2331
  import { createScope as createScope2 } from "@deepseek-ai/dsh-scope";
1833
2332
  import { canonicalHeader as canonicalHeader2 } from "@deepseek-ai/dsh-session";
1834
2333
 
2334
+ // src/engine-codex/model-handover.ts
2335
+ var CODEX_DSH_PROVIDER = "dsh";
2336
+ var CODEX_DSH_API_KEY_ENV = "DSH_LOOP_ENGINE_API_KEY";
2337
+ var CODEX_WIRE_APIS = {
2338
+ "openai-responses": "responses"
2339
+ };
2340
+ function tomlString(value) {
2341
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
2342
+ }
2343
+ function codexModelConfig(handover) {
2344
+ const wire = handover.api === void 0 ? void 0 : CODEX_WIRE_APIS[handover.api];
2345
+ const profile = [
2346
+ `name=${tomlString(CODEX_DSH_PROVIDER)}`,
2347
+ `base_url=${tomlString(handover.baseURL)}`,
2348
+ ...wire === void 0 ? [] : [`wire_api=${tomlString(wire)}`],
2349
+ `env_key=${tomlString(CODEX_DSH_API_KEY_ENV)}`
2350
+ ].join(",");
2351
+ return {
2352
+ argv: [
2353
+ "-c",
2354
+ `model_provider=${tomlString(CODEX_DSH_PROVIDER)}`,
2355
+ "-c",
2356
+ `model_providers.${CODEX_DSH_PROVIDER}={${profile}}`
2357
+ ],
2358
+ env: { [CODEX_DSH_API_KEY_ENV]: handover.apiKey }
2359
+ };
2360
+ }
2361
+
1835
2362
  // src/engine-codex/permission.ts
1836
2363
  var DEFAULT_CODEX_PERMISSION = {
1837
2364
  sandboxMode: "read-only",
@@ -1987,10 +2514,24 @@ var AppServerClient = class _AppServerClient {
1987
2514
  this.pending.clear();
1988
2515
  });
1989
2516
  }
1990
- /** Spawn the pinned app-server dependency and initialize the client. */
1991
- static async create() {
1992
- const proc = spawn(process.execPath, [codexCliEntrypoint(), "app-server"], {
1993
- stdio: ["pipe", "pipe", "pipe"]
2517
+ /**
2518
+ * Spawn the pinned app-server dependency and initialize the client.
2519
+ *
2520
+ * `argv` appends the driver's own overrides to the bare `app-server`
2521
+ * subcommand (codex's `-c key=value` configuration), and `env` layers the
2522
+ * driver's explicit entries over the ambient environment — the child still
2523
+ * needs `PATH` and, under a user's own codex setup, the ambient auth facts its
2524
+ * configuration reads, so this is an overlay, not a replacement. Both default
2525
+ * to nothing, which is the bare `codex app-server` the driver used before a
2526
+ * dsh endpoint was ever handed over.
2527
+ * @param argv - extra arguments after the `app-server` subcommand.
2528
+ * @param env - explicit environment entries layered over the ambient one.
2529
+ * @returns the initialized client.
2530
+ */
2531
+ static async create(argv = [], env = {}) {
2532
+ const proc = spawn(process.execPath, [codexCliEntrypoint(), "app-server", ...argv], {
2533
+ stdio: ["pipe", "pipe", "pipe"],
2534
+ env: { ...process.env, ...env }
1994
2535
  });
1995
2536
  const client = new _AppServerClient(proc);
1996
2537
  await client.initialize();
@@ -2302,8 +2843,7 @@ function mapMcpToolCall(item) {
2302
2843
  }
2303
2844
 
2304
2845
  // src/engine-codex/agent.ts
2305
- var PROVIDER2 = "codex";
2306
- var NATIVE_MODEL_LABEL2 = "codex-native";
2846
+ var PROVIDER2 = HOSTED_ROUTE_LABEL;
2307
2847
  function nonEmptyText(parts) {
2308
2848
  return parts !== void 0 && parts.some((part) => part.length > 0) ? parts.join("\n") : void 0;
2309
2849
  }
@@ -2377,10 +2917,30 @@ var CodexAgent = class {
2377
2917
  }
2378
2918
  /** Lazily created app-server client, reused across steps and released on scope teardown. */
2379
2919
  appServer;
2380
- /** Return the cached app-server client, spawning one on first use or after a dead process. */
2381
- async appServerClient() {
2382
- if (this.appServer !== void 0 && !this.appServer.closed) return this.appServer;
2383
- this.appServer = await AppServerClient.create();
2920
+ /**
2921
+ * The spawn configuration (`argv` + `env`) the cached client was built from.
2922
+ * Codex's endpoint lives in the child's own command line, so a handover change
2923
+ * must respawn the child rather than reuse one configured for another endpoint.
2924
+ */
2925
+ appServerConfig;
2926
+ /**
2927
+ * Return the cached app-server client, spawning one on first use, after a dead
2928
+ * process, or when the resolved dsh endpoint changed since the last spawn.
2929
+ *
2930
+ * A handover is codex's own `-c` configuration for a dsh provider, so a
2931
+ * different endpoint (different provider, base URL, protocol, or credential)
2932
+ * needs a different child — the config is fixed when the process starts.
2933
+ * @param handover - the session's resolved dsh endpoint, or undefined to leave codex to its own configuration.
2934
+ */
2935
+ async appServerClient(handover) {
2936
+ const modeled = handover === void 0 ? { argv: [], env: {} } : codexModelConfig(handover);
2937
+ const config = JSON.stringify(modeled);
2938
+ if (this.appServer !== void 0 && !this.appServer.closed && this.appServerConfig === config) {
2939
+ return this.appServer;
2940
+ }
2941
+ this.appServer?.dispose();
2942
+ this.appServer = await AppServerClient.create(modeled.argv, { ...this.config.env, ...modeled.env });
2943
+ this.appServerConfig = config;
2384
2944
  this.appServer.onRequest((method, params) => this.answerRequest(method, params));
2385
2945
  return this.appServer;
2386
2946
  }
@@ -2694,7 +3254,7 @@ var CodexAgent = class {
2694
3254
  }
2695
3255
  turnEnds = {
2696
3256
  kind: "error",
2697
- error: error instanceof LlmError2 ? error.failure : { message: errorChain2(error), code: "UNKNOWN" }
3257
+ error: error instanceof LlmError3 ? error.failure : { message: errorChain2(error), code: "UNKNOWN" }
2698
3258
  };
2699
3259
  this.throwError(error);
2700
3260
  } finally {
@@ -2712,7 +3272,7 @@ var CodexAgent = class {
2712
3272
  }
2713
3273
  /** Model label recorded in the request header for one lifecycle. */
2714
3274
  modelLabel() {
2715
- return this.config.model ?? NATIVE_MODEL_LABEL2;
3275
+ return this.config.model ?? HOSTED_DEFAULT_MODEL;
2716
3276
  }
2717
3277
  /** Append the request header snapshot once per loop instance. */
2718
3278
  assertRequestHeader() {
@@ -2755,12 +3315,15 @@ var CodexAgent = class {
2755
3315
  let live;
2756
3316
  try {
2757
3317
  const permission = this.queryPermission();
2758
- const client = await this.appServerClient();
3318
+ const override = sessionModelOverrideOf(this.loopCtx, this.session);
3319
+ const handover = await resolveModelHandover(this.loopCtx, override);
3320
+ const client = await this.appServerClient(handover);
3321
+ const model = override?.model ?? this.config.model;
2759
3322
  const threadParams = {
2760
3323
  cwd,
2761
3324
  sandbox: permission.sandboxMode,
2762
3325
  approvalPolicy: permission.approvalPolicy,
2763
- ...this.config.model === void 0 ? {} : { model: this.config.model }
3326
+ ...model === void 0 ? {} : { model }
2764
3327
  };
2765
3328
  const thread = await AppServerThread.create(client, threadParams);
2766
3329
  const input = [{ type: "text", text: prompt }];
@@ -2768,7 +3331,7 @@ var CodexAgent = class {
2768
3331
  signal: controller.signal,
2769
3332
  params: {
2770
3333
  approvalPolicy: permission.approvalPolicy,
2771
- ...this.config.model === void 0 ? {} : { model: this.config.model }
3334
+ ...model === void 0 ? {} : { model }
2772
3335
  }
2773
3336
  });
2774
3337
  let finished = false;
@@ -2899,12 +3462,13 @@ var CodexAgent = class {
2899
3462
  const activity = mapCommandExecution(item);
2900
3463
  foldToolCall(activity.call);
2901
3464
  flushHeld();
3465
+ const normalizedCall = normalizeHostedToolCall("codex", activity.call.name, activity.call.arguments);
2902
3466
  this.session.append("tool/call", {
2903
3467
  turn: phase.turn,
2904
3468
  step: phase.step,
2905
3469
  callId: activity.call.callId,
2906
- name: activity.call.name,
2907
- arguments: activity.call.arguments
3470
+ name: normalizedCall.name,
3471
+ arguments: normalizedCall.arguments
2908
3472
  });
2909
3473
  this.session.append("tool/result", { turn: phase.turn, step: phase.step, message: activity.result }, { surfaceOp: "append" });
2910
3474
  this.stepSettledTools += 1;
@@ -2913,12 +3477,13 @@ var CodexAgent = class {
2913
3477
  const activity = mapFileChange(item);
2914
3478
  foldToolCall(activity.call);
2915
3479
  flushHeld();
3480
+ const normalizedCall = normalizeHostedToolCall("codex", activity.call.name, activity.call.arguments);
2916
3481
  this.session.append("tool/call", {
2917
3482
  turn: phase.turn,
2918
3483
  step: phase.step,
2919
3484
  callId: activity.call.callId,
2920
- name: activity.call.name,
2921
- arguments: activity.call.arguments
3485
+ name: normalizedCall.name,
3486
+ arguments: normalizedCall.arguments
2922
3487
  });
2923
3488
  this.session.append("tool/result", { turn: phase.turn, step: phase.step, message: activity.result }, { surfaceOp: "append" });
2924
3489
  this.stepSettledTools += 1;
@@ -2927,12 +3492,13 @@ var CodexAgent = class {
2927
3492
  const activity = mapMcpToolCall(item);
2928
3493
  foldToolCall(activity.call);
2929
3494
  flushHeld();
3495
+ const normalizedCall = normalizeHostedToolCall("codex", activity.call.name, activity.call.arguments);
2930
3496
  this.session.append("tool/call", {
2931
3497
  turn: phase.turn,
2932
3498
  step: phase.step,
2933
3499
  callId: activity.call.callId,
2934
- name: activity.call.name,
2935
- arguments: activity.call.arguments
3500
+ name: normalizedCall.name,
3501
+ arguments: normalizedCall.arguments
2936
3502
  });
2937
3503
  this.session.append("tool/result", { turn: phase.turn, step: phase.step, message: activity.result }, { surfaceOp: "append" });
2938
3504
  this.stepSettledTools += 1;
@@ -2947,7 +3513,7 @@ var CodexAgent = class {
2947
3513
  }
2948
3514
  case "error":
2949
3515
  flushHeld();
2950
- throw new LlmError2(event.error.message, "CODEX_ERROR");
3516
+ throw new LlmError3(event.error.message, "CODEX_ERROR");
2951
3517
  /* v8 ignore next -- AppServerEvent is a closed union; no unknown kinds */
2952
3518
  default:
2953
3519
  break;
@@ -2955,7 +3521,7 @@ var CodexAgent = class {
2955
3521
  }
2956
3522
  flushHeld();
2957
3523
  if (!finished) {
2958
- throw new LlmError2(
3524
+ throw new LlmError3(
2959
3525
  `agent "${this.id}": codex query ended without a completed turn`,
2960
3526
  "CODEX_NO_RESULT"
2961
3527
  );
@@ -2995,11 +3561,10 @@ function resolveConfig2(config) {
2995
3561
  model: config.model
2996
3562
  };
2997
3563
  }
2998
- var CodexLoop = class extends HostedLoopFactory {
2999
- /** Services the loop resolves through its own fiber; blessed identically to the package-level entry inject. */
3000
- static inject = ["agents", "sessions", "systemPrompt"];
3564
+ var CODEX_ENGINE_LABEL = "agentLoopCodex";
3565
+ var CodexLoop = class extends HostedEngineRuntime {
3001
3566
  constructor(ctx, config) {
3002
- super(ctx, "agentLoopCodex", resolveConfig2(config));
3567
+ super(ctx, CODEX_ENGINE_LABEL, resolveConfig2(config));
3003
3568
  }
3004
3569
  /** Construct the Codex driver for one prepared session. */
3005
3570
  buildAgent(loopCtx, id, options, session) {
@@ -3009,16 +3574,68 @@ var CodexLoop = class extends HostedLoopFactory {
3009
3574
 
3010
3575
  // src/engine-pi/loop.ts
3011
3576
  import { readFileSync } from "node:fs";
3012
- import { dirname as dirname2, join as join2 } from "node:path";
3577
+ import { dirname as dirname2, join as join3 } from "node:path";
3013
3578
  import { fileURLToPath } from "node:url";
3014
3579
  import z3 from "@deepseek-ai/schemastery";
3015
3580
 
3016
3581
  // src/engine-pi/agent.ts
3017
3582
  import { agentEvents as agentEvents3 } from "@deepseek-ai/dsh-agent";
3018
- import { ToolCallId as ToolCallId5, LlmError as LlmError3, createAssistantMessage as createAssistantMessage3, createUserMessage as createUserMessage3, errorChain as errorChain3 } from "@deepseek-ai/dsh-llm";
3583
+ import { ToolCallId as ToolCallId5, LlmError as LlmError4, createAssistantMessage as createAssistantMessage3, createUserMessage as createUserMessage3, errorChain as errorChain3 } from "@deepseek-ai/dsh-llm";
3019
3584
  import { createScope as createScope3 } from "@deepseek-ai/dsh-scope";
3020
3585
  import { canonicalHeader as canonicalHeader3 } from "@deepseek-ai/dsh-session";
3021
3586
 
3587
+ // src/engine-pi/model-handover.ts
3588
+ import { createHash } from "node:crypto";
3589
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3590
+ import { tmpdir } from "node:os";
3591
+ import { join as join2 } from "node:path";
3592
+ var AGENT_ROOT = join2(tmpdir(), "dsh-loop-engine-pi-agent");
3593
+ var materialized = /* @__PURE__ */ new Set();
3594
+ process.once("exit", () => {
3595
+ for (const dir of materialized) {
3596
+ try {
3597
+ rmSync(dir, { recursive: true, force: true });
3598
+ } catch {
3599
+ }
3600
+ }
3601
+ materialized.clear();
3602
+ });
3603
+ function piAgentDir(handover) {
3604
+ const dir = join2(
3605
+ AGENT_ROOT,
3606
+ createHash("sha256").update(JSON.stringify({
3607
+ provider: handover.provider,
3608
+ baseURL: handover.baseURL,
3609
+ api: handover.api,
3610
+ apiKey: handover.apiKey,
3611
+ model: handover.model
3612
+ })).digest("hex").slice(0, 32)
3613
+ );
3614
+ if (!materialized.has(dir)) {
3615
+ mkdirSync(dir, { recursive: true, mode: 448 });
3616
+ writeFileSync(
3617
+ join2(dir, "models.json"),
3618
+ `${JSON.stringify(modelsDocument(handover), null, 2)}
3619
+ `,
3620
+ { mode: 384 }
3621
+ );
3622
+ materialized.add(dir);
3623
+ }
3624
+ return dir;
3625
+ }
3626
+ function modelsDocument(handover) {
3627
+ return {
3628
+ providers: {
3629
+ [handover.provider]: {
3630
+ baseUrl: handover.baseURL,
3631
+ api: handover.api,
3632
+ apiKey: handover.apiKey,
3633
+ models: [{ id: handover.model, name: handover.model }]
3634
+ }
3635
+ }
3636
+ };
3637
+ }
3638
+
3022
3639
  // src/engine-pi/permission.ts
3023
3640
  var DEFAULT_PI_PERMISSION = {
3024
3641
  sandboxMode: "read-only",
@@ -3273,11 +3890,10 @@ function mapToolResult(ev) {
3273
3890
  }
3274
3891
 
3275
3892
  // src/engine-pi/agent.ts
3276
- var PROVIDER3 = "pi";
3277
- var NATIVE_MODEL_LABEL3 = "pi-native";
3893
+ var PROVIDER3 = HOSTED_ROUTE_LABEL;
3278
3894
  var TOOLS_FLAG = "--tools";
3279
3895
  var PiAgent = class {
3280
- constructor(loopCtx, id, options, session, config, spawn4, bin, catalog) {
3896
+ constructor(loopCtx, id, options, session, config, spawn4, bin) {
3281
3897
  this.loopCtx = loopCtx;
3282
3898
  this.id = id;
3283
3899
  this.options = options;
@@ -3285,7 +3901,6 @@ var PiAgent = class {
3285
3901
  this.config = config;
3286
3902
  this.spawn = spawn4;
3287
3903
  this.bin = bin;
3288
- this.catalog = catalog;
3289
3904
  this.dispatch = agentEvents3(loopCtx, this);
3290
3905
  this.inbox = new DriverInbox(session, {
3291
3906
  inserted: (message) => {
@@ -3314,7 +3929,6 @@ var PiAgent = class {
3314
3929
  config;
3315
3930
  spawn;
3316
3931
  bin;
3317
- catalog;
3318
3932
  inbox;
3319
3933
  phase;
3320
3934
  activityDone = Promise.resolve();
@@ -3357,8 +3971,8 @@ var PiAgent = class {
3357
3971
  * never reuses one: the step teardown disposes it and the next step respawns
3358
3972
  * a fresh child.
3359
3973
  */
3360
- rpcClient(cwd) {
3361
- const client = PiRpcClient.create(this.spawnSpec(cwd), this.spawn);
3974
+ rpcClient(cwd, handover) {
3975
+ const client = PiRpcClient.create(this.spawnSpec(cwd, handover), this.spawn);
3362
3976
  this.rpc = client;
3363
3977
  return client;
3364
3978
  }
@@ -3608,7 +4222,7 @@ var PiAgent = class {
3608
4222
  }
3609
4223
  turnEnds = {
3610
4224
  kind: "error",
3611
- error: error instanceof LlmError3 ? error.failure : { message: errorChain3(error), code: "UNKNOWN" }
4225
+ error: error instanceof LlmError4 ? error.failure : { message: errorChain3(error), code: "UNKNOWN" }
3612
4226
  };
3613
4227
  this.throwError(error);
3614
4228
  } finally {
@@ -3626,7 +4240,7 @@ var PiAgent = class {
3626
4240
  }
3627
4241
  /** Model label recorded in the request header for one lifecycle. */
3628
4242
  modelLabel() {
3629
- return this.config.model ?? NATIVE_MODEL_LABEL3;
4243
+ return this.config.model ?? HOSTED_DEFAULT_MODEL;
3630
4244
  }
3631
4245
  /** Append the request header snapshot once per loop instance. */
3632
4246
  assertRequestHeader() {
@@ -3642,46 +4256,29 @@ var PiAgent = class {
3642
4256
  this.requestHeaderLogged = true;
3643
4257
  }
3644
4258
  /**
3645
- * The harness Session's web-side model selection, if any was stored. The
3646
- * durable `model/selection` event carries `{ provider, model, ... }`; when a
3647
- * user picked a model via `/model`, this is the newest pick, and it overrides
3648
- * the deployment config (which stays the fallback). Returns `undefined` when
3649
- * no selection was stored, so the deployment config governs.
3650
- */
3651
- dynamicModel() {
3652
- for (const event of [...this.session.snapshotEvents()].reverse()) {
3653
- const type = event.type;
3654
- if (type !== "model/selection") continue;
3655
- const data = event.data;
3656
- const model = data?.model;
3657
- if (typeof model === "string" && model.length > 0) return model;
3658
- }
3659
- return void 0;
3660
- }
3661
- /** Build the `pi --mode rpc` argv/cwd/env for one step's child process. */
3662
- /**
3663
- * Resolve the `--model` for the RPC child. The session-selected model (last
3664
- * `model/selection` event) is honored only when it is one of pi's discovered
3665
- * models; an unknown harness model (e.g. another provider's model such as
3666
- * `anyai-v1`, which pi cannot serve) is dropped so the child falls back to pi's
3667
- * own default instead of exiting with "Model ... not found". An empty catalog
3668
- * (probe not concluded) keeps the candidate, matching prior behavior.
4259
+ * Build the `pi --mode rpc` argv/cwd/env for one step's child process.
4260
+ *
4261
+ * The model comes from the session's own selection when it names a real dsh
4262
+ * model, else from the deployment's pinned configuration
4263
+ * ({@link sessionModelOverrideOf}). Pi's `--model` flag is
4264
+ * `"provider/id"`-qualified, so a session pick travels as the composite —
4265
+ * which also carries the provider, leaving the deployment's `--provider` out
4266
+ * of the argv for that step. A step with neither a selection nor a pin sends
4267
+ * no `--model` at all and lets Pi's own configuration decide. Read every step,
4268
+ * so a model picked mid-conversation lands on the next child.
4269
+ *
4270
+ * When dsh also discloses that model's ENDPOINT, the child is pointed at it:
4271
+ * `--provider` names the provider pi's `models.json` declares, `--api-key`
4272
+ * carries the credential, and `PI_CODING_AGENT_DIR` redirects pi's agent
4273
+ * directory to the plugin-owned one holding that `models.json`
4274
+ * ({@link piAgentDir}) — so the child runs on dsh's endpoint instead of its
4275
+ * own `~/.pi` configuration, which is never touched.
3669
4276
  */
3670
- pickModel() {
3671
- const candidate = this.dynamicModel() ?? this.config.model;
3672
- if (candidate === void 0) return void 0;
3673
- if (this.catalog.entries.length > 0) {
3674
- const known = this.catalog.entries.some(
3675
- (entry) => entry.model === candidate || `${entry.provider}/${entry.model}` === candidate
3676
- );
3677
- if (!known) return void 0;
3678
- }
3679
- return candidate;
3680
- }
3681
- spawnSpec(cwd) {
4277
+ spawnSpec(cwd, handover) {
3682
4278
  const argv = [];
3683
- const model = this.pickModel();
3684
- if (this.config.provider !== void 0) argv.push("--provider", this.config.provider);
4279
+ const override = sessionModelOverrideOf(this.loopCtx, this.session);
4280
+ const model = override === void 0 ? this.config.model : `${override.provider}/${override.model}`;
4281
+ if (override === void 0 && this.config.provider !== void 0) argv.push("--provider", this.config.provider);
3685
4282
  if (model !== void 0 && this.config.thinkingLevel !== void 0) {
3686
4283
  argv.push("--model", `${model}:${this.config.thinkingLevel}`);
3687
4284
  } else if (model !== void 0) {
@@ -3689,6 +4286,7 @@ var PiAgent = class {
3689
4286
  } else if (this.config.thinkingLevel !== void 0) {
3690
4287
  argv.push("--model", `:${this.config.thinkingLevel}`);
3691
4288
  }
4289
+ if (handover !== void 0) argv.push("--provider", handover.provider, "--api-key", handover.apiKey);
3692
4290
  const permission = this.queryPermission();
3693
4291
  if (permission.tools.length > 0) argv.push(TOOLS_FLAG, permission.tools.join(","));
3694
4292
  return {
@@ -3700,7 +4298,7 @@ var PiAgent = class {
3700
4298
  ...argv
3701
4299
  ],
3702
4300
  cwd,
3703
- env: this.config.env
4301
+ env: handover === void 0 ? this.config.env : { ...this.config.env, PI_CODING_AGENT_DIR: piAgentDir(handover) }
3704
4302
  };
3705
4303
  }
3706
4304
  /**
@@ -3723,12 +4321,17 @@ var PiAgent = class {
3723
4321
  throw new Error(`agent "${this.id}": no working directory \u2014 start the session with cwd metadata`);
3724
4322
  }
3725
4323
  const history = this.session.deriveMessages();
3726
- const prompt = serializeHistory(history);
4324
+ const prompt = engineSlashPrompt(history) ?? serializeHistory(history);
3727
4325
  if (prompt.length === 0) {
3728
4326
  throw new Error(`agent "${this.id}": cannot derive a prompt from an empty session log`);
3729
4327
  }
3730
4328
  this.assertRequestHeader();
3731
4329
  signal.throwIfAborted();
4330
+ const resolved = await resolveModelHandover(
4331
+ this.loopCtx,
4332
+ sessionModelOverrideOf(this.loopCtx, this.session)
4333
+ );
4334
+ const handover = resolved?.api === void 0 ? void 0 : resolved;
3732
4335
  const controller = new AbortController();
3733
4336
  const cancel = () => {
3734
4337
  if (!controller.signal.aborted) {
@@ -3752,7 +4355,7 @@ var PiAgent = class {
3752
4355
  }
3753
4356
  return live;
3754
4357
  };
3755
- const client = this.rpcClient(cwd);
4358
+ const client = this.rpcClient(cwd, handover);
3756
4359
  signal.throwIfAborted();
3757
4360
  await client.newSession();
3758
4361
  client.clearEvents();
@@ -3791,12 +4394,13 @@ var PiAgent = class {
3791
4394
  }
3792
4395
  held = void 0;
3793
4396
  for (const call of callsToLog) {
4397
+ const normalized = normalizeHostedToolCall("pi", call.name, call.arguments);
3794
4398
  this.session.append("tool/call", {
3795
4399
  turn: phase.turn,
3796
4400
  step: phase.step,
3797
4401
  callId: ToolCallId5(call.callId),
3798
- name: call.name,
3799
- arguments: call.arguments
4402
+ name: normalized.name,
4403
+ arguments: normalized.arguments
3800
4404
  });
3801
4405
  }
3802
4406
  callsToLog = [];
@@ -3954,7 +4558,7 @@ var PiAgent = class {
3954
4558
  }
3955
4559
  flushHeld(lastUsage);
3956
4560
  if (!finished) {
3957
- throw new LlmError3(
4561
+ throw new LlmError4(
3958
4562
  `agent "${this.id}": pi query ended without an agent settle`,
3959
4563
  "PI_NO_RESULT"
3960
4564
  );
@@ -3983,73 +4587,6 @@ var PiAgent = class {
3983
4587
  }
3984
4588
  };
3985
4589
 
3986
- // src/engine-pi/probe.ts
3987
- function waitForExit(child) {
3988
- return new Promise((resolve5) => {
3989
- ;
3990
- child.onExit(
3991
- (code) => resolve5(code ?? 0)
3992
- );
3993
- });
3994
- }
3995
- function collectOutput(child) {
3996
- return new Promise((resolve5) => {
3997
- if (child.stdout.on === void 0 || child.stderr.on === void 0) {
3998
- resolve5("");
3999
- return;
4000
- }
4001
- let text = "";
4002
- let pending = 2;
4003
- const finish = () => {
4004
- pending -= 1;
4005
- if (pending === 0) resolve5(text);
4006
- };
4007
- const out = child.stdout;
4008
- const err = child.stderr;
4009
- out.setEncoding("utf8");
4010
- out.on("data", (data) => {
4011
- text += String(data);
4012
- });
4013
- out.on("close", finish);
4014
- err.setEncoding("utf8");
4015
- err.on("data", (data) => {
4016
- text += String(data);
4017
- });
4018
- err.on("close", finish);
4019
- });
4020
- }
4021
- function parsePiModelList(output) {
4022
- const entries = [];
4023
- for (const line of output.split("\n")) {
4024
- const trimmed = line.trim();
4025
- if (trimmed.length === 0) continue;
4026
- const match = /^(\S+)\s{2,}(\S+)/.exec(trimmed);
4027
- if (match === null) continue;
4028
- const provider = match[1];
4029
- if (provider === "provider") continue;
4030
- entries.push({ provider, model: match[2] });
4031
- }
4032
- return entries;
4033
- }
4034
- async function probePiModels(bin, spawn4) {
4035
- const spec = {
4036
- argv: [bin, "--mode", "rpc", "--list-models"],
4037
- cwd: process.cwd(),
4038
- env: {}
4039
- };
4040
- let child;
4041
- try {
4042
- child = spawn4(spec);
4043
- } catch {
4044
- return [];
4045
- }
4046
- const outputPromise = collectOutput(child);
4047
- const exitCode = await waitForExit(child);
4048
- if (exitCode !== 0) return [];
4049
- const output = await outputPromise;
4050
- return parsePiModelList(output);
4051
- }
4052
-
4053
4590
  // src/engine-pi/loop.ts
4054
4591
  var PI_SANDBOX_MODES = [
4055
4592
  "read-only",
@@ -4062,8 +4599,7 @@ var Config3 = z3.object({
4062
4599
  provider: z3.string(),
4063
4600
  model: z3.string(),
4064
4601
  thinkingLevel: z3.string(),
4065
- env: z3.dict(z3.string()).default({}),
4066
- piCatalogHolder: z3.any()
4602
+ env: z3.dict(z3.string()).default({})
4067
4603
  });
4068
4604
  function resolveConfig3(config) {
4069
4605
  return {
@@ -4077,10 +4613,10 @@ function resolveConfig3(config) {
4077
4613
  function piCliEntrypoint() {
4078
4614
  const mainUrl = import.meta.resolve("@earendil-works/pi-coding-agent");
4079
4615
  const root = dirname2(dirname2(fileURLToPath(mainUrl)));
4080
- const pkg = JSON.parse(readFileSync(join2(root, "package.json"), "utf8"));
4616
+ const pkg = JSON.parse(readFileSync(join3(root, "package.json"), "utf8"));
4081
4617
  const bin = pkg.bin;
4082
4618
  const rel = typeof bin === "string" ? bin : bin?.["pi"] ?? Object.values(bin ?? {})[0] ?? "bin/pi.js";
4083
- return join2(root, rel);
4619
+ return join3(root, rel);
4084
4620
  }
4085
4621
  function piSubprocessSpec(spec, graceMs) {
4086
4622
  return {
@@ -4102,34 +4638,25 @@ function fromSubprocess(handle) {
4102
4638
  stdout,
4103
4639
  stderr,
4104
4640
  onExit: (handler) => {
4105
- const onExit = handler;
4106
- void handle.done.then((outcome) => onExit(outcome.exitCode), handler);
4641
+ void handle.done.then(handler, handler);
4107
4642
  },
4108
4643
  terminate: () => handle.terminate()
4109
4644
  };
4110
4645
  }
4111
- var PiLoop = class extends HostedLoopFactory {
4112
- /** Services the loop resolves through its own fiber; blessed identically to the package-level entry inject. */
4113
- static inject = ["agents", "sessions", "systemPrompt", "subprocess"];
4646
+ var PI_ENGINE_LABEL = "agentLoopPi";
4647
+ var PiLoop = class extends HostedEngineRuntime {
4114
4648
  /** Process-tree spawn capability handed to every agent, sandboxed by the subprocess seam. */
4115
4649
  spawn;
4116
4650
  /** Resolved Pi CLI entrypoint; `argv[0]` of every Pi RPC child. */
4117
4651
  bin;
4118
- /** Discovered Pi model catalog, forwarded to each agent so it can validate the session-selected model against what pi can actually serve. */
4119
- catalog;
4120
4652
  constructor(ctx, config) {
4121
- super(ctx, "agentLoopPi", resolveConfig3(config));
4122
- this.bin = piCliEntrypoint();
4123
- this.catalog = config.piCatalogHolder ?? { entries: [] };
4124
- this.spawn = (spec) => fromSubprocess(this.runtime.ctx.subprocess.spawn(piSubprocessSpec(spec, PI_DISPOSE_GRACE_MS)));
4125
- const holder = config.piCatalogHolder;
4126
- if (holder !== void 0) {
4127
- void probePiModels(this.bin, (spec) => this.spawn(spec)).then((models) => {
4128
- holder.entries = [...models];
4129
- }).catch(() => {
4130
- holder.entries = [];
4131
- });
4653
+ super(ctx, PI_ENGINE_LABEL, resolveConfig3(config));
4654
+ const subprocess = ctx.get("subprocess");
4655
+ if (subprocess === void 0) {
4656
+ throw new Error("loop-engine: the pi engine needs the dsh subprocess service on this context");
4132
4657
  }
4658
+ this.bin = piCliEntrypoint();
4659
+ this.spawn = (spec) => fromSubprocess(subprocess.spawn(piSubprocessSpec(spec, PI_DISPOSE_GRACE_MS)));
4133
4660
  }
4134
4661
  /** Construct the Pi RPC driver for one prepared session. */
4135
4662
  buildAgent(loopCtx, id, options, session) {
@@ -4140,8 +4667,7 @@ var PiLoop = class extends HostedLoopFactory {
4140
4667
  session,
4141
4668
  this.config,
4142
4669
  this.spawn,
4143
- this.bin,
4144
- this.catalog
4670
+ this.bin
4145
4671
  );
4146
4672
  }
4147
4673
  };
@@ -4151,22 +4677,38 @@ import z4 from "@deepseek-ai/schemastery";
4151
4677
 
4152
4678
  // src/engine-kimi/agent.ts
4153
4679
  import { agentEvents as agentEvents4 } from "@deepseek-ai/dsh-agent";
4154
- import { ToolCallId as ToolCallId7, LlmError as LlmError4, createAssistantMessage as createAssistantMessage4, createUserMessage as createUserMessage4, errorChain as errorChain4 } from "@deepseek-ai/dsh-llm";
4680
+ import { ToolCallId as ToolCallId7, LlmError as LlmError5, createAssistantMessage as createAssistantMessage4, createUserMessage as createUserMessage4, errorChain as errorChain4 } from "@deepseek-ai/dsh-llm";
4155
4681
  import { createScope as createScope4 } from "@deepseek-ai/dsh-scope";
4156
4682
  import { canonicalHeader as canonicalHeader4 } from "@deepseek-ai/dsh-session";
4157
4683
 
4684
+ // src/engine-kimi/model-handover.ts
4685
+ var KIMI_PROVIDER_TYPES = {
4686
+ "anthropic-messages": "anthropic",
4687
+ "openai-completions": "openai",
4688
+ "openai-responses": "openai"
4689
+ };
4690
+ function kimiModelEnv(handover) {
4691
+ const type = handover.api === void 0 ? void 0 : KIMI_PROVIDER_TYPES[handover.api];
4692
+ return {
4693
+ KIMI_MODEL_NAME: handover.model,
4694
+ KIMI_MODEL_API_KEY: handover.apiKey,
4695
+ KIMI_MODEL_BASE_URL: handover.baseURL,
4696
+ ...type === void 0 ? {} : { KIMI_MODEL_PROVIDER_TYPE: type }
4697
+ };
4698
+ }
4699
+
4158
4700
  // src/engine-kimi/process.ts
4159
4701
  import { existsSync } from "node:fs";
4160
4702
  import { homedir } from "node:os";
4161
- import { join as join3 } from "node:path";
4703
+ import { join as join4 } from "node:path";
4162
4704
  function kimiHomeDir() {
4163
4705
  const envHome = process.env.KIMI_CODE_HOME;
4164
- return envHome !== void 0 && envHome !== "" ? envHome : join3(homedir(), ".kimi-code");
4706
+ return envHome !== void 0 && envHome !== "" ? envHome : join4(homedir(), ".kimi-code");
4165
4707
  }
4166
4708
  function kimiBinResolver(configBin) {
4167
4709
  if (configBin !== void 0 && configBin !== "") return configBin;
4168
4710
  const executable = process.platform === "win32" ? "kimi.exe" : "kimi";
4169
- const candidate = join3(kimiHomeDir(), "bin", executable);
4711
+ const candidate = join4(kimiHomeDir(), "bin", executable);
4170
4712
  return existsSync(candidate) ? candidate : "kimi";
4171
4713
  }
4172
4714
  function kimiAcpArgv(bin) {
@@ -4320,6 +4862,18 @@ var AcpClient = class _AcpClient {
4320
4862
  }
4321
4863
  return sessionId;
4322
4864
  }
4865
+ /**
4866
+ * Select the model for one ACP session. The agent answers with an error frame
4867
+ * when it cannot serve the id, and {@link request} rejects on that frame — so
4868
+ * an engine that refuses the model fails the step loud rather than silently
4869
+ * running its own default.
4870
+ * @param sessionId - the ACP session the model applies to.
4871
+ * @param modelId - the model id to select.
4872
+ * @returns the agent's response to `session/set_model`.
4873
+ */
4874
+ setModel(sessionId, modelId) {
4875
+ return this.request("session/set_model", { sessionId, modelId });
4876
+ }
4323
4877
  /** Prompt the agent in a session and resolve when the turn completes. */
4324
4878
  prompt(sessionId, text) {
4325
4879
  return this.request("session/prompt", { sessionId, prompt: [{ type: "text", text }] });
@@ -4476,8 +5030,7 @@ function resolveToolApproval(events) {
4476
5030
  }
4477
5031
 
4478
5032
  // src/engine-kimi/agent.ts
4479
- var PROVIDER4 = "kimi";
4480
- var NATIVE_MODEL_LABEL4 = "kimi-native";
5033
+ var PROVIDER4 = HOSTED_ROUTE_LABEL;
4481
5034
  var KimiAgent = class {
4482
5035
  constructor(loopCtx, id, options, session, config, spawn4, bin) {
4483
5036
  this.loopCtx = loopCtx;
@@ -4531,6 +5084,13 @@ var KimiAgent = class {
4531
5084
  acp;
4532
5085
  /** The spawn spec the cached client was built from; a change forces a respawn. */
4533
5086
  lastSpec;
5087
+ /**
5088
+ * The environment the last handover produced, memoized by its own content so
5089
+ * an unchanged endpoint reuses ONE object: `specsEqual` compares environments
5090
+ * by reference, and a fresh object per step would otherwise respawn the child
5091
+ * every step.
5092
+ */
5093
+ handoverEnvCache;
4534
5094
  get status() {
4535
5095
  return this.phase.kind === "idle" || this.phase.kind === "maintenance" ? "idle" : "running";
4536
5096
  }
@@ -4762,7 +5322,7 @@ var KimiAgent = class {
4762
5322
  }
4763
5323
  turnEnds = {
4764
5324
  kind: "error",
4765
- error: error instanceof LlmError4 ? error.failure : { message: errorChain4(error), code: "UNKNOWN" }
5325
+ error: error instanceof LlmError5 ? error.failure : { message: errorChain4(error), code: "UNKNOWN" }
4766
5326
  };
4767
5327
  this.throwError(error);
4768
5328
  } finally {
@@ -4780,7 +5340,7 @@ var KimiAgent = class {
4780
5340
  }
4781
5341
  /** Model label recorded in the request header for one lifecycle. */
4782
5342
  modelLabel() {
4783
- return this.config.model ?? NATIVE_MODEL_LABEL4;
5343
+ return this.config.model ?? HOSTED_DEFAULT_MODEL;
4784
5344
  }
4785
5345
  /** Append the request header snapshot once per loop instance. */
4786
5346
  assertRequestHeader() {
@@ -4801,8 +5361,8 @@ var KimiAgent = class {
4801
5361
  return a.cwd === b.cwd && a.env === b.env && a.argv.length === b.argv.length && a.argv.every((value, index) => value === b.argv[index]);
4802
5362
  }
4803
5363
  /** Return the cached ACP client, respawning when the spec or process changed. */
4804
- async acpClient(cwd) {
4805
- const spec = this.spawnSpec(cwd);
5364
+ async acpClient(cwd, handover) {
5365
+ const spec = this.spawnSpec(cwd, handover);
4806
5366
  if (this.acp !== void 0 && !this.acp.closed && this.specsEqual(this.lastSpec, spec)) return this.acp;
4807
5367
  this.acp?.dispose();
4808
5368
  const client = AcpClient.create(spec, this.spawn);
@@ -4819,13 +5379,25 @@ var KimiAgent = class {
4819
5379
  return client;
4820
5380
  }
4821
5381
  /** Build the `kimi acp` argv/cwd/env for the persistent child. */
4822
- spawnSpec(cwd) {
5382
+ spawnSpec(cwd, handover) {
4823
5383
  return {
4824
5384
  argv: kimiAcpArgv(this.bin),
4825
5385
  cwd,
4826
- env: this.config.env
5386
+ env: this.handoverEnv(handover)
4827
5387
  };
4828
5388
  }
5389
+ /**
5390
+ * The child environment for one handover, memoized by content so an unchanged
5391
+ * endpoint yields the SAME object across steps (see {@link handoverEnvCache}).
5392
+ */
5393
+ handoverEnv(handover) {
5394
+ if (handover === void 0) return this.config.env;
5395
+ const key = JSON.stringify(handover);
5396
+ if (this.handoverEnvCache?.key === key) return this.handoverEnvCache.env;
5397
+ const env = { ...this.config.env, ...kimiModelEnv(handover) };
5398
+ this.handoverEnvCache = { key, env };
5399
+ return env;
5400
+ }
4829
5401
  /** Run one `kimi acp` step for the current session history and map the streamed updates. */
4830
5402
  async step() {
4831
5403
  if (this.phase.kind !== "running") throw new Error(`agent "${this.id}": step outside running phase`);
@@ -4843,17 +5415,22 @@ var KimiAgent = class {
4843
5415
  throw new Error(`agent "${this.id}": no working directory \u2014 start the session with cwd metadata`);
4844
5416
  }
4845
5417
  const history = this.session.deriveMessages();
4846
- const prompt = serializeHistory(history);
5418
+ const prompt = engineSlashPrompt(history) ?? serializeHistory(history);
4847
5419
  if (prompt.length === 0) {
4848
5420
  throw new Error(`agent "${this.id}": cannot derive a prompt from an empty session log`);
4849
5421
  }
4850
5422
  this.assertRequestHeader();
4851
5423
  signal.throwIfAborted();
4852
- const client = await this.acpClient(cwd);
5424
+ const override = sessionModelOverrideOf(this.loopCtx, this.session);
5425
+ const handover = await resolveModelHandover(this.loopCtx, override);
5426
+ const client = await this.acpClient(cwd, handover);
4853
5427
  signal.throwIfAborted();
4854
5428
  client.onPermission(() => resolveToolApproval(this.session.snapshotEvents()));
4855
5429
  const acpSessionId = await client.newSession(cwd);
4856
5430
  signal.throwIfAborted();
5431
+ const model = override?.model ?? this.config.model;
5432
+ if (handover === void 0 && model !== void 0) await client.setModel(acpSessionId, model);
5433
+ signal.throwIfAborted();
4857
5434
  try {
4858
5435
  client.onUpdate((update) => this.applyUpdate(phase, update));
4859
5436
  const cancel = () => {
@@ -4871,7 +5448,7 @@ var KimiAgent = class {
4871
5448
  this.pendingCalls.clear();
4872
5449
  this.flushSegment(phase);
4873
5450
  if (!this.producedOutput) {
4874
- throw new LlmError4(
5451
+ throw new LlmError5(
4875
5452
  `agent "${this.id}": kimi query produced no assistant output`,
4876
5453
  "KIMI_NO_RESULT"
4877
5454
  );
@@ -5028,7 +5605,8 @@ var KimiAgent = class {
5028
5605
  this.segmentCalls = [];
5029
5606
  this.flushAssistant(phase, calls);
5030
5607
  for (const call of calls) {
5031
- this.session.append("tool/call", { turn: phase.turn, step: phase.step, callId: ToolCallId7(call.callId), name: call.name, arguments: call.arguments });
5608
+ const normalized = normalizeHostedToolCall("kimi", call.name, call.arguments);
5609
+ this.session.append("tool/call", { turn: phase.turn, step: phase.step, callId: ToolCallId7(call.callId), name: normalized.name, arguments: normalized.arguments });
5032
5610
  }
5033
5611
  }
5034
5612
  /**
@@ -5093,14 +5671,17 @@ function resolveConfig4(config) {
5093
5671
  bin: kimiBinResolver(config.bin)
5094
5672
  };
5095
5673
  }
5096
- var KimiLoop = class extends HostedLoopFactory {
5097
- /** Services the loop resolves through its own fiber; blessed identically to the package-level entry inject. */
5098
- static inject = ["agents", "sessions", "systemPrompt", "subprocess"];
5674
+ var KIMI_ENGINE_LABEL = "agentLoopKimi";
5675
+ var KimiLoop = class extends HostedEngineRuntime {
5099
5676
  /** One-shot spawn capability handed to every agent, sandboxed by the subprocess seam. */
5100
5677
  spawn;
5101
5678
  constructor(ctx, config) {
5102
- super(ctx, "agentLoopKimi", resolveConfig4(config));
5103
- this.spawn = (spec) => fromSubprocess2(this.runtime.ctx.subprocess.spawn(kimiSubprocessSpec(spec, KIMI_DISPOSE_GRACE_MS)));
5679
+ super(ctx, KIMI_ENGINE_LABEL, resolveConfig4(config));
5680
+ const subprocess = ctx.get("subprocess");
5681
+ if (subprocess === void 0) {
5682
+ throw new Error("loop-engine: the kimi engine needs the dsh subprocess service on this context");
5683
+ }
5684
+ this.spawn = (spec) => fromSubprocess2(subprocess.spawn(kimiSubprocessSpec(spec, KIMI_DISPOSE_GRACE_MS)));
5104
5685
  }
5105
5686
  /** Construct the Kimi ACP driver for one prepared session. */
5106
5687
  buildAgent(loopCtx, id, options, session) {
@@ -5108,22 +5689,511 @@ var KimiLoop = class extends HostedLoopFactory {
5108
5689
  }
5109
5690
  };
5110
5691
 
5111
- // src/engine-kimi/skills.ts
5112
- import { homedir as homedir3 } from "node:os";
5113
- import { join as join7, resolve as resolve3 } from "node:path";
5114
-
5115
- // src/driver-core/agents-md-skill-provider.ts
5116
- import { readdir as readdir2, readFile as readFile3, stat as stat3 } from "node:fs/promises";
5117
- import { dirname as dirname3, join as join6 } from "node:path";
5692
+ // src/settings.ts
5693
+ import z5 from "@deepseek-ai/schemastery";
5118
5694
 
5119
- // src/driver-core/context-files.ts
5120
- import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
5121
- import { join as join5, resolve as resolve2 } from "node:path";
5695
+ // src/namespace.ts
5696
+ var LOOP_ENGINE_SETTINGS_NAMESPACE_LITERAL = "agent-loop-engine";
5122
5697
 
5123
- // src/skills.ts
5124
- import { readFile, readdir, stat } from "node:fs/promises";
5698
+ // src/settings.ts
5699
+ var LOOP_ENGINE_SETTINGS_SCHEMA = z5.object({
5700
+ engine: z5.union(LOOP_ENGINE_IDS.map((id) => z5.const(id))).default("in-process"),
5701
+ showInComposer: z5.boolean().default(true)
5702
+ });
5703
+ function loopEngineSettingsNamespace() {
5704
+ return LOOP_ENGINE_SETTINGS_NAMESPACE_LITERAL;
5705
+ }
5706
+
5707
+ // src/patch-manager.ts
5708
+ var MANAGED_BLOCK_BEGIN = "# -- dsh-loop-engine managed block --";
5709
+ var LEGACY_MANAGED_BLOCK_BEGIN = "# -- dsh-loop-engine managed block: ";
5710
+ var MANAGED_BLOCK_END = "# -- /dsh-loop-engine managed block --";
5711
+ var END_MARKER_LINE = `${MANAGED_BLOCK_END}
5712
+ `;
5713
+ function renderManagedBlock() {
5714
+ return [
5715
+ MANAGED_BLOCK_BEGIN,
5716
+ "- id: agent-loop",
5717
+ " disabled: true",
5718
+ "- id: command-goal",
5719
+ " disabled: true",
5720
+ END_MARKER_LINE
5721
+ ].join("\n");
5722
+ }
5723
+ function hasLegacyManagedBlock(text) {
5724
+ return text.includes(LEGACY_MANAGED_BLOCK_BEGIN);
5725
+ }
5726
+ function hasManagedBlock(text) {
5727
+ return text.includes("# -- dsh-loop-engine managed block");
5728
+ }
5729
+ var LEGACY_BEGIN_MARKER_RE = /^# -- dsh-loop-engine managed block: (\S+) --$/m;
5730
+ function legacyBlockEngineOf(text) {
5731
+ const engine = LEGACY_BEGIN_MARKER_RE.exec(text)?.[1];
5732
+ return LOOP_ENGINE_IDS.includes(engine ?? "") ? engine : void 0;
5733
+ }
5734
+ function managedSpan(text) {
5735
+ const begin = text.indexOf("# -- dsh-loop-engine managed block");
5736
+ if (begin === -1) return { head: text, tail: "", present: false, blankBefore: false };
5737
+ const afterBegin = begin + LEGACY_MANAGED_BLOCK_BEGIN.length;
5738
+ const endAt = text.indexOf(MANAGED_BLOCK_END, afterBegin);
5739
+ const spanEnd = endAt === -1 ? text.length : endAt + END_MARKER_LINE.length;
5740
+ const before = text.slice(0, begin);
5741
+ const blankBefore = before.endsWith("\n\n");
5742
+ return {
5743
+ head: blankBefore ? before.slice(0, -1) : before,
5744
+ tail: text.slice(spanEnd),
5745
+ present: true,
5746
+ blankBefore
5747
+ };
5748
+ }
5749
+ function ensureTrailingNewline(text) {
5750
+ return text.endsWith("\n") ? text : `${text}
5751
+ `;
5752
+ }
5753
+ function dropSeedPlaceholder(text) {
5754
+ return text.replace(/^\[\]\n/m, "");
5755
+ }
5756
+ function applyManagedBlock(text) {
5757
+ const block = renderManagedBlock();
5758
+ const span = managedSpan(text);
5759
+ if (!span.present) {
5760
+ if (text === "") return block;
5761
+ return dropSeedPlaceholder(`${ensureTrailingNewline(text)}
5762
+ ${block}`);
5763
+ }
5764
+ return dropSeedPlaceholder(`${span.head}${span.blankBefore ? "\n" : ""}${block}${span.tail}`);
5765
+ }
5766
+
5767
+ // src/preset.ts
5768
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
5769
+ import { randomUUID } from "node:crypto";
5770
+ import { dirname as dirname3, join as join5 } from "node:path";
5771
+ var USER_PRESET_DIR = ".agent-presets";
5772
+ var COMPOSITION_FILE = "agent.cordis.yml";
5773
+ var METADATA_FILE = "preset.yml";
5774
+ var HOSTED_PRESET_IDS = HOSTED_ENGINE_IDS.map((engine) => enginePresetId(engine));
5775
+ var STRIPPED_ROWS = ["skill-filesystem", "tool-skill", "tool-goal", "command-goal", "planning", "compaction"];
5776
+ var MANAGED_HEADER = `# Managed by dsh-loop-engine: the deployment's "${SOURCE_PRESET_ID}" preset minus
5777
+ # the dsh-native command/skill rows a hosted loop engine replaces. Regenerated
5778
+ # from "${SOURCE_PRESET_ID}" on boot \u2014 hand edits are overwritten. The preset id
5779
+ # names the engine this session runs.
5780
+ `;
5781
+ function managedMetadata(engine) {
5782
+ return `name: ${ENGINE_DISPLAY_NAMES[engine]}
5783
+ description: ${ENGINE_DISPLAY_NAMES[engine]}, with the dsh-native commands and skills it replaces removed.
5784
+ `;
5785
+ }
5786
+ var ENGINE_DISPLAY_NAMES = {
5787
+ "claude-code": "Claude Code",
5788
+ codex: "Codex",
5789
+ pi: "Pi",
5790
+ kimi: "Kimi Code"
5791
+ };
5792
+ function isEntryStart(line) {
5793
+ return line.startsWith("- ");
5794
+ }
5795
+ function entryId(line) {
5796
+ return /^- id:\s*(\S+)\s*$/.exec(line)?.[1];
5797
+ }
5798
+ function stripPresetRows(text, ids = STRIPPED_ROWS) {
5799
+ const lines = text.split("\n");
5800
+ const starts = [];
5801
+ for (const [index, line] of lines.entries()) {
5802
+ if (isEntryStart(line)) starts.push(index);
5803
+ }
5804
+ if (starts.length === 0) return text;
5805
+ const drop = new Set(ids);
5806
+ const entries = [];
5807
+ let heading = lines.slice(0, starts[0]);
5808
+ for (const [index, start] of starts.entries()) {
5809
+ const end = index + 1 < starts.length ? starts[index + 1] : lines.length;
5810
+ const span = lines.slice(start, end);
5811
+ let bodyEnd = span.length;
5812
+ while (bodyEnd > 1) {
5813
+ const line = span[bodyEnd - 1];
5814
+ if (line.trim() !== "" && !line.trimStart().startsWith("#")) break;
5815
+ bodyEnd -= 1;
5816
+ }
5817
+ entries.push({ id: entryId(span[0]), heading, body: span.slice(0, bodyEnd) });
5818
+ heading = span.slice(bodyEnd);
5819
+ }
5820
+ const out = [];
5821
+ out.push(...entries[0].heading);
5822
+ let lastKept = -1;
5823
+ for (const [index, entry] of entries.entries()) {
5824
+ if (entry.id !== void 0 && drop.has(entry.id)) continue;
5825
+ if (index > 0) out.push(...entry.heading);
5826
+ out.push(...entry.body);
5827
+ lastKept = index;
5828
+ }
5829
+ if (lastKept === entries.length - 1) out.push(...heading);
5830
+ if (out.length > 0 && out[out.length - 1] !== "") out.push("");
5831
+ return out.join("\n");
5832
+ }
5833
+ async function writeIfDifferent(path, text) {
5834
+ try {
5835
+ if (await readFile(path, "utf8") === text) return false;
5836
+ } catch {
5837
+ }
5838
+ await mkdir(dirname3(path), { recursive: true });
5839
+ const tmp = `${path}.tmp-${randomUUID()}`;
5840
+ await writeFile(tmp, text, "utf8");
5841
+ await rename(tmp, path);
5842
+ return true;
5843
+ }
5844
+ async function ensureEnginePresets(dshHome, source) {
5845
+ const composition = await source.read(SOURCE_PRESET_ID);
5846
+ const stripped = `${MANAGED_HEADER}
5847
+ ${stripPresetRows(composition)}`;
5848
+ let changed = false;
5849
+ for (const engine of HOSTED_ENGINE_IDS) {
5850
+ const dir = join5(dshHome, USER_PRESET_DIR, enginePresetId(engine));
5851
+ const compositionChanged = await writeIfDifferent(join5(dir, COMPOSITION_FILE), stripped);
5852
+ const metadataChanged = await writeIfDifferent(join5(dir, METADATA_FILE), managedMetadata(engine));
5853
+ changed = changed || compositionChanged || metadataChanged;
5854
+ }
5855
+ return changed;
5856
+ }
5857
+
5858
+ // src/router-loop.ts
5859
+ import AgentLoop from "@deepseek-ai/dsh-agent-loop";
5860
+
5861
+ // src/model-selection-reset.ts
5862
+ var AGENT_DEFAULT_MODEL_NS = "agent-default-model";
5863
+ var ModelSelectionReset = class {
5864
+ /**
5865
+ * @param ctx - context carrying the host's default-model service, its settings
5866
+ * (for the configured default), and the session-projection registry.
5867
+ * @param warn - diagnostic sink, used at most once per problem.
5868
+ */
5869
+ constructor(ctx, warn) {
5870
+ this.ctx = ctx;
5871
+ this.warn = warn;
5872
+ }
5873
+ ctx;
5874
+ warn;
5875
+ /** Whether the one warning this deployment is owed has been reported. */
5876
+ warned = false;
5877
+ /** Whether the one report that the deployment default names a hosted route has been made. */
5878
+ reportedHostedDefault = false;
5879
+ /**
5880
+ * Give one session the selection the engine it is being SWITCHED TO owns,
5881
+ * unless its log already names a real model of its own.
5882
+ *
5883
+ * The switch trigger. A session whose log names no selection at all is left
5884
+ * alone: a switch rewrites a seat an engine already put there, and naming a
5885
+ * session's first seat belongs to the build of a session being created
5886
+ * ({@link guardFor}) rather than to a change of engine.
5887
+ * @param session - the live Session the engine switch is being recorded for.
5888
+ * @param engine - the engine the session is being moved to.
5889
+ * @returns whether a `model/selection` event was appended.
5890
+ */
5891
+ resetFor(session, engine) {
5892
+ if (engine !== "in-process" && this.currentSelection(session) === void 0) return false;
5893
+ return this.appendSeat(session, engine);
5894
+ }
5895
+ /**
5896
+ * Give one session the selection the engine BUILDING it owns, inside the
5897
+ * router's wrapped `setup` — before the caller's own setup installs the
5898
+ * session's selection — so a selection written here is the one the host reads
5899
+ * back for the agent being built.
5900
+ *
5901
+ * The harness loop's own build keeps its narrower precondition: it makes real
5902
+ * model calls, so it is the one build that must not be left selecting a
5903
+ * placeholder route, and a session whose own log already names a real model
5904
+ * needs nothing written for it. A hosted engine is being given its seat here,
5905
+ * which is what a new session on it starts from.
5906
+ * @param session - the session whose agent is being composed.
5907
+ * @param engine - the engine composing it.
5908
+ * @returns whether a `model/selection` event was appended.
5909
+ */
5910
+ guardFor(session, engine) {
5911
+ if (engine === "in-process" && !this.selectsHostedRoute(session)) return false;
5912
+ return this.appendSeat(session, engine);
5913
+ }
5914
+ /**
5915
+ * Whether the selection this session's next request would use names one of
5916
+ * the hosted engines' provider routes — a label this plugin serves as a
5917
+ * placeholder and cannot answer a real model call on.
5918
+ *
5919
+ * The read is the host's own (`selectionFor`): a pending selection, else the
5920
+ * logged header, else the deployment's default. A session with nothing in its
5921
+ * log is therefore judged on the deployment default — the value the model menu
5922
+ * can write a hosted label into — and that default is read AS IT IS, never as
5923
+ * {@link defaultSelection} would substitute it: the substitution only exists
5924
+ * once it is written into the session, so judging the substitute would skip
5925
+ * the write that makes it real.
5926
+ * @param session - the session whose selection is being judged.
5927
+ * @returns whether that selection names a hosted engine route.
5928
+ */
5929
+ selectsHostedRoute(session) {
5930
+ const selected = this.currentSelection(session) ?? this.deploymentDefault();
5931
+ return selected !== void 0 && isHostedProviderRoute(selected.provider);
5932
+ }
5933
+ /**
5934
+ * Append the selection one engine's seat carries, by the judgements both
5935
+ * triggers share.
5936
+ *
5937
+ * A real model selection is never overwritten — the provider is the test,
5938
+ * because only a label some hosted engine logs is a seat this module owns — and
5939
+ * a session that already selects the target route is left alone. Provider and
5940
+ * model are the comparison: a reasoning effort the target carries is not a
5941
+ * reason to log a second event for a session that already selects the same
5942
+ * route, and re-appending would only grow the log on every switch.
5943
+ * @param session - the session whose selection is being written.
5944
+ * @param engine - the engine whose seat the session should select.
5945
+ * @returns whether a `model/selection` event was appended.
5946
+ */
5947
+ appendSeat(session, engine) {
5948
+ const target = this.seatOf(engine);
5949
+ if (target === void 0) return false;
5950
+ const current = this.currentSelection(session);
5951
+ if (current !== void 0) {
5952
+ if (!isHostedProviderRoute(current.provider)) return false;
5953
+ if (current.provider === target.provider && current.model === target.model) return false;
5954
+ }
5955
+ session.append("model/selection", {
5956
+ provider: target.provider,
5957
+ model: target.model,
5958
+ ...target.reasoningEffort === void 0 ? {} : { reasoningEffort: target.reasoningEffort }
5959
+ });
5960
+ return true;
5961
+ }
5962
+ /**
5963
+ * The selection one engine's seat carries: a hosted engine's shared
5964
+ * `external/default`, or — for the harness loop — the deployment's default,
5965
+ * with the substitution {@link defaultSelection} documents.
5966
+ * @param engine - the engine a session runs, or is being moved to.
5967
+ * @returns the selection to write, or undefined when this process cannot name
5968
+ * one for that engine.
5969
+ */
5970
+ seatOf(engine) {
5971
+ if (engine === "in-process") return this.defaultSelection();
5972
+ return { provider: hostedRouteLabelOf(engine), model: HOSTED_DEFAULT_MODEL };
5973
+ }
5974
+ /**
5975
+ * The selection the host will use for this session, by the host's own read —
5976
+ * the shared read in `driver-core/session-model.ts` (`currentSelection`),
5977
+ * which the four hosted drivers also use to resolve the model they hand over.
5978
+ * @param session - the session whose selection is being read.
5979
+ * @returns the current selection, or undefined when the log records none.
5980
+ */
5981
+ currentSelection(session) {
5982
+ return currentSelection(session, this.projections());
5983
+ }
5984
+ /**
5985
+ * The selection the deployment's own `agentDefaultModel` service answers, or
5986
+ * undefined when this process cannot read one (reported once).
5987
+ *
5988
+ * Read twice for two different questions — as the selection a session with
5989
+ * nothing in its log would use ({@link selectsHostedRoute}), and as the value
5990
+ * this plugin restores ({@link defaultSelection}) — so the substitution below
5991
+ * never hides the fact that the saved default itself is unusable.
5992
+ * @returns the validated default, or undefined when it cannot be named.
5993
+ */
5994
+ deploymentDefault() {
5995
+ const service = this.ctx.get("agentDefaultModel");
5996
+ if (service === void 0) return this.unavailable("this deployment composes no agentDefaultModel service");
5997
+ let answered;
5998
+ try {
5999
+ answered = service.currentSelection();
6000
+ } catch (error) {
6001
+ return this.unavailable(`agentDefaultModel.currentSelection() failed: ${String(error)}`);
6002
+ }
6003
+ const named = usableSelection(answered);
6004
+ if (named === void 0) {
6005
+ return this.unavailable("agentDefaultModel.currentSelection() named no usable provider/model");
6006
+ }
6007
+ return named;
6008
+ }
6009
+ /**
6010
+ * The selection this plugin restores: the deployment's default, or — when that
6011
+ * default names a hosted engine ROUTE — the model the deployment's own
6012
+ * composition declares instead.
6013
+ *
6014
+ * The route case is not hypothetical: `session.selectModel` saves whatever the
6015
+ * model menu submitted as the deployment default, and the menu carries the
6016
+ * hosted route as an entry, so one pick made while a hosted session was open
6017
+ * puts a label there that no adapter serves a real model on — handing it to a
6018
+ * session the harness loop drives would fail that session's first request
6019
+ * loud. The composed default is a real model the deployment itself asked for,
6020
+ * so nothing is guessed on its behalf.
6021
+ * @returns the restored selection, or undefined to skip the write.
6022
+ */
6023
+ defaultSelection() {
6024
+ const named = this.deploymentDefault();
6025
+ if (named === void 0) return void 0;
6026
+ if (!isHostedProviderRoute(named.provider)) return named;
6027
+ const configured = this.configuredDefault();
6028
+ if (configured === void 0) {
6029
+ return this.unavailable(`the deployment default model is the hosted engine route "${named.provider}/${named.model}", and this deployment composes no usable default model to replace it with`);
6030
+ }
6031
+ if (!this.reportedHostedDefault) {
6032
+ this.reportedHostedDefault = true;
6033
+ this.warn(
6034
+ `loop-engine: the deployment default model is the hosted engine route "${named.provider}/${named.model}", which no adapter serves; sessions the in-process engine drives are given the deployment's configured default model "${configured.provider}/${configured.model}" instead \u2014 pick a real model to make that the default again`
6035
+ );
6036
+ }
6037
+ return configured;
6038
+ }
6039
+ /**
6040
+ * The model the DEPLOYMENT's own composition declares as its default, read
6041
+ * from the settings descriptor's `base` layer — the value a saved (user-layer)
6042
+ * default overrode.
6043
+ *
6044
+ * This is what replaces a default that names a hosted route, and it is read
6045
+ * live because the composition is what the deployment itself configured:
6046
+ * `packages/bundle/base/cordis.patch.yml` gives the `agent-default-model` row
6047
+ * a real provider and model, and only the user layer can hold a pick made from
6048
+ * the model menu.
6049
+ * @returns the configured default, or undefined when this deployment composes
6050
+ * no default-model section, cannot enumerate settings, configured no usable
6051
+ * provider/model, or configured a hosted route there too.
6052
+ */
6053
+ configuredDefault() {
6054
+ const settings = this.ctx.get("settings");
6055
+ if (settings?.describe === void 0) return void 0;
6056
+ const base = settings.describe().find((entry) => entry.ns === AGENT_DEFAULT_MODEL_NS)?.base;
6057
+ const named = usableSelection(base);
6058
+ return named === void 0 || isHostedProviderRoute(named.provider) ? void 0 : named;
6059
+ }
6060
+ /** Read the host session-projection registry, structurally. */
6061
+ projections() {
6062
+ return this.ctx.get("sessionProjections");
6063
+ }
6064
+ /**
6065
+ * Report the one thing this read could not answer, once per plugin lifetime.
6066
+ *
6067
+ * Repeated triggers must not repeat it: a deployment without the service would
6068
+ * otherwise log a line per session built for as long as it runs.
6069
+ * @param problem - what this process cannot read, with the reason.
6070
+ * @returns undefined, so callers can `return this.unavailable(…)` to skip.
6071
+ */
6072
+ unavailable(problem) {
6073
+ if (this.warned) return void 0;
6074
+ this.warned = true;
6075
+ this.warn(`loop-engine: a session on the in-process engine cannot be given a real model selection: ${problem}; it keeps the selection its log records, which no adapter serves a real model on`);
6076
+ return void 0;
6077
+ }
6078
+ };
6079
+ function usableSelection(answered) {
6080
+ const provider = answered?.provider;
6081
+ const model = answered?.model;
6082
+ if (typeof provider !== "string" || provider.length === 0 || typeof model !== "string" || model.length === 0) {
6083
+ return void 0;
6084
+ }
6085
+ const effort = answered?.reasoningEffort;
6086
+ return {
6087
+ provider,
6088
+ model,
6089
+ ...typeof effort !== "string" || effort.length === 0 ? {} : { reasoningEffort: effort }
6090
+ };
6091
+ }
6092
+
6093
+ // src/commands.ts
6094
+ import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
5125
6095
  import { homedir as homedir2 } from "node:os";
5126
- import { join as join4, resolve } from "node:path";
6096
+ import { join as join6 } from "node:path";
6097
+ import { createUserMessage as createUserMessage5 } from "@deepseek-ai/dsh-llm";
6098
+ var COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
6099
+ function forwardClaudeCodeCommand(name2) {
6100
+ return (invocation) => {
6101
+ invocation.agent.followup(createUserMessage5({
6102
+ content: [{ type: "text", text: `/${name2}${invocation.rawInput}` }],
6103
+ source: { kind: "user" }
6104
+ }));
6105
+ return { kind: "success" };
6106
+ };
6107
+ }
6108
+ function builtin(name2, description) {
6109
+ return { name: name2, description, handler: forwardClaudeCodeCommand(name2) };
6110
+ }
6111
+ var CLAUDE_CODE_COMMANDS = [
6112
+ builtin("help", "Show help about Claude Code commands"),
6113
+ builtin("compact", "Compact the conversation to reduce context usage"),
6114
+ builtin("clear", "Clear the conversation and start fresh"),
6115
+ builtin("review", "Review recent changes (git diff)")
6116
+ ];
6117
+ function discoverUserSlashCommands() {
6118
+ let entries;
6119
+ try {
6120
+ entries = readdirSync(userCommandsDir(), { encoding: "utf8" });
6121
+ } catch {
6122
+ return [];
6123
+ }
6124
+ const definitions = [];
6125
+ const seen = new Set(CLAUDE_CODE_COMMANDS.map((command) => command.name));
6126
+ for (const entry of entries.sort()) {
6127
+ if (!entry.endsWith(".md")) continue;
6128
+ const name2 = entry.slice(0, -".md".length);
6129
+ if (!COMMAND_NAME.test(name2) || seen.has(name2)) continue;
6130
+ const path = join6(userCommandsDir(), entry);
6131
+ let raw;
6132
+ try {
6133
+ raw = readFileSync2(path, "utf8");
6134
+ } catch {
6135
+ continue;
6136
+ }
6137
+ const description = commandDescription(raw);
6138
+ if (description === void 0) continue;
6139
+ seen.add(name2);
6140
+ definitions.push({ name: name2, description, handler: forwardClaudeCodeCommand(name2) });
6141
+ }
6142
+ return definitions;
6143
+ }
6144
+ function userCommandsDir() {
6145
+ return join6(homedir2(), ".claude", "commands");
6146
+ }
6147
+ function commandDescription(raw) {
6148
+ const trimmed = raw.trim();
6149
+ if (trimmed.length === 0) return void 0;
6150
+ let body = trimmed;
6151
+ if (trimmed.startsWith("---\n")) {
6152
+ const closing = trimmed.indexOf("\n---");
6153
+ if (closing <= 0) return void 0;
6154
+ for (const line of trimmed.slice(4, closing).split("\n")) {
6155
+ const colon = line.indexOf(":");
6156
+ if (colon < 0 || line.slice(0, colon).trim() !== "description") continue;
6157
+ const value = line.slice(colon + 1).trim().replace(/^["']|["']$/g, "");
6158
+ if (value.length > 0) return value;
6159
+ }
6160
+ body = trimmed.slice(closing + 4);
6161
+ }
6162
+ for (const line of body.split("\n")) {
6163
+ const candidate = line.trim();
6164
+ if (candidate.length === 0 || candidate.startsWith("#")) continue;
6165
+ return candidate.length > 120 ? `${candidate.slice(0, 119)}\u2026` : candidate;
6166
+ }
6167
+ return void 0;
6168
+ }
6169
+
6170
+ // src/engine-kimi/commands.ts
6171
+ import { createUserMessage as createUserMessage6 } from "@deepseek-ai/dsh-llm";
6172
+ function forwardKimiCommand(name2) {
6173
+ return (invocation) => {
6174
+ invocation.agent.followup(createUserMessage6({
6175
+ content: [{ type: "text", text: `/${name2}${invocation.rawInput}` }],
6176
+ source: { kind: "user" }
6177
+ }));
6178
+ return { kind: "success" };
6179
+ };
6180
+ }
6181
+ function builtin2(name2, description) {
6182
+ return { name: name2, description, handler: forwardKimiCommand(name2) };
6183
+ }
6184
+ var KIMI_COMMANDS = [
6185
+ builtin2("compact", "Compact the conversation context"),
6186
+ builtin2("status", "Show current session status"),
6187
+ builtin2("usage", "Show session token usage"),
6188
+ builtin2("mcp", "Show MCP server status"),
6189
+ builtin2("tasks", "List background tasks"),
6190
+ builtin2("help", "Show available ACP commands")
6191
+ ];
6192
+
6193
+ // src/skills.ts
6194
+ import { readFile as readFile2, readdir, stat } from "node:fs/promises";
6195
+ import { homedir as homedir3 } from "node:os";
6196
+ import { join as join7, resolve } from "node:path";
5127
6197
  var SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5128
6198
  var PROVIDER_NAME = "claude-code";
5129
6199
  var CLAUDE_CODE_RANK = 150;
@@ -5190,8 +6260,8 @@ function findClosingFrontmatter(raw, start) {
5190
6260
  function parseSkillFile(raw) {
5191
6261
  const parsed = parseFrontmatter(raw);
5192
6262
  if (parsed === void 0) return void 0;
5193
- const name2 = stringField(parsed.data, "name");
5194
- const description = stringField(parsed.data, "description");
6263
+ const name2 = stringField2(parsed.data, "name");
6264
+ const description = stringField2(parsed.data, "description");
5195
6265
  if (name2 === void 0 || description === void 0 || !SKILL_NAME.test(name2)) return void 0;
5196
6266
  const disableModelInvocation = booleanField(parsed.data, "disable-model-invocation");
5197
6267
  const userInvocable = booleanField(parsed.data, "user-invocable");
@@ -5207,7 +6277,7 @@ function parseSkillFile(raw) {
5207
6277
  content: parsed.body.trim()
5208
6278
  };
5209
6279
  }
5210
- function stringField(data, key) {
6280
+ function stringField2(data, key) {
5211
6281
  const value = data[key];
5212
6282
  return typeof value === "string" && value.length > 0 ? value : void 0;
5213
6283
  }
@@ -5233,17 +6303,17 @@ var ClaudeCodeSkillProvider = class {
5233
6303
  const cwd = options.cwd;
5234
6304
  if (cwd !== void 0) {
5235
6305
  const projectRoot = await findProjectRoot(resolve(cwd));
5236
- await collectSkillsDir(join4(projectRoot, ".claude", "skills"), CLAUDE_CODE_RANK, candidates);
6306
+ await collectSkillsDir(join7(projectRoot, ".claude", "skills"), CLAUDE_CODE_RANK, candidates);
5237
6307
  await collectClaudeMd(projectRoot, candidates);
5238
6308
  }
5239
- await collectSkillsDir(join4(homedir2(), ".claude", "skills"), CLAUDE_CODE_USER_RANK, candidates);
6309
+ await collectSkillsDir(join7(homedir3(), ".claude", "skills"), CLAUDE_CODE_USER_RANK, candidates);
5240
6310
  if (this.control.signal.aborted) return [];
5241
6311
  return candidates;
5242
6312
  }
5243
6313
  async get(candidate, _options) {
5244
6314
  const locator = candidate.locator;
5245
6315
  try {
5246
- const raw = await readFile(locator.path, { encoding: "utf8" });
6316
+ const raw = await readFile2(locator.path, { encoding: "utf8" });
5247
6317
  const parsed = parseSkillFile(raw);
5248
6318
  if (parsed === void 0) return void 0;
5249
6319
  return {
@@ -5270,11 +6340,11 @@ async function collectSkillsDir(skillsDir, rank, candidates) {
5270
6340
  return;
5271
6341
  }
5272
6342
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
5273
- const entryPath = join4(skillsDir, entry.name);
6343
+ const entryPath = join7(skillsDir, entry.name);
5274
6344
  const info = await stat(entryPath).catch(() => void 0);
5275
6345
  if (info === void 0) continue;
5276
6346
  if (info.isDirectory()) {
5277
- const path = join4(entryPath, "SKILL.md");
6347
+ const path = join7(entryPath, "SKILL.md");
5278
6348
  const skill2 = await tryParseSkill(path);
5279
6349
  if (skill2 === void 0) continue;
5280
6350
  candidates.push(toCandidate(skill2, path, rank, entryPath));
@@ -5287,7 +6357,7 @@ async function collectSkillsDir(skillsDir, rank, candidates) {
5287
6357
  }
5288
6358
  }
5289
6359
  async function collectClaudeMd(projectRoot, candidates) {
5290
- const claudeMd = join4(projectRoot, "CLAUDE.md");
6360
+ const claudeMd = join7(projectRoot, "CLAUDE.md");
5291
6361
  try {
5292
6362
  const info = await stat(claudeMd);
5293
6363
  if (!info.isFile()) return;
@@ -5314,7 +6384,7 @@ function toCandidate(skill, path, rank, resourceBaseDir) {
5314
6384
  }
5315
6385
  async function tryParseSkill(path) {
5316
6386
  try {
5317
- const raw = await readFile(path, { encoding: "utf8" });
6387
+ const raw = await readFile2(path, { encoding: "utf8" });
5318
6388
  return parseSkillFile(raw);
5319
6389
  } catch {
5320
6390
  return void 0;
@@ -5324,7 +6394,7 @@ async function findProjectRoot(cwd) {
5324
6394
  let current = cwd;
5325
6395
  while (true) {
5326
6396
  try {
5327
- await stat(join4(current, ".git"));
6397
+ await stat(join7(current, ".git"));
5328
6398
  return current;
5329
6399
  } catch {
5330
6400
  }
@@ -5334,7 +6404,17 @@ async function findProjectRoot(cwd) {
5334
6404
  }
5335
6405
  }
5336
6406
 
6407
+ // src/engine-codex/skills.ts
6408
+ import { homedir as homedir4 } from "node:os";
6409
+ import { join as join10 } from "node:path";
6410
+
6411
+ // src/driver-core/agents-md-skill-provider.ts
6412
+ import { readdir as readdir2, readFile as readFile4, stat as stat3 } from "node:fs/promises";
6413
+ import { dirname as dirname4, join as join9 } from "node:path";
6414
+
5337
6415
  // src/driver-core/context-files.ts
6416
+ import { readFile as readFile3, stat as stat2 } from "node:fs/promises";
6417
+ import { join as join8, resolve as resolve2 } from "node:path";
5338
6418
  async function projectAncestors(cwd) {
5339
6419
  const root = await findProjectRoot(resolve2(cwd));
5340
6420
  const dirs = [];
@@ -5355,11 +6435,11 @@ async function collectProjectContextFiles(cwd, policy) {
5355
6435
  }
5356
6436
  async function dirContextFile(dir, policy) {
5357
6437
  if (policy.override !== void 0) {
5358
- const override = join5(dir, policy.override);
6438
+ const override = join8(dir, policy.override);
5359
6439
  if (await pathExists(override)) return override;
5360
6440
  }
5361
6441
  for (const name2 of policy.primary) {
5362
- const candidate = join5(dir, name2);
6442
+ const candidate = join8(dir, name2);
5363
6443
  if (await pathExists(candidate)) return candidate;
5364
6444
  }
5365
6445
  return void 0;
@@ -5374,7 +6454,7 @@ async function pathExists(path) {
5374
6454
  }
5375
6455
  async function readOptionalFile(path) {
5376
6456
  try {
5377
- return await readFile2(path, { encoding: "utf8" });
6457
+ return await readFile3(path, { encoding: "utf8" });
5378
6458
  } catch {
5379
6459
  return void 0;
5380
6460
  }
@@ -5419,18 +6499,18 @@ var AgentsMdSkillProvider = class {
5419
6499
  if (this.spec.skills !== void 0) {
5420
6500
  const catalog = this.spec.skills;
5421
6501
  for (const dir of await projectAncestors(cwd)) {
5422
- await this.collectSkillsDir(join6(dir, ...catalog.project), catalog.projectRank, candidates);
6502
+ await this.collectSkillsDir(join9(dir, ...catalog.project), catalog.projectRank, candidates);
5423
6503
  }
5424
6504
  }
5425
6505
  }
5426
6506
  const userDir = this.spec.userDir();
5427
6507
  const userContext = this.spec.userContext;
5428
6508
  if (userContext !== void 0) {
5429
- const userFile = join6(userDir, userContext.file);
6509
+ const userFile = join9(userDir, userContext.file);
5430
6510
  if (await fileNonEmpty(userFile)) candidates.push(this.agentsCandidate([userFile], userContext.rank));
5431
6511
  }
5432
6512
  if (this.spec.skills !== void 0) {
5433
- await this.collectSkillsDir(join6(userDir, this.spec.skills.userDir), this.spec.skills.userRank, candidates);
6513
+ await this.collectSkillsDir(join9(userDir, this.spec.skills.userDir), this.spec.skills.userRank, candidates);
5434
6514
  }
5435
6515
  if (this.control.signal.aborted) return [];
5436
6516
  return candidates;
@@ -5449,7 +6529,7 @@ var AgentsMdSkillProvider = class {
5449
6529
  provider: this.name,
5450
6530
  content: parsed.content,
5451
6531
  path: locator.path,
5452
- resourceBase: { kind: "directory", path: dirname3(locator.path) }
6532
+ resourceBase: { kind: "directory", path: dirname4(locator.path) }
5453
6533
  };
5454
6534
  }
5455
6535
  const content = await readSources(locator.paths);
@@ -5490,11 +6570,11 @@ var AgentsMdSkillProvider = class {
5490
6570
  return;
5491
6571
  }
5492
6572
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
5493
- const entryPath = join6(skillsDir, entry.name);
6573
+ const entryPath = join9(skillsDir, entry.name);
5494
6574
  const info = await stat3(entryPath).catch(() => void 0);
5495
6575
  if (info === void 0) continue;
5496
6576
  if (info.isDirectory()) {
5497
- const path = join6(entryPath, "SKILL.md");
6577
+ const path = join9(entryPath, "SKILL.md");
5498
6578
  const parsed2 = await this.tryParse(path);
5499
6579
  if (parsed2 === void 0) continue;
5500
6580
  candidates.push(this.skillCandidate(parsed2, path, rank, entryPath));
@@ -5521,18 +6601,74 @@ var AgentsMdSkillProvider = class {
5521
6601
  resourceBase: { kind: "directory", path: resourceDir }
5522
6602
  };
5523
6603
  }
5524
- /** Parse one SKILL.md file, or `undefined` when it is unreadable or invalid. */
5525
- async tryParse(path) {
5526
- try {
5527
- const raw = await readFile3(path, { encoding: "utf8" });
5528
- return parseSkillFile(raw);
5529
- } catch {
5530
- return void 0;
5531
- }
6604
+ /** Parse one SKILL.md file, or `undefined` when it is unreadable or invalid. */
6605
+ async tryParse(path) {
6606
+ try {
6607
+ const raw = await readFile4(path, { encoding: "utf8" });
6608
+ return parseSkillFile(raw);
6609
+ } catch {
6610
+ return void 0;
6611
+ }
6612
+ }
6613
+ };
6614
+
6615
+ // src/engine-codex/skills.ts
6616
+ var CODEX_PROJECT_RANK = 140;
6617
+ var CODEX_USER_RANK = 160;
6618
+ var CODEX_CONTEXT_POLICY = { primary: ["AGENTS.md"] };
6619
+ var CODEX_SPEC = {
6620
+ name: "codex",
6621
+ agentsMdDescription: "Codex project/user instructions (AGENTS.md)",
6622
+ contextPolicy: CODEX_CONTEXT_POLICY,
6623
+ userDir: () => join10(homedir4(), ".codex"),
6624
+ projectRank: CODEX_PROJECT_RANK,
6625
+ userContext: { file: "AGENTS.md", rank: CODEX_USER_RANK }
6626
+ };
6627
+ var CodexSkillProvider = class extends AgentsMdSkillProvider {
6628
+ constructor(control) {
6629
+ super(CODEX_SPEC, control);
6630
+ }
6631
+ };
6632
+
6633
+ // src/engine-pi/skills.ts
6634
+ import { homedir as homedir5 } from "node:os";
6635
+ import { join as join11, resolve as resolve3 } from "node:path";
6636
+ var PI_AGENTS_PROJECT_RANK = 140;
6637
+ var PI_SKILL_PROJECT_RANK = 150;
6638
+ var PI_AGENTS_USER_RANK = 160;
6639
+ var PI_SKILL_USER_RANK = 170;
6640
+ var PI_CONTEXT_POLICY = {
6641
+ override: "AGENTS.override.md",
6642
+ primary: ["AGENTS.md", "CLAUDE.md"]
6643
+ };
6644
+ function piAgentDir2() {
6645
+ const override = process.env.PI_CODING_AGENT_DIR;
6646
+ if (override !== void 0 && override.length > 0) return resolve3(override);
6647
+ return join11(homedir5(), ".pi", "agent");
6648
+ }
6649
+ var PI_SPEC = {
6650
+ name: "pi",
6651
+ agentsMdDescription: "Pi project/user instructions (AGENTS.md / CLAUDE.md)",
6652
+ contextPolicy: PI_CONTEXT_POLICY,
6653
+ userDir: piAgentDir2,
6654
+ projectRank: PI_AGENTS_PROJECT_RANK,
6655
+ userContext: { file: "AGENTS.md", rank: PI_AGENTS_USER_RANK },
6656
+ skills: {
6657
+ project: [".pi", "skills"],
6658
+ projectRank: PI_SKILL_PROJECT_RANK,
6659
+ userDir: "skills",
6660
+ userRank: PI_SKILL_USER_RANK
6661
+ }
6662
+ };
6663
+ var PiSkillProvider = class extends AgentsMdSkillProvider {
6664
+ constructor(control) {
6665
+ super(PI_SPEC, control);
5532
6666
  }
5533
6667
  };
5534
6668
 
5535
6669
  // src/engine-kimi/skills.ts
6670
+ import { homedir as homedir6 } from "node:os";
6671
+ import { join as join12, resolve as resolve4 } from "node:path";
5536
6672
  var KIMI_AGENTS_PROJECT_RANK = 140;
5537
6673
  var KIMI_SKILL_PROJECT_RANK = 150;
5538
6674
  var KIMI_SKILL_USER_RANK = 160;
@@ -5541,8 +6677,8 @@ var KIMI_CONTEXT_POLICY = {
5541
6677
  };
5542
6678
  function kimiAgentDir() {
5543
6679
  const override = process.env.KIMI_CODE_HOME;
5544
- if (override !== void 0 && override.length > 0) return resolve3(override);
5545
- return join7(homedir3(), ".kimi-code");
6680
+ if (override !== void 0 && override.length > 0) return resolve4(override);
6681
+ return join12(homedir6(), ".kimi-code");
5546
6682
  }
5547
6683
  var KIMI_SPEC = {
5548
6684
  name: "kimi",
@@ -5563,390 +6699,635 @@ var KimiSkillProvider = class extends AgentsMdSkillProvider {
5563
6699
  }
5564
6700
  };
5565
6701
 
5566
- // src/engine-kimi/commands.ts
5567
- import { createUserMessage as createUserMessage5 } from "@deepseek-ai/dsh-llm";
5568
- function forwardKimiCommand(name2) {
5569
- return (invocation) => {
5570
- invocation.agent.followup(createUserMessage5({
5571
- content: [{ type: "text", text: `/${name2}${invocation.rawInput}` }],
5572
- source: { kind: "user" }
5573
- }));
5574
- return { kind: "success" };
5575
- };
5576
- }
5577
- function builtin(name2, description) {
5578
- return { name: name2, description, handler: forwardKimiCommand(name2) };
5579
- }
5580
- var KIMI_COMMANDS = [
5581
- builtin("help", "Show available Kimi Code commands"),
5582
- builtin("status", "Show the current session runtime state"),
5583
- builtin("compact", "Compact the conversation context to free token usage"),
5584
- builtin("clear", "Start a fresh session, discarding the current context"),
5585
- builtin("plan", "Toggle plan (read-only exploration) mode"),
5586
- builtin("auto", "Toggle auto permission mode"),
5587
- builtin("usage", "Show token usage, context, and quota information"),
5588
- builtin("version", "Display the Kimi Code CLI version number"),
5589
- builtin("goal", "Start or manage an autonomous goal")
5590
- ];
5591
-
5592
- // src/settings.ts
5593
- import z5 from "@deepseek-ai/schemastery";
5594
-
5595
- // src/namespace.ts
5596
- var LOOP_ENGINE_SETTINGS_NAMESPACE_LITERAL = "agent-loop-engine";
5597
-
5598
- // src/settings.ts
5599
- var LOOP_ENGINE_IDS = ["in-process", "claude-code", "codex", "pi", "kimi"];
5600
- var LOOP_ENGINE_SETTINGS_SCHEMA = z5.object({
5601
- engine: z5.union(LOOP_ENGINE_IDS.map((id) => z5.const(id))).default("in-process"),
5602
- showInComposer: z5.boolean().default(true)
5603
- });
5604
- function loopEngineSettingsNamespace() {
5605
- return LOOP_ENGINE_SETTINGS_NAMESPACE_LITERAL;
5606
- }
5607
-
5608
- // src/patch-manager.ts
5609
- var MANAGED_BLOCK_BEGIN = "# -- dsh-loop-engine managed block: ";
5610
- var MANAGED_BLOCK_END = "# -- /dsh-loop-engine managed block --";
5611
- var END_MARKER_LINE = `${MANAGED_BLOCK_END}
5612
- `;
5613
- function renderManagedBlock(engine) {
5614
- if (engine === "in-process") return "";
5615
- return [
5616
- `${MANAGED_BLOCK_BEGIN}${engine} --`,
5617
- "- id: agent-loop",
5618
- " disabled: true",
5619
- "- id: command-goal",
5620
- " disabled: true",
5621
- END_MARKER_LINE
5622
- ].join("\n");
5623
- }
5624
- function hasManagedBlock(text) {
5625
- return text.includes(MANAGED_BLOCK_BEGIN);
5626
- }
5627
- var BEGIN_MARKER_RE = /^# -- dsh-loop-engine managed block: (\S+) --$/m;
5628
- function managedBlockEngineOf(text) {
5629
- const engine = BEGIN_MARKER_RE.exec(text)?.[1];
5630
- return LOOP_ENGINE_IDS.includes(engine ?? "") ? engine : void 0;
5631
- }
5632
- function currentEngineOf(text) {
5633
- return managedBlockEngineOf(text) ?? "in-process";
5634
- }
5635
- function managedSpan(text) {
5636
- const begin = text.indexOf(MANAGED_BLOCK_BEGIN);
5637
- if (begin === -1) return { head: text, tail: "", present: false, blankBefore: false };
5638
- const afterBegin = begin + MANAGED_BLOCK_BEGIN.length;
5639
- const endAt = text.indexOf(MANAGED_BLOCK_END, afterBegin);
5640
- const spanEnd = endAt === -1 ? text.length : endAt + END_MARKER_LINE.length;
5641
- const before = text.slice(0, begin);
5642
- const blankBefore = before.endsWith("\n\n");
5643
- return {
5644
- head: blankBefore ? before.slice(0, -1) : before,
5645
- tail: text.slice(spanEnd),
5646
- present: true,
5647
- blankBefore
5648
- };
5649
- }
5650
- function ensureTrailingNewline(text) {
5651
- return text.endsWith("\n") ? text : `${text}
5652
- `;
5653
- }
5654
- function hasRootEntry(text) {
5655
- return /^(?:- |\[)/m.test(text);
5656
- }
5657
- function dropSeedPlaceholder(text) {
5658
- return text.replace(/^\[\]\n/m, "");
5659
- }
5660
- function seedEmptyArray(text) {
5661
- const head = text.replace(/\n+$/, "");
5662
- return head === "" ? "[]\n" : `${head}
5663
- []
5664
- `;
5665
- }
5666
- function applyManagedBlock(text, engine) {
5667
- const block = renderManagedBlock(engine);
5668
- const span = managedSpan(text);
5669
- let result;
5670
- if (!span.present) {
5671
- if (block === "") {
5672
- result = text;
5673
- } else {
5674
- const base = ensureTrailingNewline(text);
5675
- result = `${base}
5676
- ${block}`;
6702
+ // src/engine-surface.ts
6703
+ var SURFACES = {
6704
+ "claude-code": {
6705
+ commands: () => [...CLAUDE_CODE_COMMANDS, ...discoverUserSlashCommands()],
6706
+ skills: (control) => new ClaudeCodeSkillProvider(control)
6707
+ },
6708
+ codex: {
6709
+ skills: (control) => new CodexSkillProvider(control)
6710
+ },
6711
+ pi: {
6712
+ skills: (control) => new PiSkillProvider(control)
6713
+ },
6714
+ kimi: {
6715
+ commands: () => KIMI_COMMANDS,
6716
+ skills: (control) => new KimiSkillProvider(control)
6717
+ }
6718
+ };
6719
+ function registerEngineSurface(agent, engine, warn) {
6720
+ const surface = SURFACES[engine];
6721
+ const commands = agent.ctx.get("commands");
6722
+ if (commands !== void 0 && surface.commands !== void 0) {
6723
+ for (const command of surface.commands()) {
6724
+ try {
6725
+ commands.register(command);
6726
+ } catch (error) {
6727
+ warn(`loop-engine: skip ${engine} command /${command.name}: ${String(error)}`);
6728
+ }
5677
6729
  }
5678
- } else if (block === "") {
5679
- result = span.tail.startsWith("\n") ? `${span.head}${span.tail.slice(1)}` : `${span.head}${span.tail}`;
5680
- } else {
5681
- result = `${span.head}${span.blankBefore ? "\n" : ""}${block}${span.tail}`;
5682
6730
  }
5683
- if (block !== "") return dropSeedPlaceholder(result);
5684
- if (text.trim() === "") return result;
5685
- return hasRootEntry(result) ? result : seedEmptyArray(result);
6731
+ const skills = agent.ctx.get("skills");
6732
+ if (skills !== void 0 && surface.skills !== void 0) {
6733
+ skills.registerProvider(surface.skills);
6734
+ }
5686
6735
  }
5687
6736
 
5688
- // src/preset.ts
5689
- import { mkdir, readFile as readFile4, rename, writeFile } from "node:fs/promises";
5690
- import { randomUUID } from "node:crypto";
5691
- import { dirname as dirname4, join as join8 } from "node:path";
5692
- var HOSTED_PRESET_ID = "loop-engine";
5693
- var USER_PRESET_DIR = ".agent-presets";
5694
- var COMPOSITION_FILE = "agent.cordis.yml";
5695
- var METADATA_FILE = "preset.yml";
5696
- var SOURCE_PRESET_ID = "standard";
5697
- var STRIPPED_ROWS = ["skill-filesystem", "tool-skill", "tool-goal", "planning", "compaction"];
5698
- var MANAGED_HEADER = `# Managed by dsh-loop-engine: the deployment's "${SOURCE_PRESET_ID}" preset minus
5699
- # the dsh-native command/skill rows a hosted loop engine replaces. Regenerated
5700
- # from "${SOURCE_PRESET_ID}" on boot \u2014 hand edits are overwritten.
5701
- `;
5702
- var MANAGED_METADATA = "name: Hosted Engine\ndescription: Standard preset minus the dsh-native commands and skills a hosted loop engine replaces.\n";
5703
- function isEntryStart(line) {
5704
- return line.startsWith("- ");
5705
- }
5706
- function entryId(line) {
5707
- return /^- id:\s*(\S+)\s*$/.exec(line)?.[1];
5708
- }
5709
- function stripPresetRows(text, ids = STRIPPED_ROWS) {
5710
- const lines = text.split("\n");
5711
- const starts = [];
5712
- for (const [index, line] of lines.entries()) {
5713
- if (isEntryStart(line)) starts.push(index);
6737
+ // src/router-loop.ts
6738
+ var ROUTER_SERVICES = [...AgentLoop.inject];
6739
+ var RouterLoop = class extends AgentLoop {
6740
+ engines = /* @__PURE__ */ new Map();
6741
+ live = /* @__PURE__ */ new Map();
6742
+ build;
6743
+ records;
6744
+ warn;
6745
+ /**
6746
+ * The model-selection half of routing: the seat a session's engine owns, moved
6747
+ * at a switch ({@link RouterLoop.moveModelSelection}) and written at a build
6748
+ * ({@link RouterLoop.engineOptions}). Owned here because each warning it owes
6749
+ * is owed once per process, not once per session.
6750
+ */
6751
+ selectionReset;
6752
+ /**
6753
+ * @param ctx - the context the router's effects belong to.
6754
+ * @param build - memoized builder for one hosted engine's runtime.
6755
+ * @param records - the plugin's own per-session engine record, which outranks
6756
+ * every preset-derived answer and is what {@link selectEngine} writes.
6757
+ * @param warn - diagnostic sink for a skipped engine command.
6758
+ */
6759
+ constructor(ctx, build, records, warn) {
6760
+ super(ctx, { agents: [] });
6761
+ this.build = build;
6762
+ this.records = records;
6763
+ this.warn = warn;
6764
+ this.selectionReset = new ModelSelectionReset(ctx, warn);
6765
+ this.rebuildOnEngineChange();
6766
+ }
6767
+ /** The engine that drives one session's agent. */
6768
+ async engineFor(presetId, parent) {
6769
+ const named = engineOfPreset(presetId);
6770
+ if (named !== void 0) return named;
6771
+ if (parent !== void 0) {
6772
+ const inherited = hostedEngineOf(await engineOfSession(this.ctx, parent.id, this.records));
6773
+ if (inherited !== void 0) return inherited;
6774
+ }
6775
+ return "in-process";
6776
+ }
6777
+ /** The engine's runtime, built on first use and kept for the plugin's lifetime. */
6778
+ runtimeOf(engine) {
6779
+ const existing = this.engines.get(engine);
6780
+ if (existing !== void 0) return existing;
6781
+ const created = this.build(engine);
6782
+ this.engines.set(engine, created);
6783
+ return created;
5714
6784
  }
5715
- if (starts.length === 0) return text;
5716
- const drop = new Set(ids);
5717
- const entries = [];
5718
- let heading = lines.slice(0, starts[0]);
5719
- for (const [index, start] of starts.entries()) {
5720
- const end = index + 1 < starts.length ? starts[index + 1] : lines.length;
5721
- const span = lines.slice(start, end);
5722
- let bodyEnd = span.length;
5723
- while (bodyEnd > 1) {
5724
- const line = span[bodyEnd - 1];
5725
- if (line.trim() !== "" && !line.trimStart().startsWith("#")) break;
5726
- bodyEnd -= 1;
6785
+ /**
6786
+ * Record one published agent so a later engine change can rebuild it, and
6787
+ * bridge the engine's command/skill surface into its session.
6788
+ * @param engine - the engine that built the handle.
6789
+ * @param handle - the published handle.
6790
+ * @param recipe - how it was built, for a hot swap's successor.
6791
+ * @param handover - a hosted handle's session handover; absent for the
6792
+ * harness loop, whose handle carries neither the session's lifetime nor a
6793
+ * way to retire the machine without releasing it.
6794
+ * @returns the handle the harness tracks.
6795
+ */
6796
+ adopt(engine, handle, recipe, handover) {
6797
+ if (engine !== "in-process") registerEngineSurface(handle.agent, engine, this.warn);
6798
+ const entry = {
6799
+ engine,
6800
+ agent: handle.agent,
6801
+ dispose: () => handle.dispose(),
6802
+ recipe,
6803
+ ...handover === void 0 ? {} : { handover }
6804
+ };
6805
+ this.live.set(handle.agent.id, entry);
6806
+ return {
6807
+ agent: handle.agent,
6808
+ dispose: async () => {
6809
+ this.forget(entry);
6810
+ await handle.dispose();
6811
+ }
6812
+ };
6813
+ }
6814
+ /** Drop one tracked session, if the record still points at that exact agent. */
6815
+ forget(entry) {
6816
+ if (this.live.get(entry.agent.id) === entry) this.live.delete(entry.agent.id);
6817
+ }
6818
+ /**
6819
+ * Create a session's agent on the engine it runs.
6820
+ *
6821
+ * The plugin's own record is consulted FIRST, ahead of the preset the caller
6822
+ * composed with: a session that already has a record must be built on the
6823
+ * engine the plugin reports for it, or the router and the Remote would answer
6824
+ * differently about one session. Only a session with no record — every session
6825
+ * this plugin has never switched, including every brand-new one — falls
6826
+ * through to the preset.
6827
+ * @param ownerCtx - caller context that owns the lifecycle.
6828
+ * @param options - identities, metadata (carrying the preset), and setup.
6829
+ * @returns the published handle.
6830
+ */
6831
+ async createAgent(ownerCtx, options) {
6832
+ const recipe = recipeOf(ownerCtx, options);
6833
+ const engine = this.records.engineOf(options.sessionId) ?? await this.engineFor(options.meta?.agentPreset, options.parentAgent);
6834
+ if (engine === "in-process") {
6835
+ return this.adopt(engine, await super.createAgent(ownerCtx, this.engineOptions(options, engine)), recipe);
5727
6836
  }
5728
- entries.push({ id: entryId(span[0]), heading, body: span.slice(0, bodyEnd) });
5729
- heading = span.slice(bodyEnd);
6837
+ const handle = await this.runtimeOf(engine).createAgent(ownerCtx, this.engineOptions(options, engine));
6838
+ return this.adopt(engine, handle, recipe, handoverOf(handle));
5730
6839
  }
5731
- const out = [];
5732
- out.push(...entries[0].heading);
5733
- let lastKept = -1;
5734
- for (const [index, entry] of entries.entries()) {
5735
- if (entry.id !== void 0 && drop.has(entry.id)) continue;
5736
- if (index > 0) out.push(...entry.heading);
5737
- out.push(...entry.body);
5738
- lastKept = index;
6840
+ /**
6841
+ * Resume a persisted session on the engine it runs.
6842
+ *
6843
+ * The read is {@link engineOfSession}: the plugin's own record first, then the
6844
+ * durable `agentPreset` projection, which folds the session header together
6845
+ * with every committed `agent-preset/selected`. The session header alone would
6846
+ * name the preset the session was CREATED with, which is exactly the wrong
6847
+ * answer for a session that switched engine while it was blank — and the
6848
+ * record is the only answer for one that switched after it started, which no
6849
+ * preset can express.
6850
+ *
6851
+ * A resume is not a create: the session's seat is one a previous build already
6852
+ * put in its log (`model-selection-reset.ts`, the switch and build triggers), so
6853
+ * a hosted engine's resume leaves the selection exactly as it finds it. Only
6854
+ * the harness loop's own resume is wrapped, with the narrower trigger that
6855
+ * applies to it — it makes real model calls, so it must not be left selecting a
6856
+ * placeholder route.
6857
+ * @param ownerCtx - caller context that owns load, setup, and the lifecycle.
6858
+ * @param options - persisted identity, loop options, and setup.
6859
+ * @returns the published handle.
6860
+ */
6861
+ async resume(ownerCtx, options) {
6862
+ const recipe = recipeOf(ownerCtx, options);
6863
+ const engine = hostedEngineOf(
6864
+ await engineOfSession(this.ctx, options.resumeSessionId, this.records)
6865
+ ) ?? await this.engineFor(void 0, options.parentAgent);
6866
+ if (engine === "in-process") {
6867
+ return this.adopt(engine, await super.resume(ownerCtx, this.engineOptions(options, engine)), recipe);
6868
+ }
6869
+ const handle = await this.runtimeOf(engine).resume(ownerCtx, options);
6870
+ return this.adopt(engine, handle, recipe, handoverOf(handle));
5739
6871
  }
5740
- if (lastKept === entries.length - 1) out.push(...heading);
5741
- if (out.length > 0 && out[out.length - 1] !== "") out.push("");
5742
- return out.join("\n");
5743
- }
5744
- async function writeIfDifferent(path, text) {
5745
- try {
5746
- if (await readFile4(path, "utf8") === text) return false;
5747
- } catch {
6872
+ /**
6873
+ * Wrap the caller's setup so the session's ENGINE gets its model seat written
6874
+ * before the host installs the session's selection, and hand a hosted engine
6875
+ * the `agentOptions` that seat names.
6876
+ *
6877
+ * The wrap is what makes the write land at all: the host installs the session's
6878
+ * model selection from INSIDE the caller's own setup
6879
+ * (`ApiSessionAgentController.selectionFor`), and that read takes the log's
6880
+ * pending `model/selection` — so the write has to happen before it, and the
6881
+ * caller's setup has to run unchanged afterwards. Both of the router's engines
6882
+ * are wrapped, each with its own trigger: the harness loop's build keeps the
6883
+ * narrow one (only a session that would otherwise select a placeholder route),
6884
+ * while a hosted engine's build is where a NEW session gets its seat. The
6885
+ * resume path calls this with `in-process` alone (see {@link resume}).
6886
+ *
6887
+ * The `agentOptions` half keeps the two answers consistent: a hosted engine is
6888
+ * handed the very selection written into the session (`external/default`), not
6889
+ * the deployment default that seat replaced. In-process keeps the caller's own
6890
+ * options, which ARE its route.
6891
+ *
6892
+ * The caller's setup is copied, never mutated: the same options object is the
6893
+ * router's rebuild recipe, which replays the caller's own closure.
6894
+ * @param options - the create or resume options the caller supplied.
6895
+ * @param engine - the engine building this session.
6896
+ * @returns a copy whose setup writes the seat first, then the caller's own.
6897
+ */
6898
+ engineOptions(options, engine) {
6899
+ const setup = options.setup;
6900
+ const guarded = async (agentCtx, agent) => {
6901
+ this.selectionReset.guardFor(agent.session, engine);
6902
+ return await setup?.(agentCtx, agent);
6903
+ };
6904
+ return {
6905
+ ...options,
6906
+ setup: guarded,
6907
+ ...engine === "in-process" ? {} : { agentOptions: engineRouteOptions(engine) }
6908
+ };
5748
6909
  }
5749
- await mkdir(dirname4(path), { recursive: true });
5750
- const tmp = `${path}.tmp-${randomUUID()}`;
5751
- await writeFile(tmp, text, "utf8");
5752
- await rename(tmp, path);
5753
- return true;
5754
- }
5755
- async function ensureHostedPreset(dshHome, source) {
5756
- const composition = await source.read(SOURCE_PRESET_ID);
5757
- const stripped = `${MANAGED_HEADER}
5758
- ${stripPresetRows(composition)}`;
5759
- const dir = join8(dshHome, USER_PRESET_DIR, HOSTED_PRESET_ID);
5760
- const compositionChanged = await writeIfDifferent(join8(dir, COMPOSITION_FILE), stripped);
5761
- const metadataChanged = await writeIfDifferent(join8(dir, METADATA_FILE), MANAGED_METADATA);
5762
- return compositionChanged || metadataChanged;
5763
- }
5764
-
5765
- // src/provider-route.ts
5766
- import { LlmAdapter, LlmError as LlmError5 } from "@deepseek-ai/dsh-llm";
5767
- var HOSTED_PROVIDER_ROUTES = {
5768
- "claude-code": PROVIDER,
5769
- codex: PROVIDER2,
5770
- pi: PROVIDER3,
5771
- kimi: PROVIDER4
5772
- };
5773
- var HostedEngineRouteAdapter = class extends LlmAdapter {
5774
6910
  /**
5775
- * @param label - the provider route label this placeholder serves.
5776
- * @param options - optional catalog source; omit for an empty catalog.
6911
+ * Report what one session ACTUALLY runs, and — when they differ — the engine
6912
+ * its record names.
6913
+ *
6914
+ * This is the read the plugin's own Remote publishes, and the reason it is the
6915
+ * router's to make is the live bookkeeping below: for a session with an agent
6916
+ * this router built, {@link RouterLoop.live} says which engine built it, and
6917
+ * that beats every record — it is what is running. A session this router does
6918
+ * not drive (cold, or still on the base loop's slot during the mount window)
6919
+ * has no live fact to report, so the record answers, exactly as it does for a
6920
+ * session about to be built; and since every switch this plugin performs
6921
+ * either swaps the agent in place or releases it, the two facts differ only
6922
+ * for a release that did not take.
6923
+ * @param sessionId - the session to report on.
6924
+ * @returns the engine driving it now, plus the engine its record names for its
6925
+ * next build when the two differ.
5777
6926
  */
5778
- constructor(label, options = {}) {
5779
- super();
5780
- this.label = label;
5781
- this.options = options;
6927
+ async reportEngine(sessionId) {
6928
+ return await engineReportOfSession(
6929
+ this.ctx,
6930
+ sessionId,
6931
+ this.records,
6932
+ (id) => this.live.get(id)?.engine
6933
+ );
6934
+ }
6935
+ /**
6936
+ * Move one live session to another engine.
6937
+ *
6938
+ * The one entry point that makes "switch this session's engine" possible after
6939
+ * a session has started, which the harness's own preset channel refuses
6940
+ * (`agent-preset/locked`, `packages/preset/agent-presets/src/index.ts`). The
6941
+ * order below is load-bearing:
6942
+ *
6943
+ * 1. the checks, so a session that cannot be moved is refused with a reason
6944
+ * instead of half-moved;
6945
+ * 2. the record, because it is the answer the router, the Remote, and the
6946
+ * host's next resolve all read — and a record that could not be written
6947
+ * must leave the session where it is;
6948
+ * 3. the session's model seat, which moves with the choice
6949
+ * ({@link moveModelSelection}) — before the move below, because the move is
6950
+ * what detaches (or re-installs) the agent that reads it;
6951
+ * 4. the move itself: an in-place swap between two hosted engines, or — when
6952
+ * the harness loop is on either side — a release of the session's agent
6953
+ * plus a request that the page reload (`reload: true`), which is what
6954
+ * makes the host build the session again on the recorded engine.
6955
+ *
6956
+ * A session already on the requested engine is recorded and left running: the
6957
+ * record is the user's explicit choice, while tearing an untouched agent down
6958
+ * would cost the session's rebuild for no change at all. "Already on" is
6959
+ * judged on the LIVE AGENT's engine, not on the record: the two can differ
6960
+ * while a released agent is still in flight, and then re-selecting the engine
6961
+ * the session is actually running is how a user takes that record back — the
6962
+ * record follows the agent back, and nothing is torn down.
6963
+ * @param sessionId - the session to move.
6964
+ * @param engine - the engine it should run.
6965
+ * @returns the engine now recorded for it, whether the page must reload for it
6966
+ * to be built, or — as `ok: false` — the {@link LoopEngineRefusalCode} of the
6967
+ * branch that refused it plus its own sentence about this session. Every
6968
+ * refusal this method produces carries a code; the browser half localizes
6969
+ * from it and keeps the sentence as detail.
6970
+ */
6971
+ async selectEngine(sessionId, engine) {
6972
+ const agent = this.ctx.agents.get(sessionId);
6973
+ if (agent === void 0) {
6974
+ return refuse2("session-closed", `session "${sessionId}" is not open; open it first, then switch its engine`);
6975
+ }
6976
+ if (agent.status === "running") {
6977
+ return refuse2("turn-running", `session "${sessionId}" is running; switch its engine after this turn ends`);
6978
+ }
6979
+ if (agent.session.header.origin === "subagent") {
6980
+ return refuse2("subagent-session", `session "${sessionId}" is a subagent session; its agent belongs to subagent routing`);
6981
+ }
6982
+ const entry = this.live.get(sessionId);
6983
+ if (entry === void 0) {
6984
+ return refuse2("not-driven", `session "${sessionId}" is not driven by this plugin's loop router`);
6985
+ }
6986
+ try {
6987
+ this.records.record(sessionId, engine);
6988
+ } catch (error) {
6989
+ return refuse2("record-failed", `could not record the engine of session "${sessionId}": ${String(error)}`);
6990
+ }
6991
+ this.moveModelSelection(entry, engine);
6992
+ if (entry.engine === engine) return { ok: true, engine };
6993
+ return await this.move(entry, engine);
6994
+ }
6995
+ /**
6996
+ * Make a just-recorded engine change take effect on the live session.
6997
+ *
6998
+ * Between two hosted engines the change is an IN-PLACE SWAP
6999
+ * ({@link hotSwap}): the session's `Session` object is kept, its agent is
7000
+ * replaced, and the browser half attached to that session sees no lifecycle
7001
+ * edge at all.
7002
+ *
7003
+ * A change with the harness loop on EITHER side cannot be done in place, and
7004
+ * this is a limit of the harness rather than a choice: `AgentLoop` neither
7005
+ * hands a live session over nor accepts one it did not create. Its factory
7006
+ * holds the session's store entry and write handle in private closure state
7007
+ * (`agent-loop/src/index.ts` `prepare`), its publication enters the session
7008
+ * (`sessions.enter`, which refuses an id already in the store), and its own
7009
+ * agent class is not exported. So the session's agent is RELEASED instead
7010
+ * ({@link release}) and the change lands on the session's next build, which
7011
+ * the returned `reload: true` puts in the user's hands: the session goes cold
7012
+ * with the record already naming its engine, and the page — reloaded, and
7013
+ * reopening that session — is what makes the host resolve it again, on the
7014
+ * engine the record names.
7015
+ *
7016
+ * The page really does have to reload, and that is the one cost of this path:
7017
+ * a release emits `session/disposed`, and the browser half reads that as this
7018
+ * session being gone, with no way back in that page's lifetime
7019
+ * (`session.ts` `handleRemoved` sets a `removed` flag nothing ever clears), so
7020
+ * a page that stayed put would show a session that can no longer be typed
7021
+ * into. The alternative — leaving the live agent alone and letting the record
7022
+ * wait for a process restart — is what this plugin used to do, and it cost the
7023
+ * user a restart for a switch the host can perform itself.
7024
+ * @param entry - the live session's record; the caller verified it is movable.
7025
+ * @param engine - the engine just recorded for it.
7026
+ * @returns the switch outcome, asking for the page reload that builds the
7027
+ * session again when its agent had to be released.
7028
+ */
7029
+ async move(entry, engine) {
7030
+ const handover = entry.handover;
7031
+ if (handover === void 0 || engine === "in-process") {
7032
+ await this.release(entry);
7033
+ return { ok: true, engine, reload: true };
7034
+ }
7035
+ return await this.hotSwap(entry, handover, engine);
7036
+ }
7037
+ /**
7038
+ * Replace one live session's agent with a machine the incoming engine builds
7039
+ * onto the SAME `Session`.
7040
+ *
7041
+ * The order is load-bearing. The outgoing machine is retired FIRST and
7042
+ * completely: only one agent may be registered per session id (`agents.enter`
7043
+ * refuses a duplicate), and two machines folded over one session's inbox would
7044
+ * splice each other's queued messages, so the outgoing machine must be settled
7045
+ * before any input can reach the successor. The SESSION is never released:
7046
+ * its store entry and write handle travel in the lifetime the outgoing machine
7047
+ * hands over, which is exactly why the page attached to this session stays
7048
+ * usable across the swap.
7049
+ *
7050
+ * A failure after the handover cannot be undone — the outgoing machine is
7051
+ * gone and the successor never published. The lifetime is then released, so
7052
+ * the session goes cold and reopens on the recorded engine the way any session
7053
+ * the host has not loaded does, and the refusal says what happened.
7054
+ * @param entry - the live session's record; the caller verified it is movable.
7055
+ * @param handover - the outgoing machine's session handover.
7056
+ * @param engine - the hosted engine to build the successor on.
7057
+ * @returns the switch outcome.
7058
+ */
7059
+ async hotSwap(entry, handover, engine) {
7060
+ const sessionId = entry.agent.id;
7061
+ try {
7062
+ await handover.retire();
7063
+ } catch (error) {
7064
+ this.warn(`loop-engine: could not fully retire the ${entry.engine} agent of "${sessionId}" before switching it to ${engine}: ${String(error)}`);
7065
+ }
7066
+ try {
7067
+ const handle = await this.runtimeOf(engine).swap(entry.recipe.ownerCtx, {
7068
+ lifetime: handover.lifetime,
7069
+ agentOptions: entry.recipe.agentOptions,
7070
+ setup: entry.recipe.setup,
7071
+ parentAgent: entry.recipe.parentAgent
7072
+ });
7073
+ this.adopt(engine, handle, entry.recipe, handoverOf(handle));
7074
+ return { ok: true, engine };
7075
+ } catch (error) {
7076
+ this.forget(entry);
7077
+ try {
7078
+ await handover.lifetime.closeHandle();
7079
+ } catch (closeFailure) {
7080
+ this.warn(`loop-engine: could not close the write handle of session "${sessionId}" after a failed switch: ${String(closeFailure)}`);
7081
+ }
7082
+ handover.lifetime.leaveStore();
7083
+ return refuse2("rebuild-failed", `could not rebuild session "${sessionId}" on ${engine}: ${String(error)}`);
7084
+ }
7085
+ }
7086
+ /** Read the host session-projection registry, structurally. */
7087
+ projections() {
7088
+ return this.ctx.get("sessionProjections");
7089
+ }
7090
+ /**
7091
+ * Drop one tracked session's agent so the host's next resolve rebuilds it.
7092
+ *
7093
+ * Two callers, and they both want the engine change to land at the session's
7094
+ * next build rather than in place:
7095
+ *
7096
+ * - {@link move}, the engine picker's path involving the harness loop, which
7097
+ * WAITS for the teardown (the outcome it is about to return says the
7098
+ * session is cold, and that must be true when it says so);
7099
+ * - the harness's own preset switch on a blank session, which fire-and-forgets
7100
+ * it because it runs inside a synchronous event handler. It is the one
7101
+ * engine change this plugin still makes by releasing the session rather
7102
+ * than by moving it: the successor's composition is the NEW preset's, which
7103
+ * only the API layer composes (a preset the harness mounted itself reaches
7104
+ * the plugin as an id, not as a composition callback), so the plugin cannot
7105
+ * build a correctly-composed successor in place.
7106
+ *
7107
+ * The record is forgotten before the teardown runs, so a teardown-driven event
7108
+ * can never find the entry it is releasing and start a second teardown of the
7109
+ * same agent; a failure is reported rather than thrown, and the returned
7110
+ * promise settles either way.
7111
+ * @param entry - the live session record to release.
7112
+ * @returns the teardown, once it settled.
7113
+ */
7114
+ release(entry) {
7115
+ this.forget(entry);
7116
+ return entry.dispose().catch((error) => {
7117
+ this.warn(`loop-engine: could not release the ${entry.engine} agent of "${entry.agent.id}": ${String(error)}`);
7118
+ });
7119
+ }
7120
+ /**
7121
+ * Follow the harness's own preset switch, for a BLANK session.
7122
+ *
7123
+ * The harness's preset switch on a blank session re-parents the live agent's
7124
+ * scope and records the choice; it does not rebuild the agent, so the engine
7125
+ * would keep running the one the session was created with. Dropping the agent
7126
+ * instead lets the host's own next resolve fall back to a resume
7127
+ * (`ApiSessionAgentController.resolve`), which composes from the recorded
7128
+ * preset and lands on the new engine — the same path a reopened session
7129
+ * takes, with the durable log as the only carry-over.
7130
+ *
7131
+ * LAST USER ACTION WINS: for a session this plugin already holds a record
7132
+ * for, the harness's picker is an engine choice too, so the record follows it
7133
+ * before the agent is released. Without that, the two entry points would
7134
+ * contradict each other — the picker would rebuild the session on the preset's
7135
+ * engine, while the very next resume would read the plugin's record and move
7136
+ * it back. A session with NO record keeps reading its engine off the preset,
7137
+ * exactly as before, and gets none: only an explicit choice is recorded.
7138
+ *
7139
+ * A session that has already run a turn is left alone — its live engine and
7140
+ * its record both stay where they are, so the router and the Remote keep
7141
+ * agreeing about it. (A started session cannot reach here through the
7142
+ * harness's picker anyway: the roster refuses with `agent-preset/locked`.)
7143
+ *
7144
+ * The session's model seat moves with the choice, before the release
7145
+ * ({@link moveModelSelection}) — the picker is an engine change like the
7146
+ * picker's own switcher, and this is the one place the harness can make it.
7147
+ */
7148
+ rebuildOnEngineChange() {
7149
+ this.ctx.on("agent-preset/selected", (sessionId, preset) => {
7150
+ const entry = this.live.get(sessionId);
7151
+ if (entry === void 0) return;
7152
+ const next = engineOfPreset(preset) ?? "in-process";
7153
+ if (next === entry.engine) return;
7154
+ const boundary = this.projections().stateOf(entry.agent.session, "turnBoundary");
7155
+ if (boundary !== void 0 && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) {
7156
+ this.warn(`loop-engine: session "${sessionId}" has already started; its engine stays ${entry.engine}`);
7157
+ return;
7158
+ }
7159
+ if (this.records.engineOf(sessionId) !== void 0) {
7160
+ try {
7161
+ this.records.record(sessionId, next);
7162
+ } catch (error) {
7163
+ this.warn(`loop-engine: could not record the engine of "${sessionId}": ${String(error)}`);
7164
+ }
7165
+ }
7166
+ this.moveModelSelection(entry, next);
7167
+ void this.release(entry);
7168
+ });
5782
7169
  }
5783
- label;
5784
- options;
5785
7170
  /**
5786
- * Advertise the injected Pi models (if any) under this route's provider label.
7171
+ * Hand one session's model seat to the engine it is being switched to.
7172
+ *
7173
+ * A session's selection follows its engine: a hosted engine owns its model
7174
+ * natively and the shared provider label it logs is served only by this
7175
+ * plugin's placeholder route (which fails loud, `HOSTED_ENGINE_ROUTE`, when a
7176
+ * real model call reaches it), while the harness loop DOES make real calls and therefore needs
7177
+ * a real model selected. So a switch moves the seat — the shared
7178
+ * `external/default` for a hosted engine, the deployment default for the
7179
+ * harness loop. A selection that
7180
+ * names a real model of its own is never touched: the model notice the browser
7181
+ * half shows beside a hosted engine says that selection is inert there, not
7182
+ * that it is gone. The write itself, its judgements, and why an engine change
7183
+ * with no logged selection writes nothing are in `model-selection-reset.ts`;
7184
+ * this method is only the router's half.
5787
7185
  *
5788
- * The model is what the picker shows at the top level, so `name` is the bare
5789
- * model; the `provider/model` composite stays the submitted `id`, which is
5790
- * both what the engine receives as `--model` and how the driver validates a
5791
- * session-selected model against its catalog.
7186
+ * The selection is written at the moment of the switch, not at the moment the
7187
+ * engine changes. Between two hosted engines the move is an in-place swap whose
7188
+ * `setup` installs the selection onto the successor, and for anything involving
7189
+ * the harness loop the switch releases the session's agent and the page reloads
7190
+ * ({@link move}) — either way the write has to be in the log before the move
7191
+ * runs, which is what this ordering buys.
7192
+ * @param entry - the live session being switched.
7193
+ * @param engine - the engine it is being switched to.
7194
+ * @returns nothing; a selection this process cannot name is skipped with one
7195
+ * warning, and the session keeps what its log records.
5792
7196
  */
5793
- async listModels(_provider) {
5794
- const catalog = this.options.listModels?.() ?? [];
5795
- return catalog.map((entry) => ({
5796
- provider: this.label,
5797
- id: `${entry.provider}/${entry.model}`,
5798
- name: entry.model
5799
- }));
5800
- }
5801
- stream(_options) {
5802
- throw new LlmError5(
5803
- `provider "${this.label}" is a hosted loop engine route, not a model endpoint`,
5804
- "HOSTED_ENGINE_ROUTE"
5805
- );
7197
+ moveModelSelection(entry, engine) {
7198
+ this.selectionReset.resetFor(entry.agent.session, engine);
5806
7199
  }
5807
7200
  };
5808
-
5809
- // src/commands.ts
5810
- import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
5811
- import { homedir as homedir4 } from "node:os";
5812
- import { join as join9 } from "node:path";
5813
- import { createUserMessage as createUserMessage6 } from "@deepseek-ai/dsh-llm";
5814
- var COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
5815
- function forwardClaudeCodeCommand(name2) {
5816
- return (invocation) => {
5817
- invocation.agent.followup(createUserMessage6({
5818
- content: [{ type: "text", text: `/${name2}${invocation.rawInput}` }],
5819
- source: { kind: "user" }
5820
- }));
5821
- return { kind: "success" };
7201
+ function engineRouteOptions(engine) {
7202
+ return { provider: hostedRouteLabelOf(engine), model: HOSTED_DEFAULT_MODEL };
7203
+ }
7204
+ function refuse2(code, reason) {
7205
+ return { ok: false, code, reason };
7206
+ }
7207
+ function recipeOf(ownerCtx, options) {
7208
+ return {
7209
+ ownerCtx,
7210
+ agentOptions: options.agentOptions ?? {},
7211
+ setup: options.setup,
7212
+ parentAgent: options.parentAgent
5822
7213
  };
5823
7214
  }
5824
- function builtin2(name2, description) {
5825
- return { name: name2, description, handler: forwardClaudeCodeCommand(name2) };
7215
+ function handoverOf(handle) {
7216
+ return { lifetime: handle.lifetime, retire: () => handle.retire() };
5826
7217
  }
5827
- var CLAUDE_CODE_COMMANDS = [
5828
- builtin2("help", "Show help about Claude Code commands"),
5829
- builtin2("compact", "Compact the conversation to reduce context usage"),
5830
- builtin2("clear", "Clear the conversation and start fresh"),
5831
- builtin2("review", "Review recent changes (git diff)"),
5832
- builtin2("explain", "Explain the selected code"),
5833
- builtin2("fix", "Fix issues in the code"),
5834
- builtin2("tests", "Add tests for the selected code")
5835
- ];
5836
- function discoverUserSlashCommands() {
5837
- let entries;
7218
+
7219
+ // src/session-engine-store.ts
7220
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
7221
+ import { randomUUID as randomUUID2 } from "node:crypto";
7222
+ import { dirname as dirname5, join as join13 } from "node:path";
7223
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
7224
+ var ENGINE_RECORD_DIR = ".loop-engine";
7225
+ var ENGINE_RECORD_FILE = "engines.json";
7226
+ var ENGINE_RECORD_VERSION = 1;
7227
+ function resolveEngineRecordPath() {
7228
+ return join13(resolveDshHome(), ENGINE_RECORD_DIR, ENGINE_RECORD_FILE);
7229
+ }
7230
+ function isMissing(error) {
7231
+ return error?.code === "ENOENT";
7232
+ }
7233
+ function writeFileAtomicSync(path, text) {
7234
+ mkdirSync2(dirname5(path), { recursive: true });
7235
+ const tmp = `${path}.tmp-${randomUUID2()}`;
7236
+ writeFileSync2(tmp, text, "utf8");
7237
+ renameSync(tmp, path);
7238
+ }
7239
+ function parseEngineRecord(text) {
7240
+ let parsed;
5838
7241
  try {
5839
- entries = readdirSync(userCommandsDir(), { encoding: "utf8" });
7242
+ parsed = JSON.parse(text);
5840
7243
  } catch {
5841
- return [];
5842
- }
5843
- const definitions = [];
5844
- const seen = new Set(CLAUDE_CODE_COMMANDS.map((command) => command.name));
5845
- for (const entry of entries.sort()) {
5846
- if (!entry.endsWith(".md")) continue;
5847
- const name2 = entry.slice(0, -".md".length);
5848
- if (!COMMAND_NAME.test(name2) || seen.has(name2)) continue;
5849
- const path = join9(userCommandsDir(), entry);
5850
- let raw;
5851
- try {
5852
- raw = readFileSync2(path, "utf8");
5853
- } catch {
5854
- continue;
5855
- }
5856
- const description = commandDescription(raw);
5857
- if (description === void 0) continue;
5858
- seen.add(name2);
5859
- definitions.push({ name: name2, description, handler: forwardClaudeCodeCommand(name2) });
7244
+ return void 0;
5860
7245
  }
5861
- return definitions;
5862
- }
5863
- function userCommandsDir() {
5864
- return join9(homedir4(), ".claude", "commands");
5865
- }
5866
- function commandDescription(raw) {
5867
- const trimmed = raw.trim();
5868
- if (trimmed.length === 0) return void 0;
5869
- let body = trimmed;
5870
- if (trimmed.startsWith("---\n")) {
5871
- const closing = trimmed.indexOf("\n---");
5872
- if (closing <= 0) return void 0;
5873
- for (const line of trimmed.slice(4, closing).split("\n")) {
5874
- const colon = line.indexOf(":");
5875
- if (colon < 0 || line.slice(0, colon).trim() !== "description") continue;
5876
- const value = line.slice(colon + 1).trim().replace(/^["']|["']$/g, "");
5877
- if (value.length > 0) return value;
5878
- }
5879
- body = trimmed.slice(closing + 4);
7246
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
7247
+ const document = parsed;
7248
+ const engines = document.engines;
7249
+ if (document.version !== ENGINE_RECORD_VERSION || typeof engines !== "object" || engines === null || Array.isArray(engines)) {
7250
+ return void 0;
5880
7251
  }
5881
- for (const line of body.split("\n")) {
5882
- const candidate = line.trim();
5883
- if (candidate.length === 0 || candidate.startsWith("#")) continue;
5884
- return candidate.length > 120 ? `${candidate.slice(0, 119)}\u2026` : candidate;
7252
+ const entries = /* @__PURE__ */ new Map();
7253
+ for (const [sessionId, engine] of Object.entries(engines)) {
7254
+ if (isLoopEngineId(engine)) entries.set(sessionId, engine);
5885
7255
  }
5886
- return void 0;
7256
+ return entries;
5887
7257
  }
5888
-
5889
- // src/engine-codex/skills.ts
5890
- import { homedir as homedir5 } from "node:os";
5891
- import { join as join10 } from "node:path";
5892
- var CODEX_PROJECT_RANK = 140;
5893
- var CODEX_USER_RANK = 160;
5894
- var CODEX_CONTEXT_POLICY = { primary: ["AGENTS.md"] };
5895
- var CODEX_SPEC = {
5896
- name: "codex",
5897
- agentsMdDescription: "Codex project/user instructions (AGENTS.md)",
5898
- contextPolicy: CODEX_CONTEXT_POLICY,
5899
- userDir: () => join10(homedir5(), ".codex"),
5900
- projectRank: CODEX_PROJECT_RANK,
5901
- userContext: { file: "AGENTS.md", rank: CODEX_USER_RANK }
5902
- };
5903
- var CodexSkillProvider = class extends AgentsMdSkillProvider {
5904
- constructor(control) {
5905
- super(CODEX_SPEC, control);
7258
+ var SessionEngineStore = class {
7259
+ /**
7260
+ * @param path - the record document's absolute path.
7261
+ * @param warn - diagnostic sink for a record this build cannot read.
7262
+ */
7263
+ constructor(path, warn) {
7264
+ this.path = path;
7265
+ this.warn = warn;
5906
7266
  }
5907
- };
5908
-
5909
- // src/engine-pi/skills.ts
5910
- import { homedir as homedir6 } from "node:os";
5911
- import { join as join11, resolve as resolve4 } from "node:path";
5912
- var PI_AGENTS_PROJECT_RANK = 140;
5913
- var PI_SKILL_PROJECT_RANK = 150;
5914
- var PI_AGENTS_USER_RANK = 160;
5915
- var PI_SKILL_USER_RANK = 170;
5916
- var PI_CONTEXT_POLICY = {
5917
- override: "AGENTS.override.md",
5918
- primary: ["AGENTS.md", "CLAUDE.md"]
5919
- };
5920
- function piAgentDir() {
5921
- const override = process.env.PI_CODING_AGENT_DIR;
5922
- if (override !== void 0 && override.length > 0) return resolve4(override);
5923
- return join11(homedir6(), ".pi", "agent");
5924
- }
5925
- var PI_SPEC = {
5926
- name: "pi",
5927
- agentsMdDescription: "Pi project/user instructions (AGENTS.md / CLAUDE.md)",
5928
- contextPolicy: PI_CONTEXT_POLICY,
5929
- userDir: piAgentDir,
5930
- projectRank: PI_AGENTS_PROJECT_RANK,
5931
- userContext: { file: "AGENTS.md", rank: PI_AGENTS_USER_RANK },
5932
- skills: {
5933
- project: [".pi", "skills"],
5934
- projectRank: PI_SKILL_PROJECT_RANK,
5935
- userDir: "skills",
5936
- userRank: PI_SKILL_USER_RANK
7267
+ path;
7268
+ warn;
7269
+ entries;
7270
+ /**
7271
+ * The engine this plugin's record names for one session.
7272
+ * @param sessionId - the session to look up.
7273
+ * @returns the recorded engine, or undefined when there is no usable record.
7274
+ */
7275
+ engineOf(sessionId) {
7276
+ return this.load().get(String(sessionId));
5937
7277
  }
5938
- };
5939
- var PiSkillProvider = class extends AgentsMdSkillProvider {
5940
- constructor(control) {
5941
- super(PI_SPEC, control);
7278
+ /**
7279
+ * Record one session's engine, replacing any earlier record for it.
7280
+ *
7281
+ * The write commits before the in-memory view moves, so a caller that sees a
7282
+ * rejection knows the store still answers what it answered before.
7283
+ * @param sessionId - the session whose engine is recorded.
7284
+ * @param engine - the engine that session now runs.
7285
+ * @throws when the document could not be written; the previous file is intact.
7286
+ */
7287
+ record(sessionId, engine) {
7288
+ const next = new Map(this.load());
7289
+ next.set(String(sessionId), engine);
7290
+ writeFileAtomicSync(this.path, formatEngineRecord(next));
7291
+ this.entries = next;
7292
+ }
7293
+ /** The in-memory record, read from disk on first use. */
7294
+ load() {
7295
+ this.entries ??= this.read();
7296
+ return this.entries;
7297
+ }
7298
+ /** Read the document once, degrading to an empty record. */
7299
+ read() {
7300
+ let text;
7301
+ try {
7302
+ text = readFileSync3(this.path, "utf8");
7303
+ } catch (error) {
7304
+ if (isMissing(error)) return /* @__PURE__ */ new Map();
7305
+ return this.degrade(`could not read "${this.path}": ${String(error)}`);
7306
+ }
7307
+ return parseEngineRecord(text) ?? this.degrade(`"${this.path}" is not a version ${ENGINE_RECORD_VERSION} engine record`);
7308
+ }
7309
+ /**
7310
+ * Report one unusable record and answer "no record".
7311
+ *
7312
+ * The load is memoized, so this runs at most once per store — once per process
7313
+ * for a path — rather than once per lookup: a broken record costs one line, and
7314
+ * the degraded view serves every later read.
7315
+ * @param problem - what is wrong with the record, with the path.
7316
+ * @returns the empty record.
7317
+ */
7318
+ degrade(problem) {
7319
+ this.warn(`loop-engine: ${problem}; sessions without a record fall back to their agent preset`);
7320
+ return /* @__PURE__ */ new Map();
5942
7321
  }
5943
7322
  };
7323
+ function formatEngineRecord(entries) {
7324
+ return `${JSON.stringify({ version: ENGINE_RECORD_VERSION, engines: Object.fromEntries(entries) }, void 0, 2)}
7325
+ `;
7326
+ }
5944
7327
 
5945
7328
  // src/index.ts
5946
7329
  var name = "loop-engine";
5947
7330
  var inject = [];
5948
- var MAX_MOUNT_ATTEMPTS = 40;
5949
- var MOUNT_RETRY_MS = 50;
5950
7331
  var Config5 = z6.object({
5951
7332
  profile: z6.string(),
5952
7333
  patchFilename: z6.string(),
@@ -5964,48 +7345,46 @@ var Config5 = z6.object({
5964
7345
  });
5965
7346
  function resolvePatchPath(config) {
5966
7347
  if (config.patchPath !== void 0 && config.patchPath !== "") return config.patchPath;
5967
- return join12(
5968
- resolveDshHome(),
7348
+ return join14(
7349
+ resolveDshHome2(),
5969
7350
  "profiles",
5970
7351
  config.profile ?? "web",
5971
7352
  config.patchFilename ?? "cordis.patch.yml"
5972
7353
  );
5973
7354
  }
5974
- function isMissing(error) {
7355
+ function isMissing2(error) {
5975
7356
  return error?.code === "ENOENT";
5976
7357
  }
5977
7358
  async function readPatchOrUndefined(path) {
5978
7359
  try {
5979
7360
  return await readFile5(path, "utf8");
5980
7361
  } catch (error) {
5981
- if (isMissing(error)) return void 0;
7362
+ if (isMissing2(error)) return void 0;
5982
7363
  throw error;
5983
7364
  }
5984
7365
  }
5985
7366
  async function writePatchFile(path, text) {
5986
- await mkdir2(dirname5(path), { recursive: true });
5987
- const tmp = `${path}.tmp-${randomUUID2()}`;
7367
+ await mkdir2(dirname6(path), { recursive: true });
7368
+ const tmp = `${path}.tmp-${randomUUID3()}`;
5988
7369
  await writeFile2(tmp, text, "utf8");
5989
7370
  await rename2(tmp, path);
5990
7371
  }
5991
7372
  function writePatchFileSync(path, text) {
5992
- mkdirSync(dirname5(path), { recursive: true });
5993
- const tmp = `${path}.tmp-${randomUUID2()}`;
5994
- writeFileSync(tmp, text, "utf8");
5995
- renameSync(tmp, path);
7373
+ writeFileAtomicSync(path, text);
5996
7374
  }
5997
- async function syncManagedBlock(path, engine) {
7375
+ async function syncManagedBlock(path) {
5998
7376
  const current = await readPatchOrUndefined(path);
5999
- if (current !== void 0 && currentEngineOf(current) === engine) return false;
6000
- const next = applyManagedBlock(current ?? "", engine);
6001
- await writePatchFile(path, next);
7377
+ if (current !== void 0 && hasManagedBlock(current) && !hasLegacyManagedBlock(current)) {
7378
+ return false;
7379
+ }
7380
+ await writePatchFile(path, applyManagedBlock(current ?? ""));
6002
7381
  return true;
6003
7382
  }
6004
7383
  function readPatchFileSync(path) {
6005
7384
  try {
6006
- return readFileSync3(path, "utf8");
7385
+ return readFileSync4(path, "utf8");
6007
7386
  } catch (error) {
6008
- if (isMissing(error)) return "";
7387
+ if (isMissing2(error)) return "";
6009
7388
  throw error;
6010
7389
  }
6011
7390
  }
@@ -6045,51 +7424,37 @@ function kimiConfig(config) {
6045
7424
  function apply(ctx, config) {
6046
7425
  const patchPath = resolvePatchPath(config);
6047
7426
  const patchText = readPatchFileSync(patchPath);
6048
- let fileEngine = currentEngineOf(patchText);
6049
- if (hasManagedBlock(patchText) && managedBlockEngineOf(patchText) === void 0) {
7427
+ const legacyEngine = legacyBlockEngineOf(patchText);
7428
+ const needsWrite = hasLegacyManagedBlock(patchText) || !hasManagedBlock(patchText);
7429
+ if (needsWrite) {
6050
7430
  try {
6051
- writePatchFileSync(patchPath, applyManagedBlock(patchText, "in-process"));
6052
- fileEngine = "in-process";
7431
+ writePatchFileSync(patchPath, applyManagedBlock(patchText));
7432
+ } catch (error) {
6053
7433
  ctx.logger.error(
6054
- "loop-engine: the managed block names an engine this build does not recognize; removed it so the base agent loop can own the factory slot. Restart `dsh web` to bring the base loop back."
7434
+ `loop-engine: could not write the managed block to "${patchPath}": ${String(error)}. The base agent loop still owns the factory slot; restart \`dsh web\` after fixing the path.`
6055
7435
  );
6056
- } catch (error) {
6057
- ctx.logger.error(`loop-engine: could not repair the unrecognized managed block: ${String(error)}`);
6058
7436
  }
6059
7437
  }
6060
7438
  const AGENT_PRESETS_NS = "agent-presets";
6061
7439
  const PRESET_DEFAULT_ATTEMPTS = 30;
6062
7440
  const PRESET_DEFAULT_RETRY_MS = 100;
6063
- let engineFiber;
6064
- let mountedEngine;
6065
- let mountGeneration = 0;
7441
+ const ROUTE_ATTEMPTS = 30;
7442
+ const ROUTE_RETRY_MS = 100;
7443
+ const ROUTER_ATTEMPTS = 40;
7444
+ const ROUTER_RETRY_MS = 50;
7445
+ let routerAttempts = 0;
7446
+ let routerRetry;
7447
+ const CLEAR_ROUTER_RETRY = () => {
7448
+ if (routerRetry !== void 0) {
7449
+ clearTimeout(routerRetry);
7450
+ routerRetry = void 0;
7451
+ }
7452
+ };
6066
7453
  let disposed = false;
6067
7454
  const retryLater = (run, ms) => setTimeout(() => {
6068
7455
  if (!disposed) run();
6069
7456
  }, ms);
6070
- let commandDisposers;
6071
- let skillDisposer;
6072
- const piCatalogHolder = { entries: [] };
6073
- let mountAttempts = 0;
6074
- let mountRetry;
6075
- const CLEAR_RETRY = () => {
6076
- if (mountRetry !== void 0) {
6077
- clearTimeout(mountRetry);
6078
- mountRetry = void 0;
6079
- }
6080
- };
6081
- let savedPresetDefault;
6082
- let presetRetry;
6083
- const CLEAR_PRESET_RETRY = () => {
6084
- if (presetRetry !== void 0) {
6085
- clearTimeout(presetRetry);
6086
- presetRetry = void 0;
6087
- }
6088
- };
6089
- const ROUTE_ATTEMPTS = 30;
6090
- const ROUTE_RETRY_MS = 100;
6091
- let routeHandle;
6092
- let routeEngine;
7457
+ let routeDisposers;
6093
7458
  let routeRetry;
6094
7459
  const CLEAR_ROUTE_RETRY = () => {
6095
7460
  if (routeRetry !== void 0) {
@@ -6097,38 +7462,65 @@ function apply(ctx, config) {
6097
7462
  routeRetry = void 0;
6098
7463
  }
6099
7464
  };
6100
- const releaseRoute = () => {
6101
- CLEAR_ROUTE_RETRY();
6102
- const handle = routeHandle;
6103
- routeHandle = void 0;
6104
- routeEngine = void 0;
6105
- handle?.();
6106
- };
6107
- const mountProviderRoute = (engine, attempt = 0) => {
6108
- if (engine === "in-process") return;
6109
- if (routeEngine === engine && routeHandle !== void 0) return;
7465
+ const mountProviderRoutes = (attempt = 0) => {
6110
7466
  CLEAR_ROUTE_RETRY();
6111
- const label = HOSTED_PROVIDER_ROUTES[engine];
6112
7467
  const llm = ctx.get("llm");
6113
7468
  if (llm === void 0) {
6114
7469
  if (attempt < ROUTE_ATTEMPTS) {
6115
- routeRetry = retryLater(() => mountProviderRoute(engine, attempt + 1), ROUTE_RETRY_MS);
7470
+ routeRetry = retryLater(() => mountProviderRoutes(attempt + 1), ROUTE_RETRY_MS);
6116
7471
  }
6117
7472
  return;
6118
7473
  }
7474
+ const registered = [];
6119
7475
  try {
6120
- const options = engine === "pi" ? { listModels: () => piCatalogHolder.entries } : void 0;
6121
- routeHandle = llm.registerAdapter([label], new HostedEngineRouteAdapter(label, options));
6122
- routeEngine = engine;
7476
+ registered.push(llm.registerAdapter([HOSTED_ROUTE_LABEL], new HostedEngineRouteAdapter()));
6123
7477
  } catch (error) {
6124
7478
  const duplicateAdapter = error?.code === "DUPLICATE_ADAPTER";
6125
7479
  if (error instanceof Error && (duplicateAdapter || error.message.includes("already registered"))) {
6126
- ctx.logger.warn(`loop-engine: provider route "${label}" is already served by another adapter`);
6127
- return;
7480
+ ctx.logger.warn(`loop-engine: provider route "${HOSTED_ROUTE_LABEL}" is already served by another adapter`);
7481
+ } else {
7482
+ ctx.logger.error(`loop-engine: provider route "${HOSTED_ROUTE_LABEL}" registration failed: ${String(error)}`);
7483
+ }
7484
+ }
7485
+ routeDisposers = registered;
7486
+ };
7487
+ const releaseRoutes = () => {
7488
+ CLEAR_ROUTE_RETRY();
7489
+ const disposers = routeDisposers;
7490
+ routeDisposers = void 0;
7491
+ for (const dispose of disposers ?? []) dispose();
7492
+ };
7493
+ let presetRetry;
7494
+ let authorRetry;
7495
+ const CLEAR_PRESET_RETRY = () => {
7496
+ if (presetRetry !== void 0) {
7497
+ clearTimeout(presetRetry);
7498
+ presetRetry = void 0;
7499
+ }
7500
+ };
7501
+ const CLEAR_AUTHOR_RETRY = () => {
7502
+ if (authorRetry !== void 0) {
7503
+ clearTimeout(authorRetry);
7504
+ authorRetry = void 0;
7505
+ }
7506
+ };
7507
+ const authorEnginePresets = (attempt = 0) => {
7508
+ CLEAR_AUTHOR_RETRY();
7509
+ const presets = ctx.get("agentPresets");
7510
+ if (presets === void 0) {
7511
+ if (attempt < PRESET_DEFAULT_ATTEMPTS) {
7512
+ authorRetry = retryLater(() => authorEnginePresets(attempt + 1), PRESET_DEFAULT_RETRY_MS);
6128
7513
  }
6129
- ctx.logger.error(`loop-engine: provider route "${label}" registration failed: ${String(error)}`);
7514
+ return void 0;
6130
7515
  }
7516
+ const settled = authoring ??= ensureEnginePresets(resolveDshHome2(), presets).catch((error) => {
7517
+ ctx.logger.error(`loop-engine: engine preset authoring failed: ${String(error)}`);
7518
+ authoring = void 0;
7519
+ return false;
7520
+ });
7521
+ return { presets, settled };
6131
7522
  };
7523
+ let authoring;
6132
7524
  const mutatePresetDefault = (op, attempt = 0) => {
6133
7525
  const settings = ctx.get("settings");
6134
7526
  if (settings === void 0) return;
@@ -6148,198 +7540,107 @@ function apply(ctx, config) {
6148
7540
  ctx.logger.error(`loop-engine: preset default switch failed: ${String(error)}`);
6149
7541
  });
6150
7542
  };
6151
- const restorePresetDefault = (attempt = 0) => {
6152
- const presets = ctx.get("agentPresets");
6153
- if (presets === void 0) return;
6154
- if (presets.defaultId === HOSTED_PRESET_ID) {
6155
- const saved = savedPresetDefault;
6156
- savedPresetDefault = void 0;
6157
- mutatePresetDefault(saved === void 0 ? { op: "unset", path: ["default"] } : { op: "set", path: ["default"], value: saved });
6158
- return;
6159
- }
6160
- if (attempt < PRESET_DEFAULT_ATTEMPTS) {
6161
- presetRetry = retryLater(() => restorePresetDefault(attempt + 1), PRESET_DEFAULT_RETRY_MS);
6162
- }
6163
- };
7543
+ let savedPresetDefault;
7544
+ let steeredEngine;
6164
7545
  const steerPresetDefault = (engine) => {
6165
- const presets = ctx.get("agentPresets");
6166
- if (presets === void 0) return;
7546
+ steeredEngine = engine;
6167
7547
  if (engine === "in-process") {
6168
- restorePresetDefault();
7548
+ const saved = savedPresetDefault;
7549
+ savedPresetDefault = void 0;
7550
+ if (saved === void 0) return;
7551
+ mutatePresetDefault({ op: "set", path: ["default"], value: saved });
6169
7552
  return;
6170
7553
  }
6171
- void (async () => {
6172
- try {
6173
- await ensureHostedPreset(resolveDshHome(), presets);
6174
- } catch (error) {
6175
- ctx.logger.error(`loop-engine: hosted preset authoring failed: ${String(error)}`);
6176
- return;
6177
- }
6178
- const current = presets.defaultId;
6179
- if (current === HOSTED_PRESET_ID) return;
6180
- savedPresetDefault = current;
6181
- mutatePresetDefault({ op: "set", path: ["default"], value: HOSTED_PRESET_ID });
6182
- })();
6183
- };
6184
- const cleanupEngineRegistrations = () => {
6185
- if (commandDisposers !== void 0) {
6186
- for (const dispose of commandDisposers) dispose();
6187
- commandDisposers = void 0;
6188
- }
6189
- if (skillDisposer !== void 0) {
6190
- skillDisposer();
6191
- skillDisposer = void 0;
6192
- }
6193
- };
6194
- const hostFactory = (engine, mount) => {
6195
- if (engineFiber !== void 0) return;
6196
- const generation = ++mountGeneration;
6197
- const fiber = mount();
6198
- engineFiber = fiber;
6199
- mountedEngine = engine;
6200
- void fiber.then(() => void 0, (error) => {
6201
- if (generation !== mountGeneration) return;
6202
- cleanupEngineRegistrations();
6203
- engineFiber = void 0;
6204
- mountedEngine = void 0;
6205
- const baseLoopHoldsSlot = ctx.get("agentLoop") !== void 0;
6206
- if (error instanceof Error && (baseLoopHoldsSlot || error.message.includes("an agent factory is already registered")) && mountAttempts < MAX_MOUNT_ATTEMPTS) {
6207
- mountAttempts += 1;
6208
- mountRetry = retryLater(() => {
6209
- try {
6210
- mountEngine(engine);
6211
- } catch (retryError) {
6212
- ctx.logger.error(`loop-engine: ${engine} mount retry failed: ${String(retryError)}`);
6213
- }
6214
- }, MOUNT_RETRY_MS);
6215
- return;
6216
- }
6217
- ctx.logger.error(
6218
- `loop-engine: ${engine} factory failed to start: ${String(error)} \u2014 restart \`dsh web\` to release the factory slot`
6219
- );
7554
+ const started = authorEnginePresets();
7555
+ if (started === void 0) return;
7556
+ void started.settled.then((authored) => {
7557
+ if (!authored) return;
7558
+ const current = started.presets.defaultId;
7559
+ const target = enginePresetId(engine);
7560
+ if (current === target) return;
7561
+ if (!current.startsWith("loop-engine-")) savedPresetDefault = current;
7562
+ mutatePresetDefault({ op: "set", path: ["default"], value: target });
6220
7563
  });
6221
7564
  };
6222
- const mountClaude = () => {
6223
- if (engineFiber !== void 0) return;
6224
- const commands = ctx.get("commands");
6225
- if (commands !== void 0) {
6226
- const disposers = [];
6227
- for (const command of [...CLAUDE_CODE_COMMANDS, ...discoverUserSlashCommands()]) {
6228
- try {
6229
- disposers.push(commands.register(command));
6230
- } catch (error) {
6231
- ctx.logger.warn(`loop-engine: skip claude-code command /${command.name}: ${String(error)}`);
6232
- }
6233
- }
6234
- commandDisposers = disposers;
6235
- }
6236
- const skills = ctx.get("skills");
6237
- if (skills !== void 0) {
6238
- skillDisposer = skills.registerProvider((control) => new ClaudeCodeSkillProvider(control));
6239
- }
6240
- hostFactory("claude-code", () => ctx.plugin(ClaudeCodeLoop, claudeCodeConfig(config)));
7565
+ const seedEngine = legacyEngine ?? "in-process";
7566
+ const pluginWarn = (message) => {
7567
+ ctx.logger.warn(message);
6241
7568
  };
6242
- const mountCodex = () => {
6243
- const skills = ctx.get("skills");
6244
- if (skills !== void 0) {
6245
- skillDisposer = skills.registerProvider((control) => new CodexSkillProvider(control));
7569
+ const engineRecords = new SessionEngineStore(resolveEngineRecordPath(), pluginWarn);
7570
+ const routerHolder = { current: void 0 };
7571
+ const buildEngine = (engineCtx, engine) => {
7572
+ switch (engine) {
7573
+ case "claude-code":
7574
+ return new ClaudeCodeLoop(engineCtx, claudeCodeConfig(config));
7575
+ case "codex":
7576
+ return new CodexLoop(engineCtx, codexConfig(config));
7577
+ case "pi":
7578
+ return new PiLoop(engineCtx, piConfig(config));
7579
+ case "kimi":
7580
+ return new KimiLoop(engineCtx, kimiConfig(config));
6246
7581
  }
6247
- hostFactory("codex", () => ctx.plugin(CodexLoop, codexConfig(config)));
6248
7582
  };
6249
- const mountPi = () => {
6250
- const skills = ctx.get("skills");
6251
- if (skills !== void 0) {
6252
- skillDisposer = skills.registerProvider((control) => new PiSkillProvider(control));
6253
- }
6254
- hostFactory("pi", () => ctx.plugin(PiLoop, {
6255
- ...piConfig(config),
6256
- piCatalogHolder
6257
- }));
6258
- };
6259
- const mountKimi = () => {
6260
- const commands = ctx.get("commands");
6261
- if (commands !== void 0) {
6262
- const disposers = [];
6263
- for (const command of KIMI_COMMANDS) {
6264
- try {
6265
- disposers.push(commands.register(command));
6266
- } catch (error) {
6267
- ctx.logger.warn(`loop-engine: skip kimi command /${command.name}: ${String(error)}`);
7583
+ const mountRouter = () => {
7584
+ const fiber = ctx.inject(ROUTER_SERVICES, (routerCtx) => {
7585
+ try {
7586
+ const router = new RouterLoop(
7587
+ routerCtx,
7588
+ (engine) => buildEngine(routerCtx, engine),
7589
+ engineRecords,
7590
+ pluginWarn
7591
+ );
7592
+ routerHolder.current = router;
7593
+ } catch (error) {
7594
+ routerHolder.current = void 0;
7595
+ if (ctx.get("agentLoop") !== void 0 && routerAttempts < ROUTER_ATTEMPTS) {
7596
+ routerAttempts += 1;
7597
+ routerRetry = retryLater(() => {
7598
+ void Promise.resolve(fiber.dispose()).then(mountRouter, mountRouter);
7599
+ }, ROUTER_RETRY_MS);
7600
+ return;
6268
7601
  }
7602
+ ctx.logger.error(`loop-engine: could not start the loop router: ${String(error)}`);
6269
7603
  }
6270
- commandDisposers = disposers;
6271
- }
6272
- const skills = ctx.get("skills");
6273
- if (skills !== void 0) {
6274
- skillDisposer = skills.registerProvider((control) => new KimiSkillProvider(control));
6275
- }
6276
- hostFactory("kimi", () => ctx.plugin(KimiLoop, kimiConfig(config)));
6277
- };
6278
- const mountEngine = (engine) => {
6279
- mountProviderRoute(engine);
6280
- if (engine === "claude-code") mountClaude();
6281
- else if (engine === "codex") mountCodex();
6282
- else if (engine === "pi") mountPi();
6283
- else if (engine === "kimi") mountKimi();
7604
+ });
6284
7605
  };
6285
- const unmountEngine = () => {
6286
- const fiber = engineFiber;
6287
- const engine = mountedEngine;
6288
- mountAttempts = 0;
6289
- mountGeneration += 1;
6290
- CLEAR_RETRY();
6291
- releaseRoute();
6292
- cleanupEngineRegistrations();
6293
- mountedEngine = void 0;
6294
- if (fiber === void 0) return;
6295
- engineFiber = void 0;
6296
- void fiber.then(
6297
- (resolved) => {
6298
- resolved.dispose().catch((error) => {
6299
- ctx.logger.error(`loop-engine: ${String(engine)} factory dispose failed: ${String(error)}`);
6300
- });
6301
- },
6302
- /* v8 ignore next -- a fiber that failed already cleared engineFiber in hostFactory's rejection handler, so this rejection arm is unreachable */
6303
- () => void 0
7606
+ mountRouter();
7607
+ const mountEngineRemote = () => {
7608
+ new LoopEngineRemote(
7609
+ ctx,
7610
+ (sessionId) => engineOfSession(ctx, sessionId, engineRecords),
7611
+ routerHolder,
7612
+ pluginWarn
6304
7613
  );
6305
7614
  };
6306
- mountEngine(fileEngine);
6307
- steerPresetDefault(fileEngine);
7615
+ mountEngineRemote();
7616
+ mountProviderRoutes();
7617
+ authorEnginePresets();
6308
7618
  ctx.effect(() => () => {
6309
7619
  disposed = true;
6310
- CLEAR_RETRY();
7620
+ CLEAR_ROUTE_RETRY();
6311
7621
  CLEAR_PRESET_RETRY();
6312
- releaseRoute();
6313
- }, "loop-engine: retry cleanup");
7622
+ CLEAR_AUTHOR_RETRY();
7623
+ CLEAR_ROUTER_RETRY();
7624
+ releaseRoutes();
7625
+ }, "loop-engine: cleanup");
6314
7626
  let source;
6315
7627
  ctx.inject(["settings"], (settingsCtx) => {
6316
- settingsCtx.settings.installSection(ctx, loopEngineSettingsNamespace(), LOOP_ENGINE_SETTINGS_SCHEMA, { engine: fileEngine, showInComposer: true }, {
6317
- setSource: (current) => {
6318
- source = current;
6319
- },
6320
- onChange: () => {
6321
- const next = source().engine;
6322
- if (next === fileEngine) return;
6323
- try {
6324
- const updated = applyManagedBlock(readPatchFileSync(patchPath), next);
6325
- writePatchFileSync(patchPath, updated);
6326
- } catch (error) {
6327
- ctx.logger.error(`loop-engine: managed block write failed: ${String(error)}`);
6328
- setTimeout(() => {
6329
- settingsCtx.settings.mutate(loopEngineSettingsNamespace(), [{ op: "set", path: ["engine"], value: fileEngine }]).catch((revertError) => {
6330
- ctx.logger.error(`loop-engine: engine selection revert failed: ${String(revertError)}`);
6331
- });
6332
- }, 0);
6333
- return;
6334
- }
6335
- fileEngine = next;
6336
- if (mountedEngine !== next) {
6337
- unmountEngine();
6338
- mountEngine(next);
7628
+ settingsCtx.settings.installSection(
7629
+ ctx,
7630
+ loopEngineSettingsNamespace(),
7631
+ LOOP_ENGINE_SETTINGS_SCHEMA,
7632
+ { engine: seedEngine, showInComposer: true },
7633
+ {
7634
+ setSource: (current) => {
7635
+ source = current;
7636
+ },
7637
+ onChange: () => {
7638
+ const next = source().engine;
7639
+ if (next === steeredEngine) return;
7640
+ steerPresetDefault(next);
6339
7641
  }
6340
- steerPresetDefault(next);
6341
7642
  }
6342
- });
7643
+ );
6343
7644
  });
6344
7645
  }
6345
7646
  export {