magic-agent 1.0.3 → 1.0.49

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -92,7 +92,7 @@ func shortSessionsDir(t *testing.T) string {
92
92
 
93
93
  func TestKeepAliveRequiresStream(t *testing.T) {
94
94
  kaEngineForTest(t)
95
- _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--keep-alive", "-p", "hi")
95
+ _, _, err := runAskCmd(t, "", "--save-registry", "-e", "codebuddy", "--keep-alive", "-p", "hi")
96
96
  if err == nil {
97
97
  t.Fatal("--keep-alive 不配 --stream 应报错")
98
98
  }
@@ -143,7 +143,7 @@ func TestKeepAliveIsDefaultForStreaming(t *testing.T) {
143
143
 
144
144
  done := make(chan error, 1)
145
145
  go func() {
146
- _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--idle", "1s", "-p", "首轮")
146
+ _, _, err := runAskCmd(t, "", "--save-registry", "-e", "codebuddy", "--stream", "--idle", "1s", "-p", "首轮")
147
147
  done <- err
148
148
  }()
149
149
 
@@ -183,7 +183,7 @@ func TestKeepAliveCanBeDisabled(t *testing.T) {
183
183
  t.Setenv(session.EnvDir, dir)
184
184
  registerFake(&streamingEngine{name: "fake-ka-off", text: "ok"})
185
185
 
186
- if _, _, err := runAskCmd(t, "", "-e", "fake-ka-off", "--stream", "--keep-alive=false", "hi"); err != nil {
186
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-ka-off", "--stream", "--keep-alive=false", "hi"); err != nil {
187
187
  t.Fatalf("ask: %v", err)
188
188
  }
189
189
  recs := mustListSessions(t, dir)
@@ -201,7 +201,7 @@ func TestKeepAliveNotAppliedToNonStream(t *testing.T) {
201
201
  t.Setenv(session.EnvDir, dir)
202
202
  registerFake(&stringEngine{name: "fake-ka-nostream", text: "ok"})
203
203
 
204
- if _, _, err := runAskCmd(t, "", "-e", "fake-ka-nostream", "hi"); err != nil {
204
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-ka-nostream", "hi"); err != nil {
205
205
  t.Fatalf("ask: %v", err)
206
206
  }
207
207
  recs := mustListSessions(t, dir)
@@ -255,7 +255,7 @@ func TestAppendToNonKeepAliveSession(t *testing.T) {
255
255
  t.Setenv(session.EnvDir, shortSessionsDir(t))
256
256
  registerFake(&stringEngine{name: "fake-ka-none", text: "ok"})
257
257
 
258
- if _, _, err := runAskCmd(t, "", "-e", "fake-ka-none", "普通调用"); err != nil {
258
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-ka-none", "普通调用"); err != nil {
259
259
  t.Fatalf("ask: %v", err)
260
260
  }
261
261
  rows := parseSessions(t, mustSessions(t))
@@ -283,7 +283,7 @@ func TestKeepAliveAppendEndToEnd(t *testing.T) {
283
283
  // ① 起常驻会话(后台跑,空闲 1s 自动收工)
284
284
  done := make(chan error, 1)
285
285
  go func() {
286
- _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--keep-alive",
286
+ _, _, err := runAskCmd(t, "", "--save-registry", "-e", "codebuddy", "--stream", "--keep-alive",
287
287
  "--idle", "1s", "-p", "首轮任务")
288
288
  done <- err
289
289
  }()
@@ -350,6 +350,101 @@ func TestKeepAliveAppendEndToEnd(t *testing.T) {
350
350
  }
351
351
  }
352
352
 
353
+ // TestKeepAliveAppendWithoutRegistry 默认不留档(不开 --save-registry)时,
354
+ // --append 仍要能用 —— 走的是「按约定找 socket」那条路(session.AppendSocketPath)。
355
+ //
356
+ // 为什么这一条重要:常驻会话对 claude/codebuddy 是**默认开**的,而登记表默认不落。
357
+ // 若 --append 只认登记表,用户会撞上「默认能常驻、却追加不了」这个自相矛盾的行为。
358
+ // 代价是这条路径只认 run_id(session_id 要靠记录映射,没有记录就无从查起)。
359
+ func TestKeepAliveAppendWithoutRegistry(t *testing.T) {
360
+ dir := shortSessionsDir(t)
361
+ t.Setenv(session.EnvDir, dir)
362
+
363
+ eng := kaEngineForTest(t)
364
+
365
+ // ① 起常驻会话(**不开** --save-registry)
366
+ done := make(chan error, 1)
367
+ go func() {
368
+ _, _, err := runAskCmd(t, "", "-e", "codebuddy", "--stream", "--keep-alive",
369
+ "--idle", "5s", "-p", "首轮任务")
370
+ done <- err
371
+ }()
372
+
373
+ // ② 登记表里什么都没有(这正是前提),只能等 socket 文件出现
374
+ if recs := mustListSessions(t, dir); len(recs) != 0 {
375
+ t.Fatalf("默认不该有登记记录,实际 %+v", recs)
376
+ }
377
+ var runID string
378
+ deadline := time.Now().Add(10 * time.Second)
379
+ for time.Now().Before(deadline) && runID == "" {
380
+ ents, _ := os.ReadDir(dir)
381
+ for _, e := range ents {
382
+ if strings.HasSuffix(e.Name(), ".sock") {
383
+ runID = strings.TrimSuffix(e.Name(), ".sock")
384
+ }
385
+ }
386
+ if runID == "" {
387
+ time.Sleep(50 * time.Millisecond)
388
+ }
389
+ }
390
+ if runID == "" {
391
+ t.Fatal("常驻会话没开出追加入口 socket")
392
+ }
393
+ // 按约定推出来的路径必须与实际建出来的同一个(两端算法唯一的交叉点)
394
+ if got, want := session.AppendSocketPath(runID), filepath.Join(dir, runID+".sock"); got != want {
395
+ t.Fatalf("AppendSocketPath = %q want %q", got, want)
396
+ }
397
+
398
+ // ③ 按 run_id 追加(记录不存在的路径)
399
+ stdout, _, err := runAskCmd(t, "", "--append", runID, "-p", "不留档也能追加")
400
+ if err != nil {
401
+ t.Fatalf("--append(无登记记录): %v", err)
402
+ }
403
+ var out struct {
404
+ Type string `json:"type"`
405
+ Queued bool `json:"queued"`
406
+ }
407
+ if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &out); err != nil {
408
+ t.Fatalf("--append 输出不是 JSON: %v (%s)", err, stdout)
409
+ }
410
+ if out.Type != "append" || !out.Queued {
411
+ t.Errorf("--append 结果不对: %+v", out)
412
+ }
413
+
414
+ // ④ 引擎确实收到了;收工后 socket 照旧清掉
415
+ if got := waitAppend(t, eng, "不留档也能追加", 10*time.Second); got != "不留档也能追加" {
416
+ t.Errorf("引擎收到的追加 = %q", got)
417
+ }
418
+ // ⑤ --stop 依赖登记表(pid 只存在那里),所以不留档的会话**停不掉** ——
419
+ // 这是「默认不留痕」的已知代价,明写出来免得当成 bug 报回来。
420
+ if _, _, err := runAskCmd(t, "", "--stop", runID); err == nil {
421
+ t.Error("--stop 对不留档的会话应当报「找不到」(它靠登记表寻址)")
422
+ }
423
+ select {
424
+ case err := <-done:
425
+ if err != nil {
426
+ t.Fatalf("常驻会话收尾报错: %v", err)
427
+ }
428
+ case <-time.After(20 * time.Second):
429
+ t.Fatal("常驻会话没收工")
430
+ }
431
+ }
432
+
433
+ // waitAppend 等引擎收到追加内容(追加是异步投递的,socket 收到 ≠ 引擎已读)。
434
+ func waitAppend(t *testing.T, eng *kaEngine, want string, d time.Duration) string {
435
+ t.Helper()
436
+ deadline := time.Now().Add(d)
437
+ for time.Now().Before(deadline) {
438
+ for _, got := range eng.received() {
439
+ if got == want {
440
+ return got
441
+ }
442
+ }
443
+ time.Sleep(50 * time.Millisecond)
444
+ }
445
+ return ""
446
+ }
447
+
353
448
  // mustListSessions 直接读登记表(比 --sessions 的 JSON 更直接,且能看到 append 字段)。
354
449
  func mustListSessions(t *testing.T, dir string) []session.Record {
355
450
  t.Helper()
@@ -139,7 +139,11 @@ func TestEnginesReportsPermission(t *testing.T) {
139
139
  if seen["codebuddy-ai"] != "flag:--permission-mode" {
140
140
  t.Errorf("codebuddy-ai 的 permission 字段 = %q, want flag:--permission-mode", seen["codebuddy-ai"])
141
141
  }
142
- for _, e := range []string{"trae", "llm", "codex", "openclaw", "dsh", "arkclaw"} {
142
+ // dsh 的档位不走 flag,走子进程环境变量 —— 能力串是 env:<VAR>,客户端据此放行。
143
+ if seen["dsh"] != "env:DSH_PERMISSION_MODE" {
144
+ t.Errorf("dsh 的 permission 字段 = %q, want env:DSH_PERMISSION_MODE", seen["dsh"])
145
+ }
146
+ for _, e := range []string{"trae", "llm", "codex", "openclaw", "arkclaw"} {
143
147
  if v, ok := seen[e]; !ok {
144
148
  t.Errorf("--engines 缺 %s 行", e)
145
149
  } else if v != "none" {
@@ -17,7 +17,7 @@ package cli
17
17
  // -f, --file <path> 从文件读 prompt(- 读 stdin)
18
18
  // -a, --attach <path> 附件(截图/图片),可重复/逗号分隔;与提示词一起发给引擎
19
19
  // -w, --workspace <dir> 工作目录:在该目录里执行引擎(详见下方「工作目录」)
20
- // -t, --timeout <dur> 单次尝试超时(如 3m;默认 600s)
20
+ // -t, --timeout <dur> 单次尝试超时(如 3m;默认 1h)
21
21
  // -r, --retries <n> 失败重试次数(默认 0)
22
22
  // --backoff <dur> 首次重试退避(默认 2s,指数翻倍,上限 30s)
23
23
  // -o, --output <format> 输出:json(默认)| text
@@ -34,10 +34,19 @@ package cli
34
34
  // --engines 列出引擎、可用性与支持的模型(JSON 数组;--json 兼容保留)
35
35
  // --no-models 配合 --engines:跳过模型探测(只列引擎,快)
36
36
  // --stop <id> 停止指定会话/运行(session_id 或 run_id),杀掉它的引擎进程组
37
- // --sessions 列出会话登记表(JSON 数组:run_id / session_id / pid / engine / state)
37
+ // --delete-session <id> 删除一条会话:登记记录 + 事件历史 + 标题覆盖一起删,**全机生效**
38
+ // (各端读同一份登记表,一端删了处处都看不到)。幂等
39
+ // --sessions 列出会话登记表(JSON 数组:run_id / session_id / pid / engine /
40
+ // source / client / state;全机视图,不按 client 过滤)。
41
+ // ⚠️ 只见过**已存档**的会话(--save-registry,默认关)
42
+ // --save-registry 把会话登记落盘(<run_id>.json),让 --sessions / --stop 事后能按 id
43
+ // 寻址。**默认关**:不留痕;不留档的会话停不掉
38
44
  // --keep-alive 常驻会话(claude/codebuddy 默认开,dsh 需显式传;需配合 --stream):
39
45
  // 首轮结束后不退出、等 --append 追加;--keep-alive=false 关闭
40
46
  // --append <id> 向常驻会话追加一条消息(内容用 -p/位置参数给)
47
+ // --session-log <id> 读一段会话的事件历史(--after <seq> 可增量续读)
48
+ // --save-history 把流式会话的事件落盘存档(<session_id>.jsonl,供 --session-log
49
+ // 与客户端回放历史)。**默认关**:一次性调用不留痕
41
50
  // --idle <dur> 常驻会话空闲收工时长(默认 5m;0 = 本轮结束就收工)
42
51
  // -v, --verbose 重试过程打到 stderr
43
52
  //
@@ -64,6 +73,7 @@ import (
64
73
  "os"
65
74
  "strings"
66
75
  "sync"
76
+ "time"
67
77
 
68
78
  "github.com/spf13/cobra"
69
79
 
@@ -179,8 +189,9 @@ llm 引擎(包装 simonw/LLM CLI,模型/密钥由它自管):
179
189
  codex codex debug models
180
190
  openclaw openclaw models list --json
181
191
  arkclaw 无清单(模型由网关按 claw_id 绑定)→ 输出 models_note 说明
182
- dsh $DSH_HOME/settings.yaml 里已配置的模型(llm-pi-ai.providers.<route>.models,
183
- 输出 route/model);headless 无 --model,换模型要改该文件
192
+ dsh 配置文件里已配置的模型(0.1.7+ 读 profile 的 cordis.patch.yml,
193
+ 更早读 $DSH_HOME/settings.yaml;输出 route/model);
194
+ headless 无 --model,换模型要改配置
184
195
  探测会真的启动这些 CLI 的只读子命令(最慢 openclaw ~2.5s),故并发执行、
185
196
  单引擎上限 30s;拿不到清单不报错,原因写进该行的 models_note。
186
197
  --no-models 整段跳过探测(只列引擎与可用性,不启动任何 CLI)。
@@ -232,19 +243,20 @@ dsh 引擎(DeepSeek Harness,本机 dsh CLI):
232
243
  说明:
233
244
  · 模型:SDK 通道下 -m 生效(转成 initialize 的 provider/model,取 route/model 形态,
234
245
  可用值见 --engines 的 models 字段);回退 headless 后 -m 不生效(无 --model 参数),
235
- 模型由 dsh 自己的配置决定($DSH_HOME/settings.yaml 的 agent-default-model 段 +
236
- llm-pi-ai.providers,密钥放 $DSH_HOME/.credentials.yaml 或 .env),此时会打一行告警。
237
- · 工具调用只在 SDK 通道上有:headless 的 stdout/stderr 里都没有工具事件(官方定位就是
238
- 「推理走 stderr、正文走 stdout、然后退出」)→ 要工具事件请确保走 SDK 通道。
239
- · --stream:SDK 通道下推理 → thinking、正文 → 逐 step text、工具 → tool_use/tool_result、
240
- 收尾一条 turn_end;回退 headless 后只流推理(stderr 的 "dsh: reasoning:" 增量),
241
- 正文在 turn 结束时一次性给出。
242
- · 多轮上下文:用**常驻会话**(SDK 通道下同一进程内对同一会话继续 prompt,官方推荐的续接方式):
243
- magic-agent -e dsh --stream --keep-alive "记住 42" # 首轮(dsh 的常驻默认关,要显式传)
244
- magic-agent --append <run_id|session_id> -p "刚才那个数是多少" # 追问,上下文接得上
245
- 不支持按 id 续接(-s/--session):headless 每次调用都是全新会话;SDK 的 sessionId
246
- 只在**同一个 runtime 进程内**可续,换进程拿旧 id 会被 -32603 "session already exists"
247
- 拒掉(协议只有 initialize / session/prompt / shutdown,没有 resume 方法)。
246
+ 模型由 dsh 自己的配置决定(0.1.7+ 在 $DSH_HOME/profiles/<profile>/cordis.patch.yml
247
+ 的 llm-pi-ai.providers + agent-default-model,更早在 $DSH_HOME/settings.yaml;
248
+ 密钥放 $DSH_HOME/.credentials.yaml 或 .env),此时会打一行告警。
249
+ · 工具调用与流式:SDK 通道(普通轮)与 headless + --json(续接轮 / 回退)都能上报
250
+ thinking / text / tool_use / tool_result;老版 dsh(headless 没有 --json)只能流
251
+ stderr 的 "dsh: reasoning:" 推理增量、正文收尾一次给出,**没有工具事件**。
252
+ · 多轮上下文(三种,按需选):
253
+ magic-agent -e dsh -s <session_id> -p "追问" # 真续接:dsh 自己恢复上下文
254
+ magic-agent -e dsh --stream --keep-alive "记住 42" # 常驻会话首轮(同进程内续接)
255
+ magic-agent --append <run_id|session_id> -p "刚才那个数" # 往常驻会话追加
256
+ -s/--session <id> 走 headless 的 --session-id:会话由 dsh 持久化在
257
+ $DSH_HOME/sessions/<cwd>/,上下文由 dsh 自己接回来(SDK 通道没有 resume 方法,
258
+ 所以续接轮由 headless + --json 承载)。⚠️ 会话按**工作区**分桶:换了 -w 就找不到
259
+ 那条会话,dsh 会明确报 does not exist。
248
260
  · --permission 未接线(dsh 的权限预设是配置层概念,无法表达四档里的「沙箱关闭」档)。
249
261
  · --tools 由 dsh 自己的工具循环决定(无命令行级逐工具开关),传入即忽略。
250
262
  · --json-schema 支持(走与 llm/openclaw 相同的输出后处理抽 JSON)。
@@ -324,7 +336,7 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
324
336
  追加进来的轮次照常以事件流输出(每轮收尾发一个 turn_end 事件,便于调用方知道「这轮完了」)。
325
337
  收工:最后一轮结束后 --idle(默认 5m)内没人追加 → 优雅退出;--idle 0 = 本轮结束就收工
326
338
  (追加窗口只在任务运行期间);也可随时 --stop。
327
- 常驻生效期间不套 -t 默认超时(长任务+追加不受 600s 限制),显式给 -t 才套。
339
+ 常驻生效期间不套 -t 默认超时(长任务+追加不受 1h 限制),显式给 -t 才套。
328
340
  不生效的形态会自动忽略(非 --stream 调用、不支持追加的引擎);显式传 --keep-alive 却
329
341
  不满足条件则明确报错(exit 2)。Windows 无 unix socket,暂不支持。
330
342
  注意:常驻进程会在最后一轮结束后继续存活 --idle 那么久 —— 调用方若要「一轮完成」
@@ -333,7 +345,8 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
333
345
  --stream 调用以为命令卡住了;要开就显式 --keep-alive。
334
346
 
335
347
  停止指定会话(--stop / --sessions):
336
- 每次调用都会往会话登记表落一条记录(~/.magic-agent/sessions/<run_id>.json,MAGIC_AGENT_SESSIONS 可改目录),
348
+ 开了 --save-registry 时,每次调用都会往会话登记表落一条记录
349
+ (~/.magic-agent/sessions/<run_id>.json,MAGIC_AGENT_SESSIONS 可改目录),
337
350
  里面记着**引擎子进程的 pid** 与引擎/模型/工作目录/会话 id。为什么需要它:
338
351
  · 引擎 CLI 在独立进程组里跑(Setpgid),调用方 kill 掉 magic-agent 的进程组**带不走它**;
339
352
  · 进程句柄只活在调用方内存里,应用重启后之前 detach 出去的会话就再也停不掉。
@@ -344,6 +357,34 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
344
357
  行为:先 SIGTERM,2s 内不退再 SIGKILL(连它的 node worker 一起带走),然后回写 state=stopped。
345
358
  幂等:进程已不在 / 会话已结束 → stopped=false + reason,退出码仍是 0;id 不存在 → 退出码 1(多半写错了)。
346
359
  另:magic-agent 自己收到 SIGINT/SIGTERM 时也会先带走自己的引擎子进程再退出,不留孤儿。
360
+ ⚠️ **默认不落盘**(用户 2026-10-12:「会话登记也不需要」):不带 --save-registry 时上面这些
361
+ 命令看不见本次调用 —— 默认不留痕是有意的(手工跑一轮 / CI / 脚本编排事后没人寻址)。
362
+ 代价:不留档的会话**停不掉**(pid 只存在记录里),--append 也只能按 run_id 走约定路径。
363
+
364
+ 删除会话(--delete-session <session_id|run_id>):
365
+ 登记记录 + 事件日志(<id>.jsonl)+ 标题覆盖一起删掉,**全机生效**:
366
+ 各端(PC 的 --sessions、移动端 H5 的 /desk/sessions)读的都是同一份登记表,
367
+ 一端删了处处都看不到。与插件的 DELETE /desk/session/{id} 是同一份实现
368
+ (internal/session/delete.go),两条路可互换。
369
+ magic-agent --delete-session <session_id> # 删掉这条会话(含历史)
370
+ 幂等:id 不认识 / 文件本来就不在 → 退出码 0,JSON 里 records_removed=0。
371
+ ⚠️ 是**物理删除**:删完历史就回放不了了。客户端把它当成「删除」的唯一实现,
372
+ 只在本地藏起来(记墓碑)的话,别的客户端照样列着(2026-10-19 用户报的
373
+ 「从pc端删除的会话移动端还在显示」就是这么来的)。
374
+
375
+ 会话历史存档(--save-history,默认关):
376
+ 会话的**内容**(每条事件)另存一份 ~/.magic-agent/sessions/<session_id>.jsonl,
377
+ --session-log / 客户端「看历史」读的就是它;只有 --stream 才写。**默认不写**:
378
+ 没人会回看,写下来却会一直堆在磁盘上(登记表有 24h 保留期在清,jsonl 没有)。
379
+ magic-agent -e claude --stream -p "跑一遍测试" # 不存档(默认)
380
+ magic-agent -e claude --stream --save-history -p "跑一遍测试" # 存档,事后可 --session-log 回看
381
+ magic-agent --session-log <session_id|run_id> # 读存档(没存档就是空数组)
382
+ ⚠️ 没存档的会话在客户端列表里 has_log=false —— 能看到、能续接,但点开没有历史内容。
383
+ magic-client 恒传 --save-registry 与 --save-history(客户端本来就要列会话 + 回放历史),
384
+ 所以界面里的会话既在列表里、也有历史。
385
+ ⚠️ **不等终点事件就落盘**(2026-10-15):一开跑就按 <run_id>.jsonl 写,引擎的
386
+ session_id 到手后改名为 <session_id>.jsonl(读侧两个名字都认)——
387
+ 于是「正在跑的会话」在别的客户端里当场可读,引擎整轮不给 id 的形态也不白开存档。
347
388
 
348
389
  附件(-a/--attach:文件+提示词,比如截图加提示词):
349
390
  每个引擎尽量用**自己的原生方式**收附件,能力见 --engines 的 attachments 字段:
@@ -355,7 +396,8 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
355
396
  arkclaw part:file A2A 原生 file part(base64 inline;远端看不到本机路径)
356
397
  trae prompt 无原生通道 → 把**绝对路径**写进提示词(需要 --tools 非 off)
357
398
  openclaw prompt 同上
358
- dsh prompt 同上(headless 无附件参数;dsh 自己的工具能读文件)
399
+ dsh sdk:content-blocks SDK 通道把图片作为原生 image 块发出(base64 inline,
400
+ 真进模型上下文);非图片附件 / headless 回退 → 路径写进提示词
359
401
  codebuddy-gateway
360
402
  prompt 走 payload.attachments 原生字段,但网关把它渲染成路径清单
361
403
  塞进 prompt(图不会以图片形式进模型上下文)
@@ -409,14 +451,29 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
409
451
  // 与 --stop 同理:用「传过没有」判断,`--session-log ""` 要走参数校验(报要 id),
410
452
  // 而不是掉进下面的「提问」分支报 empty prompt。
411
453
  return runSessionLog(cmd, opts)
454
+ case flagChanged(cmd, "summarize-title"):
455
+ // 同上:空 id 走参数校验,不要掉进「提问」分支。
456
+ return runSummarizeTitle(cmd, opts)
457
+ case flagChanged(cmd, "session-title"):
458
+ return runSessionTitle(cmd, opts)
459
+ case flagChanged(cmd, "title-force"):
460
+ // --title-force 只在 --summarize-title 下成立:单给它必然是调用方误解了
461
+ // (想要「强制重新归纳」却没给 id)。明说,不静默忽略。
462
+ return &usageError{fmt.Errorf("--title-force 只在 --summarize-title 下成立:请配合 --summarize-title <id> 使用")}
412
463
  case flagChanged(cmd, "after"):
413
464
  // --after 只在 --session-log 下成立:给了却没给 --session-log 就**明说**,
414
465
  // 不静默忽略(静默忽略是本项目反复踩的坑)。
415
466
  return &usageError{fmt.Errorf("--after 只在 --session-log 下成立:请配合 --session-log <id> 使用")}
467
+ case flagChanged(cmd, "before"), flagChanged(cmd, "limit"):
468
+ // 同上:--before / --limit 是 --session-log 的翻页参数,单给必然是误解。
469
+ return &usageError{fmt.Errorf("--before / --limit 只在 --session-log 下成立:请配合 --session-log <id> 使用")}
416
470
  case flagChanged(cmd, "stop"):
417
471
  // 用「传过没有」而不是「值非空」判断:`--stop ""` 也要走参数校验
418
472
  //(exit 2 提示要 id),而不是掉进「提问」分支报 empty prompt。
419
473
  return runStop(cmd, opts)
474
+ case flagChanged(cmd, "delete-session"):
475
+ // 同上:空 id 走参数校验(`--delete-session ""` 不该被当成提问)。
476
+ return runDeleteSession(cmd, opts)
420
477
  case flagChanged(cmd, "append"):
421
478
  return runAppend(cmd, args, opts)
422
479
  case opts.contract:
@@ -430,6 +487,11 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
430
487
  },
431
488
  }
432
489
  bindAskFlags(root, opts)
490
+ /* 唯一的子命令:`client start|stop|status`。
491
+ 为什么破例留一个子命令:它管的是**另一个常驻进程**(magic-client),
492
+ 生命周期与 core 的一次性调用完全不同,硬塞成一排 --client-* flags 会让
493
+ 「这些 flag 和提问无关」这件事看不出来(用户 2026-10-20:「提供启动停止 client 的子命令」)。 */
494
+ root.AddCommand(clientSubcommand())
433
495
  /* `--login` / `--repair-models` 忘带引擎名时,cobra 在解析阶段就报错,**走不到**
434
496
  runLogin / runRepairModels 的校验分支,默认文案是英文的
435
497
  "flag needs an argument: ..."(且退出码 1)。这里翻成与传空串一致的中文
@@ -540,6 +602,12 @@ func rejectRemovedSubcommands(cmd *cobra.Command, args []string) error {
540
602
 
541
603
  // exitCodeOf 从错误推断退出码。
542
604
  func exitCodeOf(err error) int {
605
+ // 转发型子命令(`client`)自带退出码:2 = 参数错这种约定要原样带给调用方,
606
+ // 否则脚本拿到一律为 1 就分不出「用错了」和「跑失败了」。
607
+ var ec *exitCodeError
608
+ if errors.As(err, &ec) && ec.code > 0 {
609
+ return ec.code
610
+ }
543
611
  if _, ok := err.(*usageError); ok {
544
612
  return exitUsage
545
613
  }
@@ -560,6 +628,67 @@ var probeEngineCredits = func(ctx context.Context, l agent.ModelCreditLister) ma
560
628
  return l.ModelCredits(ctx)
561
629
  }
562
630
 
631
+ // modelListCacheTTL 模型清单 / 积分倍率探测结果的缓存有效期(30 分钟)。
632
+ //
633
+ // 模型清单来自 CLI 只读子命令或本地缓存文件,30 分钟内几乎不会变;缓存让重复跑
634
+ // --engines / --contract 不必每次重新启动 CLI 进程或重读文件。瞬时失败(CLI 没装 /
635
+ // 探测超时)与空清单**不进缓存**,避免把错误钉死 30 分钟 —— 只有真正拿到模型清单
636
+ // 才落缓存。缓存是进程级的:单次 magic-agent 进程只会探测一次,跨进程的重复调用
637
+ // (如客户端反复拉 --contract)也能省掉 CLI 启动开销。
638
+ const modelListCacheTTL = 30 * time.Minute
639
+
640
+ // modelListCacheEntry 一条缓存:模型清单 + 积分倍率 + 过期时间。
641
+ type modelListCacheEntry struct {
642
+ models []string
643
+ credits map[string]string
644
+ expiresAt time.Time
645
+ }
646
+
647
+ var (
648
+ modelListCacheMu sync.Mutex
649
+ modelListCache = map[string]*modelListCacheEntry{}
650
+ )
651
+
652
+ // cachedModelList 带 30 分钟缓存地探测某引擎的模型清单与积分倍率。
653
+ //
654
+ // key 一般用引擎名(codebuddy / codebuddy-ai / trae …);命中且未过期的缓存直接返回,
655
+ // 不触碰 CLI / 文件。底层走 probeEngineModels / probeEngineCredits(可被测试替换)。
656
+ // ListModels 出错或返回空清单时**不缓存**(视为瞬时态,下次重探)。
657
+ func cachedModelList(ctx context.Context, key string, l agent.ModelLister) (models []string, credits map[string]string, note string) {
658
+ modelListCacheMu.Lock()
659
+ if it, ok := modelListCache[key]; ok && time.Now().Before(it.expiresAt) {
660
+ modelListCacheMu.Unlock()
661
+ return it.models, it.credits, ""
662
+ }
663
+ modelListCacheMu.Unlock()
664
+
665
+ models, err := probeEngineModels(ctx, l)
666
+ if err != nil {
667
+ return nil, nil, err.Error()
668
+ }
669
+ if len(models) == 0 {
670
+ return nil, nil, "no models reported"
671
+ }
672
+ if cl, ok := l.(agent.ModelCreditLister); ok {
673
+ credits = probeEngineCredits(ctx, cl)
674
+ }
675
+ modelListCacheMu.Lock()
676
+ modelListCache[key] = &modelListCacheEntry{
677
+ models: models,
678
+ credits: credits,
679
+ expiresAt: time.Now().Add(modelListCacheTTL),
680
+ }
681
+ modelListCacheMu.Unlock()
682
+ return models, credits, ""
683
+ }
684
+
685
+ // clearModelListCache 清空模型清单缓存(测试用,避免用例之间互相命中缓存)。
686
+ func clearModelListCache() {
687
+ modelListCacheMu.Lock()
688
+ modelListCache = map[string]*modelListCacheEntry{}
689
+ modelListCacheMu.Unlock()
690
+ }
691
+
563
692
  // flagChanged 报告某个 flag 是否被显式传过(用于「传了就走这条分支」的开关型参数)。
564
693
  func flagChanged(cmd *cobra.Command, name string) bool {
565
694
  f := cmd.Flags().Lookup(name)
@@ -666,8 +795,10 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
666
795
  // stdin:stream-json claude / codebuddy 原生 stream-json input(图片走 content block)
667
796
  // flag:-a llm CLI 原生 --attachment(直连模型走 image_url content parts)
668
797
  // part:file arkclaw 走 A2A 原生 file part(base64 inline)
798
+ // sdk:content-blocks dsh 的 SDK 通道把图片作为原生 image content block 发出去
799
+ // (base64 inline,真进模型上下文);显式切 headless 后为 prompt
669
800
  // prompt 无原生通道:把绝对路径写进提示词,靠引擎读文件
670
- // (trae/openclaw/dsh/codebuddy-gateway)
801
+ // (trae/openclaw/codebuddy-gateway)
671
802
  Attachments string `json:"attachments,omitempty"`
672
803
  // Append 是否支持「常驻会话 + 追加消息」(`--stream --keep-alive` 与 `--append`)。
673
804
  // claude / codebuddy 为 true(stream-json 输入可持续喂 user 消息);
@@ -686,6 +817,12 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
686
817
  // 且网关会把远程任务的权限强制切到 bypassPermissions)
687
818
  // 调用方据此决定是否下发 --permission 与那几个沙箱/规则类 flags。
688
819
  Permission string `json:"permission,omitempty"`
820
+ // Reasoning 该引擎是否接受 `--reasoning <档>`(machine-readable,见
821
+ // agent.ReasoningSupportOf):claude / codebuddy / codebuddy-ai / codex 为 true;
822
+ // 其余引擎未接线 —— core 对它们**静默忽略**该 flag(不报错、不下传)。
823
+ // 新增(2026-10-20,只增字段不升 contractVersion):老版 CLI 没有这个字段,
824
+ // 客户端据此判断「能不能安全下发 --reasoning」(缺失 = 不下发,避免 exit 2)。
825
+ Reasoning bool `json:"reasoning"`
689
826
  // Capabilities 该引擎**静态声明**的能力 id 集合(字典序,稳定、无重复)。
690
827
  // 与运行态分开:ok / streaming / models / version 说的是「此刻如何」,
691
828
  // capabilities 说的是「能做什么」,二者的推导见 internal/agent/capability.go。
@@ -707,7 +844,8 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
707
844
  Attachments: agent.AttachmentSupportOf(e.Name()),
708
845
  Append: agent.AppendSupportOf(e.Name()),
709
846
  Ask: agent.AskSupportOf(e.Name()),
710
- Permission: agent.PermissionSupportOf(e.Name())}
847
+ Permission: agent.PermissionSupportOf(e.Name()),
848
+ Reasoning: agent.ReasoningSupportOf(e.Name())}
711
849
  if ok {
712
850
  r.Bin, r.Note = note, ""
713
851
  }
@@ -738,26 +876,30 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
738
876
  continue
739
877
  }
740
878
  wg.Add(1)
741
- go func(i int, l agent.ModelLister) {
879
+ go func(i int, name string, l agent.ModelLister) {
742
880
  defer wg.Done()
743
881
  ctx, cancel := context.WithTimeout(cmd.Context(), agent.DefaultModelProbeTimeout)
744
882
  defer cancel()
745
- models, err := probeEngineModels(ctx, lister)
883
+ models, credits, note := cachedModelList(ctx, name, l)
884
+ // codebuddy 系:展示时剥掉自定义模型 id 的 "custom-local:" 前缀(噪点);
885
+ // -m 实际传值时由引擎按裸名补回前缀(见 codebuddyCore.modelArg),
886
+ // 自定义模型依旧可用。
887
+ if name == "codebuddy" || name == "codebuddy-ai" {
888
+ models = agent.StripCustomLocalPrefix(models)
889
+ credits = agent.StripCustomLocalPrefixCredits(credits)
890
+ }
746
891
  switch {
747
- case err != nil:
748
- // 失败不致命:原因进 models_note,models 留空。
749
- rows[i].ModelsNote = err.Error()
892
+ case note != "":
893
+ rows[i].ModelsNote = note
750
894
  case len(models) == 0:
751
895
  rows[i].ModelsNote = "no models reported"
752
896
  default:
753
897
  rows[i].Models = models
754
- // 积分倍率与 models 同链同源(同一批缓存文件),探测失败/缺省
755
- // 不致命:该字段整体省略(omitempty),不影响 models。
756
- if cl, ok := l.(agent.ModelCreditLister); ok {
757
- rows[i].ModelCredits = probeEngineCredits(ctx, cl)
898
+ if credits != nil {
899
+ rows[i].ModelCredits = credits
758
900
  }
759
901
  }
760
- }(i, lister)
902
+ }(i, e.Name(), lister)
761
903
  }
762
904
  wg.Wait()
763
905
  if !envelope {