dsh-loop-engine 0.1.5-rc3 → 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 -187
  2. package/README.zh.md +45 -103
  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/client.js CHANGED
@@ -29,6 +29,36 @@ module.exports = __toCommonJS(index_exports);
29
29
  // src/client/LoopEngineSection.tsx
30
30
  var import_react = require("react");
31
31
  var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
32
+
33
+ // src/agent-preset-ids.ts
34
+ var LOOP_ENGINE_IDS = ["in-process", "claude-code", "codex", "pi", "kimi"];
35
+ var HOSTED_ENGINE_IDS = LOOP_ENGINE_IDS.filter(
36
+ (id) => id !== "in-process"
37
+ );
38
+ function isHostedEngine(engine) {
39
+ return engine !== void 0 && engine !== "in-process";
40
+ }
41
+ function isLoopEngineId(value) {
42
+ return LOOP_ENGINE_IDS.includes(value);
43
+ }
44
+ var LOOP_ENGINE_REFUSAL_CODES = [
45
+ "session-closed",
46
+ "turn-running",
47
+ "subagent-session",
48
+ "not-driven",
49
+ "router-unmounted",
50
+ "record-failed",
51
+ "rebuild-failed"
52
+ ];
53
+ function isLoopEngineRefusalCode(value) {
54
+ return LOOP_ENGINE_REFUSAL_CODES.includes(value);
55
+ }
56
+ function hostedEngineOf(session) {
57
+ if (session.kind !== "engine" || session.engine === "in-process") return void 0;
58
+ return session.engine;
59
+ }
60
+
61
+ // src/client/LoopEngineSection.tsx
32
62
  var import_jsx_runtime = require("react/jsx-runtime");
33
63
  var ENGINE_OPTIONS = [
34
64
  { value: "in-process", key: "engineInProcess" },
@@ -142,9 +172,7 @@ function LoopEngineSection(props) {
142
172
  const value = pending;
143
173
  setPending(null);
144
174
  if (value !== null) {
145
- void controller.setEngine(value).then((landed) => {
146
- if (landed) window.location.reload();
147
- });
175
+ void controller.setEngine(value);
148
176
  }
149
177
  };
150
178
  const cancelSwitch = () => {
@@ -187,7 +215,7 @@ function LoopEngineSection(props) {
187
215
  }
188
216
  ),
189
217
  status === "saving" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: notice, children: t("saving") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: notice, children: t("switchNotice") }),
190
- engine === "claude-code" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: notice, children: t("claudeModelNotice") }) : null,
218
+ isHostedEngine(engine) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: notice, children: t("hostedEngineModelNotice") }) : null,
191
219
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: toggleRow, children: [
192
220
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
193
221
  "input",
@@ -220,6 +248,316 @@ function LoopEngineSection(props) {
220
248
  ] });
221
249
  }
222
250
 
