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
@@ -1,4 +1,4 @@
1
- // delete.go - 删除一条会话(登记记录 + 事件日志 + 标题覆盖)。
1
+ // delete.go - 删除一条会话(登记记录 + 事件日志 + 标题/状态覆盖,含子任务会话)。
2
2
  //
3
3
  // 为什么要它(2026-10-19 用户:「从pc端删除的会话移动端还在显示」):
4
4
  // 此前「删除」只发生在**客户端本地**(掌天瓶记一座 localStorage 墓碑、从自己的列表拿掉),
@@ -36,9 +36,17 @@ type DeleteResult struct {
36
36
  LogsRemoved int `json:"logs_removed"`
37
37
  TitlesRemoved int `json:"titles_removed"`
38
38
  StatusesRemoved int `json:"statuses_removed"`
39
+ // ChildSessionsRemoved 连同主会话一起删掉的**子任务会话**条数。
40
+ //
41
+ // 团队模式拆出的子任务各是一条普通会话(自己的 run/session、自己的日志),
42
+ // 靠父指针挂在主会话下;只删主会话会留下「父行没了、子行还挂在空处」的孤儿
43
+ // (见下面的父子闭包)。这里按**子会话 id** 去重(每轮都带 session_id 时同一条子会话只算 1),
44
+ // 不是按登记记录计数。
45
+ ChildSessionsRemoved int `json:"child_sessions_removed"`
39
46
  }
40
47
 
41
- // Delete 删掉一条会话:登记记录 + 事件日志 + 标题覆盖。
48
+ // Delete 删掉一条会话:登记记录 + 事件日志 + 标题/状态覆盖。
49
+ // 团队主会话还会沿父指针级联删掉挂在它下面的全部子任务会话。
42
50
  // id 可以是 session_id 或 run_id(与 --stop / --session-log 同一套寻址)。
43
51
  func Delete(id string) (DeleteResult, error) {
44
52
  out := DeleteResult{ID: strings.TrimSpace(id)}
@@ -57,22 +65,52 @@ func Delete(id string) (DeleteResult, error) {
57
65
  return out, err
58
66
  }
59
67
  /* ① 先收集「这条会话所有可能的名字」:传进来的 id(可能两个都不在登记表里、
60
- 只有日志文件在),加上命中记录自己的 session_id 与 run_id。 */
68
+ 只有日志文件在),加上命中记录自己的 session_id 与 run_id,**以及挂在它下面的
69
+ 子任务会话**(团队模式,2026-10-23 用户:「删除主会话时要把子任务的会话也删除」)。
70
+
71
+ 子任务各是一条普通会话(见 internal/session.Record 的 ParentRunID /
72
+ ParentSessionID),删除是物理删除:只删主会话会让子会话既没有父行可挂、
73
+ 又继续占着列表与磁盘。所以这里找一个**闭包**:
74
+ · 同一会话跨轮 —— 任一轮的 run_id / session_id 命中,就把两个名字都收进来;
75
+ · 子会话 —— 父指针命中已收名字的记录,把自己的名字也收进来;
76
+ · 反复直到不再增长(子任务再拆团队 / 多轮登记都能覆盖)。
77
+ 只沿父指针向下找,不会反向把父会话带进来:删一条子会话只删它自己。 */
61
78
  names := map[string]bool{out.ID: true}
