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
@@ -165,7 +165,7 @@ codex / dsh(+sdk) / llm / openclaw 暂不带 `item_id`,
165
165
 
166
166
  | 集合 | 位置 | 回答的问题 |
167
167
  | --- | --- | --- |
168
- | `features[]` | `--contract` 顶层 | 这台 core 支不支持某条**通道**(`feature.session.events` / `feature.session.control` / `feature.session.team` / `feature.requirements`) |
168
+ | `features[]` | `--contract` 顶层 | 这台 core 支不支持某条**通道**(`feature.session.events` / `feature.session.control` / `feature.session.hosted` / `feature.session.team` / `feature.requirements`) |
169
169
  | `engines[].capabilities[]` | 每个引擎 | 这个**引擎**能做什么(`session.stream` / `attachment.native` / `engine.install` …) |
170
170
 
171
171
  静态与运行态严格分开:`capabilities` / `features` 是静态声明,`ok` / `streaming` / `models` /
@@ -175,6 +175,11 @@ codex / dsh(+sdk) / llm / openclaw 暂不带 `item_id`,
175
175
  `--team`。客户端据它决定**要不要显示团队模式开关** —— 没有就整个不显示(点了必然 exit 2 的
176
176
  开关比没有更糟)。插件对带 `team` 的请求也先查这个位,缺了回 **501 + 中文原因**。
177
177
 
178
+ `feature.session.hosted`(2026-10-23 只增量,**不**进 `requiredFeatures`):这台 core 认
179
+ `--hosted`。它只给会话历史里的 `kind:"user"` 事件加 `hosted:true`(客户端回放画成
180
+ 「托管发送」),**不改变执行语义**。插件在能力位缺失时忽略请求里的 `hosted`、照常发送 ——
181
+ 这是纯显示字段,不能因为老 core 不认识就阻断用户的任务;实时气泡仍由客户端自己标。
182
+
178
183
  `feature.requirements`(2026-10-22 只增量,**不**进 `requiredFeatures`):这台 core 认
179
184
  `--requirements` / `--consolidate-requirements`(见 §3.4.1)。插件对 `GET /desk/requirements`
180
185
  与 `POST /desk/requirements/consolidate` 都先查这个位,缺了回 **501 + 中文原因**。
@@ -240,23 +245,30 @@ magic-agent -e claude --stream --events --team \
240
245
  **事件形状(客户端要认的只有一条)**:主会话里是**一张任务列表卡**,每个子任务是一条**普通会话**。
241
246
 
242
247
  ```
243
- notice {text:"团队模式:正在生成计划…(已 N 秒)"} ← 计划阶段每 5s 一次
248
+ notice {text:"团队模式:正在生成计划…"} ← 计划落笔(只发一次)
244
249
  notice {text:"计划已生成(N 个子任务):<PLAN.md>"} ← 计划落定
245
250
  tasks {text:"{\"tasks\":[ … ]}"} ← 任务列表**全量快照**
246
- notice {text:"团队模式:t1「<标题>」仍在运行…(已 N 秒无新输出)"} ← 静默 >30s 时每 20s 一次
247
251
  ```
248
252
 
253
+ 计划阶段不再每 5 秒发一条「已 N 秒」(2026-10-22):那一段「引擎还活着」由 core 侧续期
254
+ 会话登记表的 `updated_at` 证明(`follow` 不会误判终态),画面上一行字都不多。
255
+
249
256
  `tasks` 快照(`agent.KindTasks`)的 `text` 是 JSON:
250
257
 
251
258
  ```json
252
259
  {"tasks":[{"id":"t1","title":"写正文","role":"writer","kind":"text",
253
260
  "engine":"claude","model":"","status":"running","deps":["t0"],
261
+ "active":"需要授权:Read(等待用户选择)","runtime_ms":41200,
262
+ "arts":["/abs/path/OUT-t1.md"],
254
263
  "note":"","session_id":"…","run_id":"…","output":"…"}]}
255
264
  ```
256
265
 
257
266
  | 字段 | 说明 |
258
267
  | --- | --- |
259
268
  | `status` | `pending` / `running` / `done` / `failed` / `skipped`。渲染映射:`pending→pending`、`running→in_progress`、`done→completed`、`failed→failed`、`skipped→skipped` |
269
+ | `runtime_ms` | 这一行**已经跑了多久 / 最终跑了多久**(毫秒)。`pending` 行缺省;`running` 行随快照增长;落定后冻结(失败 / 跳过的也冻结)。是绝对值,客户端**不要**本地再计时 |
270
+ | `arts` | 这一行**实际交付的产物**(绝对路径,任务一落定就有)。主会话据此在任务列表卡下方画「子任务产物」区;不带 = 没有产物。存在性仍由宿主探针判(`pathProbe`),探针不可用时不画 |
271
+ | `active` | 仅 `running` 行有值,「此刻在干什么」的一句话:等授权 / 等用户作答,或静默超过 30s 时的「已 N 秒无新输出」。**行内次要信息**(排在「标题 … 状态 耗时」之后,字最小、可省略),**不要**拿它顶掉标题、也不要转成 `notice` |
260
272
  | `deps` | 上游任务 id(有依赖的行口径是「等待上游 t1」)|
261
273
  | `note` | 失败 / 跳过的一句话原因(成功时空)|
262
274
  | `session_id` / `run_id` | 这个子任务**自己的**会话(点进去看过程)。`run_id` 一开跑就有,`session_id` 等引擎给出;都可能为空(没开登记 / 引擎不给 id)|
@@ -320,7 +332,8 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
320
332
  `sessions` 是该类别下各子任务的引擎会话 id)。`--sessions` 的投影里还会多两个**父指针**字段:
321
333
  `parent_run_id` / `parent_session_id`(空 = 顶层会话)—— 子会话这两项指向团队主会话,
322
334
  客户端据此把它缩进挂在主会话下面(读侧优先 `parent_session_id`,缺失再按 `parent_run_id` 认亲;
323
- 父会话不在本次列表里就回落成顶层行,别丢)。`mode` / `agents` / 父指针都由 `--sessions`
335
+ 父会话不在本次列表里就回落成顶层行,别丢)。移动端列表**默认折叠**这些子会话:父行只给
336
+ 子会话计数徽标(有子任务在跑时亮运行色),点徽标才展开 / 收起。`mode` / `agents` / 父指针都由 `--sessions`
324
337
  **投影出来**(不是只存在登记表文件里),列表页读的就是它们。
325
338
  `missing[]` 是「任务书要求的交付物没落地」的清单(引擎不认 `deliverables` 时就是全缺),
326
339
  **不是失败**:`state` 仍是 `done`,正文照样在 `output` 里。
