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
@@ -71,12 +71,18 @@ type askOptions struct {
71
71
  maxTokens int
72
72
  temperature float64
73
73
  tempSet bool
74
+ reasoning string // 推理档位(--reasoning;默认 max)
74
75
  jsonSchema string // 内联 JSON Schema(仅 llm 引擎走 --schema)
75
76
  session string // 会话续接 id(空 = 新会话,默认)
76
77
  continueF bool // 续接最近一次会话(-c/--continue;不需要 id)
77
78
  workspace string // 工作目录(-w/--workspace;空 = 用调用方 cwd)
78
79
  stop string // 停止指定会话/运行(--stop <session_id|run_id>)
79
- listSessions bool // 列出会话登记表(--sessions)
80
+ // deleteSession 删除指定会话/运行(--delete-session <session_id|run_id>):
81
+ // 登记记录 + 事件日志 + 标题覆盖一起删。与插件的 DELETE /desk/session/{id} 同一份实现
82
+ // (见 internal/session/delete.go)——「一端删了、另一端还在」的根因就是删除没落在
83
+ // 大家共用的那份数据上(2026-10-19 用户:「从pc端删除的会话移动端还在显示」)。
84
+ deleteSession string
85
+ listSessions bool // 列出会话登记表(--sessions)
80
86
  // sessionLog 读一段会话的**事件历史**(--session-log <session_id|run_id>)。
81
87
  // 为什么要有它:会话的「查询」能力原先只在 HTTP 插件里(/desk/session/{id}/messages),
82
88
  // 而 magic-agent 自己不跑服务、由调用方(如掌天瓶)直接 exec CLI —— 所以读历史
@@ -84,10 +90,41 @@ type askOptions struct {
84
90
  sessionLog string
85
91
  // logAfter 增量续读游标(--after <seq>,仅配合 --session-log):
86
92
  // 调用方报「我已看到 seq=N」,只回 seq > N 的部分。断线重连时不必重传整段历史。
87
- logAfter uint64
88
- keepAlive bool // 常驻会话:首轮结束后不退出,等 --append 追加(需配合 --stream)
89
- appendTo string // 向常驻会话追加消息(--append <session_id|run_id>)
90
- idle time.Duration // 常驻会话空闲收工时长(--idle,仅 --keep-alive 有效)
93
+ logAfter uint64
94
+ // logBefore 反向翻页游标(--before <skip>,仅配合 --session-log):
95
+ // 从文件尾**跳过多少条**已画事件,再往前取一页。
96
+ // 为什么不用 seq:同一会话每次续跑都是新进程,旧版 writer 的 seq 每轮从 1 重来,
97
+ // 拿 seq 当全局游标会在 run 边界处把更早的历史整段滤掉(见 session.ReadSessionLogBefore)。
98
+ // 为什么要它:长会话默认只画最近一轮,向上滚动时按页补更早的历史 ——
99
+ // 全量读一遍几百 MB,客户端等不起,也没必要。
100
+ logBefore uint64
101
+ // logLimit 反向翻页每页条数(--limit,仅配合 --before)。默认 200。
102
+ logLimit int
103
+ // logLean 输出时略去 snapshot(--session-log-lean,仅配合 --session-log)。
104
+ // 为什么要它:snapshot 是「该 item 到此刻的累积整段」,每条增量都带一份,
105
+ // 长会话的日志因此二次方膨胀(实测 9.4 万条思考事件 = 283MB 快照,整份 338MB)。
106
+ // 回放侧只用增量 text,所以输出裁掉它不影响画面,读取却快数倍。
107
+ logLean bool
108
+ // saveHistory 是否把流式会话的事件**落盘存档**(--save-history;默认关)。
109
+ // 存档 = <dir>/<session_id>.jsonl,供 --session-log / 客户端回放历史。
110
+ // ⚠️ 与「会话登记表」(run-*.json)不是一回事:那个由 saveRegistry 管
111
+ // (--sessions / --stop / --append 靠它寻址);这个纯粹为了回看。
112
+ saveHistory bool
113
+ // saveRegistry 是否把会话**登记落盘**(--save-registry;默认关)。
114
+ // 不开时磁盘上没有 <run_id>.json:--sessions / --stop 看不见它,
115
+ // --append 只能按 run_id 走约定路径(见 session.AppendSocketPath)。
116
+ saveRegistry bool
117
+ keepAlive bool // 常驻会话:首轮结束后不退出,等 --append 追加(需配合 --stream)
118
+ appendTo string // 向常驻会话追加消息(--append <session_id|run_id>)
119
+ idle time.Duration // 常驻会话空闲收工时长(--idle,仅 --keep-alive 有效)
120
+
121
+ // 会话标题(2026-10-15;见 internal/session/title.go)。
122
+ // 归纳口径收进 core 后,客户端不再各自实现 —— 否则 PC 与移动端标题不一致,
123
+ // 且从移动端发起的会话没人替它归纳。
124
+ summarizeTitle string // --summarize-title <id>:归纳并写入覆盖表
125
+ sessionTitle string // --session-title <id>:只读已存标题(不调 LLM)
126
+ titleForce bool // --title-force:已有覆盖也重新归纳
127
+ titleEngine string // --title-engine:归纳用哪个引擎(默认 llm)
91
128
 
92
129
  // 四档权限模型(见 internal/agent/permission.go)。仅 claude / codebuddy 接线。
93
130
  permission string // 档位:manual | accept-edits | auto | full
@@ -101,7 +138,7 @@ type askOptions struct {
101
138
  // newAskOptions 返回带默认值的选项集。
102
139
  // - 默认输出 json:固定单行 envelope,程序解析最友好;人看用 -o text。
103
140
  // - 默认引擎 codebuddy:hy3 免费模型开箱即用。
104
- // - 默认超时 600s:10 分钟,覆盖 codebuddy 慢响应场景。
141
+ // - 默认超时 1h:覆盖带工具循环的长任务(2026-10-10 由 600s 上调)。
105
142
  // - 默认常驻(keepAlive):claude/codebuddy 的流式调用自动成为常驻会话,
106
143
  // 任务跑着的时候就能 `--append` 追加需求(用户 2026-09-18:「keep-alive要是默认的」)。
107
144
  // 用 --keep-alive=false 关掉;非流式 / 不支持追加的引擎会自动忽略它。
@@ -109,7 +146,7 @@ func newAskOptions() *askOptions {
109
146
  return &askOptions{
110
147
  engine: "codebuddy",
111
148
  output: "json",
112
- timeout: 600 * time.Second,
149
+ timeout: time.Hour,
113
150
  keepAlive: true,
114
151
  }
115
152
  }
@@ -124,22 +161,33 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
124
161
  f.StringVarP(&opts.workspace, "workspace", "w", "", "工作目录(workspace):在该目录里执行引擎;codex 走原生 -C,claude/codebuddy/trae/dsh 用子进程 cwd,llm/arkclaw/openclaw 不支持(忽略并提示)")
125
162
  f.StringVarP(&opts.file, "file", "f", "", "从文件读 prompt(\"-\" = stdin)")
126
163
  f.StringArrayVarP(&opts.attach, "attach", "a", nil, "附件路径(截图/图片等),可重复或逗号分隔;与提示词一起发给引擎(各引擎落地方式见 --engines 的 attachments 字段)")
127
- f.StringVar(&opts.tools, "tools", "off", "工具开关: off | on | 逗号分隔白名单(如 Bash,Read)")
128
- f.StringVar(&opts.session, "session", "", "会话续接 id(空=新会话;传入上次输出里的 session_id 继续同一会话;arkclaw 传 contextId;dsh 不支持续接)")
164
+ f.StringVar(&opts.tools, "tools", "on", "工具开关: off | on | 逗号分隔白名单(如 Bash,Read)(默认 on,2026-10-01 与 engine.toolsOrDefault 默认同步反转)")
165
+ f.StringVar(&opts.session, "session", "", "会话续接 id(空=新会话;传入上次输出里的 session_id 继续同一会话;arkclaw 传 contextId;dsh 走 headless 的 --session-id,见 --help 的 dsh 一节)")
129
166
  f.BoolVarP(&opts.continueF, "continue", "c", false, "续接当前目录最近一次会话(不需要 session id;与 --session 同时给时 --session 优先)")
130
- f.StringVar(&opts.stop, "stop", "", "停止指定会话/运行:传 session_id 或 run_id(--sessions 可见),杀掉它的引擎进程组")
131
- f.BoolVar(&opts.listSessions, "sessions", false, "列出会话登记表(JSON 数组:run_id / session_id / pid / engine / state / 起止时间)")
167
+ f.StringVar(&opts.stop, "stop", "", "停止指定会话/运行:传 session_id 或 run_id(--sessions 可见),杀掉它的引擎进程组。只停得掉**已存档**的会话(--save-registry)")
168
+ f.BoolVar(&opts.listSessions, "sessions", false, "列出会话登记表(JSON 数组:run_id / session_id / pid / engine / source / client / state / 起止时间)。全机视图,不按 client 过滤;只列**已存档**的会话(--save-registry,默认关)")
169
+ f.StringVar(&opts.deleteSession, "delete-session", "", "删除一条会话:登记记录 + 事件日志 + 标题覆盖一起删(传 session_id 或 run_id)。删除是**全机生效**的 —— 各端读的是同一份登记表,一端删了处处都看不到。幂等(id 不认识也算成功)")
132
170
  /* 会话历史的**读取**命令。与 --sessions(列表)、--stop、--append 同一族:
133
171
  调用方(掌天瓶等)exec CLI 就能拿到会话数据,不需要 magic-agent 跑任何服务。 */
134
172
  f.StringVar(&opts.sessionLog, "session-log", "", "读一段会话的事件历史(JSON:{session,events})。传 session_id 或 run_id(--sessions 可见)")
173
+ f.StringVar(&opts.summarizeTitle, "summarize-title", "", "把一条会话归纳成标题(AI 摘要,≤12 字)并写入标题表。传 session_id 或 run_id。客户端不要再各自实现归纳 —— 这是唯一口径")
174
+ f.StringVar(&opts.sessionTitle, "session-title", "", "读一条会话**已存**的标题(JSON:{id,title}),不调 LLM。没有覆盖时回落到 prompt_head。列表页取标题走这条,别逐行调 --summarize-title")
175
+ f.BoolVar(&opts.titleForce, "title-force", false, "配合 --summarize-title:已有标题也重新归纳(默认命中缓存就直接返回,省一次 LLM 调用)")
176
+ f.StringVar(&opts.titleEngine, "title-engine", "llm", "--summarize-title 用哪个引擎归纳(默认 llm:纯文本、不带工具)")
135
177
  f.Uint64Var(&opts.logAfter, "after", 0, "配合 --session-log:增量续读,只回 seq > N 的事件(另附 count/after/lastSeq/snapshotRequired)")
178
+ f.Uint64Var(&opts.logBefore, "before", 0, "配合 --session-log:向上翻更早的历史,从文件尾跳过 N 条已画事件再取一页(0 = 最新一页;下一页把返回的 nextBefore 传回来。另附 count/before/nextBefore/firstSeq/more;与 --after 互斥)")
179
+ f.IntVar(&opts.logLimit, "limit", 200, "配合 --before:每页最多返回多少条事件(默认 200)")
180
+ f.BoolVar(&opts.logLean, "session-log-lean", false, "配合 --session-log:输出省掉 snapshot(回放只用增量 text;长会话体积可降 ~85%,读取快数倍)。只影响输出,磁盘存档不变;老客户端不传该参数时行为不变")
181
+ f.BoolVar(&opts.saveHistory, "save-history", false, "把流式会话的事件落盘存档(<dir>/<session_id>.jsonl;引擎还没吐 session_id 时先落 <run_id>.jsonl,拿到 id 后改名),供 --session-log 与客户端回放历史。默认关(一次性调用不留痕);要回看历史/跨进程续看时才开")
182
+ f.BoolVar(&opts.saveRegistry, "save-registry", false, "把会话登记落盘(<dir>/<run_id>.json),让 --sessions / --stop 事后能按 id 寻址。默认关(不留痕);要列表/停止/按 session_id 追加时才开")
136
183
  f.BoolVar(&opts.keepAlive, "keep-alive", true, "常驻会话:首轮结束后不退出、等 --append 追加(需 --stream)。claude/codebuddy 默认开;dsh 默认关(显式传 --keep-alive 开启);其余引擎不支持")
137
184
  f.StringVar(&opts.appendTo, "append", "", "向常驻会话追加一条消息:传 session_id 或 run_id,内容用 -p/位置参数给")
138
185
  f.DurationVar(&opts.idle, "idle", 5*time.Minute, "常驻会话空闲收工时长(默认 5m;0 = 本轮结束就收工,追加窗口只在任务运行期间)")
139
186
  f.IntVar(&opts.maxTokens, "max-tokens", 0, "输出 token 上限(0=不指定;claude/codebuddy 经 --settings 注入,llm 透传,trae/dsh 忽略)")
140
187
  f.Float64Var(&opts.temperature, "temperature", -1, "采样温度(-1=不指定;仅 llm 引擎透传,其余引擎忽略)")
188
+ f.StringVar(&opts.reasoning, "reasoning", "max", "推理/思考档位: minimal | low | medium | high | xhigh | max | off(默认 max;claude/codebuddy 走 --effort,codex 走 -c model_reasoning_effort;其余引擎无此参数,忽略)")
141
189
  f.StringVar(&opts.jsonSchema, "json-schema", "", "JSON Schema 内联字符串(llm / openclaw / dsh / arkclaw 引擎;启用结构化输出与 JSON 后处理)")
142
- f.DurationVarP(&opts.timeout, "timeout", "t", 600*time.Second, "单次尝试超时(默认 600s=10m)")
190
+ f.DurationVarP(&opts.timeout, "timeout", "t", time.Hour, "单次尝试超时(默认 1h)")
143
191
  f.IntVarP(&opts.retries, "retries", "r", 0, "失败重试次数(默认 0)")
144
192
  f.DurationVar(&opts.backoff, "backoff", 2*time.Second, "首次重试退避间隔(指数翻倍,上限 30s)")
145
193
  f.StringVarP(&opts.output, "output", "o", "json", "输出格式: json | text")
@@ -157,7 +205,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
157
205
  bindPermissionFlags(cmd, opts)
158
206
  }
159
207
 
160
- // bindPermissionFlags 注册四档权限模型的 flags(仅 claude / codebuddy 生效)。
208
+ // bindPermissionFlags 注册四档权限模型的 flags(仅 claude / codebuddy / dsh 生效)。
161
209
  //
162
210
  // 默认档 full 是**刻意**的:改造前 claude/codebuddy 在 --tools 非 off 时恒传
163
211
  // --dangerously-skip-permissions / -y,语义正是第 4 档。把默认值改成别的档位
@@ -166,10 +214,10 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
166
214
  func bindPermissionFlags(cmd *cobra.Command, opts *askOptions) {
167
215
  f := cmd.PersistentFlags()
168
216
  f.StringVar(&opts.permission, "permission", string(agent.DefaultPermissionTier),
169
- "权限档位: manual | accept-edits | auto | full(默认 full=保持既有行为;仅 claude/codebuddy/codebuddy-ai 生效)\n"+
217
+ "权限档位: manual | accept-edits | auto | full(默认 full=保持既有行为;仅 claude/codebuddy/codebuddy-ai/dsh 生效)\n"+
170
218
  " manual 沙箱开启,只读放行,其余逐项由用户确认\n"+
171
219
  " accept-edits 沙箱开启,编辑放行,命令仍逐条确认\n"+
172
- " auto 沙箱开启,越界由内置 LLM Guardian 判定(推荐)\n"+
220
+ " auto 沙箱开启,越界由内置 LLM Guardian 判定(推荐;dsh 无分类器,退化为「沙箱内放行」)\n"+
173
221
  " full 沙箱关闭,命令直接在宿主机执行,无审批")
174
222
  f.StringArrayVar(&opts.sandboxExclude, "sandbox-exclude", nil,
175
223
  "始终在沙箱外执行的命令(如 docker,watchman);可重复或逗号分隔(manual/accept-edits/auto 有效)")
@@ -406,7 +454,7 @@ func resolvePermissionTier(engineName, raw string, explicit bool) (agent.Permiss
406
454
  }
407
455
  if explicit && !agent.PermissionSupported(engineName) {
408
456
  return "", &usageError{fmt.Errorf(
409
- "--permission 暂不支持 %s 引擎(当前仅 claude、codebuddy、codebuddy-ai;各引擎能力见 --engines 的 permission 字段)",
457
+ "--permission 暂不支持 %s 引擎(当前仅 claude、codebuddy、codebuddy-ai、dsh;各引擎能力见 --engines 的 permission 字段)",
410
458
  engineName)}
411
459
  }
412
460
  return tier, nil
@@ -421,7 +469,7 @@ func runAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
421
469
 
422
470
  // 会话登记 + spawn 钩子:把引擎子进程 pid 记下来,之后任何进程都能
423
471
  // `magic-agent --stop <session_id>` 精确停掉它(见 internal/session)。
424
- h := beginSession(engine.Name(), req)
472
+ h := beginSession(engine.Name(), req, opts.saveRegistry)
425
473
  ctx := withSessionHook(cmd.Context(), h)
426
474
 
427
475
  runner := &agent.Runner{
@@ -449,14 +497,21 @@ func runAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
449
497
  // beginSession 落一条 running 记录(失败不阻断调用:登记表只是可运维性)。
450
498
  // PID 先填自身 pid 兜底 —— HTTP 直连这类没有子进程的引擎也能被 --stop 停掉
451
499
  // (杀 magic-agent 自己即中止该次调用);子进程起来后 spawn 钩子会覆盖成引擎 pid。
452
- func beginSession(engine string, req agent.Request) *session.Handle {
500
+ //
501
+ // ⚠️ saveRegistry=false 时**不落盘**(用户 2026-10-12:「会话登记也不需要」):
502
+ // 句柄照常分配(run_id 要给常驻会话的追加入口命名),只是磁盘上不留记录 ——
503
+ // 代价是 --sessions / --stop 看不见它,--append 只能按 run_id 走约定路径。
504
+ func beginSession(engine string, req agent.Request, saveRegistry bool) *session.Handle {
453
505
  h, err := session.Begin(session.BeginOptions{
454
506
  Engine: engine,
455
507
  Model: req.Model,
508
+ Source: os.Getenv(session.EnvSource),
509
+ Client: os.Getenv(session.EnvClient),
456
510
  Workspace: req.Workspace,
457
511
  SessionID: req.SessionID,
458
512
  PromptHead: firstUserText(req.Messages),
459
513
  PID: os.Getpid(),
514
+ NoPersist: !saveRegistry,
460
515
  })
461
516
  if err != nil {
462
517
  return nil
@@ -535,15 +590,26 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
535
590
  stdout, stderr := cmd.OutOrStdout(), cmd.ErrOrStderr()
536
591
  sink := newEventSink(stdout, engine.Name(), opts.events)
537
592
 
538
- // 「看历史」持久化:每条事件落盘到 <dir>/<session_id>.jsonl,
539
- // 移动端进入会话详情时先读这个文件渲染历史,再开 SSE 接续。
540
- // 不写盘也不会让流失败;所有写盘错误都进 stderr 警告、流继续(写盘是个独立 goroutine)。
541
- sessionLog, err := newSessionWriterAuto(sessionLogDir)
542
- if err != nil {
543
- fmt.Fprintf(stderr, "magic-agent: 准备会话持久化目录失败:%v(流继续)\n", err)
544
- sessionLog = nil
545
- } else {
546
- defer func() { sessionLog.close() }()
593
+ /* 「看历史」存档(--save-history,**默认关**):每条事件落盘到
594
+ <dir>/<session_id>.jsonl,移动端/桌面端进入会话详情时先读它渲染历史,再开 SSE 接续。
595
+
596
+ 为什么默认不落(用户 2026-10-12:「加一个参数 默认不存档」):落盘是**副作用**,
597
+ 而绝大多数调用(CLI 手工跑一轮、CI、脚本编排、批量任务)**没人会回看**,
598
+ 写下来的 jsonl 却会一直堆在磁盘上(登记表有 24h 保留期在清,这个没有 ——
599
+ prune 只删 *.json)。要回放的调用方自己显式开:magic-client 就是这么干的
600
+ (它的 /desk/ask 恒传 --save-history)。
601
+
602
+ ⚠️ 不写盘也不会让流失败;所有写盘错误都进 stderr 警告、流继续
603
+ (写盘是个独立 goroutine,见 sessionlog.go 的三条约束)。 */
604
+ var sessionLog *sessionWriter
605
+ if opts.saveHistory {
606
+ sw, err := newSessionWriterAuto(sessionLogDir)
607
+ if err != nil {
608
+ fmt.Fprintf(stderr, "magic-agent: 准备会话存档目录失败:%v(流继续)\n", err)
609
+ } else {
610
+ sessionLog = sw
611
+ defer func() { sessionLog.close() }()
612
+ }
547
613
  }
548
614
  // 把 session_id 推到 writer:CLI 这边的 OnSessionID 回调(引擎流里见到 init 行就调)
549
615
  // 写新 id,writer 自己的写盘 goroutine 读出来开文件。
@@ -568,7 +634,9 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
568
634
  if m.Role != "user" || strings.TrimSpace(m.Content) == "" {
569
635
  continue
570
636
  }
571
- sessionLog.push(sessionEvent{Kind: "user", Text: m.Content})
637
+ // 附件挂在 user 事件上:引擎事件里没有附件信息(`image` 只对应助手产出),
638
+ // 只有落这里,历史回放才复原得出「那一轮带了哪张图」。
639
+ sessionLog.push(sessionEvent{Kind: "user", Text: m.Content, Attachments: req.Attachments})
572
640
  }
573
641
  }
574
642
 
@@ -583,10 +651,19 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
583
651
  // ⚠️ 这里只走**引擎事件**。用户提问(kind:"user")与失败(kind:"error")
584
652
  // 不由流事件触发,见上面 runStreamAsk 里的 push 与下面的 error 分支。
585
653
  /* ToolKind 与 SSE 流同源(同一个 toolKindOfEvent):历史回放与实时渲染
586
- 因此画出同一种卡,而不是各按工具名猜一套(见 agent/toolkind.go)。 */
654
+ 因此画出同一种卡,而不是各按工具名猜一套(见 agent/toolkind.go)。
655
+ 状态收敛三元组同样落盘 —— 历史回放按 (item_id, item_revision)
656
+ 覆盖式 upsert,语义与实时流完全一致。
657
+ ⚠️ **不落 Snapshot**(2026-10-20):它是「该 item 到此刻的累积整段」,
658
+ 每条增量都带一份 ⇒ 二次方膨胀(实测一条会话 1.8GB,95.6% 是它)。
659
+ 落盘只留增量 text + 收敛三元组;回放侧只用增量自己拼。
660
+ 实时流(NDJSON)仍照旧带 snapshot —— 那是多端同步要的两回事。 */
587
661
  sessionLog.push(sessionEvent{
588
662
  Kind: string(ev.Kind), Text: ev.Text, Name: ev.Name, ID: ev.ID,
589
663
  ToolKind: toolKindOfEvent(ev), Ask: ev.Ask,
664
+ ItemID: ev.ItemID, ItemRevision: ev.ItemRevision,
665
+ Status: string(ev.Status),
666
+ Error: ev.Error, Reason: ev.Reason,
590
667
  })
591
668
  }
592
669
  if format == agent.FormatJSON {
@@ -622,13 +699,19 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
622
699
  // ⚠️ headless 下 claude 会在模型提问后**自行拒绝**(模型拿不到答案),
623
700
  // 要真正作答得把答案作为后续 user 消息补进去(agent.EncodeAskFollowUp)。
624
701
  fmt.Fprint(stderr, "❓ "+ev.Text+"\n")
702
+ case agent.KindTurnFailed:
703
+ // 本轮异常收尾(引擎没给 result 行)。打 stderr:stdout 是正文通道,
704
+ // 往里写错误会让 `> file` 存下一堆垃圾。真正的失败 envelope 由下面
705
+ // runStreamAsk 的 error 分支写(stdout 一条 error 事件),这里只
706
+ // 保证 text 模式的调用方也**看得见**这一轮没有正常结束。
707
+ fmt.Fprint(stderr, "✗ 本轮异常收尾:"+ev.Reason+"\n")
625
708
  }
626
709
  wireLogger(ev)
627
710
  }
628
711
  }
629
712
 
630
713
  // 超时:常驻会话的存活由 --idle(空闲收工)决定,所以常驻生效时只有调用方**显式**给 -t
631
- // 才套总超时;否则不设上限(长任务 + 追加不受 600s 默认值限制)。
714
+ // 才套总超时;否则不设上限(长任务 + 追加不受 1h 默认值限制)。
632
715
  ctx := cmd.Context()
633
716
  /* --control 要能主动打断:在(可能的)超时之上再套一层可取消的 ctx。
634
717
  打断与超时走同一条路径 —— 引擎进程组由 Stream 侧按 ctx 收拾。 */
@@ -644,9 +727,39 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
644
727
  }
645
728
 
646
729
  // 会话登记 + spawn 钩子(与 runAsk 同一套:--stop 靠它拿到引擎 pid)。
647
- h := beginSession(engine.Name(), req)
730
+ h := beginSession(engine.Name(), req, opts.saveRegistry)
648
731
  ctx = withSessionHook(ctx, h)
649
732
 
733
+ /* 把「本次运行 id」也交给存档 writer(2026-10-15):引擎还没吐 session_id 时,
734
+ 日志就先落到 <run_id>.jsonl —— 于是
735
+ · 「刚开跑、还不知道 id」的会话在别的客户端里**当场可读**(此前文件还没有名字,
736
+ 读出来是空历史,看起来像「这条会话没有内容」);
737
+ · 引擎**整轮都不给 id** 的形态(dsh 的 headless 回退轮)不再白开 --save-history
738
+ (此前那些事件一直躺在内存 buffer 里,收工时被丢光)。
739
+ 拿到 id 后 writer 自己改名为 <session_id>.jsonl(见 sessionlog.go 的 renameTo)。 */
740
+ if sessionLog != nil {
741
+ sessionLog.setRunID(h.RunID())
742
+ }
743
+
744
+ /* 会话 id **一拿到就回填登记表**,不等收尾(2026-10-16 修)。
745
+ 在此之前只有收尾(finishSession → Handle.Finish)才写这个字段,于是「正在跑的会话」
746
+ 在磁盘上(以及客户端的会话列表里)只有 run_id:
747
+ · 列表按 session_id 去重的语义在这段时间**完全失效** —— 一条会话会以 run_id 为键
748
+ 另开一行(标题归纳也落在 run_id 上,用户看到的就是「同一段对话裂成两条任务」);
749
+ · 客户端点开这样的行,回传的是 run_id(它以为是会话 id)—— run_id 不是引擎的会话
750
+ id,续接必然接不上(引擎报未知会话 / 静默另起一段)。
751
+ 长任务尤其致命:一轮跑 30 分钟以上,这扇窗口就开 30 分钟。
752
+ ⚠️ SetSessionID 自己幂等(值没变直接返回),所以逐事件调用不会带来额外落盘。 */
753
+ if h != nil {
754
+ prevOnSessionID := req.OnSessionID
755
+ req.OnSessionID = func(id string) {
756
+ if prevOnSessionID != nil {
757
+ prevOnSessionID(id)
758
+ }
759
+ h.SetSessionID(id)
760
+ }
761
+ }
762
+
650
763
  // 常驻会话(默认开,claude/codebuddy 的流式调用):开追加入口等 --append,
651
764
  // 空闲 --idle 后优雅收工(关闭通道 → 引擎 stdin EOF → 正常收尾)。
652
765
  var ka *keepAlive
@@ -681,8 +794,29 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
681
794
  }
682
795
 
683
796
  // ready 握手(仅 --events):客户端据此确认「谁在跑、契约版本是多少」。
797
+ // run_id 一并带出:新会话首轮引擎可能整轮都不吐 session_id,客户端只有拿到
798
+ // run_id 才能把这条运行中的轮次对应回登记表里以 run_id 为键的会话。
799
+ if h != nil {
800
+ sink.setRunID(h.RunID())
801
+ }
684
802
  _ = sink.ready(req.Model)
685
803
 
804
+ /* 事件级别的 id 兜底也要接上日志 writer(2026-10-18,用户:「runid 不能统一记成 sessionid 吗」)。
805
+ `OnSessionID` 只是 id 的**一条**来源:codex 这一脉从不调它,只在事件上带 session_id。
806
+ 此前事件级兜底只回填了登记表(见下面 onEvent 里的 h.SetSessionID),writer 没接 ——
807
+ 于是出现「登记表里有 session_id、日志文件却永远叫 <run_id>.jsonl」这种半截状态:
808
+ 客户端按 session_id 点开就读到空历史(列表 has_log=true、详情说「还没有事件」)。
809
+ 写盘时就把名字定成终名之后,读取端不必再猜文件叫哪个名字。
810
+ ⚠️ 独立于 h:--save-history 可以不开登记表,那时这条兜底是唯一的 id 来源。
811
+ 空 id 由 setSessionID 自己跳过(它必须跳过,理由见那里的注释)。 */
812
+ if sessionLog != nil {
813
+ inner := onEvent
814
+ onEvent = func(ev agent.StreamEvent) {
815
+ sessionLog.setSessionID(ev.SessionID)
816
+ inner(ev)
817
+ }
818
+ }
819
+
686
820
  /* ── 会话状态的中途写入(2026-09-29,对齐 anywhere 的 TimelineStatus)──
687
821
  引擎**停下来等用户**(提问 / 工具待授权)时,这条会话仍然活着,但没在跑 ——
688
822
  界面上该显示「等审批」而不是「生成中」。用户作答、或这一轮的任何后续事件到达时
@@ -692,6 +826,9 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
692
826
  if h != nil {
693
827
  inner := onEvent
694
828
  onEvent = func(ev agent.StreamEvent) {
829
+ // 会话锚点:引擎把 id 挂在事件上时同样立刻回填(覆盖不调 OnSessionID 的引擎,
830
+ // 见上面那段注释)。空 id 由 SetSessionID 自己跳过。
831
+ h.SetSessionID(ev.SessionID)
695
832
  if ev.Kind == agent.KindAsk {
696
833
  h.Mark(session.StateWaitingApproval)
697
834
  } else {
@@ -701,6 +838,39 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
701
838
  }
702
839
  }
703
840
 
841
+ /* 斜杠命令节点(2026-10-10,跨引擎渲染覆盖 P0-3):引擎都不上报 command 的
842
+ 生命周期,core 依输入形态自己补 —— 与上面 user 事件同族(core 自己合成、
843
+ 不走引擎流)。最后一条 user 消息是 /xxx 形态时:开跑前发 status="running",
844
+ 流收尾后再发终态帧(见下面 Stream 返回处)。实时 SSE 与历史落盘同源,
845
+ 历史回放免费还原。
846
+ ⚠️ 走 sink.stream + wireLogger,不经 onEvent —— 此刻 onEvent 已被 keep-alive
847
+ 与 Mark 包了好几层,合成事件不该再进那些簿记;text 模式下 stdout 是正文
848
+ 通道,只落盘、不外发。
849
+ ⚠️ 只覆盖本轮初始消息:--append 追加进来的 /xxx 走 ka 通道直进引擎,core
850
+ 不经手,不合成(有需要再补)。 */
851
+ lastUserMsg := ""
852
+ for _, m := range req.Messages {
853
+ if m.Role == "user" && strings.TrimSpace(m.Content) != "" {
854
+ lastUserMsg = strings.TrimSpace(m.Content)
855
+ }
856
+ }
857
+ isCommand := isSlashCommand(lastUserMsg)
858
+ emitCommand := func(status agent.StreamStatus) {
859
+ if !isCommand {
860
+ return
861
+ }
862
+ ev := agent.StreamEvent{Kind: agent.KindCommand, Text: lastUserMsg, Status: status}
863
+ if format == agent.FormatJSON {
864
+ _ = sink.stream(ev)
865
+ wireLogger(ev)
866
+ return
867
+ }
868
+ if sessionLog != nil {
869
+ sessionLog.push(sessionEvent{Kind: string(agent.KindCommand), Text: lastUserMsg, Status: string(status)})
870
+ }
871
+ }
872
+ emitCommand(agent.StatusRunning)
873
+
704
874
  res, err := streamer.Stream(ctx, req, onEvent)
705
875
  interrupted := ctl != nil && ctl.WasInterrupted()
706
876
  if interrupted {
@@ -708,6 +878,18 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
708
878
  「这一轮真的结束了」。失败链本身就是 context canceled,不必再包一层。 */
709
879
  _ = sink.notice("interrupted", map[string]any{"reason": "control"})
710
880
  }
881
+ /* 命令节点收尾帧(P0-3):放在失败分支**之前** —— 任何出口(成功/打断/失败)
882
+ 都要有「跑完了」的帧,否则历史里会留一条永远「进行中」的命令。终态取值对齐
883
+ 前端 qaStatus 词表(completed / cancelled / failed)。 */
884
+ if isCommand {
885
+ cmdStatus := agent.StreamStatus("completed")
886
+ if interrupted {
887
+ cmdStatus = agent.StreamStatus("cancelled")
888
+ } else if err != nil {
889
+ cmdStatus = agent.StreamStatus("failed")
890
+ }
891
+ emitCommand(cmdStatus)
892
+ }
711
893
  if err != nil {
712
894
  /* 被打断 ≠ 失败(2026-09-29):这里的 err 就是 context canceled,但「用户喊停」
713
895
  与「引擎报错」是两件事 —— 混成 failed 之后左栏只能显示「失败」,
@@ -782,6 +964,33 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
782
964
  return nil
783
965
  }
784
966
 
967
+ // isSlashCommand 判断一条用户输入是不是斜杠命令(/init /compact 这类)。
968
+ //
969
+ // 规则从宽取「像命令」的形状:以 / 开头、命令词是 ASCII 字母数字(- _ 允许),
970
+ // 后面跟参数或直接结束都算。中文正文以 / 开头的场景(路径、算式)极少以整条
971
+ // 消息的形态出现;误判的代价也只是历史里多一条标记行,不影响正文渲染。
972
+ func isSlashCommand(content string) bool {
973
+ s := strings.TrimSpace(content)
974
+ if !strings.HasPrefix(s, "/") {
975
+ return false
976
+ }
977
+ word := s[1:]
978
+ if i := strings.IndexAny(word, " \t\n\r"); i >= 0 {
979
+ word = word[:i]
980
+ }
981
+ if word == "" {
982
+ return false
983
+ }
984
+ for _, r := range word {
985
+ switch {
986
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
987
+ default:
988
+ return false
989
+ }
990
+ }
991
+ return true
992
+ }
993
+
785
994
  // writeStreamEventJSON 输出一行流式事件 NDJSON。
786
995
  //
787
996
  // 输出 schema(所有事件共享 {type,text}):
@@ -862,6 +1071,16 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
862
1071
  if opts.temperature < -1 {
863
1072
  return nil, "", agent.Request{}, &usageError{fmt.Errorf("--temperature must be >= 0 or omitted, got %v", opts.temperature)}
864
1073
  }
1074
+ if !agent.ValidReasoningLevel(opts.reasoning) {
1075
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf(
1076
+ "--reasoning 取值无效: %q(可选 minimal | low | medium | high | xhigh | max | off)", opts.reasoning)}
1077
+ }
1078
+ reasoning := agent.ReasoningLevel(opts.reasoning)
1079
+ // 默认值是 max,不应对无参引擎刷提示;只有用户显式指定才说明「被忽略」。
1080
+ if flagChanged(cmd, "reasoning") && reasoning != "" && !agent.ReasoningSupportOf(engine.Name()) {
1081
+ fmt.Fprintf(cmd.ErrOrStderr(),
1082
+ "magic-agent: 提示:%s 引擎没有推理档位参数,--reasoning %s 已忽略\n", engine.Name(), reasoning)
1083
+ }
865
1084
  toolsMode, err := parseToolsMode(opts.tools)
866
1085
  if err != nil {
867
1086
  return nil, "", agent.Request{}, err
@@ -877,18 +1096,25 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
877
1096
  "magic-agent: 提示:当前 --tools off(不调用任何工具),--permission %s 不会生效;如需工具请加 --tools on\n", tier)
878
1097
  }
879
1098
  // 常驻会话默认值按引擎区分(见 kaEnabled / agent.AppendDefaultOn),但只在
880
- // 「流式 + 引擎支持追加」时才有意义。显式 `--keep-alive` 却没满足条件 → 明确报错;
881
- // 默认值不满足条件 → 静默忽略(不影响原有调用)。
1099
+ // 「流式 + 引擎支持追加 + 本平台有追加入口」时才有意义。显式 `--keep-alive`
1100
+ // 却不满足条件 → 明确报错;默认值不满足条件 → 静默忽略(不影响原有调用)。
882
1101
  if flagChanged(cmd, "keep-alive") && opts.keepAlive {
883
1102
  if !opts.stream {
884
1103
  return nil, "", agent.Request{}, &usageError{fmt.Errorf(
885
1104
  "--keep-alive 是常驻会话(首轮结束后等 --append 追加),必须配合 --stream:追加轮次的输出要靠事件流送出")}
886
1105
  }
1106
+ // 平台检查放在引擎检查**之后**:先说「这个引擎不支持」更有信息量,
1107
+ // 再说「这台机器开不出来」。两者都成立时,平台原因才是最终拦路虎。
887
1108
  if !agent.AppendSupportOf(engine.Name()) {
888
1109
  return nil, "", agent.Request{}, &usageError{fmt.Errorf(
889
1110
  "--keep-alive 暂不支持 %s 引擎(当前支持 claude、codebuddy、codebuddy-ai:靠 stream-json 输入持续收 user 消息;"+
890
1111
  "dsh:靠 SDK 通道对同一会话继续 prompt)", engine.Name())}
891
1112
  }
1113
+ if !session.AppendTransportAvailable() {
1114
+ return nil, "", agent.Request{}, &usageError{fmt.Errorf(
1115
+ "--keep-alive 暂不支持 Windows:追加入口依赖 unix domain socket,本平台没有等价通道。" +
1116
+ "去掉 --keep-alive 即可正常流式调用(提示词已改走 stdin,多行内容在 Windows 上同样是完整的)")}
1117
+ }
892
1118
  }
893
1119
  if opts.idle < 0 {
894
1120
  return nil, "", agent.Request{}, &usageError{fmt.Errorf("--idle 不能为负(收到 %v;0 = 本轮结束就收工)", opts.idle)}
@@ -1006,6 +1232,7 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
1006
1232
  SessionID: strings.TrimSpace(opts.session),
1007
1233
  Continue: opts.continueF,
1008
1234
  Workspace: workspace,
1235
+ Reasoning: reasoning,
1009
1236
  Permission: tier,
1010
1237
  PermissionOptions: agent.PermissionOptions{
1011
1238
  ExcludedCommands: opts.sandboxExclude,
@@ -12,7 +12,7 @@ package cli
12
12
  // - 引擎/格式参数校验错误 → exit 2
13
13
  //
14
14
  // 默认值契约(newAskOptions 与 bindAskFlags 双处维护,须一致):
15
- // engine=codebuddy, output=json, timeout=600s, tools=off
15
+ // engine=codebuddy, output=json, timeout=1h, tools=off
16
16
 
17
17
  import (
18
18
  "bytes"
@@ -260,6 +260,7 @@ func TestRootShorthandAsk(t *testing.T) {
260
260
  }
261
261
 
262
262
  // -p 与位置参数组合:-p 在前、args 在后;--tools 透传到 Request。
263
+ // ⚠️ 2026-10-01 默认从 off 改成 on —— 此处断言随之反转。
263
264
  func TestPromptFlagCombinedWithArgs(t *testing.T) {
264
265
  capEng := &capturingEngine{name: "fake-cap"}
265
266
  registerFake(capEng)
@@ -272,9 +273,9 @@ func TestPromptFlagCombinedWithArgs(t *testing.T) {
272
273
  if !strings.HasPrefix(got, "主问题") || !strings.Contains(got, "附加 上下文") {
273
274
  t.Errorf("prompt = %q, want -p 在前 + args 在后", got)
274
275
  }
275
- // 默认 tools=off 透传
276
- if !agent.ToolsIsOff(capEng.lastTools) {
277
- t.Errorf("default tools should be off, got %#v", capEng.lastTools)
276
+ // 默认 tools=on 透传(2026-10-01 反转)
277
+ if !agent.ToolsIsOn(capEng.lastTools) {
278
+ t.Errorf("default tools should be on (2026-10-01), got %#v", capEng.lastTools)
278
279
  }
279
280
  }
280
281
 
@@ -409,7 +410,7 @@ func TestParseToolsMode(t *testing.T) {
409
410
  }
410
411
  }
411
412
 
412
- // 默认值契约:engine=codebuddy / output=json / timeout=600s。
413
+ // 默认值契约:engine=codebuddy / output=json / timeout=1h。
413
414
  func TestDefaults(t *testing.T) {
414
415
  opts := newAskOptions()
415
416
  if opts.engine != "codebuddy" {
@@ -418,8 +419,8 @@ func TestDefaults(t *testing.T) {
418
419
  if opts.output != "json" {
419
420
  t.Errorf("default output = %q, want json", opts.output)
420
421
  }
421
- if opts.timeout != 600_000_000_000 { // 600s
422
- t.Errorf("default timeout = %v, want 600s", opts.timeout)
422
+ if opts.timeout != time.Hour {
423
+ t.Errorf("default timeout = %v, want 1h", opts.timeout)
423
424
  }
424
425
  }
425
426
 
@@ -478,7 +479,7 @@ func TestStreamPassesExplicitTimeoutToEngine(t *testing.T) {
478
479
  t.Errorf("引擎收到的 req.Timeout = %v want 15m(显式 -t 未透传)", eng.lastTimeout)
479
480
  }
480
481
 
481
- // 没给 -t → 不透传:别去覆盖引擎自己的默认(如 llm 的条目级 timeout、openclaw 的 600s)。
482
+ // 没给 -t → 不透传:别去覆盖引擎自己的默认(如 llm 的条目级 timeout、openclaw 自己的 --timeout)。
482
483
  eng.lastTimeout = -1
483
484
  if _, _, err := runAskCmd(t, "", "-e", "fake-stream-timeout", "--stream", "hi"); err != nil {
484
485
  t.Fatalf("--stream: %v", err)
@@ -704,6 +705,7 @@ func (m *modelListEngine) ModelCredits(context.Context) map[string]string {
704
705
  // 直接返回"无动态来源"。t.Cleanup 自动还原。
705
706
  func stubModelProbe(t *testing.T) {
706
707
  t.Helper()
708
+ clearModelListCache()
707
709
  prev := probeEngineModels
708
710
  probeEngineModels = func(ctx context.Context, l agent.ModelLister) ([]string, error) {
709
711
  if _, ok := l.(*modelListEngine); ok {
@@ -869,15 +871,17 @@ func TestEnginesFlagNoModelsSkipsProbe(t *testing.T) {
869
871
  // ask / engines / version / completion 子命令均已删除:
870
872
  // 这些词不再被识别为子命令——作为位置参数它们就是普通 prompt。
871
873
  // 真实执行会走引擎调用(测试环境无 codebuddy CLI,报 unknown engine),
872
- // 这里用 cobra 命令树直接断言不存在任何子命令。
874
+ // 这里用 cobra 命令树直接断言只剩 `client` 这一个(它管的是另一个常驻进程,
875
+ // 与「提问」不是同一种形态,见 clientcmd.go)。
873
876
  func TestSubcommandsRemoved(t *testing.T) {
874
877
  cmd := NewRootCommand()
875
- if subs := cmd.Commands(); len(subs) != 0 {
878
+ subs := cmd.Commands()
879
+ if len(subs) != 1 || subs[0].Name() != "client" {
876
880
  names := make([]string, 0, len(subs))
877
881
  for _, s := range subs {
878
882
  names = append(names, s.Name())
879
883
  }
880
- t.Errorf("root should have no subcommands, got %v", names)
884
+ t.Errorf("root should have only the client subcommand, got %v", names)
881
885
  }
882
886
  }
883
887