62
- for _, r := range recs {
63
- if r.RunID != out.ID && r.SessionID != out.ID {
64
- continue
79
+ children := map[string]bool{} // 子会话计数(按规范 id 去重,同一条子会话多轮只算 1)
80
+ for {
81
+ before := len(names)
82
+ for _, r := range recs {
83
+ if !names[r.RunID] && !names[r.SessionID] {
84
+ continue
85
+ }
86
+ addDeleteName(names, r.RunID)
87
+ addDeleteName(names, r.SessionID)
65
88
  }
66
- if r.RunID != "" {
67
- names[r.RunID] = true
89
+ for _, r := range recs {
90
+ if names[r.RunID] || names[r.SessionID] {
91
+ continue
92
+ }
93
+ if !names[r.ParentRunID] && !names[r.ParentSessionID] {
94
+ continue
95
+ }
96
+ key := strings.TrimSpace(r.SessionID)
97
+ if key == "" {
98
+ key = strings.TrimSpace(r.RunID)
99
+ }
100
+ if key != "" {
101
+ children[key] = true
102
+ }
103
+ addDeleteName(names, r.RunID)
104
+ addDeleteName(names, r.SessionID)
68
105
  }
69
- if r.SessionID != "" {
70
- names[r.SessionID] = true
106
+ if len(names) == before {
107
+ break
71
108
  }
72
109
  }
110
+ out.ChildSessionsRemoved = len(children)
73
111
  /* ② 再按名字集合删**这一整条会话**的记录:一条会话每轮落一条(run_id 各不同、
74
112
  session_id 相同),只删「id 正好等于传进来那个」的那一条,其它轮还在列表里
75
- —— 表现就是「删了没删干净、刷新又冒出来」。 */
113
+ —— 表现就是「删了没删干净、刷新又冒出来」。子会话的记录同样按名字集合删除。 */
76
114
  for _, r := range recs {
77
115
  if !names[r.RunID] && !names[r.SessionID] {
78
116
  continue
@@ -124,6 +162,15 @@ func logDirsForDelete(registryDir string) []string {
124
162
  return out
125
163
  }
126
164
 
165
+ // addDeleteName 把一个 id 收进删除名字集合(空值忽略:老记录可能没有 session_id)。
166
+ func addDeleteName(names map[string]bool, name string) {
167
+ name = strings.TrimSpace(name)
168
+ if name == "" {
169
+ return
170
+ }
171
+ names[name] = true
172
+ }
173
+
127
174
  // safeDeleteID id 能不能安全地当文件名用:非空、非 `.` / `..`、不含路径分隔符与控制字符。
128
175
  func safeDeleteID(id string) bool {
129
176
  if id == "" || id == "." || id == ".." {
@@ -69,6 +69,146 @@ func TestDeleteRemovesEverything(t *testing.T) {
69
69
  }
70
70
  }
71
71
 
72
+ /*
73
+ 删除主会话要把**子任务会话**一起删(2026-10-23 用户:「列表中删除主会话时要把
74
+ 子任务的会话也删除」)。
75
+
76
+ 团队模式拆出的子任务是普通会话:各自有登记记录、日志、标题与状态,靠
77
+ ParentRunID / ParentSessionID 挂在主会话下。只删主会话会留下一批「没有父行可挂、
78
+ 却仍在列表 / 磁盘上」的孤儿 —— 和 2026-10-19「一端删了另一端还在」同一类错位。
79
+ 还要正向确认:删子会话**不该**反向带走主会话。
80
+ */
81
+ func TestDeleteCascadesToSubSessions(t *testing.T) {
82
+ dir := t.TempDir()
83
+ t.Setenv(EnvDir, dir)
84
+
85
+ parent, err := Begin(BeginOptions{Engine: "claude", PromptHead: "团队主会话"})
86
+ if err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ parent.Finish("sess-parent", StateDone)
90
+
91
+ // 子会话 1:父会话已有 session_id,两个父指针都有。
92
+ kid1, err := Begin(BeginOptions{
93
+ Engine: "claude", PromptHead: "子任务 1",
94
+ ParentRunID: parent.RunID(), ParentSessionID: "sess-parent",
95
+ })
96
+ if err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ kid1.Finish("sess-kid-1", StateDone)
100
+
101
+ // 子会话 2:创建时父会话还没拿到 session_id,只能按 parent_run_id 认亲。
102
+ kid2, err := Begin(BeginOptions{
103
+ Engine: "codex", PromptHead: "子任务 2", ParentRunID: parent.RunID(),
104
+ })
105
+ if err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ kid2.Finish("sess-kid-2", StateDone)
109
+
110
+ // 子会话 1 的第二轮(同 session_id、没有父指针):按「同会话多轮」也归它。
111
+ kid1b, err := Begin(BeginOptions{Engine: "claude", PromptHead: "子任务 1 追问", SessionID: "sess-kid-1"})
112
+ if err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ kid1b.Finish("sess-kid-1", StateDone)
116
+
117
+ // 无关会话:同引擎、同目录,但不是这条主会话的子会话 —— 必须留下。
118
+ other, err := Begin(BeginOptions{Engine: "claude", PromptHead: "无关会话"})
119
+ if err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ other.Finish("sess-other", StateDone)
123
+
124
+ // 每个名字都落一份日志(run_id 与 session_id 两种命名都覆盖)。
125
+ names := []string{
126
+ parent.RunID(), "sess-parent",
127
+ kid1.RunID(), "sess-kid-1", kid1b.RunID(),
128
+ kid2.RunID(), "sess-kid-2",
129
+ other.RunID(), "sess-other",
130
+ }
131
+ for _, name := range names {
132
+ if err := os.WriteFile(filepath.Join(dir, name+".jsonl"), []byte("{\"seq\":1}\n"), 0o600); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ }
136
+ for _, id := range []string{"sess-parent", "sess-kid-1", "sess-kid-2", "sess-other"} {
137
+ if err := SetTitle(id, "标题 "+id); err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ }
141
+
142
+ res, err := Delete("sess-parent")
143
+ if err != nil {
144
+ t.Fatalf("Delete: %v", err)
145
+ }
146
+ // parent + kid1 / kid1b / kid2 共 4 条记录;无关会话不算。
147
+ if res.RecordsRemoved != 4 {
148
+ t.Errorf("登记记录删了 %d 条,want 4(主会话 + 3 条子会话记录)", res.RecordsRemoved)
149
+ }
150
+ if res.ChildSessionsRemoved != 2 {
151
+ t.Errorf("子会话删了 %d 条,want 2(sess-kid-1 / sess-kid-2)", res.ChildSessionsRemoved)
152
+ }
153
+ if res.LogsRemoved != 7 {
154
+ t.Errorf("日志删了 %d 份,want 7(主/子会话的 run_id 与 session_id 两个名字)", res.LogsRemoved)
155
+ }
156
+ if res.TitlesRemoved != 3 {
157
+ t.Errorf("标题清了 %d 条,want 3(主会话 + 两个子会话)", res.TitlesRemoved)
158
+ }
159
+
160
+ recs, _ := List()
161
+ if len(recs) != 1 || recs[0].RunID != other.RunID() {
162
+ t.Fatalf("登记表应只剩无关会话 %s,实得 %+v", other.RunID(), recs)
163
+ }
164
+ for _, name := range []string{
165
+ parent.RunID(), "sess-parent",
166
+ kid1.RunID(), "sess-kid-1", kid1b.RunID(),
167
+ kid2.RunID(), "sess-kid-2",
168
+ } {
169
+ if fileExists(filepath.Join(dir, name+".jsonl")) {
170
+ t.Errorf("日志 %s.jsonl 还在(子会话必须跟着主会话一起删)", name)
171
+ }
172
+ }
173
+ if !fileExists(filepath.Join(dir, other.RunID()+".jsonl")) {
174
+ t.Error("无关会话的日志被误删了")
175
+ }
176
+ for _, id := range []string{"sess-parent", "sess-kid-1", "sess-kid-2"} {
177
+ if got := TitleFor(id); got != "" {
178
+ t.Errorf("标题 %s 还有 %q,want 空", id, got)
179
+ }
180
+ if got := StatusFor(id); got != "" {
181
+ t.Errorf("状态 %s 还有 %q,want 空", id, got)
182
+ }
183
+ }
184
+ if TitleFor("sess-other") == "" || StatusFor("sess-other") == "" {
185
+ t.Error("无关会话的标题 / 状态被误删了")
186
+ }
187
+ }
188
+
189
+ // 删子会话只删它自己 —— 父指针是单向认亲,不能反向把主会话带走。
190
+ func TestDeleteSubSessionKeepsParent(t *testing.T) {
191
+ t.Setenv(EnvDir, t.TempDir())
192
+ parent, err := Begin(BeginOptions{Engine: "claude"})
193
+ if err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ parent.Finish("sess-parent", StateDone)
197
+ kid, err := Begin(BeginOptions{Engine: "claude", ParentRunID: parent.RunID(), ParentSessionID: "sess-parent"})
198
+ if err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ kid.Finish("sess-kid", StateDone)
202
+
203
+ if _, err := Delete("sess-kid"); err != nil {
204
+ t.Fatalf("Delete: %v", err)
205
+ }
206
+ recs, _ := List()
207
+ if len(recs) != 1 || recs[0].SessionID != "sess-parent" {
208
+ t.Fatalf("删子会话后应只剩主会话,实得 %+v", recs)
209
+ }
210
+ }
211
+
72
212
  /*
73
213
  幂等:id 不认识 / 文件本来就不在 / 再删一次 —— 都算成功。
74
214
 
@@ -323,6 +323,8 @@ type Handle struct {
323
323
  // 照常接受 pid / 状态 / 收尾回写,只是**不写文件** —— 调用方(CLI 默认)
324
324
  // 因此不留痕,而 run_id 仍能当追加入口的地址(见 AppendSocketPath)。
325
325
  noPersist bool
326
+ // lastTouch 上次 Touch 续期的时间(节流用,见 Touch)。
327
+ lastTouch time.Time
326
328
  }
327
329
 
328
330
  // RunID 本次运行的 id。
@@ -379,6 +381,29 @@ func (h *Handle) Mark(state string) {
379
381
  h.write()
380
382
  }
381
383
 
384
+ // Touch 续期一条活着的会话:只刷新 updated_at,状态不变。
385
+ //
386
+ // 为什么 Mark 不够(2026-10-22 真机复现):Mark 在「新旧状态相同」时直接 return,
387
+ // 一次盘都不写 —— 调用方以为自己在「每来一条事件就续一次期」,实际上 updated_at
388
+ // 从第一条事件起就冻住了。别端的尾随连接(followStateEvery=5s)靠 updated_at 判活,
389
+ // 团队模式计划 + 子任务几分钟不动,就被归一成 gone,画面提前落「这一轮已结束」。
390
+ //
391
+ // 节流:调用方可能每秒来几十条事件,没必要每条都落盘;minGap 内直接跳过。
392
+ // 状态已进终态(或进程已不在)时不动 —— 终态是结论,不许被心跳续活。
393
+ func (h *Handle) Touch(minGap time.Duration) {
394
+ if h == nil || h.noPersist {
395
+ return
396
+ }
397
+ if !isLiveState(normalizeState(h.rec.State)) {
398
+ return
399
+ }
400
+ if !h.lastTouch.IsZero() && time.Since(h.lastTouch) < minGap {
401
+ return
402
+ }
403
+ h.lastTouch = time.Now()
404
+ h.write()
405
+ }
406
+
382
407
  // Finish 收尾:写最终状态(done / failed / cancelled / interrupted)+ 回填会话 id。
383
408
  //
384
409
  // 一个刻意的小保护:被别人 `--stop` 停掉、或用户中途打断时,磁盘上已经是
@@ -3,6 +3,7 @@ package session
3
3
  import (
4
4
  "os"
5
5
  "testing"
6
+ "time"
6
7
  )
7
8
 
8
9
  /* Handle.Mark:唯一的中途写状态入口(2026-09-29 统一会话状态词表时新增)。
@@ -132,3 +133,64 @@ func TestNormalizeLegacyState(t *testing.T) {
132
133
  t.Errorf("不认识的词原样返回,得到 %q", got)
133
134
  }
134
135
  }
136
+
137
+ /* Handle.Touch:只续期,不改状态(2026-10-22 报障「团队模式子任务还在跑 主会话
138
+ * 就说结束了」的真因之一)。
139
+ *
140
+ * 为什么 Mark 顶不上:Mark 在新旧状态相同时**直接 return、一次盘都不写**。流式路径
141
+ * 里每条事件都喊一次 Mark(running),看起来像在续期,实际 updated_at 从第一条事件
142
+ * 起就冻住了 —— 团队模式计划 + 子任务几分钟不动,网关尾随连接据此判终态,
143
+ * 画面提前落「这一轮已结束」。 */
144
+ func TestHandleTouchRefreshesUpdatedAt(t *testing.T) {
145
+ t.Setenv(EnvDir, t.TempDir())
146
+ h, err := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
147
+ if err != nil {
148
+ t.Fatal(err)
149
+ }
150
+ runID := h.RunID()
151
+
152
+ // Mark 同值不写盘:这是 Touch 存在的理由,钉住它免得日后被「顺手优化」掉
153
+ before, _ := readRecord(h.path)
154
+ h.Mark(StateRunning)
155
+ after, _ := readRecord(h.path)
156
+ if !after.UpdatedAt.Equal(before.UpdatedAt) {
157
+ t.Errorf("Mark 同值不该写盘:%v → %v", before.UpdatedAt, after.UpdatedAt)
158
+ }
159
+
160
+ // Touch 必须真的把 updated_at 往前推
161
+ h.lastTouch = time.Time{} // 跳过节流
162
+ h.Touch(0)
163
+ rec, found, _ := Find(runID)
164
+ if !found {
165
+ t.Fatal("记录不见了")
166
+ }
167
+ if !rec.UpdatedAt.After(before.UpdatedAt) {
168
+ t.Errorf("Touch 没续期:%v → %v", before.UpdatedAt, rec.UpdatedAt)
169
+ }
170
+ if rec.State != StateRunning {
171
+ t.Errorf("Touch 不该改状态,得到 %q", rec.State)
172
+ }
173
+ }
174
+
175
+ func TestHandleTouchThrottlesAndRespectsTerminal(t *testing.T) {
176
+ t.Setenv(EnvDir, t.TempDir())
177
+ h, _ := Begin(BeginOptions{Engine: "claude", PID: os.Getpid()})
178
+ h.lastTouch = time.Time{}
179
+ h.Touch(time.Hour) // 第一次写
180
+ first, _ := readRecord(h.path)
181
+
182
+ h.Touch(time.Hour) // 节流窗口内:不写
183
+ second, _ := readRecord(h.path)
184
+ if !second.UpdatedAt.Equal(first.UpdatedAt) {
185
+ t.Errorf("节流窗口内不该写盘:%v → %v", first.UpdatedAt, second.UpdatedAt)
186
+ }
187
+
188
+ // 终态是结论,心跳不许把它续活
189
+ h.Finish("", StateDone)
190
+ h.lastTouch = time.Time{}
191
+ h.Touch(0)
192
+ final, _ := readRecord(h.path)
193
+ if final.State != StateDone {
194
+ t.Errorf("Touch 把终态改掉了:%q", final.State)
195
+ }
196
+ }