magic-agent 2.3.0 → 2.3.9

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 (59) hide show
  1. package/CLIENT-CONTRACT.md +78 -13
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  7. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  8. package/client-ui/shared/conv/conv.css +17 -0
  9. package/client-ui/shared/conversation.js +209 -39
  10. package/client-ui/shared/transport-http.js +193 -38
  11. package/client-ui-mobile/index.html +733 -107
  12. package/client-ui-mobile/reqs.js +42 -29
  13. package/internal/agent/capability.go +8 -1
  14. package/internal/agent/claude.go +5 -3
  15. package/internal/agent/codebuddy.go +6 -2
  16. package/internal/agent/codex.go +7 -2
  17. package/internal/agent/dsh_sdk.go +8 -0
  18. package/internal/agent/dsh_sdk_test.go +17 -0
  19. package/internal/agent/engine.go +20 -0
  20. package/internal/agent/engine_test.go +52 -0
  21. package/internal/agent/llmengine.go +6 -0
  22. package/internal/agent/llmengine_test.go +7 -0
  23. package/internal/agent/llmhttp.go +6 -0
  24. package/internal/agent/llmhttp_test.go +27 -0
  25. package/internal/agent/llmmodels.go +13 -0
  26. package/internal/agent/permission.go +5 -0
  27. package/internal/cli/ask.go +32 -17
  28. package/internal/cli/requirements.go +2 -0
  29. package/internal/cli/sessionlog.go +5 -0
  30. package/internal/cli/sessionlog_user_test.go +49 -0
  31. package/internal/client/cors_test.go +32 -0
  32. package/internal/client/jobs.go +1160 -0
  33. package/internal/client/jobs_test.go +369 -0
  34. package/internal/client/requirements.go +27 -7
  35. package/internal/client/requirements_test.go +51 -0
  36. package/internal/client/service.go +259 -142
  37. package/internal/client/service_test.go +78 -0
  38. package/internal/requirements/consolidate.go +86 -24
  39. package/internal/requirements/requirements.go +9 -0
  40. package/internal/requirements/requirements_test.go +129 -0
  41. package/internal/requirements/store.go +11 -3
  42. package/internal/session/session.go +15 -1
  43. package/internal/session/workspace_lock.go +165 -0
  44. package/internal/session/workspace_lock_test.go +90 -0
  45. package/internal/suggest/suggest.go +101 -7
  46. package/internal/suggest/suggest_test.go +44 -0
  47. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  48. package/npm/apk/version.json +3 -3
  49. package/npm/dist/darwin-arm64/magic-agent +0 -0
  50. package/npm/dist/darwin-arm64/magic-client +0 -0
  51. package/npm/dist/darwin-x64/magic-agent +0 -0
  52. package/npm/dist/darwin-x64/magic-client +0 -0
  53. package/npm/dist/linux-arm64/magic-agent +0 -0
  54. package/npm/dist/linux-arm64/magic-client +0 -0
  55. package/npm/dist/linux-x64/magic-agent +0 -0
  56. package/npm/dist/linux-x64/magic-client +0 -0
  57. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  58. package/npm/dist/win32-x64/magic-client.exe +0 -0
  59. package/package.json +3 -2
@@ -49,6 +49,13 @@ const RequiredContractVersion = 2
49
49
  // 没有 events 就没有带游标的事件流,没有 control 就无法打断与回审批。
50
50
  var requiredFeatures = []string{"feature.session.events", "feature.session.control"}
51
51
 
52
+ // FeatureSessionHosted core 的「会话托管历史标识」能力位(与 internal/agent 同值)。
53
+ //
54
+ // 可选特性:老 core 没有它,插件照常发送,只是不追加 --hosted ——
55
+ // 移动端实时气泡仍由客户端自己标,历史回放则退回「用户亲手发的」画法。
56
+ // 不塞进 requiredFeatures,否则升插件就得同步升 core。
57
+ const FeatureSessionHosted = "feature.session.hosted"
58
+
52
59
  // Options 启动参数。
53
60
  type Options struct {
54
61
  // MagicAgent core 可执行文件;空 = 从 PATH 找 magic-agent。
@@ -88,6 +95,9 @@ type Service struct {
88
95
  runs map[string]*askRun
89
96
  seq int64
90
97
 
98
+ // jobs 服务端作业队列(同一 workspace 串行 + 幂等 + 持久化),见 jobs.go。
99
+ jobs *jobQueue
100
+
91
101
  // 内置浏览器中转用的 HTTP 客户端(惰性建、带 cookie jar,按上游 host 存会话)。
92
102
  // 见 browse.go —— 它让 iframe 的流量走**桌面端网络**,而不是手机直连。
93
103
  browseOnce sync.Once
@@ -189,6 +199,9 @@ func New(opts Options) (*Service, error) {
189
199
  return nil, fmt.Errorf("core 缺通道能力 %s(需要支持 --stream --events 与 --control 的版本)", f)
190
200
  }
191
201
  }
202
+ /* 作业队列:服务端权威的排队层(多客户端串行 / 幂等 / 重启恢复)。
203
+ 必须在 New 里就建好:/desk/ask 第一次进来就要能入队。 */
204
+ s.jobs = newJobQueue(s)
192
205
  return s, nil
193
206
  }
194
207
 
