magic-agent 2.1.5 → 2.3.0

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 (52) hide show
  1. package/CLIENT-CONTRACT.md +103 -6
  2. package/client-ui/pc/app.css +3 -0
  3. package/client-ui/pc/index.html +5 -0
  4. package/client-ui/pc/js/05-task-list-render.js +28 -4
  5. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  6. package/client-ui/pc/js/93-rail.js +21 -1
  7. package/client-ui/shared/conv/03-chat-message.js +43 -0
  8. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  9. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  10. package/client-ui/shared/conv/19-attachments.js +75 -11
  11. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  12. package/client-ui/shared/conv/README.md +6 -0
  13. package/client-ui/shared/conv/conv.css +16 -1
  14. package/client-ui/shared/conversation.css +42 -4
  15. package/client-ui/shared/conversation.js +655 -87
  16. package/client-ui/shared/transport-http.js +16 -0
  17. package/client-ui-mobile/index.html +401 -553
  18. package/client-ui-mobile/reqs.css +39 -0
  19. package/client-ui-mobile/reqs.js +507 -0
  20. package/cmd/magic-client/main.go +1 -1
  21. package/internal/cli/ask.go +6 -0
  22. package/internal/cli/root.go +5 -0
  23. package/internal/cli/session.go +16 -14
  24. package/internal/cli/suggest.go +98 -0
  25. package/internal/cli/suggest_test.go +54 -0
  26. package/internal/cli/team.go +111 -24
  27. package/internal/client/service.go +11 -1
  28. package/internal/client/suggest.go +131 -0
  29. package/internal/client/suggest_test.go +116 -0
  30. package/internal/session/delete.go +58 -11
  31. package/internal/session/delete_test.go +140 -0
  32. package/internal/session/session.go +25 -0
  33. package/internal/session/session_mark_test.go +62 -0
  34. package/internal/suggest/suggest.go +421 -0
  35. package/internal/suggest/suggest_test.go +218 -0
  36. package/internal/team/board.go +107 -15
  37. package/internal/team/execute.go +1 -1
  38. package/internal/team/team.go +28 -19
  39. package/internal/team/team_test.go +68 -15
  40. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  41. package/npm/apk/version.json +3 -3
  42. package/npm/dist/darwin-arm64/magic-agent +0 -0
  43. package/npm/dist/darwin-arm64/magic-client +0 -0
  44. package/npm/dist/darwin-x64/magic-agent +0 -0
  45. package/npm/dist/darwin-x64/magic-client +0 -0
  46. package/npm/dist/linux-arm64/magic-agent +0 -0
  47. package/npm/dist/linux-arm64/magic-client +0 -0
  48. package/npm/dist/linux-x64/magic-agent +0 -0
  49. package/npm/dist/linux-x64/magic-client +0 -0
  50. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  51. package/npm/dist/win32-x64/magic-client.exe +0 -0
  52. package/package.json +5 -2