251
+ // src/client/use-session-engine.ts
252
+ var import_react2 = require("react");
253
+
254
+ // src/client/turn-status.ts
255
+ var ENGINE_ATTR = "data-loop-engine";
256
+ var PLUGIN_ID = "dsh-loop-engine";
257
+ var STYLESHEET = `
258
+ [class$="_turnStatus"]::before {
259
+ margin-right: 6px;
260
+ background: none;
261
+ -webkit-background-clip: border-box;
262
+ background-clip: border-box;
263
+ }
264
+
265
+ /*
266
+ * The row's own sweep, re-asserted for hosted engines.
267
+ *
268
+ * ui-chat disables that animation under \`prefers-reduced-motion: reduce\`
269
+ * (ChatView.module.css), and a media query carries no specificity \u2014 so this
270
+ * attribute-gated rule outranks it. That is deliberate here: deployment images
271
+ * ship with Windows' client-area animation off (SPI_GETCLIENTAREAANIMATION
272
+ * false), and with the guard in force EVERY indicator on this row is frozen \u2014
273
+ * the sweep and the glyph alike. Scoped to hosted engines, so in-process
274
+ * sessions keep the stock reduced-motion behaviour; delete this rule and
275
+ * restore the guard at the foot of the sheet to hand the decision back to the OS.
276
+ */
277
+ html[${ENGINE_ATTR}] [class$="_turnStatus"] {
278
+ background-position: 100% 0;
279
+ background-size: 250% 100%;
280
+ animation: le-shimmer 1.8s linear infinite;
281
+ }
282
+
283
+ @keyframes le-shimmer {
284
+ to { background-position: 0 0; }
285
+ }
286
+
287
+ html[${ENGINE_ATTR}="claude-code"] [class$="_turnStatus"] {
288
+ --dsw-static-deepseek-500: #d97757;
289
+ --dsw-static-deepseek-200: #f5bda6;
290
+ }
291
+ html[${ENGINE_ATTR}="claude-code"] [class$="_turnStatus"]::before {
292
+ content: "\u273B";
293
+ color: #d97757;
294
+ -webkit-text-fill-color: #d97757;
295
+ animation: le-bloom 1.6s ease-in-out infinite;
296
+ }
297
+
298
+ html[${ENGINE_ATTR}="codex"] [class$="_turnStatus"] {
299
+ --dsw-static-deepseek-500: #a9b1c0;
300
+ --dsw-static-deepseek-200: #e6eaf2;
301
+ }
302
+ html[${ENGINE_ATTR}="codex"] [class$="_turnStatus"]::before {
303
+ content: "\u2022";
304
+ color: #a9b1c0;
305
+ -webkit-text-fill-color: #a9b1c0;
306
+ text-shadow: 0 0 6px currentColor;
307
+ animation: le-pulse 1.4s ease-in-out infinite;
308
+ }
309
+
310
+ html[${ENGINE_ATTR}="pi"] [class$="_turnStatus"] {
311
+ --dsw-static-deepseek-500: #8e4ec6;
312
+ --dsw-static-deepseek-200: #d6bff0;
313
+ }
314
+ html[${ENGINE_ATTR}="pi"] [class$="_turnStatus"]::before {
315
+ content: "\u280B";
316
+ color: #8e4ec6;
317
+ -webkit-text-fill-color: #8e4ec6;
318
+ font-size: 1.1em;
319
+ animation: le-braille 1s linear infinite;
320
+ }
321
+
322
+ html[${ENGINE_ATTR}="kimi"] [class$="_turnStatus"] {
323
+ --dsw-static-deepseek-500: #e5484d;
324
+ --dsw-static-deepseek-200: #f5b2b4;
325
+ }
326
+ html[${ENGINE_ATTR}="kimi"] [class$="_turnStatus"]::before {
327
+ content: "\u{1F317}";
328
+ color: #e5484d;
329
+ -webkit-text-fill-color: #e5484d;
330
+ animation: le-moon 2.5s linear infinite;
331
+ }
332
+
333
+ /* Grows from small to large each cycle \u2014 the opposite of a twinkle. The range
334
+ is deliberately wide (3x) because a bare size change on a thin glyph reads
335
+ weakly otherwise, and the large state is held briefly (50%-62%) so it blooms
336
+ rather than throbs. 1.35x extends ~2.5px per side at the 14px glyph, inside
337
+ the 6px ::before margin. */
338
+ @keyframes le-bloom {
339
+ 0%, 100% { transform: scale(0.45); opacity: 0.45; }
340
+ 50%, 62% { transform: scale(1.35); opacity: 1; }
341
+ }
342
+ /* A terminal braille spinner. The glyph is an ordinary text character, not an
343
+ emoji, so the engine color actually paints \u2014 a colored emoji silently ignores
344
+ color/-webkit-text-fill-color. Stepping content walks the ten dot frames. */
345
+ @keyframes le-braille {
346
+ 0% { content: "\u280B"; }
347
+ 10% { content: "\u2819"; }
348
+ 20% { content: "\u2839"; }
349
+ 30% { content: "\u2838"; }
350
+ 40% { content: "\u283C"; }
351
+ 50% { content: "\u2834"; }
352
+ 60% { content: "\u2826"; }
353
+ 70% { content: "\u2827"; }
354
+ 80% { content: "\u2807"; }
355
+ 90% { content: "\u280F"; }
356
+ 100% { content: "\u280B"; }
357
+ }
358
+ /* A soft pulse for the codex dot: the glyph breathes between a small, dim
359
+ point and a larger, full-brightness one \u2014 a light dot, not a spinner. */
360
+ @keyframes le-pulse {
361
+ 0%, 100% { transform: scale(0.5); opacity: 0.35; }
362
+ 50% { transform: scale(1.3); opacity: 1; }
363
+ }
364
+ /* Moon phases rather than a rigid rotation: a spinning moon bitmap can only
365
+ squash and mirror itself, never show a full or a new moon. Stepping the
366
+ glyph through the phase set sweeps the lit edge across the disc AND actually
367
+ reaches \u{1F315} and \u{1F311}. The order runs waning first (full \u2192 new \u2192 full), so the lit
368
+ edge travels counter-clockwise, and the glyph under the row's first paint
369
+ (\u{1F317}) is a mid-phase rather than a full or a new moon. */
370
+ @keyframes le-moon {
371
+ 0% { content: "\u{1F315}"; }
372
+ 12.5% { content: "\u{1F316}"; }
373
+ 25% { content: "\u{1F317}"; }
374
+ 37.5% { content: "\u{1F318}"; }
375
+ 50% { content: "\u{1F311}"; }
376
+ 62.5% { content: "\u{1F312}"; }
377
+ 75% { content: "\u{1F313}"; }
378
+ 87.5% { content: "\u{1F314}"; }
379
+ 100% { content: "\u{1F315}"; }
380
+ }
381
+
382
+ `;
383
+ var focusedSessionId;
384
+ function reflectTurnStatusEngine(sessionId, engine) {
385
+ if (sessionId === void 0 || sessionId !== focusedSessionId) return;
386
+ writeTurnStatusEngine(engine);
387
+ }
388
+ function focusTurnStatusSession(sessionId) {
389
+ focusedSessionId = sessionId;
390
+ }
391
+ function blurTurnStatusSession(sessionId) {
392
+ if (focusedSessionId === sessionId) focusedSessionId = void 0;
393
+ }
394
+ function writeTurnStatusEngine(engine) {
395
+ if (typeof document === "undefined") return;
396
+ const root = document.documentElement;
397
+ if (engine === void 0 || engine === "in-process") {
398
+ delete root.dataset.loopEngine;
399
+ return;
400
+ }
401
+ root.dataset.loopEngine = engine;
402
+ }
403
+ function installTurnStatusStyles(ctx) {
404
+ if (typeof document === "undefined") return;
405
+ ctx.effect(() => {
406
+ const tag = document.createElement("style");
407
+ tag.dataset.plugin = PLUGIN_ID;
408
+ tag.dataset.pluginCss = `${PLUGIN_ID}/turn-status.css`;
409
+ tag.textContent = STYLESHEET;
410
+ document.head.appendChild(tag);
411
+ return () => {
412
+ tag.remove();
413
+ delete document.documentElement.dataset.loopEngine;
414
+ };
415
+ }, "loop-engine: per-engine turn status styles");
416
+ }
417
+
418
+ // src/client/use-session-engine.ts
419
+ function useEngineOfSession(cache, sessionId) {
420
+ const [, bump] = (0, import_react2.useState)(0);
421
+ const report = sessionId === void 0 ? void 0 : cache.read(sessionId);
422
+ (0, import_react2.useEffect)(() => {
423
+ if (sessionId === void 0) return;
424
+ return cache.watch(sessionId, () => {
425
+ bump((count) => count + 1);
426
+ });
427
+ }, [cache, sessionId]);
428
+ const engine = report === void 0 ? void 0 : hostedEngineOf(report.engine);
429
+ (0, import_react2.useEffect)(() => {
430
+ if (sessionId === void 0) return;
431
+ focusTurnStatusSession(sessionId);
432
+ reflectTurnStatusEngine(sessionId, engine);
433
+ return () => {
434
+ blurTurnStatusSession(sessionId);
435
+ };
436
+ }, [sessionId, engine]);
437
+ return report;
438
+ }
439
+
440
+ // src/client/locales.ts
441
+ var zh = {
442
+ nav: "\u5FAA\u73AF\u5F15\u64CE",
443
+ description: "\u9009\u62E9\u65B0\u4F1A\u8BDD\u9ED8\u8BA4\u4F7F\u7528\u7684 Agent \u6267\u884C\u5F15\u64CE\uFF1B\u6BCF\u4E2A\u4F1A\u8BDD\u4E5F\u53EF\u4EE5\u5728\u65B0\u4F1A\u8BDD\u9875\u5355\u72EC\u9009\u62E9\u81EA\u5DF1\u7684\u5F15\u64CE\u3002",
444
+ engineInProcess: "\u8FDB\u7A0B\u5185\u5F15\u64CE\uFF08\u9ED8\u8BA4\uFF09",
445
+ engineClaudeCode: "Claude Code CLI",
446
+ engineCodex: "Codex CLI",
447
+ enginePi: "Pi CLI",
448
+ engineKimi: "Kimi Code CLI",
449
+ engineLegacy: "\u65E7\u7248\u6258\u7BA1\u5F15\u64CE",
450
+ engineUnrecorded: "\u672A\u8BB0\u5F55",
451
+ engineLoading: "\u8BFB\u53D6\u4E2D\u2026",
452
+ showInComposerLabel: "\u5728\u5BF9\u8BDD\u9875\u663E\u793A\u5F15\u64CE\u9009\u62E9\u5668",
453
+ unavailable: "\u5FAA\u73AF\u5F15\u64CE\u8BBE\u7F6E\u4E0D\u53EF\u7528",
454
+ switchNotice: "\u8FD9\u4E00\u9009\u62E9\u53EA\u51B3\u5B9A\u65B0\u4F1A\u8BDD\u7528\u54EA\u4E2A\u5F15\u64CE\uFF0C\u5DF2\u7ECF\u5728\u8DD1\u7684\u4F1A\u8BDD\u4E0D\u53D7\u5F71\u54CD\u3002",
455
+ saving: "\u4FDD\u5B58\u4E2D\u2026",
456
+ confirmTitle: "\u5207\u6362\u5FAA\u73AF\u5F15\u64CE\uFF1F",
457
+ confirmBody: "\u6B64\u540E\u65B0\u5EFA\u7684\u4F1A\u8BDD\u4F1A\u4F7F\u7528\u8FD9\u4E2A\u5F15\u64CE\uFF1B\u5DF2\u5B58\u5728\u7684\u4F1A\u8BDD\u4FDD\u6301\u5B83\u4EEC\u5404\u81EA\u7684\u5F15\u64CE\u4E0D\u53D8\uFF0C\u4E5F\u4E0D\u9700\u8981\u5237\u65B0\u9875\u9762\u3002\u786E\u8BA4\u5207\u6362\u5417\uFF1F",
458
+ confirmAction: "\u5207\u6362",
459
+ cancelAction: "\u53D6\u6D88",
460
+ closeLabel: "\u5173\u95ED",
461
+ hostedEngineModelNotice: "\u672C\u4F1A\u8BDD\u7531\u5916\u90E8\u5F15\u64CE\u9A71\u52A8\uFF1A\u9ED8\u8BA4\u7528\u54EA\u4E2A\u6A21\u578B\u7531\u8BE5\u5F15\u64CE\u81EA\u5DF1\u51B3\u5B9A\u3002\u5728\u6A21\u578B\u83DC\u5355\u91CC\u9009\u4E00\u4E2A\u771F\u5B9E dsh \u6A21\u578B\uFF0C\u4F1A\u628A\u8FD9\u4E2A\u6A21\u578B\u7684\u7AEF\u70B9\u4E0E\u51ED\u636E\u4E00\u5E76\u4EA4\u7ED9\u8BE5\u5F15\u64CE\u4F7F\u7528\uFF0C\u8BA9\u5B83\u771F\u7684\u8D70 dsh \u914D\u7F6E\u7684\u90A3\u4E2A\u7AEF\u70B9\u5BF9\u8BDD\u3002\u80FD\u4E0D\u80FD\u7528\u53D6\u51B3\u4E8E\u8BE5\u5F15\u64CE\u662F\u5426\u652F\u6301\u90A3\u4E2A\u534F\u8BAE\uFF0C\u4E0D\u652F\u6301\u5C31\u4F1A\u7531\u5F15\u64CE\u62A5\u9519\u3002\u6A21\u578B\u83DC\u5355\u91CC\u6240\u6709\u6258\u7BA1\u5F15\u64CE\u5171\u7528\u4E00\u4E2A `external` \u5206\u7EC4\uFF08\u6A21\u578B\u76EE\u5F55\u662F\u6574\u4E2A Host \u4EE3\u9645\u5171\u4EAB\u7684\uFF0C\u4E0D\u6309\u4F1A\u8BDD\u533A\u5206\uFF0C\u6240\u4EE5\u56DB\u4E2A\u5F15\u64CE\u6298\u53E0\u6210\u8FD9\u4E00\u6761\uFF09\uFF0C\u540D\u4E0B\u90A3\u6761\u300Cdefault\u300D\u8868\u793A\u4EA4\u56DE\u5F15\u64CE\u81EA\u5DF1\u7684\u9ED8\u8BA4\uFF0C\u4E0D\u662F dsh \u80FD\u63D0\u4F9B\u7684\u6A21\u578B\u3002",
462
+ sessionNotice: "\u672C\u4F1A\u8BDD\u5F53\u524D\u8FD0\u884C\u7684\u5F15\u64CE\uFF1A\u6709\u6D3B agent \u65F6\u5C31\u662F\u6B63\u5728\u9A71\u52A8\u5B83\u7684\u90A3\u4E2A\u5F15\u64CE\uFF1B\u6CA1\u6709\u6D3B agent \u65F6\u624D\u770B\u63D2\u4EF6\u7684\u4F1A\u8BDD\u7EA7\u8BB0\u5F55\uFF0C\u6CA1\u6709\u8BB0\u5F55\u518D\u56DE\u9000\u5230\u5B83\u81EA\u5DF1\u7684 agent preset\u3002",
463
+ legacySessionNotice: "\u8FD9\u662F\u672C\u63D2\u4EF6\u65E9\u671F\u7248\u672C\u521B\u5EFA\u7684\u4F1A\u8BDD\uFF1A\u5B83\u5F53\u65F6\u8DD1\u7684\u6258\u7BA1\u5F15\u64CE\u6CA1\u6709\u88AB\u8BB0\u5F55\u3002\u9009\u4E00\u4E2A\u5F15\u64CE\u5373\u53EF\u5728\u672C\u4F1A\u8BDD\u91CC\u5207\u6362\u3002",
464
+ enginePendingPrefix: "\u5207\u5230 ",
465
+ enginePendingSuffix: " \xB7 \u5C1A\u672A\u63A5\u7BA1",
466
+ engineMenuPendingSuffix: "\uFF08\u5C1A\u672A\u63A5\u7BA1\uFF09",
467
+ pendingSessionNotice: "\u672C\u4F1A\u8BDD\u7684\u8BB0\u5F55\u5199\u7740\u4E00\u4E2A\u5F15\u64CE\uFF0C\u4F46\u5B83\u6CA1\u80FD\u63A5\u7BA1\uFF1A\u672C\u4F1A\u8BDD\u6B64\u523B\u4ECD\u7531\u53E6\u4E00\u4E2A\u5F15\u64CE\u9A71\u52A8\uFF08\u6240\u4EE5\u8FD9\u91CC\u5148\u5199\u7684\u662F\u6B63\u5728\u8DD1\u7684\u90A3\u4E2A\uFF09\u3002\u51FA\u73B0\u8FD9\u79CD\u60C5\u51B5\u53EA\u6709\u4E00\u79CD\u539F\u56E0\u2014\u2014\u4E0A\u4E00\u6B21\u5207\u6362\u8981\u62C6\u6389\u65E7 agent \u624D\u80FD\u8BA9\u65B0\u5F15\u64CE\u63A5\u7BA1\uFF0C\u800C\u90A3\u6B21\u62C6\u5378\u6CA1\u6709\u5B8C\u6210\u3002\u518D\u9009\u4E00\u6B21\u76EE\u6807\u5F15\u64CE\u5373\u53EF\u91CD\u8BD5\u3002",
468
+ pendingComposerHint: "\u672C\u4F1A\u8BDD\u7684\u8BB0\u5F55\u5199\u7740\u4E00\u4E2A\u5F15\u64CE\uFF0C\u4F46\u5B83\u6CA1\u80FD\u63A5\u7BA1\uFF1A\u672C\u4F1A\u8BDD\u6B64\u523B\u4ECD\u7531\u53E6\u4E00\u4E2A\u5F15\u64CE\u9A71\u52A8\uFF08\u6807\u7B7E\u4E0A\u5148\u5199\u7684\u662F\u6B63\u5728\u8DD1\u7684\u90A3\u4E2A\uFF09\u3002\u6D89\u53CA\u8FDB\u7A0B\u5185\u5F15\u64CE\u7684\u5207\u6362\u8981\u62C6\u6389\u65E7 agent \u624D\u80FD\u843D\u5230\u4E0B\u4E00\u6B21\u6784\u5EFA\u4E0A\uFF0C\u51FA\u73B0\u8FD9\u79CD\u60C5\u51B5\u8BF4\u660E\u90A3\u6B21\u62C6\u5378\u6CA1\u6709\u6210\u529F\u2014\u2014\u518D\u9009\u4E00\u6B21\u76EE\u6807\u5F15\u64CE\u5373\u53EF\u91CD\u8BD5\uFF1B\u9009\u5F53\u524D\u6B63\u5728\u8DD1\u7684\u90A3\u4E2A\u5F15\u64CE\u5219\u4F1A\u628A\u8BB0\u5F55\u6539\u56DE\u53BB\uFF0C\u8BA9\u5B83\u4FDD\u6301\u539F\u6837\u3002",
469
+ composerHint: "\u9009\u62E9\u672C\u4F1A\u8BDD\u8FD0\u884C\u7684\u5F15\u64CE\uFF1A\u6258\u7BA1\u5F15\u64CE\u4E4B\u95F4\u9009\u4E2D\u5373\u751F\u6548\uFF0C\u539F\u5730\u6362\u624B\uFF0C\u4F1A\u8BDD\u4FDD\u6301\u6253\u5F00\u3001agent \u88AB\u66FF\u6362\uFF1B\u4E0E\u8FDB\u7A0B\u5185\u5F15\u64CE\u4E4B\u95F4\u7684\u5207\u6362\u65E0\u6CD5\u539F\u5730\u63A5\u7BA1\uFF08harness \u7684 loop \u65E2\u4E0D\u4EA4\u51FA\u6D3B\u4F1A\u8BDD\u3001\u4E5F\u4E0D\u63A5\u7BA1\u522B\u4EBA\u5EFA\u7684\u4F1A\u8BDD\uFF09\uFF0C\u9009\u4E2D\u65F6\u4F1A\u5148\u8BF4\u660E\u4EE3\u4EF7\u5E76\u7B49\u786E\u8BA4\uFF0C\u786E\u8BA4\u540E\u5BBF\u4E3B\u62C6\u6389\u8FD9\u6761\u4F1A\u8BDD\u7684 agent \u5E76\u8BA9\u9875\u9762\u91CD\u65B0\u8F7D\u5165\uFF0C\u56DE\u6765\u65F6\u4ECD\u662F\u8FD9\u6761\u4F1A\u8BDD\uFF0C\u7531\u65B0\u5F15\u64CE\u91CD\u5EFA\uFF08\u90A3\u6B21\u91CD\u8F7D\u4F1A\u4E22\u6389\u8FD9\u4E00\u9875\u7684\u6EDA\u52A8\u4F4D\u7F6E\u4E0E\u672A\u63D0\u4EA4\u7684\u8349\u7A3F\uFF0C\u4F1A\u8BDD\u8BB0\u5F55\u4E0D\u53D7\u5F71\u54CD\uFF09\u3002\u4F1A\u8BDD\u9700\u5DF2\u6253\u5F00\u4E14\u7A7A\u95F2\uFF0C\u5426\u5219\u5BBF\u4E3B\u4F1A\u8BF4\u660E\u539F\u56E0\u3002",
470
+ switchFailedTitle: "\u5207\u6362\u672A\u751F\u6548",
471
+ refusedSessionClosed: "\u8FD9\u6761\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u6253\u5F00\u3002\u5148\u6253\u5F00\u5B83\uFF0C\u518D\u5207\u6362\u5F15\u64CE\u3002",
472
+ refusedTurnRunning: "\u8FD9\u6761\u4F1A\u8BDD\u6B63\u5728\u8FD0\u884C\u4E2D\u2014\u2014\u7B49\u8FD9\u4E00\u8F6E\u7ED3\u675F\u540E\u518D\u5207\u6362\u5F15\u64CE\u3002\u5F53\u524D\u8FD9\u4E00\u8F6E\u4E0D\u4F1A\u88AB\u6253\u65AD\u3002",
473
+ refusedSubagentSession: "\u8FD9\u662F\u5B50\u4EE3\u7406\u4F1A\u8BDD\uFF1B\u5B83\u7684\u5F15\u64CE\u8DDF\u968F\u6D3E\u53D1\u5B83\u7684\u4E3B\u4F1A\u8BDD\uFF0C\u4E0D\u80FD\u5355\u72EC\u5207\u6362\u3002",
474
+ refusedRouterNotReady: "\u8FD9\u4E2A\u8FDB\u7A0B\u6682\u65F6\u65E0\u6CD5\u5207\u6362\u5F15\u64CE\uFF08\u5FAA\u73AF\u5F15\u64CE\u8DEF\u7531\u5668\u8FD8\u6CA1\u5C31\u7EEA\uFF09\u3002",
475
+ refusedRecordFailed: "\u5F15\u64CE\u8BB0\u5F55\u5199\u5165\u5931\u8D25\uFF0C\u5207\u6362\u6CA1\u6709\u751F\u6548\u3002",
476
+ refusedRebuildFailed: "\u5207\u6362\u6CA1\u6709\u751F\u6548\uFF1A\u65B0\u5F15\u64CE\u6CA1\u80FD\u5EFA\u8D77\u6765\uFF0C\u8FD9\u6761\u4F1A\u8BDD\u7684 agent \u5DF2\u7ECF\u91CA\u653E\u3002\u518D\u6253\u5F00\u4E00\u6B21\u8FD9\u6761\u4F1A\u8BDD\uFF0C\u5B83\u5C31\u4F1A\u7528\u65B0\u5F15\u64CE\u91CD\u5EFA\u3002",
477
+ switchReloadConfirmTitle: "\u5207\u6362\u9700\u8981\u91CD\u65B0\u8F7D\u5165\u9875\u9762",
478
+ switchReloadConfirmBody: "\u4E0E\u8FDB\u7A0B\u5185\u5F15\u64CE\u4E4B\u95F4\u7684\u5207\u6362\u65E0\u6CD5\u539F\u5730\u63A5\u7BA1\uFF1A\u5BBF\u4E3B\u4F1A\u91CA\u653E\u8FD9\u6761\u4F1A\u8BDD\u7684 agent\uFF0C\u5E76\u91CD\u65B0\u8F7D\u5165\u9875\u9762\uFF0C\u7531\u65B0\u5F15\u64CE\u91CD\u5EFA\u5B83\u3002\u8FD9\u4E00\u9875\u7684\u6EDA\u52A8\u4F4D\u7F6E\u3001\u8FD8\u6CA1\u53D1\u51FA\u7684\u8349\u7A3F\u4F1A\u4E22\u5931\uFF1B\u4F1A\u8BDD\u8BB0\u5F55\uFF08\u5BF9\u8BDD\u5386\u53F2\uFF09\u4E0D\u53D7\u5F71\u54CD\u3002\u73B0\u5728\u5207\u6362\u5417\uFF1F",
479
+ switchReloadConfirmAction: "\u5207\u6362\u5E76\u91CD\u8F7D",
480
+ switchReloadTitle: "\u5207\u6362\u5DF2\u751F\u6548\uFF0C\u6B63\u5728\u91CD\u65B0\u8F7D\u5165\u9875\u9762",
481
+ switchReloadBody: "\u5DF2\u5207\u5230\u65B0\u5F15\u64CE\uFF0C\u6B63\u5728\u91CD\u65B0\u8F7D\u5165\u9875\u9762\u5E76\u56DE\u5230\u672C\u4F1A\u8BDD\u2026",
482
+ switchUnavailable: "\u5F53\u524D\u9875\u9762\u62FF\u4E0D\u5230\u5F15\u64CE\u5207\u6362\u670D\u52A1\uFF0C\u65E0\u6CD5\u5207\u6362\u672C\u4F1A\u8BDD\u7684\u5F15\u64CE\u3002"
483
+ };
484
+ var en = {
485
+ nav: "Loop engine",
486
+ description: "Choose the agent execution engine NEW sessions start on; each session can also pick its own engine on the new-session screen.",
487
+ engineInProcess: "In-process engine (default)",
488
+ engineClaudeCode: "Claude Code CLI",
489
+ engineCodex: "Codex CLI",
490
+ enginePi: "Pi CLI",
491
+ engineKimi: "Kimi Code CLI",
492
+ engineLegacy: "Legacy hosted engine",
493
+ engineUnrecorded: "Not recorded",
494
+ engineLoading: "Reading\u2026",
495
+ showInComposerLabel: "Show the engine selector in the chat page",
496
+ unavailable: "Loop engine settings are unavailable",
497
+ switchNotice: "This only decides what new sessions run; sessions already running keep their own engine.",
498
+ saving: "Saving\u2026",
499
+ confirmTitle: "Switch loop engine?",
500
+ confirmBody: "Sessions created after this point use the new engine. Sessions that already exist keep the engine they were created with, and nothing reloads. Switch now?",
501
+ confirmAction: "Switch",
502
+ cancelAction: "Cancel",
503
+ closeLabel: "Close",
504
+ hostedEngineModelNotice: `This session is driven by a hosted engine: by default that engine decides the model. Picking a real dsh model in the menu hands the engine that model's endpoint and credential too, so the engine really talks to the endpoint dsh configured. Whether it works depends on whether the engine supports that protocol, and one it does not support reports an error from the engine. Every hosted engine shares one provider group, "external", in the model menu (the model catalog is shared across the whole Host generation rather than per session, so the engines collapse into that one group), whose single entry, "default", means "hand the choice back to the engine's own default" \u2014 not a model dsh can serve.`,
505
+ sessionNotice: "The engine this session runs right now: the agent driving it, when one is live, and otherwise the plugin's per-session record (falling back to the session's own agent preset when there is no record).",
506
+ legacySessionNotice: "This session was created by an earlier version of the plugin: the hosted engine it ran was never recorded. Pick any engine to switch it here.",
507
+ enginePendingPrefix: "\u2192 ",
508
+ enginePendingSuffix: " \xB7 not in force",
509
+ engineMenuPendingSuffix: " (not in force)",
510
+ pendingSessionNotice: "This session's record names an engine that has not taken over: the session is still driven by another engine right now \u2014 which is the one named first. There is exactly one way this happens: a switch had to release the old agent for the new engine to take over, and that release did not complete. Pick the target engine again to retry it.",
511
+ pendingComposerHint: "This session's record names an engine that has not taken over: the session is still driven by another engine (the label names that one first). A switch involving the in-process engine has to release the old agent for the new engine to take over on the session's next build, and this state means that release did not succeed \u2014 pick the target engine again to retry it, or pick the engine the session is actually running to put the record back and leave it as it is.",
512
+ composerHint: "Choose the engine this session runs. A pick between two hosted engines applies at once \u2014 the agent is swapped in place and the session stays open. A switch involving the in-process engine cannot be taken over in place (the harness loop neither hands over a live session nor takes over one it did not create), so it says what it costs and waits for a confirmation first; once confirmed, the host releases this session's agent and the page reloads itself, coming back to this same session, rebuilt on the new engine \u2014 that reload drops this page's scroll position and any unsent draft, while the conversation record is unaffected. The session must be open and idle, or the host says why.",
513
+ switchFailedTitle: "Switch not applied",
514
+ refusedSessionClosed: "This session is not open yet. Open it, then switch its engine.",
515
+ refusedTurnRunning: "This session is running \u2014 switch its engine after this turn ends. The turn in flight is not interrupted.",
516
+ refusedSubagentSession: "This is a subagent session; its engine follows the main session that spawned it and cannot be switched on its own.",
517
+ refusedRouterNotReady: "This process cannot switch engines right now (the loop engine router is not ready).",
518
+ refusedRecordFailed: "The engine record could not be written, so the switch did not happen.",
519
+ refusedRebuildFailed: "The switch did not happen: the new engine could not be started and this session's agent has already been released. Open this session again and it is rebuilt on the new engine.",
520
+ switchReloadConfirmTitle: "This switch reloads the page",
521
+ switchReloadConfirmBody: "A switch involving the in-process engine cannot be handed over in place: the host releases this session's agent and reloads the page, which rebuilds the session on the new engine. Your scroll position and any unsent draft in this page are lost; the conversation record is not. Switch now?",
522
+ switchReloadConfirmAction: "Switch and reload",
523
+ switchReloadTitle: "Switch applied \u2014 reloading the page",
524
+ switchReloadBody: "Switched. Reloading the page and returning to this session\u2026",
525
+ switchUnavailable: "This page cannot reach the engine switch endpoint, so the session engine was not switched."
526
+ };
527
+ function engineLabelKey2(engine) {
528
+ switch (engine) {
529
+ case "claude-code":
530
+ return "engineClaudeCode";
531
+ case "codex":
532
+ return "engineCodex";
533
+ case "pi":
534
+ return "enginePi";
535
+ case "kimi":
536
+ return "engineKimi";
537
+ default:
538
+ return "engineInProcess";
539
+ }
540
+ }
541
+ function engineStateLabelKey(state) {
542
+ if (state.kind === "engine") return engineLabelKey2(state.engine);
543
+ return state.kind === "legacy" ? "engineLegacy" : "engineUnrecorded";
544
+ }
545
+ function pendingEngineText(t, engine) {
546
+ return `${t("enginePendingPrefix")}${t(engineLabelKey2(engine))}${t("enginePendingSuffix")}`;
547
+ }
548
+ var REFUSAL_FACES = {
549
+ "session-closed": { body: "refusedSessionClosed", detail: false },
550
+ "turn-running": { body: "refusedTurnRunning", detail: false },
551
+ "subagent-session": { body: "refusedSubagentSession", detail: false },
552
+ "not-driven": { body: "refusedRouterNotReady", detail: false },
553
+ "router-unmounted": { body: "refusedRouterNotReady", detail: false },
554
+ "record-failed": { body: "refusedRecordFailed", detail: true },
555
+ "rebuild-failed": { body: "refusedRebuildFailed", detail: true }
556
+ };
557
+ function refusalFace(code) {
558
+ return code === void 0 ? { detail: false } : REFUSAL_FACES[code];
559
+ }
560
+
223
561
  // src/client/LoopEngineBadge.tsx
