magic-agent 2.1.5 → 2.3.2

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 (78) hide show
  1. package/CLIENT-CONTRACT.md +147 -11
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +17 -0
  4. package/client-ui/pc/index.html +5 -0
  5. package/client-ui/pc/js/05-task-list-render.js +28 -4
  6. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  7. package/client-ui/pc/js/93-rail.js +24 -2
  8. package/client-ui/shared/conv/03-chat-message.js +63 -3
  9. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  10. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  11. package/client-ui/shared/conv/19-attachments.js +75 -11
  12. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  13. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  14. package/client-ui/shared/conv/README.md +6 -0
  15. package/client-ui/shared/conv/conv.css +33 -1
  16. package/client-ui/shared/conversation.css +42 -4
  17. package/client-ui/shared/conversation.js +801 -102
  18. package/client-ui/shared/transport-http.js +199 -38
  19. package/client-ui-mobile/index.html +731 -581
  20. package/client-ui-mobile/reqs.css +39 -0
  21. package/client-ui-mobile/reqs.js +520 -0
  22. package/cmd/magic-client/main.go +1 -1
  23. package/internal/agent/capability.go +8 -1
  24. package/internal/agent/claude.go +5 -3
  25. package/internal/agent/codebuddy.go +6 -2
  26. package/internal/agent/codex.go +7 -2
  27. package/internal/agent/dsh_sdk.go +8 -0
  28. package/internal/agent/dsh_sdk_test.go +17 -0
  29. package/internal/agent/engine.go +20 -0
  30. package/internal/agent/engine_test.go +52 -0
  31. package/internal/agent/llmengine.go +14 -0
  32. package/internal/agent/llmengine_test.go +24 -0
  33. package/internal/agent/llmhttp.go +6 -0
  34. package/internal/agent/llmhttp_test.go +27 -0
  35. package/internal/agent/llmmodels.go +48 -0
  36. package/internal/agent/permission.go +5 -0
  37. package/internal/cli/ask.go +38 -17
  38. package/internal/cli/requirements.go +2 -0
  39. package/internal/cli/root.go +5 -0
  40. package/internal/cli/session.go +16 -14
  41. package/internal/cli/sessionlog.go +5 -0
  42. package/internal/cli/sessionlog_user_test.go +49 -0
  43. package/internal/cli/suggest.go +98 -0
  44. package/internal/cli/suggest_test.go +54 -0
  45. package/internal/cli/team.go +111 -24
  46. package/internal/client/requirements.go +27 -7
  47. package/internal/client/requirements_test.go +51 -0
  48. package/internal/client/service.go +35 -3
  49. package/internal/client/service_test.go +35 -0
  50. package/internal/client/suggest.go +131 -0
  51. package/internal/client/suggest_test.go +116 -0
  52. package/internal/requirements/consolidate.go +86 -24
  53. package/internal/requirements/requirements.go +9 -0
  54. package/internal/requirements/requirements_test.go +129 -0
  55. package/internal/requirements/store.go +11 -3
  56. package/internal/session/delete.go +58 -11
  57. package/internal/session/delete_test.go +140 -0
  58. package/internal/session/session.go +25 -0
  59. package/internal/session/session_mark_test.go +62 -0
  60. package/internal/suggest/suggest.go +532 -0
  61. package/internal/suggest/suggest_test.go +268 -0
  62. package/internal/team/board.go +107 -15
  63. package/internal/team/execute.go +1 -1
  64. package/internal/team/team.go +28 -19
  65. package/internal/team/team_test.go +68 -15
  66. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  67. package/npm/apk/version.json +3 -3
  68. package/npm/dist/darwin-arm64/magic-agent +0 -0
  69. package/npm/dist/darwin-arm64/magic-client +0 -0
  70. package/npm/dist/darwin-x64/magic-agent +0 -0
  71. package/npm/dist/darwin-x64/magic-client +0 -0
  72. package/npm/dist/linux-arm64/magic-agent +0 -0
  73. package/npm/dist/linux-arm64/magic-client +0 -0
  74. package/npm/dist/linux-x64/magic-agent +0 -0
  75. package/npm/dist/linux-x64/magic-client +0 -0
  76. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  77. package/npm/dist/win32-x64/magic-client.exe +0 -0
  78. package/package.json +6 -2
@@ -32,6 +32,9 @@
32
32
  <!-- 对话内核那套 DOM(.qa-*)的**共用皮肤**:三端同一份(见 shared/conv/conv.css 的文件头)。
33
33
  里面的取值全走令牌,本页只负责给令牌(下面的 :root)—— 皮肤本身不定义颜色。 -->
34
34
  <link rel="stylesheet" href="shared/conv/conv.css">
35
+ <!-- 需求页(「需求」tab)是独立子页面:样式在 reqs.css、结构/逻辑在 reqs.js。
36
+ 本文件只给两个空容器与一次 mount,不再混入这一页的实现。 -->
37
+ <link rel="stylesheet" href="reqs.css">
35
38
  <style>
36
39
  /* ===== 令牌:ZCode 安卓端那一套,映射到共享组件的颜色契约 =====
37
40
  ⚠️ 默认 = 暗色:跟 ZCode 的 DarkColorScheme 逐字段对齐。 */
@@ -64,6 +67,12 @@
64
67
  /* 状态色底(半透明叠加色,明暗主题通用) */
65
68
  --tint-run:rgba(59,130,246,.18); --tint-wait:rgba(245,158,11,.20); --tint-done:rgba(34,197,94,.18);
66
69
  --tint-queued:rgba(139,92,246,.18); --queued:#8B5CF6;
70
+ /* 异常终态两档(2026-10-23 用户:「现在没有异常状态的颜色」):
71
+ · 失败(failed / gone)→ 红:这条没跑完就没了,要人看一眼;
72
+ · 已取消 / 已打断(cancelled / interrupted)→ 石褐:人为收工,不是故障,
73
+ 所以不上红,但也绝不能和「正常结束」的绿或兜底的白同色。 */
74
+ --tint-fail:rgba(239,68,68,.18); --fail:#F87171;
75
+ --tint-stop:rgba(120,113,108,.24); --stop:#A8A29E;
67
76
 
68
77
  /* ── 掌天瓶绿液(可选令牌,shared/conversation.css 用 `var(--q-bottle*, 兜底)` 取)──
69
78
  瓶是**玉**不是玻璃(见 conversation.js 的形制说明),所以颜色分两组:
@@ -109,6 +118,9 @@
109
118
  --error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
110
119
  --running:#16A34A; --transitioning:#2563EB; --active:#D97706;
111
120
  --running:#16A34A; --transitioning:#2563EB; --active:#D97706;
121
+ /* 浅底上异常终态要更重(半透明红 / 石褐在 #F8FAFC 上否则会糊成灰) */
122
+ --tint-fail:rgba(220,38,38,.13); --fail:#DC2626;
123
+ --tint-stop:rgba(120,113,108,.16); --stop:#78716C;
112
124
  /* 浅底上玉胎要更浓:`.42` 那档实测会被冲成白瓷,读不出「翠」。浓到 .66 又像实心塑料。 */
113
125
  --q-bottle-jade-hi:rgba(146,231,184,.62);
114
126
  --q-bottle-jade:rgba(96,205,152,.60);
