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/invariant.js CHANGED
@@ -1,20 +1,27 @@
1
1
  // src/settings.ts
2
2
  import z from "@deepseek-ai/schemastery";
3
+
4
+ // src/agent-preset-ids.ts
3
5
  var LOOP_ENGINE_IDS = ["in-process", "claude-code", "codex", "pi", "kimi"];
6
+ var HOSTED_ENGINE_IDS = LOOP_ENGINE_IDS.filter(
7
+ (id) => id !== "in-process"
8
+ );
9
+
10
+ // src/settings.ts
4
11
  var LOOP_ENGINE_SETTINGS_SCHEMA = z.object({
5
12
  engine: z.union(LOOP_ENGINE_IDS.map((id) => z.const(id))).default("in-process"),
6
13
  showInComposer: z.boolean().default(true)
7
14
  });
8
15
 
9
16
  // src/patch-manager.ts
10
- var MANAGED_BLOCK_BEGIN = "# -- dsh-loop-engine managed block: ";
17
+ var MANAGED_BLOCK_BEGIN = "# -- dsh-loop-engine managed block --";
18
+ var LEGACY_MANAGED_BLOCK_BEGIN = "# -- dsh-loop-engine managed block: ";
11
19
  var MANAGED_BLOCK_END = "# -- /dsh-loop-engine managed block --";
12
20
  var END_MARKER_LINE = `${MANAGED_BLOCK_END}
13
21
  `;
