magic-agent 1.0.49 → 1.0.109

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 (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2443 -178
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -0,0 +1,374 @@
1
+ package cli
2
+
3
+ // team.go - 团队模式的 CLI 主流程(--team)。
4
+ //
5
+ // 为什么单独一个文件而不是塞进 runStreamAsk:那条流程围绕**一个引擎的一次流式调用**
6
+ // 展开(streamer.Stream 一次、超时一次、结果一份)。团队模式是「主引擎 + N 个子引擎」,
7
+ // 只有最外层的事件出口与收尾格式和它相同。混在一起会让两边都长出 if —— 而这条路径
8
+ // 恰好是「出问题最难查」的那条。
9
+ //
10
+ // 与 +runStreamAsk 的**共用契约**(必须保持一致,否则客户端会看到两种收尾):
11
+ // - 每轮以 result 或 error 收尾;
12
+ // - --events 下先发 ready,每条事件带 v / seq;
13
+ // - --save-history 时事件落盘,历史回放与实时渲染同构。
14
+
15
+ import (
16
+ "context"
17
+ "encoding/json"
18
+ "fmt"
19
+ "strings"
20
+
21
+ "github.com/spf13/cobra"
22
+
23
+ "github.com/darren/magic-agent/internal/agent"
24
+ "github.com/darren/magic-agent/internal/session"
25
+ "github.com/darren/magic-agent/internal/team"
26
+ )
27
+
28
+ // runTeamAsk 执行一轮团队会话。
29
+ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
30
+ /* 先查 --stream:`--team` 不配它时**根本不该**走到这里(runAsk 里也挡了一次),
31
+ 但分发是按 opts.team 走的 —— 没有这一句,用户会看到「引擎不支持流式」
32
+ 这种指向引擎的报错,而真正的原因是漏了 --stream(2026-10-21 实测踩到)。 */
33
+ if !opts.stream {
34
+ return &usageError{fmt.Errorf(
35
+ "--team 需要 --stream(团队模式的过程靠事件流呈现:主会话里一张任务列表卡 + 每个子任务一条会话);" +
36
+ "加上 --stream 即可,或去掉 --team 走单引擎")}
37
+ }
38
+ engine, format, req, err := prepareAsk(cmd, args, opts)
39
+ if err != nil {
40
+ return err
41
+ }
42
+ if agent.AsStreamer(engine) == nil {
43
+ return &usageError{fmt.Errorf("engine %q does not support streaming(--team 需要流式能力)", engine.Name())}
44
+ }
45
+ routes, err := team.ParseRoutes(opts.teamRoutes)
46
+ if err != nil {
47
+ return &usageError{err}
48
+ }
49
+ if err := team.ValidateRoutes(routes); err != nil {
50
+ return &usageError{err}
51
+ }
52
+ split, err := team.NormalizeSplit(opts.teamSplit)
53
+ if err != nil {
54
+ return &usageError{err}
55
+ }
56
+ rules, err := team.ParseRules(opts.teamRules)
57
+ if err != nil {
58
+ return &usageError{err}
59
+ }
60
+ var teamPerm agent.PermissionTier
61
+ if raw := strings.TrimSpace(opts.teamPerm); raw != "" {
62
+ tier, ok := agent.ParsePermissionTier(raw)
63
+ if !ok {
64
+ return &usageError{fmt.Errorf("--team-permission 取值无效: %q(可选 %s)",
65
+ raw, strings.Join(agent.PermissionTiers(), " | "))}
66
+ }
67
+ teamPerm = tier
68
+ }
69
+
70
+ stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr()
71
+ sink := newEventSink(stdout, engine.Name(), opts.events)
72
+
73
+ // 会话存档:与 --stream 同一条路径(团队会话同样要能回看)。
74
+ var sessionLog *sessionWriter
75
+ if opts.saveHistory {
76
+ sw, serr := newSessionWriterAuto(sessionLogDir)
77
+ if serr != nil {
78
+ fmt.Fprintf(stderr, "magic-agent: 准备会话存档目录失败:%v(流继续)\n", serr)
79
+ } else {
80
+ sessionLog = sw
81
+ defer func() { sessionLog.close() }()
82
+ }
83
+ }
84
+ req.OnSessionID = func(id string) {
85
+ if sessionLog != nil {
86
+ sessionLog.setSessionID(id)
87
+ }
88
+ }
89
+ if sessionLog != nil {
90
+ for _, m := range req.Messages {
91
+ if m.Role != "user" || strings.TrimSpace(m.Content) == "" {
92
+ continue
93
+ }
94
+ sessionLog.push(sessionEvent{Kind: "user", Text: m.Content, Attachments: req.Attachments})
95
+ }
96
+ }
97
+
98
+ // 事件出口:--no-thinking 只压思考。
99
+ // 团队模式下主会话只出现任务列表快照(KindTasks)与汇总,子任务的过程事件
100
+ // 走各自子会话的日志(见 team_session.go),不再带 parent 转发进来。
101
+ emit := func(ev agent.StreamEvent) {
102
+ if ev.Kind == agent.KindThinking && opts.noThinking {
103
+ return
104
+ }
105
+ if format == agent.FormatJSON {
106
+ _ = sink.stream(ev)
107
+ } else {
108
+ switch ev.Kind {
109
+ case agent.KindThinking:
110
+ fmt.Fprint(stderr, "… "+ev.Text)
111
+ case agent.KindText:
112
+ fmt.Fprint(stdout, ev.Text)
113
+ case agent.KindToolUse:
114
+ fmt.Fprint(stderr, "🔧 "+ev.Name+"("+ev.ID+") "+ev.Text+"\n")
115
+ case agent.KindToolResult:
116
+ fmt.Fprint(stderr, " ↳ "+ev.Text+"\n")
117
+ case agent.KindNotice:
118
+ fmt.Fprint(stderr, "· "+ev.Text+"\n")
119
+ case agent.KindTasks:
120
+ // 快照每次变化都会整份重发:text 模式只打一行摘要,不重画整张表。
121
+ if line := tasksLine(ev.Text); line != "" {
122
+ fmt.Fprint(stderr, "☰ "+line+"\n")
123
+ }
124
+ case agent.KindTurnFailed:
125
+ fmt.Fprint(stderr, "✗ 本轮异常收尾:"+ev.Reason+"\n")
126
+ }
127
+ }
128
+ if sessionLog != nil {
129
+ sessionLog.push(sessionEvent{
130
+ Kind: string(ev.Kind), Text: ev.Text, Name: ev.Name, ID: ev.ID,
131
+ ToolKind: toolKindOfEvent(ev), Ask: ev.Ask,
132
+ ItemID: ev.ItemID, ItemRevision: ev.ItemRevision,
133
+ Status: string(ev.Status), Parent: ev.Parent,
134
+ SubSessionID: ev.SubSessionID,
135
+ Error: ev.Error, Reason: ev.Reason,
136
+ })
137
+ }
138
+ }
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
+ if sessionLog != nil && h != nil {
161
+ sessionLog.setRunID(h.RunID())
162
+ }
163
+ if h != nil {
164
+ sink.setRunID(h.RunID())
165
+ }
166
+ _ = sink.ready(req.Model)
167
+
168
+ if opts.control {
169
+ ctl := startControlReader(cmd.InOrStdin(), sink, controlHooks{
170
+ interrupt: func(reason string) { cancelAll() },
171
+ stop: func() { cancelAll() },
172
+ // 团队模式下子任务不共享上下文,也没有单条常驻会话可追加 —— 明确拒绝,
173
+ // 而不是静默吞掉(调用方以为答上了才是最坏的结果)。
174
+ answer: func(text string) bool { return false },
175
+ })
176
+ defer ctl.Close()
177
+ }
178
+
179
+ runID := ""
180
+ if h != nil {
181
+ runID = h.RunID()
182
+ }
183
+ res, err := team.Run(ctx, team.Options{
184
+ Engine: engine,
185
+ Model: req.Model,
186
+ Prompt: lastUserText(req.Messages),
187
+ SystemPrompt: req.SystemPrompt,
188
+ Reasoning: req.Reasoning,
189
+ SessionID: req.SessionID,
190
+ Continue: req.Continue,
191
+ Routes: routes,
192
+ Parallel: opts.teamParallel,
193
+ Split: split,
194
+ SplitSimpleCode: opts.teamSplitCode,
195
+ Rules: rules,
196
+ TaskTimeout: opts.teamTaskTO,
197
+ Timeout: opts.timeout,
198
+ Workspace: req.Workspace,
199
+ RunID: runID,
200
+ BaseDir: opts.teamRunDir,
201
+ Permission: teamPerm,
202
+ PermissionOptions: req.PermissionOptions,
203
+ Tools: req.Tools,
204
+ PlanOnly: opts.teamPlanOnly,
205
+ Adaptive: opts.teamAdaptive,
206
+ // 每个子任务 = 一条普通会话:登记记录 + 自己的日志,父指针挂在本会话下。
207
+ OpenSubSession: func(t team.Task, runDir, engine, model string) team.SubSession {
208
+ return openSubSession(h, opts.saveRegistry, opts.saveHistory, stderr, t, runDir, engine, model)
209
+ },
210
+ OnEvent: emit,
211
+ })
212
+ if err != nil {
213
+ finishSession(h, "", session.StateFailed)
214
+ if format == agent.FormatJSON {
215
+ _ = sink.event(streamErrorPayload(engine.Name(), err))
216
+ _ = agent.WriteError(stderr, format, engine.Name(), 1, err)
217
+ } else {
218
+ fmt.Fprintf(stderr, "\nmagic-agent: %v\n", err)
219
+ }
220
+ return &reportedError{err}
221
+ }
222
+ finishSession(h, res.SessionID, session.StateDone)
223
+ if h != nil {
224
+ // 收尾前落路由结果(按类别归并):列表页据此显示「谁在干活」。
225
+ // 放在 Finish 之后 —— 两者各写一次盘,顺序反过来也没有语义差别,
226
+ // 但先写终态能让列表页更早看到「已结束」。
227
+ h.SetAgents(agentRefs(res.Tasks))
228
+ }
229
+
230
+ if format == agent.FormatJSON {
231
+ out := struct {
232
+ Type string `json:"type"`
233
+ Engine string `json:"engine"`
234
+ Model string `json:"model"`
235
+ SessionID string `json:"session_id,omitempty"`
236
+ Mode string `json:"mode"`
237
+ PlanPath string `json:"plan_path,omitempty"`
238
+ RunDir string `json:"run_dir,omitempty"`
239
+ LatencyMS int64 `json:"latency_ms"`
240
+ Text string `json:"text"`
241
+ Tasks []taskView `json:"tasks,omitempty"`
242
+ }{
243
+ Type: "result", Engine: engine.Name(), Model: req.Model,
244
+ SessionID: res.SessionID, Mode: "team",
245
+ PlanPath: res.PlanPath, RunDir: res.RunDir,
246
+ LatencyMS: res.Latency.Milliseconds(), Text: res.Text,
247
+ Tasks: taskViews(res.Tasks),
248
+ }
249
+ _ = sink.event(out)
250
+ } else {
251
+ fmt.Fprintln(stdout)
252
+ }
253
+ return nil
254
+ }
255
+
256
+ // agentRefs 把子任务结果按「类别 + 引擎 + 模型」归并成登记表里的 Agents。
257
+ //
258
+ // 为什么按类别归并而不是一个任务一条:登记表是列表页的数据源,一个 6 任务的团队会话
259
+ // 会把列表接口撑成 6 条一样的长行,用户看不出差别(同一类多半派给同一个引擎)。
260
+ // 同类但引擎不同(比如 image 一半给了 A 一半给了 B)时各留一条,不合并 —— 那正是
261
+ // 「谁在干活」的真实情况。
262
+ func agentRefs(rs []team.TaskResult) []session.AgentRef {
263
+ type key struct{ kind, engine, model string }
264
+ order := make([]key, 0, len(rs))
265
+ counts := make(map[key]int, len(rs))
266
+ // 每个类别下**子引擎自己的**会话 id(绑定在主会话下,见 team.TaskResult.SessionID)。
267
+ sessions := make(map[key][]string, len(rs))
268
+ for _, r := range rs {
269
+ if r.Engine == "" {
270
+ continue // 没引擎 = 没跑起来(skipped),不算「谁在干活」
271
+ }
272
+ k := key{kind: r.Kind, engine: r.Engine, model: r.Model}
273
+ if _, ok := counts[k]; !ok {
274
+ order = append(order, k)
275
+ }
276
+ counts[k]++
277
+ if r.SessionID != "" {
278
+ sessions[k] = append(sessions[k], r.SessionID)
279
+ }
280
+ }
281
+ if len(order) == 0 {
282
+ return nil
283
+ }
284
+ out := make([]session.AgentRef, 0, len(order))
285
+ for _, k := range order {
286
+ out = append(out, session.AgentRef{
287
+ Kind: k.kind, Engine: k.engine, Model: k.model,
288
+ Count: counts[k], Sessions: sessions[k],
289
+ })
290
+ }
291
+ return out
292
+ }
293
+
294
+ // taskView 收尾 envelope 里的子任务摘要(客户端 / 脚本据此画任务清单)。
295
+ type taskView struct {
296
+ ID string `json:"id"`
297
+ Kind string `json:"kind"`
298
+ // Role 执行角色(规范名)。任务只写角色,Kind 是它的派生值。
299
+ Role string `json:"role,omitempty"`
300
+ Title string `json:"title"`
301
+ Engine string `json:"engine,omitempty"`
302
+ Model string `json:"model,omitempty"`
303
+ // SessionID 子引擎自己的会话 id(绑在主会话下;事件流里不带它,见 internal/team)。
304
+ SessionID string `json:"session_id,omitempty"`
305
+ State string `json:"state"`
306
+ Reason string `json:"reason,omitempty"`
307
+ Output string `json:"output,omitempty"`
308
+ Arts []string `json:"arts,omitempty"`
309
+ Missing []string `json:"missing,omitempty"`
310
+ }
311
+
312
+ func taskViews(rs []team.TaskResult) []taskView {
313
+ out := make([]taskView, 0, len(rs))
314
+ for _, r := range rs {
315
+ out = append(out, taskView{
316
+ ID: r.ID, Kind: r.Kind, Role: r.Role, Title: r.Title,
317
+ Engine: r.Engine, Model: r.Model, SessionID: r.SessionID,
318
+ State: r.State, Reason: r.Reason,
319
+ Output: r.OutputPath, Arts: r.Arts, Missing: r.Missing,
320
+ })
321
+ }
322
+ return out
323
+ }
324
+
325
+ // tasksLine 把任务列表快照压成一行(text 模式用;JSON 模式直接透传快照本身)。
326
+ //
327
+ // 快照是**全量**的(每次状态变化整份重发),所以这里只报「到哪了」:
328
+ // 完成数 + 正在跑 / 失败的任务 id,够人看进度,又不会把 stderr 刷成表格。
329
+ func tasksLine(text string) string {
330
+ var snap struct {
331
+ Tasks []struct {
332
+ ID string `json:"id"`
333
+ Status string `json:"status"`
334
+ } `json:"tasks"`
335
+ }
336
+ if json.Unmarshal([]byte(text), &snap) != nil || len(snap.Tasks) == 0 {
337
+ return ""
338
+ }
339
+ done := 0
340
+ var running, failed, skipped []string
341
+ for _, t := range snap.Tasks {
342
+ switch t.Status {
343
+ case "done":
344
+ done++
345
+ case "running":
346
+ running = append(running, t.ID)
347
+ case "failed":
348
+ failed = append(failed, t.ID)
349
+ case "skipped":
350
+ skipped = append(skipped, t.ID)
351
+ }
352
+ }
353
+ line := fmt.Sprintf("任务 %d/%d 完成", done, len(snap.Tasks))
354
+ if len(running) > 0 {
355
+ line += " · 运行中:" + strings.Join(running, "、")
356
+ }
357
+ if len(failed) > 0 {
358
+ line += " · 失败:" + strings.Join(failed, "、")
359
+ }
360
+ if len(skipped) > 0 {
361
+ line += " · 跳过:" + strings.Join(skipped, "、")
362
+ }
363
+ return line
364
+ }
365
+
366
+ // lastUserText 最后一条 user 消息(团队模式以它为原始诉求)。
367
+ func lastUserText(msgs []agent.Message) string {
368
+ for i := len(msgs) - 1; i >= 0; i-- {
369
+ if msgs[i].Role == "user" || msgs[i].Role == "" {
370
+ return msgs[i].Content
371
+ }
372
+ }
373
+ return ""
374
+ }
@@ -0,0 +1,156 @@
1
+ package cli
2
+
3
+ // team_session.go - 团队子任务的「会话化」实现。
4
+ //
5
+ // 用户 2026-10-22 定稿:拆出来的子任务跟普通会话一样 —— 有自己的登记记录、自己的
6
+ // 日志(点进去能看到完整过程,也能单独 --stop),靠父指针挂在主会话下显示;
7
+ // 主会话里只留一张任务列表卡。
8
+ //
9
+ // team 包不依赖 internal/session 与日志实现,只通过 team.SubSession 把这三件事
10
+ // 委托出来(见 internal/team/subsession.go);具体落到 session.Handle + sessionWriter
11
+ // 的实现就在本文件。
12
+
13
+ import (
14
+ "context"
15
+ "fmt"
16
+ "io"
17
+ "strings"
18
+
19
+ "github.com/darren/magic-agent/internal/agent"
20
+ "github.com/darren/magic-agent/internal/session"
21
+ "github.com/darren/magic-agent/internal/team"
22
+ )
23
+
24
+ // subSession 一条子任务对应的普通会话。
25
+ type subSession struct {
26
+ h *session.Handle
27
+ // log 过程日志(--save-history 才开;空 = 不落盘,过程仍跑)。
28
+ log *sessionWriter
29
+ }
30
+
31
+ // openSubSession 为一个子任务开一条会话。
32
+ //
33
+ // 返回 nil 表示这条子任务不单独登记(登记表开不出来)—— team 侧会退回
34
+ // 「只在任务列表里留一行」,不阻断执行。日志写盘失败同样只降级:过程照跑,
35
+ // 只是这条子会话点进去没有历史。
36
+ func openSubSession(parent *session.Handle, saveRegistry, saveHistory bool, warn io.Writer,
37
+ t team.Task, runDir, engine, model string) team.SubSession {
38
+
39
+ var parentRunID, parentSessionID, src, client string
40
+ if parent != nil {
41
+ rec := parent.Record()
42
+ parentRunID, parentSessionID = parent.RunID(), rec.SessionID
43
+ src, client = rec.Source, rec.Client
44
+ }
45
+ h, err := session.Begin(session.BeginOptions{
46
+ Engine: engine,
47
+ Model: model,
48
+ Source: src,
49
+ Client: client,
50
+ Workspace: team.TaskDir(runDir, t.ID),
51
+ PromptHead: t.Title,
52
+ NoPersist: !saveRegistry,
53
+ // 父指针让会话列表能把它挂在主会话下面(按父 run_id / session_id 归并)。
54
+ ParentRunID: parentRunID,
55
+ ParentSessionID: parentSessionID,
56
+ })
57
+ if err != nil {
58
+ fmt.Fprintf(warn, "magic-agent: 子任务 %s 的会话登记失败:%v(继续执行,只是不进会话列表)\n", t.ID, err)
59
+ return nil
60
+ }
61
+ sub := &subSession{h: h}
62
+ if saveHistory {
63
+ sw, serr := newSessionWriterAuto(sessionLogDir)
64
+ if serr != nil {
65
+ fmt.Fprintf(warn, "magic-agent: 子任务 %s 的会话存档目录准备失败:%v(过程不落盘)\n", t.ID, serr)
66
+ } else {
67
+ sw.setRunID(h.RunID())
68
+ // 历史里的第一条 user 消息 = 任务书(用户没说过话,但点进去要看得懂这是哪件事)。
69
+ sw.push(sessionEvent{Kind: "user", Text: subPrompt(t)})
70
+ sub.log = sw
71
+ }
72
+ }
73
+ return sub
74
+ }
75
+
76
+ // subPrompt 子会话历史开头的 user 消息:任务标题 + 完整任务书。
77
+ func subPrompt(t team.Task) string {
78
+ title := strings.TrimSpace(t.Title)
79
+ prompt := strings.TrimSpace(t.Prompt)
80
+ switch {
81
+ case title == "":
82
+ return prompt
83
+ case prompt == "":
84
+ return title
85
+ default:
86
+ return title + "\n\n" + prompt
87
+ }
88
+ }
89
+
90
+ func (s *subSession) RunID() string { return s.h.RunID() }
91
+
92
+ func (s *subSession) SetSessionID(id string) {
93
+ if strings.TrimSpace(id) == "" {
94
+ return
95
+ }
96
+ s.h.SetSessionID(id)
97
+ if s.log != nil {
98
+ s.log.setSessionID(id)
99
+ }
100
+ }
101
+
102
+ // Push 子任务的一条过程事件:只写它自己的日志,不进主会话。
103
+ func (s *subSession) Push(ev agent.StreamEvent) {
104
+ if ev.SessionID != "" {
105
+ s.SetSessionID(ev.SessionID)
106
+ }
107
+ if s.log == nil {
108
+ return
109
+ }
110
+ // 字段与主会话的落盘口径一致(见 team.go 的 emit):历史回放与实时渲染同构。
111
+ s.log.push(sessionEvent{
112
+ Kind: string(ev.Kind), Text: ev.Text, Name: ev.Name, ID: ev.ID,
113
+ ToolKind: toolKindOfEvent(ev), Ask: ev.Ask,
114
+ ItemID: ev.ItemID, ItemRevision: ev.ItemRevision,
115
+ Status: string(ev.Status), Parent: ev.Parent,
116
+ SubSessionID: ev.SubSessionID,
117
+ Error: ev.Error, Reason: ev.Reason,
118
+ })
119
+ }
120
+
121
+ // Finish 收尾:写最终状态 + 回填会话 id + 关掉日志。
122
+ func (s *subSession) Finish(sessionID, state, _ string) {
123
+ if strings.TrimSpace(sessionID) == "" {
124
+ sessionID = s.h.Record().SessionID
125
+ }
126
+ s.h.Finish(sessionID, subSessionState(state))
127
+ if s.log != nil {
128
+ if sessionID != "" {
129
+ s.log.setSessionID(sessionID)
130
+ }
131
+ s.log.close()
132
+ s.log = nil
133
+ }
134
+ }
135
+
136
+ // Context 把「子引擎 pid 登记」挂到子任务 ctx 上(--stop 靠它按进程组杀)。
137
+ func (s *subSession) Context(parent context.Context) context.Context {
138
+ if s.h == nil {
139
+ return parent
140
+ }
141
+ return agent.WithSpawnHook(parent, func(pid int) { s.h.SetPID(pid, true) })
142
+ }
143
+
144
+ // subSessionState 把子任务的结局翻成会话状态词表。
145
+ //
146
+ // skipped 记成 cancelled:会话侧的词表里没有「跳过」,它的含义就是「没跑,被取消了」。
147
+ func subSessionState(state string) string {
148
+ switch state {
149
+ case team.TaskDone, "":
150
+ return session.StateDone
151
+ case team.TaskSkipped:
152
+ return session.StateCancelled
153
+ default:
154
+ return session.StateFailed
155
+ }
156
+ }