magic-agent 2.4.3 → 2.6.0

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +37 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -176,6 +176,16 @@
176
176
  ⚠️ 轮廓是用 Catmull-Rom 由 15 个右半控制点算出来的三次贝塞尔(见 BOTTLE_PROFILE),
177
177
  不是手写路径 —— 手调控制点必然在肩/腹出现折角,看着像塑料件。改形去改那串点,
178
178
  别去改 BOTTLE_CAVITY 的 d。 */
179
+ /* 进选择器 / 参与「首个可用」判定的引擎:探测成功、**已登录**且能列出模型。
180
+ 没登录或取不到模型清单的(如 trae、登出后的 codebuddy-ai)不出现
181
+ (2026-10-25 用户要求:「登录了几个账号就显示几个」)。 */
182
+ function pickableEngines(list) {
183
+ return (list || []).filter(function (en) {
184
+ if (!en || !en.ok) return false;
185
+ if (en.logged_in === false || en.loggedIn === false) return false;
186
+ return (en.models || []).length > 0 || !en.models_note;
187
+ });
188
+ }
179
189
  var BOTTLE_VB = { w: 64, h: 104, x: 16, y: 4 };
180
190
  /* 右半轮廓(x 以 48 为中轴):颈口 → 颈 → 肩 → 腹 → 足 → 底心 */
181
191
  var BOTTLE_PROFILE = [
@@ -376,12 +386,20 @@
376
386
  }