@@ -346,6 +359,7 @@ notice {text:"团队模式:追加 N 个补充任务(a1、a2),继续
346
359
  | 本体文件出流 | `GET /desk/media?path=<绝对路径>[&w=<px>]`(`?token=` 供 `<img>` / `<video>` 直接引用;**只发 image / video / audio**,`Range` 可拖动)。带 `w` 时按宽缩放并重编码(`64..4096`,失败/更大则回退原图,结果内存缓存);移动端传输层自动追加 `w=480`(缩略图;点开看大图时不带 `w`,取原文件) | 已实现 |
347
360
  | 上传文件回读 | `GET /desk/uploads/{name}[?w=<px>]`(同上 `w` 语义) | 已实现 |
348
361
  | 产物存在性探测 | `POST /desk/path-probe`:`{base, paths[]}` → `{ok, items:[{path, abs, exists, size}]}`;相对路径按 `base` 拼、`~/` 展开、`file://` 剥前缀、只认 regular 文件、最多 50 条。对话内核的「本轮产物」靠它把正文里的路径变成卡片(宿主经 `window.desk.pathProbe` 供进来) | 已实现 |
362
+ | 下一轮快捷回复 | `POST /desk/suggest`:`{user, reply, engine?, model?}` → `{suggestions:[string], engine, model, elapsedMs}`。服务端 exec core 的 `--suggest-followups`(**旁路调用,不续接会话**);`suggestions` 可以为空数组(= 这轮没有下一步,客户端不画按钮),失败如实 4xx/5xx。客户端**只在成功收尾后**调用,点击即发。并发上限 2(超出 429),超时 25s | 已实现 |
349
363
  | 产物文本回读 | `POST /desk/path-read`:`{path, base}` → `{ok:true, text, size, path}` / `{ok:true, tooBig:true, size}` / `{ok:true, binary:true, size}` / `{ok:false, error}`,统一 200。上限 512 KB;前 8000 字节含 NUL 判二进制;`~/`、`file://`、相对 `base` 同 `path-probe` 口径。移动端产物卡「查看」经 `window.desk.pathRead` 用它打开 Markdown / 文本 | 已实现 |
350
364
  | 插件目录 | `GET /desk/plugins` → `{plugins:[{id, name, cli, tagline, description, requirements?, homepage?, installed, version?, bin?, sourcePath?, sourceExists, install, upgrade, examples[], usePrompt, note?}]}`。**这不是引擎**(core 的 `--engines` 里没有它们):它是「发现 → 插件」里那两个工程 CLI(`magic-shorts` / `magic-video`)的介绍与安装状态。`installed` / `version` 由服务端探测(`exec.LookPath` + 可执行文件所属 npm 包的 `package.json`,不跑 `--version`),客户端只显示不推断 | 已实现 |
351
365
  | 插件安装/升级 | `POST /desk/plugins/{id}/{install\|upgrade}` → SSE:`event: open`(`{id, action, commands[]}`)→ 每条命令 `event: step` → 输出逐行 `event: line` → `event: end`(`{ok, code, error?, plugin}`)。**命令只来自服务端那张固定表**(argv,不过 shell),客户端只传 id 与动作,未知 id/动作一律 404 —— 这条接口不接受客户端传命令 | 已实现 |
@@ -627,6 +641,9 @@ const conv = MagicConversation.create({
627
641
  onTeamChange, // 可选:(on) => void。用户在**组件自己的**团队面板里改了开关时回调
628
642
  teamConfig, // 可选:() => {routes, rules, parallel, split, splitCode, adaptive},每轮下发时现读(宿主持有这份偏好)
629
643
  features, // 可选:core 能力位数组,或 () => 数组(异步拉到之后才亮出团队入口)
644
+ onQueuePush, // 可选:(info) => void。运行中提交的追问进队(还没下发)时回调,见 3.2.3
645
+ onQueueTake, // 可选:(id) => void。轮到这条了,正要真正下发
646
+ onQueueDrop, // 可选:(id) => void。用户取消了(宿主该从磁盘删掉)
630
647
  });
631
648
  conv.send('…');
632
649
  conv.interrupt(); conv.stop(); conv.answer('…');
@@ -635,6 +652,7 @@ conv.setEngine(n); conv.setModel(n); conv.setPermission(v); conv.setEngines(list
635
652
  conv.setTeam(on); conv.teamOn(); conv.teamLabel(); conv.openTeamPicker();
636
653
  conv.featureOn('feature.session.team');
637
654
  conv.refreshEngines(force?); conv.loadHistory(sid, after?); conv.destroy();
655
+ conv.adoptQueue([{id, prompt, attachments, atts}]); // 把宿主落库的排队条目接回画布(见 3.2.3)
638
656
  ```
639
657
 
640
658
  **团队模式在组件里是薄的**(这是刻意的,见 1.4):它只做三件事 —— 记住开关、按 `teamConfig()`
@@ -656,6 +674,11 @@ conv.refreshEngines(force?); conv.loadHistory(sid, after?); conv.destroy();
656
674
  靠这一条才不会把整段历史重放一遍。宿主因此**可以**复用同一个组件实例(`client-ui-mobile`
657
675
  的 `mountConversation` 就是这么做的);换会话、`newSession()`、读历史失败之后仍会正常重载。
658
676
 
677
+ `setSession(sid, s, {same:true})` 是给**别名 id** 用的:同一条会话可能先以 `run_id` 出现、
678
+ 之后才拿到 `session_id`,组件自己不知道这层映射。宿主确认「是同一屏会话」后传 `same:true`,
679
+ 组件就只同步外壳(并更新 `state.session`),**绝不**走换会话分支 —— 否则 `clearQueue()`
680
+ 会把还没下发的排队追问整队删掉(移动端 2026-10-22「重新进来就看不到」的病根)。
681
+
659
682
  ### 3.2.2 beforeAsk:下发前的闸门(串行排队的挂载点)
660
683
 
661
684
  ```js
@@ -681,6 +704,28 @@ beforeAsk(info) // info: {isNew, session, workspace, engine, model, permission
681
704
  - 移动端串行模式的口径是**同一 workspace 同时只有一个轮次在跑,不分新任务还是历史会话追问**
682
705
  (追加追问与上一句写的是同一份会话日志,两个进程并发正是最该避免的场合)。
683
706
 
707
+ ### 3.2.3 组件内排队:落库与「重进会话」恢复
708
+
709
+ 同一会话上一轮还没收尾时再提交,组件把这一句排进 `state.queue`(**不打断**当前轮),
710
+ 列在**输入框上方的排队区**(`.queuebar`:一行「排队中 · 本轮结束后自动发送」+ 每条一句、
711
+ 带 ✕ 取消)。它**不进对话区** —— 还没发出去,画成用户气泡会让人以为已发送。
712
+ 这条追问**还没下发给 core**,所以也不在会话日志里。
713
+
714
+ - `onQueuePush(info)` 进队时回调:`info = {id, prompt, attachments, atts, session, workspace,
715
+ engine, model, permission, team}`。宿主要落库(移动端:`magic.serial.queue.v1` + 列表「等待中」
716
+ 角标 / 会话图标)。`atts` 是缩略图快照(`{name, remoteUrl}`),恢复排队行时要一起还原。
717
+ - `onQueueTake(id)` / `onQueueDrop(id)`:轮到下发 / 用户取消,宿主据此删掉那条落库记录。
718
+ - `setSession(sid, s)` 返回「历史真的画完」的 Promise。宿主应在它 resolve 之后调用
719
+ `conv.adoptQueue(items)`,把属于该会话、还没轮到的条目还回来 —— 组件把它们列回输入框
720
+ 上方的排队区、接回队列,这一轮收尾时自动发出。`adoptQueue` 用宿主持久化 id;
721
+ 重复喂同一个 id 是幂等的。
722
+
723
+ 为什么必须这样:组件内存队列活不过 `destroy()`(返回列表后重进、切过别的会话、WebView 被
724
+ 系统回收)。历史回放读的是已落盘日志,排队那句不在里面 —— 不回灌就会「重新进来就看不到」。
725
+ 宿主把自己已经派发出去的条目(owner 已交给宿主、正在等上一轮收尾)留给宿主,**不要**在
726
+ `adoptQueue` 里重复接管,否则同一条会发两次;移动端用 `owner` + `dispatching` 两个标记
727
+ 在「组件接管」与「宿主下发」之间做互斥。
728
+
684
729
  ### 3.2.1 transport 契约(宿主实现,四个方法)
685
730
 
686
731
  ```js
@@ -751,16 +796,20 @@ magic-agent 把会话的查询与修改都做成 CLI 命令,调用方 exec 即
751
796
  | 向前翻更早 | 追加 `--before <skip> --limit <n>` → 另带 `count / before / nextBefore / firstSeq / more`(与 `--after` 互斥) |
752
797
  | 读历史(精简) | 追加 `--session-log-lean`:输出省掉 `snapshot`(**只增参数**,不传则行为与旧版一致) |
753
798
  | 停止会话 | `magic-agent --stop <session_id\|run_id>`(只停得掉**已存档**的会话) |
754
- | **删除会话** | `magic-agent --delete-session <session_id\|run_id>` → `{id, records_removed, logs_removed, titles_removed, statuses_removed}`(`statuses_removed` 2026-10-21 只增) |
799
+ | **删除会话** | `magic-agent --delete-session <session_id\|run_id>` → `{id, records_removed, logs_removed, titles_removed, statuses_removed, child_sessions_removed}`(`statuses_removed` 2026-10-21 只增;`child_sessions_removed` 2026-10-23 只增,删主会话时连带删掉的子会话条数) |
755
800
  | 追加消息 | `magic-agent --append <session_id\|run_id> -p "…"` |
756
801
  | 跑一轮(流式 + 控制) | `magic-agent --stream --events --control -e <engine> -p "…"`,控制命令从 stdin 进 |
757
802
  | **存档这一轮** | 追加 `--save-registry`(登记表)与 `--save-history`(历史 jsonl),**两者默认都关** |
758
803
  | **归纳会话标题** | `magic-agent --summarize-title <session_id\|run_id>` → `{id, title, cached?}` |
804
+ | **下一轮快捷回复** | `magic-agent --suggest-followups <file\|->` → `{suggestions:[string], engine, model, elapsedMs}`(请求是 `{user, reply, engine?, model?}`;`-` = stdin。**旁路调用**:新会话、不带工具、不续接当前对话 —— 见本节「下一轮建议」) |
759
805
  | 读已存标题 | `magic-agent --session-title <id>` → `{id, title}`(不调 LLM) |
760
806
  | **需求清单** | `magic-agent --requirements all\|<ws_id\|目录>` → `{ok, root, workspaces[]}`(只读,不调 LLM,见 §3.4.1) |
761
807
  | **归纳需求** | `magic-agent --consolidate-requirements <ws_id\|目录\|.>` → 一轮归纳结果(**会调模型**) |
762
808
 
763
809
  `--session-log` 的输出形状与插件同名的 HTTP 接口**逐字段对齐**,两条路可互换。
810
+ 其中 `kind:"user"` 事件新增可选 `hosted:true`(2026-10-23 只增字段,`omitempty`):
811
+ true = 这条提问由客户端「会话托管」自动发出,客户端回放时画成机器人头像 + 「托管发送」小标;
812
+ 缺省 = 用户亲手发的。它只影响画法,不参与执行;老日志 / 老 core 没有该字段时行为不变。
764
813
  HTTP 侧的精简开关是**查询参数**(2026-10-20,只增参数,不传则与旧版一致):
765
814
  `GET /desk/session/{id}/messages?lean=1` 与 `GET /desk/session/{id}/events?after=<seq>&lean=1`
766
815
  会删掉每条事件的 `snapshot` 键(与 `--session-log-lean` 同一口径;CLI 侧该字段 `omitempty`,
@@ -834,8 +883,17 @@ event: end data: {"session":"<id>","after":<末位置>,"state":"done|faile
834
883
  丢掉当前画面,按 `messages` 重拉一遍全量,再用新游标重新 follow。不要在半截上续写。
835
884
  4. `event: end` 一定收摊:`reason=terminal` 按 `state` 落收尾(`failed`/`cancelled`/
836
885
  `interrupted`/`gone` 不写作「已完成」,`done`/`idle` 写作「已完成」);
837
- `reason=max_duration` 是服务端 30 分钟的上限(不是这一轮结束),提示用户重开再接。
838
- 5. 连接断开(网络 / 换会话 / 组件销毁)不是终态,别据此判定任务失败;重开一次即可续上。
886
+ `reason=max_duration` 是服务端 30 分钟的单连接寿命(**不是这一轮结束**)——
887
+ 客户端保留游标、自动重新 follow 续上,不要提示用户「重开会话」。
888
+ 5. 连接断开(网络 / WebView 切后台 / 反向代理空闲超时 / 换会话 / 组件销毁)**不是终态**,
889
+ 别据此判定任务失败。除主动收摊(换会话 / 销毁)外,客户端必须**自动重连**:
890
+ - 游标 = 最后一条已收到事件的 `seq`,重连请求带 `?after=<游标>`(断点续传,不重放已画过的帧);
891
+ - 指数退避:0.8s 起、翻倍、单次 15s 封顶;连续 12 次或 6 分钟仍连不上才按失败收尾;
892
+ - 重连期间那一轮**不封口**(不调 `finish` / `qaRunEnd`),底部继续显示运行态;
893
+ 界面插一行「连接中断,正在自动重连…」,恢复后就地改成「已重新连接,继续同步」;
894
+ - 4xx(404 老网关 / 401 令牌失效等)是确定性失败,**不重试**,立即 fallback / 如实报错。
895
+ 参考实现:`client-ui/shared/transport-http.js` 的 `followSession`(`onRetry` / `onResume`
896
+ 可选回调,老宿主不实现也不影响续流);闸门 `npm run check:follow`。
839
897
  6. 服务端没有这个路由(老版本,404)→ 退回旧行为(一次性快照),不要报错弹窗。
840
898
 
841
899
  服务端行为:`?lean=1` 与 `messages/events` 同一口径;`after` 缺省 = 从文件头读。
@@ -871,12 +929,21 @@ event: end data: {"session":"<id>","after":<末位置>,"state":"done|faile
871
929
 
872
930
  ⚠️ **删除是「全机生效」的(2026-10-19)**:`--delete-session` 与插件的
873
931
  `DELETE /desk/session/{id}` 是同一份实现(`internal/session/delete.go`),删的是
874
- **登记记录 + 事件日志(`<session_id>.jsonl` 与 `<run_id>.jsonl` 两个名字)+ 标题覆盖**,
932
+ **登记记录 + 事件日志(`<session_id>.jsonl` 与 `<run_id>.jsonl` 两个名字)+ 标题覆盖 + 状态**,
875
933
  幂等(id 不认识也算成功)。为什么强调:会话列表的权威来源是登记表,各端都读它 ——
876
934
  客户端**不要**把删除做成「只在本机藏起来」(本地记墓碑那种),
877
935
  否则在 A 端删掉的会话在 B 端照旧列着(2026-10-19 用户报的
878
936
  「从pc端删除的会话移动端还在显示」就是这么来的)。删除不可回放,界面提示要如实说。
879
937
 
938
+ ⚠️ **删主会话会级联删子任务会话(2026-10-23,只增字段不升版本)**:团队模式拆出的
939
+ 子任务各是一条普通会话,登记表里靠 `parent_run_id` / `parent_session_id` 挂在主会话下。
940
+ 删除主会话时 core 会沿父指针把**整棵子树**(含子会话的登记记录 / 日志 / 标题 / 状态)一起
941
+ 物理删除 —— 客户端只发一条 `DELETE /desk/session/<主会话 id>` 即可,不要再逐个删子会话;
942
+ 本地列表也应把该主会话下的子行一起移除(可按同样两个父指针在本地算闭包,见移动端
943
+ `deleteClosureOf`)。只删子会话**不会**反向带走主会话。返回里新增
944
+ `child_sessions_removed`(插件的 JSON 是 `childSessionsRemoved`):本次连带删掉的**子会话
945
+ 条数**(同一条子会话多轮 run 只算 1)。
946
+
880
947
  ⚠️ **`session` 字段回的是翻译后的 id(2026-10-15)**:日志文件按 `session_id` 命名,
881
948
  但正在跑的会话在引擎吐出 id 之前只有 `run_id` —— core 一开跑就先按 `<run_id>.jsonl`
882
949
  落盘、拿到 id 后改名为 `<session_id>.jsonl`,读侧(`--session-log` 与
@@ -913,6 +980,64 @@ core 侧只有一张空的 sid→title 覆盖表,于是:
913
980
  ⚠️ 老版本 core 没有 `--summarize-title` / `--sessions.title`(exit 1)—— 客户端应**如实回落**
914
981
  (PC 用本地缓存的标题,移动端用 `prompt_head`),不要静默显示空标题。
915
982
 
983
+ #### 下一轮建议:生成口径收在 core,客户端只负责「什么时候问、怎么发」(2026-10-23)
984
+
985
+ 用户定调:「在回复结束后 ai 自动生成下一轮的建议,点击即可发送」。
986
+
987
+ | 事实 | 说明 |
988
+ | --- | --- |
989
+ | 生成在 core | `magic-agent --suggest-followups <file\|->`(`internal/suggest`):提示词、截断、JSON 解析、去重与长度上限都只有一份;移动端 / PC 拿到的建议不会各说各话 |
990
+ | **默认关闭** | 建议引擎**不配置 = 不开启建议功能**(2026-10-23 用户定调)。组件 `suggestEngine` 为空时不发 `/desk/suggest`、不画建议、不补历史建议、托管也不自动执行;老用户升级后默认没有建议,需去「我的」里显式选引擎 |
991
+ | 配置入口 | 移动端「我的 → 建议引擎 / 建议模型」两行,点开的是与「默认引擎 / 模型」相同的**二列抽屉**:左列首项「不开启」(默认),其余是 `/desk/engines` 探测到的可用引擎;点左列只切右列,点右列才落定。引擎存 `magic.suggest.engine`、模型存 `magic.suggest.model`,都属于环境配置(`ENV_SCOPE_KEYS` + `/desk/prefs` 同步);改完立刻 `conv.setSuggestEngine(engine, model)` 生效,下一轮收尾开始生成 |
992
+ | 选引擎 / 模型 | `llm` 最快(**模型留空 / 选「引擎默认」**时 core 自动挑 `models.json` 里 `thinking.disabled` 的变体,实测 0.9-2.3s);`claude` / `codex` 可用各自的关闭档。显式选中会思考的 `llm` 模型(如 `MiniMax-M3.1-Flash`)时,建议生成会自动换同文件的 no-think 变体;配置里没有变体才保留原模型 |
993
+ | **思考固定关闭** | 建议调用一律带 `NoThinking`,各引擎走官方开关:`claude` → `--thinking disabled`;`codex` → `model_reasoning_effort="none"`;`llm` → 同线 `thinking.disabled` 变体,没有变体时 `reasoning_effort="none"`(CLI 委托路径用 `-o reasoning_effort none`);`codebuddy` → settings `alwaysThinkingEnabled=false`;`dsh` → SDK `InitializeParams.reasoningEffort="off"`。`trae`/`openclaw`/`arkclaw` 没有已知开关,仍保留 `NoThinking` 标记,失败后再降级 `llm` |
994
+ | **不续接会话** | 独立进程、新会话、`ToolsOff`、不带 `SessionID` / `Continue` —— 「请生成建议」这类指令不会写进用户正在聊的上下文,也不进会话登记表 / 日志 |
995
+ | 引擎走「无工具 + JSON」通道 | core 的 `suggest.Generate` 固定 **`ToolsOff` + `JSONSchema` + `NoThinking`**,引擎以 `--print --output-format json` 返回一小段结果,不走流式、不带工具。默认选 `llm`,优先用 `models.json` 里第一条 `extraBody.thinking.type=disabled` 的变体(本机 `minimax-nothink`,实测 0.9-2.3s) |
996
+ | 慢引擎不拖界面 | `claude` / `codex` / `codebuddy` / `dsh` 等非 `llm` 引擎给 10s 主引擎预算;失败/超时才降级 `llm`,并清掉当前引擎的 model。界面默认不传 engine,直接走快路径,20s 超时不会触发 |
997
+ | **允许空数组** | 模型判断「没有下一步」时回 `[]`,这是**合法结果**:客户端不画任何按钮,不拿模板凑 |
998
+ | 成功收尾后问 | 失败 / 被用户停止 / 还在跑的轮次都不问;本轮出现过提问卡(引擎在等作答)时也跳过,避免与作答卡抢注意力 |
999
+ | 历史最后一轮补一次 | 打开一条**已收尾、不在跑**的历史会话时,回放完对最后一轮补一次建议;更早的轮次不问(它的「下一步」早已被后续对话覆盖)。缓存按 `(sid, 最后一条 user 的 at/seq, 建议引擎, 建议模型)` 放 `localStorage`,30 分钟内重开不重复调模型;空数组也缓存 |
1000
+ | 历史建议只展示 | 历史补出来的建议**不排托管计时器、不自动发送**,只有用户亲手点才走正常发送路径;否则一打开旧会话,托管会立刻把几天前的一句建议发出去 |
1001
+ | 输入是这一轮 | `user` = 用户上一句,`reply` = 本轮回答正文(core 侧再截断:user 1500 / reply 6000 runes,取回答**尾部**) |
1002
+ | 点击 = 发送 | 界面把建议文本交给正常发送路径(用户气泡、排队、附件闸门、串行门全部一致),**不是**只填进输入框 |
1003
+ | 建议行在输入框上方 | 与截图一致:一排圆角描边的短语、横向滚动、最多 3 枚;没有建议时**不占高度**。开新轮 / 换会话 / 销毁组件立刻清空并 abort 在途请求;历史补的建议没有「托管中」标记 |
1004
+ | 失败静默 | 接口超时 / 非 2xx / 空结果都只是「这次没有建议」;界面不弹错、不重试、不画占位 |
1005
+ | 老 core 降级 | core 没有这个 flag(exit 2/unknown flag)→ 插件 502 → 界面静默;组件侧 `transport.suggest` 不存在时直接不请求(诚实降级) |
1006
+
1007
+ ⚠️ 建议是**给用户发的下一句**(用户口吻、≤16 汉字),不是给 agent 的指令;解析侧会丢掉
1008
+ 「AI / 助手 / …」开头的行与超过 28 runes 的长句,避免把模型的自述当成用户的话。
1009
+
1010
+ #### 会话托管:在「建议」之上自动发第一条(2026-10-23,纯客户端策略)
1011
+
1012
+ 用户定调:「托管模式下自动按照最佳建议提交到会话中执行」;后续把「建议引擎」配置放到「我的」tab(默认不配置 = 建议功能关闭),托管开关仍在会话页运行设置、按会话记。
1013
+ 2026-10-23 再定:「跟团队模式一样改成挂在会话上,子会话继承,不再是全局」。(前者已被后者取代:
1014
+ 开关不再是「我的」里的设备级全局项,入口与团队模式并排放进会话页的「任务运行设置」。)
1015
+
1016
+ 这**不是**新的 wire 契约:没有新接口、没有新字段,core / 插件完全不需要知道托管是否开启。
1017
+ 它只是组件在「拿到建议」与「用户点建议」之间加的一条本地策略:
1018
+
1019
+ | 事实 | 说明 |
1020
+ | --- | --- |
1021
+ | 开关位置 | 移动端会话页右上角 →「任务运行设置」→「会话托管」;**按会话**记在 `magic.hosting.sessions.v1`(`sid → 开/关`),默认关。与团队模式同一套形状,归属这个环境(`ENV_SCOPE_KEYS` + `/desk/prefs` 同步),「我的」里不再有全局开关 |
1022
+ | 启用条件 | 必须先有**建议引擎**:未配置时运行设置那行显示「未配置建议引擎」,点击只提示去「我的」配置;组件 `setHosting(true)` 也会拒绝,避免没有「最佳建议」却自己跑 |
1023
+ | 子会话继承 | 子会话(团队模式拆出的子任务)**没有自己的记录**时,沿 `parent_session_id` / `parent_run_id` 向上找最近一条显式记录;都没有才算关。主会话改开关,没单独设过的子会话跟着变;在子会话里手动改一次 = 写自己的记录,从此显式覆盖(不再继承) |
1024
+ | 自动发哪条 | 建议数组的**第一条**(模型排序即优先级);不拼接、不改写、不自己生成 |
1025
+ | 发送路径 | 与用户点建议同一条:内核画用户气泡、串行门 `beforeAsk`、权限、排队、附件闸门全部照旧;不经过输入框 |
1026
+ | 发送标识 | 托管自动发的那条**不是「我」**(2026-10-23 用户:「是托管发送的要标识 显示时区别于“我”发送」):实时气泡用机器人头像 + 虚线气泡 + 「托管发送」小标;ask 请求带 `hosted:true`,core 写进 `kind:"user"` 历史事件,重开会话回放仍是同一标识。标识只影响画法,不改提示词 / 引擎 / 权限 |
1027
+ | 先给一眼 | 建议画出来后等 1.2s 再发;这 1.2s 内输入框有字 / 有附件 / 已开新轮 / 已切会话 / 已关托管 → 放弃本次自动发送 |
1028
+ | 连续上限 | 12 轮。到顶 / 本轮失败 / 本轮被停止 → **自动暂停**并回调宿主把**这条会话**的开关关掉;回放历史里的旧失败不算 |
1029
+ | 空建议 | 不自动发,也不拿模板凑;托管保持开启,等下一轮(用户手动发的那句)结束后再看 |
1030
+ | 历史建议 | 打开历史会话补出来的「最后一轮」建议**只展示**,不进托管;托管只对本次实时新收尾的轮次生效 |
1031
+ | 交付完成 | 小说 / 故事 / 文章 / 报告等**完整成品**交付完、AI 没有提问也没列下一步 → 建议必须为空;「再来一篇 / 换个题材 / 再写一个 / 继续写」由服务端确定性过滤(除非 AI 明确邀请),托管不得自动续写 |
1032
+ | 提问卡 | 托管开着时,卡出现 1.2s 后自动选**第一项**交卷(优先「继续 / 下一步 / 开始」措辞);用户碰卡、关闭卡、自己输入 → 取消这次自动答,不抢人的操作。只处理 `kind:"question"`,待授权(permission)绝不自动点,避免代用户授权 |
1033
+ | 成功判据 | 不只看收尾回调的 `ok`:`llm` 这类**不发 `turn_end`** 的引擎走兜底 `hardFinish(0,'已完成')`,也必须算成功。否则建议永远不请求、托管第一轮就误暂停(2026-10-23 实测修复) |
1034
+ | 停止 | 运行设置里的按会话开关、建议行上的「托管中」标记、或正常的中断按钮(本轮被停 → 自动暂停) |
1035
+ | 可见性 | 待发时建议行前有「托管中」标记、第一条建议加描边;发送后建议行收起。不做「静默自己跑」 |
1036
+
1037
+ ⚠️ 托管是**宿主级**能力(`client-ui/shared/conversation.js` 的 `hosting` / `setHosting`),
1038
+ 不是内核渲染的一部分;**开关按会话**,组件只执行宿主导出的当前值(子会话的继承也在宿主侧算)。
1039
+ magic-test 桌面端那份内联 `app.js` 不加载这个组件,因此不会出现托管开关。
1040
+
916
1041
  #### 会话状态:与标题同一套存法,客户端只读(2026-10-21)
917
1042
 
918
1043
  用户定调:「会话状态搞一个字段 跟会话标题一样存起来 更新这个字段来统一」。
@@ -931,7 +1056,7 @@ core 侧只有一张空的 sid→title 覆盖表,于是:
931
1056
  | 表形状 | `{ "<session_id>": {"state","run_id","run_started","updated_at"} }`;键优先 `session_id`,引擎还没吐 id 时落在 `run_id` 上,拿到后迁移并删旧键(与标题同一坑:留双键就会按不同 id 读到两个状态) |
932
1057
  | `run_started` 排序守卫 | 写入方是**各轮的进程**:旧一轮收尾(`Finish` / `Stop` / 进程已不在 → `gone`)可能晚于新一轮开跑。只在写入轮**不早于**表里那一轮时才覆盖,迟到的 `done` 不会盖掉新一轮的 `running` |
933
1058
  | 写入时机 | 与登记表同一步(`Handle.write` + 所有 `writeRecord` 路径,含 `Stop` / 同会话旧轮被 supersede / 读侧归一 `gone`);不落登记表(`--save-registry` 未开)时状态也不留痕 |
934
- | 删除会话一并清 | `--delete-session` / `DELETE /desk/session/{id}` 会删掉状态行(返回里的 `statuses_removed`),避免同 id 再建会话时继承一条过期状态 |
1059
+ | 删除会话一并清 | `--delete-session` / `DELETE /desk/session/{id}` 会删掉状态行(返回里的 `statuses_removed`),避免同 id 再建会话时继承一条过期状态;删主会话还会沿父指针级联删掉子任务会话(`child_sessions_removed`) |
935
1060
 
936
1061
  状态词表与登记表完全一致:`running` / `waiting_approval` / `idle`(非终态),
937
1062
  `done` / `failed` / `cancelled` / `interrupted` / `gone`(终态)。
@@ -967,9 +1092,16 @@ magic-agent --consolidate-requirements ~/work/proj # 手动整理一轮(**会
967
1092
  magic-agent --consolidate-requirements . --requirement-force # 没有新记忆也跑一轮
968
1093
  magic-agent --consolidate-requirements . --requirement-all # 全量重放(回填/校正)
969
1094
  magic-agent --consolidate-requirements . --requirement-auto # 自动归纳:只增量,绝不初始化
1095
+ magic-agent --consolidate-requirements . --requirement-module 登录 # 只整理「登录」这个模块
970
1096
  magic-agent --consolidate-requirements . --requirement-engine claude # 用配置的默认引擎整理
971
1097
  ```
972
1098
 
1099
+ **模块级整理(2026-10-23)**:`--requirement-module <模块>` 只把增量写回该模块,
1100
+ **不推进空间游标** —— 其它模块的记忆仍留在 `pending`,留给空间级整理 / 自动归纳;
1101
+ 因此可以重复点、不会吞掉别的模块的需求。它**不触发首次目录初始化**(目录基线是空间级的),
1102
+ 模块不存在会明确报错。HTTP 侧对应 `POST /desk/requirements/consolidate` 的
1103
+ `{"workspace":"…","module":"登录"}`;`module` 留空 = 整理整个空间。
1104
+
973
1105
  **引擎口径(2026-10-22 用户)**:整理的引擎就是**客户端配置的默认引擎**。移动端/PC 点「整理」
974
1106
  时把「我的 → 默认引擎/模型」放进 `POST /desk/requirements/consolidate` 的
975
1107
  `{"engine":"claude","model":"…"}`,插件原样组成 `--requirement-engine` / `--requirement-model`。
@@ -1007,8 +1139,9 @@ CLI)才按 `claude → codebuddy → codebuddy-ai → codex → trae → dsh`
1007
1139
  "final_preview":"用户用手机号登录,验证码 60 秒可重发","updated_at":1761090000000}]}]}]}
