magic-agent 1.0.46 → 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 (95) 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-agent.js +24 -1
  84. package/npm/bin/magic-client.js +20 -2
  85. package/npm/dist/darwin-arm64/magic-agent +0 -0
  86. package/npm/dist/darwin-arm64/magic-client +0 -0
  87. package/npm/dist/darwin-x64/magic-agent +0 -0
  88. package/npm/dist/darwin-x64/magic-client +0 -0
  89. package/npm/dist/linux-arm64/magic-agent +0 -0
  90. package/npm/dist/linux-arm64/magic-client +0 -0
  91. package/npm/dist/linux-x64/magic-agent +0 -0
  92. package/npm/dist/linux-x64/magic-client +0 -0
  93. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  94. package/npm/dist/win32-x64/magic-client.exe +0 -0
  95. package/package.json +1 -1
@@ -42,7 +42,7 @@ import (
42
42
  */
43
43
 
44
44
  // RequiredContractVersion 本插件能解析的 core 契约版本。
45
- const RequiredContractVersion = 1
45
+ const RequiredContractVersion = 2
46
46
 
47
47
  // requiredFeatures 插件依赖的 core 通道能力。缺任何一个都不能工作:
48
48
  // 没有 events 就没有带游标的事件流,没有 control 就无法打断与回审批。
@@ -85,18 +85,44 @@ type Service struct {
85
85
  // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
86
86
  browseOnce sync.Once
87
87
  browseHTTP *http.Client
88
+
89
+ // 需求自动归纳的调度状态(每工作区一个防抖计时器,见 requirements.go)。
90
+ reqAuto reqAuto
88
91
  }
89
92
 
90
93
  // Contract core `--contract` 里本插件关心的部分。
91
94
  type Contract struct {
92
- ContractVersion int `json:"contractVersion"`
93
- Features []string `json:"features"`
94
- Engines []struct {
95
+ ContractVersion int `json:"contractVersion"`
96
+ // Version core 自身版本(2026-10-22,core 只增字段)。
97
+ // 客户端「我的」页显示它 —— 界面是 `/m/` 从桌面端远程加载的,页面本身不带版本,
98
+ // 真正该告诉用户的是「连着的这份 magic-agent 是哪一版」。老 core 没有该字段 → 空。
99
+ Version string `json:"version,omitempty"`
100
+ Features []string `json:"features"`
101
+ Engines []struct {
95
102
  Engine string `json:"engine"`
96
103
  OK bool `json:"ok"`
97
104
  Bin string `json:"bin"`
98
105
  Capabilities []string `json:"capabilities"`
99
106
  } `json:"engines"`
107
+ // TeamRoles 团队角色清单(2026-10-22,core 只增字段)。
108
+ // 客户端「团队模式」页据此渲染角色路由行(缩进在所属类别下);老 core 没有这个字段 → 空,
109
+ // 插件对 role:<name> 就只做格式校验,不拒未知名字(交给 core 裁决)。
110
+ TeamRoles []struct {
111
+ Name string `json:"name"`
112
+ Kind string `json:"kind"`
113
+ Duty string `json:"duty"`
114
+ } `json:"teamRoles"`
115
+ }
116
+
117
+ // teamRoleNames 角色规范名清单(给 teamFlags 做白名单;空 = 老 core,不校验名字)。
118
+ func (s *Service) teamRoleNames() []string {
119
+ out := make([]string, 0, len(s.contract.TeamRoles))
120
+ for _, r := range s.contract.TeamRoles {
121
+ if n := strings.TrimSpace(r.Name); n != "" {
122
+ out = append(out, n)
123
+ }
124
+ }
125
+ return out
100
126
  }
101
127
 
102
128
  // New 建服务并做契约校验。
@@ -166,6 +192,10 @@ func (s *Service) Handler() http.Handler {
166
192
  // 按**绝对路径**读文本文件(Markdown / 日志 / 源码):移动端产物卡「查看」在浮层里阅读。
167
193
  mux.HandleFunc("/desk/path-read", s.guard(s.handlePathRead))
168
194
  mux.HandleFunc("/desk/titles", s.guard(s.handleTitles))
195
+ // 需求聚合(2026-10-22,见 requirements.go):读清单 / 手动整理一轮。
196
+ // 自动归纳不在这里 —— 它是每轮 /desk/ask 收尾后的防抖动作。
197
+ mux.HandleFunc("/desk/requirements", s.guard(s.handleRequirements))
198
+ mux.HandleFunc("/desk/requirements/consolidate", s.guard(s.handleRequirementsConsolidate))
169
199
  // 插件目录(magic-shorts / magic-video 两个工程的 CLI):介绍、安装状态、安装/升级。
170
200
  // 与引擎无关 —— core 不认识它们,所以放在这一层而不是 core 的 --engines 里。
171
201
  mux.HandleFunc("/desk/plugins", s.guard(s.handlePlugins))
@@ -313,13 +343,28 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
313
343
  // 所以这里的 cwd 就是「桌面默认工作空间」,客户端用它给空 workspace 的会话归类。
314
344
  wd, _ := os.Getwd()
315
345
  writeJSON(w, http.StatusOK, map[string]any{
316
- "ok": true,
317
- "contractVersion": s.contract.ContractVersion,
346
+ "ok": true,
347
+ "contractVersion": s.contract.ContractVersion,
348
+ // 版本号(2026-10-22):移动端「我的」页显示「当前连着的 magic-agent 是哪一版」。
349
+ // 取 core 契约里的 version —— 与 `magic-agent --version` 同一份(ldflags 注入)。
350
+ // 老 core 没有这个字段 → 空串,界面显示「—」,不编一个假版本。
351
+ "version": s.contract.Version,
318
352
  "features": s.contract.Features,
319
353
  "app": s.opts.Profile.App.Name,
320
354
  "engines": len(s.contract.Engines),
321
355
  "defaultWorkspace": wd,
322
356
  "workspacesRoot": filepath.Dir(wd),
357
+ // 远程移动端界面在不在(见 handleMobileUI)。
358
+ // 客户端的闸门页要据此决定「跳远程 /m/ 还是用包里自带的 ui.html」。
359
+ // 为什么不放在 features 里:features 是**core 的能力位**(s.contract.Features),
360
+ // 由 core 上报;/m/ 是插件这一层的静态资源,core 根本不知道它。
361
+ // 为什么不省掉、让客户端自己去 HEAD 一次:多一次往返就是启动路径上多一段等待,
362
+ // 而且 HEAD 请求在 WebView 里常被记成 ERR_ABORTED 的失败请求(导航抢跑),
363
+ // 排查时是纯噪音。探活本来就要打这一枪,顺手带一句最省。
364
+ "remoteUI": s.hasMobileUI(),
365
+ // 团队角色清单(2026-10-22):移动端「团队模式」页要按角色配路由,
366
+ // 角色名不能由客户端硬编码。老 core 没有 teamRoles → null,客户端回落内置清单。
367
+ "teamRoles": s.contract.TeamRoles,
323
368
  })
324
369
  }
325
370
 
@@ -331,7 +376,7 @@ func (s *Service) handleContract(w http.ResponseWriter, r *http.Request) {
331
376
  // handleEngines 引擎清单(含模型;走 --no-models 的快路径由调用方决定)。
332
377
  func (s *Service) handleEngines(w http.ResponseWriter, r *http.Request) {
333
378
  if r.URL.Query().Get("models") == "1" {
334
- s.proxyCore(w, r, "--engines")
379
+ s.proxyEngines(w, r)
335
380
  return
336
381
  }
337
382
  s.proxyCore(w, r, "--engines", "--no-models")
@@ -410,7 +455,8 @@ func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
410
455
  // - 必须带 X-Magic-Token:这是会话数据(包含历史消息文本),没令牌读不到。
411
456
  //
412
457
  // 本函数同时是 `/desk/session/{id}/{action}` 这条前缀路由的**唯一入口**:
413
- // `action` 为空或 `messages` 走全量(下面这段),`events` 走增量续读(见 handleSessionEvents)。
458
+ // `action` 为空或 `messages` 走全量(下面这段),`events` 走一次性增量续读
459
+ // (见 handleSessionEvents),`follow` 走 SSE 持续尾随(见 handleSessionFollow)。
414
460
  // 为什么在一个 handler 里分派而不再注册一条更具体的 pattern:`/desk/session/` 是**子树匹配**,
415
461
  // 后注册的 pattern 并不会自动赢;而带 `{id}` 通配的新式 pattern 需要 Go 1.22+。
416
462
  // 在入口处显式分派既不看 Go 版本脸色,也让「有哪些子路径」集中在一处可读。
@@ -453,10 +499,13 @@ func (s *Service) handleSessionMessages(w http.ResponseWriter, r *http.Request)
453
499
  case "events":
454
500
  s.handleSessionEvents(w, r, id)
455
501
  return
502
+ case "follow":
503
+ s.handleSessionFollow(w, r, id)
504
+ return
456
505
  default:
457
506
  // 未知子路径要**明说**,不能默默当全量返回 —— 否则客户端拼错路径时会拿到
458
507
  // 一份看起来正常的全量历史,于是「增量没生效」被当成「增量没实现」。
459
- writeErr(w, http.StatusNotFound, "未知子路径:"+action+"(可用:messages / events)")
508
+ writeErr(w, http.StatusNotFound, "未知子路径:"+action+"(可用:messages / events / follow)")
460
509
  return
461
510
  }
462
511
  dir, derr := session.DefaultLogDir()
@@ -891,9 +940,10 @@ func parseSessionPath(path string) (id, action string, ok bool) {
891
940
  // 三种结果,调用方按 `snapshotRequired` 分流:
892
941
  // 1. 无新事件(含 after 正好等于最后一条)→ 200 + 空数组,snapshotRequired=false
893
942
  // 2. 有新事件 → 200 + 增量数组,snapshotRequired=false
894
- // 3. after **超前**于磁盘上的最大 seq → 200 + 空数组,snapshotRequired=**true**
895
- // 超前只可能是「日志被重置/截断」或「游标来自另一个会话」;两种情况下
896
- // 增量都不可信,客户端应改拉 `/messages` 全量重建。
943
+ // 3. after **超前**于文件末尾那条的 seq → 200 + 空数组,snapshotRequired=**true**
944
+ // 超前只可能是「日志被重置/截断」或「游标来自另一个会话」(含续跑导致的 seq
945
+ // 回落:游标停在旧轮高位、新轮从 1 数起);两种情况下增量都不可信,客户端应
946
+ // 改拉 `/messages` 全量重建。
897
947
  //
898
948
  // 边界:after 省略或为 0 = 全量(等价 /messages,但多带 lastSeq/after 元信息);
899
949
  // after 非数字或超 uint64 → 400(**不静默当 0** —— 那样客户端会以为拿到了增量,
@@ -944,11 +994,238 @@ func (s *Service) handleSessionEvents(w http.ResponseWriter, r *http.Request, id
944
994
  "after": after,
945
995
  "lastSeq": maxSeq,
946
996
  // 客户端游标比磁盘还新 ⇒ 增量不可信,去拉全量。
947
- // ⚠️ 判据是**严格大于**:after == maxSeq 是「已经追平」,那是正常的空增量。
997
+ // 「还新」= 大于**文件末尾那条**的 seq(不是全文件最大:续跑会让 seq 回落)。
998
+ // ⚠️ 判据是**严格大于**:after == lastSeq 是「已经追平」,那是正常的空增量。
948
999
  "snapshotRequired": after > maxSeq,
949
1000
  })
950
1001
  }
951
1002
 
1003
+ // follow 的几档节奏。集中在这里是为了「慢在哪、多久收尾」一眼可见(别散落在循环里)。
1004
+ const (
1005
+ // followPollEvery 增量读盘的节奏。500ms 是「看起来是实时」与「一条会话一次只读
1006
+ // 几十字节」的折中:读盘走 tailMark 快路径(seek 到上次消费的偏移),
1007
+ // 没有新事件时只 stat + 一次空读。
1008
+ followPollEvery = 500 * time.Millisecond
1009
+ // followPingEvery 心跳:移动网络 / WebView 会把长时间零字节的连接当死连接掐掉
1010
+ // (见 handleAsk 里 2026-10-19 那段报障)。
1011
+ followPingEvery = 10 * time.Second
1012
+ // followStateEvery 查登记表(会话是否已终结)的节奏。走磁盘读全量记录,
1013
+ // 比读日志贵,没必要每 500ms 一次。
1014
+ followStateEvery = 5 * time.Second
1015
+ // followGrace 看到终态后**再多等一会**再收尾。core 写 jsonl 是 32KB 缓冲的追加写
1016
+ // (另有 1s 定时 flush),且 turn_end 落盘与登记表写终态是两个动作 ——
1017
+ // 立刻 end 会把紧随其后的收尾事件截掉。
1018
+ followGrace = 2 * time.Second
1019
+ // followMaxDuration 单条连接的最长寿命:会话还在跑就先 end,客户端重连接着跟。
1020
+ // 避免僵尸连接常驻(也避开反向代理 / 移动网络的空闲上限)。
1021
+ followMaxDuration = 30 * time.Minute
1022
+ )
1023
+
1024
+ // terminalSessionKind 这些 kind 到了 = 这一轮已经收尾(词表与对话内核
1025
+ // client-ui/shared/conv/27-conv-ledger.js 的 TURN_END 同源)。
1026
+ func terminalSessionKind(kind string) bool {
1027
+ switch kind {
1028
+ case "turn_end", "turn_failed", "result", "error", "interrupted", "stopped", "end":
1029
+ return true
1030
+ }
1031
+ return false
1032
+ }
1033
+
1034
+ // terminalSessionState 登记表里「这一轮已经收尾」的状态(interrupted 也算:那一轮的进程
1035
+ // 已经结束;idle 是常驻会话的本轮已结束、进程还在等追加 —— 对跟进连接而言同样该收尾,
1036
+ // 否则客户端会对着一条已经跑完的会话空等到 30 分钟)。
1037
+ func terminalSessionState(st string) bool {
1038
+ switch st {
1039
+ case session.StateDone, session.StateFailed, session.StateCancelled,
1040
+ session.StateInterrupted, session.StateGone, session.StateIdle:
1041
+ return true
1042
+ }
1043
+ return false
1044
+ }
1045
+
1046
+ // handleSessionFollow `GET /desk/session/{id}/follow?after=<seq>&lean=1`:
1047
+ // **持续尾随**一条会话的进度(SSE,跨进程)。
1048
+ //
1049
+ // 为什么需要它(2026-10-21 报障:「移动端发起的会话在 PC 上看不到进度,反向也一样」):
1050
+ // - `/desk/asks` 只覆盖**本插件进程里**跑起来的轮次(s.runs 是内存)——
1051
+ // 掌天瓶桌面壳直接 exec core,PC 发起的轮次在本插件眼里从来不存在;
1052
+ // - `/desk/session/{id}/events` 是**一次性快照**,各端得自己定轮询节奏:
1053
+ // 移动端列表 3s、对话页 5s、PC 右栏 30s 且只刷列表 —— 详情页必然漂移。
1054
+ //
1055
+ // 所以把「尾随」收进网关:客户端一次连接,服务端按固定节奏增量读盘并推送,
1056
+ // 数据源是 core 落的 `~/.magic-agent/sessions/<id>.jsonl`(跨进程共享,谁跑的都看得到)。
1057
+ //
1058
+ // 帧格式与 `/desk/ask` 一致(各端已有 SSE 解析器,无需新代码):
1059
+ //
1060
+ // event: open data: {session, after} 连接建立(after = 起始游标)
1061
+ // data: <事件> 增量事件,一条一帧
1062
+ // event: snapshot data: {session, after, lastSeq} 游标超前(日志被重置/换会话)→ 重拉 /messages
1063
+ // event: end data: {session, after, state, reason} 收尾(state 为登记表状态,可能为空)
1064
+ // : ping 心跳注释帧(客户端忽略)
1065
+ //
1066
+ // ⚠️ 每轮都重新 ResolveLogID:run_id → session_id 的改名发生在会话**进行中**
1067
+ // (引擎吐出 session_id 后 writer 把 <run_id>.jsonl 改名)。只解一次会在改名后
1068
+ // 一直读老路径 —— 表现是「进度停在引擎吐 id 的那一刻」。
1069
+ func (s *Service) handleSessionFollow(w http.ResponseWriter, r *http.Request, id string) {
1070
+ var after uint64
1071
+ afterRaw := strings.TrimSpace(r.URL.Query().Get("after"))
1072
+ if afterRaw != "" {
1073
+ n, err := strconv.ParseUint(afterRaw, 10, 64)
1074
+ if err != nil {
1075
+ writeErr(w, http.StatusBadRequest, "after 必须是非负整数(收到 "+strconv.Quote(afterRaw)+")")
1076
+ return
1077
+ }
1078
+ after = n
1079
+ }
1080
+ dir, derr := session.DefaultLogDir()
1081
+ if derr != nil {
1082
+ writeErr(w, http.StatusInternalServerError, "读历史失败:找不到历史目录:"+derr.Error())
1083
+ return
1084
+ }
1085
+ lean := queryLean(r)
1086
+ id = session.ResolveLogID(dir, id)
1087
+ /* 既没日志、登记表里也没这条 id ⇒ 404 说清楚(拼错 id 时别让客户端白等 30 分钟)。
1088
+ ⚠️ 反过来的形态(登记表有、日志还没建)**必须放行**:core 是先写登记表、
1089
+ 后开日志文件,follow 要等着 —— 这正是「刚发起就看进度」的时刻。 */
1090
+ if !session.HasSessionLog(dir, id) {
1091
+ _, found, ferr := session.Find(id)
1092
+ if ferr != nil {
1093
+ writeErr(w, http.StatusInternalServerError, "读会话登记表失败:"+ferr.Error())
1094
+ return
1095
+ }
1096
+ if !found {
1097
+ writeErr(w, http.StatusNotFound, "没有这条会话:"+id)
1098
+ return
1099
+ }
1100
+ }
1101
+
1102
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
1103
+ w.Header().Set("Cache-Control", "no-cache")
1104
+ w.Header().Set("X-Accel-Buffering", "no")
1105
+ flusher, _ := w.(http.Flusher)
1106
+ send := func(event string, payload any) {
1107
+ b, _ := json.Marshal(payload)
1108
+ if event != "" {
1109
+ fmt.Fprintf(w, "event: %s\n", event)
1110
+ }
1111
+ fmt.Fprintf(w, "data: %s\n\n", b)
1112
+ if flusher != nil {
1113
+ flusher.Flush()
1114
+ }
1115
+ }
1116
+ send("open", map[string]any{"session": id, "after": after})
1117
+
1118
+ var termSeen bool
1119
+ lastState := ""
1120
+ // read 拉一次增量并推送;返回「这次有没有新事件」。
1121
+ read := func() bool {
1122
+ cur := session.ResolveLogID(dir, id)
1123
+ var evs []map[string]any
1124
+ var maxSeq uint64
1125
+ var err error
1126
+ if lean {
1127
+ evs, maxSeq, err = session.ReadSessionLogAfterLean(dir, cur, after, mapSeqOf, sessionEventMapFromJSON)
1128
+ } else {
1129
+ evs, maxSeq, err = session.ReadSessionLogAfter(dir, cur, after, mapSeqOf, sessionEventMapFromJSON)
1130
+ }
1131
+ if err != nil {
1132
+ /* 读失败**不掐连接**:磁盘抖动 / 文件正在改名(rename 与 open 竞态)
1133
+ 都只该少一拍,客户端下一拍就能补上。 */
1134
+ return false
1135
+ }
1136
+ if after > maxSeq {
1137
+ // 游标超前于磁盘:日志被重置 / 游标来自另一个会话 ⇒ 让客户端重拉全量。
1138
+ send("snapshot", map[string]any{"session": cur, "after": after, "lastSeq": maxSeq})
1139
+ after = maxSeq
1140
+ }
1141
+ if len(evs) == 0 {
1142
+ return false
1143
+ }
1144
+ if lean {
1145
+ evs = leanSessionEvents(evs)
1146
+ }
1147
+ for _, ev := range evs {
1148
+ b, _ := json.Marshal(ev)
1149
+ fmt.Fprintf(w, "data: %s\n\n", b)
1150
+ if terminalSessionKind(eventKind(ev)) {
1151
+ termSeen = true
1152
+ }
1153
+ }
1154
+ // maxSeq 是**文件末尾那条**的 seq(不是本批的,也不是全文件最大 ——
1155
+ // 续跑会让 seq 回落),直接当新游标不会漏,也不会停在高位。
1156
+ after = maxSeq
1157
+ if flusher != nil {
1158
+ flusher.Flush()
1159
+ }
1160
+ return true
1161
+ }
1162
+
1163
+ read() // 首屏:不等到第一个 tick 才给数据
1164
+ // 一上来就是终态(打开一条早就结束的会话)时不必等 grace —— 首屏已经把整段
1165
+ // 历史推出去了,下一拍补一次空读就收尾。
1166
+ if rec, found, err := session.Find(id); err == nil && found {
1167
+ lastState = rec.State
1168
+ if terminalSessionState(rec.State) {
1169
+ termSeen = true
1170
+ }
1171
+ }
1172
+ poll := time.NewTicker(followPollEvery)
1173
+ defer poll.Stop()
1174
+ ping := time.NewTicker(followPingEvery)
1175
+ defer ping.Stop()
1176
+ deadline := time.NewTimer(followMaxDuration)
1177
+ defer deadline.Stop()
1178
+
1179
+ var endAt time.Time // 非零 = 已看到终态,等 grace 补齐后收尾
1180
+ if termSeen {
1181
+ endAt = time.Now()
1182
+ }
1183
+ polls := 0
1184
+ stateEveryTicks := int(followStateEvery / followPollEvery)
1185
+ if stateEveryTicks < 1 {
1186
+ stateEveryTicks = 1
1187
+ }
1188
+ finish := func(reason string) {
1189
+ send("end", map[string]any{
1190
+ "session": id, "after": after, "state": lastState, "reason": reason,
1191
+ })
1192
+ }
1193
+ for {
1194
+ select {
1195
+ case <-r.Context().Done():
1196
+ return
1197
+ case <-deadline.C:
1198
+ finish("max_duration")
1199
+ return
1200
+ case <-ping.C:
1201
+ fmt.Fprint(w, ": ping\n\n")
1202
+ if flusher != nil {
1203
+ flusher.Flush()
1204
+ }
1205
+ case <-poll.C:
1206
+ read()
1207
+ polls++
1208
+ // 终态一旦出现就每拍都查(要尽快收尾);平时 5s 查一次登记表。
1209
+ if termSeen || polls%stateEveryTicks == 0 || !endAt.IsZero() {
1210
+ if rec, found, err := session.Find(id); err == nil && found {
1211
+ lastState = rec.State
1212
+ if terminalSessionState(rec.State) {
1213
+ termSeen = true
1214
+ }
1215
+ }
1216
+ }
1217
+ if termSeen && endAt.IsZero() {
1218
+ endAt = time.Now().Add(followGrace)
1219
+ }
1220
+ if !endAt.IsZero() && !time.Now().Before(endAt) {
1221
+ read() // 最后一补:把 grace 期间落盘的收尾行拿走
1222
+ finish("terminal")
1223
+ return
1224
+ }
1225
+ }
1226
+ }
1227
+ }
1228
+
952
1229
  // mapSeqOf 从解析后的 map 里取 seq。
953
1230
  //
954
1231
  // ⚠️ json.Unmarshal 进 `map[string]any` 时数字一律变 float64(除非开了 UseNumber),
@@ -1089,19 +1366,183 @@ func (s *Service) handleAsks(w http.ResponseWriter, r *http.Request) {
1089
1366
  writeJSON(w, http.StatusOK, items)
1090
1367
  }
1091
1368
 
1092
- // proxyCore 跑一条只读的 core 命令(--contract / --engines)并把 stdout 原样转发。
1093
- func (s *Service) proxyCore(w http.ResponseWriter, r *http.Request, args ...string) {
1094
- ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
1369
+ // proxyCoreTimeout 插件侧等 core 的上限。
1370
+ //
1371
+ // ⚠️ 必须**明显大于** core 自己的模型探测超时(agent.DefaultModelProbeTimeout = 30s):
1372
+ // 之前两边都是 30s,core 还在收尾时父进程就把它杀了 → 前端拿到 502 或干脆卡住。
1373
+ // 这里留 15s 余量(版本探测 5s + 收尾),超时统一按 504 报给客户端。
1374
+ const proxyCoreTimeout = 45 * time.Second
1375
+
1376
+ // errCoreTimeout core 命令超时的哨兵(errors.Is 判定用)。
1377
+ var errCoreTimeout = errors.New("core 命令超时未返回")
1378
+
1379
+ // runCore 跑一条只读的 core 命令,保证**有界返回**。
1380
+ func (s *Service) runCore(ctx context.Context, args ...string) ([]byte, error) {
1381
+ ctx, cancel := context.WithTimeout(ctx, proxyCoreTimeout)
1095
1382
  defer cancel()
1096
- out, err := exec.CommandContext(ctx, s.opts.MagicAgent, args...).Output()
1383
+ cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
1384
+ /* WaitDelay 是有界返回的最后一道保险:exec 给 Stdout/Stderr 建的是内部管道,
1385
+ 只要 core 的某个后代进程(引擎 CLI 的 node worker)还握着写端,Wait 就永远不返回 ——
1386
+ 症状正是「弹层永远停在正在探测引擎与模型…」。设了 WaitDelay 后,ctx 到期再等这么久
1387
+ 就关闭管道并返回 ErrWaitDelay,handler 一定能写出响应。 */
1388
+ cmd.WaitDelay = 5 * time.Second
1389
+ out, err := cmd.Output()
1097
1390
  if err != nil {
1098
- writeErr(w, http.StatusBadGateway, fmt.Sprintf("%s %s 失败:%v", s.opts.MagicAgent, strings.Join(args, " "), err))
1099
- return
1391
+ if ctx.Err() != nil || errors.Is(err, exec.ErrWaitDelay) {
1392
+ return nil, fmt.Errorf("%w(%s)", errCoreTimeout, proxyCoreTimeout)
1393
+ }
1394
+ return nil, err
1100
1395
  }
1396
+ return out, nil
1397
+ }
1398
+
1399
+ // writeCoreJSON 原样转发 core 的 stdout。
1400
+ func writeCoreJSON(w http.ResponseWriter, out []byte) {
1101
1401
  w.Header().Set("Content-Type", "application/json; charset=utf-8")
1102
1402
  _, _ = w.Write(out)
1103
1403
  }
1104
1404
 
1405
+ // proxyCore 跑一条只读的 core 命令(--contract / --engines)并把 stdout 原样转发。
1406
+ func (s *Service) proxyCore(w http.ResponseWriter, r *http.Request, args ...string) {
1407
+ out, err := s.runCore(r.Context(), args...)
1408
+ if err != nil {
1409
+ code := http.StatusBadGateway
1410
+ if errors.Is(err, errCoreTimeout) {
1411
+ code = http.StatusGatewayTimeout
1412
+ }
1413
+ writeErr(w, code, fmt.Sprintf("%s %s 失败:%v", s.opts.MagicAgent, strings.Join(args, " "), err))
1414
+ return
1415
+ }
1416
+ writeCoreJSON(w, out)
1417
+ }
1418
+
1419
+ // enginesCacheTTL 兜底缓存的有效期:过期就不再拿旧清单充数(引擎可能已卸载 / 换了账号)。
1420
+ const enginesCacheTTL = 24 * time.Hour
1421
+
1422
+ // enginesCachePath 探测结果的兜底缓存(与其他客户端状态同在 ~/.magic-agent)。
1423
+ func enginesCachePath() (string, error) {
1424
+ home, err := os.UserHomeDir()
1425
+ if err != nil || home == "" {
1426
+ return "", fmt.Errorf("解析家目录失败:%v", err)
1427
+ }
1428
+ return filepath.Join(home, ".magic-agent", "engines-cache.json"), nil
1429
+ }
1430
+
1431
+ // saveEnginesCache 把一次成功的 --engines 输出原样落盘(tmp + rename,避免读到半个文件)。
1432
+ // 只做尽力而为:写不进去不影响本次响应。
1433
+ func saveEnginesCache(out []byte) {
1434
+ if !json.Valid(out) {
1435
+ return
1436
+ }
1437
+ /* 一个可用引擎都没有的结果没有兜底价值,别拿它覆盖上一次的好清单
1438
+ (否则「这次全挂了」会把兜底也一起弄坏)。 */
1439
+ var rows []struct {
1440
+ OK bool `json:"ok"`
1441
+ }
1442
+ if json.Unmarshal(out, &rows) != nil {
1443
+ return
1444
+ }
1445
+ anyOK := false
1446
+ for _, row := range rows {
1447
+ if row.OK {
1448
+ anyOK = true
1449
+ break
1450
+ }
1451
+ }
1452
+ if !anyOK {
1453
+ return
1454
+ }
1455
+ p, err := enginesCachePath()
1456
+ if err != nil {
1457
+ return
1458
+ }
1459
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
1460
+ return
1461
+ }
1462
+ tmp := p + ".tmp"
1463
+ if err := os.WriteFile(tmp, out, 0o600); err != nil {
1464
+ return
1465
+ }
1466
+ _ = os.Rename(tmp, p)
1467
+ }
1468
+
1469
+ // loadEnginesCache 读上次成功的清单;过期 / 损坏都当没有。
1470
+ func loadEnginesCache() ([]byte, time.Time, bool) {
1471
+ p, err := enginesCachePath()
1472
+ if err != nil {
1473
+ return nil, time.Time{}, false
1474
+ }
1475
+ st, err := os.Stat(p)
1476
+ if err != nil || time.Since(st.ModTime()) > enginesCacheTTL {
1477
+ return nil, time.Time{}, false
1478
+ }
1479
+ out, err := os.ReadFile(p)
1480
+ if err != nil || !json.Valid(out) {
1481
+ return nil, time.Time{}, false
1482
+ }
1483
+ return out, st.ModTime(), true
1484
+ }
1485
+
1486
+ // enginesProbeRunning 后台重探的 single-flight 标记:同一时刻只跑一个,
1487
+ // 避免用户反复开关弹层时叠出一堆 core 进程。
1488
+ var (
1489
+ enginesProbeMu sync.Mutex
1490
+ enginesProbeRunning bool
1491
+ )
1492
+
1493
+ // probeEnginesAsync 后台重探并把结果写进缓存(下次请求就能直接看到新的)。
1494
+ //
1495
+ // ⚠️ 必须用 context.Background():请求此时已经用缓存响应完并结束了,
1496
+ // r.Context() 已被取消,拿它去跑 core 会立刻被杀。
1497
+ func (s *Service) probeEnginesAsync() {
1498
+ enginesProbeMu.Lock()
1499
+ if enginesProbeRunning {
1500
+ enginesProbeMu.Unlock()
1501
+ return
1502
+ }
1503
+ enginesProbeRunning = true
1504
+ enginesProbeMu.Unlock()
1505
+ go func() {
1506
+ defer func() {
1507
+ enginesProbeMu.Lock()
1508
+ enginesProbeRunning = false
1509
+ enginesProbeMu.Unlock()
1510
+ }()
1511
+ out, err := s.runCore(context.Background(), "--engines")
1512
+ if err != nil {
1513
+ return
1514
+ }
1515
+ saveEnginesCache(out)
1516
+ }()
1517
+ }
1518
+
1519
+ // proxyEngines 探测引擎与模型(--engines)。
1520
+ //
1521
+ // 策略是 stale-while-revalidate:
1522
+ // - 有缓存 → **立刻**返回缓存(带 X-Magic-Engines-Stale),同时后台重探写缓存;
1523
+ // - 没缓存 → 同步探测(45s 上限),成功即写缓存。
1524
+ //
1525
+ // 这样弹层永远是秒开:用户这次看到的是上次的结果,下一次打开就是新的。
1526
+ func (s *Service) proxyEngines(w http.ResponseWriter, r *http.Request) {
1527
+ if cached, at, ok := loadEnginesCache(); ok {
1528
+ w.Header().Set("X-Magic-Engines-Stale", at.Format(time.RFC3339))
1529
+ writeCoreJSON(w, cached)
1530
+ s.probeEnginesAsync()
1531
+ return
1532
+ }
1533
+ out, err := s.runCore(r.Context(), "--engines")
1534
+ if err == nil {
1535
+ saveEnginesCache(out)
1536
+ writeCoreJSON(w, out)
1537
+ return
1538
+ }
1539
+ code := http.StatusBadGateway
1540
+ if errors.Is(err, errCoreTimeout) {
1541
+ code = http.StatusGatewayTimeout
1542
+ }
1543
+ writeErr(w, code, fmt.Sprintf("%s --engines 失败:%v", s.opts.MagicAgent, err))
1544
+ }
1545
+
1105
1546
  // askRequest POST /desk/ask 的请求体。
1106
1547
  type askRequest struct {
1107
1548
  Engine string `json:"engine"`
@@ -1135,6 +1576,38 @@ type askRequest struct {
1135
1576
  // 为什么不能只看 Origin:移动端是**加载 /m/ 页面**(与桌面同源)跑起来的,
1136
1577
  // 同源请求分不出手机还是电脑,所以由客户端显式声明(见 shared/transport-http.js)。
1137
1578
  Client string `json:"client,omitempty"`
1579
+ // Team 团队模式(2026-10-21)。nil = 关(默认)。
1580
+ //
1581
+ // 为什么整个开关放在请求体、不落进 core 的配置文件:团队模式是**这一轮**的运行
1582
+ // 方式,不是这台机器的属性。开关状态由客户端自己记(localStorage),每轮随 body
1583
+ // 下发 —— core 因此完全无状态,「刚才那条为什么走了团队」在会话登记表里查得到。
1584
+ Team *teamRequest `json:"team,omitempty"`
1585
+ }
1586
+
1587
+ // teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
1588
+ type teamRequest struct {
1589
+ // Routes 类别 → 引擎/模型。空 map / 缺项 = 该类跟随主引擎(五类一致)。
1590
+ Routes map[string]teamRoute `json:"routes,omitempty"`
1591
+ // Parallel 同时跑几个子任务(0 = core 默认 4)。
1592
+ Parallel int `json:"parallel,omitempty"`
1593
+ // PlanOnly 只出计划与任务书,不执行(调试用)。
1594
+ PlanOnly bool `json:"planOnly,omitempty"`
1595
+ // TaskTimeout 单个子任务超时(Go duration 字符串,空 = 按总超时推算)。
1596
+ TaskTimeout string `json:"taskTimeout,omitempty"`
1597
+ // Split 拆解粒度:coarse | standard | fine(空 = core 默认 standard)。
1598
+ Split string `json:"split,omitempty"`
1599
+ // SplitCode 简单代码也拆成独立子任务(nil / false = 由主引擎直接给出)。
1600
+ SplitCode *bool `json:"splitCode,omitempty"`
1601
+ // Adaptive 执行后是否再规划一轮补充任务(nil = core 默认开)。
1602
+ Adaptive *bool `json:"adaptive,omitempty"`
1603
+ // Rules 角色规则覆盖(键 role:<name>,值「这类任务适合什么」)。
1604
+ Rules map[string]string `json:"rules,omitempty"`
1605
+ }
1606
+
1607
+ // teamRoute 某一类子任务的路由项。
1608
+ type teamRoute struct {
1609
+ Engine string `json:"engine,omitempty"`
1610
+ Model string `json:"model,omitempty"`
1138
1611
  }
1139
1612
 
1140
1613
  // askRun 一次 ask(跑完的还会短时保留,供断线续传补拉终态)。
@@ -1152,6 +1625,11 @@ type askRun struct {
1152
1625
  session_id 的事件到达时补写(见 appendEvent)。受 mu 保护。 */
1153
1626
  session string
1154
1627
 
1628
+ /* workspace 这一轮算在哪个目录(收尾后自动归纳按它定位)。建 run 时定死:
1629
+ 请求带 -w 就用它,没带就用插件自己的 cwd(= core 记录的 cwd,见 handleHealth)。
1630
+ 受 mu 保护只是为了与上面的字段同一把锁,写入只发生一次。 */
1631
+ workspace string
1632
+
1155
1633
  /* run core 本次运行的 run_id(ready 行里带出)。引擎不吐 session_id 时,
1156
1634
  登记表里这条会话只有 run_id,/desk/asks 要把两者都给客户端才能认领。受 mu 保护。 */
1157
1635
  run string
@@ -1349,6 +1827,28 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
1349
1827
  if req.NoThinking {
1350
1828
  args = append(args, "--no-thinking")
1351
1829
  }
1830
+ /* 团队模式(2026-10-21):开关随请求下发,插件只做「翻译 + 预校验」,
1831
+ 路由规则本身全在 core(internal/team/route.go)—— 两边各判一套迟早会分叉。
1832
+
1833
+ ⚠️ 顺序要紧:--team 必须在 -p 之前塞进去(这里就在),但更重要的是
1834
+ **不能**与--idle 语义混:团队模式没有常驻会话(子任务不共享上下文,
1835
+ 主引擎也不是每轮都能续上),core 侧 runTeamAsk 直接忽略 --idle。
1836
+ 这里不删它(core 若将来支持了自然就生效),只在注释里钉住。 */
1837
+ if req.Team != nil {
1838
+ /* 老 core 不认识 --team(exit 2),这里先挡下并**说清楚为什么**:
1839
+ 让用户看到「core 太旧,请升级」比让它跑一轮后报 usage error 好得多。 */
1840
+ if !hasStr(s.contract.Features, FeatureSessionTeam) {
1841
+ writeErr(w, http.StatusNotImplemented,
1842
+ "当前 core 不支持团队模式(缺少 "+FeatureSessionTeam+" 能力位,请升级 magic-agent)")
1843
+ return
1844
+ }
1845
+ teamArgs, terr := teamFlags(req.Team, s.opts.Profile, s.teamRoleNames())
1846
+ if terr != nil {
1847
+ writeErr(w, http.StatusBadRequest, terr.Error())
1848
+ return
1849
+ }
1850
+ args = append(args, teamArgs...)
1851
+ }
1352
1852
  // 附件:逐个 -a/--attach。这里先做与 core 同口径的校验(普通文件 + 32MB 上限),
1353
1853
  // 免得把非法路径丢给 core 再让它以 usage error 退出(那时错误更难定位)。
1354
1854
  for _, a := range req.Attachments {
@@ -1414,6 +1914,11 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
1414
1914
  done: make(chan struct{}),
1415
1915
  stderr: newCappedBuffer(8 << 10),
1416
1916
  session: strings.TrimSpace(req.Session),
1917
+ /* 这一轮算在哪个工作区(收尾后自动归纳要按它定位)。
1918
+ 请求没带 workspace 时 core 会用它自己的 cwd 记录会话 —— 插件 spawn core 时
1919
+ 不换目录,所以那个 cwd 就是插件自己的 cwd(见 handleHealth 的 defaultWorkspace)。
1920
+ 两处口径必须一致,否则「聊了半天需求页没变」:归纳去的是另一个目录。 */
1921
+ workspace: pinnedWorkspace(req.Workspace),
1417
1922
  }
1418
1923
  cmd.Stderr = run.stderr
1419
1924
  /* 用户这条提问先落进回放缓冲(不进 SSE 直播流):core 的事件流里没有 user 事件,
@@ -1494,6 +1999,12 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
1494
1999
  }
1495
2000
  }
1496
2001
  run.finish(code)
2002
+ /* 需求自动归纳(2026-10-22):一轮收尾后防抖触发(12s 内再来一轮顺延)。
2003
+ 用 run 上记死的 workspace,不重新读 cwd —— 归纳必须落在**这一轮聊的那个目录**,
2004
+ 而进程 cwd 在这期间可能被 profile 切换(--ui-dir 之类的启动参数不影响,但
2005
+ 以后会有切换空间的功能;钉住是零成本的正确做法)。
2006
+ 放在 finish 之后:先让 /desk/asks 与 SSE 看到本轮结束,再让后台开始整理。 */
2007
+ s.maybeAutoConsolidate(run.workspace)
1497
2008
  close(run.done)
1498
2009
  /* 任务结束后**不立刻摘除**:客户端可能在断线重连后在补拉终态
1499
2010
  (`GET /desk/ask/{id}/events`),留 askRetention 的时间窗。
@@ -1757,6 +2268,21 @@ func (s *Service) mobileRoot() string {
1757
2268
  return ""
1758
2269
  }
1759
2270
 
2271
+ // hasMobileUI 报「/m/ 现在真的能给出页面吗」。
2272
+ //
2273
+ // 与 mobileRoot 的区别:那个只回答「目录配在哪」,不看盘上有没有东西。
2274
+ // 客户端拿这个字段决定要不要跳 /m/,所以必须验到 index.html 真的存在 ——
2275
+ // 只看配置的话,--mobile-dir 指向一个空目录时会告诉客户端「有远程界面」,
2276
+ // 它跳过去得到 404,然后卡在一个错误页上(而不是退回包里的界面)。
2277
+ func (s *Service) hasMobileUI() bool {
2278
+ root := s.mobileRoot()
2279
+ if root == "" {
2280
+ return false
2281
+ }
2282
+ st, err := os.Stat(filepath.Join(root, "index.html"))
2283
+ return err == nil && !st.IsDir()
2284
+ }
2285
+
1760
2286
  // handleMobileUI 远程移动端界面。
1761
2287
  //
1762
2288
  // /m/ → <mobile>/index.html(页面本身)