377
387
  /* 选择器里的「一行模型」:左名字、右倍率(`×0.34`)。
378
388
  两列布局用 CSS 的 `.pick__cr` 定宽靠右,名字过长只自己省略 —— 倍率列不跟着抖。
379
- 倍率为空(引擎不给数据)时只画名字,不占位、不显示「×1」这类编出来的值。 */
380
- function modelRow(id, credit) {
389
+ 倍率为空(引擎不给数据)时只画名字,不占位、不显示「×1」这类编出来的值。
390
+ dsh 再补第二层:路由名(同名模型可能挂在多条路由下,只给末段分不出来)。 */
391
+ function modelRow(id, credit, engine, quota) {
392
+ var frag = document.createDocumentFragment();
381
393
  var row = el('span', 'pick__crow');
382
- row.appendChild(el('span', 'pick__cl', qaModelText(id)));
394
+ row.appendChild(el('span', 'pick__cl', qaModelText(id, engine)));
383
395
  if (credit) row.appendChild(el('span', 'pick__cr', credit));
384
- return row;
396
+ frag.appendChild(row);
397
+ /* 第二行:供应商(dsh = 路由名,其余引擎 = model_vendors)与套餐额度合在一行:
398
+ 「MiniMax · 5h 100% · 周 0%」(用户口径:和供应商在一行即可)。 */
399
+ var sub = qaModelRoute(id, engine) || qaModelVendor(state.engines, engine, id);
400
+ var extra = [sub, quota].filter(Boolean).join(" · ");
401
+ if (extra) frag.appendChild(el("span", "pick__cd" + (quota ? " pick__cd--credit" : ""), extra));
402
+ return frag;
385
403
  }
386
404
  function svgIcon(id, size) {
387
405
  var s = document.createElementNS(SVG_NS, 'svg');
@@ -407,7 +425,21 @@
407
425
  var destroyed = false;
408
426
  var state = {
409
427
  engines: opts.engines || [], engine: opts.engine || '', model: opts.model || '',
428
+ /* 主备(2026-10-24):主引擎失败(额度用尽 / 单模型故障)且零产出时,core 自动
429
+ 降级到这一对。空 = 不降级;模型为空 = 备用引擎自己的默认模型。 */
430
+ fallbackEngine: String(opts.fallbackEngine || '').trim(),
431
+ fallbackModel: String(opts.fallbackEngine ? (opts.fallbackModel || '') : '').trim(),
410
432
  permission: opts.permission || '', workspace: opts.workspace || '', attachments: [], session: opts.session || '', busy: false, askId: null,
433
+ /* 执行阶段(2026-10-24 用户:「状态已经显示黄色的是不是算停止了 按钮应该变回提交状态,
434
+ 只有真的运行中状态才是停止」)—— 与 busy 是**两件事**:
435
+ busy = 这条会话**占着一轮**(追问要排队、收尾要等它);
436
+ phase = 这一轮**现在在干什么**:
437
+ idle 空闲(没有在跑,也没有在等)
438
+ running 引擎**真的在执行**(红色停止键 / 绿液瓶 / 绿光边框只属于它)
439
+ waiting 引擎停下来等用户(waiting_approval:提问 / 工具待授权 —— 黄色)
440
+ 以前两者混在一个 busy 里,等审批时按钮也是红的「中断」—— 可引擎根本没在跑,
441
+ 点下去只会把正等着你的一轮直接打断。 */
442
+ phase: 'idle',
411
443
  /* 这一轮选中的插件(2026-10-21 用户:「选择插件后 对话框中要出现一个胶囊
412
444
  然后发送后就是提示词变成要使用这个插件做什么」)。
413
445
  存的是 GET /desk/plugins 那一行**原样**(id/name/cli/usePrompt…),
@@ -477,6 +509,9 @@
477
509
  默认不配置 = 关闭:不请求 / 不画 / 历史不补 / 托管不自动跑)。 */
478
510
  suggestEngine: String(opts.suggestEngine || '').trim(),
479
511
  suggestModel: String(opts.suggestEngine ? (opts.suggestModel || '') : '').trim(),
512
+ /* 建议引擎的主备(2026-10-24):主建议引擎失败时先试它,再落到 core 内置的 llm。 */
513
+ suggestFallbackEngine: String(opts.suggestFallbackEngine || '').trim(),
514
+ suggestFallbackModel: String(opts.suggestFallbackEngine ? (opts.suggestFallbackModel || '') : '').trim(),
480
515
  /* 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」):
481
516
  开着时每个成功收尾的轮次会把第一条(模型排序里的最佳)建议**自动发出去**。
482
517
  hostingRounds = 本次开启以来连续自动发了几轮(上限见 hostingMax,防跑飞);
@@ -755,6 +790,18 @@
755
790
  ? function (p) { return transport.fileUrl(p); } : null);
756
791
  put('pathRead', typeof transport.pathRead === 'function'
757
792
  ? function (req) { return transport.pathRead(req); } : null);
793
+ /* 待授权(计划审批 / 工具授权)的裁决出口(2026-10-25):
794
+ 内核的审批卡点「允许 / 拒绝」时调它,必须走 control 的 ask_answer ——
795
+ 引擎正阻塞在 control_response 上,当成新消息发进去它不读(见 internal/cli/control.go)。 */
796
+ put('askApprove', function (p) {
797
+ var req = p || {};
798
+ return control('ask_answer', null, {
799
+ request_id: req.request_id || '',
800
+ allowed: !!req.allowed,
801
+ message: req.message || '',
802
+ tool_name: req.tool_name || '',
803
+ });
804
+ });
758
805
  })();
759
806
 
760
807
  /* 本轮产物:与 PC 工作台 / magic-test 用**同一套**(内核 qaTurnArtsTrack,见 19-attachments.js)——
@@ -779,6 +826,15 @@
779
826
  if (el.parentNode) el.parentNode.removeChild(el);
780
827
  }
781
828
 
829
+ /* 事件类型两份清单(执行阶段与日志末尾定档共用,见 lastTurnInfo):
830
+ · 终态 —— 这一轮到此结束。与 internal/client/service.go 的 isTerminalEvent 同口径,
831
+ 另加 interrupted / stopped(打断 / 收工:事件流上它们先到,之后不一定再有终帧)。
832
+ · 执行 —— 引擎真的在吐增量;停在等(waiting)的时候由它们翻回 running。 */
833
+ var TERMINAL_KINDS = { turn_end: 1, turn_failed: 1, result: 1, error: 1, end: 1, interrupted: 1, stopped: 1 };
834
+ var EXEC_KINDS = { text: 1, thinking: 1, tool_use: 1, tool_call: 1, tool_result: 1,
835
+ image: 1, notice: 1, command: 1, tasks: 1, start: 1, user: 1,
836
+ answered: 1, answer_queued: 1 };
837
+
782
838
  function kernelApply(ev) {
783
839
  if (!ev || typeof ev !== 'object') return;
784
840
  var t = ev.type || ev.kind;
@@ -791,13 +847,32 @@
791
847
  进程退出时的 `end` 那条兜底收尾。 */
792
848
  if (t === 'turn_end' && !String(ev.text == null ? '' : ev.text).trim() && !ev.item_id && !turnHasContent(state.qa && state.qa.h)) return;
793
849
  var e = ev.type ? ev : Object.assign({}, ev, { type: t });
850
+ /* 待授权(计划审批 / 工具授权)是**现场**裁决:回放时那一轮 CLI 早就不等了,
851
+ 再画一张「允许 / 拒绝」只会给用户一个点了必然失败的按钮
852
+ (ask_answer 找不到对应的 control_request)。提问卡不受此限 ——
853
+ 历史里的提问点一下仍能把答案作为追问发进那条会话。 */
854
+ if (state.replay && t === 'ask' && e.ask && e.ask.kind === 'permission') return;
794
855
  if (t === 'start') {
795
- /* 一轮真正用的引擎 / 模型(配置里的模型链会自动降级)→ 选择器跟着改口 */
796
- if (e.engine) state.engine = String(e.engine);
856
+ /* 一轮真正用的引擎 / 模型(配置里的模型链会自动降级)→ 选择器跟着改口。
857
+ 历史会话锁:start 里的 engine 不得改写归属(模型链降级会带新 engine),
858
+ 锁定态只采纳 model。 */
859
+ if (e.engine && !engineLockReason()) state.engine = String(e.engine);
797
860
  if (e.model) state.model = String(e.model);
798
861
  syncPickers();
799
862
  }
800
863
  if (t === 'ask') { state.awaitingAnswer = true; state.qaHadAsk = true; } // 引擎在等这一问的答案(见 MagicHost.send)
864
+ /* 执行阶段推进(口径与 core 自己的状态机一致:internal/cli/ask.go ——
865
+ ask → waiting_approval;其后任何非终帧 → 又在跑)。
866
+ ⚠️ 回放(state.replay)不在这里翻:回放的档位由 loadHistory 末尾按
867
+ lastTurnInfo().waiting 一次定死。
868
+ ⚠️ 终态翻 idle 后再来的迟到 EXEC 帧(收尾后才到的 tool_result / notice)
869
+ 不会点亮任何东西:is-running 的判据是 busy && phase==='running'(见 setSendMode),
870
+ 终态已把 busy 放开,setPhase 重画时走的是 idle 模式 —— 相位值脏一点无害。 */
871
+ if (!state.replay) {
872
+ if (t === 'ask') setPhase('waiting');
873
+ else if (TERMINAL_KINDS[t]) setPhase('idle');
874
+ else if (EXEC_KINDS[t]) setPhase('running');
875
+ }
801
876
  /* 画布上已经有东西了 → 撤掉那句空态说明(它只在一条都还没画时该在) */
802
877
  var em = document.getElementById('qa-stream');
803
878
  var empty = em && em.querySelector('.conv__empty');
@@ -917,9 +992,11 @@
917
992
  whoModelOnly: true,
918
993
  onError: function (e) {
919
994
  /* 显示「根因」优先:`reason` 是 CLI 给的最内层原因(429 / 额度 / 认证),
920
- `error` 是带引擎包装的完整链 —— 前 80 字常被包装词吃掉。 */
995
+ `error` 是带引擎包装的完整链 —— 前 80 字常被包装词吃掉。
996
+ 把整份错误信封(e)作为 detail 传给收尾卡,卡片会取 reason 做根因、
997
+ 取 error 做可展开的完整链。 */
921
998
  var why = String((e && e.reason) || '').trim();
922
- kernelDone(false, '调用失败 · ' + (why ? why.slice(0, 200) : String((e && e.error) || '未知错误').slice(0, 80)));
999
+ kernelDone(false, '调用失败 · ' + (why ? why.slice(0, 200) : String((e && e.error) || '未知错误').slice(0, 80)), e);
923
1000
  },
924
1001
  onDone: function (e) { kernelDone(true, (e && e.stopped) ? '已停止' : '已完成'); },
925
1002
  };
@@ -1029,7 +1106,10 @@
1029
1106
  engine 有值、model 为空 = 引擎默认(llm 会挑不思考变体,约 1-2s);选 dsh/codebuddy
1030
1107
  则是它们自己的无工具 JSON 通道(实测 6-10s,用户自己承担)。 */
1031
1108
  engine: state.suggestEngine,
1032
- model: state.suggestModel
1109
+ model: state.suggestModel,
1110
+ /* 建议引擎的主备:core 先试这一对,再落到内置 llm。 */
1111
+ fallbackEngine: state.suggestFallbackEngine || undefined,
1112
+ fallbackModel: state.suggestFallbackModel || undefined
1033
1113
  }, { signal: ac ? ac.signal : undefined }))
