magic-agent 1.0.49 → 1.0.108

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 +2168 -143
  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,289 @@
1
+ package requirements
2
+
3
+ import (
4
+ "bufio"
5
+ "crypto/sha1"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "os"
9
+ "path/filepath"
10
+ "sort"
11
+ "strings"
12
+ "time"
13
+ )
14
+
15
+ // EnvDir 覆盖需求存储根目录的环境变量(测试 / 多套数据用)。
16
+ const EnvDir = "MAGIC_AGENT_REQUIREMENTS"
17
+
18
+ // Root 需求数据的根目录。
19
+ //
20
+ // 与业务项目(掌天瓶的 ~/.magic-test/requirements)**互不相干**:两者是两套数据,
21
+ // 不做自动迁移 —— 静默搬用户的文档比「新功能从头开始攒」危险得多。
22
+ func Root() string {
23
+ if p := strings.TrimSpace(os.Getenv(EnvDir)); p != "" {
24
+ return p
25
+ }
26
+ home, err := os.UserHomeDir()
27
+ if err != nil || home == "" {
28
+ return ""
29
+ }
30
+ return filepath.Join(home, ".magic-agent", "requirements")
31
+ }
32
+
33
+ // DirID 目录 → 工作区 id('ws-' + sha1(绝对路径) 前 10 位)。
34
+ // 同一目录重复选择天然幂等(与业务项目同一规则,便于两边对照排查)。
35
+ func DirID(dir string) string {
36
+ d := normalizeDir(dir)
37
+ if d == "" {
38
+ return ""
39
+ }
40
+ sum := sha1.Sum([]byte(d))
41
+ return "ws-" + hex.EncodeToString(sum[:])[:10]
42
+ }
43
+
44
+ // normalizeDir 归一化目录:展开 ~、转绝对、清掉尾部分隔符。
45
+ func normalizeDir(dir string) string {
46
+ d := strings.TrimSpace(dir)
47
+ if d == "" {
48
+ return ""
49
+ }
50
+ if d == "~" || strings.HasPrefix(d, "~/") {
51
+ if home, err := os.UserHomeDir(); err == nil {
52
+ d = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(d, "~"), "/"))
53
+ }
54
+ }
55
+ if abs, err := filepath.Abs(d); err == nil {
56
+ d = abs
57
+ }
58
+ d = filepath.Clean(d)
59
+ if d == string(filepath.Separator) {
60
+ return d
61
+ }
62
+ return strings.TrimRight(d, string(filepath.Separator))
63
+ }
64
+
65
+ // wsDir 一个工作区的存储目录。
66
+ func wsDir(id string) string { return filepath.Join(Root(), id) }
67
+
68
+ // featuresDir 功能点文档目录(<ws>/features,其下按模块再分一级)。
69
+ func featuresDir(id string) string { return filepath.Join(wsDir(id), "features") }
70
+
71
+ // stateFile / memoryFile 归纳游标与记忆流。
72
+ func stateFile(id string) string { return filepath.Join(wsDir(id), "state.json") }
73
+ func memoryFile(id string) string { return filepath.Join(wsDir(id), "memory.jsonl") }
74
+
75
+ // registryFile 工作区注册表(目录 ↔ id ↔ 名字)。
76
+ func registryFile() string { return filepath.Join(Root(), "workspaces.json") }
77
+
78
+ // safeID 工作区 id 会拼进路径 → 只放行本包生成过的形状(挡目录穿越)。
79
+ func safeID(id string) bool {
80
+ if !strings.HasPrefix(id, "ws-") || len(id) > 40 {
81
+ return false
82
+ }
83
+ for _, r := range id {
84
+ if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || r == '-' {
85
+ continue
86
+ }
87
+ return false
88
+ }
89
+ return true
90
+ }
91
+
92
+ // Entry 一条记忆(= 某会话里的一句用户原话)。
93
+ //
94
+ // N 是**全局单调编号**:模型引用来源时用它(prompt 里的 `#12`),代码据此回查原文
95
+ // 写「来源对话」段 —— 不让模型摘抄原话(实测过它只会摘当轮那一条)。
96
+ type Entry struct {
97
+ N int `json:"n"`
98
+ Ts int64 `json:"ts"` // unix 毫秒
99
+ Session string `json:"session,omitempty"`
100
+ Seq uint64 `json:"seq,omitempty"`
101
+ Title string `json:"title,omitempty"`
102
+ Text string `json:"text"`
103
+ }
104
+
105
+ // state 一个工作区的归纳游标。
106
+ type state struct {
107
+ // Consumed 已归纳到记忆流第几条(按条数前进:记忆流只追加,编号不变)。
108
+ Consumed int `json:"consumed"`
109
+ // Sessions 每条会话的摄入游标(防重复入记忆流),见 memory.go 的 memCursor。
110
+ Sessions map[string]memCursor `json:"sessions,omitempty"`
111
+ // LastConsolidatedAt 最后一次归纳完成时刻(unix 毫秒)。
112
+ LastConsolidatedAt int64 `json:"last_consolidated_at,omitempty"`
113
+ // BootstrapAt 首次「按空间目录反推需求基线」完成的时刻(unix 毫秒,0 = 从未初始化)。
114
+ // 它与 LastConsolidatedAt 是**两个独立事实**:自动归纳可以先产出文档,但只有
115
+ // 目录基线跑过才算初始化完成(见 bootstrap.go 的说明)。
116
+ BootstrapAt int64 `json:"bootstrap_at,omitempty"`
117
+ // LastError 上一次失败原因(成功后清空)。
118
+ LastError string `json:"last_error,omitempty"`
119
+ }
120
+
121
+ func readState(id string) state {
122
+ st := state{}
123
+ if b, err := os.ReadFile(stateFile(id)); err == nil {
124
+ _ = json.Unmarshal(b, &st)
125
+ }
126
+ if st.Sessions == nil {
127
+ st.Sessions = map[string]memCursor{}
128
+ }
129
+ return st
130
+ }
131
+
132
+ func writeState(id string, st state) error {
133
+ if st.Sessions == nil {
134
+ st.Sessions = map[string]memCursor{}
135
+ }
136
+ return writeJSONAtomic(stateFile(id), st)
137
+ }
138
+
139
+ // readMemory 读整条记忆流(按行,坏行跳过 —— 记忆是追加写,断电最多坏最后一行)。
140
+ func readMemory(id string) []Entry {
141
+ f, err := os.Open(memoryFile(id))
142
+ if err != nil {
143
+ return nil
144
+ }
145
+ defer f.Close()
146
+ out := []Entry{}
147
+ sc := bufio.NewScanner(f)
148
+ sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
149
+ for sc.Scan() {
150
+ line := strings.TrimSpace(sc.Text())
151
+ if line == "" {
152
+ continue
153
+ }
154
+ var e Entry
155
+ if json.Unmarshal([]byte(line), &e) != nil || strings.TrimSpace(e.Text) == "" {
156
+ continue
157
+ }
158
+ if e.N == 0 {
159
+ e.N = len(out) + 1
160
+ }
161
+ out = append(out, e)
162
+ }
163
+ return out
164
+ }
165
+
166
+ // appendMemory 追加若干条记忆(一次打开、一次写;编号由调用方按现有条数续上)。
167
+ func appendMemory(id string, entries []Entry) error {
168
+ if len(entries) == 0 {
169
+ return nil
170
+ }
171
+ if err := os.MkdirAll(wsDir(id), 0o700); err != nil {
172
+ return err
173
+ }
174
+ f, err := os.OpenFile(memoryFile(id), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
175
+ if err != nil {
176
+ return err
177
+ }
178
+ defer f.Close()
179
+ w := bufio.NewWriter(f)
180
+ for _, e := range entries {
181
+ b, err := json.Marshal(e)
182
+ if err != nil {
183
+ continue
184
+ }
185
+ if _, err := w.Write(append(b, '\n')); err != nil {
186
+ return err
187
+ }
188
+ }
189
+ return w.Flush()
190
+ }
191
+
192
+ // workspaceEntry 注册表里的一行。
193
+ type workspaceEntry struct {
194
+ ID string `json:"id"`
195
+ Dir string `json:"dir"`
196
+ Name string `json:"name"`
197
+ // CreatedAt / TouchedAt unix 毫秒。
198
+ CreatedAt int64 `json:"created_at"`
199
+ TouchedAt int64 `json:"touched_at"`
200
+ }
201
+
202
+ func readRegistry() []workspaceEntry {
203
+ var out []workspaceEntry
204
+ if b, err := os.ReadFile(registryFile()); err == nil {
205
+ _ = json.Unmarshal(b, &out)
206
+ }
207
+ return out
208
+ }
209
+
210
+ func writeRegistry(list []workspaceEntry) error {
211
+ sort.Slice(list, func(i, j int) bool { return list[i].TouchedAt > list[j].TouchedAt })
212
+ return writeJSONAtomic(registryFile(), list)
213
+ }
214
+
215
+ // registerWorkspace 登记(或刷新)一个目录作为工作区。
216
+ //
217
+ // 为什么要注册表:一个目录可能**只有归档过的文档、没有会话**(会话被删了 / 换机器),
218
+ // 列表仍应显示它;反过来「有会话但从没归纳过」的目录也要能在列表里看见 —— 后者靠
219
+ // 会话侧的目录反推,见 Workspaces()。
220
+ func registerWorkspace(dir string) workspaceEntry {
221
+ d := normalizeDir(dir)
222
+ if d == "" {
223
+ return workspaceEntry{}
224
+ }
225
+ id := DirID(d)
226
+ now := time.Now().UnixMilli()
227
+ list := readRegistry()
228
+ for i := range list {
229
+ if list[i].ID == id {
230
+ list[i].Dir = d
231
+ list[i].Name = filepath.Base(d)
232
+ list[i].TouchedAt = now
233
+ _ = writeRegistry(list)
234
+ return list[i]
235
+ }
236
+ }
237
+ e := workspaceEntry{ID: id, Dir: d, Name: filepath.Base(d), CreatedAt: now, TouchedAt: now}
238
+ list = append(list, e)
239
+ _ = writeRegistry(list)
240
+ return e
241
+ }
242
+
243
+ // writeJSONAtomic 原子写 JSON(tmp + rename):并发读的时候不会读到半个文件。
244
+ func writeJSONAtomic(file string, v any) error {
245
+ if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil {
246
+ return err
247
+ }
248
+ b, err := json.MarshalIndent(v, "", " ")
249
+ if err != nil {
250
+ return err
251
+ }
252
+ tmp := file + ".tmp"
253
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
254
+ return err
255
+ }
256
+ return os.Rename(tmp, file)
257
+ }
258
+
259
+ // resolveWorkspace 把「ws id 或目录路径」解析成一个工作区;解析不出来返回 ok=false。
260
+ func resolveWorkspace(idOrDir string) (workspaceEntry, bool) {
261
+ s := strings.TrimSpace(idOrDir)
262
+ if s == "" {
263
+ return workspaceEntry{}, false
264
+ }
265
+ if safeID(s) {
266
+ for _, e := range readRegistry() {
267
+ if e.ID == s {
268
+ return e, true
269
+ }
270
+ }
271
+ // 注册表里没有但目录存在过(文档还在):从 state 文件名反推不出目录,
272
+ // 只能确认它「有存储」——目录留空,清单仍可读(客户端的「目录」按钮会缺失)。
273
+ if _, err := os.Stat(wsDir(s)); err == nil {
274
+ return workspaceEntry{ID: s, Name: s}, true
275
+ }
276
+ return workspaceEntry{}, false
277
+ }
278
+ d := normalizeDir(s)
279
+ id := DirID(d)
280
+ for _, e := range readRegistry() {
281
+ if e.ID == id {
282
+ return e, true
283
+ }
284
+ }
285
+ if st, err := os.Stat(d); err == nil && st.IsDir() {
286
+ return workspaceEntry{ID: id, Dir: d, Name: filepath.Base(d)}, true
287
+ }
288
+ return workspaceEntry{}, false
289
+ }
@@ -31,10 +31,11 @@ import (
31
31
 
32
32
  // DeleteResult 删除结果。计数只用于向调用方交代「删掉了什么」,不作判据。
33
33
  type DeleteResult struct {
34
- ID string `json:"id"`
35
- RecordsRemoved int `json:"records_removed"`
36
- LogsRemoved int `json:"logs_removed"`
37
- TitlesRemoved int `json:"titles_removed"`
34
+ ID string `json:"id"`
35
+ RecordsRemoved int `json:"records_removed"`
36
+ LogsRemoved int `json:"logs_removed"`
37
+ TitlesRemoved int `json:"titles_removed"`
38
+ StatusesRemoved int `json:"statuses_removed"`
38
39
  }
39
40
 
40
41
  // Delete 删掉一条会话:登记记录 + 事件日志 + 标题覆盖。
@@ -91,6 +92,9 @@ func Delete(id string) (DeleteResult, error) {
91
92
  }
92
93
  }