224
562
  var import_jsx_runtime2 = require("react/jsx-runtime");
225
563
  var pill = {
@@ -235,23 +573,431 @@ var pill = {
235
573
  lineHeight: "18px",
236
574
  whiteSpace: "nowrap"
237
575
  };
238
- function LoopEngineBadge(props) {
239
- const { useSnapshot, t } = props;
240
- const { status, engine } = useSnapshot((state) => state);
241
- if (status !== "ready") return null;
242
- const label = t(
243
- engine === "claude-code" ? "engineClaudeCode" : engine === "codex" ? "engineCodex" : engine === "pi" ? "enginePi" : engine === "kimi" ? "engineKimi" : "engineInProcess"
244
- );
245
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: pill, title: t("description"), children: [
246
- t("nav"),
247
- " \xB7 ",
248
- label
249
- ] });
576
+ function LoopEngineBadge(props) {
577
+ const { sessionId, sessionEngines, t } = props;
578
+ const report = useEngineOfSession(sessionEngines, sessionId);
579
+ if (sessionId === void 0 || report === void 0) return null;
580
+ const engine = report.engine;
581
+ if (engine.kind === "unset") return null;
582
+ const label = t(engineStateLabelKey(engine));
583
+ const named = engine.kind === "engine" ? engine.engine : void 0;
584
+ const pending = report.pending;
585
+ const notice2 = pending === void 0 ? named === void 0 ? t("legacySessionNotice") : t("sessionNotice") : t("pendingSessionNotice");
586
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: pill, title: notice2, children: [
587
+ t("nav"),
588
+ " \xB7 ",
589
+ label,
590
+ pending === void 0 ? null : ` \xB7 ${pendingEngineText(t, pending)}`
591
+ ] });
592
+ }
593
+
594
+ // src/client/LoopEngineComposerSelect.tsx
595
+ var import_react3 = require("react");
596
+ var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives");
597
+
598
+ // src/client/reload.ts
599
+ var RELOAD_RETURN_KEY = "dsh-loop-engine:reload-session";
600
+ function browserPage() {
601
+ const globals = globalThis;
602
+ return {
603
+ stash: globals.sessionStorage,
604
+ // Off a browser there is no page to reload; the stash is missing there too,
605
+ // so nothing is ever armed for one.
606
+ reload: () => {
607
+ globals.location?.reload();
608
+ }
609
+ };
610
+ }
611
+ function armReloadReturn(page, sessionId) {
612
+ const stash = page.stash;
613
+ if (stash === void 0) return false;
614
+ try {
615
+ stash.setItem(RELOAD_RETURN_KEY, sessionId);
616
+ return true;
617
+ } catch {
618
+ return false;
619
+ }
620
+ }
621
+ function takeReloadReturn(page) {
622
+ const stash = page.stash;
623
+ if (stash === void 0) return void 0;
624
+ let stashed = null;
625
+ try {
626
+ stashed = stash.getItem(RELOAD_RETURN_KEY);
627
+ } catch {
628
+ return void 0;
629
+ }
630
+ if (stashed === null || stashed.length === 0) return void 0;
631
+ try {
632
+ stash.removeItem(RELOAD_RETURN_KEY);
633
+ } catch {
634
+ }
635
+ return stashed;
636
+ }
637
+ function restoreReloadReturn(sessions, page, warn) {
638
+ const sessionId = takeReloadReturn(page);
639
+ if (sessionId === void 0) return () => {
640
+ };
641
+ let unsubscribe;
642
+ const settle = () => {
643
+ const snapshot = sessions.list.getSnapshot();
644
+ if (snapshot.phase !== "ready") return;
645
+ unsubscribe?.();
646
+ unsubscribe = void 0;
647
+ if (!snapshot.ids.includes(sessionId)) {
648
+ warn(`loop-engine: the session this page was reloaded for ("${sessionId}") is not in the session list; open it from the list`);
649
+ return;
650
+ }
651
+ try {
652
+ sessions.open(sessionId);
653
+ } catch (error2) {
654
+ warn(`loop-engine: could not reopen session "${sessionId}" after the reload: ${String(error2)}`);
655
+ }
656
+ };
657
+ unsubscribe = sessions.list.subscribe(settle);
658
+ settle();
659
+ return () => {
660
+ unsubscribe?.();
661
+ };
662
+ }
663
+ function installReloadReturn(ctx, page = browserPage(), warn) {
664
+ ctx.inject(["sessions"], (scope) => {
665
+ const sessions = scope.get("sessions");
666
+ if (sessions === void 0) return;
667
+ const report = warn ?? ((message) => {
668
+ scope.logger.warn(message);
669
+ });
670
+ scope.effect(
671
+ () => restoreReloadReturn(sessions, page, report),
672
+ "loop-engine: reopen the session this page was reloaded for"
673
+ );
674
+ });
675
+ }
676
+
677
+ // src/client/session-engine.ts
678
+ var LOOP_ENGINE_REMOTE_NAMESPACE = "loopEngine";
679
+ var LOOP_ENGINE_REMOTE_METHOD = "engine";
680
+ var LOOP_ENGINE_REMOTE_SELECT_METHOD = "select";
681
+ function engineSwitchReady(report) {
682
+ return report !== void 0;
683
+ }
684
+ function switchNeedsReload(current, target) {
685
+ const from = current.kind === "engine" ? current.engine : "in-process";
686
+ return from === "in-process" !== (target === "in-process");
687
+ }
688
+ var LOOP_ENGINE_PACKAGE = "dsh-loop-engine";
689
+ function parseSessionEngine(value) {
690
+ const kind = value?.kind;
691
+ if (kind === "legacy" || kind === "unset") return { kind };
692
+ const engine = value?.engine;
693
+ if (kind === "engine" && isLoopEngineId(engine)) return { kind, engine };
694
+ throw new TypeError(`loop-engine: "${String(kind)}" is not a session engine state`);
695
+ }
696
+ function parseSessionEngineReport(value) {
697
+ const engine = parseSessionEngine(value?.engine);
698
+ const pending = value?.pending;
699
+ if (pending === void 0) return { engine };
700
+ if (!isLoopEngineId(pending)) {
701
+ throw new TypeError(`loop-engine: "${String(pending)}" is not a session engine state`);
702
+ }
703
+ return { engine, pending };
704
+ }
705
+ function parseSessionId(value) {
706
+ const sessionId = value?.sessionId;
707
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
708
+ throw new TypeError("loop-engine: sessionId must be a non-empty string");
709
+ }
710
+ return sessionId;
711
+ }
712
+ function parseSelectRequest(value) {
713
+ const sessionId = parseSessionId(value);
714
+ const engine = value?.engine;
715
+ if (!isLoopEngineId(engine)) {
716
+ throw new TypeError(`loop-engine: engine must be one of ${LOOP_ENGINE_IDS.join(", ")}`);
717
+ }
718
+ return { sessionId, engine };
719
+ }
720
+ function parseSelectResult(value) {
721
+ const outcome = value ?? {};
722
+ if (outcome.ok === true && isLoopEngineId(outcome.engine)) {
723
+ if (outcome.reload !== void 0 && outcome.reload !== true) {
724
+ throw new TypeError("loop-engine: not an engine switch outcome");
725
+ }
726
+ return outcome.reload === true ? { ok: true, engine: outcome.engine, reload: true } : { ok: true, engine: outcome.engine };
727
+ }
728
+ if (outcome.ok === false && typeof outcome.reason === "string") {
729
+ return isLoopEngineRefusalCode(outcome.code) ? { ok: false, code: outcome.code, reason: outcome.reason } : { ok: false, reason: outcome.reason };
730
+ }
731
+ throw new TypeError("loop-engine: not an engine switch outcome");
732
+ }
733
+ function descriptor(method, request, result) {
734
+ return {
735
+ id: `${LOOP_ENGINE_PACKAGE}#${LOOP_ENGINE_REMOTE_NAMESPACE}/${method}`,
736
+ service: LOOP_ENGINE_REMOTE_NAMESPACE,
737
+ namespace: LOOP_ENGINE_REMOTE_NAMESPACE,
738
+ method,
739
+ invocation: { kind: "direct" },
740
+ parameters: [{
741
+ // The wire name is the HOST method's own parameter name: the Gateway
742
+ // derives an unregistered endpoint's signature from the live method
743
+ // (`methodParameterNames`), and refuses args whose fields do not match it.
744
+ name: "request",
745
+ wire: "request",
746
+ source: "json",
747
+ codec: {
748
+ mode: "strict",
749
+ typeSymbol: `${LOOP_ENGINE_PACKAGE}#${LOOP_ENGINE_REMOTE_NAMESPACE}/${method}:request`,
750
+ schema: request
751
+ }
752
+ }],
753
+ result: {
754
+ mode: "strict",
755
+ typeSymbol: `${LOOP_ENGINE_PACKAGE}#${LOOP_ENGINE_REMOTE_NAMESPACE}/${method}:result`,
756
+ schema: result
757
+ }
758
+ };
759
+ }
760
+ var LOOP_ENGINE_REMOTE_CONTRIBUTION = {
761
+ package: LOOP_ENGINE_PACKAGE,
762
+ descriptors: [
763
+ descriptor(
764
+ LOOP_ENGINE_REMOTE_METHOD,
765
+ {
766
+ /** Normalize the one-field request the host's method declares. */
767
+ parse(value) {
768
+ return { sessionId: parseSessionId(value) };
769
+ }
770
+ },
771
+ { parse: parseSessionEngineReport }
772
+ ),
773
+ descriptor(LOOP_ENGINE_REMOTE_SELECT_METHOD, { parse: parseSelectRequest }, { parse: parseSelectResult })
774
+ ]
775
+ };
776
+ var SessionEngineCache = class {
777
+ engines = /* @__PURE__ */ new Map();
778
+ watchers = /* @__PURE__ */ new Map();
779
+ reading = /* @__PURE__ */ new Map();
780
+ /**
781
+ * Monotonic read generation per session. A read started before an
782
+ * invalidation describes the session as it was BEFORE the switch, so however
783
+ * late it settles it must not publish — publishing it would put the old engine
784
+ * back on screen, which is the very misreport this cache exists to prevent.
785
+ */
786
+ generations = /* @__PURE__ */ new Map();
787
+ remote;
788
+ disposed = false;
789
+ /**
790
+ * Attach the mounted Remote namespace. Values asked for before the mount
791
+ * settled are re-read, so a surface that mounted first is not left loading.
792
+ * @param remote - the namespace, or undefined when the mount failed.
793
+ */
794
+ attach(remote) {
795
+ if (this.disposed) return;
796
+ this.remote = remote;
797
+ for (const sessionId of [...this.watchers.keys()]) this.readIfUnknown(sessionId);
798
+ }
799
+ /**
800
+ * The engine report cached for one session, or undefined while the first
801
+ * answer is still in flight (or when the host could not answer at all).
802
+ * @param sessionId - the session whose engine is asked for.
803
+ * @returns the authoritative report, cached by session id.
804
+ */
805
+ read(sessionId) {
806
+ return this.engines.get(sessionId);
807
+ }
808
+ /**
809
+ * Follow one session's engine, re-reading it whenever a session's surfaces
810
+ * appear again.
811
+ *
812
+ * A watch is a surface appearing for this session, and that is the moment the
813
+ * cached answer can be stale in a way nothing else reports: a session whose
814
+ * surfaces all went away and came back — the page switched to another session
815
+ * and back — must not render what the page cached before. A re-mount is not
816
+ * what moves a session's engine (a switch is performed on the host, and either
817
+ * swaps the agent in place or releases the session and reloads this page), but
818
+ * the page's answer is old by then, and a surface that rendered it as current
819
+ * would be the one lying about what runs.
820
+ *
821
+ * Only the FIRST watcher of a session triggers the re-read, which is what
822
+ * makes this "the session's surfaces appeared" rather than "a component
823
+ * rendered": the chip and the composer are two watchers of one session, and
824
+ * the second joins the read the first one started instead of sending its own.
825
+ * While a session keeps its surfaces, nothing here asks again — the answer only
826
+ * changes through {@link invalidate} (a committed switch) or a later re-mount.
827
+ * @param sessionId - the session to follow.
828
+ * @param listener - called whenever that session's cached state changes.
829
+ * @returns the unsubscribe.
830
+ */
831
+ watch(sessionId, listener) {
832
+ const listeners = this.watchers.get(sessionId) ?? /* @__PURE__ */ new Set();
833
+ const appearing = listeners.size === 0;
834
+ listeners.add(listener);
835
+ this.watchers.set(sessionId, listeners);
836
+ if (appearing) this.refresh(sessionId);
837
+ return () => {
838
+ if (this.watchers.get(sessionId) !== listeners) return;
839
+ listeners.delete(listener);
840
+ if (listeners.size === 0) this.watchers.delete(sessionId);
841
+ };
842
+ }
843
+ /**
844
+ * Ask the host for one session's engine report again, superseding nothing.
845
+ *
846
+ * This is the way a surface that has just been rendered asks "is what this page
847
+ * cached still what runs?" — the answer that can go stale without anyone
848
+ * invalidating it, because the page is not the only thing that can move a
849
+ * session (another window, a hot swap elsewhere, a plugin reload).
850
+ * A read already in flight for that session is joined rather than replaced (it
851
+ * was started no earlier than this call and describes the same moment), and the
852
+ * cached answer is kept until the new one lands, so a re-read never blinks the
853
+ * chip back to "reading". Use {@link invalidate} instead for an answer that is
854
+ * known to be WRONG.
855
+ * @param sessionId - the session whose answer should be re-read.
856
+ */
857
+ refresh(sessionId) {
858
+ if (this.disposed) return;
859
+ if (this.reading.has(sessionId)) return;
860
+ this.start(sessionId);
861
+ }
862
+ /**
863
+ * Forget one session's cached engine and ask the host again.
864
+ *
865
+ * A successful engine switch changes what the host answers for that session,
866
+ * so the picker MUST come through here: without it the composer would keep
867
+ * showing the engine the session no longer runs. Any read already in flight
868
+ * for that session is superseded and can no longer publish.
869
+ * @param sessionId - the session whose answer is now stale.
870
+ */
871
+ invalidate(sessionId) {
872
+ this.engines.delete(sessionId);
873
+ this.generations.set(sessionId, (this.generations.get(sessionId) ?? 0) + 1);
874
+ this.reading.delete(sessionId);
875
+ this.publish(sessionId);
876
+ this.readIfUnknown(sessionId);
877
+ }
878
+ /** Drop every cached answer and notification (plugin unload). */
879
+ dispose() {
880
+ this.disposed = true;
881
+ this.remote = void 0;
882
+ this.engines.clear();
883
+ this.reading.clear();
884
+ this.generations.clear();
885
+ this.watchers.clear();
886
+ }
887
+ /**
888
+ * Ask unless the session is already answered, being read, or unanswerable.
889
+ *
890
+ * For the paths that need an answer and have none: the plugin's mount settling
891
+ * (a surface asked before the Remote existed), a session whose cached answer
892
+ * was just dropped — and never a re-read of an answer this page already holds,
893
+ * which is {@link refresh}'s job and only a freshly rendered surface may ask
894
+ * for.
895
+ */
896
+ readIfUnknown(sessionId) {
897
+ if (this.engines.has(sessionId) || this.reading.has(sessionId)) return;
898
+ this.start(sessionId);
899
+ }
900
+ /**
901
+ * Start one read for a session, unless there is nothing to read it with.
902
+ * @param sessionId - the session to read.
903
+ */
904
+ start(sessionId) {
905
+ const remote = this.remote;
906
+ if (this.disposed || remote === void 0) return;
907
+ const generation = (this.generations.get(sessionId) ?? 0) + 1;
908
+ this.generations.set(sessionId, generation);
909
+ const read = this.ask(remote, sessionId, generation);
910
+ this.reading.set(sessionId, read);
911
+ void read.then(() => {
912
+ if (this.reading.get(sessionId) === read) this.reading.delete(sessionId);
913
+ });
914
+ }
915
+ /**
916
+ * One read, quiet on failure: a session the host cannot answer for stays
917
+ * unknown, and the surfaces keep their neutral state instead of claiming an
918
+ * engine. A refusal is not cached as an answer, so the next watch re-asks.
919
+ * @param remote - the mounted namespace.
920
+ * @param sessionId - the session asked about.
921
+ * @param generation - the read generation this answer belongs to.
922
+ */
923
+ async ask(remote, sessionId, generation) {
924
+ let result;
925
+ try {
926
+ result = await remote.engine({ sessionId });
927
+ } catch {
928
+ return;
929
+ }
930
+ if (!result.ok || this.disposed || this.generations.get(sessionId) !== generation) return;
931
+ let report;
932
+ try {
933
+ report = parseSessionEngineReport(result.value);
934
+ } catch {
935
+ return;
936
+ }
937
+ this.engines.set(sessionId, report);
938
+ this.publish(sessionId);
939
+ }
940
+ /**
941
+ * Notify one session's watchers.
942
+ *
943
+ * Nothing is painted here. The session's engine shows up in the chat
944
+ * turn-status row too, but that row hangs off a DOCUMENT-level attribute, whose
945
+ * only entitled owner is the session on screen — and a publish says nothing
946
+ * about what is on screen (this is called for every session a surface has ever
947
+ * watched, and for a session the user has already left, when its late answer
948
+ * lands). So painting here let a background session take the attribute over
949
+ * from the session on screen. The surfaces reflect instead, with the focus
950
+ * guard in `./turn-status.ts`; this is the notification the chip and the
951
+ * composer wake up on.
952
+ */
953
+ publish(sessionId) {
954
+ for (const listener of this.watchers.get(sessionId) ?? []) listener();
955
+ }
956
+ };
957
+ function createSessionEngineCache(ctx) {
958
+ const cache = new SessionEngineCache();
959
+ ctx.inject(["remote"], (remoteCtx) => {
960
+ const remote = remoteCtx.get("remote");
961
+ if (remote === void 0) return;
962
+ remote.$mount(LOOP_ENGINE_REMOTE_CONTRIBUTION).then(
963
+ () => {
964
+ cache.attach(remoteCtx.get(`remote.${LOOP_ENGINE_REMOTE_NAMESPACE}`));
965
+ },
966
+ (error2) => {
967
+ ctx.logger.warn(`loop-engine: could not mount the session engine Remote: ${String(error2)}`);
968
+ }
969
+ );
970
+ });
971
+ ctx.effect(() => () => {
972
+ cache.dispose();
973
+ }, "loop-engine: session engine cache");
974
+ return cache;
975
+ }
976
+ function sessionEngineSwitcher(ctx, onSwitched, page = browserPage()) {
977
+ return async (sessionId, engine) => {
978
+ const remote = ctx.get(`remote.${LOOP_ENGINE_REMOTE_NAMESPACE}`);
979
+ if (remote === void 0) return { ok: false, kind: "unavailable" };
980
+ let result;
981
+ try {
982
+ result = await remote.select({ sessionId, engine });
983
+ } catch (error2) {
984
+ return { ok: false, kind: "refused", reason: error2 instanceof Error ? error2.message : String(error2) };
985
+ }
986
+ if (!result.ok) {
987
+ return { ok: false, kind: "refused", reason: result.error.message };
988
+ }
989
+ if (!result.value.ok) {
990
+ return result.value.code === void 0 ? { ok: false, kind: "refused", reason: result.value.reason } : { ok: false, kind: "refused", reason: result.value.reason, code: result.value.code };
991
+ }
992
+ onSwitched?.(sessionId);
993
+ if (result.value.reload !== true) return { ok: true };
994
+ armReloadReturn(page, sessionId);
995
+ page.reload();
996
+ return { ok: true, reload: true };
997
+ };
250
998
  }