@@ -134,6 +146,9 @@ html[data-theme="light"]{color-scheme:light;
134
146
  --accent:#2563EB; --warning:#D97706; --success:#16A34A;
135
147
  --error-surface:#FEF2F2; --error-border:#FECACA; --error-text:#B91C1C; --error-icon:#DC2626;
136
148
  --running:#16A34A; --transitioning:#2563EB; --active:#D97706;
149
+ /* 浅底上异常终态要更重(同 prefers-color-scheme:light 那一份) */
150
+ --tint-fail:rgba(220,38,38,.13); --fail:#DC2626;
151
+ --tint-stop:rgba(120,113,108,.16); --stop:#78716C;
137
152
  /* 浅底上玉胎要更浓(同上一条注释) */
138
153
  --q-bottle-jade-hi:rgba(146,231,184,.62);
139
154
  --q-bottle-jade:rgba(96,205,152,.60);
@@ -285,6 +300,11 @@ textarea{font:inherit}
285
300
  .srow__ico--run{background:var(--tint-run);color:var(--primary-action)}
286
301
  .srow__ico--wait{background:var(--tint-wait);color:var(--warning)}
287
302
  .srow__ico--done{background:var(--tint-done);color:var(--success)}
303
+ /* 异常终态:以前和 idle 一起落在灰白兜底档,用户看不出「这条是挂了 / 被停了,
304
+ 还是只是没跑过」(2026-10-23 用户:「现在没有异常状态的颜色」)。
305
+ 失败(failed / gone)= 红;已取消 / 已打断(cancelled / interrupted)= 石褐。 */
306
+ .srow__ico--fail{background:var(--tint-fail);color:var(--fail)}
307
+ .srow__ico--stop{background:var(--tint-stop);color:var(--stop)}
288
308
  .srow__ico--idle{background:var(--subtle);color:var(--muted)}
289
309
  /* 等待中(串行排队):紫色 + 一圈**呼吸**的环。
290
310
  与「运行中」的扫光刻意分开 —— 扫光在转(在动),这圈只明暗(在等);
@@ -313,11 +333,25 @@ textarea{font:inherit}
313
333
  .srow__s{display:flex;align-items:center;gap:5px;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden}
314
334
  .srow__stext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
315
335
  .srow__time{flex:0 0 auto;font-size:12px;color:var(--muted)}
336
+ /* 运行中**自动临时置顶**的小图钉(2026-10-22):让「它凭什么在最上面」有据可查。
337
+ 手动置顶靠「置顶」分段表达,不重复画;跑完 statusOf 不再是 run,图钉随之消失。 */
338
+ .srow__auto{flex:0 0 auto;display:flex;align-items:center;color:var(--primary-action);opacity:.75}
316
339
  /* 子会话(团队模式拆出的子任务,2026-10-22):**缩进挂在主会话下面**,不占顶层一行。
317
340
  左侧一道竖线 + 小一档图标 —— 读起来是「这条主会话拆出去的活」;点开仍是它自己
318
- 那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。 */
341
+ 那份完整过程(自己的日志)。行本身可点、可长按(重命名 / 删除同普通会话)。
342
+ ⚠️ 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
343
+ 计数徽标(有几个子会话 / 是否有子任务在跑),点它才展开。 */
319
344
  .sblock{min-width:0}
320
345
  .sblock__kids{margin:0 8px 6px 30px;padding-left:8px;border-left:2px solid var(--border)}
346
+ .srow__kids{flex:0 0 auto;display:inline-flex;align-items:center;gap:3px;height:20px;padding:0 5px;
347
+ border:1px solid var(--border);border-radius:999px;background:var(--subtle);color:var(--muted);
348
+ font-size:11px;font-weight:600;line-height:1;cursor:pointer;user-select:none;-webkit-user-select:none}
349
+ .srow__kids:active{opacity:.7}
350
+ .srow__kids svg{transition:transform .15s ease}
351
+ .srow__kids.is-open svg{transform:rotate(90deg)}
352
+ /* 有子任务在跑:徽标转成运行色 + 一个小圆点(折叠时也能看出「下面有活在跑」)。 */
353
+ .srow__kids.is-run{color:var(--running);border-color:currentColor}
354
+ .srow__kids-dot{width:6px;height:6px;border-radius:50%;background:var(--running)}
321
355
  .srow--sub{padding:9px 8px 9px 4px;gap:10px}
322
356
  .srow--sub .srow__ico{width:32px;height:32px}
323
357
  .srow--sub .srow__ico svg{width:16px;height:16px}
@@ -365,12 +399,6 @@ textarea{font:inherit}
365
399
  .wsrow__open{flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 8px;border-radius:14px;text-align:left}
366
400
  .wsrow__open:active{background:var(--subtle)}
367
401
  .wsrow__l{flex:1;min-width:0;font-size:15px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
368
- /* 空间规模(几模块 · 几条):紧跟在空间名后面,中性色 —— 不是状态,别用徽标色。
369
- 需求页专属:空间名改成自适应宽度,meta 才会贴在名字后面;meta 的 margin-right:auto
370
- 把「待整理」徽标和箭头推到行尾。 */
371
- .wsrow__open--req .wsrow__l{flex:0 1 auto}
372
- .wsrow__open--req .wsrow__meta{margin-right:auto}
373
- .wsrow__meta{flex:0 1 auto;min-width:0;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
374
402
  /* 串行等待中的任务数:空间组折叠时也要看得见(颜色与「等待中」行一致) */
375
403
  .wsrow__badge{flex:0 0 auto;padding:1px 7px;border-radius:var(--r-full);font-size:11px;line-height:16px;
376
404
  color:var(--queued);background:var(--tint-queued);white-space:nowrap}
@@ -378,33 +406,6 @@ textarea{font:inherit}
378
406
  .wsrow__chev.is-open{transform:rotate(90deg)}
379
407
  .wsrow__add{flex:0 0 auto;width:36px;height:36px;border-radius:var(--r-full);display:flex;align-items:center;justify-content:center;color:var(--muted)}
380
408
  .wsrow__add:active{background:var(--subtle)}
381
- /* 每个空间自己的「整理」:需求页会同时列出多个空间,顶部那一枚只认当前会话的空间。
382
- 把入口放到组头,用户点哪个空间就初始化/增量哪个空间,不再猜「当前空间」是谁。 */
383
- .wsrow__sync{flex:0 0 auto;height:32px;padding:0 10px;border-radius:var(--r-full);
384
- font-size:12px;font-weight:500;color:var(--primary-action);background:var(--subtle);white-space:nowrap}
385
- .wsrow__sync:active{opacity:.7}
386
- .wsrow__sync:disabled{color:var(--muted);opacity:.6}
387
- /* 「未初始化」与「待整理」同形不同色:前者是「还没按目录生成过基线」,
388
- 后者是「有对话等着归纳」,两种提示不能混成一个数字。 */
389
- .wsrow__badge--init{color:var(--warning);background:var(--tint-wait)}
390
-
391
- /* ══════════ 需求页:两级(空间→模块 → 需求列表)══════════
392
- 一级是**目录**:空间是行,模块缩进挂在空间下(树形),功能点一律不画在这里 ——
393
- 需求一多,一级就会被功能点撑成长列表,什么结构都看不出来。
394
- 二级才是内容:点某个模块进去,看**该空间下所有模块**的需求列表。 */
395
- .reqtree__mod{position:relative;display:flex;align-items:center;gap:8px;width:100%;
396
- padding:9px 10px 9px 30px;background:none;border:0;text-align:left;color:var(--ink)}
397
- .reqtree__mod:active{background:var(--subtle)}
398
- /* 连接线:从空间名垂下来的细线 + 每个模块前的一小截横线 —— 归属关系靠线说话,不靠缩进猜 */
399
- .reqtree__mod::before{content:"";position:absolute;left:14px;top:0;bottom:0;width:1px;background:var(--border)}
400
- .reqtree__mod::after{content:"";position:absolute;left:14px;top:50%;width:10px;height:1px;background:var(--border)}
401
- .reqtree__modname{flex:1;min-width:0;font-size:14px;font-weight:500;color:var(--ink);
402
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
403
- .reqtree__modcount{flex:0 0 auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
404
- .reqtree__modgo{flex:0 0 auto;color:var(--faint);display:flex;align-items:center}
405
- /* 二级视图的分组小节:当前所在模块高亮,其余弱化 */
406
- .reqdetail__sec--cur .sechead{color:var(--primary-action)}
407
- .reqdetail__sec--cur .sechead__t{font-weight:800}
408
409
  /* 长按会话行的浮动菜单 */
409
410
  .rowmenu{position:fixed;z-index:1201;min-width:184px;background:var(--raised);border:1px solid var(--border);
410
411
  border-radius:14px;box-shadow:0 14px 34px -12px var(--app-shadow);padding:6px}
@@ -722,6 +723,9 @@ button.setrow{font-family:inherit}
722
723
  .qa-todo-ico{width:14px;height:14px;border-width:1.5px}
723
724
  .qa-todo-ico svg{width:9px;height:9px}
724
725
  .qa-todo-meta{font-size:11px;max-width:52%}
726
+ .qa-todo-active{font-size:11px;max-width:56%}
727
+ .qa-todo-st{font-size:11.5px}
728
+ .qa-todo-dur{font-size:11.5px}
725
729
  .qa-todo-toggle{width:26px;height:26px}
726
730
  .qa-todo-toggle svg{width:14px;height:14px}
727
731
 
@@ -984,13 +988,20 @@ button.setrow{font-family:inherit}
984
988
  <button class="seg__b" data-v="serial">串行</button>
985
989
  </span>
986
990
  </div>
991
+ <!-- 会话托管(2026-10-23 用户:「托管模式下自动按照最佳建议提交到会话中执行」;
992
+ 同日再定:「跟团队模式一样改成挂在会话上,子会话继承,不再是全局」)。
993
+ 所以这里**没有**全局开关 —— 与团队模式同一处入口:会话页右上角 → 任务运行设置。 -->
987
994
  <!-- 默认引擎 / 模型:新建任务的初值(会话里输入框上方的级联切换会回写这里)。
988
995
  两行点开的是同一个「引擎 → 模型」二列选择器,模型必须挂在引擎下。 -->
989
996
  <button class="setrow" id="btnDefaultEngine"><span class="setrow__l">默认引擎</span><span class="setrow__v" id="defaultEngine">自动(首个可用)</span></button>
990
997
  <button class="setrow" id="btnDefaultModel"><span class="setrow__l">默认模型</span><span class="setrow__v" id="defaultModel">跟随引擎</span></button>
991
- <div class="setrow"><span class="setrow__l">触感反馈</span><button class="tgl" id="tglHaptic" aria-label="触感反馈"></button></div>
998
+ <!-- 建议引擎 / 模型(2026-10-23 用户:「是选择引擎和模型 跟默认引擎那种抽屉一样」)。
999
+ 两行点开同一个「引擎 → 模型」二列抽屉;**默认不开启**:引擎空 = 组件不请求
1000
+ /desk/suggest、不画建议、不补历史建议、托管不自动跑。选了引擎才启用。 -->
1001
+ <button class="setrow" id="btnSuggestEngine"><span class="setrow__l">建议引擎</span><span class="setrow__v" id="suggestEngine">不开启</span></button>
1002
+ <button class="setrow" id="btnSuggestModel"><span class="setrow__l">建议模型</span><span class="setrow__v" id="suggestModel">跟随引擎</span></button>
1003
+ <!-- 2026-10-22 用户:「触感反馈 / 语言」这两项不需要 —— 已移除(设备级偏好只剩主题/字体)。 -->
992
1004
  <div class="setrow"><span class="setrow__l">网络代理</span><button class="tgl" id="tglVpn" aria-label="网络代理"></button></div>
993
- <div class="setrow"><span class="setrow__l">语言</span><span class="setrow__v">跟随系统</span></div>
994
1005
  <button class="setrow" id="btnNotify"><span class="setrow__l">消息通知设置</span><span class="setrow__v" id="notifyState">—</span></button>
995
1006
  </div>
996
1007
  <!-- 团队模式(2026-10-21):开关**按会话**给,放会话页右上角的运行设置里(与权限模式并排),
@@ -1037,7 +1048,8 @@ button.setrow{font-family:inherit}
1037
1048
 
1038
1049
  <!-- ══════════ 会话页 → 任务运行设置(独立子页面)══════════
1039
1050
  会话页右上角的调节键点进来(2026-10-21 用户:「运行设置在任务对话框里 不也应该是独立页面吗」)。
1040
- 四项都是**当前这条会话**的运行参数:设备只读,其余三项点开还是原来的选择器(选择器只有一份)。 -->
1051
+ 五项都是**当前这条会话**的运行参数:设备只读,其余点开还是原来的选择器(选择器只有一份)。
1052
+ 会话托管(2026-10-23)也在这里:它跟团队模式一样**属于这条会话**,不再是「我的」里的全局开关。 -->
1041
1053
  <div class="history" id="runSettingsPage" hidden>
1042
1054
  <div class="history__top">
1043
1055
  <button class="topbar__icon" id="btnRunSettingsBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
@@ -1049,35 +1061,19 @@ button.setrow{font-family:inherit}
1049
1061
  <button class="setrow" id="btnRsWorkspace"><span class="setrow__l">工作空间</span><span class="setrow__v" id="rsWorkspace">默认(不属于任何空间)</span></button>
1050
1062
  <button class="setrow" id="btnRsPermission"><span class="setrow__l">权限模式</span><span class="setrow__v" id="rsPermission">沿用默认</span></button>
1051
1063
  <button class="setrow" id="btnRsTeam"><span class="setrow__l">团队模式</span><span class="setrow__v" id="rsTeam">关闭</span></button>
1064
+ <button class="setrow" id="btnRsHosting"><span class="setrow__l">会话托管</span><span class="setrow__v" id="rsHosting">关闭</span></button>
1052
1065
  </div>
1066
+ <div class="history__hint" style="text-align:left">会话托管:每轮成功结束自动发送最合适的建议(最多连续 12 轮)。只作用于当前会话;团队子会话默认继承主会话的开关。</div>
1053
1067
  </div>
1054
1068
  </div>
1055
1069
 
1056
1070
  <!-- ══════════ 需求(会话自动聚合成的功能点文档)══════════
1057
- 数据全来自 GET /desk/requirements(core 的 --requirements,只读)。
1058
- **两级**(2026-10-22 用户定稿):
1059
- 一级 = 目录(空间 → 模块),**不画功能点** —— 一级只回答「这个空间有哪几块」;
1060
- 二级 = 点某个模块进去,看**该空间下所有模块**的需求列表(当前模块高亮)。
1061
- 点一行用既有的阅读器打开那篇 markdown。
1062
- ⚠️ 这里**不做筛选胶囊与搜索**:第一版只要「看得见 + 能点开」,多了反而没人用
1063
- (用户 2026-10-22 定稿)。 -->
1064
- <div class="history" id="reqs" hidden>
1065
- <div class="history__top">
1066
- <span class="history__title">需求</span>
1067
- <button class="history__disconnect" id="btnReqSync" type="button">全部整理</button>
1068
- </div>
1069
- <div class="history__list" id="reqList"></div>
1070
- </div>
1071
+ **独立子页面**:内容、样式、逻辑都在 reqs.js / reqs.css,本文件只留两个空容器
1072
+ (隐藏/显示由底部 tab 控制)。数据形状、两级口径见 reqs.js 文件头。 -->
1073
+ <div class="history" id="reqs" hidden></div>
1071
1074
 
1072
- <!-- 需求的二级视图:某空间下所有模块的需求列表(从一级目录点模块进来) -->
1073
- <div class="history" id="reqDetail" hidden>
1074
- <div class="history__top">
1075
- <button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>
1076
- <span class="history__title" id="reqDetailTitle">需求</span>
1077
- <button class="history__disconnect" id="btnReqDetailSync" type="button">整理</button>
1078
- </div>
1079
- <div class="history__list" id="reqDetailList"></div>
1080
- </div>
1075
+ <!-- 需求的二级视图:只看所选模块的需求列表(从一级目录点模块进来;内容由 reqs.js 渲染) -->
1076
+ <div class="history" id="reqDetail" hidden></div>
1081
1077
 
1082
1078
  <!-- ══════════ 发现(浏览器 / 应用 / 插件 / SKILL)══════════ -->
1083
1079
  <div class="history" id="discover" hidden>
@@ -1203,7 +1199,7 @@ button.setrow{font-family:inherit}
1203
1199
  <nav class="tabbar" id="tabbar" hidden>
1204
1200
  <button class="tab" type="button" data-tab="tasks" aria-label="任务"><span class="icw"><svg width="25" height="25"><use href="#i-task"/></svg></span></button>
1205
1201
  <!-- 需求 tab 常驻:不做能力位控制(用户 2026-10-22:「移动端永远显示」)。
1206
- 老 core 上点进来会由 loadRequirements 的 501 分支给出明确文案,
1202
+ 老 core 上点进来会由 reqs.js 的 501 分支给出明确文案,
1207
1203
  不会是一片空白 —— 入口固定比入口忽隐忽现更好理解。 -->
1208
1204
  <button class="tab" type="button" data-tab="reqs" aria-label="需求"><span class="icw"><svg width="25" height="25"><use href="#i-req"/></svg></span></button>
1209
1205
  <span class="tabfab">
@@ -1237,6 +1233,9 @@ button.setrow{font-family:inherit}
1237
1233
  <script src="shared/host-glue.js"></script>
1238
1234
  <script src="shared/conversation.js"></script>
1239
1235
  <script src="shared/transport-http.js"></script>
1236
+ <!-- 需求页(独立子页面模块):只在这里加载并注册 window.MagicReqs,
1237
+ 真正的 DOM/逻辑都在 reqs.js,主 IIFE 末尾 mount 时把宿主能力注入进去。 -->
1238
+ <script src="reqs.js"></script>
1240
1239
  <script>
1241
1240
  (function () {
1242
1241
  'use strict';
@@ -1266,13 +1265,9 @@ button.setrow{font-family:inherit}
1266
1265
  var segTheme = document.getElementById('segTheme');
1267
1266
  var segFont = document.getElementById('segFont');
1268
1267
  var segMode = document.getElementById('segMode');
1269
- var tglHaptic = document.getElementById('tglHaptic');
1270
1268
  var tabbarEl = document.getElementById('tabbar');
1271
1269
  var reqsEl = document.getElementById('reqs');
1272
- var reqListEl = document.getElementById('reqList');
1273
1270
  var reqDetailEl = document.getElementById('reqDetail');
1274
- var reqDetailTitle = document.getElementById('reqDetailTitle');
1275
- var reqDetailListEl = document.getElementById('reqDetailList');
1276
1271
  var discoverEl = document.getElementById('discover');
1277
1272
  var teamPageEl = document.getElementById('teamPage');
1278
1273
  var runSettingsPageEl = document.getElementById('runSettingsPage');
@@ -1309,6 +1304,8 @@ button.setrow{font-family:inherit}
1309
1304
  /* 默认引擎 / 模型(「我的」可改;函数与选择器见下面「默认引擎 / 模型」段)。
1310
1305
  提前到这里声明:首屏 paintRunPref 会读它,晚于 PREFS 初始化赋值就会踩 undefined。 */
1311
1306
  var RUN_PREF = loadRunPref();
1307
+ /* 建议引擎 / 模型(「我的」):引擎空 = 不开启建议功能(默认)。与默认引擎分开存,互不影响。 */
1308
+ var SUGGEST = loadSuggestPref();
1312
1309
  var ENGINES = []; // 最近一次从 /desk/engines?models=1 拿到的清单
1313
1310
  var ENGINES_STALE_AT = ''; // 非空 = 这份清单是插件侧的兜底缓存(探测超时/失败),值是缓存时间
1314
1311
  /* ── 团队模式(2026-10-21)──
@@ -1432,6 +1429,87 @@ button.setrow{font-family:inherit}
1432
1429
  saveTeamPref();
1433
1430
  }
1434
1431
 
1432
+ /* ── 会话托管(2026-10-23,按会话)──
1433
+ 与团队模式同一套形状:`sessions` = sid → 开/关,`fresh` = 新会话还没拿到 session_id
1434
+ 时的临时开关。旧版 `magic.hosting` 是设备级全局值,不迁移(见上面的注释)。 */
1435
+ var HOSTING_KEY = 'magic.hosting.sessions.v1';
1436
+ function loadHostingPref() {
1437
+ var o = { sessions: {}, fresh: false };
1438
+ try {
1439
+ var m = JSON.parse(localStorage.getItem(HOSTING_KEY) || '{}');
1440
+ if (m && typeof m === 'object' && !Array.isArray(m)) {
1441
+ Object.keys(m).forEach(function (sid) { o.sessions[sid] = !!m[sid]; });
1442
+ }
1443
+ } catch (e) {}
1444
+ /* 旧版全局开关:属于「改一条会话,所有会话都变」的旧语义,清掉不迁移。 */
1445
+ try { localStorage.removeItem('magic.hosting'); } catch (e) {}
1446
+ return o;
1447
+ }
1448
+ function saveHostingPref() {
1449
+ /* 与团队模式同一条容量护栏:最多 400 条,超出按 key 顺序丢(只影响容量)。 */
1450
+ var ids = Object.keys(HOSTING.sessions || {});
1451
+ if (ids.length > 400) {
1452
+ ids.slice(0, ids.length - 400).forEach(function (sid) { delete HOSTING.sessions[sid]; });
1453
+ }
1454
+ try { localStorage.setItem(HOSTING_KEY, JSON.stringify(HOSTING.sessions || {})); } catch (e) {}
1455
+ schedulePushPrefs();
1456
+ paintRunSettings();
1457
+ }
1458
+ /* 按任意 id(session_id / run_id)找会话对象:父指针与本地表可能各用一个别名。 */
1459
+ function sessionByAnyId(key) {
1460
+ var k = String(key || '');
1461
+ if (!k) return null;
1462
+ for (var i = 0; i < LAST_SESSIONS.length; i++) {
1463
+ var s = LAST_SESSIONS[i];
1464
+ if (!s) continue;
1465
+ if (sidOf(s) === k || String(s.session_id || '') === k || String(s.run_id || '') === k) return s;
1466
+ }
1467
+ return null;
1468
+ }
1469
+ /* 这条会话的显式记录(session_id / run_id 任一命中即算)。 */
1470
+ function hostingRecordOf(s) {
1471
+ if (!s) return null;
1472
+ var keys = [sidOf(s), s.session_id, s.run_id];
1473
+ for (var i = 0; i < keys.length; i++) {
1474
+ var k = keys[i];
1475
+ if (k && Object.prototype.hasOwnProperty.call(HOSTING.sessions, String(k))) {
1476
+ return HOSTING.sessions[String(k)];
1477
+ }
1478
+ }
1479
+ return null;
1480
+ }
1481
+ /* 这条会话的托管开关。自己有记录就用自己那条;没有就**沿父链继承**(用户 2026-10-23:
1482
+ 「子会话继承」):团队子会话默认跟主会话一致,主会话改开关,没单独设过的子会话也跟着变。
1483
+ 子会话里手动改一次 = 写下自己的记录,从此不再继承(显式覆盖 > 继承)。
1484
+ 新会话(还没有 sid)用 `fresh`,不跨会话继承。 */
1485
+ function hostingOnFor(sid, s) {
1486
+ if (!sid) return !!HOSTING.fresh;
1487
+ var cur = s || sessionByAnyId(sid);
1488
+ var seen = {};
1489
+ for (var depth = 0; cur && depth < 16; depth++) {
1490
+ var key = sidOf(cur) || String(sid);
1491
+ if (seen[key]) break; // 脏数据成环:最多走 16 层并去重
1492
+ seen[key] = 1;
1493
+ var rec = hostingRecordOf(cur);
1494
+ if (rec !== null) return !!rec;
1495
+ var pk = parentKeyOf(cur);
1496
+ if (!pk) break;
1497
+ /* 父会话可能已经不在清单里(被清理 / 只给了指针),仍然认它的显式记录。 */
1498
+ if (Object.prototype.hasOwnProperty.call(HOSTING.sessions, String(pk))) {
1499
+ return !!HOSTING.sessions[String(pk)];
1500
+ }
1501
+ cur = sessionByAnyId(pk);
1502
+ }
1503
+ return false;
1504
+ }
1505
+ /* 新会话的开关要等 core 给出 session_id 才落得下来(见 mountConversation 的 onEvent)。 */
1506
+ function bindFreshHosting(sid) {
1507
+ if (!HOSTING.fresh || !sid) return;
1508
+ HOSTING.sessions[String(sid)] = true;
1509
+ HOSTING.fresh = false;
1510
+ saveHostingPref();
1511
+ }
1512
+
1435
1513
  /* ══════════ 环境(多连接)══════════
1436
1514
  2026-10-22 用户:「移动端要支持环境切换 可以有多个连接」。
1437
1515
  一台手机可以记住多台桌面(家里 / 公司 / 另一台机器),任务页标题就是切换入口。
@@ -1643,8 +1721,9 @@ button.setrow{font-family:inherit}
1643
1721
  —— 内存态全部重新初始化,不留上一台机器的影子。
1644
1722
  ⚠️ 分桶键用 host:port 而不是 url|token:token 会随 App 重启变,按 token 分桶等于每次都丢现场。 */
1645
1723
  var ENV_SCOPE_KEYS = [
1646
- 'magic.ws', 'magic.taskmode', 'magic.engine', 'magic.model',
1647
- 'magic.theme', 'magic.font', 'magic.haptic', 'magic.notify',
1724
+ 'magic.ws', 'magic.taskmode', 'magic.hosting.sessions.v1', 'magic.engine', 'magic.model',
1725
+ 'magic.suggest.engine', 'magic.suggest.model',
1726
+ 'magic.theme', 'magic.font', 'magic.notify',
1648
1727
  'magic.pinned', 'magic.spaces', 'magic.browser.stars.v1',
1649
1728
  'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
1650
1729
  'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
@@ -1697,9 +1776,10 @@ button.setrow{font-family:inherit}
1697
1776
  所以这组键以 `/desk/prefs` 为准,localStorage 只当离线缓存:
1698
1777
  · 连上后拉一次,远端是权威 → 有差异就覆盖本地并重载一次;
1699
1778
  · 本地任何一处改了,防抖推回远端。
1700
- 纯设备偏好(主题/字体/触感/通知)**不同步** —— 那是手机自己的事。 */
1779
+ 纯设备偏好(主题/字体/通知)**不同步** —— 那是手机自己的事。 */
1701
1780
  var PREF_SYNC_KEYS = [
1702
- 'magic.engine', 'magic.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
1781
+ 'magic.engine', 'magic.model', 'magic.suggest.engine', 'magic.suggest.model', 'magic.ws', 'magic.spaces', 'magic.pinned',
1782
+ 'magic.hosting.sessions.v1',
1703
1783
  'magic.team.sessions.v1', 'magic.team.routes.v1', 'magic.team.rules.v1',
1704
1784
  'magic.team.parallel', 'magic.team.split', 'magic.team.splitcode', 'magic.team.adaptive'
1705
1785
  ];
@@ -2071,23 +2151,23 @@ button.setrow{font-family:inherit}
2071
2151
  loadSessions();
2072
2152
  }
2073
2153
  function showSession(sub) { hideAll(); sessionEl.hidden = false; tabbarEl.hidden = false; setActive('tasks'); subtitleEl.textContent = (sub != null ? sub : histSubtitle) || ''; }
2074
- /* 需求页:进页面即拉一次清单(GET /desk/requirements,只读、不调模型)。
2154
+ /* 需求页:内容与逻辑在 reqs.js(独立子页面模块),这里只负责切页。
2155
+ 进页面即拉一次清单(GET /desk/requirements,只读、不调模型);
2075
2156
  「整理」按钮才触发归纳(POST /desk/requirements/consolidate)——
2076
2157
  两者代价差三个数量级,绝不能混成一个动作。 */
2077
2158
  function showReqs() {
2078
2159
  hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; setActive('reqs');
2079
- /* 从二级退回来时不要还卡在详情态:回到底栏「需求」这一页 = 回到一级目录。 */
2080
- REQ_DETAIL = null;
2081
- loadRequirements(false);
2160
+ /* 从二级退回来时不要还卡在详情态:reqs.js 的 show() 会回一级目录并拉清单。 */
2161
+ if (window.MagicReqs && window.MagicReqs.show) window.MagicReqs.show();
2082
2162
  }
2083
2163
  function showDiscover() { hideAll(); discoverEl.hidden = false; tabbarEl.hidden = false; setActive('discover'); }
2084
2164
  function showMine() {
2085
2165
  hideAll(); mineEl.hidden = false; tabbarEl.hidden = false; setActive('mine');
2086
2166
  mineDevice.textContent = histSubtitle || '本机';
2167
+ paintSuggestPref();
2087
2168
  var ns = document.getElementById('notifyState');
2088
2169
  if (ns) { var p = notifyPermState(); ns.textContent = p === 'plugin' ? '已接入' : (p === 'granted' ? '已开启' : '未开启'); }
2089
2170
  markSeg(segTheme, PREFS.theme); markSeg(segFont, PREFS.font); markSeg(segMode, TASK_MODE);
2090
- tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
2091
2171
  paintRunPref();
2092
2172
  refreshVpn();
2093
2173
  /* 版本号:先用已拉到的值立刻画(列表页的 /desk/health 顺手带回来的),
@@ -2634,15 +2714,14 @@ button.setrow{font-family:inherit}
2634
2714
  });
2635
2715
  }
2636
2716
 
2637
- /* ══════════ 偏好:主题 / 字体 / 触感(本地持久化)══════════ */
2717
+ /* ══════════ 偏好:主题 / 字体(本地持久化)══════════ */
2638
2718
  function loadPrefs() {
2639
2719
  try {
2640
2720
  return {
2641
2721
  theme: localStorage.getItem('magic.theme') || 'system',
2642
- font: localStorage.getItem('magic.font') || '1',
2643
- haptic: localStorage.getItem('magic.haptic') || 'on'
2722
+ font: localStorage.getItem('magic.font') || '1'
2644
2723
  };
2645
- } catch (e) { return { theme: 'system', font: '1', haptic: 'on' }; }
2724
+ } catch (e) { return { theme: 'system', font: '1' }; }
2646
2725
  }
2647
2726
  function savePref(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
2648
2727
  function applyThemePref(v) {
@@ -2651,24 +2730,149 @@ button.setrow{font-family:inherit}
2651
2730
  }
2652
2731
  /* 字体大小:组件尺寸是 px,改 root font-size 无效;用 body zoom 整体缩放(WebView 支持)。 */
2653
2732
  function applyFontPref(v) { try { document.body.style.zoom = (v && v !== '1') ? v : ''; } catch (e) {} }
2654
- function applyHapticPref(v) { window.MAGIC_HAPTICS = (v !== 'off'); }
2655
2733
  /* 任务模式:parallel(默认,全部并行)| serial(同一空间同时只跑一个新任务)。 */
2656
2734
  var TASK_MODE = 'parallel';
2657
2735
  try { TASK_MODE = localStorage.getItem('magic.taskmode') || 'parallel'; } catch (e) {}
2736
+ /* 会话托管(2026-10-23):跟团队模式一样**挂在会话上**(用户:「跟团队模式一样改成
2737
+ 挂在会话上,子会话继承,不再是全局」)。`sessions` 是 sid → 开/关;`fresh` 只临时装
2738
+ 「新会话还没拿到 session_id 时」的开关(首帧带回 id 就落到 `sessions` 上)。
2739
+ 旧版是全设备一个 `magic.hosting` —— 那正是「改一条会话,所有会话都变」的来源,
2740
+ 不迁移(把全局值抄给每条会话等于把 bug 换个地方),直接清掉。 */
2741
+ var HOSTING = loadHostingPref();
2658
2742
  var PREFS = loadPrefs();
2659
- applyThemePref(PREFS.theme); applyFontPref(PREFS.font); applyHapticPref(PREFS.haptic);
2743
+ applyThemePref(PREFS.theme); applyFontPref(PREFS.font);
2660
2744
  paintRunPref();
2661
2745
  paintTeamPref(); // 团队配置卡片(能力位要到连上桌面后才亮,见 loadSessions)
2662
2746
 
2663
2747
  /* 挂 conversation:传输层由宿主注入,组件不连后端(见 CLIENT-CONTRACT.md 3.2.1)。
2664
2748
  sid 非空 = 打开该会话(组件会走 readSession 回放历史);s = 该会话对象(标题取 prompt_head);空 = 新会话。 */
2749
+ /* 「同一个会话」要认 run_id ↔ session_id 的别名。
2750
+ 会话刚起(团队任务 / 新会话首轮)时清单里只有 run_id,session_id 是 core 首帧之后
2751
+ 才写回的;同一个会话因此会先后以两个 id 出现。只比字面会把「返回列表再点进来」
2752
+ 判成另一条会话 —— 组件被销毁、内存队列清空,排队的追问就整条消失
2753
+ (2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
2754
+ function sameConvId(a, b) {
2755
+ if (!a || !b) return false;
2756
+ a = String(a); b = String(b);
2757
+ if (a === b) return true;
2758
+ for (var i = 0; i < LAST_SESSIONS.length; i++) {
2759
+ var s = LAST_SESSIONS[i];
2760
+ var ids = [s.session_id, s.run_id].filter(Boolean).map(String);
2761
+ if (ids.indexOf(a) >= 0 && ids.indexOf(b) >= 0) return true;
2762
+ }
2763
+ return false;
2764
+ }
2765
+ /* 这条会话里「已落库、还没轮到发」的条目,能接回组件的那些:
2766
+ · owner==='conv' —— 组件内排队(见 queuePushed),主人就是组件;
2767
+ · owner==='host' —— 重启恢复(restoreQueue)交给宿主的,但宿主**还没开始发**
2768
+ (!p.dispatching):用户这时点进会话,让组件接管更符合预期 ——
2769
+ 否则画布上永远不显示「排队中」,只有列表角标。
2770
+ owner==='gate' 的绝不碰:那是串行门挡住的**当前这一轮**(气泡已由 fireTurn 画了),
2771
+ 由 beforeAsk 的 Promise 自己放行,接管会双发。 */
2772
+ function pendingOfConv(sid) {
2773
+ return PENDING.filter(function (p) {
2774
+ if (p.gone || p.stalled || p.dispatching) return false;
2775
+ if (p.owner !== 'conv' && p.owner !== 'host') return false;
2776
+ return sameConvId(p.session, sid);
2777
+ });
2778
+ }
2779
+ function adoptPendingFor(sid) {
2780
+ if (!conv || typeof conv.adoptQueue !== 'function' || !sid) return 0;
2781
+ /* ⚠️ 2026-10-23(用户:「排队的和自动建议发送串会话了」):`p.session` **空**时不许接。
2782
+ sameConvId('', sid) 这类「空串当通配」的匹配,正是把上一条会话的排队追问灌进
2783
+ 当前会话的入口 —— 新会话首句排队那一刻 state.session 还是空的,落库就是空串。
2784
+ 那种条目交给原主(enqueueDispatch 按 session 空 = 新会话下发),
2785
+ 不该被任何一条**已有 id 的会话**认领。 */
2786
+ var list = pendingOfConv(sid).filter(function (p) { return !!p.session; });
2787
+ if (!list.length) return 0;
2788
+ var items = list.map(function (p) {
2789
+ /* 接管:宿主那条 enqueueDispatch 在真正 fireQueued 之前会检查 owner,
2790
+ 看到它已归组件就放手(见 enqueueDispatch)—— 两边不会各发一次。 */
2791
+ p.owner = 'conv';
2792
+ /* 恢复出来的条目沿用**宿主持久化 id**:组件 take / drop 时回传的就是它,
2793
+ 宿主据此删对行(QPEND 里重新登记一次,值指向自己)。 */
2794
+ QPEND[p.id] = p.id;
2795
+ return {
2796
+ id: p.id, prompt: p.prompt,
2797
+ /* 归属会话要一并交给组件:它据此在发出前再核一次(见 flushQueue),
2798
+ 宿主这边的筛选只是第一道。 */
2799
+ session: p.session,
2800
+ attachments: (p.attachments || []).slice(),
2801
+ atts: (p.atts || []).slice(),
2802
+ hosted: !!p.hosted
2803
+ };
2804
+ });
2805
+ try { return conv.adoptQueue(items) || 0; } catch (e) { return 0; }
2806
+ }
2807
+ function adoptAfterHistory(sid, s) {
2808
+ var c = conv;
2809
+ if (!c) return;
2810
+ var p;
2811
+ try { p = c.setSession(sid, s); } catch (e) { p = null; }
2812
+ Promise.resolve(p).then(function () {
2813
+ /* 迟到的历史:这期间用户可能已经切走 / 又换了会话 —— 不许把旧队列灌进新画布。 */
2814
+ if (conv !== c || !sameConvId(sid, convSid)) return;
2815
+ adoptPendingFor(sid);
2816
+ });
2817
+ }
2818
+ /* 会话托管的开关:**按当前这条会话**落盘(用户 2026-10-23:「跟团队模式一样改成
2819
+ 挂在会话上,子会话继承,不再是全局」)。开关的权威值在这里,组件只执行。
2820
+ reason 非空 = 组件自己暂停的(失败 / 被停 / 到上限 / 用户点了「托管中」标记),
2821
+ 这里要如实弹一句;宿主自己拨开关时由调用处给更具体的提示。 */
2822
+ function convSessionKey() {
2823
+ var raw = (conv && conv.state && conv.state.session) || convSid || '';
2824
+ var s = raw ? sessionByAnyId(raw) : null;
2825
+ return s ? (sidOf(s) || String(raw || '')) : String(raw || '');
2826
+ }
2827
+ function writeHostingFor(on) {
2828
+ var key = convSessionKey();
2829
+ if (key) {
2830
+ var s = sessionByAnyId(key);
2831
+ /* 同一会话的 run_id ↔ session_id 别名只留一条,避免两个键各说一套。 */
2832
+ if (s) {
2833
+ [s.session_id, s.run_id].forEach(function (k) {
2834
+ if (k && String(k) !== key) delete HOSTING.sessions[String(k)];
2835
+ });
2836
+ }
2837
+ HOSTING.sessions[key] = !!on;
2838
+ HOSTING.fresh = false;
2839
+ } else {
2840
+ HOSTING.fresh = !!on;
2841
+ }
2842
+ saveHostingPref();
2843
+ }
2844
+ function onHostingToggleFromConv(on, reason) {
2845
+ writeHostingFor(!!on);
2846
+ if (reason) toast('会话托管已暂停:' + reason);
2847
+ }
2848
+ /* 运行设置里那一行:直接翻当前会话的开关(不弹选择器 —— 托管只有开 / 关两档)。 */
2849
+ function toggleHostingHere() {
2850
+ if (!SUGGEST.engine) { toast('先在「我的 → 建议引擎」里选一个引擎,才能开启会话托管'); return; }
2851
+ if (!conv) { toast('会话还没准备好'); return; }
2852
+ var on = typeof conv.hostingOn === 'function' ? !!conv.hostingOn() : false;
2853
+ var next = !on;
2854
+ try { conv.setHosting(next); } catch (e) {}
2855
+ writeHostingFor(next);
2856
+ toast(next
2857
+ ? '会话托管已开启:每轮结束自动发送最合适的建议,提问卡自动选第一项继续'
2858
+ : '会话托管已关闭');
2859
+ }
2860
+
2665
2861
  function mountConversation(sid, s) {
2666
2862
  /* 同一条会话、同一个空间:组件还活着,画面也还在 —— 直接把这一屏端回来。
2667
2863
  以前这里无条件 destroy() + create() + innerHTML='',于是「返回列表 → 再点进来」
2668
2864
  每次都要重拉全量历史、重新建 DOM、重新回放(长会话 = 又慢又费流量)。
2669
- 标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。 */
2670
- if (conv && sid && sid === convSid && WS === convWs) {
2671
- try { conv.setSession(sid, s); } catch (e) {}
2865
+ 标题 / 引擎 / 模型交给组件的 setSession 同步(它对本会话是幂等的)。
2866
+ ⚠️ 判「同一条会话」要认 run_id ↔ session_id 的别名(见 sameConvId):
2867
+ 只比字面的话,团队任务/新会话在 session_id 回来前后拿到的是两个 id,
2868
+ 同一屏会被判成两条会话 → 销毁重建 → 内存里排队的追问连气泡一起消失
2869
+ (2026-10-22 用户:「排队中的任务重新从会话列表进入就看不到了」)。 */
2870
+ if (conv && sid && WS === convWs && sameConvId(sid, convSid)) {
2871
+ convSid = sid; // 列表那边可能已经从 run_id 切到 session_id
2872
+ /* same:true —— 上面已经确认是同一屏会话。组件按字面判 id 会把别名当成
2873
+ 「换了会话」→ clearQueue() 删掉排队追问(本次报障的病根之一)。 */
2874
+ try { conv.setSession(sid, s, { same: true }); } catch (e) {}
2875
+ if (typeof conv.setSuggestEngine === 'function') conv.setSuggestEngine(SUGGEST.engine, SUGGEST.model);
2672
2876
  window.magicConv = conv;
2673
2877
  return;
2674
2878
  }
@@ -2677,14 +2881,18 @@ button.setrow{font-family:inherit}
2677
2881
  convSid = sid || null;
2678
2882
  convWs = WS;
2679
2883
  /* 每次重建组件都丢掉「上一条还没落定的新会话」的临时开关 —— 否则用户在新会话里
2680
- 开了团队但没发消息,转去打开一条历史会话时,那条会话的首帧会把开关误记成开启。
2681
- 老会话的开关由 teamOnFor 从它自己那份记录里取;新会话从「关」起。 */
2884
+ 开了团队 / 托管但没发消息,转去打开一条历史会话时,那条会话的首帧会把开关误记成开启。
2885
+ 老会话的开关由 teamOnFor / hostingOnFor 从它自己那份记录里取;新会话从「关」起。 */
2682
2886
  TEAM.fresh = false;
2887
+ HOSTING.fresh = false;
2683
2888
  try {
2684
2889
  var transport = window.MagicTransportHttp({ base: BASE, token: TOKEN, client: 'mobile' });
2685
2890
  conv = window.MagicConversation.create({
2686
2891
  el: convEl, transport: transport, titleEl: titleEl, workspace: WS,
2687
2892
  engine: RUN_PREF.engine, model: RUN_PREF.model,
2893
+ /* 建议功能默认关闭:只有「我的 → 建议引擎」里显式选了引擎才传非空值;
2894
+ 模型为空 = 引擎默认(core 对 llm 会自动挑最快变体)。 */
2895
+ suggestEngine: SUGGEST.engine, suggestModel: SUGGEST.model,
2688
2896
  showPermission: false, showAttach: true, beforeAsk: gateAsk,
2689
2897
  onInterrupt: cancelWaitingAsk,
2690
2898
  /* 组件内排队也要落库(2026-10-21 用户:「非团队的串行排队也没落库」):
@@ -2695,6 +2903,10 @@ button.setrow{font-family:inherit}
2695
2903
  onQueueTake: queueTaken,
2696
2904
  onQueueDrop: queueDropped,
2697
2905
  onPick: rememberRunPick,
2906
+ /* 会话托管:开关初值按**这条会话**给(自己没有记录就沿父链继承,见 hostingOnFor);
2907
+ 组件自己暂停时回调这里把这条会话的开关关掉。 */
2908
+ hosting: hostingOnFor(sid, s),
2909
+ onHostingChange: onHostingToggleFromConv,
2698
2910
  /* 团队模式:开关初值按**这条会话**给;配置是设备偏好(每轮现读,改配置立刻生效)。
2699
2911
  features 也给函数:能力清单是异步拉到的(/desk/health),
2700
2912
  查到之后开关才会亮出来,不必重新挂载组件。 */
@@ -2711,9 +2923,12 @@ button.setrow{font-family:inherit}
2711
2923
  };
2712
2924
  },
2713
2925
  features: function () { return CORE_FEATURES; },
2714
- /* 新会话的开关先记在 TEAM.fresh:core 的首帧带回 session_id 时,当场绑到这条会话上
2715
- (否则返回列表再点进来就找不到它了)。 */
2716
- onEvent: function (ev) { bindFreshTeam(ev && ev.session_id); },
2926
+ /* 新会话的开关先记在 TEAM.fresh / HOSTING.fresh:core 的首帧带回 session_id 时,
2927
+ 当场绑到这条会话上(否则返回列表再点进来就找不到它了)。 */
2928
+ onEvent: function (ev) {
2929
+ bindFreshTeam(ev && ev.session_id);
2930
+ bindFreshHosting(ev && ev.session_id);
2931
+ },
2717
2932
  /* 菜单里的「定时任务」还在,但底部那个 tab 已经删了(2026-10-22 用户:
2718
2933
  「定时任务tab先删除」)。现在点它给一句说明,而不是跳去一张只有一个占位符的页 ——
2719
2934
  原先那页的正文就是这句说明,页面本身没有承载任何数据。
@@ -2726,7 +2941,10 @@ button.setrow{font-family:inherit}
2726
2941
  }
2727
2942
  }
2728
2943
  });
2729
- if (sid) conv.setSession(sid, s);
2944
+ /* 历史画完再把落库的排队条目接回画布(见 adoptPendingFor):抢在回放之前插气泡,
2945
+ 会被组件的 clearThread 一起清掉;而且那一刻「这一轮还在不在跑」已经由
2946
+ loadHistory / follow 落定,flush 的时机才是对的。 */
2947
+ if (sid) adoptAfterHistory(sid, s);
2730
2948
  window.magicConv = conv;
2731
2949
  } catch (e) {
2732
2950
  var d = document.createElement('div');
@@ -2735,6 +2953,39 @@ button.setrow{font-family:inherit}
2735
2953
  convEl.appendChild(d);
2736
2954
  }
2737
2955
  }
2956
+ /* ── 进详情时补拉这一条的状态(2026-10-23 用户:「白色会话进会话详情时应该再拉一波更新状态」)──
2957
+ 列表里的 status 是**进列表那一刻**的快照;而 core 的 `--sessions` 读侧还会把「记录写着
2958
+ 在跑、进程其实已经不在」的行当场归一成 gone。用户点开一条**没有颜色**的会话(既不是
2959
+ 运行中 / 等审批,也不是已完成 / 失败 / 已取消)时,多半就是这份快照已经过期。
2960
+ 这里只补这一条:拉一次登记表、按 id 别名(session_id ↔ run_id)对上就地更新,
2961
+ 状态真的变了才重绘。失败静默 —— 它是补刀,不是主路径。 */
2962
+ function refreshRowStatus(sid) {
2963
+ if (!sid || !TOKEN) return;
2964
+ var seq = CONN_SEQ;
2965
+ fetch(BASE + '/desk/sessions', { headers: { 'X-Magic-Token': TOKEN } })
2966
+ .then(function (r) { return r.ok ? r.json() : null; })
2967
+ .then(function (list) {
2968
+ if (seq !== CONN_SEQ || !Array.isArray(list)) return;
2969
+ var row = null, cur = null, i;
2970
+ for (i = 0; i < list.length; i++) {
2971
+ var x = list[i];
2972
+ if (!x) continue;
2973
+ if (sameConvId(x.session_id, sid) || sameConvId(x.run_id, sid)) { row = x; break; }
2974
+ }
2975
+ if (!row) return;
2976
+ for (i = 0; i < LAST_SESSIONS.length; i++) {
2977
+ if (sameConvId(sidOf(LAST_SESSIONS[i]), sid)) { cur = LAST_SESSIONS[i]; break; }
2978
+ }
2979
+ if (!cur) return;
2980
+ var was = statusOf(cur);
2981
+ if (row.status) cur.status = row.status;
2982
+ if (row.state) cur.state = row.state;
2983
+ cur.alive = row.alive === true;
2984
+ if (row.updated_at) cur.updated_at = row.updated_at;
2985
+ if (statusOf(cur) !== was) renderSessions(LAST_SESSIONS);
2986
+ })
2987
+ .catch(function () { /* 拉不到就保持原样 */ });
2988
+ }
2738
2989
  function openSession(sid, s) {
2739
2990
  /* ⚠️ 一律以会话自己的归属为准,**不能**只在其有 workspace 时才改 WS(2026-10-21):
2740
2991
  原写法 `if (s && s.workspace) { WS = s.workspace; }` 会让「打开一条默认空间的会话」
@@ -2744,6 +2995,9 @@ button.setrow{font-family:inherit}
2744
2995
  saveWs();
2745
2996
  showSession(s && s.workspace ? basename(s.workspace) : histSubtitle);
2746
2997
  mountConversation(sid, s);
2998
+ /* 灰白档 = 状态未知(或列表快照过期):进详情时补拉一次,见 refreshRowStatus。
2999
+ 其它档(运行中 / 等审批 / 已完成 / 失败 / 已取消)列表已经说清楚了,不必再打一发。 */
3000
+ if (statusOf(s) === 'idle') refreshRowStatus(sid);
2747
3001
  }
2748
3002
  /* ws:不传 = 默认(不属于任何空间);传字符串 = 在该空间里新建(列表里空间组的「+」)。
2749
3003
  ⚠️ 「新建任务」大按钮必须从**默认**起(2026-10-21 用户:「大的新建会话 空间应该是
@@ -2906,7 +3160,9 @@ button.setrow{font-family:inherit}
2906
3160
  });
2907
3161
  }
2908
3162
  /* ── 会话列表:**置顶 / 任务 / 空间** 三段(对齐截图)──
2909
- 置顶:本地 pin(长按会话行切换);任务:最近会话(默认前 8,可「展开更多」);
3163
+ 置顶:本地 pin(长按会话行切换)+ **运行中自动临时置顶**(2026-10-22:跑着的那条
3164
+ 自己进「置顶」段,不写 PINNED,跑完自动归位;见 isAutoPinned);
3165
+ 任务:最近会话(默认前 8,可「展开更多」);
2910
3166
  空间:会话里出现过的 workspace(点行=按空间筛选任务,+=在该空间新建)。 */
2911
3167
  var LAST_SESSIONS = [];
2912
3168
  var SEC_COLLAPSED = {};
@@ -2960,6 +3216,10 @@ button.setrow{font-family:inherit}
2960
3216
  return !(pk && pk !== sidOf(s));
2961
3217
  });
2962
3218
  }
3219
+ /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。
3220
+ 任务页 / 需求页共用(需求页已拆到 reqs.js,那边自带一份同名实现;这里留着
3221
+ 是因为 isRealSpaceDir / isTeamRunDir 还服务于任务页的空间分组)。 */
3222
+ function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
2963
3223
  /* 一个目录算不算「空间」:得是**用户认得的工作区**。
2964
3224
  根目录 / 不是空间(哪怕历史上有会话在那儿跑过)—— 需求页显示它只会让人
2965
3225
  以为「/ 是个项目」(用户 2026-10-22:「没有空间的不需要」)。
@@ -3045,6 +3305,30 @@ button.setrow{font-family:inherit}
3045
3305
 
3046
3306
  function sidOf(s) { return s.session_id || s.run_id; }
3047
3307
  function isPinned(s) { return PINNED.indexOf(sidOf(s)) >= 0; }
3308
+ /* 运行中会话**自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
3309
+ 判据只看当前是不是在跑(`statusOf === 'run'`),**不写 PINNED、不落 prefs** ——
3310
+ 跑完下一次 renderSessions 自然归位,用户自己的手动置顶关系一点不动。
3311
+ 子会话在跑也算:子行默认折叠在父块里,只提子行的话首屏根本看不见,
3312
+ 所以把父块一起提到「置顶」段(父行本身不跑时不画图钉,计数徽标会亮运行色,见 sessionRow)。
3313
+ ⚠️ 与手动置顶分开判:手动置顶仍只认 PINNED,自动的只在排序 / 分段时叠加。 */
3314
+ function isAutoPinned(s) {
3315
+ if (!s) return false;
3316
+ if (statusOf(s) === 'run') return true;
3317
+ var kids = CHILD_OF[sidOf(s)] || [];
3318
+ for (var i = 0; i < kids.length; i++) { if (statusOf(kids[i]) === 'run') return true; }
3319
+ return false;
3320
+ }
3321
+ /* 列表分段用:手动置顶 ∪ 运行中自动置顶。 */
3322
+ function isListPinned(s) { return isPinned(s) || isAutoPinned(s); }
3323
+ /* 置顶段 / 搜索结果的排序:**人工置顶在前 → 运行中自动置顶 → 普通**,同类按 updated_at 倒序。
3324
+ 用户 2026-10-22 定稿:「应该在人工置顶的后面」—— 自动置顶只是临时把在跑的活提上来,
3325
+ 不能压过用户自己钉的会话;跑完 isAutoPinned 变 false,它回到普通分段。 */
3326
+ function pinnedOrder(a, b) {
3327
+ var ra = isPinned(a) ? 2 : (isAutoPinned(a) ? 1 : 0);
3328
+ var rb = isPinned(b) ? 2 : (isAutoPinned(b) ? 1 : 0);
3329
+ return (rb - ra)
3330
+ || String(b.updated_at || b.started_at || '').localeCompare(String(a.updated_at || a.started_at || ''));
3331
+ }
3048
3332
  function togglePin(s) {
3049
3333
  var id = sidOf(s), i = PINNED.indexOf(id);
3050
3334
  if (i >= 0) PINNED.splice(i, 1); else PINNED.unshift(id);
@@ -3090,10 +3374,46 @@ button.setrow{font-family:inherit}
3090
3374
  .then(function () { TITLES[id] = name; renderSessions(LAST_SESSIONS); })
3091
3375
  .catch(function (e) { window.alert('重命名失败:' + (e && e.message ? e.message : e)); });
3092
3376
  }
3093
- /* 删除:DELETE /desk/session/{id}(登记表记录 + 事件日志),然后刷新列表。 */
3377
+ /* 删除一棵会话树:删主会话时,挂在它下面的**子任务会话**也要从本地列表一起消失。
3378
+ 服务端(session.Delete)已经级联删了登记记录 / 日志 / 标题 / 状态;这里只是不让
3379
+ 用户在看到下一次轮询刷新之前,还点得进那些已经不存在了的子行。
3380
+ 认亲按 run_id / session_id 双向别名折算(与 renderSessions 的 KEY2SID 同口径):
3381
+ 子会话创建时主会话可能还没拿到 session_id,parent 指针里存的是 run_id。 */
3382
+ function deleteClosureOf(id, list) {
3383
+ var del = {};
3384
+ del[id] = 1;
3385
+ function mark(s) {
3386
+ if (!s) return false;
3387
+ var sid = sidOf(s);
3388
+ if (!sid) return false;
3389
+ var changed = !del[sid] || (s.run_id && !del[s.run_id]) || (s.session_id && !del[s.session_id]);
3390
+ del[sid] = 1;
3391
+ if (s.run_id) del[s.run_id] = 1;
3392
+ if (s.session_id) del[s.session_id] = 1;
3393
+ return changed;
3394
+ }
3395
+ (list || []).forEach(function (s) { if (del[sidOf(s)]) mark(s); });
3396
+ var changed = true;
3397
+ while (changed) {
3398
+ changed = false;
3399
+ (list || []).forEach(function (s) {
3400
+ var sid = sidOf(s);
3401
+ if (!sid || del[sid]) return;
3402
+ var pk = parentKeyOf(s);
3403
+ if (pk && del[pk]) changed = mark(s) || changed;
3404
+ });
3405
+ }
3406
+ return del;
3407
+ }
3408
+ /* 删除:DELETE /desk/session/{id}(登记表记录 + 事件日志 + 子任务会话),然后刷新列表。 */
3094
3409
  function deleteSessionRow(s) {
3095
3410
  var id = sidOf(s);
3096
- if (!window.confirm('删除这个会话?此操作不可恢复。')) return;
3411
+ /* 文案如实说明「会连子任务一起删」:这是物理删除,提示里不能只提主会话。 */
3412
+ var kidCount = (CHILD_OF[id] || []).length;
3413
+ var ask = kidCount
3414
+ ? '删除这个会话及其 ' + kidCount + ' 个子任务会话?此操作不可恢复。'
3415
+ : '删除这个会话?此操作不可恢复。';
3416
+ if (!window.confirm(ask)) return;
3097
3417
  var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
3098
3418
  fetch(BASE + '/desk/session/' + encodeURIComponent(id), { method: 'DELETE', headers: hdr })
3099
3419
  .then(function (r) {
@@ -3101,10 +3421,11 @@ button.setrow{font-family:inherit}
3101
3421
  return r.json().catch(function () { return {}; });
3102
3422
  })
3103
3423
  .then(function () {
3104
- var i = PINNED.indexOf(id); if (i >= 0) { PINNED.splice(i, 1); savePinned(); }
3105
- if (TITLES[id]) { delete TITLES[id]; }
3424
+ var del = deleteClosureOf(id, LAST_SESSIONS);
3425
+ PINNED = PINNED.filter(function (pid) { return !del[pid]; }); savePinned();
3426
+ Object.keys(TITLES).forEach(function (tid) { if (del[tid]) delete TITLES[tid]; });
3106
3427
  /* 本地移除 + 静默重绘:不走 loadSessions(那会先塞「读取中…」再重建 = 整页闪) */
3107
- LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return sidOf(x) !== id; });
3428
+ LAST_SESSIONS = LAST_SESSIONS.filter(function (x) { return !del[sidOf(x)]; });
3108
3429
  renderSessions(LAST_SESSIONS);
3109
3430
  })
3110
3431
  .catch(function (e) { window.alert('删除失败:' + (e && e.message ? e.message : e)); });
@@ -3128,12 +3449,24 @@ button.setrow{font-family:inherit}
3128
3449
  return h;
3129
3450
  }
3130
3451
  /* 会话状态:由 /desk/sessions 的 status(会话级统一状态,见 core 的 session-status.json)
3131
- 映射;老版本 CLI 不给 status 时回落 state。running / waiting_approval / done / 其它。 */
3452
+ 映射;老版本 CLI 不给 status 时回落 state。
3453
+ 词表与 core 的 session.Record.State 一一对应(不再是「除了三种都算灰色」):
3454
+ running / alive → run 蓝 + 扫光环
3455
+ waiting_approval → wait 琥珀(活着的等待)
3456
+ done / idle → done 绿(idle = 常驻会话本轮跑完、进程还在等追加,
3457
+ 与 PC 左栏的「已完成」同口径)
3458
+ failed / gone → fail 红(gone = 记录写着在跑、进程已不在)
3459
+ cancelled / interrupted → stop 石褐(人为收工 / 被打断,不是故障)
3460
+ 其它(空 / 陌生值) → idle 灰白兜底
3461
+ ⚠️ 2026-10-23 之前 failed / cancelled / gone 全落进 idle,列表上是一片白 ——
3462
+ 用户分不清「正常结束」和「挂了」(报障:「这两个会话为什么是白色」)。 */
3132
3463
  function statusOf(s) {
3133
3464
  var st = String((s && (s.status || s.state)) || '');
3134
3465
  if (st === 'running' || s.alive === true) return 'run';
3135
3466
  if (st === 'waiting_approval') return 'wait';
3136
- if (st === 'done') return 'done';
3467
+ if (st === 'done' || st === 'idle') return 'done';
3468
+ if (st === 'failed' || st === 'gone') return 'fail';
3469
+ if (st === 'cancelled' || st === 'interrupted' || st === 'stopped') return 'stop';
3137
3470
  return 'idle';
3138
3471
  }
3139
3472
  /* 会话行图标:团队会话=双人图形(mode:"team"),其余**设备形状按来源端**
@@ -3215,20 +3548,30 @@ button.setrow{font-family:inherit}
3215
3548
  }
3216
3549
  /* 子会话(团队模式拆出的子任务,2026-10-22):core 在 /desk/sessions 里给出
3217
3550
  parent_session_id / parent_run_id(见 internal/session 的 Record)。子任务是**普通会话**
3218
- (自己的登记 + 自己的日志,点进去看完整过程),列表里只做两件事:
3551
+ (自己的登记 + 自己的日志,点进去看完整过程),列表里只做三件事:
3219
3552
  · 不占顶层一行 —— 缩进挂在主会话下面(CHILD_OF:父 key → 子会话数组);
3553
+ · 默认**折叠**(2026-10-23 用户:「列表中默认不展开显示子会话」):父行只留一枚
3554
+ 计数徽标,点它才展开;展开状态按会话 id 记在 KIDS_OPEN 里,刷新列表不收回;
3220
3555
  · 不进「任务 / 空间」分组(它们的 workspace 是主会话里的任务目录,不该自成一个空间)。
3221
3556
  ⚠️ 父会话不在列表里(被删 / 老 core 没给父字段)→ 回落成顶层一行,
3222
3557
  宁可多一行也不能让子会话凭空消失。
3223
3558
  ⚠️ 主会话可能还没拿到 session_id,parent_session_id 同时给了 parent_run_id 兜底。 */
3224
- var CHILD_OF = {}, CHILD_IDS = {};
3559
+ var CHILD_OF = {}, CHILD_IDS = {}, KIDS_OPEN = {};
3225
3560
  function parentKeyOf(s) { return (s && (s.parent_session_id || s.parent_run_id)) || ''; }
3561
+ function kidsOpenOf(id) { return !!KIDS_OPEN[id]; }
3562
+ function toggleKids(id) {
3563
+ if (!id) return;
3564
+ KIDS_OPEN[id] = !KIDS_OPEN[id];
3565
+ renderSessions(LAST_SESSIONS);
3566
+ }
3226
3567
  function sessionBlock(s) {
3227
3568
  var wrap = document.createElement('div');
3228
3569
  wrap.className = 'sblock';
3229
3570
  wrap.appendChild(s.pendingWait ? pendingRow(s) : sessionRow(s, !!CHILD_IDS[sidOf(s)]));
3230
- var kids = CHILD_OF[sidOf(s)] || [];
3231
- if (kids.length) {
3571
+ var sid = sidOf(s);
3572
+ var kids = CHILD_OF[sid] || [];
3573
+ /* 默认不展开:只有用户点过父行上的计数徽标(KIDS_OPEN[sid])才挂子行。 */
3574
+ if (kids.length && kidsOpenOf(sid)) {
3232
3575
  var box = document.createElement('div');
3233
3576
  box.className = 'sblock__kids';
3234
3577
  kids.forEach(function (k) { box.appendChild(sessionRow(k, true)); });
@@ -3252,6 +3595,49 @@ button.setrow{font-family:inherit}
3252
3595
  col.appendChild(subEl);
3253
3596
  /* 排队与团队标记都不在这里做:两者都做在图标里(见 sessionIcon),行尾不再挂角标。 */
3254
3597
  b.appendChild(col);
3598
+ /* 子会话计数徽标(默认折叠开关):点一下展开 / 收回,**不触发打开会话**。
3599
+ 没有子会话的普通行不画它,保持列表干净;有子任务在跑时亮运行色 + 圆点,
3600
+ 这样折叠状态下也能看出「父会话下面还有活在跑」。 */
3601
+ var kids = CHILD_OF[sid] || [];
3602
+ if (!sub && kids.length) {
3603
+ var kidsRunning = false;
3604
+ for (var ki = 0; ki < kids.length; ki++) {
3605
+ if (statusOf(kids[ki]) === 'run') { kidsRunning = true; break; }
3606
+ }
3607
+ var opened = kidsOpenOf(sid);
3608
+ var chip = elSpan('srow__kids' + (opened ? ' is-open' : '') + (kidsRunning ? ' is-run' : ''));
3609
+ chip.setAttribute('role', 'button');
3610
+ chip.setAttribute('tabindex', '0');
3611
+ chip.setAttribute('aria-expanded', opened ? 'true' : 'false');
3612
+ chip.setAttribute('aria-label', (opened ? '收起 ' : '展开 ') + kids.length + ' 个子会话');
3613
+ chip.title = kidsRunning
3614
+ ? kids.length + ' 个子会话 · 有任务在跑(点' + (opened ? '收起' : '展开') + ')'
3615
+ : kids.length + ' 个子会话(点' + (opened ? '收起' : '展开') + ')';
3616
+ if (kidsRunning) chip.appendChild(elSpan('srow__kids-dot'));
3617
+ chip.appendChild(elSpan('srow__kids-n', kids.length + ' 子会话'));
3618
+ chip.appendChild(svgUse('#i-chev-r', 12));
3619
+ var toggleKid = function (e) {
3620
+ if (e) { e.preventDefault(); e.stopPropagation(); }
3621
+ toggleKids(sid);
3622
+ };
3623
+ /* 父行有长按菜单与点击打开:徽标上的指针 / 点击事件都要截住,别让它们穿到父行。 */
3624
+ chip.addEventListener('pointerdown', function (e) { e.stopPropagation(); });
3625
+ chip.addEventListener('pointerup', function (e) { e.stopPropagation(); });
3626
+ chip.addEventListener('click', toggleKid);
3627
+ chip.addEventListener('keydown', function (e) {
3628
+ if (e.key === 'Enter' || e.key === ' ') toggleKid(e);
3629
+ });
3630
+ b.appendChild(chip);
3631
+ }
3632
+ /* 运行中自动置顶的行带一枚小图钉(手动置顶由「置顶」分段说明,不重复画):
3633
+ 只有**这行自己**在跑才画 —— 父行因某个子会话在跑而被提上来时,行上不画图钉
3634
+ (计数徽标会亮运行色),否则会误读成「父会话在跑」。 */
3635
+ if (run && !isPinned(s)) {
3636
+ var autoPin = elSpan('srow__auto');
3637
+ autoPin.title = '运行中 · 自动临时置顶(跑完自动归位)';
3638
+ autoPin.appendChild(svgUse('#i-pin', 14));
3639
+ b.appendChild(autoPin);
3640
+ }
3255
3641
  var timeEl = elSpan('srow__time', run ? elapsedText(s) : fmtRel(s.updated_at || s.started_at));
3256
3642
  b.appendChild(timeEl);
3257
3643
  if (run) { RUN_ROWS[sid] = { subEl: subEl, timeEl: timeEl, s: s }; refreshProgress(sid); }
@@ -3373,7 +3759,10 @@ button.setrow{font-family:inherit}
3373
3759
 
3374
3760
  /* 搜索态:不分组、不出「分组视图」行,命中项平铺(对齐截图 2)。 */
3375
3761
  if (searching) {
3376
- var matched = query ? rows.filter(taskMatches) : rows;
3762
+ var matched = (query ? rows.filter(taskMatches) : rows).slice();
3763
+ /* 与分组列表同一口径:运行中的自动临时置顶排最前(搜索是临时态,跑完同样归位)。 */
3764
+ /* 与分组列表同一口径:人工置顶在前,运行中的自动临时置顶紧随其后(搜索是临时态,跑完归位)。 */
3765
+ matched.sort(pinnedOrder);
3377
3766
  if (!matched.length && query) {
3378
3767
  var miss = document.createElement('div'); miss.className = 'history__hint';
3379
3768
  miss.textContent = '没有匹配「' + TASK_QUERY + '」的任务。';
@@ -3389,9 +3778,11 @@ button.setrow{font-family:inherit}
3389
3778
  return;
3390
3779
  }
3391
3780
 
3392
- /* 置顶:只在这里显示;置顶项不再出现在「任务 / 空间」里(去重) */
3393
- var pinned = rows.filter(isPinned);
3394
- var rest = rows.filter(function (s) { return !isPinned(s); });
3781
+ /* 置顶:手动 pin + **运行中自动临时置顶**(不写 PINNED,跑完归位);
3782
+ 只在这里显示,置顶项不再出现在「任务 / 空间」里(去重)。 */
3783
+ var pinned = rows.filter(isListPinned);
3784
+ var rest = rows.filter(function (s) { return !isListPinned(s); });
3785
+ pinned.sort(pinnedOrder);
3395
3786
 
3396
3787
  /* 任务:**不属于任何空间**的普通任务(且未置顶)+ 默认空间(不指定)的串行等待任务。
3397
3788
  显式选了空间的等待任务归到下面的「空间」组(用户 2026-10-20:「串行等待中的任务
@@ -3605,451 +3996,6 @@ button.setrow{font-family:inherit}
3605
3996
  if (e.key === 'Escape' && readerEl && !readerEl.hidden) { e.preventDefault(); closeReader(); }
3606
3997
  }, true);
3607
3998
 
3608
- /* ══════════ 需求页(会话自动聚合成的功能点文档)══════════
3609
- 数据形状见 CLIENT-CONTRACT §3.4.1:{ok, root, workspaces[{id,dir,name,pending,memory,
3610
- modules:[{name,features:[{module,name,file,desc,points,conclusions,conclusion,
3611
- sources,has_final,final_preview,updated_at}]}],feature_count,last_consolidated_at,last_error}]}
3612
-
3613
- 三个口径(照抄契约,别自己发明):
3614
- · 列表只读:`loadRequirements` 只打 GET /desk/requirements,**不触发归纳**;
3615
- · 「整理」才归纳:POST /desk/requirements/consolidate(可能跑几十秒,按钮态要变);
3616
- · 一篇文档就是一个绝对路径:点行直接进既有阅读器(markdown,不另做渲染)。 */
3617
- var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
3618
- var REQ_BUSY = false; // 「整理」进行中(防重复点击)
3619
- var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
3620
- var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
3621
- var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
3622
- var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
3623
- /* 路径归一:只去尾部斜杠,不做 realpath —— 与 core 登记表里的 workspace 同一口径。 */
3624
- function reqNormDir(p) { return String(p || '').replace(/\/+$/, ''); }
3625
-
3626
- function reqFeatureRow(f) {
3627
- /* 一行 = 一个功能点,**照会话行的排布**做(图标 + 标题 / 副标题 / 右侧时间):
3628
- 标题(功能点名称)→ 归纳结论 / 需求描述 → 「N 需求点 · M 结论 · 来源 K」。
3629
- 为什么优先结论:这一页要回答的是「这个功能定了什么」,不是「记录了多少条」。 */
3630
- var b = hEl('button', 'srow srow--req');
3631
- b.type = 'button';
3632
- var ico = hEl('span', 'srow__ico srow__ico--idle');
3633
- ico.appendChild(svgUse('#i-doc', 20));
3634
- b.appendChild(ico);
3635
- var col = hEl('span', 'srow__col');
3636
- col.appendChild(hEl('span', 'srow__t', f.name || '(未命名)'));
3637
- /* 先结论,再一句话描述,最后才是定稿预览。定稿预览是 markdown(可能带 `**目标**:`),
3638
- 直接塞进这一行会把星号原样显示出来;有 desc 时它更适合当副标题。 */
3639
- var sub = f.conclusion || f.desc || f.final_preview || '';
3640
- if (sub) col.appendChild(hEl('span', 'srow__s', sub));
3641
- var meta = [];
3642
- if (f.points) meta.push(f.points + ' 需求点');
3643
- if (f.conclusions) meta.push(f.conclusions + ' 结论');
3644
- if (f.sources) meta.push('来源 ' + f.sources);
3645
- if (!f.has_final) meta.push('未定稿');
3646
- if (meta.length) col.appendChild(hEl('span', 'srow__s', meta.join(' · ')));
3647
- b.appendChild(col);
3648
- if (f.updated_at) {
3649
- var d = new Date(Number(f.updated_at) || 0);
3650
- if (!isNaN(d.getTime())) {
3651
- b.appendChild(hEl('span', 'srow__time',
3652
- (d.getMonth() + 1) + '-' + ('0' + d.getDate()).slice(-2)));
3653
- }
3654
- }
3655
- b.onclick = function () { openReader(f.file); };
3656
- return b;
3657
- }
3658
-
3659
- /* 一级:空间目录。空间是行,模块缩进挂在空间下(树形),**功能点一律不画在这里**。
3660
- 一级只回答「这个空间有哪几块、每块多少条」—— 需求一多时画功能点会把目录撑成长列表,
3661
- 结构反而看不见了(2026-10-22 用户定稿:两级,一级只显示空间和模块)。
3662
- idx = 排序后的位次(0 = 需求条数最多的那个)。 */
3663
- function reqWorkspaceBlock(w, idx) {
3664
- var wrap = hEl('div', '');
3665
- var head = hEl('div', 'wsrow__head');
3666
- var key = w.id || w.dir || w.name || '';
3667
- var count = Number(w.feature_count) || 0;
3668
- /* 默认只展开**第一个**(需求条数最多的那个),其余折叠(用户 2026-10-22):
3669
- 空间一多,全展开就是一屏拉到底的长列表,什么结构都看不出来。
3670
- 用户手动点过谁就按用户的来(REQ_OPEN 里有记录就不动它)。 */
3671
- var open = (REQ_OPEN[key] !== undefined) ? !!REQ_OPEN[key] : (idx === 0);
3672
- var tog = document.createElement('button');
3673
- tog.type = 'button'; tog.className = 'wsrow__open wsrow__open--req';
3674
- tog.appendChild(elSpan('wsrow__l', w.name || w.dir || w.id));
3675
- /* 模块数 + 功能点数:一行说清这个空间有多大,比只写空间名有用。
3676
- 用**中性色**(不是 wsrow__badge 那枚 queued 紫)—— 那是「等待中」的语义色,
3677
- 空间规模不是状态,套上会让人误以为这个空间在排队。 */
3678
- var mods = (w.modules || []).filter(function (m) { return (m.features || []).length; });
3679
- tog.appendChild(elSpan('wsrow__meta', mods.length + ' 模块 · ' + count + ' 条'));
3680
- /* 待整理条数做成徽标(与「等待中」同款):折叠起来也看得见这个空间在长新需求。 */
3681
- if (w.pending > 0) tog.appendChild(elSpan('wsrow__badge', w.pending + ' 待整理'));
3682
- /* 未初始化 = 还没按空间目录生成过基线。老 core 不带 initialized 字段(undefined),
3683
- 此时不提示 —— 不能把「不知道」画成「未初始化」。 */
3684
- if (w.initialized === false) tog.appendChild(elSpan('wsrow__badge wsrow__badge--init', '未初始化'));
3685
- var chev = elSpan('wsrow__chev' + (open ? ' is-open' : ''));
3686
- chev.appendChild(svgUse('#i-chev-r', 16));
3687
- tog.appendChild(chev);
3688
- tog.onclick = function () { REQ_OPEN[key] = !open; renderRequirements(); };
3689
- head.appendChild(tog);
3690
- /* 组头右侧的「整理」:只作用于这个空间(初始化它 / 归纳它的新会话)。 */
3691
- var sync = document.createElement('button');
3692
- sync.type = 'button'; sync.className = 'wsrow__sync'; sync.textContent = '整理';
3693
- sync.setAttribute('data-ws', key);
3694
- sync.onclick = function (ev) {
3695
- if (ev && ev.stopPropagation) ev.stopPropagation();
3696
- consolidateRequirements(w);
3697
- };
3698
- head.appendChild(sync);
3699
- wrap.appendChild(head);
3700
- if (!open) return wrap;
3701
-
3702
- if (w.last_error) {
3703
- var err = hEl('div', 'history__hint', '上次整理失败:' + w.last_error);
3704
- err.style.color = 'var(--warning)';
3705
- wrap.appendChild(err);
3706
- }
3707
- var shown = 0;
3708
- (w.modules || []).forEach(function (m) {
3709
- var fs = (m.features || []);
3710
- if (!fs.length) return;
3711
- shown += fs.length;
3712
- /* 模块 = 目录里的一行(缩进 + 连接线归属到空间),点它进二级看需求列表。
3713
- 未归类(name 为空)的老数据没有模块名可写 —— 这种情况直接不画这一行,
3714
- 功能点仍能在二级里看到(core 把空 module 排在最后)。 */
3715
- if (!m.name) return;
3716
- var row = document.createElement('button');
3717
- row.type = 'button'; row.className = 'reqtree__mod';
3718
- row.appendChild(elSpan('reqtree__modname', m.name));
3719
- row.appendChild(elSpan('reqtree__modcount', String(fs.length)));
3720
- var go = elSpan('reqtree__modgo');
3721
- go.appendChild(svgUse('#i-chev-r', 16));
3722
- row.appendChild(go);
3723
- row.onclick = function () { openReqDetail(w, m.name); };
3724
- wrap.appendChild(row);
3725
- });
3726
- /* 空间还在但还没有文档:别画成一片空白,给出这一组的下一步(整理)。 */
3727
- if (!shown) {
3728
- var tip = Number(w.pending) > 0
3729
- ? '有 ' + w.pending + ' 条对话待整理'
3730
- : (w.initialized === false ? '还没有需求文档,点右侧「整理」按空间生成' : '还没有需求文档');
3731
- var hint = hEl('div', 'history__hint', tip);
3732
- hint.style.padding = '6px 12px 12px';
3733
- wrap.appendChild(hint);
3734
- }
3735
- return wrap;
3736
- }
3737
-
3738
- /* 二级:某空间下**所有模块**的需求列表(用户 2026-10-22 定稿)。
3739
- 进来的那个模块高亮,滚到它那儿;其余模块照常列出来 —— 需求之间有交叉,
3740
- 只看当前模块会漏掉相关需求。 */
3741
- function reqDetailBlock(d) {
3742
- var w = d.ws;
3743
- var frag = document.createDocumentFragment();
3744
- (w.modules || []).forEach(function (m) {
3745
- var fs = (m.features || []);
3746
- if (!fs.length) return;
3747
- var sec = hEl('div', 'reqdetail__sec' + (m.name === d.mod ? ' reqdetail__sec--cur' : ''));
3748
- var mh = hEl('div', 'sechead');
3749
- mh.style.paddingLeft = '16px';
3750
- mh.appendChild(elSpan('sechead__t', (m.name || '未归类') + ' (' + fs.length + ')'));
3751
- sec.appendChild(mh);
3752
- fs.forEach(function (f) { sec.appendChild(reqFeatureRow(f)); });
3753
- frag.appendChild(sec);
3754
- });
3755
- if (!frag.childNodes.length) {
3756
- frag.appendChild(hEl('div', 'history__hint', '这个空间还没有需求文档'));
3757
- }
3758
- return frag;
3759
- }
3760
-
3761
- /* 进二级:记下是哪个空间的哪个模块,渲染后滚到那个模块。 */
3762
- function openReqDetail(w, mod) {
3763
- var key = w.id || w.dir || w.name || '';
3764
- REQ_DETAIL = { key: key, mod: mod, ws: w };
3765
- renderRequirements();
3766
- /* 空间在两次渲染之间被删掉了(会话全删 → 空间联动消失):
3767
- renderRequirements 会把 REQ_DETAIL 清空,这时别停在空白的二级页,退回目录。 */
3768
- if (!REQ_DETAIL) { hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false; return; }
3769
- hideAll(); reqDetailEl.hidden = false; tabbarEl.hidden = true;
3770
- if (reqDetailTitle) reqDetailTitle.textContent = (w.name || w.dir || w.id) + ' · ' + mod;
3771
- /* 滚到当前模块:它高亮,但用户进来第一眼要看到的是自己点的那一块。 */
3772
- if (reqDetailListEl) {
3773
- var cur = reqDetailListEl.querySelector('.reqdetail__sec--cur');
3774
- if (cur && cur.scrollIntoView) cur.scrollIntoView({ block: 'start' });
3775
- }
3776
- }
3777
-
3778
- /* 退二级回一级目录。 */
3779
- function closeReqDetail() {
3780
- REQ_DETAIL = null;
3781
- hideAll(); reqsEl.hidden = false; tabbarEl.hidden = false;
3782
- renderRequirements();
3783
- }
3784
-
3785
- function renderRequirements() {
3786
- if (!reqListEl) return;
3787
- var data = REQ_DATA;
3788
- if (!data) return;
3789
- /* 空间清单与任务页对齐:**用与 collectSpaceKeys 同一套空间键过滤**
3790
- (用户 2026-10-22:「4个空间需求里显示的不一样」「会话中全部删除后需求中的空间还在
3791
- 也要联动删除」)。没有文档的空间不再整块藏掉,而是留一个组头 + 「整理」按钮 ——
3792
- 这样两页的空间数一致,也给了「这个空间还没生成需求」的入口。
3793
- 拿不到会话/目录清单时(REQ_ALIVE=null)不筛。 */
3794
- var all = (data && data.workspaces) || [];
3795
- var list = all.filter(function (w) {
3796
- var dir = reqNormDir(w && w.dir);
3797
- /* 根目录 / 归一后是空串 —— 「!dir 就放行」的老写法会把它漏进来,
3798
- 需求页于是出现一个叫「/」的假空间(用户 2026-10-22:「没有空间的不需要」)。
3799
- 这里先卡一道:不是真空间目录的直接不画。 */
3800
- if (!isRealSpaceDir(dir)) return false;
3801
- return !REQ_ALIVE || !dir || !!REQ_ALIVE[dir];
3802
- });
3803
- /* 排序:**需求条数多的排前面**(用户 2026-10-22)。
3804
- core 给的顺序是「最后归纳时间倒序」,但那是给「最近在动的」用的;
3805
- 需求页要回答的是「哪块需求最多」,条数多的才是用户要先看的那个。
3806
- 条数一样时按名字排 —— 同一条数下顺序要稳定,不能每次刷新都在跳。 */
3807
- list.sort(function (a, b) {
3808
- var ca = Number(a && a.feature_count) || 0;
3809
- var cb = Number(b && b.feature_count) || 0;
3810
- if (ca !== cb) return cb - ca;
3811
- return String((a && a.name) || '').localeCompare(String((b && b.name) || ''));
3812
- });
3813
- /* 二级视图:只画这个空间的需求列表。空间可能已被会话删除联动掉
3814
- (list 里没有它)—— 那就退回一级目录,不要留一页空白。 */
3815
- if (REQ_DETAIL) {
3816
- var hit = null;
3817
- for (var i = 0; i < list.length; i++) {
3818
- if ((list[i].id || list[i].dir || list[i].name || '') === REQ_DETAIL.key) { hit = list[i]; break; }
3819
- }
3820
- if (!hit) { REQ_DETAIL = null; }
3821
- else {
3822
- REQ_DETAIL.ws = hit;
3823
- if (reqDetailTitle) reqDetailTitle.textContent = (hit.name || hit.dir || hit.id) + ' · ' + REQ_DETAIL.mod;
3824
- if (reqDetailListEl) reqDetailListEl.replaceChildren(reqDetailBlock(REQ_DETAIL));
3825
- if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
3826
- return;
3827
- }
3828
- }
3829
- var frag = document.createDocumentFragment();
3830
- if (!list.length) {
3831
- /* 一个空间都没有(会话全删了 / 还没聊过)。 */
3832
- frag.appendChild(hEl('div', 'history__hint',
3833
- REQ_ALIVE
3834
- ? '还没有任何空间。在任务里聊几句,这里会按空间整理出功能点文档。'
3835
- : '还没有任何需求。在任务里聊几句,这里会自动整理出功能点文档。'));
3836
- reqListEl.replaceChildren(frag);
3837
- return;
3838
- }
3839
- /* 工作区顺序:上面已按需求条数排好(多的在前)。默认只展开第一个 ——
3840
- 见 reqWorkspaceBlock 的 open 判定。 */
3841
- list.forEach(function (w, i) { frag.appendChild(reqWorkspaceBlock(w, i)); });
3842
- reqListEl.replaceChildren(frag);
3843
- /* 整理进行中切走再回来:新 DOM 的按钮默认是可点的,这里按当前状态重新置忙。 */
3844
- if (REQ_BUSY) reqSetBusy(true, REQ_BUSY_LABEL);
3845
- }
3846
-
3847
- function loadRequirements(quiet) {
3848
- if (!reqListEl) return;
3849
- if (!quiet && !REQ_DATA) reqListEl.innerHTML = '<div class="history__hint">读取中…</div>';
3850
- var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
3851
- var pReq = fetch(BASE + '/desk/requirements', { headers: hdr })
3852
- .then(function (r) {
3853
- if (r.status === 501) {
3854
- /* 老 core 没有这个能力位:**说清楚**,不要给一页空白
3855
- (空白看起来像「没有需求」,是最难查的一种)。 */
3856
- return r.json().then(function (e) { throw new Error((e && e.error) || '当前 core 不支持需求聚合'); });
3857
- }
3858
- if (!r.ok) throw new Error('HTTP ' + r.status);
3859
- return r.json();
3860
- });
3861
- /* 会话清单与空间目录:用来判断「这个空间在任务页还在不在」。
3862
- 两者都允许失败 —— 拿不到就不筛空间(见 renderRequirements)。 */
3863
- var pSess = fetch(BASE + '/desk/sessions', { headers: hdr })
3864
- .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
3865
- var pDirs = fetch(BASE + '/desk/dirs', { headers: hdr })
3866
- .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
3867
- Promise.all([pReq, pSess, pDirs])
3868
- .then(function (a) {
3869
- var sess = a[1], dirs = (a[2] && a[2].dirs) || null;
3870
- if (Array.isArray(sess) || Array.isArray(dirs)) {
3871
- /* 与任务页**同一套空间键**(用户 2026-10-22:「4个空间需求里显示的不一样」
3872
- 「会话中全部删除后需求中的空间还在 也要联动删除」「没有空间的不需要」):
3873
- ① 根目录下的 magic-* ② 自定义空间 ③ **顶层会话**的工作目录。
3874
- ⚠️ ③ 只能取**顶层**会话的 workspace:团队子代理的会话挂在父会话下面,
3875
- 它们的工作目录是 /…/run-xxx/a1 这种临时目录 —— 任务页不把它们列成空间,
3876
- 需求页照收就会多出一堆 a1/t1/tmp 假空间。
3877
- ⚠️ 根目录 / 与 /tmp 这类系统目录不是空间,哪怕历史上有会话在那儿跑过。 */
3878
- var alive = {};
3879
- (ROOT_DIRS || []).forEach(function (d) { if (d && d.path) alive[reqNormDir(d.path)] = 1; });
3880
- (dirs || []).forEach(function (d) {
3881
- if (d && d.path && String(d.name || '').indexOf('magic-') === 0) alive[reqNormDir(d.path)] = 1;
3882
- });
3883
- (CUSTOM_SPACES || []).forEach(function (p) { if (p) alive[reqNormDir(p)] = 1; });
3884
- topLevelSessions(sess).forEach(function (s) {
3885
- if (s && s.workspace && isRealSpaceDir(s.workspace)) alive[reqNormDir(s.workspace)] = 1;
3886
- });
3887
- REQ_ALIVE = alive;
3888
- } else {
3889
- REQ_ALIVE = null; // 清单没拿到:不筛,宁可多显示
3890
- }
3891
- REQ_DATA = a[0] || { workspaces: [] };
3892
- renderRequirements();
3893
- })
3894
- .catch(function (e) {
3895
- if (quiet && REQ_DATA) return; // 后台刷新失败不覆盖已有画面
3896
- reqListEl.innerHTML = '';
3897
- reqListEl.appendChild(hEl('div', 'history__hint', '读取需求失败:' + ((e && e.message) || e)));
3898
- });
3899
- }
3900
-
3901
- /* 「整理」:手动触发一轮(POST /desk/requirements/consolidate)。
3902
- 服务端按 state 判断走哪条路:
3903
- · 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
3904
- · 已初始化 → 只消费上次整理之后的新会话。
3905
- 入口有两个:
3906
- · 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
3907
- · 右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
3908
- 两个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
3909
- function reqPostConsolidate(ws) {
3910
- var hdr = { 'Content-Type': 'application/json' };
3911
- if (TOKEN) hdr['X-Magic-Token'] = TOKEN;
3912
- return fetch(BASE + '/desk/requirements/consolidate', {
3913
- method: 'POST', headers: hdr,
3914
- body: JSON.stringify({
3915
- workspace: ws || '',
3916
- engine: RUN_PREF.engine || '',
3917
- model: (RUN_PREF.engine && RUN_PREF.model) || ''
3918
- })
3919
- })
3920
- .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
3921
- .then(function (x) {
3922
- var d = x.d || {};
3923
- if (!x.ok) throw new Error(d.error || ('HTTP ' + (x.d && x.d.status)));
3924
- if (d.error) throw new Error(d.error);
3925
- return d;
3926
- });
3927
- }
3928
-
3929
- /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
3930
- label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
3931
- 二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
3932
- function reqSetBusy(on, label) {
3933
- if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
3934
- else REQ_BUSY_LABEL = '全部整理';
3935
- var btn = document.getElementById('btnReqSync');
3936
- if (btn) {
3937
- btn.disabled = !!on;
3938
- if (label !== undefined) btn.textContent = label;
3939
- }
3940
- var dbtn = document.getElementById('btnReqDetailSync');
3941
- if (dbtn) {
3942
- dbtn.disabled = !!on;
3943
- dbtn.textContent = on ? '整理中…' : '整理';
3944
- }
3945
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3946
- b.disabled = !!on;
3947
- b.textContent = on ? '整理中…' : '整理';
3948
- });
3949
- }
3950
-
3951
- function consolidateRequirements(target) {
3952
- if (REQ_BUSY || !target) return;
3953
- var ws = target.dir || target.id || '';
3954
- if (!ws) return;
3955
- var firstTime = target.initialized === false;
3956
- REQ_BUSY = true;
3957
- reqSetBusy(true, firstTime ? '初始化中…' : '整理中…');
3958
- var targetKey = target.id || target.dir || '';
3959
- var groupBtn = null;
3960
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
3961
- if (!groupBtn && targetKey && b.getAttribute('data-ws') === targetKey) groupBtn = b;
3962
- });
3963
- /* 点的是哪个空间,就只让哪个组头显示「初始化中…」;其它组头保持「整理中…」。 */
3964
- if (groupBtn) groupBtn.textContent = firstTime ? '初始化中…' : '整理中…';
3965
- reqPostConsolidate(ws)
3966
- .then(function (d) {
3967
- if (d.bootstrap) {
3968
- if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
3969
- else toast('已按空间目录初始化:暂未识别到功能点');
3970
- }
3971
- else if (d.noop) toast('没有新的对话需要整理');
3972
- else if (d.updated || d.created) toast('整理完成:更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
3973
- else toast('整理完成');
3974
- loadRequirements(true); // 后台刷新(不闪「读取中」)
3975
- })
3976
- .catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
3977
- .then(function () {
3978
- REQ_BUSY = false;
3979
- reqSetBusy(false, '全部整理');
3980
- });
3981
- }
3982
-
3983
- /* 全部整理:按空间串行跑,避免多个初始化同时读目录把机器拖垮。
3984
- 顺序 = 有待整理的 → 未初始化的 → 其它(最近动过的仍在前),先做便宜/该做的。
3985
- 每个空间单独一次 HTTP(各自 10 分钟超时);某个失败不影响后面的。 */
3986
- function reqAllWorkspaces() {
3987
- var seen = {};
3988
- var list = ((REQ_DATA && REQ_DATA.workspaces) || []).filter(function (w) {
3989
- var key = w.dir || w.id || '';
3990
- if (!key || seen[key]) return false;
3991
- /* 会话已全部删除的空间不参与「全部整理」:否则又会把它重新生成出来。 */
3992
- var dir = reqNormDir(w.dir);
3993
- if (REQ_ALIVE && dir && !REQ_ALIVE[dir]) return false;
3994
- seen[key] = true;
3995
- return true;
3996
- });
3997
- function rank(w) {
3998
- if (Number(w.pending) > 0) return 0;
3999
- if (w.initialized === false) return 1;
4000
- return 2;
4001
- }
4002
- return list.sort(function (a, b) { return rank(a) - rank(b); });
4003
- }
4004
-
4005
- function consolidateAllRequirements() {
4006
- if (REQ_BUSY) return;
4007
- var list = reqAllWorkspaces();
4008
- if (!list.length) {
4009
- toast('还没有可整理的空间');
4010
- return;
4011
- }
4012
- REQ_BUSY = true;
4013
- reqSetBusy(true, '整理中 0/' + list.length);
4014
- var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
4015
- function done() {
4016
- REQ_BUSY = false;
4017
- reqSetBusy(false, '全部整理');
4018
- loadRequirements(true);
4019
- if (fail) {
4020
- toast('全部整理完成:成功 ' + ok + ' · 失败 ' + fail + (firstErr ? '(' + firstErr + ')' : ''));
4021
- } else {
4022
- toast('全部整理完成:' + ok + ' 个空间 · 新建 ' + created + ' · 更新 ' + updated);
4023
- }
4024
- }
4025
- function step() {
4026
- if (i >= list.length) { done(); return; }
4027
- var w = list[i++];
4028
- REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
4029
- var btn = document.getElementById('btnReqSync');
4030
- if (btn) btn.textContent = REQ_BUSY_LABEL;
4031
- reqPostConsolidate(w.dir || w.id || '')
4032
- .then(function (d) {
4033
- ok++;
4034
- created += Number(d.created) || 0;
4035
- updated += Number(d.updated) || 0;
4036
- })
4037
- .catch(function (e) {
4038
- fail++;
4039
- if (!firstErr) firstErr = (e && e.message) || String(e);
4040
- })
4041
- .then(step);
4042
- }
4043
- step();
4044
- }
4045
- on('btnReqSync', function () { consolidateAllRequirements(); });
4046
- /* 二级视图的两个动作:返回一级目录 / 整理**当前这个空间**(不是全部空间 ——
4047
- 二级页的作用域就是这个空间,标成「全部整理」会让人以为还是全局动作)。 */
4048
- on('btnReqDetailBack', function () { closeReqDetail(); });
4049
- on('btnReqDetailSync', function () {
4050
- if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
4051
- consolidateRequirements(REQ_DETAIL.ws);
4052
- });
4053
3999
 
4054
4000
  function openHostSheet(title, build) {
4055
4001
  closeHostSheet();
@@ -4104,6 +4050,11 @@ button.setrow{font-family:inherit}
4104
4050
  if (permBtn) permBtn.classList.toggle('setrow--danger', !!(conv && conv.permissionTier && conv.permissionTier() === 'full'));
4105
4051
  var t = document.getElementById('rsTeam');
4106
4052
  if (t) t.textContent = (conv && conv.teamLabel) ? conv.teamLabel() : '关闭';
4053
+ /* 会话托管(2026-10-23,按会话):与团队模式并列在同一张卡里。
4054
+ 标签读**组件此刻的真实状态**(子会话可能是从父会话继承来的开启值)。 */
4055
+ var h = document.getElementById('rsHosting');
4056
+ if (h) h.textContent = !SUGGEST.engine ? '未配置建议引擎'
4057
+ : ((conv && typeof conv.hostingOn === 'function' && conv.hostingOn()) ? '开启' : '关闭');
4107
4058
  /* 团队模式:与权限模式并列(用户 2026-10-21:「团队模式开关在界面右上角跟权限模式一样」)。
4108
4059
  core 没有这个能力位时**不显示**这一行 —— 显示一个点了必然失败的开关是最坏的做法。 */
4109
4060
  var showTeam = !conv || typeof conv.featureOn !== 'function' || conv.featureOn('feature.session.team');
@@ -4173,7 +4124,20 @@ button.setrow{font-family:inherit}
4173
4124
  /* team:重发时原样还原团队配置(见 fireQueued)——
4174
4125
  丢了它这条排队任务就会从「团队」静默降级成单任务。 */
4175
4126
  team: info.team || null,
4127
+ /* hosted:托管自动发的那一轮被串行门挡住 / 重启恢复时要原样还原,
4128
+ 否则重发出去的历史事件会退回「用户亲手发的」画法。 */
4129
+ hosted: !!info.hosted,
4176
4130
  attachments: (info.attachments || []).slice(),
4131
+ /* atts:气泡缩略图的快照({name, remoteUrl},见 conversation.js 的 notifyQueue)。
4132
+ 重进会话时 adoptPendingFor 用它把排队气泡原样画回来 —— 只存路径的话恢复出来会丢图。 */
4133
+ atts: (info.atts || []).slice(),
4134
+ /* owner:谁负责把这一条真正发出去。
4135
+ 'gate' = 串行门(gateAsk,放行后由这一轮自己下发)
4136
+ 'host' = 宿主(restoreQueue / 重启恢复,见 enqueueDispatch)
4137
+ 'conv' = 会话组件(组件内排队,轮到它时组件自己 flush)
4138
+ 组件被销毁重建后,它的 owner='conv' 条目还留在 PENDING 里,由
4139
+ adoptPendingFor 接回新实例 —— 状态只能落在条目上,不能只存在组件内存。 */
4140
+ owner: info.owner || 'gate',
4177
4141
  hide: !!hide, dev: devKey(), at: Date.now() };
4178
4142
  PENDING.push(p); saveQueue();
4179
4143
  if (!hide) renderSessions(LAST_SESSIONS);
@@ -4194,11 +4158,12 @@ button.setrow{font-family:inherit}
4194
4158
  (2026-10-21 用户:「非团队的串行排队也没落库」)。
4195
4159
  这里把它接进同一个 PENDING:进队即写 magic.serial.queue.v1、轮到就删行、取消就清盘。
4196
4160
  重开 App 后由 restoreQueue 直接下发(不挂会话页面,事件仍进会话日志,点开可回放)。 */
4197
- var QPEND = {}; // 组件条目 id(q…)→ 本端 PENDING id(p…)
4161
+ var QPEND = {}; // 组件条目 id(q… / 重新接管后的 p…)→ 本端 PENDING id(p…)
4198
4162
  function queuePushed(info) {
4199
4163
  if (!info || !info.id || !info.prompt || QPEND[info.id]) return;
4200
4164
  /* hide:用户此刻就在会话里,气泡已经画出来了 —— 列表那条「等待中」延后
4201
4165
  PENDING_SHOW_MS 再出现(与 gateAsk 同一口径:空间本来就空闲时不会闪一条假排队)。 */
4166
+ info.owner = 'conv';
4202
4167
  var p = addPending(wsKey(info.workspace), info, true);
4203
4168
  QPEND[info.id] = p.id;
4204
4169
  setTimeout(function () {
@@ -4243,7 +4208,10 @@ button.setrow{font-family:inherit}
4243
4208
  session: p.session || '',
4244
4209
  prompt: p.prompt || '', engine: p.engine || '', model: p.model || '',
4245
4210
  permission: p.permission || '', team: p.team || null,
4246
- attachments: p.attachments || [],
4211
+ attachments: p.attachments || [], atts: p.atts || [],
4212
+ /* owner 刻意不落盘:重启后所有残留条目都由 restoreQueue 交给宿主
4213
+ (enqueueDispatch),语义与 'host' 一致 —— 存下来反而会让「上次是
4214
+ 组件排队」的那条永远等不到组件接管。 */
4247
4215
  dev: p.dev, at: p.at };
4248
4216
  });
4249
4217
  try { localStorage.setItem(QUEUE_KEY, JSON.stringify(others.concat(mineRows))); } catch (e) {}
@@ -4330,6 +4298,35 @@ button.setrow{font-family:inherit}
4330
4298
  check();
4331
4299
  });
4332
4300
  }
4301
+ /* 宿主下发的**追问**要等原会话这一轮收尾(同一条会话不许并发两轮)。
4302
+ 与 waitForSlot 的区别:那条按**空间**串行、且只看 TASK_MODE;这条按**会话**,
4303
+ 并行模式也生效 —— 恢复一条「排在上一轮后面的追问」时,正是它在挡住并发。
4304
+ sid 为空(新任务)直接放行;清单拿不到 / 会话已不在 / 等超上限也放行:
4305
+ 排队是**保护**不是**闸门**,不能因为探测失败把用户的任务永久卡住。 */
4306
+ var SESSION_WAIT_MAX_MS = 30 * 60 * 1000;
4307
+ function waitSessionIdle(sid) {
4308
+ if (!sid) return Promise.resolve();
4309
+ var t0 = Date.now();
4310
+ return new Promise(function (resolve) {
4311
+ function check() {
4312
+ var hdr = TOKEN ? { 'X-Magic-Token': TOKEN } : {};
4313
+ fetch(BASE + '/desk/sessions', { headers: hdr })
4314
+ .then(function (r) { return r.ok ? r.json() : null; })
4315
+ .then(function (list) {
4316
+ if (!Array.isArray(list)) { resolve(); return; }
4317
+ var row = null;
4318
+ for (var i = 0; i < list.length; i++) {
4319
+ var s = list[i];
4320
+ if (sidOf(s) === sid || s.session_id === sid || s.run_id === sid) { row = s; break; }
4321
+ }
4322
+ if (!row || !serialBusy(row) || Date.now() - t0 >= SESSION_WAIT_MAX_MS) { resolve(); return; }
4323
+ setTimeout(check, SLOT_POLL_MS);
4324
+ })
4325
+ .catch(function () { resolve(); });
4326
+ }
4327
+ check();
4328
+ });
4329
+ }
4333
4330
  /* 排队行的出现时机:**进队即落库**(关 App 也不能丢,这是用户报障的正题);
4334
4331
  列表上那条「等待中」行则由**是否真的要等**决定可见性(见 gateAsk 的 markWaiting)。
4335
4332
  这个常量只剩一处用处:组件内排队(queuePushed,同一会话上一轮还没收尾)在
@@ -4452,6 +4449,9 @@ button.setrow{font-family:inherit}
4452
4449
  var list = loadQueue();
4453
4450
  if (!list.length) return;
4454
4451
  list.sort(function (a, b) { return (a.at || 0) - (b.at || 0); }); // 按当时进队顺序
4452
+ /* 重启恢复的条目一律归宿主下发:它们的主人(会话组件)已经不在了,
4453
+ 再标成 'conv' 就没人会认领(见 adoptPendingFor 的判据)。 */
4454
+ list.forEach(function (p) { p.owner = 'host'; });
4455
4455
  PENDING = list;
4456
4456
  renderSessions(LAST_SESSIONS);
4457
4457
  list.forEach(function (p) { enqueueDispatch(p); });
@@ -4461,11 +4461,20 @@ button.setrow{font-family:inherit}
4461
4461
  var q = serialQ(ws);
4462
4462
  /* 重新尝试(用户点「未能启动」那条):先把红字连原因一起摘掉,回到正常的排队态。 */
4463
4463
  if (p.stalled) { p.stalled = false; p.stalledReason = ''; renderSessions(LAST_SESSIONS); }
4464
- var mine = q.tail.then(function () { return waitForSlot(ws, q); }).then(function () {
4465
- /* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
4466
- if (!connected()) return false;
4467
- return fireQueued(p);
4468
- });
4464
+ var mine = q.tail.then(function () { return waitForSlot(ws, q); })
4465
+ /* 同一条会话同时只能跑一轮:宿主下发的「追问」必须等这一轮真的收尾。
4466
+ 并行模式(waitForSlot 直接放行)下尤其重要 —— 否则新进程和还在跑的那一轮
4467
+ 会同时往同一份会话日志里写,正文与思考区交错,是最难查的一类脏数据。 */
4468
+ .then(function () { return waitSessionIdle(p.session); })
4469
+ .then(function () {
4470
+ /* 断线 / 还没配对:**留在队列里**,等下次连上再发 —— 不能当成「已下发」删掉。 */
4471
+ if (!connected()) return false;
4472
+ /* 等待期间用户点进了这条会话,组件已经把它接管回去了(adoptPendingFor 把 owner
4473
+ 改回 'conv')→ 让组件自己 flush,宿主这里放手。不检查的话同一条会被发两次。 */
4474
+ if (p.owner !== 'host') return false;
4475
+ p.dispatching = true; // 已交给 fireQueued:此后不许组件再接管(防双发)
4476
+ return fireQueued(p);
4477
+ });
4469
4478
  q.tail = mine.catch(function () { return false; });
4470
4479
  return mine.then(function (sent) {
4471
4480
  if (!sent) return; // 没落盘:留在队列里(stalled),不删
@@ -4496,6 +4505,8 @@ button.setrow{font-family:inherit}
4496
4505
  /* 团队配置是在发送那一刻快照下来的(见 addPending):重发必须带上,
4497
4506
  否则这条排队任务会被当成普通单任务跑。 */
4498
4507
  if (p.team) payload.team = p.team;
4508
+ /* 托管自动发的那一轮:重发也要把标识带上(历史回放才能继续画成「托管发送」)。 */
4509
+ if (p.hosted) payload.hosted = true;
4499
4510
  if (p.attachments && p.attachments.length) payload.attachments = p.attachments;
4500
4511
  return new Promise(function (resolve) {
4501
4512
  var done = false, handle = null, t0 = Date.now();
@@ -4765,6 +4776,40 @@ button.setrow{font-family:inherit}
4765
4776
  会话里输入框上方的级联切换也会回写这里(见 mountConversation 的 onPick)——
4766
4777
  与 PC 端「选过一次就一直用」的语义一致,「我的」里显示的就是下次新建会用的那一组。
4767
4778
  ⚠️ 模型必须挂在引擎下:engine 为空时 model 一律清空,不留孤儿模型。 */
4779
+ /* 建议引擎:只认**显式配置过**的值;没有 / 空串 = 不开启建议功能(默认)。
4780
+ 不能回落到 RUN_PREF.engine —— 那等于「配了默认编码引擎就自动有建议」,
4781
+ 与用户「默认不配置就不开启」相反。 */
4782
+ function loadSuggestPref() {
4783
+ var o = { engine: '', model: '' };
4784
+ try {
4785
+ o.engine = String(localStorage.getItem('magic.suggest.engine') || '').trim();
4786
+ o.model = String(localStorage.getItem('magic.suggest.model') || '').trim();
4787
+ } catch (e) {}
4788
+ if (!o.engine) o.model = '';
4789
+ return o;
4790
+ }
4791
+ function saveSuggestPref() {
4792
+ try {
4793
+ localStorage.setItem('magic.suggest.engine', SUGGEST.engine || '');
4794
+ localStorage.setItem('magic.suggest.model', SUGGEST.model || '');
4795
+ } catch (e) {}
4796
+ schedulePushPrefs();
4797
+ paintSuggestPref();
4798
+ }
4799
+ function paintSuggestPref() {
4800
+ var e = document.getElementById('suggestEngine');
4801
+ var m = document.getElementById('suggestModel');
4802
+ if (e) e.textContent = SUGGEST.engine || '不开启';
4803
+ if (m) m.textContent = SUGGEST.engine ? (qaModelText(SUGGEST.model) || '引擎默认') : '跟随引擎';
4804
+ }
4805
+ function applySuggestToLive() {
4806
+ if (conv && typeof conv.setSuggestEngine === 'function') {
4807
+ conv.setSuggestEngine(SUGGEST.engine, SUGGEST.model);
4808
+ }
4809
+ paintSuggestPref();
4810
+ paintRunSettings();
4811
+ }
4812
+
4768
4813
  function loadRunPref() {
4769
4814
  var o = { engine: '', model: '' };
4770
4815
  try {
@@ -5140,6 +5185,67 @@ button.setrow{font-family:inherit}
5140
5185
  el.style.textAlign = 'left';
5141
5186
  return el;
5142
5187
  }
5188
+ /* 「我的 → 建议引擎」:单列,首项「不开启」。不展示模型 —— 建议是纯文本短任务,
5189
+ 选模型只会让界面更复杂;core 对 llm 会自动挑不思考的变体。 */
5190
+ /* 「我的 → 建议引擎 / 模型」:与默认引擎同一个二列抽屉(左引擎 · 右模型)。
5191
+ 首项「不开启」;点左列只浏览、点右列才落定。llm 放最前,模型为空时 core 自动挑最快变体。 */
5192
+ function openSuggestEnginePicker() {
5193
+ openHostSheet('建议引擎 / 模型', function (body) {
5194
+ body.innerHTML = '';
5195
+ var hint = hEl('div', 'history__hint', '正在探测引擎与模型…');
5196
+ hint.style.textAlign = 'left';
5197
+ body.appendChild(hint);
5198
+ fetchEngines().then(function (list) {
5199
+ if (hint.parentNode) hint.remove();
5200
+ var stale = enginesStaleHint();
5201
+ if (stale) body.appendChild(stale);
5202
+ var avail = (list || []).filter(function (x) { return x && x.ok; });
5203
+ /* 推荐的放最上面:llm 是建议这条旁路最快的引擎。 */
5204
+ avail.sort(function (a, b) {
5205
+ if (a.engine === 'llm') return -1;
5206
+ if (b.engine === 'llm') return 1;
5207
+ return String(a.engine).localeCompare(String(b.engine));
5208
+ });
5209
+ if (!avail.length) {
5210
+ var d = hEl('div', 'history__hint',
5211
+ '没有可用引擎:先在这台机器上装一个(终端跑 magic-agent --engines 会给出安装命令)。');
5212
+ d.style.textAlign = 'left';
5213
+ body.appendChild(d);
5214
+ return;
5215
+ }
5216
+ buildEngineCascade(body, avail, {
5217
+ prefs: SUGGEST,
5218
+ pick: pickSuggestPref,
5219
+ offLabel: '不开启',
5220
+ offDesc: '不生成下一轮建议(默认)',
5221
+ modelDefaultDesc: '不指定模型(llm 会自动挑最快变体)',
5222
+ });
5223
+ /* 建议生成一律不思考。各引擎都走官方关闭参数;某些模型路由不支持时
5224
+ 才在超时/报错后降级 llm,不预先吞掉用户的选择。 */
5225
+ var nk = hEl('div', 'history__hint',
5226
+ '建议生成固定关闭思考:claude --thinking disabled、codex effort none、llm no-think / reasoning_effort、codebuddy alwaysThinkingEnabled=false、dsh reasoningEffort=off;引擎失败才降级 llm。');
5227
+ nk.style.textAlign = 'left';
5228
+ body.appendChild(nk);
5229
+ }, function (e) {
5230
+ hint.textContent = '拉取引擎清单失败:' + ((e && e.message) || e);
5231
+ var retry = hEl('button', 'pick__citem', '重试');
5232
+ retry.type = 'button';
5233
+ retry.onclick = function () { openSuggestEnginePicker(); };
5234
+ body.appendChild(retry);
5235
+ });
5236
+ });
5237
+ }
5238
+ function pickSuggestPref(engine, model) {
5239
+ SUGGEST.engine = String(engine || '').trim();
5240
+ SUGGEST.model = (SUGGEST.engine && model) ? String(model).trim() : '';
5241
+ saveSuggestPref();
5242
+ applySuggestToLive();
5243
+ closeHostSheet();
5244
+ toast(SUGGEST.engine
5245
+ ? ('建议引擎:' + SUGGEST.engine + ' · ' + (qaModelText(SUGGEST.model) || '引擎默认模型'))
5246
+ : '已关闭建议功能');
5247
+ }
5248
+
5143
5249
  function openDefaultEnginePicker() {
5144
5250
  openHostSheet('默认引擎 / 模型', function (body) {
5145
5251
  function load() {
@@ -5172,8 +5278,16 @@ button.setrow{font-family:inherit}
5172
5278
  });
5173
5279
  }
5174
5280
  /* 二列级联(左引擎 · 右该引擎的模型),与对话框里那套同构:
5175
- 点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。 */
5176
- function buildEngineCascade(body, avail) {
5281
+ 点左列只切右列,点右列才落定 —— 避免「引擎换了、模型还挂在旧引擎下」。
5282
+ 默认引擎与建议引擎共用;o 可换目标 / 落定回调 / 关闭项文案。 */
5283
+ function buildEngineCascade(body, avail, o) {
5284
+ o = o || {};
5285
+ var prefs = o.prefs || RUN_PREF;
5286
+ var pick = o.pick || pickRunPref;
5287
+ var offLabel = o.offLabel || '自动';
5288
+ var offDesc = o.offDesc || '首个可用引擎';
5289
+ var modelDefaultDesc = o.modelDefaultDesc || '不指定模型,由引擎自己挑';
5290
+
5177
5291
  var wrap = hEl('div', 'pick__cols');
5178
5292
  var left = hEl('div', 'pick__col');
5179
5293
  var right = hEl('div', 'pick__col pick__col--r');
@@ -5181,24 +5295,24 @@ button.setrow{font-family:inherit}
5181
5295
  body.appendChild(wrap);
5182
5296
 
5183
5297
  var cur = avail[0];
5184
- avail.forEach(function (en) { if (en.engine === RUN_PREF.engine) cur = en; });
5298
+ avail.forEach(function (en) { if (en.engine === prefs.engine) cur = en; });
5185
5299
 
5186
5300
  function paint() {
5187
5301
  left.innerHTML = ''; right.innerHTML = '';
5188
- var auto = hEl('button', 'pick__citem' + (!RUN_PREF.engine ? ' is-on' : ''));
5189
- auto.type = 'button';
5190
- auto.appendChild(hEl('span', 'pick__cl', '自动'));
5191
- auto.appendChild(hEl('span', 'pick__cd', '首个可用引擎'));
5192
- auto.onclick = function () { pickRunPref('', ''); };
5193
- left.appendChild(auto);
5302
+ var off = hEl('button', 'pick__citem' + (!prefs.engine ? ' is-on' : ''));
5303
+ off.type = 'button';
5304
+ off.appendChild(hEl('span', 'pick__cl', offLabel));
5305
+ off.appendChild(hEl('span', 'pick__cd', offDesc));
5306
+ off.onclick = function () { pick('', ''); };
5307
+ left.appendChild(off);
5194
5308
 
5195
5309
  avail.forEach(function (en) {
5196
5310
  var n = (en.models || []).length;
5197
- /* is-on = 已保存的默认(浅蓝实底);is-cur = 右列此刻在浏览谁(左侧细条,无底色)——
5198
- 两者分开,默认是「自动」时不会出现两个一样的选中块。 */
5311
+ /* is-on = 已保存的值(浅蓝实底);is-cur = 右列此刻在浏览谁(左侧细条,无底色)——
5312
+ 两者分开,关闭项选中时不会出现两个一样的选中块。 */
5199
5313
  var b = hEl('button', 'pick__citem'
5200
- + (RUN_PREF.engine === en.engine ? ' is-on' : '')
5201
- + (en.engine === cur.engine && RUN_PREF.engine !== en.engine ? ' is-cur' : ''));
5314
+ + (prefs.engine === en.engine ? ' is-on' : '')
5315
+ + (en.engine === cur.engine && prefs.engine !== en.engine ? ' is-cur' : ''));
5202
5316
  b.type = 'button';
5203
5317
  b.appendChild(hEl('span', 'pick__cl', en.engine));
5204
5318
  b.appendChild(hEl('span', 'pick__cd', n ? (n + ' 个模型') : '引擎默认'));
@@ -5206,20 +5320,20 @@ button.setrow{font-family:inherit}
5206
5320
  left.appendChild(b);
5207
5321
  });
5208
5322
 
5209
- var d = hEl('button', 'pick__citem' + (RUN_PREF.engine === cur.engine && !RUN_PREF.model ? ' is-on' : ''));
5323
+ var d = hEl('button', 'pick__citem' + (prefs.engine === cur.engine && !prefs.model ? ' is-on' : ''));
5210
5324
  d.type = 'button';
5211
5325
  d.appendChild(hEl('span', 'pick__cl', '引擎默认'));
5212
- d.appendChild(hEl('span', 'pick__cd', '不指定模型,由引擎自己挑'));
5213
- d.onclick = function () { pickRunPref(cur.engine, ''); };
5326
+ d.appendChild(hEl('span', 'pick__cd', modelDefaultDesc));
5327
+ d.onclick = function () { pick(cur.engine, ''); };
5214
5328
  right.appendChild(d);
5215
5329
 
5216
5330
  (cur.models || []).forEach(function (mm) {
5217
- var b = hEl('button', 'pick__citem' + (RUN_PREF.engine === cur.engine && RUN_PREF.model === mm ? ' is-on' : ''));
5331
+ var b = hEl('button', 'pick__citem' + (prefs.engine === cur.engine && prefs.model === mm ? ' is-on' : ''));
5218
5332
  b.type = 'button';
5219
- /* 显示剥掉 `custom-local:` 前缀(`mm` 本身是完整 id,选中时原样存进 RUN_PREF),
5333
+ /* 显示剥掉 `custom-local:` 前缀(`mm` 本身是完整 id,选中时原样存进 prefs),
5220
5334
  倍率挂同行右侧。 */
5221
5335
  modelItem(b, cur.engine, mm);
5222
- b.onclick = function () { pickRunPref(cur.engine, mm); };
5336
+ b.onclick = function () { pick(cur.engine, mm); };
5223
5337
  right.appendChild(b);
5224
5338
  });
5225
5339
  }
@@ -5316,11 +5430,46 @@ button.setrow{font-family:inherit}
5316
5430
  /* 权限 / 团队沿用会话组件里那唯一一份选择器(浮层盖在本页之上);选完就地重画。 */
5317
5431
  on('btnRsPermission', function () { if (conv && conv.openPermissionPicker) conv.openPermissionPicker(); });
5318
5432
  on('btnRsTeam', function () { if (conv && conv.openTeamPicker) conv.openTeamPicker(); });
5433
+ /* 会话托管(2026-10-23,按会话):只有开 / 关两档,直接翻当前这条会话。 */
5434
+ on('btnRsHosting', toggleHostingHere);
5319
5435
  /* 选择器由会话组件管理、没有回调通知宿主,所以用一次点击兜底重画:
5320
5436
  本页可见时任何一次点击(包括选择器里点中一行)之后重读状态。 */
5321
5437
  document.addEventListener('click', function () {
5322
5438
  if (!runSettingsPageEl.hidden) paintRunSettings();
5323
5439
  });
5440
+ /* 需求页(reqs.js):内容与逻辑不在本文件,这里只注入宿主能力并挂载一次。
5441
+ getter 形式传 BASE/TOKEN/空间清单 —— 切环境后这些值会变,不能传快照。 */
5442
+ if (window.MagicReqs && window.MagicReqs.mount) {
5443
+ window.MagicReqs.mount({
5444
+ rootEl: reqsEl,
5445
+ detailEl: reqDetailEl,
5446
+ hEl: hEl,
5447
+ elSpan: elSpan,
5448
+ svgUse: svgUse,
5449
+ toast: toast,
5450
+ openReader: openReader,
5451
+ hideAll: hideAll,
5452
+ setActive: setActive,
5453
+ showRoot: function () {
5454
+ hideAll();
5455
+ reqsEl.hidden = false;
5456
+ tabbarEl.hidden = false;
5457
+ setActive('reqs');
5458
+ },
5459
+ showDetail: function () {
5460
+ hideAll();
5461
+ reqDetailEl.hidden = false;
5462
+ tabbarEl.hidden = true;
5463
+ },
5464
+ getBase: function () { return BASE; },
5465
+ getToken: function () { return TOKEN; },
5466
+ getRootDirs: function () { return ROOT_DIRS; },
5467
+ getCustomSpaces: function () { return CUSTOM_SPACES; },
5468
+ getRunPref: function () { return RUN_PREF; },
5469
+ isRealSpaceDir: isRealSpaceDir,
5470
+ topLevelSessions: topLevelSessions
5471
+ });
5472
+ }
5324
5473
  Array.prototype.forEach.call(tabbarEl.querySelectorAll('.tab'), function (b) {
5325
5474
  b.onclick = function () {
5326
5475
  var t = b.getAttribute('data-tab');
@@ -5352,6 +5501,8 @@ button.setrow{font-family:inherit}
5352
5501
  on('btnNotify', openNotifySettings);
5353
5502
  on('btnDefaultEngine', openDefaultEnginePicker);
5354
5503
  on('btnDefaultModel', openDefaultEnginePicker);
5504
+ on('btnSuggestEngine', openSuggestEnginePicker);
5505
+ on('btnSuggestModel', openSuggestEnginePicker);
5355
5506
  /* 团队模式配置(「我的」→ 子页面):计划 + 7 个角色,行由 paintTeamRoles 动态渲染,
5356
5507
  行的点击在渲染时绑定(见 addTeamRouteRow / addTeamRuleRow),这里只留入口与返回。 */
5357
5508
  on('btnTeamPage', showTeamPage);
@@ -5362,11 +5513,10 @@ button.setrow{font-family:inherit}
5362
5513
  if (segTheme) takeSeg(segTheme, 'magic.theme', function (v) { PREFS.theme = v; applyThemePref(v); });
5363
5514
  if (segFont) takeSeg(segFont, 'magic.font', function (v) { PREFS.font = v; applyFontPref(v); });
5364
5515
  if (segMode) takeSeg(segMode, 'magic.taskmode', function (v) { TASK_MODE = v; });
5365
- if (tglHaptic) tglHaptic.onclick = function () {
5366
- PREFS.haptic = (PREFS.haptic === 'off') ? 'on' : 'off';
5367
- savePref('magic.haptic', PREFS.haptic); applyHapticPref(PREFS.haptic);
5368
- tglHaptic.classList.toggle('is-on', PREFS.haptic !== 'off');
5369
- };
5516
+
5517
+ /* 会话托管(2026-10-23 起按会话):入口在会话页 → 任务运行设置(见 btnRsHosting)。
5518
+ 「我的」里不再有全局开关 —— 曾经那枚 `tglHosting` 写的是设备级 `magic.hosting`,
5519
+ 改一条会话会把所有会话都改掉(用户 2026-10-23:「跟团队模式一样…不再是全局」)。 */
5370
5520
 
5371
5521
  /* ══════════ 网络代理(VPN 隧道)══════════
5372
5522
  把整机流量经隧道送到桌面端的 SOCKS5 出口:TUN → 内核 → 桌面端 → 互联网。