magic-agent 2.6.0 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -89,6 +89,25 @@ type Options struct {
89
89
  // 打开它 = 任意网页都能**读**响应;真正的门锁仍是令牌(没有 X-Magic-Token 一律 401)。
90
90
  // `--lan` 时默认开启(都绑 0.0.0.0 了,再按 loopback 卡 Origin 没有意义)。
91
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
92
111
  }
93
112
 
94
113
  // Service 本地服务。
@@ -110,6 +129,16 @@ type Service struct {
110
129
  // inst 服务实例的稳定 id(见 instance.go):同一台桌面的多个入口靠它归一。
111
130
  inst instanceIDCache
112
131
 
132
+ /* 本进程的启动时刻(2026-10-27)。
133
+ *
134
+ * 为什么需要:重启换的是**进程**,会话、任务、设置全在磁盘上,所以界面重启前后
135
+ * 长得一模一样 —— 用户点了「重启服务」,看到列表没变,只会得出「没效果」。
136
+ * `instanceId` 帮不上忙:它是**同一台桌面**的稳定身份(多入口归一要靠它),
137
+ * 跨重启必须不变,所以不能用它判断新旧代。
138
+ * 这里给 /desk/health 补一对「这次真的是新进程」的证据:pid + 启动时刻。
139
+ * 只增字段,不升 contractVersion;老插件没有 → 客户端退回原判据。 */
140
+ startedAt time.Time
141
+
113
142
  // 内置浏览器中转用的 HTTP 客户端(惰性建、带 cookie jar,按上游 host 存会话)。
114
143
  // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
115
144
  browseOnce sync.Once
@@ -126,15 +155,41 @@ type Service struct {
126
155
  // 脚本清单与运行态(「发现 → 脚本」,见 scripts.go)。
127
156
  scripts *scriptManager
128
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
+
129
170
  // 随包发布的移动端 APK(空 = 这个包里没带)。见 apk.go。
130
171
  apkPath string
131
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
+
132
181
  /* 公网入口探测(2026-10-22):隧道是**壳**起的,插件不知道它的域名,而 quick tunnel
133
- 每次启动都换域名。这里缓存 probePublicURL 的结果,让 /desk/health 不必每次都扫端口。 */
134
- pubMu sync.Mutex
135
- pubURL string
136
- pubExp time.Time
137
- 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
138
193
 
139
194
  /* 知识库(OpenViking)的最新版本缓存 + 安装互斥(见 knowledge.go)。
140
195
  成功缓存 6h、失败缓存 15min —— 失败不该每次开页面都重试一次外网。
@@ -144,6 +199,10 @@ type Service struct {
144
199
  ovLatestOK bool
145
200
  ovLatestAt time.Time
146
201
  ovBusy bool
202
+
203
+ // 飞书推送(/desk/feishu*,见 feishu.go):惰性建,状态落 ~/.magic-agent/feishu-notify.json。
204
+ feishuOnce sync.Once
205
+ feishuN *feishuNotifier
147
206
  }
148
207
 
149
208
  // Contract core `--contract` 里本插件关心的部分。
@@ -186,10 +245,12 @@ func New(opts Options) (*Service, error) {
186
245
  if opts.Logger == nil {
187
246
  opts.Logger = log.New(os.Stderr, "magic-client: ", 0)
188
247
  }
189
- if strings.TrimSpace(opts.MagicAgent) == "" {
190
- opts.MagicAgent = "magic-agent"
191
- }
192
- 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()}
193
254
  // 移动端 APK:显式路径优先;否则在包内 npm/apk/ 找(随 npm 包发布,见 apk.go)。
194
255
  s.apkPath = strings.TrimSpace(opts.APKPath)
195
256
  if s.apkPath == "" {
@@ -242,6 +303,43 @@ func (s *Service) Contract() Contract { return s.contract }
242
303
  // MagicAgentPath 实际使用的 core 可执行文件。
243
304
  func (s *Service) MagicAgentPath() string { return s.opts.MagicAgent }
244
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
+
245
343
  // Handler 路由。
246
344
  func (s *Service) Handler() http.Handler {
247
345
  mux := http.NewServeMux()
@@ -254,6 +352,11 @@ func (s *Service) Handler() http.Handler {
254
352
  mux.HandleFunc("/desk/app", s.guard(s.handleAppInfo))
255
353
  // 系统浏览器/下载器带不了自定义头,这个接口靠 guard 支持的 ?token= 过鉴权。
256
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))
257
360
  mux.HandleFunc("/desk/engines", s.guard(s.handleEngines))
258
361
  // 每日积分领取(2026-10-24):core --checkin <engine>,用该引擎自己的票据。
259
362
  mux.HandleFunc("/desk/checkin", s.guard(s.handleCheckin))
@@ -280,6 +383,10 @@ func (s *Service) Handler() http.Handler {
280
383
  // 移动端「环境配置」(引擎/模型/团队/空间选择)的远端存储:多客户端连同一台桌面时共用一份,
281
384
  // 不再各存各的 localStorage(见 prefs.go)。
282
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))
283
390
  // 需求聚合(2026-10-22,见 requirements.go):读清单 / 手动整理一轮。
284
391
  // 自动归纳不在这里 —— 它是每轮 /desk/ask 收尾后的防抖动作。
285
392
  mux.HandleFunc("/desk/requirements", s.guard(s.handleRequirements))
@@ -302,6 +409,13 @@ func (s *Service) Handler() http.Handler {
302
409
  // 命令**只来自登记过的脚本定义**(不过 shell),客户端不能传任意命令行。
303
410
  mux.HandleFunc("/desk/scripts", s.guard(s.handleScripts))
304
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)
305
419
  mux.HandleFunc("/desk/dirs", s.guard(s.handleDirs))
306
420
  // /desk/session/{id}/messages 是**前缀匹配**路由 —— 标准 ServeMux 把它挂上后,
307
421
  // 该前缀下任何 path 都先进这里(其它挂载顺序靠后也拦不住)。
@@ -311,7 +425,10 @@ func (s *Service) Handler() http.Handler {
311
425
  mux.HandleFunc("/desk/ask", s.guard(s.handleAsk))
312
426
  mux.HandleFunc("/desk/ask/", s.guard(s.handleAskSub))
313
427
  // 业务模块的路由:原样转发到项目自己的后端(见 Profile.Backend 的注释)。
314
- mux.HandleFunc("/api/", s.guard(s.handleProjectAPI))
428
+ /* /api/* 先看是不是工作台页面发出来的(发现 → 应用):工作台的工作台页面在根路径下跑,
429
+ 页面里的 '/api/…' 是根相对的,不能落到项目后端引导师头上。判定见 apps.go 的 workbenchFor,
430
+ 命中就交给工作台,未命中再照旧(鉴权 + 转给 profile 的 backend)。 */
431
+ mux.HandleFunc("/api/", s.routeAPI)
315
432
  // 业务模块的静态资源:优先项目自己的模块目录(profile 的 ui.modulesDir)。
316
433
  mux.HandleFunc("/modules/", s.handleModulesStatic)
317
434
  // 二维码配对:把「桌面地址 + 令牌」给手机扫。内容等同于令牌,所以用普通鉴权把门
@@ -452,7 +569,12 @@ func (s *Service) guard(next http.HandlerFunc) http.HandlerFunc {
452
569
  func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
453
570
  // core 由本插件 spawn、继承本进程的 cwd;而 core 不传 -w 时默认工作空间就是 cwd。
454
571
  // 所以这里的 cwd 就是「桌面默认工作空间」,客户端用它给空 workspace 的会话归类。
455
- wd, _ := os.Getwd()
572
+ //
573
+ // ⚠️ 走 defaultWorkspace() 而不是直接 os.Getwd()(2026-10-26):插件被 `cd /`
574
+ // 起来时 cwd 是文件系统根,把它当默认工作区会让移动端新建的会话全归到 `/`,
575
+ // 同空间冲突判定与 core 的工作空间锁也随之退化成「全机一个空间」。
576
+ // 规则与作业队列同源(defaultWorkspace:cwd 有意义用它,否则退回上次用的)。
577
+ wd := defaultWorkspace()
456
578
  writeJSON(w, http.StatusOK, map[string]any{
457
579
  "ok": true,
458
580
  "contractVersion": s.contract.ContractVersion,
@@ -473,6 +595,11 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
473
595
  // 再探到该字段为假的 200 才是新一代起来。老插件没有该字段 → 客户端照旧
474
596
  // 「先看到连不上、再看到 200」的判据。
475
597
  "restarting": restartRequested(),
598
+ // 重启交接(2026-10-25,只增字段):本实例受理重启、且已算出新实例的样子时,
599
+ // {urls:[手机可用入口,不带令牌], token:新实例令牌}。手机在「重启服务」的探活里
600
+ // 顺走它,改完原环境的连接地址与令牌再连 —— 地址随网卡变化、令牌重发都不会让
601
+ // 手机永远掉线(见 restart.go 的 restartHandoff)。不重启 / 算不出来 → 缺失。
602
+ "restartHandoff": restartHandoff(),
476
603
  // 服务实例的稳定 id(2026-10-23,只增字段):同一台桌面的本地 / 局域网 / 公网
477
604
  // 入口可能有多条 URL,客户端据此把它们归一成同一个环境(队列与设置共用)。
478
605
  // 老插件没有该字段 → 客户端退回按连接各自算环境。
@@ -496,6 +623,14 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
496
623
  // 团队角色清单(2026-10-22):移动端「团队模式」页要按角色配路由,
497
624
  // 角色名不能由客户端硬编码。老 core 没有 teamRoles → null,客户端回落内置清单。
498
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),
499
634
  })
500
635
  }
501
636
 
@@ -541,9 +676,23 @@ const (
541
676
  // /quicktunnel 里,扫一小段端口就能拿到候选,再用**本服务自己的令牌**回打一次
542
677
  // /desk/health 确认「这条隧道确实指向我」(别人的隧道令牌不同 → 401)。
543
678
  //
544
- // 空返回 = 没开公网 / 还没扫到;调用方(移动端)退回局域网地址,不假装有。
679
+ // 为什么还要落盘(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名就可以了」):
680
+ // 域名只随**隧道**重启而变,magic-client 重启并不碰 cloudflared。可探测结果只在内存里,
681
+ // 客户端一重启就空,手机就退回局域网地址 —— 表现成「重启后地址变了」。所以启动先读回
682
+ // 上次的域名立刻顶上,后台探测再纠正(见 publicurl.go)。
683
+ //
684
+ // 空返回 = 没开公网 / 已确认没了;调用方(移动端)退回局域网地址,不假装有。
545
685
  func (s *Service) publicURL() string {
546
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
+ }
547
696
  cur, exp, busy := s.pubURL, s.pubExp, s.pubBusy
548
697
  s.pubMu.Unlock()
549
698
  if busy || time.Now().Before(exp) {
@@ -557,31 +706,95 @@ func (s *Service) publicURL() string {
557
706
  s.pubBusy = true
558
707
  s.pubMu.Unlock()
559
708
 
560
- go func() {
561
- u := s.probePublicURL()
562
- s.pubMu.Lock()
563
- changed := u != s.pubURL
564
- s.pubURL = u
565
- s.pubExp = time.Now().Add(publicTTL)
566
- s.pubBusy = false
567
- s.pubMu.Unlock()
568
- if changed && s.log != nil {
569
- if u == "" {
570
- s.log.Printf("公网入口:未发现(本机没有指向本服务的 cloudflared quick tunnel)")
571
- } else {
572
- s.log.Printf("公网入口:%s", u)
573
- }
574
- }
575
- }()
709
+ go func() { s.applyProbeResult(s.probePublicURL()) }()
576
710
  return cur
577
711
  }
578
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
+
579
758
  // probePublicURL 扫 cloudflared 的 metrics 端口,找出指向本服务的那条 quick tunnel。
580
759
  func (s *Service) probePublicURL() string {
581
760
  tok := strings.TrimSpace(s.opts.Token)
582
761
  if tok == "" {
583
762
  return ""
584
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 {
585
798
  hosts := make(chan string, quickTunnelPortHi-quickTunnelPortLo+1)
586
799
  for p := quickTunnelPortLo; p <= quickTunnelPortHi; p++ {
587
800
  go func(port int) {
@@ -608,46 +821,32 @@ func (s *Service) probePublicURL() string {
608
821
  cands = append(cands, h)
609
822
  }
610
823
  }
611
- if len(cands) == 0 {
612
- return ""
613
- }
824
+ return cands
825
+ }
614
826
 
615
- // 用本服务的令牌回打一次:只有真正指向本服务的隧道才认这个令牌。
616
- var (
617
- mu sync.Mutex
618
- found string
619
- wg sync.WaitGroup
620
- )
621
- for _, h := range cands {
622
- wg.Add(1)
623
- go func(host string) {
624
- defer wg.Done()
625
- req, err := http.NewRequest(http.MethodGet, "https://"+host+"/desk/health", nil)
626
- if err != nil {
627
- return
628
- }
629
- req.Header.Set("X-Magic-Token", tok)
630
- resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req)
631
- if err != nil {
632
- return
633
- }
634
- defer resp.Body.Close()
635
- if resp.StatusCode != http.StatusOK {
636
- return
637
- }
638
- var v map[string]any
639
- if json.NewDecoder(resp.Body).Decode(&v) != nil || v["ok"] != true {
640
- return
641
- }
642
- mu.Lock()
643
- if found == "" {
644
- found = "https://" + host
645
- }
646
- mu.Unlock()
647
- }(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
648
833
  }
649
- wg.Wait()
650
- 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
651
850
  }
652
851
 
653
852
  // handleContract 原样转发 core 的契约(客户端拿它做能力降级,不需要经过插件再解释一遍)。
@@ -677,11 +876,54 @@ func (s *Service) handleProfile(w http.ResponseWriter, r *http.Request) {
677
876
  // (用户 2026-10-15:「通过 magic-test 新建的会话要在 magic-agent 移动端 h5 上看到,
678
877
  // 同时 magic-agent 上可以看到 magic-test 和 magic-agent 来源的会话」)。
679
878
  //
680
- // 名单两项:magic-test = 掌天瓶桌面壳;magic-agent = core 自身/直连 CLI 的显式标识。
681
- // ⚠️ 只放行**同族的 magic-* 来源**:匿名(空 client = 手工跑 / CI)与不相干应用仍被挡在
682
- // 外面 —— 这正是原过滤要避免的「把别人的任务混进本应用的会话列表」。
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
+ // 那正是这次过滤要避免的「把别人的任务混进本应用的会话列表」。
683
888
  var extraSessionClients = []string{"magic-test", "magic-agent"}
684
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
+
685
927
  // handleSessions 会话登记表(core 的 `--sessions`):列表页的「最近会话」用它。
686
928
  // 与 /desk/asks 的区别:asks 是**本进程正在跑的轮次**(内存里),sessions 是 core 的
687
929
  // 持久登记表(跨进程、含已结束的)。
@@ -690,11 +932,17 @@ var extraSessionClients = []string{"magic-test", "magic-agent"}
690
932
  // (~/.magic-agent/sessions),直接跑 CLI(匿名)、CI、别的 magic-* 应用都在往里写 ——
691
933
  // 原样转发等于把别人的任务混进本应用的会话列表。认客户端靠 core 落的 Record.Client
692
934
  // (本插件在 /desk/ask 时注入自己的 AppID;掌天瓶桌面壳注入 magic-test)。
693
- // 放行名单见 extraSessionClients(2026-10-15 起,不再是「只认自己」)。
935
+ // 放行判据见 sessionClientVisible(2026-10-26 起按**同族前缀**判,不再认死名字)。
694
936
  func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
695
937
  ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
696
938
  defer cancel()
697
- out, err := newCoreCmd(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
+ })
698
946
  if err != nil {
699
947
  writeErr(w, http.StatusBadGateway,
700
948
  fmt.Sprintf("%s --sessions 失败:%v", s.opts.MagicAgent, err))
@@ -710,17 +958,18 @@ func (s *Service) handleSessions(w http.ResponseWriter, r *http.Request) {
710
958
  _, _ = w.Write(out)
711
959
  return
712
960
  }
713
- allowed := map[string]bool{s.opts.Profile.AppID(): true}
714
- for _, c := range extraSessionClients {
715
- allowed[c] = true
716
- }
961
+ selfID := s.opts.Profile.AppID()
717
962
  mine := make([]map[string]any, 0, len(rows))
718
963
  for _, row := range rows {
719
- if c, _ := row["client"].(string); allowed[c] {
964
+ c, _ := row["client"].(string)
965
+ if sessionClientVisible(c, selfID) {
720
966
  mine = append(mine, row)
721
967
  }
722
968
  }
723
- writeJSON(w, http.StatusOK, mine)
969
+ /* 按会话折叠多轮记录(见 sessions_fold.go):411 行里 302 行是同一会话的其它轮次,
970
+ 列表是**会话**视图,一屏也只画 60 行 —— 「最新那条会话排不进去」正是折叠前的问题。
971
+ 顺序在过滤之后:可见性逐行判(只看 client),折叠再按 session_id 归并,两步互不干扰。 */
972
+ writeJSON(w, http.StatusOK, foldSessionRuns(mine))
724
973
  }
725
974
 
726
975
  // handleSessionMessages 「看历史」持久化落盘的会话事件(`GET /desk/session/{id}/messages`)。
@@ -1090,6 +1339,8 @@ func (s *Service) handleSessionTitle(w http.ResponseWriter, r *http.Request, id
1090
1339
  writeErr(w, http.StatusInternalServerError, "保存标题失败:"+err.Error())
1091
1340
  return
1092
1341
  }
1342
+ // 改名后列表里的标题要立刻变(见 sessions_cache.go)。
1343
+ s.invalidateSessionsCache()
1093
1344
  t := strings.TrimSpace(body.Title)
1094
1345
  if t == "" {
1095
1346
  t = session.TitleFor(id)
@@ -1145,6 +1396,10 @@ func (s *Service) handleSessionTitleSummarize(w http.ResponseWriter, r *http.Req
1145
1396
  writeErr(w, http.StatusBadGateway, "core 回的归纳结果不是 JSON:"+truncateForMsg(string(out)))
1146
1397
  return
1147
1398
  }
1399
+ /* 归纳出了新标题 → 列表要立刻换(见 sessions_cache.go)。这条尤其重要:客户端
1400
+ 补归纳是「列表画完之后逐条异步打」的(移动端 ensureTitle),如果缓存不失效,
1401
+ 归纳结果要等一个 TTL 才反映到列表上,用户会以为没生效。 */
1402
+ s.invalidateSessionsCache()
1148
1403
  writeJSON(w, http.StatusOK, res)
1149
1404
  }
1150
1405
 
@@ -1173,6 +1428,9 @@ func truncateForMsg(s string) string {
1173
1428
  // 还没拿到轮次 id」)。
1174
1429
  func (s *Service) handleSessionStop(w http.ResponseWriter, r *http.Request, id string) {
1175
1430
  res, err := session.Stop(id)
1431
+ /* 停会话改了登记表的 state → 列表必须立刻反映(否则用户看到「停了但还在跑」
1432
+ 停留一个 TTL)。见 sessions_cache.go。缓存没建立时这一步是空操作。 */
1433
+ s.invalidateSessionsCache()
1176
1434
  if err != nil {
1177
1435
  writeErr(w, http.StatusNotFound, fmt.Sprintf("停止会话 %s 失败:%v", id, err))
1178
1436
  return
@@ -1202,6 +1460,8 @@ func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
1202
1460
  return
1203
1461
  }
1204
1462
  res, err := session.Delete(id)
1463
+ // 删掉了一条 → 列表必须立刻反映(见 sessions_cache.go)。
1464
+ s.invalidateSessionsCache()
1205
1465
  if err != nil {
1206
1466
  writeErr(w, http.StatusBadRequest, fmt.Sprintf("删除会话 %s 失败:%v", id, err))
1207
1467
  return
@@ -1598,6 +1858,18 @@ func (s *Service) handleModules(w http.ResponseWriter, r *http.Request) {
1598
1858
  // 2. **凭据服务端注入**:profile 里的 backend.token 由插件加进请求头,不进浏览器。
1599
1859
  // 3. **支持流式**:项目的后端可能用 SSE 推进度(magic-test 的派发流就是),
1600
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
+
1601
1873
  func (s *Service) handleProjectAPI(w http.ResponseWriter, r *http.Request) {
1602
1874
  target := strings.TrimSpace(s.opts.Profile.Backend.URL)
1603
1875
  if target == "" || s.backend == nil {
@@ -2088,6 +2360,17 @@ type askRequest struct {
2088
2360
  // Serial 是否参与「同一 workspace 单槽」串行(nil / true = 参与)。
2089
2361
  // 客户端「并行模式」显式传 false;服务端仍会套一层全局并发上限。
2090
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"`
2091
2374
  }
2092
2375
 
2093
2376
  // teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
@@ -2366,7 +2649,17 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2366
2649
 
2367
2650
  ctx := r.Context()
2368
2651
  run, ok := s.jobs.waitStarted(ctx, job, func(pos int) {
2369
- 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)
2370
2663
  })
2371
2664
  if !ok {
2372
2665
  /* 排队期间客户端断开:匿名提交(没有 request_id)没人会再来接,直接撤掉;
@@ -2503,6 +2796,24 @@ func (s *Service) prepareAsk(req askRequest) (string, []string, int, error) {
2503
2796
  return engine, args, http.StatusOK, nil
2504
2797
  }
2505
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
+
2506
2817
  /*
2507
2818
  startAsk 真正拉起 core 并开始吸收 stdout(不绑任何 HTTP 请求)。
2508
2819
 
@@ -2515,17 +2826,15 @@ func (s *Service) startAsk(req askRequest, engine string, args []string, source
2515
2826
  cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
2516
2827
  /* 两个「谁发起的」维度随环境变量传给 core,落到会话登记表:
2517
2828
  · EnvSource 设备形态(mobile / pc)—— 按请求 Origin 判定,列表页显示设备标志;
2518
- · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**。
2519
- 工作空间锁(选入式,见 internal/session/workspace_lock.go)**只对串行作业注入**:
2520
- core 在 Begin 时先抢锁再写 running,让直接跑 CLI 的进程也参与同一套串行;
2521
- 并行作业注入了锁就会在同空间的 core Begin 处重新排队,把「并行」架空 ——
2522
- 模式互斥的裁决只在队列这一层做(jobs.go dispatch)。 */
2523
- 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()),
2524
2835
  session.EnvSource+"="+source,
2525
2836
  session.EnvClient+"="+s.opts.Profile.AppID())
2526
- if req.Serial == nil || *req.Serial {
2527
- cmd.Env = append(cmd.Env, session.EnvWorkspaceLock+"=1")
2528
- }
2837
+ cmd.Env = append(cmd.Env, workspaceLockEnv(req.Serial)...)
2529
2838
  stdin, err := cmd.StdinPipe()
2530
2839
  if err != nil {
2531
2840
  return nil, fmt.Errorf("stdin pipe: %w", err)
@@ -2848,6 +3157,13 @@ func (s *Service) handleStatic(w http.ResponseWriter, r *http.Request) {
2848
3157
  writeErr(w, http.StatusInternalServerError, err.Error())
2849
3158
  return
2850
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
+ }
2851
3167
  p := strings.TrimPrefix(r.URL.Path, "/")
2852
3168
  if p == "" {
2853
3169
  // 手机浏览器打开根地址 → 直接给移动端界面(/m/)。