magic-agent 2.6.0 → 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 (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -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)
@@ -1 +1,8 @@
1
- { "versionCode": 20600, "version": "2.6.0", "builtAt": "2026-10-25T09:20:06.000Z" }
1
+ {
2
+ "versionCode": 20603,
3
+ "version": "2.6.3",
4
+ "builtAt": "2026-10-27T07:31:48+08:00",
5
+ "sha256": "e22d607fce03a1d6a5d3fe137cd41903648b640d684422130a1da8aa394a9037",
6
+ "signer": "CN=Magic Agent, O=Magic Agent, C=CN",
7
+ "signerSha256": "bc1a3d7402c2faad0cb4855edb89b91a652bc5b37daee017568fb8dceff389ab"
8
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/npm/install.js CHANGED
@@ -51,6 +51,17 @@ for (const b of BINARIES) {
51
51
  );
52
52
  // Windows 上没有 POSIX 权限位,chmodSync 会抛 EPERM/EINVAL,别碰。
53
53
  if (target.goos !== "windows") fs.chmodSync(b.out, 0o755);
54
+ /* darwin 必须补 adhoc 签名(2026-10-26 实测):go build 的产物虽带 linker-signed
55
+ 签名,但覆盖已有文件后内核缓存的仍是旧签名页 → 启动瞬间
56
+ SIGKILL (Code Signature Invalid),零输出、退出码 137。
57
+ 这条兜底路径与 npm/build.js 同理,漏了它「现场编译」出来的包一跑就被杀。 */
58
+ if (target.goos === "darwin") {
59
+ try {
60
+ execFileSync("codesign", ["--force", "--sign", "-", b.out], { stdio: ["ignore", "ignore", "pipe"] });
61
+ } catch (err) {
62
+ process.stderr.write(`\n ⚠️ ${b.name} 签名失败,产物可能一启动就被系统 SIGKILL\n`);
63
+ }
64
+ }
54
65
  console.log("ok");
55
66
  } catch (err) {
56
67
  console.log(`失败: ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magic-agent",
3
- "version": "2.6.0",
3
+ "version": "2.6.3",
4
4
  "description": "专业的 agent CLI 代理工具 - 统一 claude / codebuddy / trae / llm / codex / openclaw 引擎",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -26,7 +26,9 @@
26
26
  "check:conv-comp": "node tests/check-conv-component.mjs",
27
27
  "check:conv-queue": "node tests/check-conv-queue.mjs",
28
28
  "check:follow": "node tests/check-follow-retry.mjs",
29
- "check:req-shot": "node tests/check-req-shot.mjs"
29
+ "check:req-shot": "node tests/check-req-shot.mjs",
30
+ "apk:sign": "bash tools/apk-sign.sh sign",
31
+ "check:apk": "bash tools/apk-sign.sh verify"
30
32
  },
31
33
  "files": [
32
34
  "npm/bin/",
@@ -51,5 +53,8 @@
51
53
  "darwin",
52
54
  "linux",
53
55
  "win32"
54
- ]
56
+ ],
57
+ "dependencies": {
58
+ "playwright-core": "^1.64.1"
59
+ }
55
60
  }