magic-agent 2.4.3 → 2.6.3

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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -142,3 +142,31 @@ func TestDeleteDropsStatuses(t *testing.T) {
142
142
  t.Fatalf("删除后状态该被清掉,实得 %q", got)
143
143
  }
144
144
  }
145
+
146
+ // TestStatusOfMatchesStatusForAny 快照读取必须与逐条读取**逐字一致**。
147
+ //
148
+ // 为什么单独立一条(2026-10-26):列表页从 StatusForAny 换成了 StatusSnapshot + StatusOf
149
+ // (逐行调前者会让底层把整张表深拷贝一遍,407 行 = 407 次全表拷贝)。这类「为了性能换一条
150
+ // 取数路径」的改动最容易悄悄改变语义 —— 键优先级(session 优先 / run 兜底)只要漏一处,
151
+ // 列表上的状态就会一部分对、一部分错,而且极难看出来。这里把两者钉成同一个答案。
152
+ func TestStatusOfMatchesStatusForAny(t *testing.T) {
153
+ withStatusHome(t)
154
+ now := time.Now()
155
+ setStatus(Record{RunID: "run-a", SessionID: "sess-a", State: StateIdle, StartedAt: now})
156
+ setStatus(Record{RunID: "run-b", State: StateRunning, StartedAt: now})
157
+ snap := StatusSnapshot()
158
+
159
+ cases := [][2]string{
160
+ {"sess-a", "run-a"}, // 两个键都有:取 session
161
+ {"", "run-b"}, // 只有 run:取 run
162
+ {"sess-a", "run-b"}, // 撞车:session 优先(与 StatusForAny 同口径)
163
+ {"nope", "nope2"}, // 都没有:空
164
+ {"", ""}, // 空 id:空
165
+ }
166
+ for _, c := range cases {
167
+ want := StatusForAny(c[0], c[1])
168
+ if got := StatusOf(snap, c[0], c[1]); got != want {
169
+ t.Fatalf("StatusOf(%q,%q) = %q,StatusForAny 却是 %q", c[0], c[1], got, want)
170
+ }
171
+ }
172
+ }
@@ -133,6 +133,22 @@ func TitleForAny(sessionID, runID string) string {
133
133
  return m[strings.TrimSpace(runID)]
134
134
  }
135
135
 
136
+ // TitleSnapshot 取整张标题表的**只读快照**,供批量读取(列表页)用。
137
+ //
138
+ // 与 StatusSnapshot 同一个理由(2026-10-26):TitleForAny 每次都会让 loadTitles()
139
+ // 把整张表深拷贝一份(缓存不能交给调用方乱改)。列表页逐行调它,407 行就是 407 次
140
+ // 全表拷贝 —— 而它要的只是一个 O(1) 的查表。这里拷一次,调用方自己按 id 取。
141
+ func TitleSnapshot() map[string]string { return Titles() }
142
+
143
+ // TitleOf 从快照里取一条会话的标题:**session_id 优先,其次 run_id** ——
144
+ // 与 TitleForAny 同口径,只是不再重复加载表。
145
+ func TitleOf(snapshot map[string]string, sessionID, runID string) string {
146
+ if t := snapshot[strings.TrimSpace(sessionID)]; t != "" {
147
+ return t
148
+ }
149
+ return snapshot[strings.TrimSpace(runID)]
150
+ }
151
+
136
152
  // AdoptTitle 把标题从 run_id 迁到 session_id(幂等),并**清掉那个 run_id 键**。
137
153
  //
138
154
  // 只在新键**为空**时搬:用户可能已经手动重命名过(那就是权威值),不能被旧键覆盖回去。
@@ -296,3 +296,29 @@ func TestSummarizeCachedAcrossKeys(t *testing.T) {
296
296
  t.Fatal("命中时应把标题迁到 session_id(否则下一轮又要重算)")
297
297
  }
298
298
  }