251
999
 
252
1000
  // src/client/LoopEngineComposerSelect.tsx
253
- var import_react2 = require("react");
254
- var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives");
255
1001
  var import_jsx_runtime3 = require("react/jsx-runtime");
256
1002
  var ENGINE_OPTIONS2 = [
257
1003
  { value: "in-process", key: "engineInProcess" },
@@ -260,19 +1006,14 @@ var ENGINE_OPTIONS2 = [
260
1006
  { value: "pi", key: "enginePi" },
261
1007
  { value: "kimi", key: "engineKimi" }
262
1008
  ];
263
- function engineLabelKey2(engine) {
264
- switch (engine) {
265
- case "claude-code":
266
- return "engineClaudeCode";
267
- case "codex":
268
- return "engineCodex";
269
- case "pi":
270
- return "enginePi";
271
- case "kimi":
272
- return "engineKimi";
273
- default:
274
- return "engineInProcess";
275
- }
1009
+ var READING = { label: "engineLoading", engine: void 0, selectedId: void 0 };
1010
+ function triggerFace(report) {
1011
+ const { engine: state, pending } = report;
1012
+ const face = state.kind === "engine" ? { label: engineLabelKey2(state.engine), engine: state.engine, selectedId: state.engine } : { label: engineStateLabelKey(state), engine: void 0, selectedId: void 0 };
1013
+ return pending === void 0 ? face : { ...face, pending };
1014
+ }
1015
+ function defaultFace(fallback) {
1016
+ return { label: engineLabelKey2(fallback), engine: fallback, selectedId: fallback };
276
1017
  }
277
1018
  function engineGlyph(engine, size = 16) {
278
1019
  switch (engine) {
@@ -331,40 +1072,88 @@ var chevron = {
331
1072
  transition: "transform 120ms ease"
332
1073
  };
333
1074
  var chevronOpen = { ...chevron, transform: "rotate(180deg)" };
334
- var confirmBody2 = {
1075
+ var rowPendingMark = {
1076
+ marginLeft: 4,
1077
+ color: "var(--dsw-alias-label-caption)",
1078
+ fontSize: 12
1079
+ };
1080
+ var noticeBody = {
335
1081
  margin: 0,
336
1082
  fontSize: 13,
337
1083
  lineHeight: 1.55,
338
1084
  color: "var(--dsw-alias-label-secondary)"
339
1085
  };
1086
+ var noticeDetail = {
1087
+ margin: 0,
1088
+ fontSize: 12,
1089
+ lineHeight: 1.5,
1090
+ color: "var(--dsw-alias-label-tertiary)",
1091
+ wordBreak: "break-word"
1092
+ };
340
1093
  function LoopEngineComposerSelect(props) {
341
- const { controller, useSnapshot, t } = props;
342
- const { status, engine, showInComposer, writable } = useSnapshot((snapshot) => snapshot);
343
- const [open, setOpen] = (0, import_react2.useState)(false);
344
- const [hovered, setHovered] = (0, import_react2.useState)(false);
345
- const [pending, setPending] = (0, import_react2.useState)(null);
346
- const triggerRef = (0, import_react2.useRef)(null);
1094
+ const { controller, useSnapshot, sessionId, sessionEngines, switchEngine, t } = props;
1095
+ const { status, engine: defaultEngine, showInComposer, writable } = useSnapshot((snapshot) => snapshot);
1096
+ const resolved = useEngineOfSession(sessionEngines, sessionId);
1097
+ const { label, engine, selectedId, pending } = sessionId === void 0 ? defaultFace(defaultEngine) : resolved === void 0 ? READING : triggerFace(resolved);
1098
+ const switchReady = sessionId === void 0 || engineSwitchReady(resolved);
1099
+ const [open, setOpen] = (0, import_react3.useState)(false);
1100
+ const [hovered, setHovered] = (0, import_react3.useState)(false);
1101
+ const [notice2, setNotice] = (0, import_react3.useState)(null);
1102
+ const [pendingReload, setPendingReload] = (0, import_react3.useState)(null);
1103
+ const triggerRef = (0, import_react3.useRef)(null);
347
1104
  if (status !== "ready" || !showInComposer) return null;
348
- const disabled = !writable;
349
- const label = t(engineLabelKey2(engine));
350
- const title = engine === "claude-code" ? t("claudeModelNotice") : t("description");
1105
+ const disabled = !writable || !switchReady;
1106
+ const labelText = t(label);
1107
+ const pendingText = pending === void 0 ? void 0 : ` \xB7 ${pendingEngineText(t, pending)}`;
1108
+ const title = pending !== void 0 ? t("pendingComposerHint") : isHostedEngine(engine) ? t("hostedEngineModelNotice") : sessionId === void 0 ? t("description") : t("composerHint");
1109
+ const commitSwitch = (value) => {
1110
+ if (sessionId === void 0) {
1111
+ void controller.setEngine(value);
1112
+ return;
1113
+ }
1114
+ void switchEngine(sessionId, value).then((result) => {
1115
+ if (!result.ok) {
1116
+ if (result.kind === "unavailable") {
1117
+ setNotice({ title: "switchFailedTitle", body: t("switchUnavailable") });
1118
+ return;
1119
+ }
1120
+ const face = refusalFace(result.code);
1121
+ setNotice({
1122
+ title: "switchFailedTitle",
1123
+ body: face.body === void 0 ? result.reason : t(face.body),
1124
+ ...face.detail ? { detail: result.reason } : {}
1125
+ });
1126
+ return;
1127
+ }
1128
+ if (result.reload === true) {
1129
+ setNotice({ title: "switchReloadTitle", body: t("switchReloadBody") });
1130
+ return;
1131
+ }
1132
+ });
1133
+ };
351
1134
  const onSelect = (next) => {
352
1135
  setOpen(false);
353
1136
  const value = next;
354
- if (value === engine) return;
355
- setPending(value);
356
- };
357
- const confirmSwitch = () => {
358
- const value = pending;
359
- setPending(null);
360
- if (value !== null) {
361
- void controller.setEngine(value).then((landed) => {
362
- if (landed) window.location.reload();
363
- });
1137
+ if (value === selectedId) return;
1138
+ if (sessionId !== void 0) {
1139
+ if (!engineSwitchReady(resolved)) return;
1140
+ if (switchNeedsReload(resolved.engine, value)) {
1141
+ setPendingReload(value);
1142
+ return;
1143
+ }
364
1144
  }
1145
+ commitSwitch(value);
365
1146
  };
366
- const cancelSwitch = () => {
367
- setPending(null);
1147
+ const confirmReload = () => {
1148
+ const value = pendingReload;
1149
+ setPendingReload(null);
1150
+ if (value !== null) commitSwitch(value);
1151
+ };
1152
+ const cancelReload = () => {
1153
+ setPendingReload(null);
1154
+ };
1155
+ const dismissNotice = () => {
1156
+ setNotice(null);
368
1157
  };
369
1158
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
370
1159
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -376,10 +1165,18 @@ function LoopEngineComposerSelect(props) {
376
1165
  },
377
1166
  items: ENGINE_OPTIONS2.map((option) => ({
378
1167
  id: option.value,
379
- label: t(option.key),
1168
+ // The recorded engine's own row carries the marker too: the trigger may
1169
+ // be truncated to ellipsis, and a user who opens the list has to be
1170
+ // able to see which row was recorded instead of concluding the pick
1171
+ // did nothing. The check mark stays on the row in force (selectedId),
1172
+ // which is never the marked one.
1173
+ label: option.value === pending ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
1174
+ t(option.key),
1175
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: rowPendingMark, children: t("engineMenuPendingSuffix") })
1176
+ ] }) : t(option.key),
380
1177
  icon: engineGlyph(option.value)
381
1178
  })),
382
- selectedId: engine,
1179
+ selectedId,
383
1180
  onSelect,
384
1181
  align: "start",
385
1182
  portal: true,
@@ -404,8 +1201,11 @@ function LoopEngineComposerSelect(props) {
404
1201
  setOpen(!open);
405
1202
  },
406
1203
  children: [
407
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: triggerIcon, "aria-hidden": true, children: engineGlyph(engine, 14) }),
408
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: triggerLabel, children: label }),
1204
+ engine === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: triggerIcon, "aria-hidden": true, children: engineGlyph(engine, 14) }),
1205
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { style: triggerLabel, children: [
1206
+ labelText,
1207
+ pendingText
1208
+ ] }),
409
1209
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: open ? chevronOpen : chevron, "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_dsh_client_ui_primitives2.IconChevronDownOutline14, { size: 14 }) })
410
1210
  ]
