dsh-loop-engine 0.1.5-rc2 → 0.1.5-rc4

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