299
+
300
+ // TestTitleOfMatchesTitleForAny 快照读取必须与逐条读取**逐字一致**(理由见 status_test.go
301
+ // 的 TestStatusOfMatchesStatusForAny:列表页为省掉逐行全表拷贝换了取数路径)。
302
+ func TestTitleOfMatchesTitleForAny(t *testing.T) {
303
+ withTitleHome(t)
304
+ if err := SetTitle("sess-a", "会话标题"); err != nil {
305
+ t.Fatal(err)
306
+ }
307
+ if err := SetTitle("run-b", "只按 run 存的"); err != nil {
308
+ t.Fatal(err)
309
+ }
310
+ snap := TitleSnapshot()
311
+
312
+ cases := [][2]string{
313
+ {"sess-a", "run-b"}, // 撞车:session 优先
314
+ {"", "run-b"}, // 只按 run
315
+ {"nope", "nope"}, // 都没有
316
+ {"", ""}, // 空 id
317
+ }
318
+ for _, c := range cases {
319
+ want := TitleForAny(c[0], c[1])
320
+ if got := TitleOf(snap, c[0], c[1]); got != want {
321
+ t.Fatalf("TitleOf(%q,%q) = %q,TitleForAny 却是 %q", c[0], c[1], got, want)
322
+ }
323
+ }
324
+ }
@@ -3,6 +3,7 @@ package session
3
3
  import (
4
4
  "crypto/sha1"
5
5
  "encoding/hex"
6
+ "encoding/json"
6
7
  "fmt"
7
8
  "os"
8
9
  "path/filepath"
@@ -13,25 +14,51 @@ import (
13
14
  "github.com/darren/magic-agent/internal/agent"
14
15
  )
15
16
 
16
- /* ── 工作空间串行锁(2026-10-23,多客户端串行的一部分)──
17
+ /* ── 工作空间串行锁(2026-10-23 起;2026-10-26 改成「多客户端默认生效」)──
17
18
 
18
- 为什么要锁在 core 而不是只靠 magic-client 的调度器:
19
- · 同一台桌面上,除了 magic-client 起的轮次,还有人直接跑 `magic-agent -p`;
20
- · 多台手机连的是同一个 magic-client,但**进程间**(多个 magic-client 实例)也可能并存。
21
- 调度器只能管自己进程里的队列;一个落在文件系统上的锁才能让「谁先跑」对所有进程一致。
19
+ 为什么会存在这件事:**同一个工作空间不能同时跑两个引擎**。
20
+ 两个 agent 同时改同一批文件,冲突是必然的;更隐蔽的是同一 session_id 被两端
21
+ 同时 resume 时,两边的 sessionWriter 各算各的 seq,同一份 jsonl 里出现成对重号,
22
+ 读侧按 `--after seq` 增量续读会把另一条 lane 的事件全滤掉 —— 一端「已完成」、
23
+ 另一端永远等不到终态(2026-10-21 用户报障的正是这个)。
22
24
 
23
- 为什么是选入式(MAGIC_AGENT_WORKSPACE_LOCK=1):
24
- 默认开启会改变 CLI 的既有语义(两条命令并发跑本来是允许的),也会让团队模式的
25
- 子任务与主任务互相等待。这里只在 magic-client spawn core 时注入该环境变量,
26
- 团队子任务(ParentRunID 非空)自动跳过。
25
+ 为什么锁必须落在 core 而不是只靠某个客户端的调度器:
26
+ · 能起轮次的地方**不止一个客户端**:桌面壳直接 exec 本 CLI、HTTP 插件排自己的队列、
27
+ 手机连同一个插件、还有人手工 `magic-agent -p` / CI / 别的 magic-* 应用;
28
+ · 任何进程内的队列都只管得住自己那一份。只有落在**文件系统**上的锁,
29
+ 才能让「谁先跑」对所有进程一致。
30
+ 这就是「magic-agent 本身要支持多客户端」的字面含义:**仲裁在 core,不在各宿主**。
31
+ 宿主可以自己再排一层(为了显示排队位次、让路给别的客户端),但那是**视图与礼貌**,
32
+ 不是正确性的来源 —— 少一个宿主忘了实现,文件就已经被两个进程改了。
27
33
 
28
- 锁的形态:<sessions dir>/locks/<sha1(workspace)>.lock,内容 `<pid>\n<unix nano>`。
29
- · 用 O_CREATE|O_EXCL 原子创建;
30
- · 持锁进程已不在 / 文件超过 staleAfter → 视为陈旧并回收;
31
- · 进程退出没来得及删也不会永久堵住(下一步的持有者会按 pid 判死回收)。
34
+ ── 2026-10-26 改了什么(原先只在插件起 core 时选入)──
35
+
36
+ 改前:`MAGIC_AGENT_WORKSPACE_LOCK=1` 才生效,且只有 magic-client 注入。
37
+ 于是「壳直接 exec core」这条路**完全绕过**串行 —— 用户实测:手机在跑时,
38
+ PC 能在同一目录立刻起第二个引擎,两边同时改文件。补在每个宿主里是不收敛的
39
+ (N 个宿主 = N 份实现,且新宿主天然漏掉)。
40
+
41
+ 改后:**默认生效**,只有显式 `MAGIC_AGENT_WORKSPACE_LOCK=0` 才关。
42
+ 保留关闭开关的理由:那些「本来就允许并发」的用法不该被悄悄改语义 ——
43
+ 跑批、测试、团队模式子任务(它们与主任务同空间,见 Begin 里的 ParentRunID 判断)。
44
+
45
+ ── 等待方要能看见「在等谁」──
46
+
47
+ 锁文件里除 pid 外还记 `client` / `source` / `run_id` / `session_id` / 起始时间。
48
+ 原先是 `pid\n时间戳`,等待方只能打一句「有人在跑」——用户看到的是「卡住了」,
49
+ 既不知道是手机上那条、也不知道该去停谁。现在 `LockHolder()` 能把持有者读出来,
50
+ `--sessions` 与客户端都能据此显示「等谁」。
51
+
52
+ 锁的形态:`<sessions dir>/locks/<sha1(workspace)>.lock`,JSON 内容(兼容旧的纯文本)。
53
+ · `O_CREATE|O_EXCL` 原子创建;
54
+ · 持有进程已不在 / 文件超过 staleAfter → 陈旧,回收;
55
+ · 进程崩溃没删也不会永久堵住(下一个持有者按 pid 判死回收)。
32
56
  */
33
57
 
34
- // EnvWorkspaceLock 打开「工作空间串行锁」的环境变量(值为 "1" 时生效)。
58
+ // EnvWorkspaceLock 开关「工作空间串行锁」的环境变量。
59
+ //
60
+ // 取值语义(2026-10-26 起):未设置 = **启用**;`0` / `false` / `off` / `no` = 关闭;
61
+ // 其余非空值 = 启用。改前是「必须为 1 才启用」,见文件头「改了什么」。
35
62
  const EnvWorkspaceLock = "MAGIC_AGENT_WORKSPACE_LOCK"
36
63
 
37
64
  // EnvWorkspaceLockWait 等锁上限(Go duration;默认 10m)。
@@ -42,14 +69,33 @@ const (
42
69
  workspaceLockStale = 6 * time.Hour
43
70
  )
44
71
 
72
+ // LockHolder 锁的持有者信息(等待方 / 列表页用来回答「我在等谁」)。
73
+ type LockHolder struct {
74
+ PID int `json:"pid"`
75
+ Client string `json:"client,omitempty"` // 发起方应用(如 magic-test / com.magic.test)
76
+ Source string `json:"source,omitempty"` // 设备形态(mobile | pc)
77
+ RunID string `json:"run_id,omitempty"` // 占用者那一轮的 run_id
78
+ SessionID string `json:"session_id,omitempty"`
79
+ Engine string `json:"engine,omitempty"`
80
+ Since time.Time `json:"since"` // 拿到锁的时刻
81
+ // Workspace 归一路径(只是为了回报时不用调用方再算一次)。
82
+ Workspace string `json:"workspace,omitempty"`
83
+ }
84
+
45
85
  // WorkspaceLock 一把已持有的工作空间锁;Release 幂等。
46
86
  type WorkspaceLock struct {
47
87
  path string
48
88
  held bool
49
89
  }
50
90
 
91
+ // workspaceLockEnabled 锁是否启用(默认启用;见 EnvWorkspaceLock 的取值语义)。
51
92
  func workspaceLockEnabled() bool {
52
- return strings.TrimSpace(os.Getenv(EnvWorkspaceLock)) == "1"
93
+ switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvWorkspaceLock))) {
94
+ case "0", "false", "off", "no":
95
+ return false
96
+ default:
97
+ return true
98
+ }
53
99
  }