1034
1114
  .then(function (res) {
1035
1115
  if (seq !== state.suggestSeq) return; // 已经开新轮 / 换会话 / 又收尾了
@@ -1258,13 +1338,19 @@
1258
1338
  /** 宿主改「建议引擎」:空 = 关闭建议功能。
1259
1339
  * 关闭时立刻清掉建议行 / 在途请求 / 托管计时器,并暂停托管(回调宿主落盘)。
1260
1340
  * 开启后从**下一轮**收尾开始生成;不追着已经画过的旧轮补请求。 */
1261
- function setSuggestEngine(engine, model) {
1341
+ function setSuggestEngine(engine, model, fallbackEngine, fallbackModel) {
1262
1342
  var next = String(engine == null ? '' : engine).trim();
1263
1343
  var nextModel = String(model == null ? '' : model).trim();
1264
1344
  if (!next) nextModel = ''; // 引擎关了就不留孤儿模型
1265
- if (next === state.suggestEngine && nextModel === state.suggestModel) return;
1345
+ var nextFb = String(fallbackEngine == null ? '' : fallbackEngine).trim();
1346
+ var nextFbModel = String(fallbackModel == null ? '' : fallbackModel).trim();
1347
+ if (!nextFb) nextFbModel = '';
1348
+ if (next === state.suggestEngine && nextModel === state.suggestModel
1349
+ && nextFb === state.suggestFallbackEngine && nextFbModel === state.suggestFallbackModel) return;
1266
1350
  state.suggestEngine = next;
1267
1351
  state.suggestModel = nextModel;
1352
+ state.suggestFallbackEngine = nextFb;
1353
+ state.suggestFallbackModel = nextFbModel;
1268
1354
  clearSuggest();
1269
1355
  if (!next) pauseHosting('未配置建议引擎');
1270
1356
  }
@@ -1325,7 +1411,7 @@
1325
1411
 
1326
1412
  /** 一轮收尾(成功 / 失败 / 被停):内核的收尾动作已经做完(收思考区 / 工具卡 / 运行胶囊),
1327
1413
  * 这一层只补**收尾行**(状态语 + 总耗时,取墙上时钟 —— 与掌天瓶那边同一口径)。 */
1328
- function kernelDone(ok, label) {
1414
+ function kernelDone(ok, label, detail) {
1329
1415
  /* 销毁后迟到的终态:这一轮已经不属于任何画面了 —— 收尾行、托管判定、建议请求
1330
1416
  一条都不该再跑(它们会写进新实例的画布,或替新实例发下一句)。 */
1331
1417
  if (destroyed) return;
@@ -1353,7 +1439,7 @@
1353
1439
  (2026-10-20 用户截图:空块 + 「已完成 · 总耗时 1.8s」)。收尾行本身挂在线程容器上,
1354
1440
  不在这条消息块里,所以撤掉不丢收尾行。 */
1355
1441
  dropEmptyAnswer(q.h);
1356
- qaRenderDone(ok, label, thread(), ms);
1442
+ qaRenderDone(ok, label, thread(), ms, detail);
1357
1443
  /* 只有「真的跑完了」才有下一步可取:失败 / 被停 / 回放的轮次都不问。
1358
1444
  请求异步进行,界面不等待 —— 结果回来再画(见 requestSuggestions)。 */
1359
1445
  if (ok && label !== '已停止' && !state.replay) requestSuggestions(state.lastPrompt, state.agentRaw);
@@ -1369,6 +1455,7 @@
1369
1455
  if (!state.replay) {
1370
1456
  clearWorking();
1371
1457
  state.busy = false;
1458
+ state.phase = 'idle';
1372
1459
  state.askId = null;
1373
1460
  setSendMode('idle');
1374
1461
  /* 顺手把还挂着的那条流掐掉:让 runTurn 的 Promise 收口(否则它会一直等 end 帧)。
@@ -1431,7 +1518,7 @@
1431
1518
  ⚠️ 例外:这一轮**还在宿主那里排队**(beforeAsk 没 resolve)时本来就没有 askId,
1432
1519
  此时打断的真实含义是「别发这一轮了」。宿主用 opts.onInterrupt 认领这层语义:
1433
1520
  返回 true = 它已经把排队撤掉,组件不再往下走 control。 */
1434
- async function control(op, text) {
1521
+ async function control(op, text, extra) {
1435
1522
  if (!state.askId) {
1436
1523
  if (op === 'interrupt' && typeof opts.onInterrupt === 'function') {
1437
1524
  var owned = false;
@@ -1485,8 +1572,16 @@
1485
1572
  try {
1486
1573
  var payload = { op: op, id: state.askId };
1487
1574
  if (text != null) payload.text = text;
1575
+ /* 附加字段(2026-10-25):ask_answer 要带 request_id / allowed / message / tool_name ——
1576
+ 与 core `--control` 的命令形状一致,别在这里丢字段。 */
1577
+ if (extra && typeof extra === 'object') {
1578
+ Object.keys(extra).forEach(function (k) {
1579
+ if (extra[k] !== undefined && extra[k] !== null) payload[k] = extra[k];
1580
+ });
1581
+ }
1488
1582
  await transport.control(payload);
1489
- if (op !== 'answer') noticeItem(op === 'interrupt' ? '已请求打断' : '已请求收工');
1583
+ if (op === 'interrupt') noticeItem('已请求打断');
1584
+ else if (op === 'stop') noticeItem('已请求收工');
1490
1585
  return true;
1491
1586
  } catch (e) {
1492
1587
  noticeItem('控制失败:' + (e.message || e));
@@ -1841,7 +1936,9 @@
1841
1936
  而新一轮的事件也在同一张画布上,正文就重复了。也不走 stopFollow:
1842
1937
  它会把 busy 放开,而这里马上就要置 busy。 */
1843
1938
  cancelFollowQuiet();
1844
- state.busy = true; setSendMode('busy');
1939
+ /* 直接赋值而不走 setPhase:上一轮迟到的 EXEC 帧可能把 phase 留在 'running',
1940
+ setPhase 的幂等挡板会吞掉这次重画 —— 这里必须无条件切到 busy 运行态。 */
1941
+ state.busy = true; state.phase = 'running'; setSendMode('busy');
1845
1942
  var askHandle = null; // 本轮 transport.ask 的返回值(收尾时取消用)
1846
1943
  /* 串行门(beforeAsk)真正把这一轮挡住时,画布上必须看得见「排队中」——
1847
1944
  2026-10-21 用户:「其实在串行排队 但是没显示出来任何标识」:气泡已经画出来了,
@@ -1889,7 +1986,7 @@
1889
1986
  markQueued(false);
1890
1987
  if (gate === false) {
1891
1988
  clearWorking();
1892
- state.busy = false; state.askId = null; setSendMode('idle');
1989
+ state.busy = false; state.phase = 'idle'; state.askId = null; setSendMode('idle');
1893
1990
  /* 气泡已经画在画布上了(fireTurn 先画再等门):这一轮不会有人回答它,
1894
1991
  标成「已取消」比留一句悬空的话强 —— 用户至少知道它被撤了、不是还在等。 */
1895
1992
  markLastMineCancelled();
@@ -1921,6 +2018,11 @@
1921
2018
  try {
1922
2019
  var payload = { engine: state.engine, prompt: prompt };
1923
2020
  if (state.model) payload.model = state.model;
2021
+ /* 主备:只配了备用也要带上(主引擎仍是 state.engine)。 */
2022
+ if (state.fallbackEngine) {
2023
+ payload.fallbackEngine = state.fallbackEngine;
2024
+ if (state.fallbackModel) payload.fallbackModel = state.fallbackModel;
2025
+ }
1924
2026
  if (state.workspace) payload.workspace = state.workspace;
1925
2027
  if (attPaths && attPaths.length) payload.attachments = attPaths;
1926
2028
  /* 空值就不传:让 core 用它自己的默认档,不替用户预设(安全语义)。 */
@@ -2048,7 +2150,7 @@
2048
2150
  这里只摘掉本壳自己的「运行中」指示并把状态放开 —— 别再补「完成」这类行,
2049
2151
  内核那条收尾行已经写了,补一条会变成画布上多一行重复的「完成」。 */
2050
2152
  clearWorking();
2051
- state.busy = false; state.askId = null; setSendMode('idle');
2153
+ state.busy = false; state.phase = 'idle'; state.askId = null; setSendMode('idle');
2052
2154
  /* 只收自己那条流:收尾后用户可能已经发了新一轮,别把新一轮的钩子抹掉 */
2053
2155
  if (state.askHandle === askHandle) state.askHandle = null;
2054
2156
  if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
@@ -2058,6 +2160,15 @@
2058
2160
  }
2059
2161
  }
2060
2162
 
2163
+ /* 执行阶段归一 + 唯一出口:phase 一变就把按钮/胶囊重画一遍。
2164
+ busy 不动 —— 占着一轮(排队/收尾)是占着一轮,现在在跑还是在等是另一件事。 */
2165
+ function setPhase(p) {
2166
+ var v = p === 'running' ? 'running' : (p === 'waiting' ? 'waiting' : 'idle');
2167
+ if (state.phase === v) return;
2168
+ state.phase = v;
2169
+ setSendMode(state.busy ? 'busy' : 'idle');
2170
+ }
2171
+
2061
2172
  function setSendMode(mode) {
2062
2173
  var b = ctx.sendBtn;
2063
2174
  var busy = mode === 'busy';
@@ -2067,7 +2178,12 @@
2067
2178
  输入框里的字一敲一删都会回调这里(见下面 input 监听)。 */
2068
2179
  var typed = '';
2069
2180
  try { typed = String(ctx.input.value || '').trim(); } catch (e) { typed = ''; }
2070
- var stop = busy && !typed && !state.plugin;
2181
+ /* 2026-10-24 用户:「状态已经显示黄色的是不是算停止了 按钮应该变回提交状态,
2182
+ 只有真的运行中状态才是停止」—— 停止键只属于 phase==='running'。
2183
+ waiting(waiting_approval:引擎停下来等确认)时按钮回到「发送」,
2184
+ 但仍占着一轮 → 有字照样排队,由 send() 的 busy 守卫拦重复。 */
2185
+ var running = busy && state.phase === 'running';
2186
+ var stop = running && !typed && !state.plugin;
2071
2187
  /* ⚠️ busy 时**不能**把它 disabled:那样点它不会派发 click,
2072
2188
  它就从「中断」退化成一枚按不动的图标。改指中断后,
2073
2189
  重复发送由 send() 自己的 state.busy 守卫拦住,不需要靠 disabled 防。 */
@@ -2076,17 +2192,19 @@
2076
2192
  b.innerHTML = '';
2077
2193
  /* 停止用方块、且比发送箭头略小(方块本身比箭头「重」,同尺寸会显得头重脚轻) */
2078
2194
  b.appendChild(svgIcon(stop ? 'i-stop' : 'i-up', stop ? 14 : 17));
2079
- b.setAttribute('aria-label', !busy ? '发送' : (stop ? '中断' : '排队发送'));
2195
+ b.setAttribute('aria-label', !busy ? '发送' : (stop ? '中断' : (running ? '排队发送' : '发送')));
2080
2196
  b.title = !busy ? '发送'
2081
- : (stop ? '中断这一轮' : '排队发送 · 上一条处理中,结束后自动提交');
2197
+ : (stop ? '中断这一轮'
2198
+ : (running ? '排队发送 · 上一条处理中,结束后自动提交'
2199
+ : '发送 · 上一轮在等你确认,现在发出的消息会先排队'));
2082
2200
  /* 运行态并进引擎 · 模型那枚胶囊(2026-10-20 用户:「两个胶囊合并成1个」
2083
2201
  →「是图中胶囊下方的生成中 应该跟胶囊合并」→「不需要文字了 用其他动画效果代替」)——
2084
2202
  运行态时这枚胶囊**自己**变成运行态:前面的小图标换成绿液瓶(会涨落)。
2085
- ⚠️ 跟着 busy 走(setSendMode 是 busy 的唯一出口:发一轮 / 收一轮 / 续传轮
2086
- 都经过它),所以不需要另开一个状态位。
2087
- ⚠️ 引擎·模型的真值仍在胶囊里:降级换模型时 'start' 事件会改写它
2203
+ ⚠️ 跟着 **phase** 走而不是 busy:等审批(黄色)时引擎没在跑,
2204
+ 绿液瓶 / 绿光边框必须熄掉,只有真执行才亮(见 state.phase 注释)。
2205
+ 引擎·模型的真值仍在胶囊里:降级换模型时 'start' 事件会改写它
2088
2206
  (见 kernelApply 的 'start' 分支 → syncPickers),所以不丢信息。 */
2089
- if (ctx.casc) ctx.casc.classList.toggle('is-running', mode === 'busy');
2207
+ if (ctx.casc) ctx.casc.classList.toggle('is-running', running);
2090
2208
  }
2091
2209
 
2092
2210
  /* ══════════ 底部抽屉组件 Sheet(引擎 / 模型 / 授权共用)══════════
@@ -2096,6 +2214,8 @@
2096
2214
  和叠加元素盖住;现在收成一个组件:层级拉高、高度用 dvh 贴合真实可视区。 */
2097
2215
  var Sheet = (function () {
2098
2216
  var scrim = null, panel = null, bodyEl = null, titleEl = null, curAnchor = null;
2217
+ /* 每次 open 递增:异步回调据此判断「这次打开是否还在」 */
2218
+ var gen = 0;
2099
2219
  function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
2100
2220
  function build() {
2101
2221
  scrim = el('div', 'pick-scrim');
@@ -2156,11 +2276,19 @@
2156
2276
  }
2157
2277
  function open(anchor, fn) {
2158
2278
  /* 同一按钮再点一次 = 收起 */
2159
- if (panel && curAnchor === anchor) { close(); return; }
2279
+ if (panel && curAnchor === anchor) { close(); return null; }
2160
2280
  close();
2281
+ gen++;
2282
+ var my = gen;
2161
2283
  curAnchor = anchor;
2162
2284
  build();
2163
2285
  render(fn);
2286
+ /* 返回这一次打开的手柄:异步拿到新数据时用来判断「抽屉还是同一次打开」
2287
+ (用户可能已经关掉或换了别的抽屉,那就不该再往里画)。 */
2288
+ return {
2289
+ alive: function () { return panel != null && my === gen; },
2290
+ render: function (next) { if (panel != null && my === gen) render(next); }
2291
+ };
2164
2292
  }
2165
2293
  return { open: open, close: close, render: render };
2166
2294
  })();
@@ -2168,7 +2296,16 @@
2168
2296
  /* 引擎 · 模型:**省市联动**(左列引擎 · 右列该引擎的模型,选中实时联动切换)。
2169
2297
  —— 不是「下钻二级」那种一列换一列;两列同屏,像省市选择器。 */
2170
2298
  function openEnginePicker(anchor) {
2171
- Sheet.open(anchor, function (M) { engineModelCascade(M); });
2299
+ var h = Sheet.open(anchor, function (M) { engineModelCascade(M); });
2300
+ if (!h) return;
2301
+ /* 额度 / 签到**不跟缓存走**:模型清单可以缓存 5 分钟(要起引擎 CLI),
2302
+ 额度不行 —— 它随用量和 key 变化(core --quotas 只读票据 + 一两条 HTTP)。
2303
+ 打开选择器时后台再取一次;数据真变了且抽屉还开着,就地重画。 */
2304
+ var before = JSON.stringify(state.engines);
2305
+ refreshEngines(true).then(function () {
2306
+ if (!h.alive() || JSON.stringify(state.engines) === before) return;
2307
+ h.render(function (M) { engineModelCascade(M); });
2308
+ }, function () { /* 取不到就继续用旧值,不打扰用户 */ });
2172
2309
  }
2173
2310
  /* 引擎/模型**落定**(点了具体模型或「默认模型」)时通知宿主。
2174
2311
  宿主据此把它记成下次新建任务的默认值(移动端「我的 → 默认引擎/模型」);
@@ -2178,10 +2315,28 @@
2178
2315
  if (!opts.onPick) return;
2179
2316
  try { opts.onPick(state.engine, state.model); } catch (e) {}
2180
2317
  }
2318
+ /* 历史会话锁:会话已有归属(state.session 非空)时引擎不可换,模型照旧可换。
2319
+ 会话 id 是引擎命名空间的 —— codebuddy 开的会话拿 dsh 去续,dsh 直接报
2320
+ 「session does not exist」。state.engine 为空时不锁(无可锁对象,锁了发送
2321
+ 就没有引擎可带)。返回锁定原因文案(供 title / desc 复用),未锁返回 ''。 */
2322
+ function engineLockReason() {
2323
+ if (state.session && state.engine) return '这条会话由 ' + state.engine + ' 开的 · 引擎不能换,模型可以换';
2324
+ return '';
2325
+ }
2181
2326
  function engineModelCascade(M) {
2182
2327
  var all = state.engines || [];
2183
- var available = all.filter(function (x) { return x.ok; });
2184
- M.title('选择引擎 / 模型(' + available.length + ' / ' + all.length + ')');
2328
+ var available = pickableEngines(all);
2329
+ var locked = engineLockReason();
2330
+ if (locked) {
2331
+ /* 锁定态左列只画当前引擎这一项(置灰不可点)。当前引擎不在可用清单里
2332
+ (探测失败等)也要画 —— 会话归属不随探测结果变。 */
2333
+ var own = null;
2334
+ all.forEach(function (x) { if (x.engine === state.engine) own = x; });
2335
+ if (!own) own = { engine: state.engine, ok: true, models: [] };
2336
+ available = [own];
2337
+ }
2338
+ M.title(locked ? '这条会话的引擎不能换(模型可以换)'
2339
+ : '选择引擎 / 模型(' + available.length + ' / ' + all.length + ')');
2185
2340
  if (!available.length) {
2186
2341
  M.row({ label: '没有可用引擎', desc: 'magic-agent --engines 会给每个引擎的一键安装命令' });
2187
2342
  return;
@@ -2197,6 +2352,7 @@
2197
2352
  if (!cur) cur = available[0];
2198
2353
 
2199
2354
  function chooseEngine(en) {
2355
+ if (engineLockReason()) return; // 历史会话:引擎归属锁死,堵死一切绕过路径
2200
2356
  cur = en;
2201
2357
  var models = en.models || [];
2202
2358
  state.engine = en.engine;
@@ -2205,30 +2361,79 @@
2205
2361
  syncPickers();
2206
2362
  paint();
2207
2363
  }
2364
+ /* 引擎行副标题:没模型时说清「取不到(未登录等)」还是「用引擎默认」——
2365
+ 之前一律写「引擎默认」,会把 trae 这类没登录、models 为空的引擎误写成默认可用。
2366
+ 多账号引擎(codebuddy 系)把登录的账号昵称放在最前面,一眼看出是哪个号。 */
2367
+ function engineSubLine(en) {
2368
+ var n = (en.models || []).length;
2369
+ var head = n ? (n + ' 个模型') : '引擎默认';
2370
+ return en.account ? (en.account + ' · ' + head) : head;
2371
+ }
2372
+ /* 每日积分领取(codebuddy 系):点「领取」只领积分,不切换引擎。
2373
+ 成功后就地更新这一行的状态并重画,不重新探测整份引擎清单(太慢)。
2374
+ 领取结果**不弹任何提示**(2026-10-25 用户要求):领完「领取」直接消失,
2375
+ 失败静默恢复按钮。 */
2376
+ function claimCheckin(en) {
2377
+ if (!transport.claimCheckin || en._claimBusy) return;
2378
+ en._claimBusy = true;
2379
+ paint();
2380
+ transport.claimCheckin(en.engine).then(function (res) {
2381
+ en._claimBusy = false;
2382
+ if (res && res.ok) {
2383
+ en.checkin_today = true;
2384
+ en.checkin_credit = '';
2385
+ if (res.credit_left) en.credit_left = res.credit_left;
2386
+ } else if (res && res.checked_today) {
2387
+ en.checkin_today = true;
2388
+ en.checkin_credit = '';
2389
+ }
2390
+ paint();
2391
+ }, function () {
2392
+ en._claimBusy = false;
2393
+ paint();
2394
+ });
2395
+ }
2208
2396
  function paint() {
2209
2397
  left.innerHTML = ''; right.innerHTML = '';
2210
2398
  available.forEach(function (en) {
2211
2399
  var n = (en.models || []).length;
2212
2400
  var b = el('button', 'pick__citem' + (en.engine === cur.engine ? ' is-on' : ''));
2213
2401
  b.type = 'button';
2402
+ if (locked) b.disabled = true; // 历史会话:引擎项置灰不可点
2214
2403
  b.appendChild(el('span', 'pick__cl', en.engine));
2215
- b.appendChild(el('span', 'pick__cd', n ? (n + ' 个模型') : '引擎默认'));
2404
+ b.appendChild(el('span', 'pick__cd', engineSubLine(en)));
2405
+ if (locked) b.appendChild(el('span', 'pick__cd', engineLockReason()));
2406
+ /* 账号剩余积分(codebuddy 系才有 credit_left,见 core --engines)。
2407
+ 只显示「剩余」,总量随套餐变化、对选引擎没判据价值。
2408
+ ⚠️ PC 宿主的 glue 会归一成 camelCase,两种拼写都认。 */
2409
+ var creditLeft = en.credit_left || en.creditLeft;
2410
+ var creditSuffix = en.credit_suffix || en.creditSuffix || ' 积分';
2411
+ if (creditLeft) {
2412
+ b.appendChild(el('span', 'pick__cd pick__cd--credit', '剩余 ' + creditLeft + creditSuffix));
2413
+ }
2414
+ /* 每日积分:今天没领就画「领取」;已领显示状态。点 chip 只领取,不切引擎。 */
2415
+ var checkedToday = (typeof en.checkin_today === 'boolean') ? en.checkin_today : en.checkinToday;
2416
+ if (checkedToday === false) {
2417
+ var checkinCredit = en.checkin_credit || en.checkinCredit || '';
2418
+ var chip = el('span', 'pick__cd pick__cd--credit pick__claim',
2419
+ en._claimBusy ? '领取中…' : ('领取 ' + checkinCredit + ' 积分'));
2420
+ chip.onclick = function (ev) {
2421
+ if (ev) { ev.preventDefault(); ev.stopPropagation(); }
2422
+ claimCheckin(en);
2423
+ };
2424
+ b.appendChild(chip);
2425
+ }
2216
2426
  b.onclick = function () { chooseEngine(en); };
2217
2427
  left.appendChild(b);
2218
2428
  });
2219
- var models = cur.models || [];
2220
- var d = el('button', 'pick__citem' + (state.engine === cur.engine && !state.model ? ' is-on' : ''));
2221
- d.type = 'button';
2222
- d.appendChild(el('span', 'pick__cl', '默认模型'));
2223
- d.appendChild(el('span', 'pick__cd', '不传 -m,由引擎自己挑'));
2224
- d.onclick = function () { state.engine = cur.engine; state.model = ''; syncPickers(); notifyPick(); Sheet.close(); };
2225
- right.appendChild(d);
2429
+ var models = qaModelsOrdered(state.engines, cur.engine, cur.models || []);
2226
2430
  models.forEach(function (mm) {
2227
2431
  var b = el('button', 'pick__citem' + (state.engine === cur.engine && state.model === mm ? ' is-on' : ''));
2228
2432
  b.type = 'button';
2229
2433
  /* 清单项显示剥离前缀后的名字(`mm` 本身是 CLI 认的完整 id,选它时原样写回)。
2230
2434
  倍率(`model_credits`)靠右挂在同一行:模型名再长也只挤自己,倍率列不会错位。 */
2231
- b.appendChild(modelRow(mm, qaModelCredit(state.engines, cur.engine, mm)));
2435
+ b.appendChild(modelRow(mm, qaModelCredit(state.engines, cur.engine, mm), cur.engine,
2436
+ qaModelQuota(state.engines, cur.engine, mm)));
2232
2437
  b.onclick = function () { state.engine = cur.engine; state.model = mm; syncPickers(); notifyPick(); Sheet.close(); };
2233
2438
  right.appendChild(b);
2234
2439
  });
@@ -2257,10 +2462,18 @@
2257
2462
  Object.keys(routes).forEach(function (k) {
2258
2463
  var r = routes[k] || {};
2259
2464
  var engine = String(r.engine || '').trim();
2260
- if (!engine) return; // 没选引擎 = 这一类跟随主引擎(不传空对象,core 会当成显式指定)
2261
- var item = { engine: engine };
2465
+ var fe = String(r.fallbackEngine || '').trim();
2466
+ /* 主/备任一非空都要下发:只配了备用 = 主引擎跟随主引擎 + 这一路的备。 */
2467
+ if (!engine && !fe) return;
2468
+ var item = {};
2469
+ if (engine) item.engine = engine;
2262
2470
  var model = String(r.model || '').trim();
2263
2471
  if (model) item.model = model;
2472
+ if (fe) {
2473
+ item.fallbackEngine = fe;
2474
+ var fm = String(r.fallbackModel || '').trim();
2475
+ if (fm) item.fallbackModel = fm;
2476
+ }
2264
2477
  clean[k] = item;
2265
2478
  });
2266
2479
  if (Object.keys(clean).length) out.routes = clean;
@@ -2445,12 +2658,16 @@
2445
2658
  var k = events[i] && (events[i].kind || events[i].type);
2446
2659
  if (k === 'user') idx = i;
2447
2660
  }
2448
- var terminal = false;
2661
+ var terminal = false, waiting = false;
2449
2662
  for (var j = idx + 1; j < events.length; j++) {
2450
2663
  var k2 = events[j] && (events[j].kind || events[j].type);
2451
- if (k2 === 'turn_end' || k2 === 'turn_failed' || k2 === 'result' || k2 === 'error' || k2 === 'end') { terminal = true; break; }
2664
+ /* waiting:最后一轮停在等用户(ask:提问 / 工具待授权)。其后若又有执行帧
2665
+ (answered 之后引擎继续跑),清掉 —— 口径与 core 的 ask.go 状态机一致。 */
2666
+ if (k2 === 'ask') waiting = true;
2667
+ else if (EXEC_KINDS[k2]) waiting = false;
2668
+ if (TERMINAL_KINDS[k2]) { terminal = true; waiting = false; break; }
2452
2669
  }
2453
- return { index: idx, terminal: terminal, at: idx >= 0 ? replayAt(events[idx]) : 0 };
2670
+ return { index: idx, terminal: terminal, waiting: waiting, at: idx >= 0 ? replayAt(events[idx]) : 0 };
2454
2671
  }
2455
2672
 
2456
2673
  /* 历史最后一轮能不能补建议:必须是**成功收尾**、没有待答提问卡、正文够长。
@@ -2555,6 +2772,7 @@
2555
2772
  if (state.askId && state.askId !== runID) return;
2556
2773
  clearWorking();
2557
2774
  state.busy = false;
2775
+ state.phase = 'idle';
2558
2776
  state.askId = null;
2559
2777
  state.resumeT0 = 0;
2560
2778
  setSendMode('idle');
@@ -2625,6 +2843,7 @@
2625
2843
  if (!state.askId) {
2626
2844
  clearWorking();
2627
2845
  state.busy = false;
2846
+ state.phase = 'idle';
2628
2847
  setSendMode('idle');
2629
2848
  if (opts.onBusy) { try { opts.onBusy(false); } catch (e) {} }
2630
2849
  }
@@ -2647,6 +2866,7 @@
2647
2866
  var st = String((info && info.state) || '');
2648
2867
  clearWorking();
2649
2868
  state.busy = false;
2869
+ state.phase = 'idle';
2650
2870
  state.askId = null;
2651
2871
  state.resumeT0 = 0;
2652
2872
  setSendMode('idle');
@@ -2708,6 +2928,11 @@
2708
2928
  tok.retryNotice = null;
2709
2929
  var t = n && n.querySelector ? n.querySelector('.notice__t') : null;
2710
2930
  if (t) t.textContent = '已重新连接,继续同步';
2931
+ /* 「已恢复」是一次过去式的确认,不是这一轮的历史:短暂提示后收走,
2932
+ 别让这行红条一直挂在画布里(2026-10-24 用户:「重连成功了不要一直还显示提示」)。 */
2933
+ setTimeout(function () {
2934
+ if (n && n.parentNode) n.parentNode.removeChild(n);
2935
+ }, 1800);
2711
2936
  },
2712
2937
  /* 终态 / 重试用尽 / 4xx 才收摊;普通网络抖动不会走到这里(传输层在重连)。 */
2713
2938
  onEnd: function (info) { if (state.follow === tok) followEnd(info); },
@@ -2952,9 +3177,20 @@
2952
3177
  续传中的那一轮(partial)**不封**:它的收尾由后面接上的实时通道落定。 */
2953
3178
  if (!partial) finishReplayRound();
2954
3179
  } finally { state.replay = false; }
3180
+ /* 回放的档位在这里一次定死(回放期间 kernelApply 不翻相位):
3181
+ 最后一轮停在等用户(日志里有 ask、其后没有终帧)→ waiting;
3182
+ 有终态 → idle;还在跑(partial,马上接实时流)→ running。 */
3183
+ var lti = lastTurnInfo(replayList || []);
3184
+ if (partial) setPhase('running');
3185
+ else if (lti.waiting && !lti.terminal) setPhase('waiting');
3186
+ else setPhase('idle');
2955
3187
  var t = thread(); t.scrollTop = t.scrollHeight;
2956
3188
  /* 打开 / 切换会话 = 从底部开始看:把内核的「用户离开底部」闸复位(见 qaScrollReset)。 */
2957
3189
  if (typeof qaScrollReset === 'function') { try { qaScrollReset(); } catch (e) {} }
3190
+ /* 兜底贴底(2026-10-25 用户:「历史会话也应该默认到最底部」):上面那次同步贴底
3191
+ 在缩略图 / 字体加载完成**之前**执行,图一到位画布就被撑高、停在半空(手机上几乎必现)。
3192
+ rAF + 300ms + 慢图 load 三重补贴,见内核 qaHistoryStickBottom。 */
3193
+ if (typeof qaHistoryStickBottom === 'function') { try { qaHistoryStickBottom(); } catch (e) {} }
2958
3194
  state.autoFollow = true;
2959
3195
  /* 分页游标落定后才显示入口(旧服务端 hist = null → 不显示,行为与改动前一致)。 */
2960
3196
  state.hist = after == null ? hist : state.hist;
@@ -3029,6 +3265,20 @@
3029
3265
  Array.prototype.slice.call(t.childNodes).forEach(function (n) {
3030
3266
  if (n !== moreBtn) frag.appendChild(n);
3031
3267
  });
3268
+ /* ⚠️ 翻页可能发生在**一轮还在跑**的时候(2026-10-24 用户:「点击向上滚动没反应」)。
3269
+ 前插走的是回放那条管线,而回放会「遇到 user 就把当前这一笔封口」、产物也挂到
3270
+ 「当前这一笔」上 —— 不隔离就会把正在流式增长的那张实时卡当历史轮封掉,
3271
+ 后续增量只能另起一张(同一轮变两卡)。所以把实时那一轮属于宿主的状态整体摘下来,
3272
+ 回放完原样接回去。回放是同步的(detach → 画 → 接回都在同一段 JS 里),实时事件插不进来。 */
3273
+ var live = {
3274
+ qa: state.qa, qaClosed: state.qaClosed, lastUserSig: state.lastUserSig,
3275
+ agentRaw: state.agentRaw, working: state.working,
3276
+ awaitingAnswer: state.awaitingAnswer, qaHadAsk: state.qaHadAsk,
3277
+ engine: state.engine, model: state.model, arts: turnArts,
3278
+ };
3279
+ state.qa = null;
3280
+ state.working = null; // 不回放里抢「运行中」那一行(mountWorking 就没得搬)
3281
+ if (typeof qaTurnArtsTrack === 'function') turnArts = qaTurnArtsTrack(); // 这一页的产物自己收,别和实时混账
3032
3282
  state.replay = true;
3033
3283
  state.replayT0 = 0; state.replayLastAt = 0;
3034
3284
  state.qaClosed = false; // 历史轮次要先能开新的一笔
@@ -3039,7 +3289,22 @@
3039
3289
  /* 页尾通常切在同一轮中间(页首才是 user,见 histCursor)→ 封口,
3040
3290
  否则最后一笔会一直挂着「生成中」胶囊;整轮完整时这里已是空操作。 */
3041
3291
  finishReplayRound();
3042
- } finally { state.replay = false; }
3292
+ } finally {
3293
+ state.replay = false;
3294
+ turnArts = live.arts;
3295
+ state.qa = live.qa;
3296
+ state.working = live.working;
3297
+ state.qaClosed = live.qaClosed;
3298
+ state.lastUserSig = live.lastUserSig;
3299
+ state.agentRaw = live.agentRaw;
3300
+ state.awaitingAnswer = live.awaitingAnswer;
3301
+ state.qaHadAsk = live.qaHadAsk;
3302
+ /* 旧轮次里的 start 事件会把选择器改口成那一轮的引擎 / 模型 —— 翻页不该改现在这一轮 */
3303
+ if (state.engine !== live.engine || state.model !== live.model) {
3304
+ state.engine = live.engine; state.model = live.model;
3305
+ syncPickers();
3306
+ }
3307
+ }
3043
3308
  t.appendChild(frag);
3044
3309
  /* 别让内核把这个动作当成「有新内容」把画面拽回底部(qaUserAway 是它的滚动闸)。 */
3045
3310
  if (typeof qaUserAway !== 'undefined') { try { qaUserAway = true; } catch (e) {} }
@@ -3053,8 +3318,9 @@
3053
3318
  var h = state.hist;
3054
3319
  var sid = state.session;
3055
3320
  if (!sid || !h || h.loading || !h.more) return Promise.resolve(false);
3056
- /* 正在看一条运行中的轮次:前插会打乱实时事件的落笔位置,等它收尾再翻。 */
3057
- if (state.busy) return Promise.resolve(false);
3321
+ /* ⚠️ 不再因为「这一轮还在跑」就拒绝翻页(2026-10-24 用户:「点击向上滚动没反应」):
3322
+ 此前 busy 直接 return,按钮看着能点、点了一点动静都没有。回放与实时那一轮的状态
3323
+ 隔离在 prependPage 里做(见那里的注释)。 */
3058
3324
  h.loading = true;
3059
3325
  syncMore();
3060
3326
  /* 一页装不下一整轮时(实测一条会话有 5000 条思考/工具的分页上限),服务端只能给出
@@ -3152,8 +3418,15 @@
3152
3418
  if (casc) {
3153
3419
  casc.querySelector('.casc__p').textContent = state.engine || '选择引擎';
3154
3420
  /* 展示走 qaModelText:剥掉 codebuddy 系自定义模型的 `custom-local:` 前缀
3155
- (`state.model` 本身仍是完整 id,`-m` 要用它)。 */
3156
- casc.querySelector('.casc__s').textContent = qaModelText(state.model) || '默认模型';
3421
+ (`state.model` 本身仍是完整 id,`-m` 要用它)。
3422
+ dsh 的 id 是 `route/model`:同名模型可能挂在多条路由下(modelverse 与
3423
+ volcengine 都有 deepseek-v4.1-flash-1017),胶囊只显示末段就分不清选的是
3424
+ 哪条 —— 2026-10-25 报障里「我明明选了另一条」就是这么来的,故 dsh 带上路由。 */
3425
+ var modelLabel = qaModelText(state.model, state.engine) || '默认模型';
3426
+ var modelRoute = qaModelRoute(state.model, state.engine);
3427
+ if (modelRoute) modelLabel = modelRoute + '/' + modelLabel;
3428
+ casc.querySelector('.casc__s').textContent = modelLabel;
3429
+ casc.title = engineLockReason(); // 置灰胶囊的悬停说明(历史会话锁)
3157
3430
  }
3158
3431
  var chip = ctx.permBtn;
3159
3432
  if (chip) {
@@ -3384,6 +3657,11 @@
3384
3657
  try { typed = String(ctx.input.value || '').trim(); } catch (e0) { typed = ''; }
3385
3658
  /* 附件也算「有内容」:忙时贴了图再点发送 = 排队,不能把它当成「停止」(同 send 的闸门)。 */
3386
3659
  if (typed || state.plugin || (state.attachments || []).length) return;
3660
+ /* 2026-10-24 用户:「状态已经显示黄色的是不是算停止了 按钮应该变回提交状态,
3661
+ 只有真的运行中状态才是停止」—— waiting(等审批)时按钮是「发送」,空输入点它
3662
+ 不是中断:不许把正等着你确认的那一轮打断。不 preventDefault,让表单照常走
3663
+ send()(空输入它自己 no-op)。 */
3664
+ if (state.phase !== 'running') return;
3387
3665
  e.preventDefault();
3388
3666
  /* 用户按了「停止」:托管当场暂停,不等收尾行 —— 否则它可能在这一轮结束的
3389
3667
  同一秒自动发出下一条,看起来像「点了停止还在自己跑」。 */
@@ -3430,7 +3708,7 @@
3430
3708
  /* 内核的角色行 / 「生成中」胶囊都走这里取显示名。**必须剥掉 `custom-local:`**
3431
3709
  —— 本壳没有 modelLabels,宿主回不出可读名时内核会退回 id,前缀就露出来了
3432
3710
  (移动端尤其明显:它没有 PC 那份 qaModelName 标签表,见 04-task-list.js)。 */
3433
- name: function (id) { return qaModelText(id) || qaModelText(state.model); },
3711
+ name: function (id) { return qaModelText(id, state.engine) || qaModelText(state.model, state.engine); },
3434
3712
  set: function (id) { state.model = id ? String(id) : ''; syncPickers(); return true; },
3435
3713
  };
3436
3714
  window.MagicHost.toast = function (msg) { console.info('[conv] ' + String(msg == null ? '' : msg)); };
@@ -3944,6 +4222,8 @@
3944
4222
  `t.model = s.model || ""`):空值 = 让 core 用默认档,不替用户预设。 */
3945
4223
  state.model = (s && s.model) || '';
3946
4224
  clearThread();
4225
+ /* 新开 / 换到空会话:相位归零(有历史的会话由 loadHistory 末尾按日志定档)。 */
4226
+ if (!state.busy) setPhase('idle');
3947
4227
  /* 换到另一条会话:上一条留下的插件胶囊不跟过来(它是「这一轮用哪个 CLI」,
3948
4228
  属于未发出的那一句,换了会话就该作废)。 */
3949
4229
  setPlugin(null);
@@ -3982,7 +4262,7 @@
3982
4262
  不做这步的话,带着一个跑不起来的引擎发任务,报错指向 core 而不是
3983
4263
  「这个引擎没了」;宿主记的默认值也会一直卡在死引擎上。
3984
4264
  (清单为空 = 探测失败,保持原引擎不动,别把用户的默认值清掉。) */
3985
- var okRows = state.engines.filter(function (x) { return x.ok; });
4265
+ var okRows = pickableEngines(state.engines);
3986
4266
  if (!state.engine) {
3987
4267
  var firstOk = okRows[0];
3988
4268
  if (firstOk) state.engine = firstOk.engine; // 「自动」档:静默取首个可用,不回写宿主
@@ -4021,6 +4301,7 @@
4021
4301
  免得一块已经不在页面上的排队条继续替自己发车。 */
4022
4302
  state.queue = [];
4023
4303
  state.busy = false;
4304
+ state.phase = 'idle'; // 销毁即归零:新实例从干净相位起步
4024
4305
  Sheet.close();
4025
4306
  if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
4026
4307
  }
@@ -4042,6 +4323,11 @@
4042
4323
  answer: function (text) { return control('answer', text); },
4043
4324
  setEngine: function (n) { state.engine = n; syncPickers(); },
4044
4325
  setModel: function (n) { state.model = n; syncPickers(); },
4326
+ /* 主备(2026-10-24):宿主改「我的 → 备用引擎」时同步进来(新会话立即生效)。 */
4327
+ setFallback: function (engine, model) {
4328
+ state.fallbackEngine = String(engine || '').trim();
4329
+ state.fallbackModel = state.fallbackEngine ? String(model || '').trim() : '';
4330
+ },
4045
4331
  setPermission: function (v) { state.permission = v; syncPickers(); },
4046
4332
  setWorkspace: function (v) { state.workspace = v || ''; },
4047
4333
  permissionLabel: function () { return tierOf(state.permission || '').label; },