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
@@ -0,0 +1,98 @@
1
+ package cli
2
+
3
+ // suggest.go - `--suggest-followups <file|->`:一轮结束后生成「用户下一句」的快捷回复。
4
+ //
5
+ // 为什么要单独一条 CLI(用户 2026-10-23:「在回复结束后 ai 自动生成下一轮的建议,
6
+ // 点击即可发送」):界面(移动端 / PC)不该自己拼提示词、自己调模型 —— 那样每端一份,
7
+ // 同一轮回答在两处会给出不同建议。口径收进 core 之后只有一份(internal/suggest),
8
+ // 客户端(magic-client 的 /desk/suggest)exec 这一条即可。
9
+ //
10
+ // 与 `--summarize-title` 同一套旁路调用:独立进程、新会话、ToolsOff,不续接当前对话,
11
+ // 因此不会污染用户正在聊的那条上下文;也不写会话登记 / 日志(不经过 ask 主流程)。
12
+ //
13
+ // 退出码:
14
+ //
15
+ // 0 成功(哪怕建议为空数组 —— 「没有下一步」是合法结果,调用方不画按钮)
16
+ // 1 引擎失败(不可用 / 超时 / 报错)
17
+ // 2 参数错(请求 JSON 读不到 / 不合法 / 缺 reply)
18
+
19
+ import (
20
+ "encoding/json"
21
+ "fmt"
22
+ "io"
23
+ "os"
24
+ "strings"
25
+
26
+ "github.com/spf13/cobra"
27
+
28
+ "github.com/darren/magic-agent/internal/suggest"
29
+ )
30
+
31
+ // runSuggestFollowups 读取请求 JSON → 调 suggest.Generate → 打印 JSON 结果。
32
+ func runSuggestFollowups(cmd *cobra.Command, opts *askOptions) error {
33
+ body, err := readSuggestInput(cmd, opts.suggestInput)
34
+ if err != nil {
35
+ return &usageError{err}
36
+ }
37
+ /* 引擎:没显式传 -e 时用 llm(纯文本、无工具、没有 CLI 会话副作用,与 --title-engine 同口径);
38
+ 传了就以调用方的为准 —— 移动端把当前引擎带下来,用户的机器上一定装过它。 */
39
+ engine := suggest.DefaultEngine
40
+ if flagChanged(cmd, "engine") {
41
+ engine = opts.engine
42
+ }
43
+ model := ""
44
+ if flagChanged(cmd, "model") {
45
+ model = opts.model
46
+ }
47
+ res, err := suggest.Generate(cmd.Context(), suggest.Request{
48
+ User: body.User,
49
+ Reply: body.Reply,
50
+ Engine: engine,
51
+ Model: model,
52
+ Timeout: suggest.DefaultTimeout,
53
+ })
54
+ if err != nil {
55
+ return fmt.Errorf("--suggest-followups: 生成失败: %w", err)
56
+ }
57
+ if res.Suggestions == nil {
58
+ res.Suggestions = []string{}
59
+ }
60
+ return printJSON(cmd.OutOrStdout(), res)
61
+ }
62
+
63
+ // readSuggestInput 读请求 JSON:`-` = stdin,其余按文件路径(支持 `@file` 写法)。
64
+ // 上限 1MB:建议只该带「这一轮」的正文,超了说明调用方把整条会话塞进来了。
65
+ func readSuggestInput(cmd *cobra.Command, spec string) (suggest.RequestBody, error) {
66
+ var body suggest.RequestBody
67
+ src := strings.TrimSpace(spec)
68
+ switch src {
69
+ case "":
70
+ return body, fmt.Errorf("--suggest-followups 需要请求 JSON:传文件路径或 \"-\"(stdin)")
71
+ case "-":
72
+ b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
73
+ if err != nil {
74
+ return body, fmt.Errorf("读 stdin 失败:%w", err)
75
+ }
76
+ if err := json.Unmarshal(b, &body); err != nil {
77
+ return body, fmt.Errorf("stdin 不是合法 JSON:%w", err)
78
+ }
79
+ default:
80
+ path := strings.TrimPrefix(src, "@")
81
+ if strings.HasPrefix(path, "~/") {
82
+ if home, herr := os.UserHomeDir(); herr == nil {
83
+ path = home + path[1:]
84
+ }
85
+ }
86
+ b, err := os.ReadFile(path)
87
+ if err != nil {
88
+ return body, fmt.Errorf("读请求文件失败:%w", err)
89
+ }
90
+ if err := json.Unmarshal(b, &body); err != nil {
91
+ return body, fmt.Errorf("请求文件不是合法 JSON:%w", err)
92
+ }
93
+ }
94
+ if strings.TrimSpace(body.Reply) == "" {
95
+ return body, fmt.Errorf(`请求 JSON 缺少 reply(本轮回答正文):形如 {"user":"…","reply":"…"}`)
96
+ }
97
+ return body, nil
98
+ }
@@ -0,0 +1,54 @@
1
+ package cli
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/darren/magic-agent/internal/suggest"
9
+ )
10
+
11
+ // TestSuggestFollowupsJSON 走完整 CLI:读 stdin 的请求 JSON → 假引擎 → 输出建议。
12
+ func TestSuggestFollowupsJSON(t *testing.T) {
13
+ registerFake(&stringEngine{
14
+ name: "fake-suggest-cli",
15
+ text: "这是建议:\n```json\n{\"suggestions\":[\"好的,执行吧\",\"先看一下 diff\"]}\n```",
16
+ })
17
+ body := `{"user":"改完了吗","reply":"要我继续吗?"}`
18
+ out, errOut, err := runAskCmd(t, body, "--suggest-followups", "-", "-e", "fake-suggest-cli")
19
+ if err != nil {
20
+ t.Fatalf("suggest: %v(stderr=%s)", err, errOut)
21
+ }
22
+ var res suggest.Result
23
+ if uerr := json.Unmarshal([]byte(out), &res); uerr != nil {
24
+ t.Fatalf("输出不是 JSON:%v\n%s", uerr, out)
25
+ }
26
+ if len(res.Suggestions) != 2 || res.Suggestions[0] != "好的,执行吧" {
27
+ t.Fatalf("Suggestions = %#v", res.Suggestions)
28
+ }
29
+ if res.Engine != "fake-suggest-cli" {
30
+ t.Fatalf("Engine = %q", res.Engine)
31
+ }
32
+ }
33
+
34
+ // TestSuggestFollowupsEmptyReply 没有 reply 是**参数错**,不能当成空建议静默成功。
35
+ func TestSuggestFollowupsEmptyReply(t *testing.T) {
36
+ _, _, err := runAskCmd(t, `{"user":"只说了一句"}`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
37
+ if err == nil {
38
+ t.Fatal("缺 reply 应报参数错")
39
+ }
40
+ if !strings.Contains(err.Error(), "reply") {
41
+ t.Fatalf("报错没说清缺什么:%v", err)
42
+ }
43
+ }
44
+
45
+ // TestSuggestFollowupsBadJSON 非法 JSON 也必须明确报错(exit 2 的语义)。
46
+ func TestSuggestFollowupsBadJSON(t *testing.T) {
47
+ _, _, err := runAskCmd(t, `not json`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
48
+ if err == nil {
49
+ t.Fatal("非法 JSON 应报参数错")
50
+ }
51
+ if _, ok := err.(*usageError); !ok {
52
+ t.Fatalf("应是 usageError(exit 2),实际 %T: %v", err, err)
53
+ }
54
+ }
@@ -17,6 +17,8 @@ import (
17
17
  "encoding/json"
18
18
  "fmt"
19
19
  "strings"
20
+ "sync"
21
+ "time"
20
22
 
21
23
  "github.com/spf13/cobra"
22
24
 
@@ -98,7 +100,50 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
98
100
  // 事件出口:--no-thinking 只压思考。
99
101
  // 团队模式下主会话只出现任务列表快照(KindTasks)与汇总,子任务的过程事件
100
102
  // 走各自子会话的日志(见 team_session.go),不再带 parent 转发进来。
101
- emit := func(ev agent.StreamEvent) {
103
+ //
104
+ // ⚠️ 登记表的「还活着」靠这里续期(2026-10-22 报障:「团队模式子任务还在跑 主会话
105
+ // 就说结束了」)。流式路径(ask.go)对**每条事件**都写一次状态,团队路径此前只在
106
+ // 开跑前写一次 running —— 计划 + 子任务几分钟里 updated_at 一动不动,网关的尾随
107
+ // 连接(followStateEvery=5s)据 terminalSessionState 判「这一轮已结束」,画面落
108
+ // 红点收尾行,而子任务进程其实还在跑。团队模式没有 turn_end 可依,所以任何**主会话
109
+ // 自己发出的事件**(计划、任务列表快照、心跳、汇总)都当成一次「还在跑」。
110
+ // (h 在下面才建:闭包定义在这里不带值,第一次调用在 beginSession 之后。)
111
+ var emit func(agent.StreamEvent)
112
+
113
+ ctx := cmd.Context()
114
+ if !opts.keepAlive || flagChanged(cmd, "timeout") {
115
+ var cancel context.CancelFunc
116
+ ctx, cancel = context.WithTimeout(ctx, opts.timeout)
117
+ defer cancel()
118
+ }
119
+ // --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
120
+ var cancelAll context.CancelFunc
121
+ if opts.control {
122
+ ctx, cancelAll = context.WithCancel(ctx)
123
+ defer cancelAll()
124
+ }
125
+
126
+ h := beginSession(engine.Name(), req, opts.saveRegistry)
127
+ /* 团队模式**不挂** spawn hook:登记表里的 pid 必须一直是 magic-agent 自己。
128
+ 团队路径会多次 spawn 主引擎(计划 / 汇总 / 补充规划),hook 会把 pid 换成
129
+ 这些**短命**子进程的 pid;第一个(计划阶段)一退,normalizeDead 见 pid 不在
130
+ 就判整条主会话 gone —— 而此刻子任务正在跑。真机复现:24 分钟的一轮里主会话
131
+ 在第 32 秒就被判死,直到收尾才写回 done。
132
+ 子引擎进程不靠这条登记收:它们各有自己的子会话记录(openSubSession),
133
+ 异常退出时由 orphanSubs 按进程组收。 */
134
+ _ = withSessionHook
135
+ if h != nil {
136
+ // 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
137
+ h.SetMode(session.ModeTeam)
138
+ h.Mark(session.StateRunning)
139
+ }
140
+ emit = func(ev agent.StreamEvent) {
141
+ h.Mark(session.StateRunning)
142
+ /* 续期(2026-10-22 真机复现):Mark 在新旧状态相同时**不写盘**,所以上面那一句
143
+ 对「已经 running」的会话等于空操作 —— updated_at 从第一条事件起就冻住了,
144
+ 网关尾随连接据此判终态,画面在子任务还在跑时就落「这一轮已结束」。
145
+ Touch 才是真正的续期;5 秒节流对齐网关的 followStateEvery。 */
146
+ h.Touch(5 * time.Second)
102
147
  if ev.Kind == agent.KindThinking && opts.noThinking {
103
148
  return
104
149
  }
@@ -136,27 +181,6 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
136
181
  })
137
182
  }
138
183
  }
139
-
140
- ctx := cmd.Context()
141
- if !opts.keepAlive || flagChanged(cmd, "timeout") {
142
- var cancel context.CancelFunc
143
- ctx, cancel = context.WithTimeout(ctx, opts.timeout)
144
- defer cancel()
145
- }
146
- // --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
147
- var cancelAll context.CancelFunc
148
- if opts.control {
149
- ctx, cancelAll = context.WithCancel(ctx)
150
- defer cancelAll()
151
- }
152
-
153
- h := beginSession(engine.Name(), req, opts.saveRegistry)
154
- ctx = withSessionHook(ctx, h)
155
- if h != nil {
156
- // 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
157
- h.SetMode(session.ModeTeam)
158
- h.Mark(session.StateRunning)
159
- }
160
184
  if sessionLog != nil && h != nil {
161
185
  sessionLog.setRunID(h.RunID())
162
186
  }
@@ -180,6 +204,10 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
180
204
  if h != nil {
181
205
  runID = h.RunID()
182
206
  }
207
+ /* 本次运行开出来的子会话(按 pid 收进程组用,见 orphanSubs)。
208
+ 写它的是跑子任务的 goroutine,读的是异常退出那一支 —— 加锁。 */
209
+ var mu sync.Mutex
210
+ var subs []*subSession
183
211
  res, err := team.Run(ctx, team.Options{
184
212
  Engine: engine,
185
213
  Model: req.Model,
@@ -204,13 +232,48 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
204
232
  PlanOnly: opts.teamPlanOnly,
205
233
  Adaptive: opts.teamAdaptive,
206
234
  // 每个子任务 = 一条普通会话:登记记录 + 自己的日志,父指针挂在本会话下。
235
+ //
236
+ // 开出来的子会话都记进 subs:父进程异常退出时要按它们的 pid 收进程组
237
+ // (见下面的 orphan sweep),否则主会话没了、子引擎还在跑,列表里挂一条永远
238
+ // running 的子会话。
207
239
  OpenSubSession: func(t team.Task, runDir, engine, model string) team.SubSession {
208
- return openSubSession(h, opts.saveRegistry, opts.saveHistory, stderr, t, runDir, engine, model)
240
+ s := openSubSession(h, opts.saveRegistry, opts.saveHistory, stderr, t, runDir, engine, model)
241
+ if ss, ok := s.(*subSession); ok && ss != nil {
242
+ mu.Lock()
243
+ subs = append(subs, ss)
244
+ mu.Unlock()
245
+ }
246
+ return s
209
247
  },
210
248
  OnEvent: emit,
249
+ // 计划阶段不发事件的续期(见 team.Options.OnTick)。
250
+ OnTick: func() { h.Touch(0) },
211
251
  })
252
+ mu.Lock()
253
+ running := append([]*subSession(nil), subs...)
254
+ mu.Unlock()
212
255
  if err != nil {
213
- finishSession(h, "", session.StateFailed)
256
+ /* 异常退出也要有**终态**(2026-10-22):团队模式没有 turn_end 可依,父进程失败时
257
+ 一条终态事件都不发 —— 客户端只能看登记表,而登记表停在 running,网关按「还在跑」
258
+ 一直等(或按内核被杀归一成 gone)。这里补一条 turn_failed:走的是流式路径
259
+ 同一条收尾口径(turn_failed → 画面一行「本轮异常收尾」),调用方不必再猜。 */
260
+ interrupted := ctx.Err() != nil
261
+ reason := ""
262
+ if interrupted {
263
+ reason = "已中断(主进程被取消或被杀)"
264
+ } else {
265
+ reason = err.Error()
266
+ }
267
+ emit(agent.StreamEvent{Kind: agent.KindTurnFailed, Text: reason, Reason: reason})
268
+ state := session.StateFailed
269
+ if interrupted {
270
+ state = session.StateInterrupted
271
+ }
272
+ finishSession(h, "", state)
273
+ /* 子引擎是独立进程组(子会话上下文里挂的 SpawnHook 按组登记 pid):
274
+ 父这一轮已经作废,把还在跑的一起收掉 —— 否则会话列表里永远挂着一条 running
275
+ 的子会话,用户点进去只看到半截过程,而且它会一直烧额度。 */
276
+ orphanSubs(running)
214
277
  if format == agent.FormatJSON {
215
278
  _ = sink.event(streamErrorPayload(engine.Name(), err))
216
279
  _ = agent.WriteError(stderr, format, engine.Name(), 1, err)
@@ -253,6 +316,30 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
253
316
  return nil
254
317
  }
255
318
 
319
+ // orphanSubs 收掉还没落定的子会话对应的子引擎进程组。
320
+ //
321
+ // 只在团队这一轮**异常退出**时调:正常收尾时每个子会话都在 runTask 的 end() 里
322
+ // Finish 过(进程已经自己退了)。这里兜的是「父进程这一轮作废、子引擎还在跑」那条路
323
+ // —— 不收就是孤儿进程:它一边烧额度,一边让会话列表里那条子会话永远 running。
324
+ //
325
+ // 幂等:进程已经不在(PIDAlive=false)就只补登记表状态,不再发信号。
326
+ func orphanSubs(list []*subSession) {
327
+ for _, s := range list {
328
+ if s == nil {
329
+ continue
330
+ }
331
+ rec := s.h.Record()
332
+ if rec.PID > 0 && agent.PIDAlive(rec.PID) {
333
+ _ = agent.KillPID(rec.PID, rec.KillGroup)
334
+ }
335
+ s.h.Finish(rec.SessionID, session.StateInterrupted)
336
+ if s.log != nil {
337
+ s.log.close()
338
+ s.log = nil
339
+ }
340
+ }
341
+ }
342
+
256
343
  // agentRefs 把子任务结果按「类别 + 引擎 + 模型」归并成登记表里的 Agents。
257
344
  //
258
345
  // 为什么按类别归并而不是一个任务一条:登记表是列表页的数据源,一个 6 任务的团队会话
@@ -21,6 +21,7 @@ package client
21
21
  // 「一句说完、下一句还没打完」的经验值,期间再来一轮就顺延、合并成一次。
22
22
 
23
23
  import (
24
+ "bytes"
24
25
  "context"
25
26
  "encoding/json"
26
27
  "errors"
@@ -82,12 +83,13 @@ func (s *Service) handleRequirements(w http.ResponseWriter, r *http.Request) {
82
83
  // handleRequirementsConsolidate POST /desk/requirements/consolidate:
83
84
  // 立即归纳一轮(用户点「整理」)。
84
85
  //
85
- // 请求体:{"workspace":"<ws_id|目录>", "force":false, "all":false,
86
+ // 请求体:{"workspace":"<ws_id|目录>", "module":"<模块名>", "force":false,
86
87
  //
87
- // "engine":"claude", "model":"..."}
88
+ // "all":false, "engine":"claude", "model":"..."}
88
89
  //
89
90
  // workspace 留空 = 插件自己的工作目录(与 /desk/health 的 defaultWorkspace 一致 ——
90
91
  // 那就是 core 不传 -w 时记录的 cwd)。
92
+ // module 留空 = 整理整个空间;给了就只整理那一个模块(移动端二级页的「整理」)。
91
93
  // engine / model 是客户端当前配置的默认引擎与模型(移动端「我的 → 默认引擎」):
92
94
  // 「整理的引擎就是配置的默认引擎」—— 客户端传什么,core 就按什么跑,不再自动替换。
93
95
  func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.Request) {
@@ -102,6 +104,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
102
104
  }
103
105
  var body struct {
104
106
  Workspace string `json:"workspace,omitempty"`
107
+ Module string `json:"module,omitempty"`
105
108
  Force bool `json:"force,omitempty"`
106
109
  All bool `json:"all,omitempty"`
107
110
  Engine string `json:"engine,omitempty"`
@@ -122,6 +125,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
122
125
  writeErr(w, http.StatusBadRequest, "workspace 不能为空(传 ws id 或目录)")
123
126
  return
124
127
  }
128
+ module := strings.TrimSpace(body.Module)
125
129
  engine := strings.TrimSpace(body.Engine)
126
130
  model := strings.TrimSpace(body.Model)
127
131
  if model != "" && engine == "" {
@@ -133,7 +137,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
133
137
  return
134
138
  }
135
139
  out, err := s.runCoreTimed(r.Context(), reqConsolidateTimeout,
136
- consolidateArgs(ws, body.Force, body.All, false, engine, model)...)
140
+ consolidateArgs(ws, module, body.Force, body.All, false, engine, model)...)
137
141
  if err != nil {
138
142
  code := http.StatusBadGateway
139
143
  if isCoreTimeout(err) {
@@ -150,8 +154,11 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
150
154
  // engine/model 来自客户端配置的默认引擎(手动「整理」):传了就用它,core 不再自动换。
151
155
  // 自动归纳(auto=true)不传:后台没有「当前设备默认引擎」这个概念,让 core 走自己的
152
156
  // 默认(增量用 llm;首次初始化也不会发生,因为 --requirement-auto 明确禁止)。
153
- func consolidateArgs(ws string, force, all, auto bool, engine, model string) []string {
157
+ func consolidateArgs(ws, module string, force, all, auto bool, engine, model string) []string {
154
158
  args := []string{"--consolidate-requirements", ws}
159
+ if module != "" {
160
+ args = append(args, "--requirement-module", module)
161
+ }
155
162
  if force {
156
163
  args = append(args, "--requirement-force")
157
164
  }
@@ -178,14 +185,27 @@ func (s *Service) runCoreTimed(ctx context.Context, timeout time.Duration, args
178
185
  cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
179
186
  // 与 runCore 同一道保险:core 的后代进程握着管道时 Wait 不能永久挂住。
180
187
  cmd.WaitDelay = 5 * time.Second
181
- out, err := cmd.Output()
188
+ var stdout, stderr bytes.Buffer
189
+ cmd.Stdout = &stdout
190
+ cmd.Stderr = &stderr
191
+ err := cmd.Run()
182
192
  if err != nil {
183
193
  if ctx.Err() != nil || errors.Is(err, exec.ErrWaitDelay) {
184
194
  return nil, fmt.Errorf("%w(%s)", errCoreTimeout, timeout)
185
195
  }
196
+ /* core 失败时也会先把结构化结果写到 stdout(`{ok:false,error:"..."}`),
197
+ 再返回非零退出码。Output() 会把它丢掉,界面只剩一句「exit status 1」——
198
+ 用户看不到真正原因(例如「模块不存在」)。这里把 stdout 交回调用方,
199
+ 由它按 JSON 里的 error 展示;真的一个字都没输出才退化成 stderr/退出码。 */
200
+ if stdout.Len() > 0 {
201
+ return stdout.Bytes(), nil
202
+ }
203
+ if msg := strings.TrimSpace(stderr.String()); msg != "" {
204
+ return nil, fmt.Errorf("%s", msg)
205
+ }
186
206
  return nil, err
187
207
  }
188
- return out, nil
208
+ return stdout.Bytes(), nil
189
209
  }
190
210
 
191
211
  // isCoreTimeout 判断错误链里是不是「等 core 超时」。
@@ -270,7 +290,7 @@ func (s *Service) runAutoConsolidate(ws string) {
270
290
 
271
291
  // 归纳是后台行为:**不绑**任何 HTTP 请求的生命周期,用独立 context。
272
292
  ctx, cancel := context.WithTimeout(context.Background(), reqConsolidateTimeout)
273
- out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, false, false, true, "", "")...)
293
+ out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, "", "")...)
274
294
  cancel()
275
295
  if err != nil {
276
296
  s.log.Printf("需求自动归纳失败 %s: %v", ws, err)
@@ -131,6 +131,23 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
131
131
  if !ok {
132
132
  t.Errorf("应透传目录 / force / 配置的默认引擎与模型,实际 %v", readCalls(t, logPath))
133
133
  }
134
+ // 二级页「整理」:module 要原样透传给 core,否则会退化成整空间。
135
+ recMod := httptest.NewRecorder()
136
+ bodyMod := strings.NewReader(`{"workspace":"/proj","module":"登录","engine":"claude"}`)
137
+ svc.Handler().ServeHTTP(recMod, httptest.NewRequest(http.MethodPost,
138
+ "/desk/requirements/consolidate?token=tok", bodyMod))
139
+ if recMod.Code != http.StatusOK {
140
+ t.Fatalf("模块级归纳应 200,实际 %d: %s", recMod.Code, recMod.Body.String())
141
+ }
142
+ var sawModule bool
143
+ for _, c := range readCalls(t, logPath) {
144
+ if strings.Contains(c, "--consolidate-requirements /proj --requirement-module 登录") {
145
+ sawModule = true
146
+ }
147
+ }
148
+ if !sawModule {
149
+ t.Errorf("应透传 --requirement-module 登录,实际 %v", readCalls(t, logPath))
150
+ }
134
151
  // 归纳的结果要能解出核心字段(客户端靠它显示「更新/新建/待整理」)。
135
152
  var res struct {
136
153
  OK bool `json:"ok"`
@@ -141,6 +158,40 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
141
158
  }
142
159
  }
143
160
 
161
+ // TestRequirementsConsolidateSurfacesCoreError core 失败时先写 JSON 再 exit 1,
162
+ // 插件不能把它吞成「exit status 1」——界面要能显示「模块不存在」这类真正原因。
163
+ func TestRequirementsConsolidateSurfacesCoreError(t *testing.T) {
164
+ dir := t.TempDir()
165
+ core := filepath.Join(dir, "magic-agent")
166
+ script := `#!/bin/sh
167
+ case "$*" in
168
+ *--contract*)
169
+ echo '{"contractVersion":2,"features":["feature.session.events","feature.session.control","` + FeatureRequirements + `"],"engines":[]}'
170
+ exit 0
171
+ ;;
172
+ *--consolidate-requirements*)
173
+ echo '{"ok":false,"workspace":"ws-1","module":"不存在","error":"模块不存在: 不存在"}'
174
+ exit 1
175
+ ;;
176
+ esac
177
+ exit 0
178
+ `
179
+ if err := os.WriteFile(core, []byte(script), 0o755); err != nil {
180
+ t.Fatal(err)
181
+ }
182
+ svc := newTestService(t, core)
183
+ rec := httptest.NewRecorder()
184
+ body := strings.NewReader(`{"workspace":"ws-1","module":"不存在"}`)
185
+ svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
186
+ "/desk/requirements/consolidate?token=tok", body))
187
+ if rec.Code != http.StatusOK {
188
+ t.Fatalf("core 的结构化失败应原样回传,实际 %d: %s", rec.Code, rec.Body.String())
189
+ }
190
+ if !strings.Contains(rec.Body.String(), "模块不存在") {
191
+ t.Errorf("应保留 core 的 error 文案,实际 %s", rec.Body.String())
192
+ }
193
+ }
194
+
144
195
  func TestMaybeAutoConsolidate(t *testing.T) {
145
196
  logPath := filepath.Join(t.TempDir(), "calls.log")
146
197
  core := reqCore(t, `"feature.session.events","feature.session.control",`+
@@ -49,6 +49,13 @@ const RequiredContractVersion = 2
49
49
  // 没有 events 就没有带游标的事件流,没有 control 就无法打断与回审批。
50
50
  var requiredFeatures = []string{"feature.session.events", "feature.session.control"}
51
51
 
52
+ // FeatureSessionHosted core 的「会话托管历史标识」能力位(与 internal/agent 同值)。
53
+ //
54
+ // 可选特性:老 core 没有它,插件照常发送,只是不追加 --hosted ——
55
+ // 移动端实时气泡仍由客户端自己标,历史回放则退回「用户亲手发的」画法。
56
+ // 不塞进 requiredFeatures,否则升插件就得同步升 core。
57
+ const FeatureSessionHosted = "feature.session.hosted"
58
+
52
59
  // Options 启动参数。
53
60
  type Options struct {
54
61
  // MagicAgent core 可执行文件;空 = 从 PATH 找 magic-agent。
@@ -221,6 +228,9 @@ func (s *Service) Handler() http.Handler {
221
228
  // 按**绝对路径**读文本文件(Markdown / 日志 / 源码):移动端产物卡「查看」在浮层里阅读。
222
229
  mux.HandleFunc("/desk/path-read", s.guard(s.handlePathRead))
223
230
  mux.HandleFunc("/desk/titles", s.guard(s.handleTitles))
231
+ // 下一轮快捷回复(2026-10-23):一轮结束后生成 0-3 条「用户下一句」。
232
+ // 旁路调用 core 的 --suggest-followups(不续接会话),界面点击即发。
233
+ mux.HandleFunc("/desk/suggest", s.guard(s.handleSuggest))
224
234
  // 移动端「环境配置」(引擎/模型/团队/空间选择)的远端存储:多客户端连同一台桌面时共用一份,
225
235
  // 不再各存各的 localStorage(见 prefs.go)。
226
236
  mux.HandleFunc("/desk/prefs", s.guard(s.handlePrefs))
@@ -1074,6 +1084,8 @@ func truncateForMsg(s string) string {
1074
1084
  // 2026-10-19:实现挪进 `session.Delete`(internal/session/delete.go)—— CLI 新增的
1075
1085
  // `--delete-session` 走的是同一份,两条路不再各写一遍(此前只有本接口能做真删除,
1076
1086
  // 于是桌面端只能在自己本地藏起来,出现「PC 删了、移动端还在」)。
1087
+ // 2026-10-23:`session.Delete` 会沿父指针级联删掉团队主会话下的子任务会话,
1088
+ // 本接口因此也一并生效(响应多一个只增字段 childSessionsRemoved)。
1077
1089
  func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
1078
1090
  id, action, ok := parseSessionPath(r.URL.Path)
1079
1091
  if !ok || action != "" {
@@ -1085,7 +1097,12 @@ func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
1085
1097
  writeErr(w, http.StatusBadRequest, fmt.Sprintf("删除会话 %s 失败:%v", id, err))
1086
1098
  return
1087
1099
  }
1088
- writeJSON(w, http.StatusOK, map[string]any{"id": res.ID, "recordsRemoved": res.RecordsRemoved})
1100
+ // 子会话计数只增字段(旧客户端忽略):告诉 UI「这次连子任务一起删了几条」。
1101
+ writeJSON(w, http.StatusOK, map[string]any{
1102
+ "id": res.ID,
1103
+ "recordsRemoved": res.RecordsRemoved,
1104
+ "childSessionsRemoved": res.ChildSessionsRemoved,
1105
+ })
1089
1106
  }
1090
1107
 
1091
1108
  // parseSessionPath 从 `/desk/session/{id}/{action}` 拆出 id 与 action,并做**路径安全校验**。
@@ -1773,6 +1790,12 @@ type askRequest struct {
1773
1790
  // 方式,不是这台机器的属性。开关状态由客户端自己记(localStorage),每轮随 body
1774
1791
  // 下发 —— core 因此完全无状态,「刚才那条为什么走了团队」在会话登记表里查得到。
1775
1792
  Team *teamRequest `json:"team,omitempty"`
1793
+ // Hosted 本轮提问由客户端「会话托管」自动发送(2026-10-23,只增字段)。
1794
+ //
1795
+ // 只用于把 `kind:"user"` 历史事件标成 hosted(客户端回放画成「托管发送」),
1796
+ // 不改变执行语义。core 没有 feature.session.hosted 时插件**忽略**它 ——
1797
+ // 实时气泡仍由客户端自己标,老 core 上只是历史回放少一个标识,不该阻断发送。
1798
+ Hosted bool `json:"hosted,omitempty"`
1776
1799
  }
1777
1800
 
1778
1801
  // teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
@@ -1870,7 +1893,7 @@ const (
1870
1893
  // 游标(handleAskEvents 里 `after == 0` 才放行它);
1871
1894
  // - `prompt_id` 是客户端去重的键:core 首行到达前客户端会反复以 after=0 轮询,没有它
1872
1895
  // 同一句提问会被画很多遍(见 client-ui/shared/conversation.js 的 render)。
1873
- func (r *askRun) appendPrompt(text string, attachments []string) {
1896
+ func (r *askRun) appendPrompt(text string, attachments []string, hosted bool) {
1874
1897
  atts := make([]map[string]string, 0, len(attachments))
1875
1898
  for _, p := range attachments {
1876
1899
  if p = strings.TrimSpace(p); p != "" {
@@ -1886,6 +1909,10 @@ func (r *askRun) appendPrompt(text string, attachments []string) {
1886
1909
  if len(atts) > 0 {
1887
1910
  ev["attachments"] = atts
1888
1911
  }
1912
+ /* 与 core 落盘的同名字段同义:断线续传画回来的那条也要是「托管发送」。 */
1913
+ if hosted {
1914
+ ev["hosted"] = true
1915
+ }
1889
1916
  b, err := json.Marshal(ev)
1890
1917
  if err != nil {
1891
1918
  return
@@ -2066,6 +2093,11 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2066
2093
  args = append(args, "-a", p)
2067
2094
  }
2068
2095
  args = append(args, "-p", req.Prompt)
2096
+ /* 托管标识:老 core 不认识 --hosted(exit 2),所以先看能力位;缺了就不传 ——
2097
+ 发送本身不能因为这个**纯显示**字段被阻断(实时气泡由客户端自己标)。 */
2098
+ if req.Hosted && hasStr(s.contract.Features, FeatureSessionHosted) {
2099
+ args = append(args, "--hosted")
2100
+ }
2069
2101
 
2070
2102
  /* 子进程**不绑 HTTP 请求的生命周期**(2026-10-19):此前用 r.Context(),手机端 SSE
2071
2103
  一断(锁屏 / 切后台 / WebView 回收 / 页面刷新)就会 SIGKILL core —— core 来不及写
@@ -2114,7 +2146,7 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2114
2146
  cmd.Stderr = run.stderr
2115
2147
  /* 用户这条提问先落进回放缓冲(不进 SSE 直播流):core 的事件流里没有 user 事件,
2116
2148
  断线重连 / 打开运行中会话时补出来的画面会缺「用户问的是什么」。 */
2117
- run.appendPrompt(req.Prompt, req.Attachments)
2149
+ run.appendPrompt(req.Prompt, req.Attachments, req.Hosted)
2118
2150
  if err := cmd.Start(); err != nil {
2119
2151
  writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
2120
2152
  return
@@ -66,6 +66,9 @@ echo '{"v":1,"seq":4,"type":"result","text":"hi"}'
66
66
 
67
67
  const goodContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
68
68
 
69
+ // hostedContract 多了托管历史标识的能力位(2026-10-23 只增)。
70
+ const hostedContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control","feature.session.hosted"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
71
+
69
72
  func newTestService(t *testing.T, core string) *Service {
70
73
  t.Helper()
71
74
  svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
@@ -418,6 +421,38 @@ func TestAskPassesSaveHistory(t *testing.T) {
418
421
  }
419
422
  }
420
423
 
424
+ // TestAskPassesHosted 托管自动发送的标识:core 有这个能力位时才透传 --hosted。
425
+ // 它只写会话历史(回放画成「托管发送」),执行语义不变。
426
+ func TestAskPassesHosted(t *testing.T) {
427
+ svc := newTestService(t, fakeCore(t, hostedContract))
428
+ srv := httptest.NewServer(svc.Handler())
429
+ defer srv.Close()
430
+
431
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
432
+ defer sse.close()
433
+ _ = sse.next(t)
434
+ raw, _ := sse.next(t)["raw"].(string)
435
+ if !strings.Contains(raw, "--hosted") {
436
+ t.Errorf("core 支持 feature.session.hosted 时应透传 --hosted,实际 %q", raw)
437
+ }
438
+ }
439
+
440
+ // TestAskDropsHostedForOldCore 老 core 不认识 --hosted(会 exit 2),插件必须忽略。
441
+ // 这是**纯显示**字段,不能因为它阻断发送;实时气泡仍由客户端自己标。
442
+ func TestAskDropsHostedForOldCore(t *testing.T) {
443
+ svc := newTestService(t, fakeCore(t, goodContract))
444
+ srv := httptest.NewServer(svc.Handler())
445
+ defer srv.Close()
446
+
447
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
448
+ defer sse.close()
449
+ _ = sse.next(t)
450
+ raw, _ := sse.next(t)["raw"].(string)
451
+ if strings.Contains(raw, "--hosted") {
452
+ t.Errorf("老 core 没有 feature.session.hosted,不该透传 --hosted,实际 %q", raw)
453
+ }
454
+ }
455
+
421
456
  func TestAskInjectsClientEnv(t *testing.T) {
422
457
  svc := newTestService(t, fakeCore(t, goodContract))
423
458
  srv := httptest.NewServer(svc.Handler())