54
100
 
55
101
  func workspaceLockWait() time.Duration {
@@ -61,7 +107,12 @@ func workspaceLockWait() time.Duration {
61
107
  return 10 * time.Minute
62
108
  }
63
109
 
64
- // lockKey 归一工作空间:空 = 进程 cwd;返回绝对路径 + 哈希文件名。
110
+ // LockKey 归一工作空间:空 = 进程 cwd;返回绝对路径 + 哈希文件名。
111
+ //
112
+ // 导出是因为客户端要自己判断「我这条和它那条是不是同一个目录」——
113
+ // 让它们各写一份归一逻辑,迟早在「尾斜杠算不算同一个」上分叉。
114
+ func LockKey(workspace string) (key, file string) { return workspaceLockKey(workspace) }
115
+
65
116
  func workspaceLockKey(workspace string) (key, file string) {
66
117
  ws := strings.TrimSpace(workspace)
67
118
  if ws == "" {
@@ -75,13 +126,117 @@ func workspaceLockKey(workspace string) (key, file string) {
75
126
  return ws, hex.EncodeToString(sum[:])
76
127
  }
77
128
 
129
+ // lockDirPath 锁目录(与登记表同目录,见 Dir)。
130
+ func lockDirPath() string {
131
+ dir := Dir()
132
+ if dir == "" {
133
+ return ""
134
+ }
135
+ return filepath.Join(dir, "locks")
136
+ }
137
+
138
+ // lockFilePath 某个 workspace 的锁文件路径(空 = 环境不可用)。
139
+ func lockFilePath(workspace string) string {
140
+ ld := lockDirPath()
141
+ if ld == "" {
142
+ return ""
143
+ }
144
+ _, name := workspaceLockKey(workspace)
145
+ return filepath.Join(ld, name+".lock")
146
+ }
147
+
148
+ /* ── 锁文件的内容编解码 ────────────────────────────────────────────
149
+ * 新格式是 JSON(能带持有者身份)。旧格式是两行纯文本 `pid\n纳秒时间`。
150
+ * 读的时候两种都认 —— 升级途中会有上一个版本留下的锁文件,
151
+ * 不认它就会把「有人在跑」误判成「锁文件坏了」,进而当成陈旧锁抢过来(两个进程同时跑)。
152
+ */
153
+
154
+ // encodeLock 锁文件的正文。
155
+ func encodeLock(h LockHolder) []byte {
156
+ if h.Since.IsZero() {
157
+ h.Since = time.Now()
158
+ }
159
+ b, err := json.Marshal(h)
160
+ if err != nil { // 理论上不可达(字段全是可序列化的基本类型);退化成旧格式也别写坏文件
161
+ return []byte(fmt.Sprintf("%d\n%d\n", h.PID, h.Since.UnixNano()))
162
+ }
163
+ return append(b, '\n')
164
+ }
165
+
166
+ // decodeLock 解析锁文件正文;两种格式都认。
167
+ func decodeLock(b []byte) LockHolder {
168
+ trimmed := strings.TrimSpace(string(b))
169
+ if trimmed == "" {
170
+ return LockHolder{}
171
+ }
172
+ // JSON 形状:以 { 开头。旧格式第一行是纯数字,不会撞。
173
+ if strings.HasPrefix(trimmed, "{") {
174
+ var h LockHolder
175
+ if err := json.Unmarshal([]byte(trimmed), &h); err == nil {
176
+ return h
177
+ }
178
+ }
179
+ lines := strings.Split(trimmed, "\n")
180
+ var h LockHolder
181
+ if len(lines) > 0 {
182
+ if pid, err := strconv.Atoi(strings.TrimSpace(lines[0])); err == nil {
183
+ h.PID = pid
184
+ }
185
+ }
186
+ if len(lines) > 1 {
187
+ if ns, err := strconv.ParseInt(strings.TrimSpace(lines[1]), 10, 64); err == nil && ns > 0 {
188
+ h.Since = time.Unix(0, ns)
189
+ }
190
+ }
191
+ return h
192
+ }
193
+
194
+ /* ── 持有者查询(等待方 / 列表页用)────────────────────────────────
195
+ * 与 acquire 是**只读**的一对:这里绝不创建、绝不回收 —— 回收只在真正要取锁时做
196
+ * (见 reapStaleWorkspaceLock)。原因:查询方可能只是个列表页,
197
+ * 它凭什么替别人判定「那把锁已经死了」然后删掉?判死要取锁方才做的决定。
198
+ */
199
+
200
+ // LockHolderFor 读某个 workspace 上当前锁的持有者(没有锁 / 环境不可用 → 0 值)。
201
+ //
202
+ // ⚠️ 不判活、不回收:pid 可能已经死了但文件还在。要不要清理是取锁方的事。
203
+ // 调用方若要「这锁还算不算数」,请自己看 PIDAlive(agent.PIDAlive 与这里同口径)。
204
+ func LockHolderFor(workspace string) (LockHolder, bool) {
205
+ p := lockFilePath(workspace)
206
+ if p == "" {
207
+ return LockHolder{}, false
208
+ }
209
+ b, err := os.ReadFile(p)
210
+ if err != nil {
211
+ return LockHolder{}, false
212
+ }
213
+ h := decodeLock(b)
214
+ if h.PID <= 0 {
215
+ return LockHolder{}, false
216
+ }
217
+ if ws, _ := workspaceLockKey(workspace); h.Workspace == "" {
218
+ h.Workspace = ws
219
+ }
220
+ if h.Since.IsZero() {
221
+ // 旧格式没带时间就退回文件 mtime —— 至少能报「占多久了」。
222
+ if st, err := os.Stat(p); err == nil {
223
+ h.Since = st.ModTime()
224
+ }
225
+ }
226
+ return h, true
227
+ }
228
+
229
+ /* ── 取锁 ─────────────────────────────────────────────────────────── */
230
+
78
231
  /*
79
- AcquireWorkspaceLock 取工作空间锁(阻塞,直到拿到或超时)。
232
+ acquireWorkspaceLock 取工作空间锁(阻塞,直到拿到或超时)。
80
233
 
81
234
  o.NoPersist 没关系:锁在 sessions 目录,不依赖会话记录落盘。
82
235
  返回 nil 表示未启用(调用方无需处理)。
236
+ who 是持有者身份(pid / client / run_id…),写进锁文件供等待方读取;
237
+ 传零值时按本进程 pid 兜底,绝不写一个 pid<=0 的锁(那会被当成陈旧锁立刻回收)。
83
238
  */
84
- func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
239
+ func acquireWorkspaceLock(workspace string, who LockHolder) (*WorkspaceLock, error) {
85
240
  if !workspaceLockEnabled() {
86
241
  return nil, nil
87
242
  }
@@ -95,12 +250,22 @@ func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
95
250
  }
96
251
  ws, name := workspaceLockKey(workspace)
97
252
  path := filepath.Join(lockDir, name+".lock")
253
+
254
+ if who.PID <= 0 {
255
+ who.PID = os.Getpid()
256
+ }
257
+ who.Workspace = ws
258
+ if who.Since.IsZero() {
259
+ who.Since = time.Now()
260
+ }
261
+ body := encodeLock(who)
262
+
98
263
  deadline := time.Now().Add(workspaceLockWait())
99
264
  announced := false
100
265
  for {
101
266
  f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
102
267
  if err == nil {
103
- _, _ = fmt.Fprintf(f, "%d\n%d\n", os.Getpid(), time.Now().UnixNano())
268
+ _, _ = f.Write(body)
104
269
  _ = f.Close()
105
270
  return &WorkspaceLock{path: path, held: true}, nil
106
271
  }
@@ -111,16 +276,63 @@ func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
111
276
  continue
112
277
  }
113
278
  if time.Now().After(deadline) {
279
+ /* 超时的报错里带上「谁占着」—— 原来的文案只有工作空间路径,
280
+ 用户看到的是「等超时了」却不知道该去停谁(手机上那条?还是另一个窗口?)。 */
281
+ if h, ok := LockHolderFor(workspace); ok {
282
+ return nil, fmt.Errorf("等待工作空间 %s 上的上一个任务超时(%s,占用者 pid=%d%s%s),可用 %s 调整",
283
+ ws, workspaceLockWait(), h.PID, lockWhoText(h), lockSinceText(h), EnvWorkspaceLockWait)
284
+ }
114
285
  return nil, fmt.Errorf("等待工作空间 %s 上的上一个任务超时(%s),可用 %s 调整", ws, workspaceLockWait(), EnvWorkspaceLockWait)
115
286
  }
116
287
  if !announced {
117
288
  announced = true
118
- fmt.Fprintf(os.Stderr, "magic-agent: 同一工作空间已有任务在跑,等待其结束(%s)…\n", ws)
289
+ /* 同理:等待时第一句就交代「在等谁」。这一行是 stderr,
290
+ 客户端会转给界面(magic-client 的 SSE / 壳的 notice)。 */
291
+ if h, ok := LockHolderFor(workspace); ok {
292
+ fmt.Fprintf(os.Stderr, "magic-agent: 同一工作空间已有任务在跑,等待其结束(%s;占用者 pid=%d%s)…\n",
293
+ ws, h.PID, lockWhoText(h))
294
+ } else {
295
+ fmt.Fprintf(os.Stderr, "magic-agent: 同一工作空间已有任务在跑,等待其结束(%s)…\n", ws)
296
+ }
119
297
  }
120
298
  time.Sleep(workspaceLockPoll)
121
299
  }
122
300
  }
123
301
 
302
+ // lockWhoText 「谁在占着」的人话后缀:` / 客户端 com.magic.test / 手机`。
303
+ // 没有任何身份信息时返回空串(旧锁文件),调用方据此不加多余的分隔符。
304
+ func lockWhoText(h LockHolder) string {
305
+ var parts []string
306
+ if c := strings.TrimSpace(h.Client); c != "" {
307
+ parts = append(parts, "客户端 "+c)
308
+ }
309
+ switch h.Source {
310
+ case SourceMobile:
311
+ parts = append(parts, "手机")
312
+ case SourcePC:
313
+ parts = append(parts, "电脑")
314
+ }
315
+ if len(parts) == 0 {
316
+ return ""
317
+ }
318
+ return " / " + strings.Join(parts, " / ")
319
+ }
320
+
321
+ // lockSinceText 「占了多久」的人话后缀(读不出时间就给空串)。
322
+ func lockSinceText(h LockHolder) string {
323
+ if h.Since.IsZero() {
324
+ return ""
325
+ }
326
+ d := time.Since(h.Since)
327
+ if d < 0 {
328
+ d = 0
329
+ }
330
+ if d < time.Minute {
331
+ return fmt.Sprintf(",已占用 %d 秒", int(d.Seconds()))
332
+ }
333
+ return fmt.Sprintf(",已占用 %d 分", int(d.Minutes()))
334
+ }
335
+
124
336
  // reapStaleWorkspaceLock 回收陈旧锁(pid 已不在 / 文件太老);返回是否已删。
125
337
  func reapStaleWorkspaceLock(path string) bool {
126
338
  st, err := os.Stat(path)
@@ -130,12 +342,12 @@ func reapStaleWorkspaceLock(path string) bool {
130
342
  stale := time.Since(st.ModTime()) > workspaceLockStale
131
343
  if !stale {
132
344
  if b, err := os.ReadFile(path); err == nil {
133
- lines := strings.Split(strings.TrimSpace(string(b)), "\n")
134
- if len(lines) > 0 {
135
- if pid, err := strconv.Atoi(strings.TrimSpace(lines[0])); err == nil {
136
- if pid <= 0 || !agent.PIDAlive(pid) {
137
- stale = true
138
- }
345
+ /* 两种格式都认(见 decodeLock):只按旧格式解析的话,
346
+ 新格式的 JSON 首行是 `{"pid":123,...}`,Atoi 失败 → 判不出死进程 →
347
+ 一个崩溃留下的锁要等到 6 小时 stale 才回收。 */
348
+ if h := decodeLock(b); h.PID > 0 {
349
+ if !agent.PIDAlive(h.PID) {
350
+ stale = true
139
351
  }
140
352
  }
141
353
  }
@@ -147,6 +359,32 @@ func reapStaleWorkspaceLock(path string) bool {
147
359
  return true
148
360
  }
149
361
 
362
+ // noteRun 拿到锁之后再补写一次持有者身份(run_id 在取锁时还没生成)。
363
+ //
364
+ // 为什么值得多写一次文件:等待方拿到的线索里,run_id 是**唯一能直接对上
365
+ // `--sessions` 那一行**的键。只有 pid 的话,界面上只能显示一个数字,
366
+ // 用户没法点进去看「到底是谁在跑」,也没法据此决定要不要停掉它。
367
+ // 失败不影响持有(锁已经拿到了,身份信息只是附加价值)。
368
+ func (l *WorkspaceLock) noteRun(runID string) {
369
+ if l == nil || !l.held || strings.TrimSpace(runID) == "" {
370
+ return
371
+ }
372
+ cur, ok := LockHolderFor("")
373
+ b, err := os.ReadFile(l.path)
374
+ if err != nil {
375
+ return
376
+ }
377
+ h := decodeLock(b)
378
+ if h.PID != os.Getpid() {
379
+ return // 已不是自己的锁(被别人回收后重取了),不要覆盖
380
+ }
381
+ h.RunID = strings.TrimSpace(runID)
382
+ if ok && cur.RunID == h.RunID {
383
+ return // 没变化,不重复写
384
+ }
385
+ _ = os.WriteFile(l.path, encodeLock(h), 0o600)
386
+ }
387
+
150
388
  // Release 释放锁;只删自己持有的那把(内容里的 pid 对得上)。
151
389
  func (l *WorkspaceLock) Release() {
152
390
  if l == nil || !l.held {
@@ -157,8 +395,7 @@ func (l *WorkspaceLock) Release() {
157
395
  if err != nil {
158
396
  return
159
397
  }
160
- first := strings.TrimSpace(strings.SplitN(string(b), "\n", 2)[0])
161
- if first != strconv.Itoa(os.Getpid()) {
398
+ if decodeLock(b).PID != os.Getpid() {
162
399
  return
163
400
  }
164
401
  _ = os.Remove(l.path)
@@ -13,7 +13,8 @@
13
13
  // 去重、形态校验,挡掉「AI 的回答」冒充「用户的话」。
14
14
  //
15
15
  // 与 `--summarize-title`(internal/session/title.go::Summarize)的分工:那条把会话首问压成
16
- // 标题、结果落进标题表;这条只产出一组**临时**建议,不落盘、不缓存 —— 下一轮结束就重算。
16
+ // 标题、结果落进标题表;这条只产出一组**临时**建议,core 侧不落盘 —— 同一轮的重复请求
17
+ // 由 magic-client 插件层缓存(internal/client/suggest.go),下一轮问答换了内容自然重算。
17
18
  package suggest
18
19
 
19
20
  import (
@@ -51,6 +52,12 @@ type Request struct {
51
52
  Engine string
52
53
  // Model 模型标识(空 = 引擎默认)。
53
54
  Model string
55
+ // FallbackEngine / FallbackModel 用户配的备用引擎(2026-10-24)。
56
+ //
57
+ // 主引擎失败时**优先**试它,再落到内置的 llm 兜底;空 = 直接用内置兜底。
58
+ // 这样界面上「建议引擎」也能配主 / 备,与默认引擎、团队模式同一套语义。
59
+ FallbackEngine string
60
+ FallbackModel string
54
61
  // Count 最多几条(<=0 = DefaultCount)。
55
62
  Count int
56
63
  // Timeout 单次调用超时(<=0 = DefaultTimeout)。
@@ -205,6 +212,21 @@ func Generate(ctx context.Context, in Request) (Result, error) {
205
212
  if err == nil {
206
213
  return res, nil
207
214
  }
215
+ /* 用户配的备用引擎:优先于内置 llm 兜底(2026-10-24)。它更贴近用户的意图
216
+ (例如「建议用 codebuddy,额度不够就换 dsh」),llm 只是最后的保底。 */
217
+ if fb := strings.TrimSpace(in.FallbackEngine); fb != "" && !strings.EqualFold(fb, name) {
218
+ if agent.Lookup(fb) != nil {
219
+ remain := timeout - time.Since(start)
220
+ if remain < 2*time.Second {
221
+ remain = 2 * time.Second
222
+ }
223
+ fbIn := in
224
+ fbIn.Model = strings.TrimSpace(in.FallbackModel)
225
+ if fbRes, ferr := tryGenerate(cctx, fbIn, fb, remain, start); ferr == nil {
226
+ return fbRes, nil
227
+ }
228
+ }
229
+ }
208
230
  if name == DefaultEngine {
209
231
  return Result{}, err
210
232
  }
@@ -6,7 +6,9 @@ package team
6
6
  // · 计划 → 一条 notice(一行提示 + 计划文档路径),任务本身在画布上表现为
7
7
  // 后续的委派卡,不需要一张额外的「计划卡」;
8
8
  // · 每个子任务 → tool_use(name=Task) 开卡 + 带 Parent 的子事件 + tool_result 收卡;
9
- // · 降级/汇总 → notice。
9
+ // · 降级 → `fallback` 帧(2026-10-26):谁换成了谁,客户端据此把子任务标成
10
+ // 「备用作答」(以前只有一行 notice,主备到底生效没有无从判断);
11
+ // · 汇总 → notice。
10
12
  //
11
13
  // 于是客户端(三端共用同一份渲染内核)**零改动**就能画出团队模式 —— 它看起来
12
14
  // 就是主代理连续委派了几次。要加「计划总览卡」时再补新 kind,那是一次独立的
@@ -153,7 +153,11 @@ func runTask(ctx context.Context, opts Options, plan Plan, t Task, runDir string
153
153
  got.ID, got.Kind, got.Title = t.ID, t.EffectiveKind(), t.Title
154
154
  got.Role = t.Role
155
155
  got.OutputPath = OutputPath(runDir, t.ID)
156
- got.Engine, got.Model = eng.Name(), route.Model
156
+ // 引擎标签以**尝试自己记的**为准:runAttempt 在备用接上时已改成备用那一路,
157
+ // 这里若再盖回主引擎,用户就看不到「这一行是备用给的」。
158
+ if got.Engine == "" {
159
+ got.Engine, got.Model = eng.Name(), route.Model
160
+ }
157
161
  got.Attempts = attempt
158
162
  r = got
159
163
 
@@ -235,7 +239,7 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
235
239
  board.setSession(t.ID, id, "")
236
240
  }
237
241
 
238
- res, err := streamer.Stream(tctx, agent.Request{
242
+ res, fbInfo, err := agent.StreamWithFallback(tctx, eng, agent.Request{
239
243
  Engine: eng.Name(),
240
244
  Model: route.Model,
241
245
  // 角色片段追加在主系统提示词之后(见 role.go);未设角色时与原来一致。
@@ -249,7 +253,11 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
249
253
  Permission: permissionFor(opts, t),
250
254
  PermissionOptions: opts.PermissionOptions,
251
255
  OnSessionID: setSID,
252
- Messages: []agent.Message{{Role: "user", Content: prompt}},
256
+ // 主引擎只有过程、没有正文时降级到这一路的备用(额度用尽 / 单模型故障)。
257
+ // 降级发生时 agent 层已经发过 fallback 帧(下面原样交给子会话)。
258
+ FallbackEngine: route.FallbackEngine,
259
+ FallbackModel: route.FallbackModel,
260
+ Messages: []agent.Message{{Role: "user", Content: prompt}},
253
261
  }, func(ev agent.StreamEvent) {
254
262
  if ev.SessionID != "" {
255
263
  setSID(ev.SessionID)
@@ -270,6 +278,12 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
270
278
  }
271
279
  board.touch(t.ID, ev)
272
280
  })
281
+ /* 备用接上了:任务结果如实记成「谁给出的答案」(主备没生效时用户只能看到一个
282
+ 失败的子任务,分不清是没配还是配了没试)。只改展示字段,State 由下面的
283
+ err 决定 —— 备用成功时 err 必然为 nil。 */
284
+ if fbInfo.Used {
285
+ r.Engine, r.Model = fbInfo.Engine, fbInfo.Model
286
+ }
273
287
 
274
288
  if err != nil {
275
289
  r.State = TaskFailed
@@ -396,8 +410,7 @@ func completeTask(ctx context.Context, opts Options, eng agent.Engine, route Rou
396
410
  r.Engine, r.Model = eng.Name(), route.Model
397
411
  rctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
398
412
  defer cancel()
399
- runner := &agent.Runner{Engine: eng, Timeout: taskTimeout(opts)}
400
- resp, err := runner.Run(rctx, agent.Request{
413
+ resp, _, err := agent.RunWithFallback(rctx, eng, agent.Request{
401
414
  Engine: eng.Name(),
402
415
  Model: route.Model,
403
416
  SystemPrompt: systemPromptFor(opts, t),
@@ -406,8 +419,10 @@ func completeTask(ctx context.Context, opts Options, eng agent.Engine, route Rou
406
419
  Reasoning: opts.Reasoning,
407
420
  Permission: permissionFor(opts, t),
408
421
  PermissionOptions: opts.PermissionOptions,
422
+ FallbackEngine: route.FallbackEngine,
423
+ FallbackModel: route.FallbackModel,
409
424
  Messages: []agent.Message{{Role: "user", Content: prompt}},
410
- })
425
+ }, agent.RunOptions{Timeout: taskTimeout(opts)}, nil)
411
426
  r.SessionID = resp.SessionID
412
427
  r.Text = resp.Text
413
428
  collectDeliverables(TaskDir(runDir, t.ID), t.Deliverables, &r)