@@ -10,6 +10,7 @@ package team
10
10
  import (
11
11
  "encoding/json"
12
12
  "fmt"
13
+ "strings"
13
14
  "sync"
14
15
  "time"
15
16
 
@@ -38,11 +39,33 @@ type TaskRow struct {
38
39
  Deps []string `json:"deps,omitempty"`
39
40
  // Note 失败 / 跳过的一句话原因(成功时为空)。
40
41
  Note string `json:"note,omitempty"`
42
+ // Active 正在跑的那一行**此刻在干什么**(「等待授权」「已 40 秒无新输出」)。
43
+ //
44
+ // 2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」——
45
+ // 以前心跳是一条条 notice(主会话里长出十几行「仍在运行…」),现在只挂在这一行上:
46
+ // 快照每次整份重发,进度就只剩一处可看,也不占画布。只对 running 行写。
47
+ //
48
+ // ⚠️ 它是**状态说明**,不是标题:客户端画在「标题 + 状态」的次要位置
49
+ // (2026-10-22 用户:「应该显示子任务标题和状态耗时」)。标题永远取 Title。
50
+ Active string `json:"active,omitempty"`
51
+ // RuntimeMs 这一行**已经跑了多久 / 最终跑了多久**(毫秒,0 = 还没开跑)。
52
+ //
53
+ // pending 行是 0;running 行随快照整份重发不断增大;落定后冻结成总耗时
54
+ // (失败的也冻结 —— 「跑了 3 分钟然后失败」比光一个红叉有用)。由 core 给绝对值,
55
+ // 客户端不做本地计时:多端重连 / 历史回放看到的就是当时的数,不会各自跳字。
56
+ RuntimeMs int64 `json:"runtime_ms,omitempty"`
41
57
  // SessionID / RunID 这一行的子会话(点进去看过程)。RunID 在任务一开跑就有,
42
58
  // SessionID 要等引擎给出;两个都可能为空(调用方没开登记 / 引擎不给 id)。
43
59
  SessionID string `json:"session_id,omitempty"`
44
60
  RunID string `json:"run_id,omitempty"`
45
61
  Output string `json:"output,omitempty"`
62
+ // Arts 这一行**实际交付的产物**(绝对路径)。
63
+ //
64
+ // 2026-10-22 用户:「主会话产物要显示子任务的产物」—— 子任务的产物以前只出现在
65
+ // result.tasks[].arts(要等整轮结束才下发一份终态信封),主会话的画布上到那时
66
+ // 才看得到;而任务列表卡是随状态一路整份重发的,把产物挂在这一行上,某个子任务
67
+ // 一落定,主会话里立刻就能看到它交了什么东西(图 → 缩略图,文档 → 卡片)。
68
+ Arts []string `json:"arts,omitempty"`
46
69
  }
47
70
 
48
71
  // Board 任务列表的唯一写者(并发安全)。
@@ -54,13 +77,33 @@ type Board struct {
54
77
  rows map[string]*TaskRow
55
78
  // last 每一行最后一次「有动静」的时间(子事件 / 状态变化),心跳据此播报。
56
79
  last map[string]time.Time
80
+ // started 每一行开跑的时刻(RuntimeMs 的起点)。
81
+ started map[string]time.Time
57
82
  }
58
83
 
59
84
  func newBoard(emit func(agent.StreamEvent)) *Board {
60
85
  return &Board{
61
- emit: emit,
62
- rows: make(map[string]*TaskRow),
63
- last: make(map[string]time.Time),
86
+ emit: emit,
87
+ rows: make(map[string]*TaskRow),
88
+ last: make(map[string]time.Time),
89
+ started: make(map[string]time.Time),
90
+ }
91
+ }
92
+
93
+ // stampLocked 按开跑时刻刷新一行的运行时长(调用方持锁)。
94
+ //
95
+ // 每次出快照前统一刷一遍:状态没变的行也不会停在上一份快照的秒数上
96
+ // (心跳 quiet 与状态变化都会整份重发,用户看到的就是「一路在涨」)。
97
+ func (b *Board) stampLocked() {
98
+ now := time.Now()
99
+ for _, id := range b.order {
100
+ row := b.rows[id]
101
+ if row == nil || row.Status != RowRunning {
102
+ continue
103
+ }
104
+ if st, ok := b.started[id]; ok && !st.IsZero() {
105
+ row.RuntimeMs = now.Sub(st).Milliseconds()
106
+ }
64
107
  }
65
108
  }
66
109
 
@@ -81,6 +124,7 @@ func (b *Board) snapshotLocked() string {
81
124
  if len(b.order) == 0 {
82
125
  return ""
83
126
  }
127
+ b.stampLocked()
84
128
  rows := make([]TaskRow, 0, len(b.order))
85
129
  for _, id := range b.order {
86
130
  rows = append(rows, *b.rows[id])
@@ -127,7 +171,10 @@ func (b *Board) start(t Task, engine, model string) {
127
171
  row.Status = RowRunning
128
172
  row.Engine, row.Model = engine, model
129
173
  row.Note = ""
174
+ row.Active = ""
175
+ row.RuntimeMs = 0
130
176
  b.last[t.ID] = time.Now()
177
+ b.started[t.ID] = b.last[t.ID]
131
178
  })
132
179
  }
133
180
 
@@ -171,6 +218,15 @@ func (b *Board) finish(t Task, r TaskResult) {
171
218
  row.SessionID = r.SessionID
172
219
  }
173
220
  row.Output = r.OutputPath
221
+ /* 产物跟着结局一起落在这一行上:主会话画布据此立即(不必等整轮 result)
222
+ 显示子任务交了什么东西。空结果也显式清空,免得重试时留着上一次的旧产物。 */
223
+ row.Arts = r.Arts
224
+ row.Active = "" // 落定了就没有「此刻在干什么」了
225
+ /* 运行时长冻结在落定这一刻:失败 / 跳过的行同样有数 ——
226
+ 「跑了 3 分钟才失败」和「一开跑就失败」是两件事。 */
227
+ if st, ok := b.started[t.ID]; ok && !st.IsZero() {
228
+ row.RuntimeMs = time.Since(st).Milliseconds()
229
+ }
174
230
  if r.State != TaskDone {
175
231
  row.Note = oneLine(r.Reason)
176
232
  }
@@ -179,21 +235,47 @@ func (b *Board) finish(t Task, r TaskResult) {
179
235
  }
180
236
 
181
237
  // touch 子任务有事件了(刷新心跳时间;事件本身写子会话的日志,不进主会话)。
182
- func (b *Board) touch(id string) {
238
+ func (b *Board) touch(id string, ev agent.StreamEvent) {
183
239
  b.mu.Lock()
184
- defer b.mu.Unlock()
185
- if _, ok := b.rows[id]; ok {
186
- b.last[id] = time.Now()
240
+ row := b.rows[id]
241
+ if row == nil {
242
+ b.mu.Unlock()
243
+ return
244
+ }
245
+ b.last[id] = time.Now()
246
+ /* 又有输出了 → 那一行的「已 N 秒无新输出」不再成立。只改内存不发快照:
247
+ 下一条状态变化会带上;否则每条子事件都要整份广播一次,白白刷屏。 */
248
+ row.Active = ""
249
+ /* 但**卡在授权上**是另一种情况:子引擎停在那里等答复,事件停了也不会再动 —— 只写
250
+ 「已 900 秒无新输出」等于什么都没说。把「需要授权:Read(等待用户选择)」直接挂到
251
+ 这一行(它属于任务列表,不占画布)。团队模式没有作答通道(子任务不共享上下文),
252
+ 用户能做的只有去那条子会话里看 / 停掉它,所以这里只报事实。 */
253
+ var snapshot string
254
+ if ev.Kind == agent.KindAsk {
255
+ label := strings.TrimSpace(ev.Text)
256
+ if label == "" {
257
+ label = "等待用户处理"
258
+ }
259
+ row.Active = label
260
+ snapshot = b.snapshotLocked()
261
+ }
262
+ b.mu.Unlock()
263
+ if snapshot != "" {
264
+ b.emit(agent.StreamEvent{Kind: agent.KindTasks, Text: snapshot})
187
265
  }
188
266
  }
189
267
 
190
- // quietLines 返回「已经 taskQuiet 没动静」的运行中任务的播报文案,并把它们的活跃时间
191
- // 推到此刻 —— 同一条任务因此每 taskHeartbeat 才播一次,秒数持续增长。
192
- func (b *Board) quietLines() []string {
268
+ // quiet 把「已经 taskQuiet 没动静」的运行中任务的**行内进度**写上去,并把它们的
269
+ // 活跃时间推到此刻 —— 同一条任务因此每 taskHeartbeat 才回写一次,秒数持续增长。
270
+ //
271
+ // 2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」:以前这里
272
+ // 返回一串文案,由 executeWithHeartbeat 逐条发 notice —— 主会话的画布上于是长出
273
+ // 十几行「仍在运行…(已 40 秒无新输出)」。现在只更新任务列表卡对应那一行,进度
274
+ // 只有一处可看;「有动静了」也要把 Active 清掉,否则那一行会停在最后一次心跳的秒数上。
275
+ func (b *Board) quiet() {
193
276
  b.mu.Lock()
194
- defer b.mu.Unlock()
277
+ changed := false
195
278
  now := time.Now()
196
- var out []string
197
279
  for _, id := range b.order {
198
280
  row := b.rows[id]
199
281
  if row == nil || row.Status != RowRunning {
@@ -204,10 +286,20 @@ func (b *Board) quietLines() []string {
204
286
  continue
205
287
  }
206
288
  b.last[id] = now
207
- out = append(out, fmt.Sprintf("团队模式:%s「%s」仍在运行…(已 %d 秒无新输出)",
208
- id, row.Title, int(now.Sub(last).Seconds())))
289
+ if s := fmt.Sprintf("已 %d 秒无新输出", int(now.Sub(last).Seconds())); s != row.Active {
290
+ row.Active = s
291
+ changed = true
292
+ }
293
+ }
294
+ if !changed {
295
+ b.mu.Unlock()
296
+ return
297
+ }
298
+ text := b.snapshotLocked()
299
+ b.mu.Unlock()
300
+ if text != "" {
301
+ b.emit(agent.StreamEvent{Kind: agent.KindTasks, Text: text})
209
302
  }
210
- return out
211
303
  }
212
304
 
213
305
  // notice 往主会话发一条过程提示(不属于任何子任务)。
@@ -268,7 +268,7 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
268
268
  if sub != nil {
269
269
  sub.Push(ev)
270
270
  }
271
- board.touch(t.ID)
271
+ board.touch(t.ID, ev)
272
272
  })
273
273
 
274
274
  if err != nil {
@@ -146,6 +146,12 @@ type Options struct {
146
146
 
147
147
  // OnEvent 事件出口(由 CLI 层接到 eventSink)。并发安全由调用方保证。
148
148
  OnEvent func(agent.StreamEvent)
149
+ // OnTick 计划阶段的心跳(每隔 planHeartbeat 一次),**不发事件**。
150
+ //
151
+ // 为什么要它:计划阶段是非流式调用,几十秒里一条事件都没有;这一段既不能往
152
+ // 主会话里刷「已 N 秒」(用户明确不要多行进度),又不能让别端把这一轮判成死。
153
+ // CLI 层把它接到会话登记表的续期上(见 cli/team.go)。空 = 什么都不做。
154
+ OnTick func()
149
155
 
150
156
  // OpenSubSession 为一个子任务开一条**普通会话**(登记 + 独立日志)。
151
157
  //
@@ -244,7 +250,7 @@ func Run(ctx context.Context, opts Options) (Result, error) {
244
250
  /* 计划阶段:主引擎**关工具**跑一次。
245
251
  关工具不是为了省事 —— 计划必须是可解析的结构化输出,开着工具时模型会把
246
252
  中间过程(读文件 / 跑命令)混进正文,解析成功率会掉下来。 */
247
- plan, planSession, planErr := planWithHeartbeat(ctx, opts, emit)
253
+ plan, planSession, planErr := planWithHeartbeat(ctx, opts, opts.OnTick)
248
254
  degraded := ""
249
255
  if planErr != nil {
250
256
  degraded = "计划生成失败,已降级为单任务:" + planErr.Error()
@@ -322,24 +328,30 @@ func Run(ctx context.Context, opts Options) (Result, error) {
322
328
  return res, nil
323
329
  }
324
330
 
325
- // planHeartbeat 计划阶段的进度播报间隔。
326
- const planHeartbeat = 5 * time.Second
327
-
328
- // planWithHeartbeat 跑计划阶段,等待期间定期播报「已等 N 秒」。
331
+ // planHeartbeat 计划阶段的心跳间隔。
329
332
  //
330
333
  // 计划是一次**非流式**调用(ToolsOff,要模型整段吐 JSON),实测 40 秒起步;
331
- // 这期间一条事件都没有,客户端看起来就是「发出去了没反应」。心跳不伪造进度,
332
- // 只证明 core 还活着 —— 这是「无响应」里最长、也最容易被当成挂死的一段。
334
+ // 这期间一条事件都没有,客户端看起来就是「发出去了没反应」。
335
+ // 心跳**只证明 core 还活着**,不再往主会话里发「已 N 秒」的 notice
336
+ // (2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」):
337
+ // 计划阶段任务列表还没建出来,所以这一段的「还活着」由 core 侧续期登记表
338
+ // (cli/team.go 的 emit → Touch)证明,客户端跟随时不会被判成终态而提前收尾。
339
+ const planHeartbeat = 5 * time.Second
340
+
341
+ // planWithHeartbeat 跑计划阶段,期间按 planHeartbeat 打点续期。
333
342
  //
334
- // 退出顺序:close(stop) 之后 wg.Wait(),保证心跳 goroutine 彻底停了才返回,
335
- // 否则它的 notice 可能插到「计划已生成」后面。
336
- func planWithHeartbeat(ctx context.Context, opts Options, emit func(agent.StreamEvent)) (Plan, string, error) {
343
+ // 续期而不是发事件:画面上一行字都不多,但别端的尾随连接能从 updated_at 看出
344
+ // 这一轮没死。退出顺序 close(stop) → wg.Wait():心跳彻底停了才返回,避免它
345
+ // 把 lastTouch 推到「计划已生成」之后。
346
+ func planWithHeartbeat(ctx context.Context, opts Options, touch func()) (Plan, string, error) {
337
347
  stop := make(chan struct{})
338
348
  var wg sync.WaitGroup
349
+ if touch == nil {
350
+ touch = func() {}
351
+ }
339
352
  wg.Add(1)
340
353
  go func() {
341
354
  defer wg.Done()
342
- start := time.Now()
343
355
  tick := time.NewTicker(planHeartbeat)
344
356
  defer tick.Stop()
345
357
  for {
@@ -349,10 +361,7 @@ func planWithHeartbeat(ctx context.Context, opts Options, emit func(agent.Stream
349
361
  case <-ctx.Done():
350
362
  return
351
363
  case <-tick.C:
352
- emit(agent.StreamEvent{
353
- Kind: agent.KindNotice,
354
- Text: fmt.Sprintf("团队模式:正在生成计划…(已 %d 秒)", int(time.Since(start).Seconds())),
355
- })
364
+ touch()
356
365
  }
357
366
  }
358
367
  }()
@@ -366,7 +375,9 @@ func planWithHeartbeat(ctx context.Context, opts Options, emit func(agent.Stream
366
375
  //
367
376
  // 子引擎可能几十秒到十几分钟不吐一条事件:模型侧 request 超时后 claude CLI 会自己
368
377
  // 重试,重试期间不产生任何 stream-json。此时任务列表里只有一行「运行中」,跟卡死
369
- // 无法区分。心跳不伪造进度,只证明「这个子任务还活着」。
378
+ // 无法区分。心跳不伪造进度,只证明「这个子任务还活着」——
379
+ // 落点只在**任务列表那一行**(TaskRow.Active),不往主会话里发 notice(2026-10-22
380
+ // 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」)。
370
381
  const (
371
382
  taskHeartbeat = 20 * time.Second
372
383
  taskQuiet = 30 * time.Second
@@ -388,9 +399,7 @@ func executeWithHeartbeat(ctx context.Context, opts Options, plan Plan, runDir s
388
399
  case <-ctx.Done():
389
400
  return
390
401
  case <-tick.C:
391
- for _, text := range board.quietLines() {
392
- board.notice(text)
393
- }
402
+ board.quiet()
394
403
  }
395
404
  }
396
405
  }()
@@ -1323,31 +1323,50 @@ func TestWriteOutputRecordsMissing(t *testing.T) {
1323
1323
  }
1324
1324
  }
1325
1325
 
1326
- // 执行阶段心跳:只播报「还在跑且很久没动静」的任务,收尾的不再播。
1327
- func TestBoardQuietLinesOnlyAnnouncesSilentRunningTasks(t *testing.T) {
1328
- b := newBoard(func(agent.StreamEvent) {})
1326
+ // 执行阶段心跳:只给「还在跑且很久没动静」的那一行挂行内进度,收尾的不再挂。
1327
+ //
1328
+ // 2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」——
1329
+ // 心跳落点从一串 notice 改成任务列表行的 Active,所以断言也看行。
1330
+ func TestBoardQuietOnlyMarksSilentRunningTasks(t *testing.T) {
1331
+ var events []agent.StreamEvent
1332
+ b := newBoard(func(ev agent.StreamEvent) { events = append(events, ev) })
1329
1333
  t1 := Task{ID: "t1", Kind: KindText, Title: "写介绍"}
1330
1334
  t2 := Task{ID: "t2", Kind: KindImage, Title: "画封面"}
1331
1335
  t3 := Task{ID: "t3", Kind: KindText, Title: "汇总图"}
1332
1336
  for _, task := range []Task{t1, t2, t3} {
1333
1337
  b.add(task, Route{})
1334
1338
  }
1339
+ b.announce()
1340
+ events = nil
1335
1341
  b.start(t1, "e", "")
1336
1342
  b.start(t2, "e", "")
1337
1343
  b.finish(t3, TaskResult{ID: "t3", State: TaskDone})
1344
+ events = nil
1338
1345
 
1339
1346
  // t1 刚刚有输出;t3 已收尾;t2 静默超过阈值。
1340
- b.touch("t1")
1347
+ b.touch("t1", agent.StreamEvent{Kind: agent.KindText})
1341
1348
  b.mu.Lock()
1342
1349
  b.last["t2"] = time.Now().Add(-time.Minute)
1343
1350
  b.mu.Unlock()
1344
1351
 
1345
- got := b.quietLines()
1346
- if len(got) != 1 || !strings.Contains(got[0], "t2") || !strings.Contains(got[0], "画封面") {
1347
- t.Fatalf("只该播报静默中的 t2,得到 %v", got)
1352
+ b.quiet()
1353
+ if len(events) != 1 || events[0].Kind != agent.KindTasks {
1354
+ t.Fatalf("心跳应当整份重发一次任务列表快照,得到 %d 条 %v", len(events), events)
1355
+ }
1356
+ if !strings.Contains(events[0].Text, "无新输出") {
1357
+ t.Fatalf("快照里应当带上这一行的行内进度:%s", events[0].Text)
1358
+ }
1359
+ b.mu.Lock()
1360
+ active1, active2, active3 := b.rows["t1"].Active, b.rows["t2"].Active, b.rows["t3"].Active
1361
+ b.mu.Unlock()
1362
+ if active2 == "" || active1 != "" || active3 != "" {
1363
+ t.Fatalf("只该给静默中的 t2 挂进度,得到 t1=%q t2=%q t3=%q", active1, active2, active3)
1348
1364
  }
1349
- if again := b.quietLines(); len(again) != 0 {
1350
- t.Fatalf("刚播过的任务不应立刻重复:%v", again)
1365
+
1366
+ events = nil
1367
+ b.quiet()
1368
+ if len(events) != 0 {
1369
+ t.Fatalf("刚刷过的行不应立刻重复发快照:%v", events)
1351
1370
  }
1352
1371
  }
1353
1372
 
@@ -1363,24 +1382,58 @@ func TestBoardTouchKeepsRunningRowAlive(t *testing.T) {
1363
1382
  b.last["t2"] = time.Now().Add(-time.Minute)
1364
1383
  b.mu.Unlock()
1365
1384
 
1366
- b.touch("t2")
1367
- if got := b.quietLines(); len(got) != 0 {
1368
- t.Fatalf("刚有动静的任务不该被心跳播报:%v", got)
1369
- }
1385
+ b.touch("t2", agent.StreamEvent{Kind: agent.KindText})
1386
+ b.quiet()
1370
1387
  b.mu.Lock()
1371
1388
  row := b.rows["t2"]
1372
1389
  b.mu.Unlock()
1373
1390
  if row == nil || row.Status != RowRunning {
1374
1391
  t.Fatalf("内部事件不该改变这一行的状态,得到 %+v", row)
1375
1392
  }
1393
+ if row.Active != "" {
1394
+ t.Fatalf("刚有动静的任务不该挂着「无新输出」:%q", row.Active)
1395
+ }
1376
1396
 
1377
1397
  // 真正的收尾由 finish 落定(之后不再进心跳候选)。
1378
1398
  b.finish(t2, TaskResult{ID: "t2", State: TaskDone})
1379
1399
  b.mu.Lock()
1380
1400
  b.last["t2"] = time.Now().Add(-time.Minute)
1381
1401
  b.mu.Unlock()
1382
- if got := b.quietLines(); len(got) != 0 {
1383
- t.Fatalf("已收尾的任务不该被心跳播报:%v", got)
1402
+ b.quiet()
1403
+ b.mu.Lock()
1404
+ row = b.rows["t2"]
1405
+ b.mu.Unlock()
1406
+ if row.Active != "" {
1407
+ t.Fatalf("已收尾的任务不该被心跳挂进度:%q", row.Active)
1408
+ }
1409
+ }
1410
+
1411
+ // 卡在授权上的子任务:行内进度必须说「等待授权」,而不是「已 N 秒无新输出」。
1412
+ // 回归:2026-10-22 实测 --team-permission accept-edits 下子引擎读任务书也要授权,
1413
+ // 两个子任务一起停在那里十几分钟,主会话里没有任何一处看得出来它在等人。
1414
+ func TestBoardTouchShowsPendingAsk(t *testing.T) {
1415
+ var events []agent.StreamEvent
1416
+ b := newBoard(func(ev agent.StreamEvent) { events = append(events, ev) })
1417
+ t1 := Task{ID: "t1", Kind: KindText, Title: "写介绍"}
1418
+ b.add(t1, Route{})
1419
+ b.start(t1, "e", "")
1420
+ events = nil
1421
+
1422
+ b.touch("t1", agent.StreamEvent{Kind: agent.KindAsk, Text: "需要授权:Read(等待用户选择)"})
1423
+ if len(events) != 1 || events[0].Kind != agent.KindTasks {
1424
+ t.Fatalf("卡在授权上要当场重发一次快照,得到 %d 条", len(events))
1425
+ }
1426
+ if !strings.Contains(events[0].Text, "等待用户选择") {
1427
+ t.Fatalf("快照里应当带着等待授权的说明:%s", events[0].Text)
1428
+ }
1429
+
1430
+ // 有后续输出(用户放行 / 引擎自己继续)→ 行内进度清空。
1431
+ b.touch("t1", agent.StreamEvent{Kind: agent.KindText, Text: "继续"})
1432
+ b.mu.Lock()
1433
+ active := b.rows["t1"].Active
1434
+ b.mu.Unlock()
1435
+ if active != "" {
1436
+ t.Fatalf("恢复输出后不该还挂着等待授权:%q", active)
1384
1437
  }
1385
1438
  }
1386
1439
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.1.3",
3
- "versionCode": 20103,
4
- "builtAt": "2026-10-22T11:18:59.511Z"
2
+ "version": "2.3.0",
3
+ "versionCode": 20300,
4
+ "builtAt": "2026-10-23T00:29:48.082Z"
5
5
  }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magic-agent",
3
- "version": "2.1.5",
3
+ "version": "2.3.0",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -14,9 +14,12 @@
14
14
  "prepack": "node npm/build.js",
15
15
  "postinstall": "node npm/install.js",
16
16
  "check:ui": "node tests/check-ui-spec.mjs",
17
+ "check:pin": "node tests/check-task-pin.mjs",
17
18
  "check:conv": "node tests/check-conv-shards.mjs",
18
19
  "check:conv-host": "node tests/check-conv-shared-host.mjs",
19
- "check:conv-comp": "node tests/check-conv-component.mjs"
20
+ "check:conv-mobile": "node tests/check-conv-mobile-host.mjs",
21
+ "check:conv-comp": "node tests/check-conv-component.mjs",
22
+ "check:conv-queue": "node tests/check-conv-queue.mjs"
20
23
  },
21
24
  "files": [
22
25
  "npm/bin/",