14
- function renderManagedBlock(engine) {
15
- if (engine === "in-process") return "";
22
+ function renderManagedBlock() {
16
23
  return [
17
- `${MANAGED_BLOCK_BEGIN}${engine} --`,
24
+ MANAGED_BLOCK_BEGIN,
18
25
  "- id: agent-loop",
19
26
  " disabled: true",
20
27
  "- id: command-goal",
@@ -22,18 +29,18 @@ function renderManagedBlock(engine) {
22
29
  END_MARKER_LINE
23
30
  ].join("\n");
24
31
  }
25
- var BEGIN_MARKER_RE = /^# -- dsh-loop-engine managed block: (\S+) --$/m;
26
- function managedBlockEngineOf(text) {
27
- const engine = BEGIN_MARKER_RE.exec(text)?.[1];
28
- return LOOP_ENGINE_IDS.includes(engine ?? "") ? engine : void 0;
32
+ function hasManagedBlock(text) {
33
+ return text.includes("# -- dsh-loop-engine managed block");
29
34
  }
30
- function currentEngineOf(text) {
31
- return managedBlockEngineOf(text) ?? "in-process";
35
+ var LEGACY_BEGIN_MARKER_RE = /^# -- dsh-loop-engine managed block: (\S+) --$/m;
36
+ function legacyBlockEngineOf(text) {
37
+ const engine = LEGACY_BEGIN_MARKER_RE.exec(text)?.[1];
38
+ return LOOP_ENGINE_IDS.includes(engine ?? "") ? engine : void 0;
32
39
  }
33
40
  function managedSpan(text) {
34
- const begin = text.indexOf(MANAGED_BLOCK_BEGIN);
41
+ const begin = text.indexOf("# -- dsh-loop-engine managed block");
35
42
  if (begin === -1) return { head: text, tail: "", present: false, blankBefore: false };
36
- const afterBegin = begin + MANAGED_BLOCK_BEGIN.length;
43
+ const afterBegin = begin + LEGACY_MANAGED_BLOCK_BEGIN.length;
37
44
  const endAt = text.indexOf(MANAGED_BLOCK_END, afterBegin);
38
45
  const spanEnd = endAt === -1 ? text.length : endAt + END_MARKER_LINE.length;
39
46
  const before = text.slice(0, begin);
@@ -49,38 +56,18 @@ function ensureTrailingNewline(text) {
49
56
  return text.endsWith("\n") ? text : `${text}
50
57
  `;
51
58
  }
52
- function hasRootEntry(text) {
53
- return /^(?:- |\[)/m.test(text);
54
- }
55
59
  function dropSeedPlaceholder(text) {
56
60
  return text.replace(/^\[\]\n/m, "");
57
61
  }
58
- function seedEmptyArray(text) {
59
- const head = text.replace(/\n+$/, "");
60
- return head === "" ? "[]\n" : `${head}
61
- []
62
- `;
63
- }
64
- function applyManagedBlock(text, engine) {
65
- const block = renderManagedBlock(engine);
62
+ function applyManagedBlock(text) {
63
+ const block = renderManagedBlock();
66
64
  const span = managedSpan(text);
67
- let result;
68
65
  if (!span.present) {
69
- if (block === "") {
70
- result = text;
71
- } else {
72
- const base = ensureTrailingNewline(text);
73
- result = `${base}
74
- ${block}`;
75
- }
76
- } else if (block === "") {
77
- result = span.tail.startsWith("\n") ? `${span.head}${span.tail.slice(1)}` : `${span.head}${span.tail}`;
78
- } else {
79
- result = `${span.head}${span.blankBefore ? "\n" : ""}${block}${span.tail}`;
66
+ if (text === "") return block;
67
+ return dropSeedPlaceholder(`${ensureTrailingNewline(text)}
68
+ ${block}`);
80
69
  }
81
- if (block !== "") return dropSeedPlaceholder(result);
82
- if (text.trim() === "") return result;
83
- return hasRootEntry(result) ? result : seedEmptyArray(result);
70
+ return dropSeedPlaceholder(`${span.head}${span.blankBefore ? "\n" : ""}${block}${span.tail}`);
84
71
  }
85
72
 
86
73
  // src/invariant.ts
@@ -91,16 +78,27 @@ var install = (ctx, fail) => {
91
78
  void ctx;
92
79
  const seed = "";
93
80
  const commentOnly = "# dsh profile patch layer\n";
81
+ const applied = applyManagedBlock(seed);
82
+ if (applyManagedBlock(applied) !== applied) fail("the managed block is not a fixed point");
83
+ if (!hasManagedBlock(renderManagedBlock())) fail("the rendered block is not recognized by hasManagedBlock");
84
+ if (!applied.includes("- id: agent-loop")) fail("the managed block must disable the base agent-loop row");
85
+ if (legacyBlockEngineOf(applied) !== void 0) fail("the current block must not read as a legacy engine pin");
94
86
  for (const engine of LOOP_ENGINE_IDS) {
95
- const applied = applyManagedBlock(seed, engine);
96
- const reborn = applyManagedBlock(applied, currentEngineOf(applied));
97
- if (reborn !== applied) fail(`bare round trip for ${engine} is not a fixed point`);
98
- if (engine === "in-process" && applied !== seed) fail("in-process engine must leave a bare layer unchanged");
99
- if (engine !== "in-process" && currentEngineOf(renderManagedBlock(engine)) !== engine) fail(`${engine} block must read back as the ${engine} engine`);
100
- if (engine === "in-process" && applyManagedBlock(commentOnly, engine) === commentOnly) {
101
- fail("in-process engine must re-seed a comment-only file to a loadable top-level array");
87
+ const head = "# user patch layer\n";
88
+ const legacy = `${head}
89
+ ${LEGACY_MANAGED_BLOCK_BEGIN}${engine} --
90
+ - id: agent-loop
91
+ disabled: true
92
+ ${MANAGED_BLOCK_END}
93
+ `;
94
+ if (legacyBlockEngineOf(legacy) !== engine) fail(`a legacy ${engine} block must still read as the ${engine} pin`);
95
+ if (applyManagedBlock(legacy) !== applyManagedBlock(head)) {
96
+ fail(`a legacy ${engine} block must migrate to the current block`);
102
97
  }
103
98
  }
99
+ if (applyManagedBlock(commentOnly) === commentOnly) {
100
+ fail("a comment-only file must gain the block as its top-level array");
101
+ }
104
102
  };
105
103
  var apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
106
104
  export {
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Loop engine ids, the agent-preset ids that select them, and the two plain
3
+ * wire shapes both halves of the plugin share — in a module with no imports at
4
+ * all.
5
+ *
6
+ * The mapping is pure identity arithmetic (an engine to a preset id and back),
7
+ * so it belongs to neither half; it lives here for the same reason
8
+ * `./namespace.ts` does — the browser bundle needs it, and the files that
9
+ * otherwise carry it are host-side: `./settings.ts` imports `schemastery` and
10
+ * the `dsh-settings` brand type, `./preset.ts` imports `node:fs/promises`.
11
+ * Importing either from `src/client/**` would pull a host package into the
12
+ * client artifact. Both of them re-export what this module defines, so the
13
+ * existing import paths and names stay valid on the node side.
14
+ *
15
+ * The two shapes are here for that same reason: {@link SessionEngine} is what
16
+ * the browser half renders and {@link LoopEngineSelectResult} is what its switch
17
+ * comes back with, so both halves name the same types without either half's
18
+ * modules crossing the boundary. The refusal codes travel with the second one
19
+ * ({@link LoopEngineRefusalCode}) for the same reason plus one more: the browser
20
+ * half is what turns a refusal into copy, so the codes it may read have to be
21
+ * knowable without importing the host's router.
22
+ *
23
+ * @module dsh-loop-engine/agent-preset-ids
24
+ */
25
+ /** The installed engine driving new Agent turns. */
26
+ export declare const LOOP_ENGINE_IDS: readonly ["in-process", "claude-code", "codex", "pi", "kimi"];
27
+ /** Installed agent loop engine id. */
28
+ export type LoopEngineId = (typeof LOOP_ENGINE_IDS)[number];
29
+ /** An engine this plugin hosts itself; `in-process` is the harness's own loop. */
30
+ export type HostedEngineId = Exclude<LoopEngineId, 'in-process'>;
31
+ /** Every hosted engine id, in selection order. */
32
+ export declare const HOSTED_ENGINE_IDS: HostedEngineId[];
33
+ /**
34
+ * Whether an engine is one this plugin hosts — an external CLI some other
35
+ * vendor runs, which owns its own model — rather than the harness's own loop.
36
+ *
37
+ * The judgement a surface needs to say "the model is the engine's own business"
38
+ * without hardcoding one engine's name: every hosted engine behaves the same
39
+ * way here, and naming one of them would make a general fact read as a property
40
+ * of that engine. An engine nobody has answered for yet is not hosted: a surface
41
+ * that does not know says nothing rather than claiming the wrong half.
42
+ * @param engine - an engine id, or undefined while one is still being read.
43
+ * @returns whether that engine is hosted by this plugin.
44
+ */
45
+ export declare function isHostedEngine(engine: LoopEngineId | undefined): engine is HostedEngineId;
46
+ /**
47
+ * The single provider route label EVERY hosted engine logs into its sessions'
48
+ * `request/header`, and the one placeholder route this plugin registers in the
49
+ * llm registry.
50
+ *
51
+ * One label for all four engines, because the browser model catalog is built
52
+ * for the whole Host GENERATION and is not scoped to a session
53
+ * (`packages/api/session-controller/src/catalog.ts` — "Build the browser model
54
+ * catalog without requiring a Session"; it walks `ctx.llm.listProviders()`
55
+ * once). A per-engine label would therefore surface one provider group per
56
+ * engine in every session's menu at once — four identical `default` entries —
57
+ * which is what this constant collapses away.
58
+ *
59
+ * The value is ASCII on purpose: it is a WIRE value. It is written into each
60
+ * session's `request/header`, it travels through selections, and the host
61
+ * compares it (`providerInfo().id` must equal the provider the adapter
62
+ * registered for — `packages/llm/llm/src/index.ts` `prepareRoutes`). The
63
+ * display name ({@link HOSTED_ROUTE_NAME}) is the same token, for the reason
64
+ * that constant documents.
65
+ */
66
+ export declare const HOSTED_ROUTE_LABEL = "external";
67
+ /**
68
+ * The user-visible name of the one hosted route ({@link HOSTED_ROUTE_LABEL}),
69
+ * shown as the model menu's provider group label.
70
+ *
71
+ * The same token as the wire value, and deliberately not localized: the catalog
72
+ * carries exactly one string per provider group (`ModelCatalog`'s `group.name`,
73
+ * taken from `LlmProviderInfo.name`) and the browser half has no hook to
74
+ * re-translate it, so one fixed name is what every locale sees.
75
+ */
76
+ export declare const HOSTED_ROUTE_NAME = "external";
77
+ /**
78
+ * The one model id a hosted engine's provider route advertises to the model
79
+ * menu, and the model label that engine logs into its sessions'
80
+ * `request/header` when the deployment pins none.
81
+ *
82
+ * The two are ONE string on purpose, and that is a host-side requirement rather
83
+ * than a style choice: the picker builds its selection out of a catalog entry
84
+ * (`ModelSelect.tsx` `choices` — `model: model.id`) and resolves a session's
85
+ * `(provider, model)` back to that entry by comparing the pair with the logged
86
+ * header (`selectedIndex`), so an entry whose `id` differs from the logged
87
+ * label falls through to the raw `provider/model` string — how a menu ends up
88
+ * showing a model that does not exist. The label is the engine's own word for
89
+ * "whatever it decides"; `name` is the same string because `LlmModelInfo.name`
90
+ * is rendered verbatim and has no localization hook.
91
+ */
92
+ export declare const HOSTED_DEFAULT_MODEL = "default";
93
+ /** Prefix every plugin-authored preset id carries. */
94
+ export declare const HOSTED_PRESET_PREFIX = "loop-engine-";
95
+ /** The deployment's own preset id, which selects the harness loop. */
96
+ export declare const SOURCE_PRESET_ID = "standard";
97
+ /**
98
+ * The pre-routing single preset id: older versions of this plugin authored one
99
+ * preset for every hosted engine and pinned the profile to one of them, so a
100
+ * session carrying this id DID run a hosted engine — but the id does not say
101
+ * which one. It is not a hosted preset id ({@link engineOfPreset} reads it as
102
+ * `undefined`, so the router keeps such a session on the harness loop), and it
103
+ * is not `standard` either; surfaces that name a session's engine must report it
104
+ * as "a hosted engine, unrecorded" rather than claim the in-process loop.
105
+ */
106
+ export declare const LEGACY_HOSTED_PRESET_ID = "loop-engine";
107
+ /**
108
+ * The preset id that selects one engine for a session. `in-process` names the
109
+ * deployment's own `standard` preset: the harness loop is not something this
110
+ * plugin authors a preset for.
111
+ * @param engine - the engine a session should run.
112
+ * @returns the preset id to record on the session.
113
+ */
114
+ export declare function enginePresetId(engine: LoopEngineId): string;
115
+ /**
116
+ * Whether an untyped value is one of {@link LOOP_ENGINE_IDS}.
117
+ *
118
+ * The boundary test both halves need: a value crossing the wire, the sidecar
119
+ * file, or a projection read is only an engine when it is one of these.
120
+ * @param value - the value to classify.
121
+ * @returns whether `value` is an installed engine id.
122
+ */
123
+ export declare function isLoopEngineId(value: unknown): value is LoopEngineId;
124
+ /**
125
+ * The engine a preset id selects, or `undefined` for a preset this plugin does
126
+ * not own (any deployment-authored preset, including `standard`).
127
+ * @param presetId - the session's preset id, when it has one.
128
+ * @returns the hosted engine it names, or undefined for the harness loop.
129
+ */
130
+ export declare function engineOfPreset(presetId: string | undefined): HostedEngineId | undefined;
131
+ /**
132
+ * What a session's recorded preset says about the engine that session runs.
133
+ *
134
+ * Not every session has an answer: the pre-routing single preset id names a
135
+ * hosted engine without recording which one, and a deployment that composes no
136
+ * presets (or a transcript whose projection is gone) records nothing at all.
137
+ * Both are reported as themselves — a surface that names a session's engine must
138
+ * never read "unknown" as the in-process loop.
139
+ */
140
+ export type SessionEngine = {
141
+ readonly kind: 'engine';
142
+ readonly engine: LoopEngineId;
143
+ }
144
+ /** The pre-routing preset: this session ran a hosted engine, but the id does not say which. */
145
+ | {
146
+ readonly kind: 'legacy';
147
+ }
148
+ /** The session records no preset at all (a deployment that composes none, or one created before presets existed). */
149
+ | {
150
+ readonly kind: 'unset';
151
+ };
152
+ /**
153
+ * What one session's engine report carries: the engine the session ACTUALLY
154
+ * runs, and — when they differ — the engine its own record names for it.
155
+ *
156
+ * The two are one answer because a surface must never render the second as if it
157
+ * were the first. A session switched onto or off the harness loop has its agent
158
+ * RELEASED (`src/router-loop.ts` `move`), so the session is simply cold
159
+ * afterwards and its record IS what its next build runs — no second fact, no
160
+ * pending. This field therefore survives for the one case in which a live agent
161
+ * still differs from the record: the release did not take (its teardown failed,
162
+ * or another process wrote the record), so the session keeps running its old
163
+ * engine while the record names another. Reporting only the live agent would
164
+ * hide that a switch was accepted at all — the "显示在撒谎" bug this report
165
+ * exists to prevent — so both travel, and {@link pending} says which is which.
166
+ */
167
+ export interface SessionEngineReport {
168
+ /** The engine driving this session NOW. */
169
+ readonly engine: SessionEngine;
170
+ /**
171
+ * Present only while the session has an engine recorded that its LIVE agent is
172
+ * not running — i.e. a recorded switch whose release did not complete. Absent
173
+ * whenever the record and the live agent agree, and always absent for a session
174
+ * with no live agent (there the record IS what the session runs).
175
+ */
176
+ readonly pending?: LoopEngineId;
177
+ }
178
+ /**
179
+ * Every way this plugin refuses to move a session to another engine, as a stable
180
+ * code.
181
+ *
182
+ * A code exists because the host's own sentence is not what a surface shows any
183
+ * more: the browser half localizes the refusal from the code (`refusalFace` in
184
+ * `./client/locales.ts`) and keeps {@link LoopEngineRefusal.reason} only as
185
+ * detail, so the codes are part of the wire contract and a value may be added but
186
+ * never quietly repurposed. The list is exactly the refusals the host can produce
187
+ * — one per branch of `RouterLoop.selectEngine`, plus the Remote's own
188
+ * "no router mounted" answer:
189
+ *
190
+ * - `session-closed`: the session has no agent in this process (nothing is open
191
+ * to move);
192
+ * - `turn-running`: a turn is in flight, and it is never interrupted;
193
+ * - `subagent-session`: a delegated child's agent belongs to its delegation;
194
+ * - `not-driven`: an agent is live but this router did not build it (the mount
195
+ * window in which the base loop still owns the factory slot);
196
+ * - `router-unmounted`: no router is mounted at all yet;
197
+ * - `record-failed`: the plugin's own per-session record could not be written,
198
+ * so the choice could not be committed;
199
+ * - `rebuild-failed`: the outgoing machine was retired, the successor could not
200
+ * be built, and the session was left cold on the recorded engine.
201
+ *
202
+ * A malformed REQUEST is deliberately NOT one of these: it travels as a
203
+ * `RemoteError` with `gateway/bad-request`, because it is the caller's fault
204
+ * rather than a state of the session.
205
+ */
206
+ export declare const LOOP_ENGINE_REFUSAL_CODES: readonly ["session-closed", "turn-running", "subagent-session", "not-driven", "router-unmounted", "record-failed", "rebuild-failed"];
207
+ /** Why one session's engine switch was refused. */
208
+ export type LoopEngineRefusalCode = (typeof LOOP_ENGINE_REFUSAL_CODES)[number];
209
+ /**
210
+ * Whether an untyped value is one of {@link LOOP_ENGINE_REFUSAL_CODES}.
211
+ *
212
+ * The boundary test the browser half needs: a code is only a code when this
213
+ * build knows it, so a value it does not recognize is normalized away and the
214
+ * host's `reason` stays readable instead (`./client/session-engine.ts`
215
+ * `parseSelectResult`).
216
+ * @param value - the value to classify.
217
+ * @returns whether `value` is a known refusal code.
218
+ */
219
+ export declare function isLoopEngineRefusalCode(value: unknown): value is LoopEngineRefusalCode;
220
+ /**
221
+ * What one attempt to move a session to another engine produced.
222
+ *
223
+ * A refusal is a VALUE rather than a thrown error: every reason below is a
224
+ * predictable state of the session (it is not open, it is mid-turn, it belongs
225
+ * to subagent routing) that the surface must show — localized by
226
+ * {@link LoopEngineRefusal.code}, with the host's own sentence kept as detail —
227
+ * and only a malformed REQUEST is the caller's fault (`gateway/bad-request`).
228
+ */
229
+ export type LoopEngineSelectResult = {
230
+ readonly ok: true;
231
+ readonly engine: LoopEngineId;
232
+ /**
233
+ * Present when the switch was made to land by RELEASING the session's live
234
+ * agent rather than by moving it: a change with the harness loop on either
235
+ * side of it cannot be applied in place, so the agent is torn down, the
236
+ * session goes cold, and the record's engine is what its next build uses.
237
+ *
238
+ * The page must reload for that next build to happen: releasing publishes
239
+ * `session/disposed`, which the browser half reads as this session being
240
+ * gone — with no way back in that page's lifetime — so a reload (and a
241
+ * re-open of the session) is what replaces the page state with a fresh list
242
+ * and a fresh build. The flag is how the surface knows to do that instead of
243
+ * leaving the user on a session that looks broken.
244
+ */
245
+ readonly reload?: true;
246
+ }
247
+ /** The switch did not happen; the branch that refused it is `code`. */
248
+ | {
249
+ readonly ok: false;
250
+ /**
251
+ * Which refusal this is, for the surface to localize
252
+ * ({@link LoopEngineRefusalCode}). Every refusal this plugin's host produces
253
+ * carries one — `refuse(code, reason)` in `./router-loop.ts` takes it as a
254
+ * required argument, so the node half cannot forget it.
255
+ *
256
+ * Optional in the SHAPE only, because the browser half is what reads it off
257
+ * the wire: a code this build does not know (a newer host, or a hand-written
258
+ * answer) is normalized to absent so that `reason` — which is always there —
259
+ * is what the user reads, instead of the whole answer failing the boundary
260
+ * check and leaving the surface with nothing to show.
261
+ */
262
+ readonly code?: LoopEngineRefusalCode;
263
+ /**
264
+ * The host's own complete sentence about this session. It is DETAIL, not the
265
+ * message any more: a surface shows its own localized copy for `code` and
266
+ * keeps this beside it (small print) or falls back to it when `code` is
267
+ * absent.
268
+ */
269
+ readonly reason: string;
270
+ };
271
+ /**
272
+ * The engine one session's recorded preset says it runs, with the harness's own
273
+ * fallback for a preset this plugin does not own: the deployment's `standard` and
274
+ * anything else a deployment authored keep the session on the harness loop.
275
+ *
276
+ * The two answers that are not an engine stay distinct: {@link LEGACY_HOSTED_PRESET_ID}
277
+ * is a hosted engine whose name was never recorded, and a missing preset is this
278
+ * session recording nothing.
279
+ *
280
+ * This is the ONE preset-id → engine judgement: the router routes on what it
281
+ * returns and the plugin's own Remote reports it, so "what the session runs" and
282
+ * "what the session is shown as running" cannot disagree
283
+ * (`src/engine-of-session.ts` supplies the preset id from the durable log;
284
+ * `src/engine-remote.ts` publishes the answer to the browser half).
285
+ * @param presetId - the session's recorded preset id, when it has one. Reads off
286
+ * an untyped projection, so anything that is not a string is as good as no
287
+ * preset at all.
288
+ * @returns what that id says about the session's engine.
289
+ */
290
+ export declare function sessionEngineOf(presetId: unknown): SessionEngine;
291
+ /**
292
+ * The hosted engine one {@link SessionEngine} names, or `undefined` for every
293
+ * answer that is not this plugin's own engine preset.
294
+ *
295
+ * This is the router's half of the judgement: the harness loop still owns
296
+ * `in-process`, the pre-routing id and an unrecorded session both run whatever
297
+ * the deployment's own composition gives them (the harness loop).
298
+ * @param session - what a session's preset says about its engine.
299
+ * @returns the hosted engine to build a driver runtime for, or undefined for the
300
+ * harness loop.
301
+ */
302
+ export declare function hostedEngineOf(session: SessionEngine): HostedEngineId | undefined;
303
+ //# sourceMappingURL=agent-preset-ids.d.ts.map
@@ -1,34 +1,61 @@
1
1
  /**
2
- * Session header engine badge: a read-only chip naming the loop engine that
3
- * drives this session. The engine is a deployment-level choice, so the chip
4
- * reports the same value for every session — naming what sessions run is the
5
- * honest affordance; the switch itself lives in the settings section.
2
+ * Session header engine chip: a read-only pill naming the engine THIS session
3
+ * runs, read from this plugin's own Remote — which answers from the plugin's own
4
+ * per-session record and, for a session with none, from the session's durable
5
+ * log: the same read the router routes on, so the chip cannot disagree with what
6
+ * the session is actually driven by.
7
+ *
8
+ * It deliberately does NOT read the client session list's `agentPreset`
9
+ * projection: that value is a cache-shaped hint, and it names the preset the
10
+ * session was CREATED with, which is the wrong answer both for a session that
11
+ * switched while it was blank and for one that switched after it started. The
12
+ * harness's own preset label reads that same hint and lags the same way; this
13
+ * chip does not.
14
+ *
15
+ * The settings section's engine is NOT what this chip shows: that value is only
16
+ * the default for sessions created later, and a session created before a default
17
+ * change keeps running the engine it has. The two sessions that name no engine
18
+ * are reported as themselves rather than as the in-process loop: the pre-routing
19
+ * single preset id reads as "legacy hosted engine" (it ran a hosted engine, the
20
+ * id just never recorded which), and a session whose engine is not known — no
21
+ * record, no preset, or no answer from the host — renders nothing, which is also
22
+ * what the chip does until the host's first answer arrives.
23
+ *
24
+ * "What the session runs" is the ACTUAL engine, and for a live session that is
25
+ * the agent in front of it rather than the plugin's record: a switch onto the
26
+ * harness loop makes the host RELEASE the session's agent and reload the page,
27
+ * and if that release did not take, the session keeps running the engine it had
28
+ * while its record names another. This chip never renders the record as its name;
29
+ * it appends the `切到 X · 尚未接管` marker beside the engine that really runs,
30
+ * and its tooltip says what to do about it (`pendingSessionNotice`). See
31
+ * `SessionEngineReport` in `src/agent-preset-ids.ts`.
6
32
  *
7
33
  * Styling is token-driven inline styles like the settings section (the
8
34
  * client-module bundle is esbuild-built without a CSS loader).
9
35
  * @module dsh-loop-engine/client/badge
10
36
  */
11
37
  import type { JSX } from 'react';
12
- import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
13
- import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots';
14
- import type { LoopEngineState } from './store.ts';
15
- import type { en } from './locales.ts';
38
+ import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
39
+ import type { SessionEngineCache, SessionSeat } from './session-engine.ts';
40
+ import { type en } from './locales.ts';
16
41
  /** Registration-side business face for the header badge. */
17
42
  export interface LoopEngineBadgeInjected {
18
- hooks: {
19
- /** Engine snapshot bound by the renderer as useSnapshot. */
20
- snapshot: SnapshotStore<LoopEngineState>;
21
- };
43
+ /** The plugin's authoritative per-session engine cache. */
44
+ sessionEngines: SessionEngineCache;
22
45
  /** Section copy bound to the engine dictionaries. */
23
46
  t: (key: keyof typeof en) => string;
24
47
  }
25
- /** Props delivered by the slot outlet (the renderer erases the share boundary). */
26
- export type LoopEngineBadgeProps = Partial<InjectFace<LoopEngineBadgeInjected>>;
27
48
  /**
28
- * Render the session header's loop-engine chip. Hides until the settings
29
- * scope settles, so the header never flashes a provisional engine.
49
+ * Props delivered by the slot outlet (the renderer erases the share boundary).
50
+ * The seat members stay partial here so the registration face matches the slot's
51
+ * own props; {@link BadgeFace} asserts them the way the component reads them.
52
+ */
53
+ export type LoopEngineBadgeProps = PropsRuntime<'conversation.session.header.actions'> & Partial<SessionSeat> & Partial<InjectFace<LoopEngineBadgeInjected>>;
54
+ /**
55
+ * Render the session header's loop-engine chip for the session on screen.
30
56
  * @param props - composed slot props.
31
- * @returns the chip, or null while the engine is unknown.
57
+ * @returns the chip, or null when the seat carries no session to speak about or
58
+ * the session's engine is not known yet (or not recorded at all).
32
59
  */
33
60
  export declare function LoopEngineBadge(props: LoopEngineBadgeProps): JSX.Element | null;
34
61
  //# sourceMappingURL=LoopEngineBadge.d.ts.map
@@ -1,12 +1,68 @@
1
1
  /**
2
2
  * Composer loop-engine picker: a compact dropdown registered at the
3
3
  * `conversation.input.right` seat, so it sits immediately left of the model
4
- * select in the composer's tool row. The engine is a deployment-level choice,
5
- * so this surface shares the same settings-backed {@link LoopEngineStore} as
6
- * the settings section and the header badge — a change in any one is what the
7
- * others show next. Switching still asks for confirmation first (it interrupts
8
- * sessions still running on the previous engine) and reloads the page once the
9
- * commit lands, matching the settings section's semantics.
4
+ * select in the composer's tool row.
5
+ *
6
+ * It picks the engine of the session it is rendered in — the picker's value is
7
+ * this plugin's own authoritative read of that session's engine, and a pick
8
+ * moves the session over this plugin's own `loopEngine/select` Remote. Any
9
+ * session can be moved, at any point in its life, as long as it is open and no
10
+ * turn is in flight, and the host picks one of two ways to make the pick land:
11
+ *
12
+ * - between two hosted engines it REBUILDS that session's agent IN PLACE, on the
13
+ * session's own live Session object, so the conversation it is rendered beside
14
+ * never closes;
15
+ * - when the harness loop is on either side of the change, the host RELEASES
16
+ * that session's agent and answers `reload: true`: the session goes cold with
17
+ * its record naming the new engine, this page is reloaded (which is what
18
+ * clears the client state a `session/disposed` leaves behind), and the page
19
+ * that comes back opens the same session again — the host then builds it on
20
+ * the engine the record names (`src/client/reload.ts`). This control says so
21
+ * in a notice rather than letting the reload look like a glitch.
22
+ *
23
+ * Either way the host refuses one that is
24
+ * running, and a refusal leaves the session's engine untouched — the label goes
25
+ * back to the engine the session actually runs. The refusal is rendered from its
26
+ * CODE in the user's own language (`refusalFace`), with the host's own sentence
27
+ * kept as detail — never as the message itself, which is how a raw
28
+ * `session "…" is running; …` used to reach the user.
29
+ *
30
+ * A pick commits as soon as the host can apply it, with ONE exception: when the
31
+ * target and the engine this session ACTUALLY runs differ in being the in-process
32
+ * one, the host cannot hand the session over in place, so the switch releases its
33
+ * agent and reloads this page — and a reload costs this page's scroll position and
34
+ * any unsent draft in it. That one pick is staged behind a confirmation
35
+ * (`switchNeedsReload(报告里的实际引擎, 目标引擎)`, resolved before anything is
36
+ * sent) and only then committed; a pick between two hosted engines, which swaps
37
+ * the agent in place and reloads nothing, still commits immediately and opens no
38
+ * dialog at all. That judgement is only ever made about an engine somebody knows:
39
+ * until the host has answered what this session runs there is no engine to judge,
40
+ * and a session with no answer may reload in EITHER direction — so the control is
41
+ * DISABLED for as long as it is reading (`engineSwitchReady`), rather than
42
+ * guessing a direction. This control deliberately asks NOTHING else: whether the session
43
+ * can be moved right now is the host's own judgement — it refuses one that is not
44
+ * open, one that is mid-turn, and a subagent's own session, each with a code and a
45
+ * sentence — so a pick that cannot land costs nothing, and the control never
46
+ * reads an idle hint off a cached session list.
47
+ *
48
+ * So it opens two dialogs, with different jobs: the CONFIRMATION (two buttons,
49
+ * cancel and switch) that a reloading pick must pass before it is sent, and the
50
+ * NOTICE (one button, close) that reports what the host answered — the refusal's
51
+ * localized copy with the host's sentence as detail, or this plugin's own
52
+ * sentence for a reloaded pick.
53
+ *
54
+ * WITH a session, the settings default is never shown — not even while the first
55
+ * answer is still in flight: a session on the pre-routing single preset id reads
56
+ * "legacy hosted engine", one whose engine is not recorded (or not readable)
57
+ * reads "not recorded", and a session whose engine has not been answered yet
58
+ * reads "reading" AND is disabled while it does (see above) — a default or a stale
59
+ * hint would be a claim about a session this control has no facts for, and a pick
60
+ * would have to be judged against an engine nobody knows. Without a session (the
61
+ * seat renders only with one, so this is the defensive branch) the trigger names
62
+ * the default and a pick writes it — immediately, like every other pick here, and
63
+ * with the control usable from the start: a new-session page is waiting for
64
+ * nothing, and that pick reloads nothing. The settings section's own picker is the
65
+ * one that still stages its choice behind a confirmation.
10
66
  *
11
67
  * Styling is token-driven inline styles like the badge and section (the
12
68
  * client-module bundle is esbuild-built without a CSS loader).
@@ -14,9 +70,10 @@
14
70
  */
15
71
  import { type JSX } from 'react';
16
72
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
17
- import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots';
73
+ import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
18
74
  import type { LoopEngineStore, LoopEngineState } from './store.ts';
19
- import type { en } from './locales.ts';
75
+ import { type SessionEngineCache, type SessionEngineSwitcher, type SessionSeat } from './session-engine.ts';
76
+ import { type en } from './locales.ts';
20
77
  /** Injected dependencies of {@link LoopEngineComposerSelect} (slot `inject`). */
21
78
  export interface LoopEngineComposerSelectInjected {
22
79
  /** The selection store (loaded on mount, refreshed by scope pushes). */
@@ -25,16 +82,25 @@ export interface LoopEngineComposerSelectInjected {
25
82
  /** Engine snapshot bound by the UI renderer as useSnapshot. */
26
83
  snapshot: SnapshotStore<LoopEngineState>;
27
84
  };
85
+ /** The plugin's authoritative per-session engine cache. */
86
+ sessionEngines: SessionEngineCache;
87
+ /** Move one session to another engine. */
88
+ switchEngine: SessionEngineSwitcher;
28
89
  /** Composer copy bound to the loop engine dictionaries. */
29
90
  t: (key: keyof typeof en) => string;
30
91
  }
31
- /** Props delivered by the slot outlet (the renderer erases the share boundary). */
32
- export type LoopEngineComposerSelectProps = Partial<InjectFace<LoopEngineComposerSelectInjected>>;
33
92
  /**
34
- * Render the composer's loop-engine dropdown. Hides until the settings scope
35
- * settles, so the composer never flashes a provisional engine.
93
+ * Props delivered by the slot outlet (the renderer erases the share boundary).
94
+ * The seat members stay partial here so the registration face matches the slot's
95
+ * own props; {@link ComposerFace} asserts them the way the component reads them.
96
+ */
97
+ export type LoopEngineComposerSelectProps = PropsRuntime<'conversation.input.right'> & Partial<SessionSeat> & Partial<InjectFace<LoopEngineComposerSelectInjected>>;
98
+ /**
99
+ * Render the composer's loop-engine dropdown for the session on screen. Hides
100
+ * until the settings scope settles, so the picker never flashes a provisional
101
+ * default while a session's own engine is already known.
36
102
  * @param props - composed slot props.
37
- * @returns the picker, or null while the engine is unknown.
103
+ * @returns the picker, or null while the picker is unavailable or switched off.
38
104
  */
39
105
  export declare function LoopEngineComposerSelect(props: LoopEngineComposerSelectProps): JSX.Element | null;
40
106
  //# sourceMappingURL=LoopEngineComposerSelect.d.ts.map
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Loop engine settings section component: one dropdown choosing the agent
3
- * loop engine, backed by the duplicated settings scope through the inject face.
4
- * Changing the engine asks for confirmation first, because the switch
5
- * interrupts sessions still running on the previous engine.
2
+ * Loop engine settings section component: one dropdown choosing the DEFAULT
3
+ * agent loop engine for new sessions, backed by the duplicated settings scope
4
+ * through the inject face. The commit is confirmed first because it changes
5
+ * what every future session runs; sessions that already exist keep the engine
6
+ * they run, so nothing is interrupted and nothing reloads.
6
7
  *
7
8
  * Styling is token-driven like the rest of the settings shell (`--dsw-*`
8
9
  * aliases), with the picker rendered through the shared `Menu` primitive and