93
94
  out.TitlesRemoved = dropTitles(names)
95
+ // 状态表是会话级的覆盖表(见 status.go):会话没了它还留着,
96
+ // 同 id 再建会话时会继承一条过期状态(「新建就显示已完成」)。
97
+ out.StatusesRemoved = dropStatuses(names)
94
98
  return out, nil
95
99
  }
96
100
 
@@ -119,9 +119,15 @@ func normalizeClient(s string) string {
119
119
  // · interrupted 用户**中途打断当前轮**(`--control interrupt`),会话本身仍可继续
120
120
  // · waiting_approval 引擎停下来等用户(提问 / 工具待授权)—— 会话还活着,但没在跑,
121
121
  // 界面上该显示「等审批」而不是「生成中」
122
+ // · idle **本轮**已结束,但常驻进程还活着等 `--append` —— 会话还活着,没在跑。
123
+ // 没有它的时候,keep-alive 会话在轮次之间只能一直写 running:PC 侧按事件流已经显示
124
+ // 「已完成」,别的端读登记表却以为还在跑(跟进连接空等到 30 分钟、移动端串行门永远
125
+ // 排不上队)。用户 2026-10-21 报障「pc是会话已结束 但是移动端状态未结束 不统一导致
126
+ // 还在排队」修的就是这件事。
122
127
  const (
123
128
  StateRunning = "running" // 正在跑
124
129
  StateWaitingApproval = "waiting_approval" // 卡在等授权 / 等用户回答
130
+ StateIdle = "idle" // 本轮已结束、进程仍在等追加(非终态)
125
131
  StateDone = "done" // 正常结束
126
132
  StateFailed = "failed" // 报错结束
127
133
  StateCancelled = "cancelled" // 被 --stop 取消
@@ -129,6 +135,15 @@ const (
129
135
  StateGone = "gone" // 记录里是 running,但进程已不在(自然退出/被别处杀掉)
130
136
  )
131
137
 
138
+ // isLiveState 这个状态是否代表「会话/进程还活着」(running / waiting_approval / idle)。
139
+ //
140
+ // 全包只有这一处判定:Mark 的可切范围、supersedeSiblings 该不该接管旧进程、Stop 该不该
141
+ // 真去终止、normalizeDead 该不该归一成 gone,四件事问的是同一个问题 ——
142
+ // 各写一份的话,新增一个活着的状态就会漏掉其中一处(idle 就是靠这个收进来的)。
143
+ func isLiveState(s string) bool {
144
+ return s == StateRunning || s == StateWaitingApproval || s == StateIdle
145
+ }
146
+
132
147
  // normalizeState 把磁盘上的**旧状态值**归一成当前词表。
133
148
  //
134
149
  // 为什么必须有:`stopped` 在 2026-09-29 之前由 `--stop` 写入,一次性全部改写磁盘上的
@@ -166,12 +181,43 @@ type Record struct {
166
181
  //
167
182
  // 与 Source 的分工:Source 是设备形态(手机/电脑),Client 是发起方应用。
168
183
  // 两者都缺一不可 —— 同一台机器上既有客户端发起的、也有直接跑 CLI 的会话。
169
- Client string `json:"client,omitempty"`
170
- Workspace string `json:"workspace,omitempty"`
171
- PromptHead string `json:"prompt_head,omitempty"`
172
- State string `json:"state"`
173
- StartedAt time.Time `json:"started_at"`
174
- UpdatedAt time.Time `json:"updated_at"`
184
+ Client string `json:"client,omitempty"`
185
+ Workspace string `json:"workspace,omitempty"`
186
+ // Cwd 这条会话**实际跑在哪个目录**(core 在 Begin 时记下自己的 cwd)。
187
+ //
188
+ // 与 Workspace 的分工:Workspace 是调用方**显式指定**的(`-w/--workspace`,用户/客户端
189
+ // 选的那个空间);Cwd 是没指定时它自然落到的目录(调用方的 cwd = 默认工作空间)。
190
+ // 为什么要两个都留(2026-10-22):需求聚合按目录归属,而**绝大多数调用不带 -w** ——
191
+ // 只看 Workspace 的话,不指定空间的会话就归不到任何目录,用户会看到「聊了半天
192
+ // 一条需求都没有」。空 = 旧记录(那时不记 cwd),归属回落到 Workspace。
193
+ Cwd string `json:"cwd,omitempty"`
194
+ PromptHead string `json:"prompt_head,omitempty"`
195
+ State string `json:"state"`
196
+ // Mode 这条会话的运行模式:空 = 单引擎(默认);"team" = 团队模式。
197
+ //
198
+ // 为什么记在这里而不是另开一张表:列表页要能标出「这条是团队会话」,
199
+ // 用户点进去看到的也是同一段对话。
200
+ //
201
+ // 团队拆出的子任务**是独立会话**(用户 2026-10-22:「拆分的子任务跟普通的会话
202
+ // 一样」):它们各自有自己的登记记录与日志,靠 ParentRunID / ParentSessionID
203
+ // 挂在主会话下显示,点进去就是那条子任务的完整过程。上下文仍不共享(见 internal/team)。
204
+ Mode string `json:"mode,omitempty"`
205
+ // ParentRunID 父会话(团队主会话)的 run_id;空 = 顶层会话。
206
+ //
207
+ // 为什么两个父指针都记:主会话在收尾前可能还没有 session_id(引擎没给),
208
+ // 这时只能按 run_id 认亲;一旦主会话拿到 session_id,列表就能按 session_id
209
+ // 归并 —— 两个都留着,读侧哪个非空用哪个(优先 ParentSessionID)。
210
+ ParentRunID string `json:"parent_run_id,omitempty"`
211
+ // ParentSessionID 父会话的 session_id;空 = 父会话当时还没有 session_id。
212
+ ParentSessionID string `json:"parent_session_id,omitempty"`
213
+ // Agents 团队模式下各类子任务实际派给了谁(按类别归并,同类只留一条)。
214
+ // 空 = 不是团队会话,或团队只出了计划没执行(--team-plan-only)。
215
+ //
216
+ // 只记**路由结果**,不记子任务状态(那是运行目录里 OUT-*.md 的事):
217
+ // 登记表是列表页的数据源,塞进每个子任务的状态会让列表接口变成大对象。
218
+ Agents []AgentRef `json:"agents,omitempty"`
219
+ StartedAt time.Time `json:"started_at"`
220
+ UpdatedAt time.Time `json:"updated_at"`
175
221
 
176
222
  // Append 追加入口的 unix socket 路径(常驻会话 / --keep-alive 才有)。
177
223
  // 空 = 该会话不可追加(--append 会明确告知原因,不静默)。
@@ -190,6 +236,59 @@ func Dir() string {
190
236
  return filepath.Join(home, ".magic-agent", "sessions")
191
237
  }
192
238
 
239
+ // AgentRef 团队会话里「某一类子任务交给了谁」。
240
+ type AgentRef struct {
241
+ // Kind 子任务类别:text | code | image | video | game(见 internal/team.Kinds)。
242
+ Kind string `json:"kind"`
243
+ // Engine / Model 实际使用的引擎与模型(空模型 = 引擎默认)。
244
+ Engine string `json:"engine"`
245
+ Model string `json:"model,omitempty"`
246
+ // Count 这一类别派了几个子任务(合并同类,避免列表里一长串重复行)。
247
+ Count int `json:"count,omitempty"`
248
+ // Sessions 这一类别下各子任务**自己的**引擎会话 id(可能为空:引擎没给)。
249
+ //
250
+ // 子任务是独立会话(各自有登记记录),这里只是**路由结果的索引**:
251
+ // 列表页按登记的 ParentRunID / ParentSessionID 把它们挂在主会话下,
252
+ // 这一串用来快速跳转到某类子任务的会话。留着的用途是排障 / 回查。
253
+ Sessions []string `json:"sessions,omitempty"`
254
+ }
255
+
256
+ // ModeTeam 团队模式(Record.Mode 的取值之一)。
257
+ const ModeTeam = "team"
258
+
259
+ // Mode 返回这条会话的运行模式(空 = 单引擎)。
260
+ func (h *Handle) Mode() string {
261
+ if h == nil {
262
+ return ""
263
+ }
264
+ return h.rec.Mode
265
+ }
266
+
267
+ // SetMode 标记运行模式(目前只有 ModeTeam);值没变时直接返回。
268
+ //
269
+ // 为什么由 CLI 在**开跑前**写:团队会话的计划阶段最早也要几十秒,这期间列表里
270
+ // 已经能看到这条会话了 —— 等收尾才写 Mode,用户点开的就是一条「普通会话」。
271
+ func (h *Handle) SetMode(mode string) {
272
+ if h == nil || mode == "" || h.rec.Mode == mode {
273
+ return
274
+ }
275
+ h.rec.Mode = mode
276
+ h.write()
277
+ }
278
+
279
+ // SetAgents 记录团队会话的路由结果(按类别归并,调用方负责去重)。
280
+ func (h *Handle) SetAgents(refs []AgentRef) {
281
+ if h == nil {
282
+ return
283
+ }
284
+ if len(refs) == 0 {
285
+ h.rec.Agents = nil
286
+ } else {
287
+ h.rec.Agents = refs
288
+ }
289
+ h.write()
290
+ }
291
+
193
292
  // BeginOptions 开始一条会话记录所需的输入。
194
293
  type BeginOptions struct {
195
294
  Engine string
@@ -210,6 +309,10 @@ type BeginOptions struct {
210
309
  // ⚠️ 取反命名(NoPersist 而非 Persist):默认零值仍是「落盘」,
211
310
  // 既有调用方与测试不必逐个改语义 —— 只有 CLI 显式关它。
212
311
  NoPersist bool
312
+ // ParentRunID / ParentSessionID 父会话指针(团队拆出的子任务才填)。
313
+ // 空 = 顶层会话。见 Record 同名字段的说明。
314
+ ParentRunID string
315
+ ParentSessionID string
213
316
  }
214
317
 
215
318
  // Handle 一条运行中会话的句柄(负责回写 pid 与收尾状态)。
@@ -248,25 +351,25 @@ func (h *Handle) SetSessionID(id string) {
248
351
  h.write()
249
352
  }
250
353
 
251
- // Mark 就地改状态(只在 running 与 waiting_approval 之间来回时生效)。
354
+ // Mark 就地改状态(只在 isLiveState 的那几个值之间来回时生效)。
252
355
  //
253
356
  // 为什么需要一个**中途**写状态的入口:引擎会停下来等用户(提问 / 工具待授权),此时这条
254
357
  // 会话还活着,但**没在跑** —— 界面上该显示「等审批」而不是「生成中」。这是 2026-09-29
255
358
  // 统一会话状态词表时新增的写入点(值取自 anywhere 的 waiting_approval)。
256
359
  //
257
- // ⚠️ 只允许在这两个值之间切:一旦写进终态(done / failed / cancelled / interrupted),
360
+ // 2026-10-21 起多一个取值 idle:keep-alive 会话**本轮结束**(turn_end)时写它,下一轮
361
+ // 第一个事件到达再回到 running。它让登记表在轮次之间说的是实话 —— 别的端据此收尾跟进、
362
+ // 放行排队,与 PC 的「已完成」同口径。
258
363
  //
259
- // 这里不许改回去。终态是「这条会话结束了」的结论,让一个迟到的流事件把它翻回
260
- // running,等于让历史撒谎。
364
+ // ⚠️ 只允许在活着的那几个值之间切:一旦写进终态(done / failed / cancelled / interrupted),
365
+ // 这里不许改回去。终态是「这条会话结束了」的结论,让一个迟到的流事件把它翻回
366
+ // running,等于让历史撒谎。
261
367
  func (h *Handle) Mark(state string) {
262
368
  if h == nil || state == "" {
263
369
  return
264
370
  }
265
371
  cur := normalizeState(h.rec.State)
266
- if cur != StateRunning && cur != StateWaitingApproval {
267
- return
268
- }
269
- if state != StateRunning && state != StateWaitingApproval {
372
+ if !isLiveState(cur) || !isLiveState(state) {
270
373
  return
271
374
  }
272
375
  if cur == state {
@@ -333,6 +436,8 @@ func (h *Handle) write() {
333
436
  return
334
437
  }
335
438
  _ = os.Rename(tmp, h.path)
439
+ // 会话级状态与登记表同一步更新(见 status.go):客户端只读那一份,不再各自按 run 推算。
440
+ setStatus(h.rec)
336
441
  }
337
442
 
338
443
  // Begin 新建一条 running 记录(NoPersist 时只在内存里建,不落盘)。
@@ -362,18 +467,72 @@ func Begin(o BeginOptions) (*Handle, error) {
362
467
  Source: normalizeSource(o.Source),
363
468
  Client: normalizeClient(o.Client),
364
469
  Workspace: o.Workspace,
470
+ Cwd: cwdOf(),
365
471
  PromptHead: head(o.PromptHead, 80),
366
472
  State: StateRunning,
367
473
  StartedAt: now,
368
474
  UpdatedAt: now,
475
+ // 父会话指针(团队子任务):落盘后列表页据此把子会话挂在主会话下。
476
+ ParentRunID: strings.TrimSpace(o.ParentRunID),
477
+ ParentSessionID: strings.TrimSpace(o.ParentSessionID),
369
478
  }
370
479
  // path 照样算出来:追加入口的 socket 与它同目录同前缀(见 AppendSocketPath),
371
480
  // 句柄里留着它,SetAppendPath 之类也不用知道自己是不是在存档模式。
372
481
  h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist}
373
482
  h.write() // noPersist 时这里什么都不做
483
+ // 「新起即停旧」:同一条会话上还活着的上一次运行先停掉(见 supersedeSiblings)。
484
+ if !o.NoPersist && rec.SessionID != "" {
485
+ supersedeSiblings(rec.SessionID, rec.RunID, rec.PID)
486
+ }
374
487
  return h, nil
375
488
  }
376
489
 
490
+ // supersedeSiblings 停掉同一会话上**还活着**的上一次运行。
491
+ //
492
+ // 为什么(2026-10-21,用户:「两边的回话状态不一致,一边结束了,一边还在显示运行中」):
493
+ // 同一个 session_id 被两个进程同时 `--resume` 时,两边的 sessionWriter 各算各的 seq、
494
+ // 都往 <session_id>.jsonl 追加 —— 文件里出现成对重号的 seq(实测一条 9506 条事件的
495
+ // 文件里 4589 个重号)。读侧按 `--after seq` 增量续读时,另一条 lane 的事件全都
496
+ // `seq <= after` 被滤掉:一端读到 turn_end 显示「已完成」,另一端永远等不到终态;
497
+ // 登记表里那条旧 run 又一直 running,`Find` 于是永远答「在跑」。
498
+ // 而同一会话本来也不该被两个引擎进程同时续接(引擎侧的会话状态也会互相覆盖)。
499
+ //
500
+ // 口径与 Stop 一致:先 SIGTERM,宽限期不退再 SIGKILL,确认消失后写 cancelled。
501
+ // 只在落盘模式下做:NoPersist 的调用彼此看不见,也无从判定谁是「旧」的。
502
+ func supersedeSiblings(sessionID, runID string, selfPID int) {
503
+ sessionID = strings.TrimSpace(sessionID)
504
+ if sessionID == "" {
505
+ return
506
+ }
507
+ all, err := List()
508
+ if err != nil {
509
+ return
510
+ }
511
+ for _, r := range all {
512
+ if r.RunID == runID || r.SessionID != sessionID {
513
+ continue
514
+ }
515
+ if !isLiveState(r.State) {
516
+ continue
517
+ }
518
+ if r.PID <= 0 || r.PID == selfPID || !agent.PIDAlive(r.PID) {
519
+ continue
520
+ }
521
+ _ = agent.TerminatePID(r.PID, r.KillGroup)
522
+ if !waitGone(r.PID, 2*time.Second) {
523
+ _ = agent.KillPID(r.PID, r.KillGroup)
524
+ if !waitGone(r.PID, 2*time.Second) {
525
+ fmt.Fprintf(os.Stderr, "magic-agent: 同会话的上一次运行(pid %d / %s)未能停止,两端状态可能仍不一致\n", r.PID, r.RunID)
526
+ continue
527
+ }
528
+ }
529
+ r.State = StateCancelled
530
+ r.UpdatedAt = time.Now()
531
+ writeRecord(r)
532
+ fmt.Fprintf(os.Stderr, "magic-agent: 已停止同会话的上一次运行(pid %d / %s),避免两个进程同时续写同一份会话日志\n", r.PID, r.RunID)
533
+ }
534
+ }
535
+
377
536
  // head 截取开头 n 个字符(按 rune,避免把中文切坏)。
378
537
  func head(s string, n int) string {
379
538
  s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
@@ -384,6 +543,16 @@ func head(s string, n int) string {
384
543
  return string(r[:n]) + "…"
385
544
  }
386
545
 
546
+ // cwdOf 取进程当前目录;拿不到就留空(需求归纳按 workspace 归属时会退回用
547
+ // Workspace 字段,两者都没有就只是不参与按目录的自动分组,不影响会话本身)。
548
+ func cwdOf() string {
549
+ dir, err := os.Getwd()
550
+ if err != nil {
551
+ return ""
552
+ }
553
+ return dir
554
+ }
555
+
387
556
  // List 返回全部记录(新的在前);顺带清理读不出来的坏文件。
388
557
  func List() ([]Record, error) {
389
558
  dir := Dir()
@@ -424,7 +593,7 @@ func List() ([]Record, error) {
424
593
  // 与 Stop() 的「进程已不在 → gone」判定同口径;PID 缺失(<=0)的老记录无从判断,
425
594
  // 保持原样。返回 true 表示状态被改过,调用方需要回写。
426
595
  func normalizeDead(rec *Record) bool {
427
- if rec.State != StateRunning && rec.State != StateWaitingApproval {
596
+ if !isLiveState(rec.State) {
428
597
  return false
429
598
  }
430
599
  if rec.PID <= 0 || agent.PIDAlive(rec.PID) {
@@ -436,7 +605,7 @@ func normalizeDead(rec *Record) bool {
436
605
  }
437
606
 
438
607
  // Find 按 run_id 或 session_id 精确匹配一条记录。
439
- // 同 id 有多条时优先「running」的那条,其次取最新。
608
+ // 同 id 有多条时优先「还活着」的那条(其中 running 优先于 idle/等审批),其次取最新。
440
609
  func Find(id string) (Record, bool, error) {
441
610
  want := strings.TrimSpace(id)
442
611
  if want == "" {
@@ -455,6 +624,8 @@ func Find(id string) (Record, bool, error) {
455
624
  switch {
456
625
  case !found:
457
626
  best, found = r, true
627
+ case isLiveState(r.State) && !isLiveState(best.State):
628
+ best = r
458
629
  case r.State == StateRunning && best.State != StateRunning:
459
630
  best = r
460
631
  case r.State == best.State && r.StartedAt.After(best.StartedAt):
@@ -484,7 +655,9 @@ func Stop(id string) (StopResult, error) {
484
655
  return StopResult{}, fmt.Errorf("no such session %q(--sessions 可列出全部)", id)
485
656
  }
486
657
 
487
- if rec.State != StateRunning {
658
+ /* idle / waiting_approval 的进程同样活着(只是当前没在跑),必须照常终止 ——
659
+ 否则 --stop 对一条常驻会话只会回一句「已结束」,进程却继续占着会话日志。 */
660
+ if !isLiveState(rec.State) {
488
661
  /* gone 是 List() 在读侧归一的「进程已不在」,和下面那条死进程分支是同一件事,
489
662
  原因文案保持一致(Stop 的调用方据此区分「本来就结束了」与「进程没了」)。 */
490
663
  if rec.State == StateGone {
@@ -548,7 +721,11 @@ func readRecord(path string) (Record, error) {
548
721
  }
549
722
 
550
723
  // writeRecord 覆盖写一条记录(按 RunID 定位文件名)。
724
+ //
725
+ // ⚠️ 这里也是**会话级状态**的唯一写入口(setStatus):Stop / supersedeSiblings /
726
+ // normalizeDead 都不经过 Handle,只走这里 —— 状态表挂在更低一层才覆盖得到它们。
551
727
  func writeRecord(rec Record) {
728
+ setStatus(rec)
552
729
  dir := Dir()
553
730
  if dir == "" || rec.RunID == "" {
554
731
  return
@@ -240,7 +240,10 @@ func AppendMessage(id, text string) (AppendResult, error) {
240
240
  return AppendResult{Record: rec, Reason: reason},
241
241
  fmt.Errorf("%s;常驻会话要用 --stream --keep-alive 启动,已结束的会话请用 --session <id> 续接", reason)
242
242
  }
243
- if rec.State != StateRunning {
243
+ /* 常驻会话在轮次之间是 idle(本轮已结束、进程还在等追加)—— 这正是 --append
244
+ 存在的窗口,不能按「已结束」拒收。其它非 running 状态(等审批)维持原样:
245
+ 那时引擎正停在提问上,追加的内容该走 control 的 answer,而不是当成新一轮。 */
246
+ if rec.State != StateRunning && rec.State != StateIdle {
244
247
  return AppendResult{Record: rec, Reason: "会话已结束(state=" + rec.State + ")"},
245
248
  fmt.Errorf("会话已结束(state=%s);请用 --session <id> 续接", rec.State)
246
249
  }
@@ -80,6 +80,25 @@ func TestAppendMessageDelivers(t *testing.T) {
80
80
  }
81
81
  }
82
82
 
83
+ // 常驻会话在轮次之间是 idle(本轮已结束、进程还在等追加)—— --append 正是这个窗口,
84
+ // 不能按「会话说已结束」拒收(2026-10-21 新增 idle 时一并钉住)。
85
+ func TestAppendMessageAllowsIdle(t *testing.T) {
86
+ h, _, ch := startAppender(t)
87
+ h.Mark(StateIdle)
88
+
89
+ if _, err := AppendMessage(h.RunID(), "下一轮"); err != nil {
90
+ t.Fatalf("idle 的常驻会话应能追加: %v", err)
91
+ }
92
+ select {
93
+ case got := <-ch:
94
+ if got != "下一轮" {
95
+ t.Errorf("收到的消息 = %q", got)
96
+ }
97
+ case <-time.After(5 * time.Second):
98
+ t.Fatal("没投递")
99
+ }
100
+ }
101
+
83
102
  // 用会话 id 追加(观物台手里通常只有 session_id)。
84
103
  func TestAppendMessageBySessionID(t *testing.T) {
85
104
  isolate(t)