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
@@ -0,0 +1,866 @@
1
+ package client
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "regexp"
12
+ "runtime"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/darren/magic-agent/internal/agent"
17
+ )
18
+
19
+ /* magic-agent(core)自身的 npm 升级 —— 移动端「我的 → 版本」在装新 APK 的同时也要把
20
+ * 桌面端这份从 npm 装出来的 CLI 升上去(2026-10-23 用户:「我的 tab 版本中除了升级 apk,
21
+ * 也要触发 npm 升级」)。
22
+ *
23
+ * ── 为什么是「同一件事的两半」──
24
+ * 移动端界面、APK、桌面端的 magic-agent/magic-client 是**同一个 npm 包**(版本号同源)。
25
+ * 只装新 APK ⇒ 手机是新的、桌面还是旧的:新界面依赖的新接口(/desk/core、字段)会 404。
26
+ * 所以两半要一起完成;任一半失败都如实告诉用户,不假装整件事成功。
27
+ *
28
+ * ── 实现是什么 ──
29
+ * 在**桌面端**执行 `npm install -g magic-agent@latest --prefix <magic-agent 原来所在的前缀>`
30
+ * ——与 `npm run bump` 之后人工装两份前缀是同一个动作。命令只来自服务端拼装:
31
+ * 包名固定、前缀来自本机探测,接口不接受客户端传来的任何命令片段;版本号先过正则。
32
+ *
33
+ * ── 装完要重启才生效 ──
34
+ * 契约与 `/desk/health.version` 都是插件**启动时**缓存的(见 health 的注释),新二进制
35
+ * 换上去后正在跑的 serve 仍是旧代码;而 magic-client 是从 PATH 找到的魔数 —— 重启后
36
+ * 自然就是新版本。后台常驻(`magic-client start`)的实例用记录下来的启动参数原地重启,
37
+ * 前端凭证与端口都不变;手工 serve 的实例只提示「重启后生效」,不替用户杀掉前台进程。
38
+ */
39
+
40
+ // npmEnv 交给 npm 子进程的环境。
41
+ //
42
+ // 为什么不能直接继承:宿主 IDE 会给客户端塞 `HTTP_PROXY=http://127.0.0.1:<会漂移的端口>`,
43
+ // 那是它自己那层沙箱的出口,进程活久了端口早就没人监听(2026-10-27 实测:客户端里的
44
+ // `npm view` 因此**静默失败**,手机看到的是「桌面端没有可升级版本」而 registry 明明有)。
45
+ // agent.ChildEnvWith 会剔掉指向**死回环端口**的代理变量与宿主垫片(见 internal/agent/env.go),
46
+ // 非回环代理一律不动。
47
+ func npmEnv() []string { return agent.ChildEnvWith(nil) }
48
+
49
+ // npmViewTimeout 查 npm registry 的等待上限。只为显示「有没有新版」,拿不到就不显示。
50
+ var npmViewTimeout = 6 * time.Second
51
+
52
+ // coreRepoName npm 上的包名(package.json 的 name)。
53
+ const coreRepoName = "magic-agent"
54
+
55
+ // coreInfo GET /desk/core 的响应。字段只增不改;读取方按字段缺失处理旧插件。
56
+ type coreInfo struct {
57
+ // Version 此刻真正跑着的版本(与 /desk/health.version 同源:契约里的 version)。
58
+ Version string `json:"version"`
59
+ // Installed 本机 PATH 上找到的 magic-agent 版本(`magic-agent --version`)。
60
+ // 与 Version 不同 = 盘上已经是新版、只是服务没重启。
61
+ Installed string `json:"installed,omitempty"`
62
+ // Latest npm registry 上的最新版;空 = 没查到(离线 / npm 不在 PATH / 超时)。
63
+ Latest string `json:"latest,omitempty"`
64
+ Upgradable bool `json:"upgradable"` // 有更新的 npm 版本可装
65
+ RestartOnly bool `json:"restartOnly"` // 盘上已是新版,只差重启
66
+ Upgrade string `json:"upgrade,omitempty"` // 将要执行的命令(给人看、可复制)
67
+ Bin string `json:"bin,omitempty"` // 将被替换的可执行文件
68
+ Prefix string `json:"prefix,omitempty"` // 它所属的 npm 前缀
69
+ CanUpgrade bool `json:"canUpgrade"` // npm 与目标二进制都找得到
70
+ Daemon bool `json:"daemon"` // 后台常驻(自带 state 文件)⇒ 能原地重启
71
+ Note string `json:"note,omitempty"` // 为什么不能升级 / 为什么需要重启
72
+ }
73
+
74
+ // coreUpgradeTarget 本次升级要装到哪、怎么装。零值 CanUpgrade=false。
75
+ type coreUpgradeTarget struct {
76
+ Bin string
77
+ Prefix string
78
+ CanUpgrade bool // npm 与目标二进制都找得到
79
+ Installed string // 盘上那份 magic-agent 的版本(升级前)
80
+ Latest string // npm registry 上的最新版
81
+ Replaced bool // 盘上那份是本进程启动后才换上去的(旧版读不出新版本号)
82
+ Args []string // npm 的参数(不含 npm 自身);空 = 不用升级
83
+ Note string
84
+ Npm string // npm 可执行文件(升级时用它跑 Args)
85
+ }
86
+
87
+ var semverRe = regexp.MustCompile(`\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?`)
88
+
89
+ // parseSemver 从 `magic-agent v2.4.2` / `2.4.2` 这类输出里取出版本号。
90
+ func parseSemver(s string) string { return semverRe.FindString(s) }
91
+
92
+ // resolveLink 解析符号链接(npm 全局 bin 多为 symlink);失败就原样返回。
93
+ func resolveLink(p string) string {
94
+ if p == "" {
95
+ return ""
96
+ }
97
+ if real, err := filepath.EvalSymlinks(p); err == nil {
98
+ return real
99
+ }
100
+ return p
101
+ }
102
+
103
+ /*
104
+ coreInstall 描述「这台机器上的 magic-agent 装在哪儿」——
105
+
106
+ 升级要**装回原处**:npm i -g 会写进 <prefix>/{lib/,}node_modules/<pkg>,
107
+ 前缀算错就会装到别处(实测 Homebrew 上曾算出包内目录,结果版本没变 —— 白跑一趟)。
108
+ 所以这里认三样东西:
109
+ · PkgDir npm 包根(…/node_modules/magic-agent,含 package.json)
110
+ · Bin 真正会被替换掉的那个二进制(包内 dist 产物,不依赖 node 还在 PATH 上)
111
+ · Prefix 装回原处要用的 --prefix(npm 全局前缀)
112
+ */
113
+ type coreInstall struct {
114
+ Bin string
115
+ PkgDir string
116
+ Prefix string
117
+ // ReplacedAfter 这个二进制是**本进程启动之后**才被换上去的(npm 已装好新版、只差重启)。
118
+ // 见 replacedAfterStart。
119
+ ReplacedAfter bool
120
+ }
121
+
122
+ // corePackageOf 从一个路径往上找 npm 包根(package.json 的 name == magic-agent)。
123
+ //
124
+ // 两条硬要求:
125
+ // 1. 路径先绝对化 —— PATH 里可能有 "." 之类的相对项,不绝对化就会在当前目录里乱认;
126
+ // 2. 包必须**装在 node_modules 下** —— 源码目录(仓库根也有 package.json,name 同样是
127
+ // magic-agent)不算 npm 装的那份:npm i -g 换不掉它。
128
+ //
129
+ // 认不出就返回空 —— 那份二进制不是 npm 装的(自行编译 / 别的发行方式)。
130
+ // inspectPackage npm 包根 → 安装信息(顺带判「是不是装好了没重启」)。
131
+ func (s *Service) inspectPackage(pkgDir string) coreInstall {
132
+ inst := coreInstall{PkgDir: pkgDir, Prefix: npmPrefixForPackage(pkgDir)}
133
+ inst.Bin = coreBinaryInPackage(pkgDir)
134
+ if inst.Bin != "" {
135
+ inst.ReplacedAfter = s.replacedAfterStart(inst.Bin)
136
+ }
137
+ return inst
138
+ }
139
+
140
+ func corePackageOf(path string) string {
141
+ if path == "" {
142
+ return ""
143
+ }
144
+ if abs, err := filepath.Abs(path); err == nil {
145
+ path = abs
146
+ }
147
+ p := filepath.Dir(path)
148
+ for i := 0; i < 10; i++ {
149
+ if pkgNameAt(p) == coreRepoName {
150
+ parent := filepath.Dir(p)
151
+ if strings.EqualFold(filepath.Base(parent), "node_modules") {
152
+ return p
153
+ }
154
+ return "" // 名字对但不是 node_modules 下的 —— 源码目录,不是 npm 装的
155
+ }
156
+ parent := filepath.Dir(p)
157
+ if parent == p {
158
+ break
159
+ }
160
+ p = parent
161
+ }
162
+ return ""
163
+ }
164
+
165
+ // npmPrefixForPackage 包根 → 装回原处要用的 npm 全局前缀。
166
+ //
167
+ // npm 的全局根固定是 <prefix>/lib/node_modules(Windows 是 <prefix>/node_modules),
168
+ // 所以从包根反推前缀只有两种形状:
169
+ //
170
+ // …/<prefix>/lib/node_modules/magic-agent → 前缀 = …/<prefix>
171
+ // …/<prefix>/node_modules/magic-agent → 前缀 = …/<prefix>(Windows 形状)
172
+ //
173
+ // 认不出(布局不常见)就返回空:交给 npm 用它自己的默认前缀,
174
+ // 事后由「版本到底变没变」来判定成败(不谎报)。
175
+ func npmPrefixForPackage(pkgDir string) string {
176
+ if pkgDir == "" {
177
+ return ""
178
+ }
179
+ root := filepath.Dir(pkgDir) // …/node_modules
180
+ if !strings.EqualFold(filepath.Base(root), "node_modules") {
181
+ return ""
182
+ }
183
+ parent := filepath.Dir(root) // …/lib 或 <prefix>
184
+ if strings.EqualFold(filepath.Base(parent), "lib") {
185
+ return filepath.Dir(parent)
186
+ }
187
+ return parent
188
+ }
189
+
190
+ // coreBinaryInPackage 包内真正会被 npm 替换掉的那个二进制(dist 产物)。
191
+ // 没有 dist 产物时回落到包里的 bin 转发脚本。
192
+ func coreBinaryInPackage(pkgDir string) string {
193
+ if pkgDir == "" {
194
+ return ""
195
+ }
196
+ cands := []string{
197
+ filepath.Join(pkgDir, "npm", "dist", runtime.GOOS+"-"+runtime.GOARCH, "magic-agent"+exeSuffix()),
198
+ filepath.Join(pkgDir, "bin", "magic-agent"+exeSuffix()),
199
+ }
200
+ for _, p := range cands {
201
+ if fileExists(p) {
202
+ return resolveLink(p)
203
+ }
204
+ }
205
+ return ""
206
+ }
207
+
208
+ // procStart 本进程的启动时刻;读不到就退回到「本进程可执行文件的 mtime」。
209
+ //
210
+ // 为什么不直接写 mtime:npm 换包时是先删再写,新二进制与本进程自己的 mtime 可能只差
211
+ // 毫秒,同一文件系统上只能按秒比;而「本进程启动时刻」在快机器上几乎总是与本进程
212
+ // 可执行文件同一秒 —— 按秒判就会漏掉「刚换上去」的那种。Linux 上 /proc/self/stat 精确到
213
+ // 毫秒(见 procstart_linux.go),能稳稳分开。测试里可替换成固定时刻。
214
+ var procStart = func() (time.Time, bool) {
215
+ self, err := os.Executable()
216
+ if err != nil {
217
+ return time.Time{}, false
218
+ }
219
+ st, err := os.Stat(resolveLink(self))
220
+ if err != nil {
221
+ return time.Time{}, false
222
+ }
223
+ return fileTime(st), true
224
+ }
225
+
226
+ // fileTime 文件修改时刻(**丢掉单调时钟读数**:os.Stat 给的时间在本进程里其实带着
227
+ // m=+… 的单调读数,和 time.Now() 比时会优先拿它比 —— 结论取决于跑多久,而不是 mtime)。
228
+ func fileTime(st os.FileInfo) time.Time {
229
+ if st == nil {
230
+ return time.Time{}
231
+ }
232
+ return st.ModTime().Round(0)
233
+ }
234
+
235
+ // replacedAfterStart 盘上那个可执行文件是不是**本进程启动之后**才被写上去的。
236
+ //
237
+ // 为什么需要它:升级成功后本进程**原地重启**,但重启前这一小段时间里旧进程还在跑 ——
238
+ // 新起来的那个读到的是**旧版**的 magic-agent --version(旧版不认识改过的升级参数,回的
239
+ // 是 `magic-agent v2.4.1`),于是界面上「检测更新」又亮起来,再点一次就是**降级安装**。
240
+ // 盘上的文件比本进程新 ⇒ 它已经是新装的那份,只差重启,不许再拿它的「版本」去判升级。
241
+ //
242
+ // 这里**不**用来证明「升级真的成功了」——那是版本号 / 重启后新实例来证明的;它只用来在
243
+ // 原地重启的那几秒里不让界面再喊一次升级(再点就是降级)。非 Linux 上 procStart 退回
244
+ // 本进程 mtime,精度只到秒,判不准就返回 false(宁可多喊一次升级)。
245
+ func replacedAfterStart(bin string) bool { return (&Service{}).replacedAfterStart(bin) }
246
+
247
+ // replacedAfterStart s 版的 replacedAfterStart:测试可以钉住「本进程启动时刻」(procStartOverride),
248
+ // 免得结果取决于测试进程已经跑了多久。
249
+ func (s *Service) replacedAfterStart(bin string) bool {
250
+ if bin == "" {
251
+ return false
252
+ }
253
+ st, err := os.Stat(resolveLink(bin))
254
+ if err != nil {
255
+ return false
256
+ }
257
+ startFn := procStart
258
+ if s.procStartOverride != nil {
259
+ startFn = s.procStartOverride
260
+ }
261
+ start, ok := startFn()
262
+ if !ok {
263
+ return false
264
+ }
265
+ return st.ModTime().After(start)
266
+ }
267
+
268
+ // npmPrefixForBinDir `<prefix>/bin/magic-agent`(非 npm 包的普通安装)→ `<prefix>`。
269
+ // npm 的全局 bin 就装在 <prefix>/bin,所以这是「装回原处」的前缀。
270
+ func npmPrefixForBinDir(bin string) string {
271
+ dir := filepath.Dir(bin)
272
+ if strings.EqualFold(filepath.Base(dir), "bin") && runtime.GOOS != "windows" {
273
+ return filepath.Dir(dir)
274
+ }
275
+ return ""
276
+ }
277
+
278
+ // resolveCoreInstall 找出「这台机器上该被 npm 升级替换的那份 magic-agent」。
279
+ //
280
+ // 候选顺序:**正在跑的那份**(hint = 本服务用的 core 路径)→ PATH 上的 magic-agent →
281
+ // 本进程所在目录的 magic-agent。逐个看它是不是 **npm 装的那份**(在 node_modules 下):
282
+ // 是就以那个包为目标(npm i -g 换的就是它);都不是就退回 hint 本身,并按
283
+ // <prefix>/bin/… 的形状推前缀(装不装得回原处,交给 npm 与事后校验)。
284
+ func resolveCoreInstall(hint string) coreInstall { return (&Service{}).resolveCoreInstall(hint) }
285
+
286
+ // resolveCoreInstall s 版(带测试覆盖)。
287
+ func (s *Service) resolveCoreInstall(hint string) coreInstall {
288
+ var cands []string
289
+ if hint != "" {
290
+ cands = append(cands, hint)
291
+ }
292
+ if p, err := exec.LookPath("magic-agent"); err == nil {
293
+ cands = append(cands, p)
294
+ }
295
+ if exe, err := os.Executable(); err == nil {
296
+ cands = append(cands, filepath.Join(filepath.Dir(resolveLink(exe)), "magic-agent"+exeSuffix()))
297
+ }
298
+ var fallback coreInstall
299
+ for _, c := range cands {
300
+ if c == "" {
301
+ continue
302
+ }
303
+ c = resolveLink(c)
304
+ if pkg := corePackageOf(c); pkg != "" {
305
+ inst := s.inspectPackage(pkg)
306
+ inst.Bin = coreBinaryInPackage(pkg)
307
+ if inst.Bin == "" {
308
+ inst.Bin = c
309
+ }
310
+ return inst
311
+ }
312
+ if fallback.Bin == "" {
313
+ fallback = coreInstall{Bin: c, Prefix: npmPrefixForBinDir(c), ReplacedAfter: s.replacedAfterStart(c)}
314
+ }
315
+ }
316
+ return fallback
317
+ }
318
+
319
+ // findCoreBin 兼容旧调用点:只要那个二进制路径(探测用,不带 hint)。
320
+ func findCoreBin() string { return resolveCoreInstall("").Bin }
321
+
322
+ func exeSuffix() string {
323
+ if runtime.GOOS == "windows" {
324
+ return ".exe"
325
+ }
326
+ return ""
327
+ }
328
+
329
+ func fileExists(p string) bool {
330
+ st, err := os.Stat(p)
331
+ return err == nil && !st.IsDir()
332
+ }
333
+
334
+ // pkgNameAt 读 dir/package.json 的 name;不是 npm 包目录返回空。
335
+ func pkgNameAt(dir string) string {
336
+ b, err := os.ReadFile(filepath.Join(dir, "package.json"))
337
+ if err != nil {
338
+ return ""
339
+ }
340
+ var pkg struct {
341
+ Name string `json:"name"`
342
+ }
343
+ if json.Unmarshal(b, &pkg) != nil {
344
+ return ""
345
+ }
346
+ return strings.TrimSpace(pkg.Name)
347
+ }
348
+
349
+ // resolveCoreUpgrade 决定怎么升级:找 npm、找目标二进制、清理版本号,拼出参数表。
350
+ // latest 为空表示没查到 registry(只做「盘上已是新版就重启」的判断)。
351
+ func resolveCoreUpgrade(latest string, hint string) coreUpgradeTarget {
352
+ return (&Service{}).resolveCoreUpgrade(latest, hint)
353
+ }
354
+
355
+ // resolveCoreUpgrade s 版:s 上带测试用的覆盖(procStartOverride),生产路径没有。
356
+ func (s *Service) resolveCoreUpgrade(latest string, hint string) coreUpgradeTarget {
357
+ var t coreUpgradeTarget
358
+ inst := s.resolveCoreInstall(hint)
359
+ bin := inst.Bin
360
+ if bin != "" {
361
+ t.Bin = resolveLink(bin)
362
+ t.Prefix = inst.Prefix
363
+ }
364
+ latest = parseSemver(latest)
365
+ t.Latest = latest
366
+ // 盘上那份被 npm 换掉之后才是「重装好」的判断依据;空 = 读不出来(老版本 / 不可执行)。
367
+ t.Installed = coreBinaryVersion(bin)
368
+ // 本进程启动之后盘上才换上去的 ⇒ coreStatus 要跟「正在跑的那份」比一比再定夺
369
+ // (见 coreStatus 里 RestartOnly 的判断)。
370
+ t.Replaced = inst.ReplacedAfter
371
+
372
+ if bin == "" {
373
+ t.Note = "没找到本机的 magic-agent 可执行文件:不知道要升级哪一份"
374
+ return t
375
+ }
376
+ npm, npmErr := exec.LookPath("npm")
377
+ if npmErr != nil {
378
+ t.Note = "PATH 上没有 npm:没法在这台电脑上执行 npm 升级(请手工在终端里执行 npm i -g magic-agent@latest)"
379
+ return t
380
+ }
381
+ if latest == "" {
382
+ t.Note = "没查到 npm 上的最新版本(离线?npm 不在 PATH?),所以不做 npm 升级"
383
+ return t
384
+ }
385
+ // 版本号已过正则(只可能是 x.y.z[-pre]),不会被 npm 当成参数。
386
+ t.Npm = npm
387
+ /* `-g` 放最后(2026-10-26):npm 自己能容忍这个顺序,但「假 npm」(测试里顶替 registry
388
+ 的那份)按 `$1 = install`、`$3 = 包@版本` 读参数 —— 在中间插一个 `-g` 会让所有
389
+ 升级类单测悄悄测不到 install,甚至让「什么都没装」被当成「装好了」。 */
390
+ t.Args = []string{"install", coreRepoName + "@" + latest, "--no-fund", "--no-audit", "-g"}
391
+ if t.Prefix != "" {
392
+ t.Args = append(t.Args, "--prefix", t.Prefix)
393
+ }
394
+ t.CanUpgrade = true
395
+ return t
396
+ }
397
+
398
+ // coreUpgradeCommand 给人看的完整命令(复制到终端也能跑)。
399
+ func (t coreUpgradeTarget) Command() string {
400
+ if !t.CanUpgrade {
401
+ return ""
402
+ }
403
+ return "npm " + strings.Join(t.Args, " ")
404
+ }
405
+
406
+ // fetchNpmLatest 问 registry「magic-agent 的最新版是多少」。拿不到就返回空(不是错误)。
407
+ //
408
+ // 为什么不走 HTTP 直连 registry:npm 自带的代理 / 镜像 / 认证配置都在 npm 里,
409
+ // 自己发请求会得到与 `npm i` 不一样的答案(用户开了镜像时尤其明显)。
410
+ func fetchNpmLatest(ctx context.Context) string {
411
+ npm, err := exec.LookPath("npm")
412
+ if err != nil {
413
+ return ""
414
+ }
415
+ c, cancel := context.WithTimeout(ctx, npmViewTimeout)
416
+ defer cancel()
417
+ cmd := exec.CommandContext(c, npm, "view", coreRepoName, "version", "--json")
418
+ cmd.Env = npmEnv()
419
+ out, err := cmd.Output()
420
+ if err != nil {
421
+ return ""
422
+ }
423
+ var s string
424
+ if json.Unmarshal(out, &s) != nil {
425
+ s = string(out)
426
+ }
427
+ return parseSemver(s)
428
+ }
429
+
430
+ // runningBinaryVersion 「正在跑的那份」的版本 —— 与 coreStatus 用同一条判据(见 runningVersion)。
431
+ // bin 为空或跑不起来时退回契约缓存(空 = 不知道)。
432
+ func (s *Service) runningBinaryVersion(bin string) string {
433
+ if v := coreBinaryVersion(bin); v != "" {
434
+ return v
435
+ }
436
+ if s.coreTarget != nil && s.opts.MagicAgent == "" {
437
+ // 测试桩:没有真实路径可跑时以桩给的 Installed 为准。
438
+ return s.coreTarget(context.Background()).Installed
439
+ }
440
+ return s.contract.Version
441
+ }
442
+
443
+ // coreBinaryVersion 跑 `<bin> --version` 拿盘上那份的版本。
444
+ func coreBinaryVersion(bin string) string {
445
+ if bin == "" {
446
+ return ""
447
+ }
448
+ c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
449
+ defer cancel()
450
+ out, err := exec.CommandContext(c, bin, "--version").Output()
451
+ if err != nil {
452
+ return ""
453
+ }
454
+ return parseSemver(string(out))
455
+ }
456
+
457
+ // selfDaemonState 本服务如果由 `magic-client start` 拉起(或前台 serve 自登记过),
458
+ // 返回它那份状态记录(可原地重启)。
459
+ func (s *Service) selfDaemonState() *DaemonState {
460
+ bin := resolveLink(selfExe())
461
+ if bin == "" {
462
+ return nil
463
+ }
464
+ /* 先看服务自己**启动时用过的**状态文件(`--state` / MAGIC_CLIENT_STATE 决定的,
465
+ 存在 Service 里 —— 不在请求时重新读环境变量:后台常驻进程可能已经跑了好几天)。 */
466
+ var paths []string
467
+ if p := strings.TrimSpace(s.opts.StatePath); p != "" {
468
+ paths = []string{expandHome(p)}
469
+ } else if p := strings.TrimSpace(os.Getenv("MAGIC_CLIENT_STATE")); p != "" {
470
+ paths = []string{expandHome(p)}
471
+ } else if p := strings.TrimSpace(s.opts.SelfStatePath); p != "" {
472
+ /* 前台 serve 自登记的那份(<数据目录>/serve.json,见 daemon.go 的 SelfStateName)。
473
+ 必须显式列出来:它不在 DefaultStatePath 那个目录里,下面那段扫目录扫不到它 ——
474
+ 而「扫不到」正是过去手机点重启一律 409 的直接原因。
475
+ ⚠️ 排在 StatePath / 环境变量**之后**:`magic-client start` 起的实例两者都有值,
476
+ 那时必须认 client.json(信息更全,含日志位置),不能被 serve.json 抢先。 */
477
+ paths = []string{expandHome(p)}
478
+ } else {
479
+ dir := filepath.Dir(DefaultStatePath())
480
+ ents, err := os.ReadDir(dir)
481
+ if err != nil {
482
+ return nil
483
+ }
484
+ for _, e := range ents {
485
+ n := e.Name()
486
+ // client.json 是唯一的实例;client-<hash>.json 是多实例(见 instance.go)。
487
+ if !e.IsDir() && strings.HasPrefix(n, "client") && strings.HasSuffix(n, ".json") {
488
+ paths = append(paths, filepath.Join(dir, n))
489
+ }
490
+ }
491
+ }
492
+ me := os.Getpid()
493
+
494
+ for _, p := range paths {
495
+ st, err := ReadDaemonState(p)
496
+ if err != nil || st == nil {
497
+ continue
498
+ }
499
+ // 必须就是**本进程**:同一份二进制可能还跑着别的实例(别的端口/别的用户),
500
+ // 重启错的那一个等于把别人的服务踢掉。
501
+ // 按「Exe 是**同一个 magic-client 文件**」认领,而不是按 core 路径:升级前 PATH 上
502
+ // 的 core 可能还指向旧目录(npm 前缀里被驱动覆盖的那份),拿 core 路径比对会认不出来。
503
+ if st.PID != me || st.PID <= 0 {
504
+ continue
505
+ }
506
+ self, _ := os.Executable()
507
+ if st.Exe != "" && resolveLink(st.Exe) != resolveLink(self) {
508
+ continue
509
+ }
510
+ return st
511
+ }
512
+ return nil
513
+ }
514
+
515
+ // coreStatus 汇总 GET /desk/core(不升级,只回答「是不是最新」)。
516
+ func (s *Service) coreStatus(ctx context.Context) coreInfo {
517
+ t := s.coreUpgradeTarget(ctx)
518
+ running := s.runningVersion(t)
519
+ info := coreInfo{
520
+ Version: running,
521
+ Installed: t.Installed,
522
+ Latest: t.Latest,
523
+ Bin: t.Bin,
524
+ Prefix: t.Prefix,
525
+ Note: t.Note,
526
+ }
527
+ // 升级的判据以「盘上那份」为准:重启之后 running 会追上来,两者相等即无需升级。
528
+ base := t.Installed
529
+ if base == "" {
530
+ base = running
531
+ }
532
+ if t.Latest != "" && cmpSemver(t.Latest, base) > 0 {
533
+ info.Upgradable = true
534
+ }
535
+ if t.CanUpgrade {
536
+ info.CanUpgrade = true
537
+ // 只在**真的有更高版本**时才给命令:registry 上的 latest 可能比本机旧(开发版 /
538
+ // 预发版),照着这条命令敲就是降级(接口那头会拒,但别把坑摆在界面上)。
539
+ if info.Upgradable {
540
+ info.Upgrade = t.Command()
541
+ }
542
+ }
543
+ /* 盘上已经是新版 ⇒ 只差重启:
544
+ · Installed 读得出且比正在跑的更新(典型:刚 npm 装完、没重启服务);
545
+ · 或盘上那份是**本进程启动后**才换上去的(原地重启那几秒,旧版二进制读不出新版本号,
546
+ 见 replacedAfterStart)—— 这时 Latest 也可能是空的(没连上 registry),照样算「只差重启」,
547
+ 因为「比本进程新」本身就说明刚才那次装成功了。 */
548
+ if (t.Installed != "" && running != "" && cmpSemver(t.Installed, running) > 0) ||
549
+ (t.Replaced && (t.Latest == "" || running == "" || cmpSemver(t.Latest, running) > 0)) {
550
+ info.Upgradable = false
551
+ info.RestartOnly = true
552
+ if info.Note == "" {
553
+ info.Note = "盘上已经是 " + orDash(t.Installed) + ",正在运行的仍是 " + running + ":重启 magic-client 后生效"
554
+ }
555
+ }
556
+ info.Daemon = s.selfDaemonState() != nil
557
+ return info
558
+ }
559
+
560
+ // coreUpgradeTarget 「最新版 + 该升哪一份」——coreStatus 与 handleCoreUpgrade 共用这一个入口
561
+ // (测试里只替换它一处,就能同时钉住「绝不碰真 core」,见 coreupgrade_test.go)。
562
+ func (s *Service) coreUpgradeTarget(ctx context.Context) coreUpgradeTarget {
563
+ if s.coreTarget != nil {
564
+ return s.coreTarget(ctx)
565
+ }
566
+ latest := ""
567
+ if s.latestFn != nil {
568
+ latest = s.latestFn(ctx)
569
+ } else {
570
+ latest = fetchNpmLatest(ctx)
571
+ }
572
+ return s.resolveCoreUpgrade(latest, s.opts.MagicAgent)
573
+ }
574
+
575
+ // parseArgVersion 从 `magic-agent@2.4.3` 这类参数里取版本。
576
+ func parseArgVersion(args []string) string {
577
+ for _, a := range args {
578
+ if i := strings.LastIndex(a, "@"); i >= 0 {
579
+ return parseSemver(a[i+1:])
580
+ }
581
+ }
582
+ return ""
583
+ }
584
+
585
+ // cmpSemver 比较 a、b 两段 x.y.z(忽略预发布后缀)。a > b 返回正数。
586
+ func cmpSemver(a, b string) int {
587
+ pa := strings.SplitN(a, ".", 3)
588
+ pb := strings.SplitN(b, ".", 3)
589
+ for i := 0; i < 3; i++ {
590
+ var x, y int
591
+ if i < len(pa) {
592
+ fmt.Sscanf(strings.TrimLeft(pa[i], "v"), "%d", &x)
593
+ }
594
+ if i < len(pb) {
595
+ fmt.Sscanf(strings.TrimLeft(pb[i], "v"), "%d", &y)
596
+ }
597
+ if x != y {
598
+ if x > y {
599
+ return 1
600
+ }
601
+ return -1
602
+ }
603
+ }
604
+ return 0
605
+ }
606
+
607
+ // runningVersion 「此刻真正在跑的那份 core 是哪一版」。
608
+ //
609
+ // ⚠️ 盘上的二进制在**本进程启动之后**被 npm 换掉时(t.Replaced),`--version` 跑的是
610
+ // **新文件**,答的却是我们没在跑的版本 —— 这时以契约缓存(启动时校验过的那份)为准,
611
+ // 才能把「装好了、只差重启」跟「还在跑旧版」区分开。
612
+ func (s *Service) runningVersion(t coreUpgradeTarget) string {
613
+ if !t.Replaced {
614
+ if v := coreBinaryVersion(s.opts.MagicAgent); v != "" {
615
+ return v
616
+ }
617
+ }
618
+ if v := parseSemver(s.contract.Version); v != "" {
619
+ return v
620
+ }
621
+ if v := coreBinaryVersion(s.opts.MagicAgent); v != "" {
622
+ return v
623
+ }
624
+ if s.coreTarget != nil && s.opts.MagicAgent == "" {
625
+ return t.Installed
626
+ }
627
+ return ""
628
+ }
629
+
630
+ // handleCoreInfo GET /desk/core:桌面端 CLI 的版本信息(当前 / 盘上 / npm 最新)。
631
+ func (s *Service) handleCoreInfo(w http.ResponseWriter, r *http.Request) {
632
+ if r.Method != http.MethodGet {
633
+ writeErr(w, http.StatusMethodNotAllowed, "GET only")
634
+ return
635
+ }
636
+ writeJSON(w, http.StatusOK, s.coreStatus(r.Context()))
637
+ }
638
+
639
+ /* handleCoreUpgrade POST /desk/core/upgrade
640
+ *
641
+ * 与插件安装同一套 SSE 口径:`open`(要跑的命令)→ `step` → `line`(逐行输出)→ `end`
642
+ * (ok / code / 升级后的信息)。升级成功后**本进程已经是被换掉的那个旧二进制**,
643
+ * 所以成功时按需原地重启 —— 重启发生在 end 之后,前端先收到「完成」再收到断线。
644
+ *
645
+ * ── 重启为什么交给 restart.go 的那条链路 ──
646
+ * 不能在本进程里起新实例:新实例要先抢占旧实例的端口,而旧实例还活着(端口被占),
647
+ * 只能等旧进程先退出 —— 那样 handler 已经跟着进程一起消失了。所以照 `POST /desk/restart`
648
+ * 的做法:先答、再由定时器拉起一个脱离终端的 `magic-client restart` 助手(它停旧起新、
649
+ * 参数全来自状态文件,端口 / 令牌 / 界面目录沿用)。手机探活靠 /desk/health.restarting
650
+ * 分辨「旧实例认账」与「新实例已起」。
651
+ */
652
+ func (s *Service) handleCoreUpgrade(w http.ResponseWriter, r *http.Request) {
653
+ if r.Method != http.MethodPost {
654
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
655
+ return
656
+ }
657
+ ctx := context.WithoutCancel(r.Context())
658
+ t := s.coreUpgradeTarget(ctx)
659
+ if !t.CanUpgrade {
660
+ writeErr(w, http.StatusConflict, t.Note)
661
+ return
662
+ }
663
+ /* 绝不降级:registry 上的 latest 可能比本地还旧(本地是开发版 / 预发版,
664
+ 或者别人先发了旧版本)。`npm i -g magic-agent@<latest>` 是**无条件安装**,
665
+ 不挡住就是把好的那份换成旧的 —— 这里明确拒绝并说明。 */
666
+ if t.Replaced {
667
+ // 盘上已经是本进程启动后换上去的新版(旧版二进制读不出自己的版本号):再装一遍
668
+ // 没有意义,而且「装完版本没变」会把它误报成失败 —— 要的是重启。
669
+ writeErr(w, http.StatusConflict, "盘上已经是 npm 最新版 v"+t.Latest+",只差重启 magic-client 后生效")
670
+ return
671
+ }
672
+ if t.Installed != "" && cmpSemver(t.Latest, t.Installed) <= 0 {
673
+ writeErr(w, http.StatusConflict,
674
+ "npm 上的最新版是 v"+t.Latest+",不高于本机已装的 v"+t.Installed+":不做降级安装")
675
+ return
676
+ }
677
+
678
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
679
+ w.Header().Set("Cache-Control", "no-cache")
680
+ w.Header().Set("X-Accel-Buffering", "no")
681
+ flusher, _ := w.(http.Flusher)
682
+ send := func(event string, payload any) {
683
+ b, _ := json.Marshal(payload)
684
+ if event != "" {
685
+ fmt.Fprintf(w, "event: %s\n", event)
686
+ }
687
+ fmt.Fprintf(w, "data: %s\n\n", b)
688
+ if flusher != nil {
689
+ flusher.Flush()
690
+ }
691
+ }
692
+
693
+ send("open", map[string]any{"command": t.Command(), "bin": t.Bin, "from": s.contract.Version, "to": parseArgVersion(t.Args)})
694
+ send("step", map[string]any{"index": 0, "command": t.Command()})
695
+ code, err := runPluginStep(ctx, pluginStep{Args: append([]string{t.Npm}, t.Args...), Env: npmEnv()}, func(line string) {
696
+ send("line", map[string]any{"index": 0, "line": line})
697
+ })
698
+ if err == nil && code != 0 {
699
+ err = fmt.Errorf("%s 退出码 %d", t.Command(), code)
700
+ }
701
+ if err != nil {
702
+ send("end", map[string]any{"ok": false, "code": code, "error": err.Error(), "core": s.coreStatus(ctx)})
703
+ return
704
+ }
705
+
706
+ /**
707
+ * 不谎报成功:以**盘上那份的版本真的变了**为判据。
708
+ * (`npm i -g` 退出 0 但装到了别的前缀、或 registry 还没同步时,都会走到这里。)
709
+ */
710
+ after := s.coreStatus(ctx)
711
+ newVer := coreBinaryVersion(t.Bin)
712
+ /* 新二进制**旧版读不出新版本号**(本进程启动后才换上、回的还是旧版式输出):
713
+ 升级本身是成功的,只是没法从这里读出它的版本 —— 交给重启后的新实例回答,
714
+ 不能因为「读不出」就报失败。 */
715
+ replacedOnly := newVer == "" || (t.Installed != "" && cmpSemver(newVer, t.Installed) <= 0)
716
+ if replacedOnly && after.RestartOnly {
717
+ newVer = orDash(after.Latest)
718
+ } else if newVer == "" || newVer == t.Installed || cmpSemver(newVer, t.Installed) <= 0 {
719
+ send("end", map[string]any{"ok": false, "code": 0,
720
+ "error": "npm 已执行,但 " + t.Bin + " 的版本没有变化(仍是 " + orDash(newVer) + "):可能装到了别的前缀,或 npm 源还没同步",
721
+ "core": after})
722
+ return
723
+ }
724
+
725
+ restarted := false
726
+ note := ""
727
+ if s.selfDaemonState() != nil {
728
+ // 与 POST /desk/restart 同一套:立标志(挡住并发的第二次升级)、先答、定时器后拉助手。
729
+ restartMu.Lock()
730
+ already := restartPending
731
+ restartPending = true
732
+ restartMu.Unlock()
733
+ restarted = true
734
+ switch {
735
+ case already:
736
+ note = "已装好 " + newVer + ";服务已在重启中,几秒后自动重连"
737
+ default:
738
+ note = "已装好 " + newVer + ",正在重启后台服务,几秒后自动重连"
739
+ }
740
+ /* 状态文件路径一并交给助手(2026-10-26):本实例可能是前台 serve(magic-test 那种),
741
+ 它的登记在 <数据目录>/serve.json,助手按默认 client.json 找会去重启**别的**实例。 */
742
+ statePath := statePathOf(s.selfDaemonState())
743
+ if exe := selfExe(); exe != "" {
744
+ if !already {
745
+ time.AfterFunc(restartDelay, func() { restartSpawner(exe, s.log, statePath) })
746
+ }
747
+ } else {
748
+ restarted = false
749
+ note = "npm 已装好新版本;没能定位自身可执行文件,请手工重启 magic-client 后生效"
750
+ }
751
+ } else {
752
+ note = "npm 已装好新版本;当前是前台 serve 且没有登记过自己,重启 magic-client 后生效"
753
+ }
754
+ send("end", map[string]any{"ok": true, "code": 0, "restarting": restarted, "note": note, "core": after})
755
+ if flusher != nil {
756
+ flusher.Flush()
757
+ }
758
+ }
759
+
760
+ // coreQuickInfo /desk/health 里顺带的那一小块:不查 registry、不起子进程(探活要快)。
761
+ //
762
+ // 只说两件事:npm 与目标二进制在不在(能不能让手机一键升级)、能不能原地重启。
763
+ // 具体版本要升级前再问 GET /desk/core。
764
+ func (s *Service) coreQuickInfo() map[string]any {
765
+ return s.coreQuickInfoWith(true)
766
+ }
767
+
768
+ // upgradableCore 「这台机器上能不能让手机一键升级桌面端(npm)」—— 与 /desk/health 的
769
+ // core.available 同一条判据。/desk/app 用它随 APK 信息一起告诉界面(2026-10-26)。
770
+ func (s *Service) upgradableCore() bool { return s.coreQuickInfoWith(false)["available"] == true }
771
+
772
+ func (s *Service) coreQuickInfoWith(withRestart bool) map[string]any {
773
+ out := map[string]any{
774
+ "available": false, // 能不能执行 npm 升级(npm 在 PATH 上且找得到目标二进制)
775
+ "version": s.contract.Version,
776
+ }
777
+ if withRestart {
778
+ out["restart"] = false // 能不能原地重启(后台常驻实例)
779
+ }
780
+ inst := resolveCoreInstall(s.opts.MagicAgent)
781
+ if _, err := exec.LookPath("npm"); err == nil && inst.Bin != "" {
782
+ out["available"] = true
783
+ out["bin"] = inst.Bin
784
+ out["prefix"] = inst.Prefix
785
+ }
786
+ if withRestart && s.selfDaemonState() != nil {
787
+ out["restart"] = true
788
+ }
789
+ return out
790
+ }
791
+
792
+ // restartDaemon 已并入 POST /desk/restart 的同一条链路(restart.go 的 restartSpawner):
793
+ // 「先立标志、先答、定时器后拉助手停旧起新」,升级完不再自己起第二个实例。
794
+
795
+ func orDash(v string) string {
796
+ if v == "" {
797
+ return "—"
798
+ }
799
+ return v
800
+ }
801
+
802
+ // selfExe 本进程自己的可执行文件(解析符号链接)。`start` 之后 serve 跑的就是它。
803
+ func selfExe() string {
804
+ p, err := os.Executable()
805
+ if err != nil {
806
+ return ""
807
+ }
808
+ return resolveLink(p)
809
+ }
810
+
811
+ // ReadCoreContract 跑 `<bin> --contract` 并按插件要求校验(契约版本 + 必需的通道能力)。
812
+ // 报错文案与启动流程里的那条保持一致 —— 用户看到的必须是同一句话。
813
+ func ReadCoreContract(bin string) (Contract, error) {
814
+ if strings.TrimSpace(bin) == "" {
815
+ bin = "magic-agent"
816
+ }
817
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
818
+ defer cancel()
819
+ out, err := exec.CommandContext(ctx, bin, "--contract").Output()
820
+ if err != nil {
821
+ return Contract{}, fmt.Errorf("读 core 契约失败(%s --contract):%w", bin, err)
822
+ }
823
+ var c Contract
824
+ if err := json.Unmarshal(out, &c); err != nil {
825
+ return Contract{}, fmt.Errorf("解析 core 契约失败:%w", err)
826
+ }
827
+ if c.ContractVersion != RequiredContractVersion {
828
+ return c, fmt.Errorf("core 契约版本 %d 与本插件要求的 %d 不一致:请升级 magic-agent(或换用匹配的插件版本)",
829
+ c.ContractVersion, RequiredContractVersion)
830
+ }
831
+ for _, f := range requiredFeatures {
832
+ if !hasStr(c.Features, f) {
833
+ return c, fmt.Errorf("core 缺通道能力 %s(需要支持 --stream --events 与 --control 的版本)", f)
834
+ }
835
+ }
836
+ return c, nil
837
+ }
838
+
839
+ // CoreContractProblem 返回空串 = 这个 core 能用;否则是不能用的原因(给人看的)。
840
+ func CoreContractProblem(bin string) string {
841
+ if _, err := ReadCoreContract(bin); err != nil {
842
+ return err.Error()
843
+ }
844
+ return ""
845
+ }
846
+
847
+ // PrecheckCore 由 `magic-client start` 调用(内部子命令 core-check):判定 core 能不能用。
848
+ //
849
+ // 为什么新开一个子进程:升级之后父进程 exec 到的「本进程的路径」在磁盘上已经是新二进制,
850
+ // 但判定逻辑(契约版本、必需能力)必须来自**新二进制自己的那份代码** —— 老代码不知道
851
+ // 新版本对契约的要求会不会变,那样升级完可能启动到一个用不了的服务而没人发现。
852
+ func PrecheckCore(coreBin string) (bool, string) {
853
+ bin := strings.TrimSpace(coreBin)
854
+ if bin == "" {
855
+ p, err := exec.LookPath("magic-agent")
856
+ if err != nil {
857
+ return false, "PATH 上没有 magic-agent"
858
+ }
859
+ bin = p
860
+ }
861
+ c, err := ReadCoreContract(bin)
862
+ if err != nil {
863
+ return false, err.Error()
864
+ }
865
+ return true, c.Version
866
+ }