411
1211
  }
@@ -415,15 +1215,29 @@ function LoopEngineComposerSelect(props) {
415
1215
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
416
1216
  import_dsh_client_ui_primitives2.Modal,
417
1217
  {
418
- open: pending !== null,
419
- onClose: cancelSwitch,
420
- title: t("confirmTitle"),
421
- closeLabel: t("closeLabel"),
1218
+ open: pendingReload !== null,
1219
+ onClose: cancelReload,
1220
+ title: t("switchReloadConfirmTitle"),
1221
+ closeLabel: t("cancelAction"),
422
1222
  footer: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
423
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_dsh_client_ui_primitives2.Button, { variant: "outline", onClick: cancelSwitch, children: t("cancelAction") }),
424
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_dsh_client_ui_primitives2.Button, { variant: "primary", onClick: confirmSwitch, children: t("confirmAction") })
1223
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_dsh_client_ui_primitives2.Button, { variant: "outline", onClick: cancelReload, children: t("cancelAction") }),
1224
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_dsh_client_ui_primitives2.Button, { variant: "primary", onClick: confirmReload, children: t("switchReloadConfirmAction") })
425
1225
  ] }),
426
- children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: confirmBody2, children: t("confirmBody") })
1226
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: noticeBody, children: t("switchReloadConfirmBody") })
1227
+ }
1228
+ ),
1229
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
1230
+ import_dsh_client_ui_primitives2.Modal,
1231
+ {
1232
+ open: notice2 !== null,
1233
+ onClose: dismissNotice,
1234
+ title: t(notice2?.title ?? "switchFailedTitle"),
1235
+ closeLabel: t("closeLabel"),
1236
+ footer: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_dsh_client_ui_primitives2.Button, { variant: "primary", onClick: dismissNotice, children: t("closeLabel") }),
1237
+ children: [
1238
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: noticeBody, children: notice2?.body }),
1239
+ notice2?.detail === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { style: noticeDetail, children: notice2.detail })
1240
+ ]
427
1241
  }