1008
1140
  ```
1009
1141
 
1010
- `--consolidate-requirements` 的输出:`{ok, workspace, dir, noop?, bootstrap?, bootstrap_engine?,
1011
- scanned?, updated, created, points, conclusions, consumed, replayed?, calls, pending, error?}`。
1142
+ `--consolidate-requirements` 的输出:`{ok, workspace, dir, module?, noop?, bootstrap?, bootstrap_engine?,
1143
+ scanned?, updated, created, points, conclusions, consumed, replayed?, calls, pending, error?}`
1144
+ (`module` 只在模块级整理时出现;此时 `consumed` 恒为 0)。
1012
1145
  `noop=true` 是「没有未归纳记忆、也没 `--requirement-force`」的正常结果,不是错;
1013
1146
  `bootstrap=true` 表示这一轮做了首次目录初始化(`bootstrap_engine` 是实际读目录的引擎,
1014
1147
  `scanned` 是扫描到的条目数,排障用)。
@@ -1027,8 +1160,11 @@ scanned?, updated, created, points, conclusions, consumed, replayed?, calls, pen
1027
1160
  **客户端要做的事**:列表页只调 `--requirements`(每刷一次都调,绝不顺手触发归纳);
1028
1161
  「整理」按钮调 `--consolidate-requirements`,并**传它要整理的那个空间 + 当前配置的默认引擎**
1029
1162
  (移动端需求页同时列出多个空间时,组头各给一枚「整理」,不要把「当前会话的空间」当成用户点的那个);
1163
+ 二级页(点进某个模块)的「整理」必须带 `module`,**只整理当前模块** —— 不带就会退化成整空间,
1164
+ 用户点一个模块却看到所有模块都变了(2026-10-23 报障)。
1030
1165
  顶栏另给「全部整理」:按空间**串行**逐个调同一个接口(有待整理的 → 未初始化的 → 其它),
1031
1166
  一个空间失败不阻断后面的,全部结束后汇总「成功/失败/新建/更新」。
1167
+ 忙态只标**本次真正在整理**的那一个入口;其余按钮只置灰、保持原文案(否则看起来像全部在跑)。
1032
1168
  按钮在首次初始化时要允许几分钟等待(文案「初始化中…」),完成后再 `--requirements` 刷新。
1033
1169
  打开一篇文档读 `file` 绝对路径的正文即可(普通 markdown,走既有的阅读器)。插件在每轮 `/desk/ask` 收尾后**防抖 12s 自动归纳**
1034
1170
  该轮所在工作区(`MAGIC_CLIENT_NO_REQ_AUTO=1` 关闭),所以客户端不必自己轮询触发。
package/README.md CHANGED
@@ -173,7 +173,8 @@ magic-agent -p "对比两张图" -a a.png -a b.png
173
173
 
174
174
  # 流式输出
175
175
  magic-agent --stream -e claude -o text "复杂问题"
176
- magic-agent --stream --no-thinking -e claude "问题"
176
+ magic-agent --stream --no-thinking -e claude "问题" # 只隐藏思考事件
177
+ magic-agent --disable-thinking -e claude "问题" # 请求模型真正关闭思考
177
178
 
178
179
  # 会话
179
180
  magic-agent --sessions # 会话登记表
@@ -199,7 +200,8 @@ magic-agent --append <run_id> -p "追加:顺便把 lint 也跑了"
199
200
  | `--backoff` | `2s` | 首次重试退避(指数翻倍,上限 30s,带抖动) |
200
201
  | `-o, --output` | `json` | 输出格式:`json` \| `text` |
201
202
  | `--stream` | 关 | 流式输出(text 模式思考走 stderr) |
202
- | `--no-thinking` | 关 | 流式模式下不转发思考过程增量 |
203
+ | `--no-thinking` | 关 | 流式模式下不转发思考过程增量(只隐藏,不关闭模型思考) |
204
+ | `--disable-thinking` / `--no-think` | 关 | 请求引擎真正关闭模型思考:claude `--thinking disabled`、codex `effort=none`、codebuddy `alwaysThinkingEnabled=false`、dsh `reasoningEffort=off`、llm 选 no-think 变体 / `reasoning_effort=none` |
203
205
  | `--engines` | 关 | 列出引擎、可用性、模型、版本与一键安装命令 |
204
206
  | `--no-models` | 关 | 配合 `--engines`:跳过模型与版本探测 |
205
207
  | `--json` | 关 | 兼容保留(行为同 `--engines` 的默认 JSON 输出) |
@@ -255,6 +257,7 @@ magic-agent --requirements all # 列全部工作区(只读
255
257
  magic-agent --requirements ~/work/proj # 只列这个目录(也认 ws id)
256
258
  magic-agent --consolidate-requirements ~/work/proj # 手动整理:首次=按目录初始化,之后只处理新会话
257
259
  magic-agent --consolidate-requirements . --requirement-all # 全量重放(回填/校正)
260
+ magic-agent --consolidate-requirements . --requirement-module 登录 # 只整理「登录」这个模块
258
261
  ```