@@ -211,6 +224,10 @@ func (s *Service) Handler() http.Handler {
211
224
  mux.HandleFunc("/desk/profile", s.guard(s.handleProfile))
212
225
  mux.HandleFunc("/desk/modules", s.guard(s.handleModules))
213
226
  mux.HandleFunc("/desk/sessions", s.guard(s.handleSessions))
227
+ /* 服务端作业队列(2026-10-23):跨客户端提交 / 查询 / 取消 / 事件。
228
+ /desk/ask 也走同一个调度器,这里给需要「先入队、不挂长连接」的客户端用。 */
229
+ mux.HandleFunc("/desk/jobs", s.guard(s.handleJobs))
230
+ mux.HandleFunc("/desk/jobs/", s.guard(s.handleJobsSub))
214
231
  mux.HandleFunc("/desk/upload", s.guard(s.handleUpload))
215
232
  // 回读已上传附件(缩略图 / 历史回看):<img> 带不了自定义头,靠 guard 支持的 ?token= 过鉴权。
216
233
  mux.HandleFunc("/desk/uploads/", s.guard(s.handleUploadGet))
@@ -294,7 +311,13 @@ func (s *Service) withCORS(next http.Handler) http.Handler {
294
311
  w.Header().Add("Vary", "Origin")
295
312
  if localOrigin(origin) {
296
313
  w.Header().Set("Access-Control-Allow-Origin", origin)
297
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
314
+ /* ⚠️ 写方法必须点名(2026-10-23 用户:「删除无法删除」)。
315
+ 只写 GET/POST 时,容器里(源=https://localhost)的删除 / 重命名 / 置顶同步
316
+ 全是带 X-Magic-Token 的 DELETE、PUT → 必发 OPTIONS 预检 → 浏览器看到
317
+ DELETE 未被允许,**真请求根本发不出去**(fetch 直接 reject)。
318
+ 同源页面(/m/ 远程加载、桌面壳)不走预检,所以只有打包进 App 的那侧坏 ——
319
+ curl 也永远看不见这条,必须靠 cors_test 钉住。 */
320
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
298
321
  w.Header().Set("Access-Control-Allow-Headers", "X-Magic-Token, Content-Type")
299
322
  w.Header().Set("Access-Control-Max-Age", "600")
300
323
  }
@@ -389,6 +412,9 @@ func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
389
412
  "engines": len(s.contract.Engines),
390
413
  "defaultWorkspace": wd,
391
414
  "workspacesRoot": filepath.Dir(wd),
415
+ // 服务端作业队列(2026-10-23):有它才谈得上「多客户端串行 + 幂等」。
416
+ // 老插件没有该字段 → 客户端退回本地排队(见 client-ui-mobile 的 fallback)。
417
+ "jobs": true,
392
418
  // 远程移动端界面在不在(见 handleMobileUI)。
393
419
  // 客户端的闸门页要据此决定「跳远程 /m/ 还是用包里自带的 ui.html」。
394
420
  // 为什么不放在 features 里:features 是**core 的能力位**(s.contract.Features),
@@ -674,8 +700,15 @@ func (s *Service) handleSessionMessages(w http.ResponseWriter, r *http.Request)
674
700
  }
675
701
  return
676
702
  }
703
+ /* 停止这一轮:**跨进程**用 —— 手机 / 另一台设备只尾随了别人进程里跑的那一轮,
704
+ 手里没有 ask id(/desk/ask/{id}/control 打不到),只能按会话 id 停。
705
+ 与 core 的 `--stop` 同一条路(登记表里的 pid + SIGTERM)。 */
706
+ if ok && action == "stop" && r.Method == http.MethodPost {
707
+ s.handleSessionStop(w, r, id)
708
+ return
709
+ }
677
710
  writeErr(w, http.StatusMethodNotAllowed,
678
- "POST / PUT 只支持 /desk/session/{id}/title 与 /desk/session/{id}/title/summarize")
711
+ "POST / PUT 只支持 /desk/session/{id}/title、/desk/session/{id}/title/summarize 与 /desk/session/{id}/stop")
679
712
  return
680
713
  }