428
1242
  )
429
1243
  ] });
@@ -565,203 +1379,6 @@ var LoopEngineStore = class {
565
1379
  }
566
1380
  };
567
1381
 
568
- // src/client/turn-status.ts
569
- var ENGINE_ATTR = "data-loop-engine";
570
- var PLUGIN_ID = "dsh-loop-engine";
571
- var STYLESHEET = `
572
- [class$="_turnStatus"]::before {
573
- margin-right: 6px;
574
- background: none;
575
- -webkit-background-clip: border-box;
576
- background-clip: border-box;
577
- }
578
-
579
- /*
580
- * The row's own sweep, re-asserted for hosted engines.
581
- *
582
- * ui-chat disables that animation under \`prefers-reduced-motion: reduce\`
583
- * (ChatView.module.css), and a media query carries no specificity \u2014 so this
584
- * attribute-gated rule outranks it. That is deliberate here: deployment images
585
- * ship with Windows' client-area animation off (SPI_GETCLIENTAREAANIMATION
586
- * false), and with the guard in force EVERY indicator on this row is frozen \u2014
587
- * the sweep and the glyph alike. Scoped to hosted engines, so in-process
588
- * sessions keep the stock reduced-motion behaviour; delete this rule and
589
- * restore the guard at the foot of the sheet to hand the decision back to the OS.
590
- */
591
- html[${ENGINE_ATTR}] [class$="_turnStatus"] {
592
- background-position: 100% 0;
593
- background-size: 250% 100%;
594
- animation: le-shimmer 1.8s linear infinite;
595
- }
596
-
597
- @keyframes le-shimmer {
598
- to { background-position: 0 0; }
599
- }
600
-
601
- html[${ENGINE_ATTR}="claude-code"] [class$="_turnStatus"] {
602
- --dsw-static-deepseek-500: #d97757;
603
- --dsw-static-deepseek-200: #f5bda6;
604
- }
605
- html[${ENGINE_ATTR}="claude-code"] [class$="_turnStatus"]::before {
606
- content: "\u273B";
607
- color: #d97757;
608
- -webkit-text-fill-color: #d97757;
609
- animation: le-bloom 1.6s ease-in-out infinite;
610
- }
611
-
612
- html[${ENGINE_ATTR}="codex"] [class$="_turnStatus"] {
613
- --dsw-static-deepseek-500: #a9b1c0;
614
- --dsw-static-deepseek-200: #e6eaf2;
615
- }
616
- html[${ENGINE_ATTR}="codex"] [class$="_turnStatus"]::before {
617
- content: "\u2022";
618
- color: #a9b1c0;
619
- -webkit-text-fill-color: #a9b1c0;
620
- text-shadow: 0 0 6px currentColor;
621
- animation: le-pulse 1.4s ease-in-out infinite;
622
- }
623
-
624
- html[${ENGINE_ATTR}="pi"] [class$="_turnStatus"] {
625
- --dsw-static-deepseek-500: #8e4ec6;
626
- --dsw-static-deepseek-200: #d6bff0;
627
- }
628
- html[${ENGINE_ATTR}="pi"] [class$="_turnStatus"]::before {
629
- content: "\u280B";
630
- color: #8e4ec6;
631
- -webkit-text-fill-color: #8e4ec6;
632
- font-size: 1.1em;
633
- animation: le-braille 1s linear infinite;
634
- }
635
-
636
- html[${ENGINE_ATTR}="kimi"] [class$="_turnStatus"] {
637
- --dsw-static-deepseek-500: #e5484d;
638
- --dsw-static-deepseek-200: #f5b2b4;
639
- }
640
- html[${ENGINE_ATTR}="kimi"] [class$="_turnStatus"]::before {
641
- content: "\u{1F317}";
642
- color: #e5484d;
643
- -webkit-text-fill-color: #e5484d;
644
- animation: le-moon 2.5s linear infinite;
645
- }
646
-
647
- /* Grows from small to large each cycle \u2014 the opposite of a twinkle. The range
648
- is deliberately wide (3x) because a bare size change on a thin glyph reads
649
- weakly otherwise, and the large state is held briefly (50%-62%) so it blooms
650
- rather than throbs. 1.35x extends ~2.5px per side at the 14px glyph, inside
651
- the 6px ::before margin. */
652
- @keyframes le-bloom {
653
- 0%, 100% { transform: scale(0.45); opacity: 0.45; }
654
- 50%, 62% { transform: scale(1.35); opacity: 1; }
655
- }
656
- /* A terminal braille spinner. The glyph is an ordinary text character, not an
657
- emoji, so the engine color actually paints \u2014 a colored emoji silently ignores
658
- color/-webkit-text-fill-color. Stepping content walks the ten dot frames. */
659
- @keyframes le-braille {
660
- 0% { content: "\u280B"; }
661
- 10% { content: "\u2819"; }
662
- 20% { content: "\u2839"; }
663
- 30% { content: "\u2838"; }
664
- 40% { content: "\u283C"; }
665
- 50% { content: "\u2834"; }
666
- 60% { content: "\u2826"; }
667
- 70% { content: "\u2827"; }
668
- 80% { content: "\u2807"; }
669
- 90% { content: "\u280F"; }
670
- 100% { content: "\u280B"; }
671
- }
672
- /* A soft pulse for the codex dot: the glyph breathes between a small, dim
673
- point and a larger, full-brightness one \u2014 a light dot, not a spinner. */
674
- @keyframes le-pulse {
675
- 0%, 100% { transform: scale(0.5); opacity: 0.35; }
676
- 50% { transform: scale(1.3); opacity: 1; }
677
- }
678
- /* Moon phases rather than a rigid rotation: a spinning moon bitmap can only
679
- squash and mirror itself, never show a full or a new moon. Stepping the
680
- glyph through the phase set sweeps the lit edge across the disc AND actually
681
- reaches \u{1F315} and \u{1F311}. The order runs waning (full \u2192 new), so the lit edge
682
- retreats right-to-left \u2014 the direction the user picked. */
683
- @keyframes le-moon {
684
- 0% { content: "\u{1F315}"; }
685
- 12.5% { content: "\u{1F314}"; }
686
- 25% { content: "\u{1F313}"; }
687
- 37.5% { content: "\u{1F312}"; }
688
- 50% { content: "\u{1F311}"; }
689
- 62.5% { content: "\u{1F318}"; }
690
- 75% { content: "\u{1F317}"; }
691
- 87.5% { content: "\u{1F316}"; }
692
- 100% { content: "\u{1F315}"; }
693
- }
694
-
695
- `;
696
- function reflect(engine, settled) {
697
- const root = document.documentElement;
698
- if (!settled || engine === "in-process") {
699
- delete root.dataset.loopEngine;
700
- return;
701
- }
702
- root.dataset.loopEngine = engine;
703
- }
704
- function installTurnStatusStyles(ctx, store) {
705
- if (typeof document === "undefined") return;
706
- ctx.effect(() => {
707
- const tag = document.createElement("style");
708
- tag.dataset.plugin = PLUGIN_ID;
709
- tag.dataset.pluginCss = `${PLUGIN_ID}/turn-status.css`;
710
- tag.textContent = STYLESHEET;
711
- document.head.appendChild(tag);
712
- return () => {
713
- tag.remove();
714
- delete document.documentElement.dataset.loopEngine;
715
- };
716
- }, "loop-engine: per-engine turn status styles");
717
- const sync = () => {
718
- const { status, engine } = store.getSnapshot();
719
- reflect(engine, status === "ready");
720
- };
721
- ctx.effect(() => store.subscribe(sync), "loop-engine: turn status engine reflection");
722
- sync();
723
- }
724
-
725
- // src/client/locales.ts
726
- var zh = {
727
- nav: "\u5FAA\u73AF\u5F15\u64CE",
728
- description: "\u9009\u62E9\u9A71\u52A8\u4F1A\u8BDD\u7684 Agent \u6267\u884C\u5F15\u64CE\uFF0C\u5207\u6362\u5BF9\u540E\u7EED\u4F1A\u8BDD\u751F\u6548\u3002",
729
- engineInProcess: "\u8FDB\u7A0B\u5185\u5F15\u64CE\uFF08\u9ED8\u8BA4\uFF09",
730
- engineClaudeCode: "Claude Code CLI",
731
- engineCodex: "Codex CLI",
732
- enginePi: "Pi CLI",
733
- engineKimi: "Kimi Code CLI",
734
- showInComposerLabel: "\u5728\u5BF9\u8BDD\u9875\u663E\u793A\u5F15\u64CE\u9009\u62E9\u5668",
735
- unavailable: "\u5FAA\u73AF\u5F15\u64CE\u8BBE\u7F6E\u4E0D\u53EF\u7528",
736
- switchNotice: "\u5207\u6362\u5F15\u64CE\u4F1A\u4E2D\u65AD\u5F53\u524D\u4F7F\u7528\u65E7\u5F15\u64CE\u8FD0\u884C\u4E2D\u7684\u4F1A\u8BDD\u3002",
737
- saving: "\u4FDD\u5B58\u4E2D\u2026",
738
- confirmTitle: "\u5207\u6362\u5FAA\u73AF\u5F15\u64CE\uFF1F",
739
- confirmBody: "\u5207\u6362\u4F1A\u4E2D\u65AD\u5F53\u524D\u4F7F\u7528\u65E7\u5F15\u64CE\u8FD0\u884C\u4E2D\u7684\u4F1A\u8BDD\uFF0C\u786E\u8BA4\u540E\u9875\u9762\u5C06\u81EA\u52A8\u5237\u65B0\uFF0C\u65B0\u5BF9\u8BDD\u4F7F\u7528\u65B0\u5F15\u64CE\u3002\u786E\u8BA4\u5207\u6362\u5417\uFF1F",
740
- confirmAction: "\u5207\u6362",
741
- cancelAction: "\u53D6\u6D88",
742
- closeLabel: "\u5173\u95ED",
743
- claudeModelNotice: "\u5F53\u524D\u4F7F\u7528 Claude Code \u5F15\u64CE\uFF1A\u5B9E\u9645\u6A21\u578B\u7531 Claude Code \u539F\u751F\u51B3\u5B9A\uFF0C\u9875\u9762\u4E0A\u7684\u6A21\u578B\u9009\u62E9\u4E0D\u751F\u6548\u3002"
744
- };
745
- var en = {
746
- nav: "Loop engine",
747
- description: "Choose the agent execution engine that drives sessions; the switch applies to new turns.",
748
- engineInProcess: "In-process engine (default)",
749
- engineClaudeCode: "Claude Code CLI",
750
- engineCodex: "Codex CLI",
751
- enginePi: "Pi CLI",
752
- engineKimi: "Kimi Code CLI",
753
- showInComposerLabel: "Show the engine selector in the chat page",
754
- unavailable: "Loop engine settings are unavailable",
755
- switchNotice: "Switching engines interrupts sessions currently running on the previous engine.",
756
- saving: "Saving\u2026",
757
- confirmTitle: "Switch loop engine?",
758
- confirmBody: "Switching interrupts sessions currently running on the previous engine; the page reloads after the switch and new turns use the new engine. Switch now?",
759
- confirmAction: "Switch",
760
- cancelAction: "Cancel",
761
- closeLabel: "Close",
762
- claudeModelNotice: "Claude Code engine active: the actual model is decided natively by Claude Code; the model selector in this session has no effect."
763
- };
764
-
765
1382
  // src/namespace.ts