259
262
 
260
263
  - **第一次整理 = 初始化**:工作区还没初始化过时,先有界扫描空间目录,用**客户端配置的默认
@@ -263,8 +266,11 @@ magic-agent --consolidate-requirements . --requirement-all # 全量重放(
263
266
  默认引擎不能读目录(如 `llm`)会明确报错并提示换引擎。目录为空不算失败,会回落到会话增量。
264
267
  - **自动归纳只增量**:插件在每轮对话收尾后防抖 12s 自动归纳(`MAGIC_CLIENT_NO_REQ_AUTO=1`
265
268
  关闭),它带 `--requirement-auto`,**绝不触发首次目录初始化**。
266
- - 需求页每个空间组头有独立「整理」,只整理那个空间;顶栏「全部整理」按空间串行跑一遍,
267
- 一键触发全部(某个空间失败不影响后面的)。没有新会话时返回 `noop`,不会空跑一次模型。
269
+ - **模块级整理**:`--requirement-module <模块>` 只把这个模块的增量写回该模块,
270
+ **不推进空间游标**(别的模块的记忆仍留在待整理),可以重复点;它不触发首次目录初始化。
271
+ - 需求页每个空间组头有独立「整理」,只整理那个空间;点进某个模块后,二级页的「整理」
272
+ 只整理当前模块;顶栏「全部整理」按空间串行跑一遍,一键触发全部(某个空间失败不影响后面的)。
273
+ 没有新会话时返回 `noop`,不会空跑一次模型。
268
274
 
