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
@@ -29,6 +29,7 @@ import (
29
29
  "os"
30
30
  "os/exec"
31
31
  "path/filepath"
32
+ "strconv"
32
33
  "strings"
33
34
  "time"
34
35
 
@@ -59,6 +60,48 @@ const (
59
60
  probeTimeout = 1500 * time.Millisecond
60
61
  )
61
62
 
63
+ /* SelfStateName 前台 `serve` 自己登记的那份状态文件名(落在 profile 的 DataDir 下)。
64
+ *
65
+ * ── 为什么前台 serve 也要落状态(2026-10-26 用户:「通过 magic-test 启动的无法在移动端
66
+ * 点击重启服务重启」)──
67
+ * `POST /desk/restart` 原本只认 `magic-client start` 写的那份 client.json,前台 serve
68
+ * 什么都不写,于是手机点重启一律 409「当前实例不是后台启动的」。可是 magic-test 的桥接
69
+ * (desktop/magic-client-bridge.cjs)正是用 `spawn(bin, ['serve', ...])` 起的实例 ——
70
+ * 用户手机上连的就是它,按钮却永远点不动。
71
+ *
72
+ * 修法不是把前台硬改成后台(那会丢掉「壳退出即服务退出」的托管语义),而是让 serve
73
+ * **如实登记自己是谁、怎么起的**:重启助手照这份记录停旧起新即可。
74
+ *
75
+ * ⚠️ 必须是**另一个文件名**,不能复用 client.json:那份是 `start` 的唯一实例记录,
76
+ * 两者混写会出现「start 起的实例被前台 serve 的登记覆盖」——重启时按它重建参数,
77
+ * 就会用一个根本不是 start 参数拼出来的命令行去起新实例。 */
78
+ const SelfStateName = "serve.json"
79
+
80
+ // SelfStatePath 前台 serve 的状态文件路径:<profile 数据目录>/serve.json。
81
+ //
82
+ // 按 DataDir 分而不是按 ~/.magic-client 分,理由与单实例锁一致:同机可能同时跑着
83
+ // 好几个不同应用(magic-test 的 8977、magic-agent 自己的 9100),数据目录不同、
84
+ // 令牌不同,各登记各的,谁也认领不到谁。
85
+ func SelfStatePath(dataDir string) string {
86
+ d := strings.TrimSpace(expandHome(dataDir))
87
+ if d == "" {
88
+ return ""
89
+ }
90
+ return filepath.Join(d, SelfStateName)
91
+ }
92
+
93
+ // HostAlive 托管父进程是否还在(pid <= 0 = 没人托管,恒 true)。
94
+ //
95
+ // 重启后的新实例是 detach 出去的(不然它会被旧实例的退出带走),但它的「主人」仍然是
96
+ // 拉起这台服务的桌面壳:壳退出了服务就该跟着退,否则会留下一个孤儿占着端口与单实例锁,
97
+ // 下次开壳直接「插件启动即退出」。见 service.go 的 WatchHost。
98
+ func HostAlive(pid int) bool {
99
+ if pid <= 0 {
100
+ return true
101
+ }
102
+ return agent.PIDAlive(pid)
103
+ }
104
+
62
105
  // DefaultStatePath 状态文件默认位置:~/.magic-client/client.json。
63
106
  func DefaultStatePath() string {
64
107
  if p := strings.TrimSpace(os.Getenv(EnvState)); p != "" {
@@ -178,6 +221,17 @@ type DaemonState struct {
178
221
  Addr string `json:"addr,omitempty"`
179
222
  BrowseUA string `json:"browseUA,omitempty"`
180
223
  MagicAgent string `json:"magicAgent,omitempty"`
224
+ // AnyOrigin serve 那次是否对任意 Origin 回 CORS 头(`--allow-any-origin` 或 `--lan`)。
225
+ // 重启 / 升级后原地拉起时要原样带上,否则升级完手机页面突然跨域失败。
226
+ AnyOrigin bool `json:"anyOrigin,omitempty"`
227
+ /* HostPID 托管这台服务的父进程(桌面壳 / 测试宿主)。
228
+ *
229
+ * 2026-10-26 加:重启后的新实例必须 detach(否则旧实例退出会把它一起带走),可
230
+ * detach 之后它就不在壳的进程组里了 —— 壳退出时 `r.stop()` 杀不到它,留下一个
231
+ * 孤儿占着端口与单实例锁,下次开壳就是「插件启动即退出」。
232
+ * 所以把「主人是谁」记下来,由服务自己盯着:主人没了就优雅退出(见 WatchHost)。
233
+ * 0 = 没人托管(`magic-client start` 起的常驻实例),不盯。 */
234
+ HostPID int `json:"hostPid,omitempty"`
181
235
  }
182
236
 
183
237
  // Base 本机根地址(不带令牌)。
@@ -204,6 +258,60 @@ func writeDaemonState(path string, st *DaemonState) error {
204
258
  return os.Rename(tmp, path)
205
259
  }
206
260
 
261
+ // WriteSelfState 前台 `serve` 监听成功后,把自己是谁、怎么起的登记下来。
262
+ //
263
+ // 与 writeDaemonState 同一套落盘规则(0600 原子写),但**写到 serve.json**,
264
+ // 绝不碰 `start` 独占的 client.json(理由见 SelfStateName)。
265
+ //
266
+ // 记的东西要和 `start` 那份一样全 —— 重启助手会拿它当**唯一的启动参数来源**重建
267
+ // 命令行(RestartDaemon → StartDaemon),少记一项新实例就会少一个 flag:
268
+ // 少了 Profile ⇒ 数据目录/业务模块全变;少了 Token ⇒ 手机里存的地址立刻作废;
269
+ // 少了 Addr ⇒ 换端口,手机再也连不上。
270
+ //
271
+ // 返回路径,调用方负责在退出时删掉(见 RemoveSelfState)。
272
+ func WriteSelfState(dataDir string, st *DaemonState) (string, error) {
273
+ return WriteSelfStateAt("", dataDir, st)
274
+ }
275
+
276
+ // WriteSelfStateAt 同上,但允许显式指定完整路径(`--state`)。
277
+ // explicit 空 → 用 dataDir 推出来的 SelfStatePath。
278
+ func WriteSelfStateAt(explicit, dataDir string, st *DaemonState) (string, error) {
279
+ path := strings.TrimSpace(expandHome(explicit))
280
+ if path == "" {
281
+ path = SelfStatePath(dataDir)
282
+ }
283
+ if path == "" {
284
+ return "", fmt.Errorf("没有数据目录:无法登记前台实例状态")
285
+ }
286
+ if st == nil {
287
+ return "", fmt.Errorf("状态为空")
288
+ }
289
+ if st.PID == 0 {
290
+ st.PID = os.Getpid()
291
+ }
292
+ // 状态文件自己那份路径要记对:重启助手按它找回来(见 selfDaemonState)。
293
+ st.StatePath = path
294
+ if err := writeDaemonState(path, st); err != nil {
295
+ return "", err
296
+ }
297
+ return path, nil
298
+ }
299
+
300
+ // RemoveSelfState 退出时删掉自己那份登记。
301
+ //
302
+ // 只删「pid 还是我」的那份:重启链路里旧实例退出时,新实例可能**已经**登记好了
303
+ // (它先监听成功才轮到我们收尾),这时删掉就等于把新实例弄成"没有状态"的孤儿 ——
304
+ // 手机再点重启又变成 409。判断错了会留下陈旧登记,但那比删掉活人的准。
305
+ func RemoveSelfState(path string) {
306
+ if strings.TrimSpace(path) == "" {
307
+ return
308
+ }
309
+ if st, err := ReadDaemonState(path); err == nil && st != nil && st.PID != os.Getpid() {
310
+ return // 已经不是我的了(新实例接管),别动
311
+ }
312
+ _ = os.Remove(path)
313
+ }
314
+
207
315
  // ReadDaemonState 读状态文件:不存在 → (nil, nil)。
208
316
  func ReadDaemonState(path string) (*DaemonState, error) {
209
317
  if strings.TrimSpace(path) == "" {
@@ -313,6 +421,12 @@ type StartOptions struct {
313
421
  Token string
314
422
  ProxyAddr string
315
423
  BrowseUA string
424
+ /* AnyOrigin 新实例要不要对任意 Origin 回 CORS 头。由 `restart` / 升级后重启按状态
425
+ 文件原样带回(`--lan` 时 create 自己也会开,这里不重复造条件,只是别丢)。 */
426
+ AnyOrigin bool
427
+ /* HostPID 托管父进程(见 DaemonState.HostPID)。0 = 没人托管。
428
+ `restart` 按状态文件原样带回,于是重启出来的新实例继续盯着同一个主人。 */
429
+ HostPID int
316
430
  Lan bool
317
431
  StatePath string
318
432
  LogPath string
@@ -382,6 +496,14 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
382
496
  defer logf.Close()
383
497
 
384
498
  args := []string{"serve", "--ready-file", readyPath, "--token", tok}
499
+ /* --state(2026-10-26):告诉子进程「哪份状态文件是你自己的」。
500
+ 两个用处,缺一不可:
501
+ · 退出时清理 —— 重启出来的实例由 WatchHost 收摊(宿主退出),它不走 StopDaemon,
502
+ 没人删这份登记,留着就是一个指向死 pid 的陈旧文件;
503
+ · 认领自己 —— selfDaemonState 按这条路径找回参数,重启/升级才能落到自己身上。
504
+ 不传的话子进程只知道 ready 文件,对「我那份登记在哪」一无所知(实测:重启后
505
+ serve.json 残留 pid 已死的旧记录)。 */
506
+ args = append(args, "--state", statePath)
385
507
  appendIf := func(flag, v string) {
386
508
  if v = strings.TrimSpace(v); v != "" {
387
509
  args = append(args, flag, v)
@@ -394,6 +516,12 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
394
516
  appendIf("--proxy-addr", o.ProxyAddr)
395
517
  appendIf("--browse-ua", o.BrowseUA)
396
518
  appendIf("--magic-agent", o.MagicAgent)
519
+ if o.AnyOrigin {
520
+ args = append(args, "--allow-any-origin")
521
+ }
522
+ if o.HostPID > 0 {
523
+ args = append(args, "--host-pid", strconv.Itoa(o.HostPID))
524
+ }
397
525
  if o.Lan {
398
526
  args = append(args, "--lan")
399
527
  } else {
@@ -456,6 +584,8 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
456
584
  Addr: o.Addr,
457
585
  BrowseUA: o.BrowseUA,
458
586
  MagicAgent: o.MagicAgent,
587
+ AnyOrigin: o.AnyOrigin,
588
+ HostPID: o.HostPID,
459
589
  }
460
590
  if st.PID == 0 {
461
591
  st.PID = cmd.Process.Pid
@@ -469,6 +599,40 @@ func StartDaemon(o StartOptions) (*DaemonState, error) {
469
599
  // StopOptions `stop` 的参数。
470
600
  type StopOptions struct {
471
601
  StatePath string
602
+ /* AlsoLockHolder(2026-10-26):状态文件里没有 / 指向的进程已死时,再按**单实例锁**
603
+ 里记的 pid 试一次。
604
+
605
+ 为什么必须有这一步:`magic-client serve`(前台)与旧版(不认锁、可执行文件已变成
606
+ *.stale 的那些)都**不写状态文件**,于是 `stop` 只读状态文件就永远「本来就没在跑」,
607
+ 而 8977 / 54428 上实实在在还有两个进程占着隧道 —— 用户此刻正是这个现场:
608
+ 手机连着旧的、新版起不来、`stop` 又停不掉,只能手工 kill。
609
+
610
+ `restart` 不传这个(它必须严格按状态文件重建参数,顺手停掉一个前台实例会把
611
+ 用户手上的调试会话一起干掉)。 */
612
+ AlsoLockHolder bool
613
+ /* DataDir(2026-10-26):单实例锁的位置。空 → 从状态文件所在目录推(老调用方行为)。
614
+ 与 profile 同源 —— 锁是按数据目录分的,不知道是哪份目录就不知道该停谁。 */
615
+ DataDir string
616
+ /* AppID(2026-10-26):AppID 级占用登记的位置。有了它才能停掉「同一应用换了
617
+ 数据目录在跑」的那一份 —— 那种情况下 DataDir 锁查不到它(在另一个目录)。 */
618
+ AppID string
619
+ }
620
+
621
+ // stopPID 发 SIGTERM,等 stopGrace,还在就 SIGKILL。返回是否真的停了。
622
+ func stopPID(pid int) bool {
623
+ if !agent.PIDAlive(pid) {
624
+ return false
625
+ }
626
+ _ = agent.TerminatePID(pid, false)
627
+ deadline := time.Now().Add(stopGrace)
628
+ for time.Now().Before(deadline) {
629
+ if !agent.PIDAlive(pid) {
630
+ return true
631
+ }
632
+ time.Sleep(50 * time.Millisecond)
633
+ }
634
+ _ = agent.KillPID(pid, false)
635
+ return true
472
636
  }
473
637
 
474
638
  // StopDaemon 停掉后台实例。
@@ -484,26 +648,49 @@ func StopDaemon(o StopOptions) (bool, *DaemonState, error) {
484
648
  if err != nil {
485
649
  return false, nil, err
486
650
  }
487
- if st == nil {
488
- return false, nil, nil
489
- }
490
- if !agent.PIDAlive(st.PID) {
491
- _ = os.Remove(statePath)
492
- return false, st, nil
493
- }
494
- // SIGTERM 先礼:serve 收到后会 srv.Close(),正在跑的请求有机会收尾。
495
- _ = agent.TerminatePID(st.PID, false)
496
- deadline := time.Now().Add(stopGrace)
497
- for time.Now().Before(deadline) {
498
- if !agent.PIDAlive(st.PID) {
651
+ if st == nil || !agent.PIDAlive(st.PID) {
652
+ if st != nil {
499
653
  _ = os.Remove(statePath)
500
- return true, st, nil
501
654
  }
502
- time.Sleep(50 * time.Millisecond)
655
+ /* 状态文件这条路走不通:还有一份不写状态文件的实例(前台 serve / 旧版 .stale)
656
+ 占着锁的话,用户唯一的自救方式就是它。停掉并清锁 —— 不清的话下一次启动
657
+ 会因为「锁的持有者已死」走回收路径,虽然也能起来,但日志里会多一段困惑。 */
658
+ if o.AlsoLockHolder {
659
+ /* 两条路都要查:先按数据目录(最常见),再按 AppID 登记(治「同一应用
660
+ 换了目录在跑」—— 那一份的 DataDir 锁根本不在我们要找的目录里)。 */
661
+ type lockTarget struct{ path string }
662
+ var targets []lockTarget
663
+ if p := DefaultLockPath(o.DataDir); p != "" {
664
+ targets = append(targets, lockTarget{p})
665
+ }
666
+ if p := appIDLockPath(o.AppID); p != "" && p != DefaultLockPath(o.DataDir) {
667
+ targets = append(targets, lockTarget{p})
668
+ }
669
+ for _, t := range targets {
670
+ h := ReadSingleInstanceHolder(t.path)
671
+ if h == nil || h.PID == os.Getpid() || !stopPID(h.PID) {
672
+ continue
673
+ }
674
+ _ = os.Remove(t.path)
675
+ // 顺手把它的锁也清掉:不清的话它那个目录下次启动会撞「持有者已死」。
676
+ if h.DataDir != "" {
677
+ _ = os.Remove(DefaultLockPath(h.DataDir))
678
+ }
679
+ return true, &DaemonState{
680
+ PID: h.PID, Port: h.Port, Exe: h.Exe,
681
+ }, nil
682
+ }
683
+ }
684
+ if st == nil {
685
+ return false, nil, nil
686
+ }
687
+ return false, st, nil
688
+ }
689
+ if stopPID(st.PID) {
690
+ _ = os.Remove(statePath)
691
+ return true, st, nil
503
692
  }
504
- _ = agent.KillPID(st.PID, false)
505
- _ = os.Remove(statePath)
506
- return true, st, nil
693
+ return false, st, nil
507
694
  }
508
695
 
509
696
  // RestartOptions `restart` 的参数。
@@ -512,6 +699,8 @@ type RestartOptions struct {
512
699
  // Delay 停旧实例之前等多久。服务端「自重启」用它先把 HTTP 响应写回手机
513
700
  // (响应一发完,本进程就会在 Delay 之后被 TerminatePID 带走)。
514
701
  Delay time.Duration
702
+ // DataDir 该实例的数据目录(决定单实例锁的位置)。
703
+ DataDir string
515
704
  }
516
705
 
517
706
  // RestartDaemon 按**当前实例自己的启动参数**重启:读状态文件 → 停旧实例 → 拉起新实例。
@@ -537,7 +726,7 @@ func RestartDaemon(o RestartOptions) (*DaemonState, error) {
537
726
  if o.Delay > 0 {
538
727
  time.Sleep(o.Delay)
539
728
  }
540
- if _, _, err := StopDaemon(StopOptions{StatePath: statePath}); err != nil {
729
+ if _, _, err := StopDaemon(StopOptions{StatePath: statePath, DataDir: o.DataDir}); err != nil {
541
730
  return nil, err
542
731
  }
543
732
  /* 旧进程退出到端口真正释放之间还有一小段窗口(Windows 上关句柄不是瞬时),
@@ -553,6 +742,8 @@ func RestartDaemon(o RestartOptions) (*DaemonState, error) {
553
742
  Token: prev.Token,
554
743
  ProxyAddr: prev.ProxyAddr,
555
744
  BrowseUA: prev.BrowseUA,
745
+ AnyOrigin: prev.AnyOrigin,
746
+ HostPID: prev.HostPID,
556
747
  Lan: prev.Lan,
557
748
  StatePath: statePath,
558
749
  LogPath: prev.LogPath,