766
1383
  var LOOP_ENGINE_SETTINGS_NAMESPACE_LITERAL = "agent-loop-engine";
767
1384
 
@@ -781,7 +1398,9 @@ function apply(ctx) {
781
1398
  controller.dispose();
782
1399
  };
783
1400
  }, "loop-engine: store lifecycle");
784
- installTurnStatusStyles(ctx, controller.store);
1401
+ installTurnStatusStyles(ctx);
1402
+ const sessionEngines = createSessionEngineCache(ctx);
1403
+ installReloadReturn(ctx);
785
1404
  const t = ctx.locale.bind(NS);
786
1405
  const injected = () => ({
787
1406
  controller,
@@ -796,10 +1415,7 @@ function apply(ctx) {
796
1415
  inject: injected
797
1416
  }, LoopEngineSection));
798
1417
  ctx.inject(["slots", "conversation"], (scope2) => {
799
- const badgeInjected = () => ({
800
- hooks: { snapshot: controller.store },
801
- t
802
- });
1418
+ const badgeInjected = () => ({ sessionEngines, t });
803
1419
  scope2.effect(() => {
804
1420
  return scope2.slots.register({
805
1421
  name: "conversation.session.header.actions",
@@ -812,10 +1428,15 @@ function apply(ctx) {
812
1428
  }, LoopEngineBadge);
813
1429
  }, "loop-engine: session header engine badge");
814
1430
  });
1431
+ const switchEngine = sessionEngineSwitcher(ctx, (sessionId) => {
1432
+ sessionEngines.invalidate(sessionId);
1433
+ });
815
1434
  ctx.inject(["slots", "conversation"], (scope2) => {
816
1435
  const composerInjected = () => ({
817
1436
  controller,
818
1437
  hooks: { snapshot: controller.store },
1438
+ sessionEngines,
1439
+ switchEngine,
819
1440
  t
820
1441
  });
821
1442
  scope2.effect(() => {