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
@@ -22,6 +22,7 @@ import (
22
22
  "os"
23
23
  "os/exec"
24
24
  "path/filepath"
25
+ "reflect"
25
26
  "sort"
26
27
  "strconv"
27
28
  "strings"
@@ -38,8 +39,8 @@ import (
38
39
  * 1) 契约校验:启动时读 core 的 --contract,版本或能力不匹配就**明确报错**,
39
40
  * 不带着不匹配的能力跑起来 —— 这类漂移的表现是界面空白,最难定位。
40
41
  * 2) 只走 core 的公开面:spawn `magic-agent` 子进程收 NDJSON 事件,**不解析引擎输出**。
41
- * 3) 默认只绑 127.0.0.1 且要令牌:本地服务对同机所有进程可见,不开鉴权等于把
42
- * 引擎执行权交给任意本机进程。
42
+ * 3) 默认绑 0.0.0.0:9100 且强制令牌:局域网内端口能被扫到,令牌是唯一门锁;
43
+ * 只想本机访问时用 --local 绑回 127.0.0.1。
43
44
  */
44
45
 
45
46
  // RequiredContractVersion 本插件能解析的 core 契约版本。
@@ -73,7 +74,7 @@ type Options struct {
73
74
  // APKPath 可选:要分发给移动端的 APK 路径。空 = 自动在包内 npm/apk/ 下找
74
75
  // (见 apk.go)。显式给路径主要给测试与特殊布局用。
75
76
  APKPath string
76
- // Lan 是否绑到了局域网(--lan)。移动端「环境」菜单要靠它拿到手机可用的入口,
77
+ // Lan 是否绑到了局域网(默认开启,--local 关闭)。移动端「环境」菜单要靠它拿到手机可用的入口,
77
78
  // 否则用户只能手抄 IP。绑回环时不报(报了也连不上)。
78
79
  Lan bool
79
80
  // BrowseUA 内置浏览器中转使用的 User-Agent;空 = 用内置的 Android Chrome。
@@ -88,6 +89,25 @@ type Options struct {
88
89
  // 打开它 = 任意网页都能**读**响应;真正的门锁仍是令牌(没有 X-Magic-Token 一律 401)。
89
90
  // `--lan` 时默认开启(都绑 0.0.0.0 了,再按 loopback 卡 Origin 没有意义)。
90
91
  AllowAnyOrigin bool
92
+ /* StatePath 本实例(若是 `magic-client start` 起的)状态文件路径,来自 `--state` /
93
+ MAGIC_CLIENT_STATE。升级与重启要「认领自己」:同机可能还跑着别的实例,按路径找状态
94
+ 文件比按默认路径猜准(默认路径下存的是另一个实例时,重启会重启别人)。 */
95
+ StatePath string
96
+ /* SelfStatePath 前台 serve 自登记的状态文件(<数据目录>/serve.json,见 daemon.go)。
97
+ 与 StatePath 分开两个字段而不是复用一个:两者的语义不同 —— StatePath 是
98
+ `start` 的实例记录(还决定日志/令牌位置),SelfStatePath 只是「我是谁」,
99
+ 重启与升级靠它认领自己。空 = 本实例是 start 起的(走 StatePath / 默认路径)。 */
100
+ SelfStatePath string
101
+ /* FeishuAppID / FeishuAppSecret 飞书自建应用的凭据(2026-10-26,「登录后推送通知」)。
102
+ 优先级:移动端写入的本机 feishu-notify.json > 环境变量 MAGIC_FEISHU_APP_ID/SECRET
103
+ > 这里(config.json 的 feishu 节)。空 = 没配置,界面提示去填。见 feishu.go。 */
104
+ FeishuAppID string
105
+ FeishuAppSecret string
106
+ /* HostPID 托管本服务的父进程(桌面壳 / 测试宿主)。0 = 没人托管。
107
+ 重启出来的新实例是 detach 的(必须如此,否则旧实例退出会连带杀掉它),
108
+ 壳就再也杀不到它 —— 于是由服务自己盯着这个 pid:主人没了就优雅退出,
109
+ 免得孤儿占着端口与单实例锁让下次开壳「插件启动即退出」。见 WatchHost。 */
110
+ HostPID int
91
111
  }
92
112
 
93
113
  // Service 本地服务。
@@ -109,23 +129,80 @@ type Service struct {
109
129
  // inst 服务实例的稳定 id(见 instance.go):同一台桌面的多个入口靠它归一。
110
130
  inst instanceIDCache
111
131
 
132
+ /* 本进程的启动时刻(2026-10-27)。
133
+ *
134
+ * 为什么需要:重启换的是**进程**,会话、任务、设置全在磁盘上,所以界面重启前后
135
+ * 长得一模一样 —— 用户点了「重启服务」,看到列表没变,只会得出「没效果」。
136
+ * `instanceId` 帮不上忙:它是**同一台桌面**的稳定身份(多入口归一要靠它),
137
+ * 跨重启必须不变,所以不能用它判断新旧代。
138
+ * 这里给 /desk/health 补一对「这次真的是新进程」的证据:pid + 启动时刻。
139
+ * 只增字段,不升 contractVersion;老插件没有 → 客户端退回原判据。 */
140
+ startedAt time.Time
141
+
112
142
  // 内置浏览器中转用的 HTTP 客户端(惰性建、带 cookie jar,按上游 host 存会话)。
113
143
  // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
114
144
  browseOnce sync.Once
115
145
  browseHTTP *http.Client
116
146
 
147
+ // 浏览器输入中继(/browse-input,见 browseinput.go):页面内 relay 脚本 viewer
148
+ // 与未来的 MCP driver 之间的枢纽,惰性建。
149
+ inputHubOnce sync.Once
150
+ inputHubRef *browseInputHub
151
+
117
152
  // 需求自动归纳的调度状态(每工作区一个防抖计时器,见 requirements.go)。
118
153
  reqAuto reqAuto
119
154
 
155
+ // 脚本清单与运行态(「发现 → 脚本」,见 scripts.go)。
156
+ scripts *scriptManager
157
+
158
+ // 工作台应用(「发现 → 应用」,见 apps.go):magic-shorts / magic-video 的本地
159
+ // 工作台在这里启停,并经 /workbench/{id}/ 反代给手机内置浏览器。
160
+ apps *appManager
161
+
162
+ /* 会话清单(/desk/sessions)的短 TTL 缓存 + 单飞(2026-10-26,见 sessions_cache.go)。
163
+ 为什么必须缓存:这个接口每次请求都 spawn 一个 core 进程(`--sessions` 本身约 20ms,
164
+ 但在负载高的机器上、且 core 是 npm 转发脚本时要 200~900ms)。而列表页是**轮询**驱动的
165
+ —— 移动端主列表每 5s 拉一次,一次 loadSessions 还会并发问 5 个接口;PC 左栏在有会话
166
+ 跑着时每 30s 一次。用户报「会话列表显示很慢」实测就是这个:同一个 core 直连 20ms,
167
+ 经本插件 400~900ms,10 并发时每个都到 1.6s。 */
168
+ sessCache sessionsCache
169
+
120
170
  // 随包发布的移动端 APK(空 = 这个包里没带)。见 apk.go。
121
171
  apkPath string
122
172
 
173
+ // coreTarget 覆盖「桌面端 CLI 的最新版 + 该升哪一份」的解析(只给测试用,见
174
+ // coreupgrade.go::coreUpgradeTarget)。nil = 按本机真实探测。
175
+ coreTarget func(context.Context) coreUpgradeTarget
176
+ // procStartOverride 覆盖「本进程启动时刻」(只给测试用,见 coreupgrade.go::replacedAfterStart)。
177
+ procStartOverride func() (time.Time, bool)
178
+ // latestFn 覆盖「问 npm registry 最新版」(只给测试用:不联网)。nil = 真去问。
179
+ latestFn func(context.Context) string
180
+
123
181
  /* 公网入口探测(2026-10-22):隧道是**壳**起的,插件不知道它的域名,而 quick tunnel
124
- 每次启动都换域名。这里缓存 probePublicURL 的结果,让 /desk/health 不必每次都扫端口。 */
125
- pubMu sync.Mutex
126
- pubURL string
127
- pubExp time.Time
128
- pubBusy bool
182
+ 每次启动都换域名。这里缓存 probePublicURL 的结果,让 /desk/health 不必每次都扫端口。
183
+ 2026-10-25:结果**落盘**(见 publicurl.go)—— 客户端重启不该让手机把公网地址换成
184
+ 局域网地址。pubLoaded 保证只读一次盘;pubMiss 记连续探空次数(两次才认定隧道真没了);
185
+ pubGraceTo 是重启后的宽限期,期内探空不撤已知域名。 */
186
+ pubMu sync.Mutex
187
+ pubURL string
188
+ pubExp time.Time
189
+ pubBusy bool
190
+ pubLoaded bool
191
+ pubMiss int
192
+ pubGraceTo time.Time
193
+
194
+ /* 知识库(OpenViking)的最新版本缓存 + 安装互斥(见 knowledge.go)。
195
+ 成功缓存 6h、失败缓存 15min —— 失败不该每次开页面都重试一次外网。
196
+ ovBusy 挡住两个客户端同时点安装(会抢同一个 venv)。 */
197
+ ovMu sync.Mutex
198
+ ovLatest string
199
+ ovLatestOK bool
200
+ ovLatestAt time.Time
201
+ ovBusy bool
202
+
203
+ // 飞书推送(/desk/feishu*,见 feishu.go):惰性建,状态落 ~/.magic-agent/feishu-notify.json。
204
+ feishuOnce sync.Once
205
+ feishuN *feishuNotifier
129
206
  }
130
207
 
131
208
  // Contract core `--contract` 里本插件关心的部分。
@@ -168,10 +245,12 @@ func New(opts Options) (*Service, error) {
168
245
  if opts.Logger == nil {
169
246
  opts.Logger = log.New(os.Stderr, "magic-client: ", 0)
170
247
  }
171
- if strings.TrimSpace(opts.MagicAgent) == "" {
172
- opts.MagicAgent = "magic-agent"
173
- }
174
- s := &Service{opts: opts, log: opts.Logger, runs: map[string]*askRun{}}
248
+ /* core 用**绝对路径**(优先同目录的真二进制),别让 exec 走 PATH ——
249
+ PATH 上可能是 npm 的 node 转发脚本,宿主 IDE 的环境足以让它在 require 阶段就崩。
250
+ 详见 coreexe.go 顶部那段 2026-10-26 的真机记录。 */
251
+ selfExe, _ := os.Executable()
252
+ opts.MagicAgent = resolveCoreExe(opts.MagicAgent, selfExe)
253
+ s := &Service{opts: opts, log: opts.Logger, runs: map[string]*askRun{}, startedAt: time.Now()}
175
254
  // 移动端 APK:显式路径优先;否则在包内 npm/apk/ 找(随 npm 包发布,见 apk.go)。
176
255
  s.apkPath = strings.TrimSpace(opts.APKPath)
177
256
  if s.apkPath == "" {
@@ -193,7 +272,7 @@ func New(opts Options) (*Service, error) {
193
272
 
194
273
  ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
195
274
  defer cancel()
196
- out, err := exec.CommandContext(ctx, opts.MagicAgent, "--contract").Output()
275
+ out, err := newCoreCmd(ctx, opts.MagicAgent, "--contract").Output()
197
276
  if err != nil {
198
277
  return nil, fmt.Errorf("读 core 契约失败(%s --contract):%w", opts.MagicAgent, err)
199
278
  }
@@ -213,6 +292,8 @@ func New(opts Options) (*Service, error) {
213
292
  /* 作业队列:服务端权威的排队层(多客户端串行 / 幂等 / 重启恢复)。
214
293
  必须在 New 里就建好:/desk/ask 第一次进来就要能入队。 */
215
294
  s.jobs = newJobQueue(s)
295
+ // 脚本管理器同理:清单要跨重启保留,运行中的脚本要靠 pid 探活。
296
+ s.scripts = newScriptManager(s)
216
297
  return s, nil
217
298
  }
218
299
 
@@ -222,16 +303,63 @@ func (s *Service) Contract() Contract { return s.contract }
222
303
  // MagicAgentPath 实际使用的 core 可执行文件。
223
304
  func (s *Service) MagicAgentPath() string { return s.opts.MagicAgent }
224
305
 
306
+ /* WatchHost 盯着托管本服务的父进程:它没了就调用 quit(2026-10-26)。
307
+ *
308
+ * ── 为什么需要 ──
309
+ * 「手机点重启」要求新实例 detach(独立会话),否则旧实例退出时会把同组的它一起带走。
310
+ * 可 detach 之后,掌天瓶壳的 `will-quit` 里那句 `r.stop()`(杀的是它 spawn 的那个句柄)
311
+ * 就管不到新实例了 —— 壳退出、服务还在,占着端口和单实例锁,下次开壳直接「插件启动即退出」,
312
+ * 用户只能去终端 magic-client stop 救。
313
+ *
314
+ * 这里把「主人是谁」变成服务自己的一条退出条件:壳没了,服务主动收摊。于是
315
+ * 「壳托管」这个语义在重启前后保持一致,不需要壳那边配合改任何东西。
316
+ *
317
+ * `magic-client start` 起的常驻实例 HostPID = 0(没人托管)→ 直接返回,行为不变。
318
+ *
319
+ * ⚠️ 只在**启动时**判一次「主人在不在」,之后每 2 秒看一次:
320
+ * 重启链路里父进程可能恰好在我们起来之前退出,那种情况下这个实例本来就没人要了。 */
321
+ func (s *Service) WatchHost(quit func()) {
322
+ host := s.opts.HostPID
323
+ if host <= 0 || quit == nil {
324
+ return
325
+ }
326
+ go func() {
327
+ for {
328
+ if !HostAlive(host) {
329
+ s.log.Printf("托管进程(pid %d)已退出:本服务跟着收摊,避免留下孤儿占着端口", host)
330
+ quit()
331
+ return
332
+ }
333
+ time.Sleep(hostPollInterval)
334
+ }
335
+ }()
336
+ }
337
+
338
+ // hostPollInterval 盯父进程的轮询间隔。
339
+ // 2 秒:壳退出到端口释放要在用户下次开壳前完成,这个粒度足够;又不会太密去打扰系统调用。
340
+ // 是 var 不是 const:用例要把它压到毫秒级,否则测一次要等两秒。
341
+ var hostPollInterval = 2 * time.Second
342
+
225
343
  // Handler 路由。
226
344
  func (s *Service) Handler() http.Handler {
227
345
  mux := http.NewServeMux()
228
346
  mux.HandleFunc("/desk/health", s.guard(s.handleHealth))
229
347
  mux.HandleFunc("/desk/contract", s.guard(s.handleContract))
348
+ /* 重启后台实例(2026-10-24,移动端「我的 → 重启服务」)。见 restart.go:
349
+ 本进程发完响应后由一个脱离终端的助手停旧起新,令牌 / 端口沿用。 */
350
+ mux.HandleFunc("/desk/restart", s.guard(s.handleRestart))
230
351
  // 移动端 APK:版本信息 + 安装包本身(版本号跟 npm 包走,见 apk.go)。
231
352
  mux.HandleFunc("/desk/app", s.guard(s.handleAppInfo))
232
353
  // 系统浏览器/下载器带不了自定义头,这个接口靠 guard 支持的 ?token= 过鉴权。
233
354
  mux.HandleFunc("/desk/app.apk", s.guard(s.handleAppAPK))
355
+ /* 桌面端 CLI(npm 那份)的版本与升级(见 coreupgrade.go)。2026-10-26 用户:
356
+ 「版本检测需要同时考虑 apk 和 npm,都要支持升级」—— 升级链路一直有,但这两条
357
+ 路由从没挂上过(只有测试里手挂),手机根本问不到;这次补上。 */
358
+ mux.HandleFunc("/desk/core", s.guard(s.handleCoreInfo))
359
+ mux.HandleFunc("/desk/core/upgrade", s.guard(s.handleCoreUpgrade))
234
360
  mux.HandleFunc("/desk/engines", s.guard(s.handleEngines))
361
+ // 每日积分领取(2026-10-24):core --checkin <engine>,用该引擎自己的票据。
362
+ mux.HandleFunc("/desk/checkin", s.guard(s.handleCheckin))
235
363
  mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
236
364
  mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
237
365
  mux.HandleFunc("/desk/sessions", s.guard(s.handleSessions))
@@ -255,14 +383,39 @@ func (s *Service) Handler() http.Handler {
255
383
  // 移动端「环境配置」(引擎/模型/团队/空间选择)的远端存储:多客户端连同一台桌面时共用一份,
256
384
  // 不再各存各的 localStorage(见 prefs.go)。
257
385
  mux.HandleFunc("/desk/prefs", s.guard(s.handlePrefs))
386
+ /* 飞书推送(2026-10-26):绑定码 / 绑定 / 开关 / 测试 / 幂等推送,见 feishu.go。
387
+ 子树路由、一个入口分派 —— 与 /desk/session/ 同一个理由(「有哪些子路径」集中在一处)。 */
388
+ mux.HandleFunc("/desk/feishu", s.guard(s.handleFeishu))
389
+ mux.HandleFunc("/desk/feishu/", s.guard(s.handleFeishu))
258
390
  // 需求聚合(2026-10-22,见 requirements.go):读清单 / 手动整理一轮。
259
391
  // 自动归纳不在这里 —— 它是每轮 /desk/ask 收尾后的防抖动作。
260
392
  mux.HandleFunc("/desk/requirements", s.guard(s.handleRequirements))
393
+ mux.HandleFunc("/desk/requirements/graph", s.guard(s.handleRequirementsGraph))
261
394
  mux.HandleFunc("/desk/requirements/consolidate", s.guard(s.handleRequirementsConsolidate))
395
+ // 页面完成情况截图(2026-10-25):body = PNG 原始字节,参数走查询串。
396
+ mux.HandleFunc("/desk/requirements/shots", s.guard(s.handleRequirementsShots))
397
+ // 服务端 agent-browser 截图(2026-10-25 MCP 改基 agent-browser):
398
+ // body = {"url":…},服务端真实 Chromium 截全页图落 uploads。
399
+ mux.HandleFunc("/desk/requirements/shot-remote", s.guard(s.handleRequirementShotRemote))
262
400
  // 插件目录(magic-shorts / magic-video 两个工程的 CLI):介绍、安装状态、安装/升级。
263
401
  // 与引擎无关 —— core 不认识它们,所以放在这一层而不是 core 的 --engines 里。
264
402
  mux.HandleFunc("/desk/plugins", s.guard(s.handlePlugins))
265
403
  mux.HandleFunc("/desk/plugins/", s.guard(s.handlePluginAction))
404
+ // 知识库目录(OpenViking:面向 Agent 的上下文数据库):本机是否已安装、当前版本、
405
+ // 与 PyPI 最新版本的差距,以及一键安装 / 升级。与插件同源:不是引擎,放在这一层。
406
+ mux.HandleFunc("/desk/knowledge", s.guard(s.handleKnowledge))
407
+ mux.HandleFunc("/desk/knowledge/", s.guard(s.handleKnowledgeAction))
408
+ // 脚本清单与启停(「发现 → 脚本」):用户登记脚本,服务端负责启停与日志。
409
+ // 命令**只来自登记过的脚本定义**(不过 shell),客户端不能传任意命令行。
410
+ mux.HandleFunc("/desk/scripts", s.guard(s.handleScripts))
411
+ mux.HandleFunc("/desk/scripts/", s.guard(s.handleScriptSub))
412
+ /* 应用(2026-10-26):magic-shorts / magic-video 的本地工作台,手机「发现 → 应用」起停。
413
+ 见 apps.go。反代入口 /workbench/{id}/ 不套 s.guard(与 /b/ 同理):它只转给
414
+ 127.0.0.1 上那个工作台,工作台自己要不要鉴权是它的事;页面在根路径下跑时,
415
+ 由 handleStatic 按 Referer 转发(同样不套 guard —— 工作台的静态资源本就不带令牌)。 */
416
+ mux.HandleFunc("/desk/apps", s.guard(s.handleApps))
417
+ mux.HandleFunc("/desk/apps/", s.guard(s.handleAppAction))
418
+ mux.HandleFunc("/workbench/", s.handleWorkbench)
266
419
  mux.HandleFunc("/desk/dirs", s.guard(s.handleDirs))
267
420
  // /desk/session/{id}/messages 是**前缀匹配**路由 —— 标准 ServeMux 把它挂上后,
268
421
  // 该前缀下任何 path 都先进这里(其它挂载顺序靠后也拦不住)。
@@ -272,7 +425,10 @@ func (s *Service) Handler() http.Handler {
272
425
  mux.HandleFunc("/desk/ask", s.guard(s.handleAsk))
273
426
  mux.HandleFunc("/desk/ask/", s.guard(s.handleAskSub))
274
427
  // 业务模块的路由:原样转发到项目自己的后端(见 Profile.Backend 的注释)。
275
- mux.HandleFunc("/api/", s.guard(s.handleProjectAPI))
428
+ /* /api/* 先看是不是工作台页面发出来的(发现 → 应用):工作台的工作台页面在根路径下跑,
429
+ 页面里的 '/api/…' 是根相对的,不能落到项目后端引导师头上。判定见 apps.go 的 workbenchFor,
430
+ 命中就交给工作台,未命中再照旧(鉴权 + 转给 profile 的 backend)。 */
431
+ mux.HandleFunc("/api/", s.routeAPI)
276
432
  // 业务模块的静态资源:优先项目自己的模块目录(profile 的 ui.modulesDir)。
277
433
  mux.HandleFunc("/modules/", s.handleModulesStatic)
278
434
  // 二维码配对:把「桌面地址 + 令牌」给手机扫。内容等同于令牌,所以用普通鉴权把门
@@ -287,6 +443,9 @@ func (s *Service) Handler() http.Handler {
287
443
  // 内置浏览器的**服务端中转**:/b/<scheme>/<host><path>。走桌面端网络抓取并改写,
288
444
  // 见 browse.go。注意它自己鉴权(cookie/查询令牌),不再套 s.guard。
289
445
  mux.HandleFunc("/b/", s.handleBrowse)
446
+ // 浏览器输入中继:/browse-input?role=viewer|driver。鉴权同 /b/(查询令牌/cookie),
447
+ // 不套 s.guard —— 页面内注入的 relay 脚本只带 cookie。见 browseinput.go。
448
+ mux.HandleFunc(browseWSPath, s.handleBrowseInput)
290
449
  // SOCKS5 over WebSocket:让 SOCKS5 能穿过 HTTP 隧道(Cloudflare 等)到公网,见 wsproxy.go。
291
450
  mux.HandleFunc(proxyWSPath, s.handleProxyWS)
292
451
  mux.HandleFunc("/", s.handleStatic)
@@ -410,7 +569,12 @@ func (s *Service) guard(next http.HandlerFunc) http.HandlerFunc {
410
569
  func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
411
570
  // core 由本插件 spawn、继承本进程的 cwd;而 core 不传 -w 时默认工作空间就是 cwd。
412
571
  // 所以这里的 cwd 就是「桌面默认工作空间」,客户端用它给空 workspace 的会话归类。
413
- wd, _ := os.Getwd()
572
+ //
573
+ // ⚠️ 走 defaultWorkspace() 而不是直接 os.Getwd()(2026-10-26):插件被 `cd /`
574
+ // 起来时 cwd 是文件系统根,把它当默认工作区会让移动端新建的会话全归到 `/`,
575
+ // 同空间冲突判定与 core 的工作空间锁也随之退化成「全机一个空间」。
576
+ // 规则与作业队列同源(defaultWorkspace:cwd 有意义用它,否则退回上次用的)。
577
+ wd := defaultWorkspace()
414
578
  writeJSON(w, http.StatusOK, map[string]any{
415
579
  "ok": true,
416
580
  "contractVersion": s.contract.ContractVersion,
@@ -426,6 +590,16 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
426
590
  // 服务端作业队列(2026-10-23):有它才谈得上「多客户端串行 + 幂等」。
427
591
  // 老插件没有该字段 → 客户端退回本地排队(见 client-ui-mobile 的 fallback)。
428
592
  "jobs": true,
593
+ // 重启标志(2026-10-25,只增字段):本实例已受理「重启服务」、定时器还没到点。
594
+ // 探活语义:true = 旧实例认账、即将下线(见 restart.go 的标志 + 定时器链路);
595
+ // 再探到该字段为假的 200 才是新一代起来。老插件没有该字段 → 客户端照旧
596
+ // 「先看到连不上、再看到 200」的判据。
597
+ "restarting": restartRequested(),
598
+ // 重启交接(2026-10-25,只增字段):本实例受理重启、且已算出新实例的样子时,
599
+ // {urls:[手机可用入口,不带令牌], token:新实例令牌}。手机在「重启服务」的探活里
600
+ // 顺走它,改完原环境的连接地址与令牌再连 —— 地址随网卡变化、令牌重发都不会让
601
+ // 手机永远掉线(见 restart.go 的 restartHandoff)。不重启 / 算不出来 → 缺失。
602
+ "restartHandoff": restartHandoff(),
429
603
  // 服务实例的稳定 id(2026-10-23,只增字段):同一台桌面的本地 / 局域网 / 公网
430
604
  // 入口可能有多条 URL,客户端据此把它们归一成同一个环境(队列与设置共用)。
431
605
  // 老插件没有该字段 → 客户端退回按连接各自算环境。
@@ -449,6 +623,14 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
449
623
  // 团队角色清单(2026-10-22):移动端「团队模式」页要按角色配路由,
450
624
  // 角色名不能由客户端硬编码。老 core 没有 teamRoles → null,客户端回落内置清单。
451
625
  "teamRoles": s.contract.TeamRoles,
626
+ /* 「这次真的是新进程」的证据(2026-10-27,只增字段,不升 contractVersion)。
627
+ *
628
+ * 为什么加:重启换的是进程,会话/任务/设置全在盘上,界面重启前后一模一样 ——
629
+ * 用户点了「重启服务」看见列表没变,只会说「没效果」(当天真机原话)。
630
+ * instanceId 不能担此任:它是同一台桌面的稳定身份,跨重启**必须不变**。
631
+ * → pid 变 + startedAt 变才是「换了一代」的硬证据,客户端据此把「已重启」说实。 */
632
+ "pid": os.Getpid(),
633
+ "startedAt": s.startedAt.UTC().Format(time.RFC3339),
452
634
  })
453
635
  }
454
636
 
@@ -494,9 +676,23 @@ const (
494
676
  // /quicktunnel 里,扫一小段端口就能拿到候选,再用**本服务自己的令牌**回打一次
495
677
  // /desk/health 确认「这条隧道确实指向我」(别人的隧道令牌不同 → 401)。
496
678
  //
497
- // 空返回 = 没开公网 / 还没扫到;调用方(移动端)退回局域网地址,不假装有。
679
+ // 为什么还要落盘(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名就可以了」):
680
+ // 域名只随**隧道**重启而变,magic-client 重启并不碰 cloudflared。可探测结果只在内存里,
681
+ // 客户端一重启就空,手机就退回局域网地址 —— 表现成「重启后地址变了」。所以启动先读回
682
+ // 上次的域名立刻顶上,后台探测再纠正(见 publicurl.go)。
683
+ //
684
+ // 空返回 = 没开公网 / 已确认没了;调用方(移动端)退回局域网地址,不假装有。
498
685
  func (s *Service) publicURL() string {
499
686
  s.pubMu.Lock()
687
+ if !s.pubLoaded {
688
+ s.pubLoaded = true
689
+ if v := s.loadSavedPublicURL(); v != "" {
690
+ s.pubURL = v
691
+ /* exp 保持零值 = 立刻触发一次后台探测(只是别让 health 等它);
692
+ grace 期内探不到也不撤这个已知域名。 */
693
+ s.pubGraceTo = time.Now().Add(publicGrace)
694
+ }
695
+ }
500
696
  cur, exp, busy := s.pubURL, s.pubExp, s.pubBusy
501
697
  s.pubMu.Unlock()
502
698
  if busy || time.Now().Before(exp) {
@@ -510,31 +706,95 @@ func (s *Service) publicURL() string {
510
706
  s.pubBusy = true
511
707
  s.pubMu.Unlock()
512
708
 
513
- go func() {
514
- u := s.probePublicURL()
515
- s.pubMu.Lock()
516
- changed := u != s.pubURL
517
- s.pubURL = u
518
- s.pubExp = time.Now().Add(publicTTL)
519
- s.pubBusy = false
520
- s.pubMu.Unlock()
521
- if changed && s.log != nil {
522
- if u == "" {
523
- s.log.Printf("公网入口:未发现(本机没有指向本服务的 cloudflared quick tunnel)")
524
- } else {
525
- s.log.Printf("公网入口:%s", u)
526
- }
527
- }
528
- }()
709
+ go func() { s.applyProbeResult(s.probePublicURL()) }()
529
710
  return cur
530
711
  }
531
712
 
713
+ // applyProbeResult 收下一次探测的结果:更新缓存、落盘、记探空次数。
714
+ //
715
+ // 抽成方法是为了能直接测(各人机器上网卡/隧道千差万别,靠真起 cloudflared 测不稳)。
716
+ // 规则见 publicGrace 与 publicTTL 的注释:宽限期内探空不动已知域名;连续两次探空
717
+ // 且过了宽限期才认定隧道真没了。
718
+ func (s *Service) applyProbeResult(u string) {
719
+ s.pubMu.Lock()
720
+ prev := s.pubURL
721
+ inGrace := time.Now().Before(s.pubGraceTo)
722
+ /* 宽限期内探不到 → 已知域名继续顶着(cloudflared 可能比我们起得慢)。
723
+ 真探到了就以新值为准(隧道重起换了域名)。 */
724
+ next := u
725
+ if u == "" && inGrace && prev != "" {
726
+ next = prev
727
+ }
728
+ changed := next != prev
729
+ s.pubURL = next
730
+ s.pubExp = time.Now().Add(publicTTL)
731
+ s.pubBusy = false
732
+ if u == "" {
733
+ s.pubMiss++
734
+ } else {
735
+ s.pubMiss = 0
736
+ }
737
+ miss := s.pubMiss
738
+ s.pubMu.Unlock()
739
+
740
+ /* 落盘:探到了就存;连续两次探空(且过了宽限期)才认定隧道真没了、删掉旧域名。
741
+ 只探空一次就删太激进 —— cloudflared 刚起、metrics 还没就绪时也会空。 */
742
+ switch {
743
+ case u != "":
744
+ s.savePublicURL(u)
745
+ case miss >= 2 && !inGrace:
746
+ s.clearSavedPublicURL()
747
+ }
748
+
749
+ if changed && s.log != nil {
750
+ if next == "" {
751
+ s.log.Printf("公网入口:未发现(本机没有指向本服务的 cloudflared quick tunnel)")
752
+ } else {
753
+ s.log.Printf("公网入口:%s", next)
754
+ }
755
+ }
756
+ }
757
+
532
758
  // probePublicURL 扫 cloudflared 的 metrics 端口,找出指向本服务的那条 quick tunnel。
533
759
  func (s *Service) probePublicURL() string {
534
760
  tok := strings.TrimSpace(s.opts.Token)
535
761
  if tok == "" {
536
762
  return ""
537
763
  }
764
+ cands := quickTunnelHosts()
765
+ if len(cands) == 0 {
766
+ return ""
767
+ }
768
+
769
+ // 用本服务的令牌回打一次:只有真正指向本服务的隧道才认这个令牌。
770
+ var (
771
+ mu sync.Mutex
772
+ found string
773
+ wg sync.WaitGroup
774
+ )
775
+ for _, h := range cands {
776
+ wg.Add(1)
777
+ go func(host string) {
778
+ defer wg.Done()
779
+ if !healthOK("https://"+host, tok, 3*time.Second) {
780
+ return
781
+ }
782
+ mu.Lock()
783
+ if found == "" {
784
+ found = "https://" + host
785
+ }
786
+ mu.Unlock()
787
+ }(h)
788
+ }
789
+ wg.Wait()
790
+ return found
791
+ }
792
+
793
+ // quickTunnelHosts 列出本机 cloudflared 当前挂在 metrics 端口上的 quick tunnel 域名。
794
+ //
795
+ // 单独抽出来是为了「验证式取址」:缓存里的域名可能是**上一轮隧道**留下的(进程重启
796
+ // 就换域名),手机上表现为「卡片里的地址打不开」。发消息前要拿这份**当前**清单重新验。
797
+ func quickTunnelHosts() []string {
538
798
  hosts := make(chan string, quickTunnelPortHi-quickTunnelPortLo+1)
539
799
  for p := quickTunnelPortLo; p <= quickTunnelPortHi; p++ {
540
800
  go func(port int) {
@@ -561,46 +821,32 @@ func (s *Service) probePublicURL() string {
561
821
  cands = append(cands, h)
562
822
  }
563
823
  }
564
- if len(cands) == 0 {
565
- return ""
566
- }
824
+ return cands
825
+ }
567
826
 
568
- // 用本服务的令牌回打一次:只有真正指向本服务的隧道才认这个令牌。
569
- var (
570
- mu sync.Mutex
571
- found string
572
- wg sync.WaitGroup
573
- )
574
- for _, h := range cands {
575
- wg.Add(1)
576
- go func(host string) {
577
- defer wg.Done()
578
- req, err := http.NewRequest(http.MethodGet, "https://"+host+"/desk/health", nil)
579
- if err != nil {
580
- return
581
- }
582
- req.Header.Set("X-Magic-Token", tok)
583
- resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req)
584
- if err != nil {
585
- return
586
- }
587
- defer resp.Body.Close()
588
- if resp.StatusCode != http.StatusOK {
589
- return
590
- }
591
- var v map[string]any
592
- if json.NewDecoder(resp.Body).Decode(&v) != nil || v["ok"] != true {
593
- return
594
- }
595
- mu.Lock()
596
- if found == "" {
597
- found = "https://" + host
598
- }
599
- mu.Unlock()
600
- }(h)
827
+ // healthOK 用本服务的令牌回打一次 /desk/health:只有真指向本服务的入口才会 200 + ok。
828
+ // 这是「地址能不能用」的唯一可信判据 —— 不能只看域名像不像、也不能信缓存。
829
+ func healthOK(base, tok string, timeout time.Duration) bool {
830
+ req, err := http.NewRequest(http.MethodGet, strings.TrimRight(base, "/")+"/desk/health", nil)
831
+ if err != nil {
832
+ return false
601
833
  }
602
- wg.Wait()
603
- return found
834
+ if tok != "" {
835
+ req.Header.Set("X-Magic-Token", tok)
836
+ }
837
+ resp, err := (&http.Client{Timeout: timeout}).Do(req)
838
+ if err != nil {
839
+ return false
840
+ }
841
+ defer resp.Body.Close()
842
+ if resp.StatusCode != http.StatusOK {
843
+ return false
844
+ }
845
+ var v map[string]any
846
+ if json.NewDecoder(resp.Body).Decode(&v) != nil {
847
+ return false
848
+ }
849
+ return v["ok"] == true
604
850
  }
605
851
 
606
852
  // handleContract 原样转发 core 的契约(客户端拿它做能力降级,不需要经过插件再解释一遍)。
@@ -630,11 +876,54 @@ func (s *Service) handleProfile(w http.ResponseWriter, r *http.Request) {
630
876
  // (用户 2026-10-15:「通过 magic-test 新建的会话要在 magic-agent 移动端 h5 上看到,
631
877
  // 同时 magic-agent 上可以看到 magic-test 和 magic-agent 来源的会话」)。
632
878
  //
633
- // 名单两项:magic-test = 掌天瓶桌面壳;magic-agent = core 自身/直连 CLI 的显式标识。
634
- // ⚠️ 只放行**同族的 magic-* 来源**:匿名(空 client = 手工跑 / CI)与不相干应用仍被挡在
635
- // 外面 —— 这正是原过滤要避免的「把别人的任务混进本应用的会话列表」。
879
+ // ⚠️ 2026-10-26(用户:「多个客户端要能拉到同样的会话历史」):**从「认名字」改成
880
+ // 「认同族」**。此前是钉死的两个字符串 `{magic-test, magic-agent}`,于是每接一个新
881
+ // 客户端(另一个 magic-* 桌面应用、改了 bundleId 的构建、用户自己起的 magic-xxx)
882
+ // 都得回来改这句常量 —— 忘了改的表现是**静默少一堆会话**,而且只有那个客户端看不见,
883
+ // 极难归因。改成按前缀判定后不再有这个维护点。
884
+ //
885
+ // 判据(见 sessionClientVisible):本插件自己的 AppID / 同族 magic-* 前缀 /
886
+ // 本仓认得的历史遗留名。匿名(空 client = 手工跑 / CI)与不相干应用仍被挡住 ——
887
+ // 那正是这次过滤要避免的「把别人的任务混进本应用的会话列表」。
636
888
  var extraSessionClients = []string{"magic-test", "magic-agent"}
637
889
 
890
+ /*
891
+ sessionClientVisible 这个来源客户端该不该出现在本客户端的会话列表里。
892
+
893
+ //
894
+ // 三条放行理由,任一成立即可:
895
+ //
896
+ // ① 就是我(Profile.AppID)—— 从这台机器上、经本插件发起的;
897
+ // ② 同族(前缀 magic-)—— 掌天瓶、别的 magic-* 桌面应用、直连 CLI 的显式标识。
898
+ // 族内互看是用户 2026-10-15 定的口径,2026-10-26 的多客户端需求只是把它推广开;
899
+ // ③ 名单里显式列出的历史遗留名(extraSessionClients)。
900
+ //
901
+ // ⚠️ 为什么不干脆全放行(把 client 过滤整个删掉):登记表在 ~/.magic-agent/sessions,
902
+ // 是**全机共享**的 —— 用户手工跑的、CI 跑的、别的产品写的都在里面,client 为空。
903
+ // 全放行的结果是把「不知道哪来的」会话混进本应用列表,那比少显示几条更难查。
904
+ //
905
+ // ⚠️ 前缀故意用 `magic-` 而不是更宽的 `magic`:`magic` 会把 magicclient、magical 之类
906
+ // 的第三方标识也认进来,前缀带连字符才是这一族的命名约定。
907
+ */
908
+ func sessionClientVisible(client, selfID string) bool {
909
+ c := strings.TrimSpace(client)
910
+ if c == "" {
911
+ return false // 匿名 = 手工跑 / CI / 旧记录:不混进本应用列表
912
+ }
913
+ if c == strings.TrimSpace(selfID) {
914
+ return true // ①
915
+ }
916
+ if strings.HasPrefix(c, "magic-") {
917
+ return true // ②
918
+ }
919
+ for _, allowed := range extraSessionClients {
920
+ if c == allowed {
921
+ return true // ③
922
+ }
923
+ }
924
+ return false
925
+ }
926
+
638
927
  // handleSessions 会话登记表(core 的 `--sessions`):列表页的「最近会话」用它。
639
928
  // 与 /desk/asks 的区别:asks 是**本进程正在跑的轮次**(内存里),sessions 是 core 的
640
929
  // 持久登记表(跨进程、含已结束的)。
@@ -643,11 +932,17 @@ var extraSessionClients = []string{"magic-test", "magic-agent"}
643
932
  // (~/.magic-agent/sessions),直接跑 CLI(匿名)、CI、别的 magic-* 应用都在往里写 ——
644
933
  // 原样转发等于把别人的任务混进本应用的会话列表。认客户端靠 core 落的 Record.Client
645
934
  // (本插件在 /desk/ask 时注入自己的 AppID;掌天瓶桌面壳注入 magic-test)。
646
- // 放行名单见 extraSessionClients(2026-10-15 起,不再是「只认自己」)。
935
+ // 放行判据见 sessionClientVisible(2026-10-26 起按**同族前缀**判,不再认死名字)。
647
936
  func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
648
937
  ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
649
938
  defer cancel()
650
- out, err := exec.CommandContext(ctx, s.opts.MagicAgent, "--sessions").Output()
939
+ /* 短 TTL 缓存 + 单飞(见 sessions_cache.go):列表页是轮询驱动的,而这里每次都 spawn
940
+ 一个 core 进程 —— 实测同一个 core 直连 20ms、经本插件 200~900ms,10 并发各 1.6s。
941
+ 缓存的是 core 的**原始输出**,所以下面按 selfID 过滤仍然逐请求做(每个实例的
942
+ AppID 不同,缓存过滤结果会让第二个实例看到别人的列表)。 */
943
+ out, err := s.sessCache.get(ctx, sessionsTTL(), func(ctx context.Context) ([]byte, error) {
944
+ return newCoreCmd(ctx, s.opts.MagicAgent, "--sessions").Output()
945
+ })
651
946
  if err != nil {
652
947
  writeErr(w, http.StatusBadGateway,
653
948
  fmt.Sprintf("%s --sessions 失败:%v", s.opts.MagicAgent, err))
@@ -663,17 +958,18 @@ func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
663
958
  _, _ = w.Write(out)
664
959
  return
665
960
  }
666
- allowed := map[string]bool{s.opts.Profile.AppID(): true}
667
- for _, c := range extraSessionClients {
668
- allowed[c] = true
669
- }
961
+ selfID := s.opts.Profile.AppID()
670
962
  mine := make([]map[string]any, 0, len(rows))
671
963
  for _, row := range rows {
672
- if c, _ := row["client"].(string); allowed[c] {
964
+ c, _ := row["client"].(string)
965
+ if sessionClientVisible(c, selfID) {
673
966
  mine = append(mine, row)
674
967
  }
675
968
  }
676
- writeJSON(w, http.StatusOK, mine)
969
+ /* 按会话折叠多轮记录(见 sessions_fold.go):411 行里 302 行是同一会话的其它轮次,
970
+ 列表是**会话**视图,一屏也只画 60 行 —— 「最新那条会话排不进去」正是折叠前的问题。
971
+ 顺序在过滤之后:可见性逐行判(只看 client),折叠再按 session_id 归并,两步互不干扰。 */
972
+ writeJSON(w, http.StatusOK, foldSessionRuns(mine))
677
973
  }
678
974
 
679
975
  // handleSessionMessages 「看历史」持久化落盘的会话事件(`GET /desk/session/{id}/messages`)。
@@ -1043,6 +1339,8 @@ func (s *Service) handleSessionTitle(w http.ResponseWriter, r *http.Request, id
1043
1339
  writeErr(w, http.StatusInternalServerError, "保存标题失败:"+err.Error())
1044
1340
  return
1045
1341
  }
1342
+ // 改名后列表里的标题要立刻变(见 sessions_cache.go)。
1343
+ s.invalidateSessionsCache()
1046
1344
  t := strings.TrimSpace(body.Title)
1047
1345
  if t == "" {
1048
1346
  t = session.TitleFor(id)
@@ -1079,7 +1377,7 @@ func (s *Service) handleSessionTitleSummarize(w http.ResponseWriter, r *http.Req
1079
1377
  if body.Force {
1080
1378
  args = append(args, "--title-force")
1081
1379
  }
1082
- out, err := exec.CommandContext(ctx, s.opts.MagicAgent, args...).Output()
1380
+ out, err := newCoreCmd(ctx, s.opts.MagicAgent, args...).Output()
1083
1381
  if err != nil {
1084
1382
  // core 的失败原因写在 stderr,原样带回:只回「归纳失败」调用方没法排障。
1085
1383
  msg := strings.TrimSpace(string(out))
@@ -1098,6 +1396,10 @@ func (s *Service) handleSessionTitleSummarize(w http.ResponseWriter, r *http.Req
1098
1396
  writeErr(w, http.StatusBadGateway, "core 回的归纳结果不是 JSON:"+truncateForMsg(string(out)))
1099
1397
  return
1100
1398
  }
1399
+ /* 归纳出了新标题 → 列表要立刻换(见 sessions_cache.go)。这条尤其重要:客户端
1400
+ 补归纳是「列表画完之后逐条异步打」的(移动端 ensureTitle),如果缓存不失效,
1401
+ 归纳结果要等一个 TTL 才反映到列表上,用户会以为没生效。 */
1402
+ s.invalidateSessionsCache()
1101
1403
  writeJSON(w, http.StatusOK, res)
1102
1404
  }
1103
1405
 
@@ -1126,6 +1428,9 @@ func truncateForMsg(s string) string {
1126
1428
  // 还没拿到轮次 id」)。
1127
1429
  func (s *Service) handleSessionStop(w http.ResponseWriter, r *http.Request, id string) {
1128
1430
  res, err := session.Stop(id)
1431
+ /* 停会话改了登记表的 state → 列表必须立刻反映(否则用户看到「停了但还在跑」
1432
+ 停留一个 TTL)。见 sessions_cache.go。缓存没建立时这一步是空操作。 */
1433
+ s.invalidateSessionsCache()
1129
1434
  if err != nil {
1130
1435
  writeErr(w, http.StatusNotFound, fmt.Sprintf("停止会话 %s 失败:%v", id, err))
1131
1436
  return
@@ -1155,6 +1460,8 @@ func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
1155
1460
  return
1156
1461
  }
1157
1462
  res, err := session.Delete(id)
1463
+ // 删掉了一条 → 列表必须立刻反映(见 sessions_cache.go)。
1464
+ s.invalidateSessionsCache()
1158
1465
  if err != nil {
1159
1466
  writeErr(w, http.StatusBadRequest, fmt.Sprintf("删除会话 %s 失败:%v", id, err))
1160
1467
  return
@@ -1551,6 +1858,18 @@ func (s *Service) handleModules(w http.ResponseWriter, r *http.Request) {
1551
1858
  // 2. **凭据服务端注入**:profile 里的 backend.token 由插件加进请求头,不进浏览器。
1552
1859
  // 3. **支持流式**:项目的后端可能用 SSE 推进度(magic-test 的派发流就是),
1553
1860
  // 所以用 ReverseProxy + 立即 flush,不缓冲整段响应。
1861
+ //
1862
+ // routeAPI 是 /api/* 的总入口:工作台页面发来的走工作台反代,其余走项目后端转发。
1863
+ func (s *Service) routeAPI(w http.ResponseWriter, r *http.Request) {
1864
+ if spec, port, ok := s.workbenchFor(r); ok {
1865
+ /* 路径原样交给工作台('/api/…' 就是工作台的 '/api/…'):serveWorkbench 的第三个参数
1866
+ 是「要改写成什么路径」,传空 = 不改写。 */
1867
+ s.serveWorkbench(w, r, spec, port, "")
1868
+ return
1869
+ }
1870
+ s.guard(s.handleProjectAPI)(w, r)
1871
+ }
1872
+
1554
1873
  func (s *Service) handleProjectAPI(w http.ResponseWriter, r *http.Request) {
1555
1874
  target := strings.TrimSpace(s.opts.Profile.Backend.URL)
1556
1875
  if target == "" || s.backend == nil {
@@ -1650,7 +1969,7 @@ var errCoreTimeout = errors.New("core 命令超时未返回")
1650
1969
  func (s *Service) runCore(ctx context.Context, args ...string) ([]byte, error) {
1651
1970
  ctx, cancel := context.WithTimeout(ctx, proxyCoreTimeout)
1652
1971
  defer cancel()
1653
- cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
1972
+ cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
1654
1973
  /* WaitDelay 是有界返回的最后一道保险:exec 给 Stdout/Stderr 建的是内部管道,
1655
1974
  只要 core 的某个后代进程(引擎 CLI 的 node worker)还握着写端,Wait 就永远不返回 ——
1656
1975
  症状正是「弹层永远停在正在探测引擎与模型…」。设了 WaitDelay 后,ctx 到期再等这么久
@@ -1736,6 +2055,56 @@ func saveEnginesCache(out []byte) {
1736
2055
  _ = os.Rename(tmp, p)
1737
2056
  }
1738
2057
 
2058
+ // patchEnginesCacheAfterCheckin 领取(或发现「今天已签」)后,把兜底缓存里该引擎的
2059
+ // 签到状态就地改掉:下一次打开选择器立刻看到「今日已领」,不用等后台重探。
2060
+ // 只改缓存、不重探 —— 重探要起所有引擎 CLI,弹层会卡几十秒。
2061
+ func patchEnginesCacheAfterCheckin(out []byte) {
2062
+ var res struct {
2063
+ Engine string `json:"engine"`
2064
+ CheckedToday bool `json:"checked_today"`
2065
+ CreditLeft string `json:"credit_left"`
2066
+ }
2067
+ if json.Unmarshal(out, &res) != nil || !res.CheckedToday || res.Engine == "" {
2068
+ return
2069
+ }
2070
+ p, err := enginesCachePath()
2071
+ if err != nil {
2072
+ return
2073
+ }
2074
+ raw, err := os.ReadFile(p)
2075
+ if err != nil {
2076
+ return
2077
+ }
2078
+ var rows []map[string]any
2079
+ if json.Unmarshal(raw, &rows) != nil {
2080
+ return
2081
+ }
2082
+ changed := false
2083
+ for _, r := range rows {
2084
+ if name, _ := r["engine"].(string); name != res.Engine {
2085
+ continue
2086
+ }
2087
+ r["checkin_today"] = true
2088
+ r["checkin_credit"] = ""
2089
+ if res.CreditLeft != "" {
2090
+ r["credit_left"] = res.CreditLeft
2091
+ }
2092
+ changed = true
2093
+ }
2094
+ if !changed {
2095
+ return
2096
+ }
2097
+ b, err := json.Marshal(rows)
2098
+ if err != nil {
2099
+ return
2100
+ }
2101
+ tmp := p + ".tmp"
2102
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
2103
+ return
2104
+ }
2105
+ _ = os.Rename(tmp, p)
2106
+ }
2107
+
1739
2108
  // loadEnginesCache 读上次成功的清单;过期 / 损坏都当没有。
1740
2109
  func loadEnginesCache() ([]byte, time.Time, bool) {
1741
2110
  p, err := enginesCachePath()
@@ -1795,6 +2164,9 @@ func (s *Service) probeEnginesAsync() {
1795
2164
  // 这样弹层永远是秒开:用户这次看到的是上次的结果,下一次打开就是新的。
1796
2165
  func (s *Service) proxyEngines(w http.ResponseWriter, r *http.Request) {
1797
2166
  if cached, at, ok := loadEnginesCache(); ok {
2167
+ /* 模型清单用缓存,额度 / 签到现查:额度会随用量与 key 变化,跟着缓存一起
2168
+ 冻 24 小时就是用户看到的「额度不刷新」(2026-10-25 报障)。 */
2169
+ cached = s.mergeLiveQuota(r.Context(), cached)
1798
2170
  w.Header().Set("X-Magic-Engines-Stale", at.Format(time.RFC3339))
1799
2171
  writeCoreJSON(w, cached)
1800
2172
  s.probeEnginesAsync()
@@ -1813,12 +2185,133 @@ func (s *Service) proxyEngines(w http.ResponseWriter, r *http.Request) {
1813
2185
  writeErr(w, code, fmt.Sprintf("%s --engines 失败:%v", s.opts.MagicAgent, err))
1814
2186
  }
1815
2187
 
2188
+ // quotaFields core --quotas 里允许覆盖到引擎清单上的字段(与 --engines 同名同义)。
2189
+ var quotaFields = []string{
2190
+ "credit_left", "credit_suffix", "model_quota", "checkin_today", "checkin_credit", "logged_in", "account",
2191
+ }
2192
+
2193
+ // mergeLiveQuota 用 core --quotas 的最新结果覆盖引擎清单里的额度 / 签到字段。
2194
+ //
2195
+ // 为什么不整体重探:模型清单要逐个启动引擎 CLI(秒级起步),而额度只读本机票据 +
2196
+ // 一两条只读请求。分开之后选择器依旧秒开,额度却是这次现查的。
2197
+ // 查询失败(老 core 没有 --quotas / 网络抖动)就原样返回缓存 —— 不能因为额度
2198
+ // 查不到让整份引擎清单变成错误。
2199
+ func (s *Service) mergeLiveQuota(ctx context.Context, engines []byte) []byte {
2200
+ // core 内部还有 6s 上限;这里再兜一层,避免异常时钟 / 挂起的子进程拖住响应。
2201
+ ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
2202
+ defer cancel()
2203
+ quotas, err := s.runCore(ctx, "--quotas")
2204
+ if err != nil {
2205
+ return engines
2206
+ }
2207
+ return mergeQuotaJSON(engines, quotas)
2208
+ }
2209
+
2210
+ // mergeQuotaJSON 把 --quotas 的行按 engine 合并进 --engines 的数组(纯函数,便于单测)。
2211
+ // 只覆盖「这次查到了的」字段:空串 / 空对象不覆盖,避免一次网络失败把已有数字抹掉。
2212
+ func mergeQuotaJSON(engines, quotas []byte) []byte {
2213
+ var rows []map[string]any
2214
+ if json.Unmarshal(engines, &rows) != nil {
2215
+ return engines
2216
+ }
2217
+ var qs []map[string]any
2218
+ if json.Unmarshal(quotas, &qs) != nil {
2219
+ return engines
2220
+ }
2221
+ byEngine := make(map[string]map[string]any, len(qs))
2222
+ for _, q := range qs {
2223
+ if name, _ := q["engine"].(string); name != "" {
2224
+ byEngine[name] = q
2225
+ }
2226
+ }
2227
+ changed := false
2228
+ for _, r := range rows {
2229
+ name, _ := r["engine"].(string)
2230
+ q := byEngine[name]
2231
+ if q == nil {
2232
+ continue
2233
+ }
2234
+ for _, k := range quotaFields {
2235
+ v, ok := q[k]
2236
+ if !ok || emptyQuotaValue(v) || reflect.DeepEqual(r[k], v) {
2237
+ continue
2238
+ }
2239
+ r[k] = v
2240
+ changed = true
2241
+ }
2242
+ }
2243
+ if !changed {
2244
+ return engines
2245
+ }
2246
+ out, err := json.Marshal(rows)
2247
+ if err != nil {
2248
+ return engines
2249
+ }
2250
+ return out
2251
+ }
2252
+
2253
+ // emptyQuotaValue --quotas 里「没查到」的值形态(omitempty 后本不该出现,
2254
+ // 但显式空串 / 空对象也要当成没有,别覆盖缓存里的旧值)。
2255
+ func emptyQuotaValue(v any) bool {
2256
+ switch t := v.(type) {
2257
+ case nil:
2258
+ return true
2259
+ case string:
2260
+ return strings.TrimSpace(t) == ""
2261
+ case map[string]any:
2262
+ return len(t) == 0
2263
+ case []any:
2264
+ return len(t) == 0
2265
+ }
2266
+ return false
2267
+ }
2268
+
2269
+ // handleCheckin POST /desk/checkin:领取某引擎账号的每日积分(core --checkin <engine>)。
2270
+ //
2271
+ // 只做转发:读本机票据、调账单接口都在 core;这里负责参数校验与有界返回。
2272
+ // 业务失败(如「今天已经领过了」)也是 200 —— 响应体是 core 的
2273
+ // {engine, ok:false, message},客户端直接显示原因,不当成网络错误。
2274
+ func (s *Service) handleCheckin(w http.ResponseWriter, r *http.Request) {
2275
+ if r.Method != http.MethodPost {
2276
+ writeErr(w, http.StatusMethodNotAllowed, "/desk/checkin 只支持 POST")
2277
+ return
2278
+ }
2279
+ var req struct {
2280
+ Engine string `json:"engine"`
2281
+ }
2282
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
2283
+ writeErr(w, http.StatusBadRequest, `请求体不是合法 JSON:需要 {"engine":"codebuddy"}`)
2284
+ return
2285
+ }
2286
+ engine := strings.TrimSpace(req.Engine)
2287
+ if engine == "" {
2288
+ writeErr(w, http.StatusBadRequest, "缺少 engine")
2289
+ return
2290
+ }
2291
+ out, err := s.runCore(r.Context(), "--checkin", engine)
2292
+ if err != nil {
2293
+ code := http.StatusBadGateway
2294
+ if errors.Is(err, errCoreTimeout) {
2295
+ code = http.StatusGatewayTimeout
2296
+ }
2297
+ writeErr(w, code, fmt.Sprintf("--checkin %s 失败:%v", engine, err))
2298
+ return
2299
+ }
2300
+ // 领到 / 发现「今天已签」后同步兜底缓存,避免重开弹层又显示「领取」。
2301
+ patchEnginesCacheAfterCheckin(out)
2302
+ writeCoreJSON(w, out)
2303
+ }
2304
+
1816
2305
  // askRequest POST /desk/ask 的请求体。
1817
2306
  type askRequest struct {
1818
- Engine string `json:"engine"`
1819
- Prompt string `json:"prompt"`
1820
- Model string `json:"model,omitempty"`
1821
- Workspace string `json:"workspace,omitempty"`
2307
+ Engine string `json:"engine"`
2308
+ Prompt string `json:"prompt"`
2309
+ Model string `json:"model,omitempty"`
2310
+ // FallbackEngine / FallbackModel 主引擎的备用(2026-10-24,只增字段)。
2311
+ // 主引擎失败且**零产出**时 core 自动降级一次;空 = 不降级(老客户端行为不变)。
2312
+ FallbackEngine string `json:"fallbackEngine,omitempty"`
2313
+ FallbackModel string `json:"fallbackModel,omitempty"`
2314
+ Workspace string `json:"workspace,omitempty"`
1822
2315
  // Permission 四档权限模型(manual | accept-edits | auto | full),透传给 core 的
1823
2316
  // --permission。空 = 不传,由 core 用它自己的默认值(agent.DefaultPermissionTier)。
1824
2317
  // ⚠️ core 只在**显式传过** --permission 时才校验引擎是否支持(当前 claude / codebuddy /
@@ -1867,6 +2360,17 @@ type askRequest struct {
1867
2360
  // Serial 是否参与「同一 workspace 单槽」串行(nil / true = 参与)。
1868
2361
  // 客户端「并行模式」显式传 false;服务端仍会套一层全局并发上限。
1869
2362
  Serial *bool `json:"serial,omitempty"`
2363
+ /* Mode 任务模式(2026-10-26,只增):**每个会话提交都过这一道**。
2364
+ "auto" = 由服务端判「本轮与同空间运行中的任务是否冲突」再裁决串行/并行;
2365
+ "parallel" = 一律并行,不判定;
2366
+ "serial" = 同空间单槽串行,不判定;
2367
+ 空 / 其它 = 沿用 Serial 的老语义(老客户端一行不改也保持原有行为)。
2368
+ 两者同时给时**以 Mode 为准**(用户选的那个说了算,Serial 只当缺省回退)。 */
2369
+ Mode string `json:"mode,omitempty"`
2370
+ /* JudgeEngine / JudgeModel 判定用哪个引擎(前端传用户配的「建议引擎」)。
2371
+ 空 = core 默认 llm。判定预算是异步的,提交侧不等待(见 jobs_auto.go)。 */
2372
+ JudgeEngine string `json:"judgeEngine,omitempty"`
2373
+ JudgeModel string `json:"judgeModel,omitempty"`
1870
2374
  }
1871
2375
 
1872
2376
  // teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
@@ -1893,6 +2397,10 @@ type teamRequest struct {
1893
2397
  type teamRoute struct {
1894
2398
  Engine string `json:"engine,omitempty"`
1895
2399
  Model string `json:"model,omitempty"`
2400
+ // FallbackEngine / FallbackModel 这一路的备用(2026-10-24,只增字段)。
2401
+ // 主引擎失败且零产出时自动降级一次;空 = 不降级。
2402
+ FallbackEngine string `json:"fallbackEngine,omitempty"`
2403
+ FallbackModel string `json:"fallbackModel,omitempty"`
1896
2404
  }
1897
2405
 
1898
2406
  // askRun 一次 ask(跑完的还会短时保留,供断线续传补拉终态)。
@@ -1919,6 +2427,12 @@ type askRun struct {
1919
2427
  登记表里这条会话只有 run_id,/desk/asks 要把两者都给客户端才能认领。受 mu 保护。 */
1920
2428
  run string
1921
2429
 
2430
+ /* fbEngine / fbModel 本轮**由备用引擎接上**时记下是谁(扫 type=fallback 事件,
2431
+ 2026-10-26)。作业记录靠它回答「主备到底生效没有」—— 以前那份记录里只有
2432
+ 配置、没有结果,用户报障时无从判断。受 mu 保护;没降级时为空。 */
2433
+ fbEngine string
2434
+ fbModel string
2435
+
1922
2436
  /* 事件回放缓冲(2026-10-19 断线续传):core 的每一行原样留一份,客户端拿
1923
2437
  `GET /desk/ask/{id}/events?after=<seq>` 按 seq 增量补拉。
1924
2438
  为什么不直接复用会话日志(`/desk/session/{id}/events`):
@@ -2015,6 +2529,19 @@ func (r *askRun) appendEvent(line string) {
2015
2529
  if seq > r.seq {
2016
2530
  r.seq = seq
2017
2531
  }
2532
+ /* 主备降级帧(2026-10-26):只留 to 端,作业收尾时写进作业记录。
2533
+ 只在长度够时才解析 —— 绝大多数行都不是 fallback。 */
2534
+ if len(line) > 24 && strings.Contains(line, `"type":"fallback"`) {
2535
+ var fe struct {
2536
+ Fallback *struct {
2537
+ ToEngine string `json:"to_engine"`
2538
+ ToModel string `json:"to_model"`
2539
+ } `json:"fallback"`
2540
+ }
2541
+ if json.Unmarshal([]byte(line), &fe) == nil && fe.Fallback != nil {
2542
+ r.fbEngine, r.fbModel = fe.Fallback.ToEngine, fe.Fallback.ToModel
2543
+ }
2544
+ }
2018
2545
  r.events = append(r.events, askEvent{seq: seq, data: []byte(line)})
2019
2546
  size := 0
2020
2547
  for _, e := range r.events {
@@ -2053,6 +2580,13 @@ func (r *askRun) finish(code int) {
2053
2580
  r.mu.Unlock()
2054
2581
  }
2055
2582
 
2583
+ // fallbackState 本轮降级到了哪个引擎 / 模型(没降级返回空)。
2584
+ func (r *askRun) fallbackState() (engine, model string) {
2585
+ r.mu.Lock()
2586
+ defer r.mu.Unlock()
2587
+ return r.fbEngine, r.fbModel
2588
+ }
2589
+
2056
2590
  // snapshot 取 seq > after 的事件(after=0 时额外放行网关合成的用户提问,seq=0)。
2057
2591
  // 与 handleAskEvents 的返回口径一致,供 SSE 中继与作业事件共用。
2058
2592
  func (r *askRun) snapshot(after uint64) (out []askEvent, lastSeq uint64, done bool, code int, stderr string) {
@@ -2115,7 +2649,17 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2115
2649
 
2116
2650
  ctx := r.Context()
2117
2651
  run, ok := s.jobs.waitStarted(ctx, job, func(pos int) {
2118
- send("queued", map[string]any{"job": job.ID, "state": JobQueued, "position": pos})
2652
+ ev := map[string]any{"job": job.ID, "state": JobQueued, "position": pos}
2653
+ /* 自动模式的判定结论随排队事件一起下发(2026-10-26,只增字段):客户端要能
2654
+ 当场说「为什么在排队」,否则只会显示一句「等待中」,看起来像模式开关没生效。
2655
+ 判定是异步的,job 是提交时的副本 —— 这里重新取一次最新状态。 */
2656
+ if cur := s.jobs.get(job.ID); cur != nil {
2657
+ if cur.Auto != nil {
2658
+ ev["auto"] = cur.Auto
2659
+ }
2660
+ ev["serial"] = cur.Serial
2661
+ }
2662
+ send("queued", ev)
2119
2663
  })
2120
2664
  if !ok {
2121
2665
  /* 排队期间客户端断开:匿名提交(没有 request_id)没人会再来接,直接撤掉;
@@ -2172,6 +2716,17 @@ func (s *Service) prepareAsk(req askRequest) (string, []string, int, error) {
2172
2716
  if req.Model != "" {
2173
2717
  args = append(args, "-m", req.Model)
2174
2718
  }
2719
+ /* 主备(2026-10-24):备用引擎同样要过白名单,否则会绕过「这台桌面允许用哪些引擎」。
2720
+ 主引擎失败且零产出时由 core 自动降级(见 agent/fallback.go)。 */
2721
+ if fb := strings.TrimSpace(req.FallbackEngine); fb != "" {
2722
+ if !s.opts.Profile.AllowsEngine(fb) {
2723
+ return "", nil, http.StatusForbidden, fmt.Errorf("profile 白名单不允许备用引擎 %q", fb)
2724
+ }
2725
+ args = append(args, "--fallback-engine", fb)
2726
+ if m := strings.TrimSpace(req.FallbackModel); m != "" {
2727
+ args = append(args, "--fallback-model", m)
2728
+ }
2729
+ }
2175
2730
  if req.Workspace != "" {
2176
2731
  args = append(args, "-w", req.Workspace)
2177
2732
  }
@@ -2241,6 +2796,24 @@ func (s *Service) prepareAsk(req askRequest) (string, []string, int, error) {
2241
2796
  return engine, args, http.StatusOK, nil
2242
2797
  }
2243
2798
 
2799
+ /* workspaceLockEnv 决定给 core 传什么工作空间锁开关(2026-10-26)。
2800
+ *
2801
+ * 背景:core 的锁**默认生效**。于是"串行"是免费的(什么都不传),"并行"必须显式关。
2802
+ * 这条以前是反的(只有传 `=1` 才开),语义一翻转就必须集中在一处 —— 散在 startAsk
2803
+ * 里没人测得出来,而这正是会「看着开了、实际没生效」的那种 bug。
2804
+ *
2805
+ * 返回值:nil = 不追加任何环境变量(走 core 默认 = 串行);否则是一条 K=V。
2806
+ * · serial 未指定(nil)→ 按串行处理,与 jobs.go 的默认值同一口径;
2807
+ * · serial=false → 显式 `=0`,让同空间的并行作业真能并行;
2808
+ * · serial=true → nil(不必冗余地写 `=1`,core 的默认就是开)。
2809
+ */
2810
+ func workspaceLockEnv(serial *bool) []string {
2811
+ if serial == nil || *serial {
2812
+ return nil
2813
+ }
2814
+ return []string{session.EnvWorkspaceLock + "=0"}
2815
+ }
2816
+
2244
2817
  /*
2245
2818
  startAsk 真正拉起 core 并开始吸收 stdout(不绑任何 HTTP 请求)。
2246
2819
 
@@ -2250,16 +2823,18 @@ stdout 全部进 askRun 的回放缓冲,谁来读(SSE / /desk/ask/{id}/event
2250
2823
  */
2251
2824
  func (s *Service) startAsk(req askRequest, engine string, args []string, source string) (*askRun, error) {
2252
2825
  ctx := context.WithoutCancel(context.Background())
2253
- cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
2826
+ cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
2254
2827
  /* 两个「谁发起的」维度随环境变量传给 core,落到会话登记表:
2255
2828
  · EnvSource 设备形态(mobile / pc)—— 按请求 Origin 判定,列表页显示设备标志;
2256
- · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**。
2257
- 另外注入工作空间锁(选入式,见 internal/session/workspace_lock.go):core 在 Begin
2258
- 时先抢锁再写 running,让直接跑 CLI 的进程也参与同一套串行。 */
2259
- cmd.Env = append(os.Environ(),
2829
+ · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话。
2830
+ 工作空间锁按 workspaceLockEnv 的裁决传(串行 = 不传,并行 = 显式关)——
2831
+ 细节与所以然都在那个函数上,别在这里再写一份判据。 */
2832
+ /* cleanHostEnv:摘掉宿主 IDE 塞进来的 node 垫片与假命令目录 —— 否则引擎子进程
2833
+ 连不上宿主的 broker,每条任务都 failed(2026-10-26 真机,见 hostenv.go)。 */
2834
+ cmd.Env = append(cleanHostEnv(os.Environ()),
2260
2835
  session.EnvSource+"="+source,
2261
- session.EnvClient+"="+s.opts.Profile.AppID(),
2262
- session.EnvWorkspaceLock+"=1")
2836
+ session.EnvClient+"="+s.opts.Profile.AppID())
2837
+ cmd.Env = append(cmd.Env, workspaceLockEnv(req.Serial)...)
2263
2838
  stdin, err := cmd.StdinPipe()
2264
2839
  if err != nil {
2265
2840
  return nil, fmt.Errorf("stdin pipe: %w", err)
@@ -2422,13 +2997,20 @@ func (s *Service) handleAskSub(w http.ResponseWriter, r *http.Request) {
2422
2997
  Op string `json:"op"`
2423
2998
  Text string `json:"text,omitempty"`
2424
2999
  Reason string `json:"reason,omitempty"`
3000
+ /* ask_answer(原地回审批,2026-10-25):这三项必须原样透传给 core 的 --control。
3001
+ 顶层 decode 只认 op/text/reason 的话,它们会在 re-marshal 时被丢掉,
3002
+ 症状是客户端明明点了「允许」,core 却回 control_error「需要 request_id」。 */
3003
+ RequestID string `json:"request_id,omitempty"`
3004
+ Allowed *bool `json:"allowed,omitempty"`
3005
+ Message string `json:"message,omitempty"`
3006
+ ToolName string `json:"tool_name,omitempty"`
2425
3007
  }
2426
3008
  if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&cmd); err != nil {
2427
3009
  writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
2428
3010
  return
2429
3011
  }
2430
3012
  if strings.TrimSpace(cmd.Op) == "" {
2431
- writeErr(w, http.StatusBadRequest, "op 不能为空(ping / interrupt / stop / answer)")
3013
+ writeErr(w, http.StatusBadRequest, "op 不能为空(ping / interrupt / stop / answer / ask_answer)")
2432
3014
  return
2433
3015
  }
2434
3016
  b, _ := json.Marshal(cmd)
@@ -2575,8 +3157,28 @@ func (s *Service) handleStatic(w http.ResponseWriter, r *http.Request) {
2575
3157
  writeErr(w, http.StatusInternalServerError, err.Error())
2576
3158
  return
2577
3159
  }
3160
+ /* 工作台在根路径下跑(见 apps.go 的文件头):页面用 '/api/…' 这种根相对地址,
3161
+ 它们带着 Referer=…/workbench/{id}/,先转给工作台 —— 必须先于下面所有静态判定,
3162
+ 否则 '/api/*' 会被当成 UI 目录里的文件(404),页面能出来、数据全空。 */
3163
+ if spec, port, ok := s.workbenchFor(r); ok {
3164
+ s.serveWorkbench(w, r, spec, port, "")
3165
+ return
3166
+ }
2578
3167
  p := strings.TrimPrefix(r.URL.Path, "/")
2579
3168
  if p == "" {
3169
+ // 手机浏览器打开根地址 → 直接给移动端界面(/m/)。
3170
+ // "/" 是 PC 宿主页(见 client-ui/index.html 的文件头),手机上打开只会得到
3171
+ // 被窄屏挤压的桌面布局;移动端有专门的 client-ui-mobile。
3172
+ // 只跳「/m/ 真的能给出页面」的情况(hasMobileUI),否则照旧给 PC 页 —— 否则
3173
+ // 手机用户会落到一个 404 上。
3174
+ if isMobileUA(r.UserAgent()) && s.hasMobileUI() {
3175
+ target := "/m/"
3176
+ if q := r.URL.RawQuery; q != "" {
3177
+ target += "?" + q
3178
+ }
3179
+ http.Redirect(w, r, target, http.StatusFound)
3180
+ return
3181
+ }
2580
3182
  p = s.opts.Profile.UI.Entry
2581
3183
  if p == "" {
2582
3184
  p = "index.html"
@@ -2590,6 +3192,18 @@ func (s *Service) handleStatic(w http.ResponseWriter, r *http.Request) {
2590
3192
  http.ServeFile(w, r, full)
2591
3193
  }
2592
3194
 
3195
+ // isMobileUA 粗判手机浏览器(够用即可,不追求 UA 库的完备度)。
3196
+ // iPadOS 13+ 默认发桌面 UA(Macintosh),这里刻意不认 —— 它本来就更适合桌面布局。
3197
+ func isMobileUA(ua string) bool {
3198
+ ua = strings.ToLower(ua)
3199
+ for _, k := range []string{"iphone", "ipod", "android", "mobile", "windows phone"} {
3200
+ if strings.Contains(ua, k) {
3201
+ return true
3202
+ }
3203
+ }
3204
+ return false
3205
+ }
3206
+
2593
3207
  // mobileRoot 解析移动端界面目录:显式 --mobile-dir 优先,否则取与 --ui-dir 同级的 client-ui-mobile。
2594
3208
  func (s *Service) mobileRoot() string {
2595
3209
  if d := strings.TrimSpace(s.opts.MobileRoot); d != "" {
@@ -2852,10 +3466,16 @@ func isMediaFile(head []byte, path string) bool {
2852
3466
  if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") {
2853
3467
  return true
2854
3468
  }
3469
+ // HTML 产物(2026-10-24):对话内核的产物卡用 <iframe src=/desk/media?path=…> 就地预览
3470
+ // .html/.htm —— 放行 text/html(内容嗅探为主、扩展名兜底,与图 / 视频同一口径)。
3471
+ if strings.HasPrefix(ct, "text/html") {
3472
+ return true
3473
+ }
2855
3474
  switch strings.ToLower(filepath.Ext(path)) {
2856
3475
  case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
2857
3476
  ".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi",
2858
- ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac":
3477
+ ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac",
3478
+ ".html", ".htm", ".xhtml":
2859
3479
  return true
2860
3480
  }
2861
3481
  return false