681
714
  if r.Method != http.MethodGet {
@@ -1064,6 +1097,27 @@ func truncateForMsg(s string) string {
1064
1097
  return string(r[:cap]) + "…"
1065
1098
  }
1066
1099
 
1100
+ // handleSessionStop 停止某条会话正在跑的那一轮(跨进程)。
1101
+ //
1102
+ // 语义与 core 的 `magic-agent --stop <id>` 完全一致(session.Stop:SIGTERM 进程组,
1103
+ // 宽限期不退再 SIGKILL,最后把状态写成 cancelled):
1104
+ // - id 收 session_id 或 run_id;
1105
+ // - 幂等:进程已不在 / 会话已结束 → stopped=false + reason,仍是 200;
1106
+ // - 找不到 id → 404(多半是 id 写错了)。
1107
+ //
1108
+ // 为什么不让客户端一律走 /desk/ask/{id}/control:那个 id 只存在于**拉起 core 的那个
1109
+ // 进程**里。手机、另一台设备、桌面壳直接 exec 的轮次都只能读盘尾随(见 /desk/session/{id}/follow),
1110
+ // 手里没有 ask id;这时不看登记表就永远停不下来(用户侧表现:「点了停止只回一句
1111
+ // 还没拿到轮次 id」)。
1112
+ func (s *Service) handleSessionStop(w http.ResponseWriter, r *http.Request, id string) {
1113
+ res, err := session.Stop(id)
1114
+ if err != nil {
1115
+ writeErr(w, http.StatusNotFound, fmt.Sprintf("停止会话 %s 失败:%v", id, err))
1116
+ return
1117
+ }
1118
+ writeJSON(w, http.StatusOK, res)
1119
+ }
1120
+
1067
1121
  // handleSessionDelete DELETE /desk/session/{id}:删掉一个会话 —— 登记表记录(可多条 run)
1068
1122
  // 与落盘的事件日志。id 可以是 session_id 或 run_id。
1069
1123
  //
@@ -1783,6 +1837,21 @@ type askRequest struct {
1783
1837
  // 方式,不是这台机器的属性。开关状态由客户端自己记(localStorage),每轮随 body
1784
1838
  // 下发 —— core 因此完全无状态,「刚才那条为什么走了团队」在会话登记表里查得到。
1785
1839
  Team *teamRequest `json:"team,omitempty"`
1840
+ // Hosted 本轮提问由客户端「会话托管」自动发送(2026-10-23,只增字段)。
1841
+ //
1842
+ // 只用于把 `kind:"user"` 历史事件标成 hosted(客户端回放画成「托管发送」),
1843
+ // 不改变执行语义。core 没有 feature.session.hosted 时插件**忽略**它 ——
1844
+ // 实时气泡仍由客户端自己标,老 core 上只是历史回放少一个标识,不该阻断发送。
1845
+ Hosted bool `json:"hosted,omitempty"`
1846
+ // RequestID 客户端生成的幂等键(2026-10-23,只增字段)。
1847
+ //
1848
+ // 同一个 id 重复提交(断线重连 / App 重启 / 两个标签页同时发)只执行一次:
1849
+ // 服务端按它找到原作业并接回去,见 jobs.go。空 = 旧客户端,按匿名提交处理
1850
+ // (仍参与 workspace 串行,但没有幂等保护)。
1851
+ RequestID string `json:"request_id,omitempty"`
1852
+ // Serial 是否参与「同一 workspace 单槽」串行(nil / true = 参与)。
1853
+ // 客户端「并行模式」显式传 false;服务端仍会套一层全局并发上限。
1854
+ Serial *bool `json:"serial,omitempty"`
1786
1855
  }
1787
1856
 
1788
1857
  // teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
@@ -1880,7 +1949,7 @@ const (
1880
1949
  // 游标(handleAskEvents 里 `after == 0` 才放行它);
1881
1950
  // - `prompt_id` 是客户端去重的键:core 首行到达前客户端会反复以 after=0 轮询,没有它
1882
1951
  // 同一句提问会被画很多遍(见 client-ui/shared/conversation.js 的 render)。
1883
- func (r *askRun) appendPrompt(text string, attachments []string) {
1952
+ func (r *askRun) appendPrompt(text string, attachments []string, hosted bool) {
1884
1953
  atts := make([]map[string]string, 0, len(attachments))
1885
1954
  for _, p := range attachments {
1886
1955
  if p = strings.TrimSpace(p); p != "" {
@@ -1896,6 +1965,10 @@ func (r *askRun) appendPrompt(text string, attachments []string) {
1896
1965
  if len(atts) > 0 {
1897
1966
  ev["attachments"] = atts
1898
1967
  }
1968
+ /* 与 core 落盘的同名字段同义:断线续传画回来的那条也要是「托管发送」。 */
1969
+ if hosted {
1970
+ ev["hosted"] = true
1971
+ }
1899
1972
  b, err := json.Marshal(ev)
1900
1973
  if err != nil {
1901
1974
  return
@@ -1965,6 +2038,19 @@ func (r *askRun) finish(code int) {
1965
2038
  r.mu.Unlock()
1966
2039
  }
1967
2040
 
2041
+ // snapshot 取 seq > after 的事件(after=0 时额外放行网关合成的用户提问,seq=0)。
2042
+ // 与 handleAskEvents 的返回口径一致,供 SSE 中继与作业事件共用。
2043
+ func (r *askRun) snapshot(after uint64) (out []askEvent, lastSeq uint64, done bool, code int, stderr string) {
2044
+ r.mu.Lock()
2045
+ defer r.mu.Unlock()
2046
+ for _, e := range r.events {
2047
+ if e.seq > after || (e.seq == 0 && after == 0) {
2048
+ out = append(out, e)
2049
+ }
2050
+ }
2051
+ return out, r.seq, r.ended, r.code, r.stderr.String()
2052
+ }
2053
+
1968
2054
  // handleAsk 发起一轮并**把 core 的 NDJSON 事件原样中继**给调用方(SSE)。
1969
2055
  //
1970
2056
  // 中继而不是解释:事件契约的解析权在客户端(桌面 / 移动端各一份实现),
@@ -1979,18 +2065,79 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
1979
2065
  writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
1980
2066
  return
1981
2067
  }
1982
- if strings.TrimSpace(req.Prompt) == "" {
1983
- writeErr(w, http.StatusBadRequest, "prompt 不能为空")
2068
+ engine, _, st, perr := s.prepareAsk(req)
2069
+ if perr != nil {
2070
+ writeErr(w, st, perr.Error())
2071
+ return
2072
+ }
2073
+
2074
+ /* 从这里开始交给服务端调度器(见 jobs.go):
2075
+ · 同一 workspace 同时只跑一条;
2076
+ · request_id 相同 = 同一次提交,重连 / 重发不会多跑一遍;
2077
+ · 排队期间在 SSE 里发 `queued`(老客户端忽略未知事件),开跑时再发 `open`。 */
2078
+ source := requestSource(req.Client, r.Header.Get("Origin"))
2079
+ job, created, jerr := s.jobs.submitAsk(req, engine, source)
2080
+ if jerr != nil {
2081
+ writeErr(w, http.StatusBadRequest, jerr.Error())
2082
+ return
2083
+ }
2084
+
2085
+ // SSE 头必须在**第一帧之前**发出,否则客户端拿到的是 application/json。
2086
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
2087
+ w.Header().Set("Cache-Control", "no-cache")
2088
+ w.Header().Set("X-Accel-Buffering", "no")
2089
+ flusher, _ := w.(http.Flusher)
2090
+ send := func(event string, payload any) {
2091
+ b, _ := json.Marshal(payload)
2092
+ if event != "" {
2093
+ fmt.Fprintf(w, "event: %s\n", event)
2094
+ }
2095
+ fmt.Fprintf(w, "data: %s\n\n", b)
2096
+ if flusher != nil {
2097
+ flusher.Flush()
2098
+ }
2099
+ }
2100
+
2101
+ ctx := r.Context()
2102
+ run, ok := s.jobs.waitStarted(ctx, job, func(pos int) {
2103
+ send("queued", map[string]any{"job": job.ID, "state": JobQueued, "position": pos})
2104
+ })
2105
+ if !ok {
2106
+ /* 排队期间客户端断开:匿名提交(没有 request_id)没人会再来接,直接撤掉;
2107
+ 带 request_id 的留着 —— 客户端重启后会按同一个 id 接回来。 */
2108
+ if ctx.Err() != nil && created && strings.TrimSpace(req.RequestID) == "" {
2109
+ s.jobs.cancel(job.ID, "client disconnected")
2110
+ }
2111
+ /* 开跑前就终结(启动失败 / 被取消 / 排队过期):补一帧 end,
2112
+ 否则客户端只能看到连接被关掉、不知道原因。 */
2113
+ if jv := s.jobs.get(job.ID); jv != nil && jv.terminal() {
2114
+ code := -1
2115
+ if jv.State == JobDone {
2116
+ code = 0
2117
+ }
2118
+ send("end", map[string]any{"id": job.ID, "code": code, "stderr": jv.Error, "state": jv.State})
2119
+ }
1984
2120
  return
1985
2121
  }
2122
+ send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent, "job": job.ID})
2123
+ s.relayRun(ctx, w, run, flusher, send)
2124
+ }
2125
+
2126
+ /*
2127
+ prepareAsk 校验请求并拼出 core 参数(/desk/ask 与 /desk/jobs 共用一份)。
2128
+
2129
+ 返回 (engine, args, HTTP 状态码, error);error 非空时状态码用于直接回给客户端。
2130
+ */
2131
+ func (s *Service) prepareAsk(req askRequest) (string, []string, int, error) {
2132
+ if strings.TrimSpace(req.Prompt) == "" {
2133
+ return "", nil, http.StatusBadRequest, fmt.Errorf("prompt 不能为空")
2134
+ }
1986
2135
  engine := strings.TrimSpace(req.Engine)
1987
2136
  if engine == "" {
1988
- writeErr(w, http.StatusBadRequest, "engine 不能为空(清单见 /desk/engines)")
1989
- return
2137
+ return "", nil, http.StatusBadRequest, fmt.Errorf("engine 不能为空(清单见 /desk/engines)")
1990
2138
  }
1991
2139
  if !s.opts.Profile.AllowsEngine(engine) {
1992
- writeErr(w, http.StatusForbidden, fmt.Sprintf("profile 白名单不允许引擎 %q", engine))
1993
- return
2140
+ return "", nil, http.StatusForbidden, fmt.Errorf("profile 白名单不允许引擎 %q", engine)
1994
2141
  }
1995
2142
 
1996
2143
  idle := strings.TrimSpace(req.Idle)
@@ -2039,14 +2186,12 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2039
2186
  /* 老 core 不认识 --team(exit 2),这里先挡下并**说清楚为什么**:
2040
2187
  让用户看到「core 太旧,请升级」比让它跑一轮后报 usage error 好得多。 */
2041
2188
  if !hasStr(s.contract.Features, FeatureSessionTeam) {
2042
- writeErr(w, http.StatusNotImplemented,
2043
- "当前 core 不支持团队模式(缺少 "+FeatureSessionTeam+" 能力位,请升级 magic-agent)")
2044
- return
2189
+ return "", nil, http.StatusNotImplemented,
2190
+ fmt.Errorf("当前 core 不支持团队模式(缺少 %s 能力位,请升级 magic-agent)", FeatureSessionTeam)
2045
2191
  }
2046
2192
  teamArgs, terr := teamFlags(req.Team, s.opts.Profile, s.teamRoleNames())
2047
2193
  if terr != nil {
2048
- writeErr(w, http.StatusBadRequest, terr.Error())
2049
- return
2194
+ return "", nil, http.StatusBadRequest, terr
2050
2195
  }
2051
2196
  args = append(args, teamArgs...)
2052
2197
  }
@@ -2062,49 +2207,51 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2062
2207
  }
2063
2208
  st, err := os.Stat(p)
2064
2209
  if err != nil {
2065
- writeErr(w, http.StatusBadRequest, "附件不可访问:"+p)
2066
- return
2210
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件不可访问:%s", p)
2067
2211
  }
2068
2212
  if !st.Mode().IsRegular() {
2069
- writeErr(w, http.StatusBadRequest, "附件不是普通文件:"+p)
2070
- return
2213
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件不是普通文件:%s", p)
2071
2214
  }
2072
2215
  if st.Size() > maxUploadBytes {
2073
- writeErr(w, http.StatusBadRequest, fmt.Sprintf("附件超过 %dMB:%s", maxUploadBytes>>20, p))
2074
- return
2216
+ return "", nil, http.StatusBadRequest, fmt.Errorf("附件超过 %dMB:%s", maxUploadBytes>>20, p)
2075
2217
  }
2076
2218
  args = append(args, "-a", p)
2077
2219
  }
2078
2220
  args = append(args, "-p", req.Prompt)
2221
+ /* 托管标识:老 core 不认识 --hosted(exit 2),所以先看能力位;缺了就不传 ——
2222
+ 发送本身不能因为这个**纯显示**字段被阻断(实时气泡由客户端自己标)。 */
2223
+ if req.Hosted && hasStr(s.contract.Features, FeatureSessionHosted) {
2224
+ args = append(args, "--hosted")
2225
+ }
2226
+ return engine, args, http.StatusOK, nil
2227
+ }
2079
2228
 
2080
- /* 子进程**不绑 HTTP 请求的生命周期**(2026-10-19):此前用 r.Context(),手机端 SSE
2081
- 一断(锁屏 / 切后台 / WebView 回收 / 页面刷新)就会 SIGKILL core —— core 来不及写
2082
- 终态,登记表里那条会话就永远停在 running:界面显示「一直不结束」,移动端的串行门
2083
- (同空间有 running 就排队)还会被它永久挡住,新任务发不出去。
2084
- 客户端断连只意味着「没人看直播了」,不是「取消任务」;任务只应由显式控制
2085
- (/desk/ask/{id}/control 的 stop)或自然结束来终止。
2086
- WithoutCancel 保留请求里的值(便于排障),但不继承取消信号。
2087
- 也**不能**在这里 WithCancel + defer cancel:handler 断连就会返回,defer 一执行
2088
- 就等于把仍在跑的任务杀掉 —— 那正是要修的行为。 */
2089
- ctx := context.WithoutCancel(r.Context())
2229
+ /*
2230
+ startAsk 真正拉起 core 并开始吸收 stdout(不绑任何 HTTP 请求)。
2090
2231
 
2232
+ 排队与否由 jobs.go 决定;这里只负责「起了之后」的事:
2233
+ stdout 全部进 askRun 的回放缓冲,谁来读(SSE / /desk/ask/{id}/events / 作业事件)
2234
+ 都用同一份,断连不影响任务本身。
2235
+ */
2236
+ func (s *Service) startAsk(req askRequest, engine string, args []string, source string) (*askRun, error) {
2237
+ ctx := context.WithoutCancel(context.Background())
2091
2238
  cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
2092
2239
  /* 两个「谁发起的」维度随环境变量传给 core,落到会话登记表:
2093
2240
  · EnvSource 设备形态(mobile / pc)—— 按请求 Origin 判定,列表页显示设备标志;
2094
- · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**,
2095
- 不把直接跑 CLI / 别的应用的任务混进来(用户 2026-10-12)。 */
2241
+ · EnvClient 发起方应用(本插件的 AppID)—— 列表页据此**只显示本客户端发起的会话**。
2242
+ 另外注入工作空间锁(选入式,见 internal/session/workspace_lock.go):core 在 Begin
2243
+ 时先抢锁再写 running,让直接跑 CLI 的进程也参与同一套串行。 */
2096
2244
  cmd.Env = append(os.Environ(),
2097
- session.EnvSource+"="+requestSource(req.Client, r.Header.Get("Origin")),
2098
- session.EnvClient+"="+s.opts.Profile.AppID())
2245
+ session.EnvSource+"="+source,
2246
+ session.EnvClient+"="+s.opts.Profile.AppID(),
2247
+ session.EnvWorkspaceLock+"=1")
2099
2248
  stdin, err := cmd.StdinPipe()
2100
2249
  if err != nil {
2101
- writeErr(w, http.StatusInternalServerError, "stdin pipe: "+err.Error())
2102
- return
2250
+ return nil, fmt.Errorf("stdin pipe: %w", err)
2103
2251
  }
2104
2252
  stdout, err := cmd.StdoutPipe()
2105
2253
  if err != nil {
2106
- writeErr(w, http.StatusInternalServerError, "stdout pipe: "+err.Error())
2107
- return
2254
+ return nil, fmt.Errorf("stdout pipe: %w", err)
2108
2255
  }
2109
2256
  run := &askRun{
2110
2257
  id: s.nextID(),
@@ -2117,126 +2264,75 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2117
2264
  session: strings.TrimSpace(req.Session),
2118
2265
  /* 这一轮算在哪个工作区(收尾后自动归纳要按它定位)。
2119
2266
  请求没带 workspace 时 core 会用它自己的 cwd 记录会话 —— 插件 spawn core 时
2120
- 不换目录,所以那个 cwd 就是插件自己的 cwd(见 handleHealth 的 defaultWorkspace)。
2121
- 两处口径必须一致,否则「聊了半天需求页没变」:归纳去的是另一个目录。 */
2267
+ 不换目录,所以那个 cwd 就是插件自己的 cwd(见 handleHealth 的 defaultWorkspace)。 */
2122
2268
  workspace: pinnedWorkspace(req.Workspace),
2123
2269
  }
2124
2270
  cmd.Stderr = run.stderr
2125
2271
  /* 用户这条提问先落进回放缓冲(不进 SSE 直播流):core 的事件流里没有 user 事件,
2126
2272
  断线重连 / 打开运行中会话时补出来的画面会缺「用户问的是什么」。 */
2127
- run.appendPrompt(req.Prompt, req.Attachments)
2273
+ run.appendPrompt(req.Prompt, req.Attachments, req.Hosted)
2128
2274
  if err := cmd.Start(); err != nil {
2129
- writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
2130
- return
2275
+ return nil, fmt.Errorf("启动 core 失败:%w", err)
2131
2276
  }
2132
2277
  s.mu.Lock()
2133
2278
  s.runs[run.id] = run
2134
2279
  s.mu.Unlock()
2135
- // 注意:这里**不** defer 摘除 s.runs —— 断连后 handler 会先返回,任务仍在跑;
2136
- // 摘除由后台读 stdout 的 goroutine 在 core 真正退出时做(见下)。
2280
+ go s.pumpAsk(run, stdout)
2281
+ return run, nil
2282
+ }
2137
2283
 
2138
- // SSE 头必须在**第一行事件之前**发出,否则客户端拿到的是 application/json。
2139
- w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
2140
- w.Header().Set("Cache-Control", "no-cache")
2141
- w.Header().Set("X-Accel-Buffering", "no")
2142
- flusher, _ := w.(http.Flusher)
2143
- send := func(event string, payload any) {
2144
- b, _ := json.Marshal(payload)
2145
- if event != "" {
2146
- fmt.Fprintf(w, "event: %s\n", event)
2147
- }
2148
- fmt.Fprintf(w, "data: %s\n\n", b)
2149
- if flusher != nil {
2150
- flusher.Flush()
2284
+ // pumpAsk 读尽 stdout(断连也照读,否则管道写满会把 core 卡死),收尾后通知调度器。
2285
+ func (s *Service) pumpAsk(run *askRun, stdout io.Reader) {
2286
+ sc := bufio.NewScanner(stdout)
2287
+ sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
2288
+ for sc.Scan() {
2289
+ line := strings.TrimSpace(sc.Text())
2290
+ if line == "" {
2291
+ continue
2151
2292
  }
2293
+ run.appendEvent(line)
2152
2294
  }
2153
- send("open", map[string]any{"id": run.id, "engine": engine, "core": s.opts.MagicAgent})
2295
+ werr := run.cmd.Wait()
2296
+ code := 0
2297
+ if werr != nil {
2298
+ var ee *exec.ExitError
2299
+ if errors.As(werr, &ee) {
2300
+ code = ee.ExitCode()
2301
+ } else {
2302
+ code = -1
2303
+ }
2304
+ }
2305
+ run.finish(code)
2306
+ s.jobs.runEnded(run)
2307
+ /* 需求自动归纳(2026-10-22):一轮收尾后防抖触发(12s 内再来一轮顺延)。
2308
+ 用 run 上记死的 workspace,不重新读 cwd —— 归纳必须落在**这一轮聊的那个目录**。 */
2309
+ s.maybeAutoConsolidate(run.workspace)
2310
+ close(run.done)
2311
+ /* 任务结束后**不立刻摘除**:客户端可能在断线重连后补拉终态(askRetention 窗口)。 */
2312
+ time.AfterFunc(askRetention, func() {
2313
+ s.mu.Lock()
2314
+ if cur := s.runs[run.id]; cur == run {
2315
+ delete(s.runs, run.id)
2316
+ }
2317
+ s.mu.Unlock()
2318
+ })
2319
+ }
2154
2320
 
2155
- /* SSE 心跳(2026-10-19 真实报障:手机端 17.7s 就「连接中断」,而 core 在首段输出前
2156
- 静默了约 27s —— 期间一个字节都没写,移动网络 / WebView 会把这条连接当成死连接掐掉,
2157
- 服务端任务其实一直跑到了 turn_end)。
2158
- 注释帧(以 ':' 开头、没有 data 行)按 SSE 规范会被客户端忽略,
2159
- 本仓库的 transport-http.js 解析器也只认 data 行,因此安全。 */
2321
+ /*
2322
+ relayRun 把一轮的事件按 seq 增量推给 SSE。
2323
+
2324
+ 不再用「读 stdout 的 goroutine → channel」那条路:作业可能先排队再开跑,
2325
+ 多一个消费者时 channel 会互相抢;回放缓冲天然支持多读者。
2326
+ */
2327
+ func (s *Service) relayRun(ctx context.Context, w http.ResponseWriter, run *askRun, flusher http.Flusher, send func(string, any)) {
2328
+ var after uint64
2329
+ tick := time.NewTicker(80 * time.Millisecond)
2160
2330
  ping := time.NewTicker(10 * time.Second)
2331
+ defer tick.Stop()
2161
2332
  defer ping.Stop()
2162
-
2163
- /* 读 stdout 与写 SSE 分开成两个 goroutine(2026-10-19):
2164
- 客户端断连时 handler 立即返回,但**后台必须继续把 core 的 stdout 抽干** ——
2165
- 否则管道写满后 core 会阻塞在写事件上,任务反而被卡死。
2166
- handler 走人之前关掉 detached,后台就改为「只 drain、不再入队」。 */
2167
- lines := make(chan string, 64)
2168
- detached := make(chan struct{})
2169
- finished := make(chan int, 1)
2170
- go func() {
2171
- sc := bufio.NewScanner(stdout)
2172
- sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
2173
- gone := false
2174
- for sc.Scan() {
2175
- line := strings.TrimSpace(sc.Text())
2176
- if line == "" {
2177
- continue
2178
- }
2179
- // 先入回放缓冲,再判断有没有人在看直播:断连之后客户端要靠这份缓冲
2180
- // 按 seq 续传,漏掉「gone 之后」的行就等于把断点之后的内容丢了。
2181
- run.appendEvent(line)
2182
- if gone {
2183
- continue // 没人看了:只 drain,避免 core 写满管道
2184
- }
2185
- select {
2186
- case lines <- line:
2187
- case <-detached:
2188
- gone = true
2189
- }
2190
- }
2191
- close(lines)
2192
- werr := cmd.Wait()
2193
- code := 0
2194
- if werr != nil {
2195
- var ee *exec.ExitError
2196
- if errors.As(werr, &ee) {
2197
- code = ee.ExitCode()
2198
- } else {
2199
- code = -1
2200
- }
2201
- }
2202
- run.finish(code)
2203
- /* 需求自动归纳(2026-10-22):一轮收尾后防抖触发(12s 内再来一轮顺延)。
2204
- 用 run 上记死的 workspace,不重新读 cwd —— 归纳必须落在**这一轮聊的那个目录**,
2205
- 而进程 cwd 在这期间可能被 profile 切换(--ui-dir 之类的启动参数不影响,但
2206
- 以后会有切换空间的功能;钉住是零成本的正确做法)。
2207
- 放在 finish 之后:先让 /desk/asks 与 SSE 看到本轮结束,再让后台开始整理。 */
2208
- s.maybeAutoConsolidate(run.workspace)
2209
- close(run.done)
2210
- /* 任务结束后**不立刻摘除**:客户端可能在断线重连后在补拉终态
2211
- (`GET /desk/ask/{id}/events`),留 askRetention 的时间窗。
2212
- 保留期内若同一个 id 被重新登记(不可能,id 唯一)也不覆盖。
2213
- handleAsks 会按 ended 过滤,所以这个「僵尸条目」不会出现在运行中列表。 */
2214
- time.AfterFunc(askRetention, func() {
2215
- s.mu.Lock()
2216
- if cur := s.runs[run.id]; cur == run {
2217
- delete(s.runs, run.id)
2218
- }
2219
- s.mu.Unlock()
2220
- })
2221
- finished <- code
2222
- }()
2223
- // 断连后后台继续跑完任务;handler 只负责把已有事件送出去。
2224
- defer close(detached)
2225
-
2226
2333
  for {
2227
2334
  select {
2228
- case line, ok := <-lines:
2229
- if !ok {
2230
- lines = nil // 等 finished(stdout 已读尽,core 正在退出)
2231
- continue
2232
- }
2233
- // 原样中继:核心事件流自己就是 NDJSON,不再包一层。
2234
- fmt.Fprintf(w, "data: %s\n\n", line)
2235
- if flusher != nil {
2236
- flusher.Flush()
2237
- }
2238
- case code := <-finished:
2239
- send("end", map[string]any{"id": run.id, "code": code, "stderr": run.stderr.String()})
2335
+ case <-ctx.Done():
2240
2336
  return
2241
2337
  case <-ping.C:
2242
2338
  // 心跳:只养连接,不下发业务数据(解析端会忽略没有 data 行的帧)。
@@ -2244,8 +2340,29 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
2244
2340
  if flusher != nil {
2245
2341
  flusher.Flush()
2246
2342
  }
2247
- case <-r.Context().Done():
2248
- return
2343
+ case <-tick.C:
2344
+ events, last, done, code, stderr := run.snapshot(after)
2345
+ for _, e := range events {
2346
+ /* seq=0 是网关合成的「用户提问」(见 appendPrompt):它只进回放缓冲,
2347
+ 不进实时 SSE —— 实时路径上客户端自己已经画过用户气泡了。 */
2348
+ if e.seq == 0 {
2349
+ continue
2350
+ }
2351
+ fmt.Fprintf(w, "data: %s\n\n", e.data)
2352
+ if e.seq > after {
2353
+ after = e.seq
2354
+ }
2355
+ }
2356
+ if last > after {
2357
+ after = last
2358
+ }
2359
+ if flusher != nil {
2360
+ flusher.Flush()
2361
+ }
2362
+ if done {
2363
+ send("end", map[string]any{"id": run.id, "code": code, "stderr": stderr})
2364
+ return
2365
+ }
2249
2366
  }
2250
2367
  }
2251
2368
  }
@@ -66,8 +66,15 @@ echo '{"v":1,"seq":4,"type":"result","text":"hi"}'
66
66
 
67
67
  const goodContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
68
68
 
69
+ // hostedContract 多了托管历史标识的能力位(2026-10-23 只增)。
70
+ const hostedContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control","feature.session.hosted"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
71
+
69
72
  func newTestService(t *testing.T, core string) *Service {
70
73
  t.Helper()
74
+ /* 测试互不干扰:作业目录放临时目录;并发上限放大(假 core 不退出,不放大就会把
75
+ 同一进程里后续用例堵在队列里)。串行语义由 TestJobs* 用独立服务端覆盖。 */
76
+ t.Setenv("MAGIC_CLIENT_JOBS_DIR", t.TempDir())
77
+ t.Setenv("MAGIC_CLIENT_MAX_RUNNING", "64")
71
78
  svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
72
79
  if err != nil {
73
80
  t.Fatalf("New: %v", err)
@@ -108,6 +115,33 @@ func TestNewRejectsContractMismatch(t *testing.T) {
108
115
  })
109
116
  }
110
117
 
118
+ // TestSessionStopRoute 手机 / 另一台设备只能按**会话 id** 停(那一轮在别人的进程里,
119
+ // 手里没有 ask id):路由必须存在,且未知 id 要诚实 404 —— 默默 200 会让界面以为停住了。
120
+ func TestSessionStopRoute(t *testing.T) {
121
+ t.Setenv(session.EnvDir, t.TempDir())
122
+ svc := newTestService(t, fakeCore(t, goodContract))
123
+ srv := httptest.NewServer(svc.Handler())
124
+ defer srv.Close()
125
+
126
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/desk/session/no-such-session/stop", nil)
127
+ req.Header.Set("X-Magic-Token", "tok")
128
+ resp, err := http.DefaultClient.Do(req)
129
+ if err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ defer resp.Body.Close()
133
+ if resp.StatusCode != http.StatusNotFound {
134
+ t.Fatalf("未知会话应 404,实际 %d", resp.StatusCode)
135
+ }
136
+ var body map[string]any
137
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ if !strings.Contains(fmt.Sprint(body["error"]), "no such session") {
141
+ t.Errorf("404 应说明找不到该会话,实际 %v", body["error"])
142
+ }
143
+ }
144
+
111
145
  // TestAuthRequired 静态资源不鉴权,但 /desk/* 必须带令牌 ——
112
146
  // 本地端口对同机所有进程可见,不鉴权等于把引擎执行权交出去。
113
147
  func TestAuthRequired(t *testing.T) {
@@ -201,6 +235,18 @@ type sseReader struct {
201
235
  func openSSE(t *testing.T, url, token string, body any) *sseReader {
202
236
  t.Helper()
203
237
  b, _ := json.Marshal(body)
238
+ /* 这些用例验的是「中继 / 参数透传」,不是串行:显式 serial=false 让它们保持并发,
239
+ 否则假 core 跑完一轮后还等在 stdin 上,会把同一 workspace 的下一条堵死。
240
+ 串行语义由 TestJobs* 专门覆盖。 */
241
+ var m map[string]any
242
+ if err := json.Unmarshal(b, &m); err == nil && m != nil {
243
+ if _, ok := m["serial"]; !ok {
244
+ m["serial"] = false
245
+ if nb, err := json.Marshal(m); err == nil {
246
+ b = nb
247
+ }
248
+ }
249
+ }
204
250
  req, _ := http.NewRequest(http.MethodPost, url+"/desk/ask", bytes.NewReader(b))
205
251
  req.Header.Set("X-Magic-Token", token)
206
252
  req.Header.Set("Accept", "text/event-stream")
@@ -418,6 +464,38 @@ func TestAskPassesSaveHistory(t *testing.T) {
418
464
  }
419
465
  }
420
466
 
467
+ // TestAskPassesHosted 托管自动发送的标识:core 有这个能力位时才透传 --hosted。
468
+ // 它只写会话历史(回放画成「托管发送」),执行语义不变。
469
+ func TestAskPassesHosted(t *testing.T) {
470
+ svc := newTestService(t, fakeCore(t, hostedContract))
471
+ srv := httptest.NewServer(svc.Handler())
472
+ defer srv.Close()
473
+
474
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
475
+ defer sse.close()
476
+ _ = sse.next(t)
477
+ raw, _ := sse.next(t)["raw"].(string)
478
+ if !strings.Contains(raw, "--hosted") {
479
+ t.Errorf("core 支持 feature.session.hosted 时应透传 --hosted,实际 %q", raw)
480
+ }
481
+ }
482
+
483
+ // TestAskDropsHostedForOldCore 老 core 不认识 --hosted(会 exit 2),插件必须忽略。
484
+ // 这是**纯显示**字段,不能因为它阻断发送;实时气泡仍由客户端自己标。
485
+ func TestAskDropsHostedForOldCore(t *testing.T) {
486
+ svc := newTestService(t, fakeCore(t, goodContract))
487
+ srv := httptest.NewServer(svc.Handler())
488
+ defer srv.Close()
489
+
490
+ sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
491
+ defer sse.close()
492
+ _ = sse.next(t)
493
+ raw, _ := sse.next(t)["raw"].(string)
494
+ if strings.Contains(raw, "--hosted") {
495
+ t.Errorf("老 core 没有 feature.session.hosted,不该透传 --hosted,实际 %q", raw)
496
+ }
497
+ }
498
+
421
499
  func TestAskInjectsClientEnv(t *testing.T) {
422
500
  svc := newTestService(t, fakeCore(t, goodContract))
423
501
  srv := httptest.NewServer(svc.Handler())