269
275
  详细契约见 [CLIENT-CONTRACT.md](CLIENT-CONTRACT.md) §3.4.1。
270
276
 
@@ -375,6 +375,9 @@ body::after{content:"";position:fixed;inset:0;z-index:60;pointer-events:none;opa
375
375
  .tk-item .tk-pin{width:11px;height:11px;flex:none;display:grid;place-items:center;
376
376
  color:var(--cinnabar-hi)}
377
377
  .tk-item .tk-pin svg{width:11px;height:11px;display:block;fill:currentColor}
378
+ /* 运行中的**自动**置顶图钉(2026-10-22):同一个图形、更淡 —— 一眼能看出「它被临时钉上来了」,
379
+ 又不会与用户手动置顶(实心朱砂)混为一谈;跑完 `qaTaskAutoPin` 变 false,图钉随之消失。 */
380
+ .tk-item .tk-pin--auto{opacity:.55}
378
381
  /* 分组胶囊 tab(用户 2026-09-20:「对话列表中审查中的任务直接显示到了已归档里不对 增加1个审查中的
379
382
  分组。同时3个状态的分组改成胶囊tab形式」+「已归档改成已完成」):
380
383
  三档状态 = 处理中 / 审查中 / 已完成,渲染成**一枚分段控件**(与需求看板页头「列表 / 看板」、
@@ -1537,6 +1540,18 @@ body::after{content:"";position:fixed;inset:0;z-index:60;pointer-events:none;opa
1537
1540
  .qa-msg.qa-mine .qa-bd{padding:10px 14px;
1538
1541
  border:1px solid var(--line);border-radius:14px 5px 14px 14px;
1539
1542
  background:linear-gradient(180deg,#FBF9F7,#F5F1ED)}
1543
+ /* 托管自动发送(2026-10-23):PC 没有托管开关,但同一条历史可能在手机端被托管发出。
1544
+ 回放时按 `hosted:true` 画成机器人头像 + 「托管发送」小标。
1545
+ ⚠️ 与 client-ui/shared/conv/conv.css 的 `.qa-hosted` 那段同步改(两份皮肤目前各存一份)。 */
1546
+ .qa-msg.qa-hosted .qa-bd{border-style:dashed;border-color:var(--cinnabar-hi);
1547
+ background:linear-gradient(180deg,#FFF8F5,#FDF0EA)}
1548
+ .qa-msg.qa-hosted .qa-ico{color:var(--cinnabar-hi);--h:var(--halo-c-red)}
1549
+ .qa-hosted-tag{display:inline-flex;align-items:center;gap:4px;margin:0 0 6px;
1550
+ padding:2px 7px;border-radius:999px;background:rgba(240,105,83,.10);
1551
+ color:var(--cinnabar-hi);font-size:10.5px;line-height:15px;font-weight:600;
1552
+ letter-spacing:.02em;user-select:none;white-space:nowrap}
1553
+ .qa-hosted-tag::before{content:"";width:4px;height:4px;border-radius:50%;
1554
+ background:currentColor;flex:0 0 auto}
1540
1555
  /* ===== 审核用户的回复(2026-09-20 加审查轮;2026-09-22 **去卡片**)=====
1541
1556
  用户原话(2026-09-20):「验证不通过的返回建议 再代表审核用户发送到对话中」——
1542
1557
  它不是需求作者、也不是引擎,所以既不能复用用户气泡(朱砂「超」字),也不能复用 bot 气泡(大喵)。
@@ -3579,6 +3594,8 @@ body::after{content:"";position:fixed;inset:0;z-index:60;pointer-events:none;opa
3579
3594
  [data-theme="dark"] .qa-t.md-body blockquote{background:rgba(245,244,243,.04);border-color:#3B3835}
3580
3595
  [data-theme="dark"] .qa-t.md-body blockquote p{color:#A8A29E}
3581
3596
  [data-theme="dark"] .qa-msg.qa-mine .qa-bd{background:#1C1C1E;border-color:#3B3835}
3597
+ [data-theme="dark"] .qa-msg.qa-hosted .qa-bd{background:#241B18;border-color:#8A5144}
3598
+ [data-theme="dark"] .qa-hosted-tag{background:rgba(240,105,83,.16);color:#F2A08E}
3582
3599
  [data-theme="dark"] .qa-av-txt{background:linear-gradient(180deg,#2B2B2E,#1C1C1E)}
3583
3600
  /* 审核用户的回复(深色):**不需要另给一套** —— 它已经与 bot 答复同形(无底无框),
3584
3601
  与下面「审核引擎的回复」同一条口径。原来那条 `background:#241F16;border-color:琥珀` 的
@@ -69,6 +69,11 @@ body { overflow: hidden; background: var(--bg-page); }
69
69
  .rail-dot.live{ background: #2E9E6B; box-shadow: 0 0 0 3px rgba(46,158,107,.16); }
70
70
  .rail-dot.done{ background: #C9C1B6; }
71
71
  .rail-dot.failed{ background: #C4553C; }
72
+ /* 运行中**自动临时置顶**的图钉(2026-10-22):与移动端、旧左栏同一图形、更淡 ——
73
+ 跑完状态一变,行上的图钉与排在最前的顺序一起归位(见 93-rail.js 的 qaRailOrder)。 */
74
+ .rail-pin{ flex:none; width:11px; height:11px; display:grid; place-items:center;
75
+ color: var(--cinnabar-hi); opacity:.55; }
76
+ .rail-pin svg{ width:11px; height:11px; display:block; fill:currentColor; }
72
77
  /* 「无历史」标记:这条会话有会话 id(能续接),但引擎当时非流式跑、没留下事件日志。
73
78
  在行上先标出来,免得点开才发现只有一句说明(`93-rail.js` 的 noLog)。 */
74
79
  .rail-nolog{
@@ -8,6 +8,7 @@
8
8
  三份开合态一起删掉):用户要的是**固定的上限**,不是「想点就能看全」。
9
9
  ⚠️ 上限内**当前会话 / 正在处理的那条 / 置顶的那条优先占名额**(挤掉的是最老的那些普通行)——
10
10
  正在跑的被挤出去等于进度效果白加,置顶被挤出去等于没置顶。
11
+ 「正在处理」还包括**运行中自动临时置顶**(见 ⑤):它在排序里被提到手动置顶之后、普通会话之前,跑完自动归位。
11
12
  ⚠️ 超出 20 条的**不再有展开入口**:老对话的找回路径是**搜索**(见 ④)或
12
13
  **在需求看板点卡片跳到它那条会话**(见 qaTaskReveal)——用户原话
13
14
  「历史的能通过搜索或者点击需求跳转找到就行了」。列表底部留一行如实说明(`.tk-note`),
@@ -51,6 +52,9 @@
51
52
  ⚠️ 「点需求跳转」时**要清掉搜索词**(见 qaTaskReveal):否则跳过去的那条会被搜索条件过滤掉,
52
53
  画布切了、左栏却看不到它(用户会认为没反应)。
53
54
  ⑤ **组内排序**:置顶在最前,其余按 `ts` 倒序(与行尾那个时间戳同源)。
55
+ **运行中的会话自动临时置顶**(2026-10-22 用户:「运行中的会话自动临时置顶」):
56
+ 它只影响本次排序 / 取行,**不写 `pinned`、不落库** —— 跑完自动归位,手动置顶仍是
57
+ 用户自己的持久选择(手动置顶在前,运行中紧随其后,见 `qaTaskOrder`)。
54
58
  ⚠️ 归属哪些档由 ③ 的 `qaTaskGroupOf` 决定;本文件只负责「取哪一档的行、怎么排、列几条」。 */
55
59
  /* 每档的**硬上限**(2026-09-22 用户:「任务对话区域永远最多显示20个」)——
56
60
  不是「默认值」、也没有「查看更多」能突破它(那条路径 2026-09-22 整体下线)。
@@ -75,6 +79,11 @@ const QA_TK_DOTS = '<svg viewBox="0 0 24 24" aria-hidden="true">'
75
79
  + '<circle cx="5" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="19" cy="12" r="1.9"/></svg>';
76
80
  const QA_TK_PIN = '<span class="tk-pin" role="img" aria-label="已置顶" title="已置顶">'
77
81
  + '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 9V4h1a1 1 0 000-2H7a1 1 0 000 2h1v5a3 3 0 01-3 3v2h5.97v7l1 1 1-1v-7H19v-2a3 3 0 01-3-3z"/></svg></span>';
82
+ /* 运行中那枚**自动**图钉:与手动置顶同一个图形,但更淡(`--auto`),title 说清「跑完自动归位」——
83
+ 不解释的话,用户会以为自己什么时候置顶过它。 */
84
+ const QA_TK_PIN_AUTO = '<span class="tk-pin tk-pin--auto" role="img" aria-label="运行中 · 自动置顶" '
85
+ + 'title="运行中 · 自动置顶(跑完自动归位)">'
86
+ + '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 9V4h1a1 1 0 000-2H7a1 1 0 000 2h1v5a3 3 0 01-3 3v2h5.97v7l1 1 1-1v-7H19v-2a3 3 0 01-3-3z"/></svg></span>';
78
87
  /** 行尾那枚状态标记(转圈 / 圆点)——**与需求状态 1 对 1**(用户 2026-09-20:「需求的状态为什么跟
79
88
  * 任务状态不一致 应该1对1一致的啊」):
80
89
  * 文案与状态一律取自 `qaTaskStatusOf`(需求派发的会话直接复用需求侧的 `reqStatusLabel`),
@@ -87,9 +96,24 @@ function qaTaskSt(t){
87
96
  if(s.st === "running") return qaSpinHtml(s.label, title);
88
97
  return `<span class="tk-st ${escHtml(s.st)}" title="${escHtml(title)}"></span>`;
89
98
  }
90
- /** 组内排序:**置顶在前**,其余按 ts 倒序(ts 同时是行尾那个时间戳,见 qaSaveSnapshotFor)。 */
99
+ /** 这条会话是不是**正在跑**(运行中)。判据只走唯一状态口径 `qaTaskStatusOf`:
100
+ * `st === "running"` 才算 —— 排队 / 待处理(`pending`)不自动置顶,它们还没开始占资源;
101
+ * 需求派发的会话在跑审查轮时状态也是 `running`,同样按运行中处理(与转圈同源)。
102
+ * ⚠️ 这是**派生状态**,不写 `t.pinned`、不落库:跑完 `st` 一变,下一次渲染自动归位。 */
103
+ function qaTaskAutoPin(t){
104
+ return !!(t && typeof qaTaskStatusOf === "function" && qaTaskStatusOf(t).st === "running");
105
+ }
106
+ /** 组内排序:**手动置顶 → 运行中(自动临时置顶) → 其余按 ts 倒序**(ts 同时是行尾那个时间戳,
107
+ * 见 qaSaveSnapshotFor)。用户 2026-10-22 定稿:「应该在人工置顶的后面」—— 自动置顶只是
108
+ * 临时把在跑的活提上来,不能压过用户自己钉的会话;跑完自动归位。 */
91
109
  function qaTaskOrder(a, b){
92
- return ((b.pinned?1:0) - (a.pinned?1:0)) || ((b.ts||0) - (a.ts||0));
110
+ return (qaTaskPinRank(b) - qaTaskPinRank(a)) || ((b.ts||0) - (a.ts||0));
111
+ }
112
+ /** 排序档位:2 = 用户手动置顶 · 1 = 运行中自动置顶 · 0 = 普通。 */
113
+ function qaTaskPinRank(t){
114
+ if(!t) return 0;
115
+ if(t.pinned) return 2;
116
+ return qaTaskAutoPin(t) ? 1 : 0;
93
117
  }
94
118
  /* ---------- 归组:三档互斥,**状态优先**(用户 2026-09-20 报的就是「审查中的跑到已完成里」)---------- */
95
119
  /** 这条会话是不是「审查中」—— 即需求看板「待验证」那一列里的会话。两个判据(任一成立即算):
@@ -231,7 +255,7 @@ function qaTaskRowHtml(t){
231
255
  return `<button class="tk-item${t.id===qaActiveId?" active":""}" data-id="${t.id}" type="button" title="${escHtml(t.title)}">
232
256
  <span class="tico">${QA_TK_ICON}</span>
233
257
  <span class="tt">${escHtml(t.title)}</span>
234
- ${t.pinned ? QA_TK_PIN : ""}
258
+ ${t.pinned ? QA_TK_PIN : (qaTaskAutoPin(t) ? QA_TK_PIN_AUTO : "")}
235
259
  ${qaTaskSt(t)}
236
260
  <span class="tk-eng">${escHtml(t.engine||"claude")}</span>
237
261
  <span class="tm">${qaTaskTime(t.ts)}</span>
@@ -246,7 +270,7 @@ function qaTaskRowHtml(t){
246
270
  * ⚠️ 返回的 list **仍按原顺序**(置顶在前 → ts 倒序),顶名额只换「谁留下」,不动排序。 */
247
271
  function qaTaskFold(rows){
248
272
  const keepRule = (t)=> t.id === qaActiveId || t.id === qaTaskRevealId
249
- || t.status === "running" || !!t.pinned;
273
+ || qaTaskAutoPin(t) || !!t.pinned;
250
274
  if(rows.length <= QA_TK_VISIBLE) return { list: rows, hidden: 0 };
251
275
  const take = new Set(rows.slice(0, QA_TK_VISIBLE).map(t=>t.id));
252
276
  rows.filter(t=>keepRule(t) && !take.has(t.id)).forEach((t)=>{
@@ -126,6 +126,11 @@ async function qaStopActive(){
126
126
  async function qaSend(v, opts){
127
127
  const followup = !!(opts && opts.followup);
128
128
  const from = (opts && opts.from) || "";
129
+ /* sid = 这条消息**归属哪条会话**(可选)。只有排队的下一条会传(qaMsgFlush):
130
+ 它出队时 `qaActiveId` 可能已经被切走,而 qaSend 下面的判据与落笔用的都是 qaActiveId,
131
+ 不显式指定就会把别人的消息发进当前显示的这条会话(用户 2026-10-23 报障:
132
+ 「排队的和自动建议发送串会话了」)。传了就**只认它**,且落笔前核对画布确实还是它。 */
133
+ const sid = (opts && typeof opts.sid === "string") ? opts.sid : "";
129
134
  /* mode = 这一轮是不是**某种模式**(目前只有 'rec' = 录屏模式,由 + 菜单的「录屏」胶囊决定,
130
135
  见 25-record.js::recStart)。它一路透传到主进程 desk:ask —— 那边据此把用户原文换成
131
136
  **模式指令**再交给引擎(用户气泡里显示的仍是他的原话,指令只进 prompt)。
@@ -147,8 +152,8 @@ async function qaSend(v, opts){
147
152
  只贴图不发文字是允许的(贴一张截图直接发是很自然的动作),
148
153
  此时 prompt 由 askAttPrompt 补一句中性引导。 */
149
154
  const atts = askAttReady(from);
150
- if(askAttBusy(from)){ toast("截图还在保存,稍等一下再发"); return; }
151
- if(askAttFailed(from).length){ toast("有截图没保存成功,先重试或移除"); return; }
155
+ if(askAttBusy(from)){ toast("截图还在保存,稍等一下再发"); return false; }
156
+ if(askAttFailed(from).length){ toast("有截图没保存成功,先重试或移除"); return false; }
152
157
  // 无桌面桥(纯浏览器打开):降级 = 创建流水线任务并跳转,两个输入框共用同一入口语义
153
158
  if(!(window.desk && typeof window.desk.ask === "function")){
154
159
  if(!v && !atts.length) return;
@@ -165,7 +170,10 @@ async function qaSend(v, opts){
165
170
  }
166
171
  /* 空输入直接 return(点停止钮时输入框是空的 → 那条路径已在 qaSendBtnSync 分流到
167
172
  qaStopActive,走不到这里;这里只挡「回车但什么都没写」这种空提交)。 */
168
- if(!v && !atts.length) return;
173
+ if(!v && !atts.length) return false;
174
+ /* 指定了会话(排队那条)但画布已经不是它了 → **一条都不发**,把调用方交给它的处理
175
+ (qaMsgFlush 会按返回值把条目退回队首,等切回来再发)。不静默吞掉,也不发错人。 */
176
+ if(sid && sid !== (qaActiveId || "")) return false;
169
177
  /* 处理中提交的下一条 → **排队**,不打断在跑的那一轮(用户 2026-09-18:「会话处理中时
170
178
  改成支持输入 但是新需求排列在会话框上面默认等待上一个结束后自动提交」)。
171
179
  改造前这里是「停就是停」:撰写区锁死、提交钮就是停止钮,任何调进来的路径都只会停。
@@ -175,21 +183,24 @@ async function qaSend(v, opts){
175
183
  ⚠️ 只对 followup(任务对话实例)成立:home2 = 新建对话入口,它提交的永远是**新会话**,
176
184
  不排进别人的队(qaBusyEx 也永不锁它,两边口径一致)。
177
185
  ⚠️ append(提问卡答案)不走这里:答案有自己的队列与时机(qaAskQueue),更不该排队。 */
178
- if(!append && followup && qaTaskRunning(qaActiveId)){
186
+ if(!append && followup && qaTaskRunning(sid || qaActiveId)){
179
187
  /* 模式轮(录屏 / 截屏 / 短视频)**不排队**:队列条目只带正文(qaMsgEnqueue({ text: v })),排进去就等于
180
188
  「用户以为在录屏,结果变成了一句普通对话」—— 静默降级比直接拒绝更糟。
181
189
  (recStart / shortsStart 发送前也拦了一道,这里是第二道:别指望调用方永远记得判。) */
182
190
  if(mode){
183
191
  /* 文案按模式给全称(2026-09-23 起模式轮不止「录屏」一种,别再硬编码)。 */
184
192
  const mn = { rec: "录屏", shot: "截屏", shorts: "做短视频", sim: "发模拟测试" }[mode] || "发送";
185
- toast("这条会话还在跑 —— 等它结束再" + mn); return;
193
+ toast("这条会话还在跑 —— 等它结束再" + mn); return false;
186
194
  }
195
+ /* ⚠️ 排进的是**这条消息自己的会话**(sid || qaActiveId):显式 sid 的调用方(队列 flush)
196
+ 已经声明了归属,不能因为此刻 qaActiveId 恰好是别人就排进别人的队 —— 那正是
197
+ 2026-10-23 那个「排队串会话」的同一个错,只是晚一步发作。 */
187
198
  if(from) askChipsClear(from); // 与正常发送同约定:发出即清掉该实例的胶囊
188
199
  const srcInp = $("#home-input");
189
200
  if(srcInp) srcInp.value = ""; // 正文已挪进队列条目 → 输入框腾空
190
- qaMsgEnqueue(qaActiveId, { text: v });
201
+ qaMsgEnqueue(sid || qaActiveId, { text: v });
191
202
  qaSendBtnSync(from || "home"); // 输入框空了 → 那枚钮切回「停止」
192
- return;
203
+ return true;
193
204
  }
194
205
  if(from) askChipsClear(from);
195
206
  const srcInput = followup ? $("#home-input") : $("#home2-input");
@@ -231,7 +242,7 @@ async function qaSend(v, opts){
231
242
  const prompt = v || (attPaths.length ? "请看截图。" : "");
232
243
  if(from) askAttClear(from); // 已进气泡 → 输入框上方的附件条功成身退
233
244
  const h = qaRenderAnswer();
234
- if(!h){ return; }
245
+ if(!h){ return false; }
235
246
  /* ⚠️ 锁只落**这条会话**自己的撰写区:run 按 taskId=qaActiveId 登记 → qaTaskRunning 成立,
236
247
  qaBusyEx 就只锁当前显示的这条;别的会话(含 home2 的新建入口)照常可发、互不设卡。
237
248
  `userText` / `msgText`(2026-09-22)=这一轮的**消息台账**:持久层只存消息(不存样式),