magic-agent 2.1.5 → 2.3.2

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 (78) hide show
  1. package/CLIENT-CONTRACT.md +147 -11
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +17 -0
  4. package/client-ui/pc/index.html +5 -0
  5. package/client-ui/pc/js/05-task-list-render.js +28 -4
  6. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  7. package/client-ui/pc/js/93-rail.js +24 -2
  8. package/client-ui/shared/conv/03-chat-message.js +63 -3
  9. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  10. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  11. package/client-ui/shared/conv/19-attachments.js +75 -11
  12. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  13. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  14. package/client-ui/shared/conv/README.md +6 -0
  15. package/client-ui/shared/conv/conv.css +33 -1
  16. package/client-ui/shared/conversation.css +42 -4
  17. package/client-ui/shared/conversation.js +801 -102
  18. package/client-ui/shared/transport-http.js +199 -38
  19. package/client-ui-mobile/index.html +731 -581
  20. package/client-ui-mobile/reqs.css +39 -0
  21. package/client-ui-mobile/reqs.js +520 -0
  22. package/cmd/magic-client/main.go +1 -1
  23. package/internal/agent/capability.go +8 -1
  24. package/internal/agent/claude.go +5 -3
  25. package/internal/agent/codebuddy.go +6 -2
  26. package/internal/agent/codex.go +7 -2
  27. package/internal/agent/dsh_sdk.go +8 -0
  28. package/internal/agent/dsh_sdk_test.go +17 -0
  29. package/internal/agent/engine.go +20 -0
  30. package/internal/agent/engine_test.go +52 -0
  31. package/internal/agent/llmengine.go +14 -0
  32. package/internal/agent/llmengine_test.go +24 -0
  33. package/internal/agent/llmhttp.go +6 -0
  34. package/internal/agent/llmhttp_test.go +27 -0
  35. package/internal/agent/llmmodels.go +48 -0
  36. package/internal/agent/permission.go +5 -0
  37. package/internal/cli/ask.go +38 -17
  38. package/internal/cli/requirements.go +2 -0
  39. package/internal/cli/root.go +5 -0
  40. package/internal/cli/session.go +16 -14
  41. package/internal/cli/sessionlog.go +5 -0
  42. package/internal/cli/sessionlog_user_test.go +49 -0
  43. package/internal/cli/suggest.go +98 -0
  44. package/internal/cli/suggest_test.go +54 -0
  45. package/internal/cli/team.go +111 -24
  46. package/internal/client/requirements.go +27 -7
  47. package/internal/client/requirements_test.go +51 -0
  48. package/internal/client/service.go +35 -3
  49. package/internal/client/service_test.go +35 -0
  50. package/internal/client/suggest.go +131 -0
  51. package/internal/client/suggest_test.go +116 -0
  52. package/internal/requirements/consolidate.go +86 -24
  53. package/internal/requirements/requirements.go +9 -0
  54. package/internal/requirements/requirements_test.go +129 -0
  55. package/internal/requirements/store.go +11 -3
  56. package/internal/session/delete.go +58 -11
  57. package/internal/session/delete_test.go +140 -0
  58. package/internal/session/session.go +25 -0
  59. package/internal/session/session_mark_test.go +62 -0
  60. package/internal/suggest/suggest.go +532 -0
  61. package/internal/suggest/suggest_test.go +268 -0
  62. package/internal/team/board.go +107 -15
  63. package/internal/team/execute.go +1 -1
  64. package/internal/team/team.go +28 -19
  65. package/internal/team/team_test.go +68 -15
  66. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  67. package/npm/apk/version.json +3 -3
  68. package/npm/dist/darwin-arm64/magic-agent +0 -0
  69. package/npm/dist/darwin-arm64/magic-client +0 -0
  70. package/npm/dist/darwin-x64/magic-agent +0 -0
  71. package/npm/dist/darwin-x64/magic-client +0 -0
  72. package/npm/dist/linux-arm64/magic-agent +0 -0
  73. package/npm/dist/linux-arm64/magic-client +0 -0
  74. package/npm/dist/linux-x64/magic-agent +0 -0
  75. package/npm/dist/linux-x64/magic-client +0 -0
  76. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  77. package/npm/dist/win32-x64/magic-client.exe +0 -0
  78. package/package.json +6 -2
@@ -167,6 +167,11 @@ type dshSDKInitParams struct {
167
167
  Provider string `json:"provider"`
168
168
  Model string `json:"model"`
169
169
  MaxTokens int `json:"maxTokens,omitempty"`
170
+ // ReasoningEffort 为空 = 沿用模型默认;NoThinking 时传官方值 "off"。
171
+ // 官方依据:dsh-sdk-protocol InitializeParams.reasoningEffort;
172
+ // dsh-llm-deepseek README:"off" 序列化为 thinking.type=disabled。
173
+ // pi-ai 路由需在 profile 声明 reasoningEfforts.off(本机 sdk patch 已加)。
174
+ ReasoningEffort string `json:"reasoningEffort,omitempty"`
170
175
  }
171
176
 
172
177
  // dshSDKTextBlock = TextBlock(ContentBlock 的 text 成员)。
@@ -410,6 +415,9 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
410
415
 
411
416
  // ① initialize:握手失败 = SDK 通道不可用(旧版 dsh 没有该 profile 时会走到这里)。
412
417
  initParams := dshSDKInitParams{Cwd: workspace, Provider: provider, Model: model, MaxTokens: req.MaxTokens}
418
+ if req.NoThinking {
419
+ initParams.ReasoningEffort = "off"
420
+ }
413
421
  initID, err := send("initialize", initParams)
414
422
  if err != nil {
415
423
  return dshSDKTurnResult{}, fmt.Errorf("%w: 写 initialize: %v", errDshSDKUnavailable, err)
@@ -261,6 +261,23 @@ func TestDshSDKIgnoresOtherSessions(t *testing.T) {
261
261
  }
262
262
  }
263
263
 
264
+ // NoThinking:官方 InitializeParams.reasoningEffort=off,不能只靠隐藏事件。
265
+ func TestDshSDKNoThinkingSendsOff(t *testing.T) {
266
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
267
+ dir := t.TempDir()
268
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
269
+
270
+ if _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
271
+ Messages: []Message{{Role: "user", Content: "hi"}},
272
+ NoThinking: true,
273
+ }); err != nil {
274
+ t.Fatalf("Complete: %v", err)
275
+ }
276
+ if got := readFileString(t, stdinLog); !strings.Contains(got, `"reasoningEffort":"off"`) {
277
+ t.Errorf("initialize 应带 reasoningEffort=off: %s", got)
278
+ }
279
+ }
280
+
264
281
  // initialize 被服务端明确拒绝(模型没配)→ **真失败**,不静默回退 headless。
265
282
  func TestDshSDKInitializeRejectedIsFatal(t *testing.T) {
266
283
  dshSDKHome(t, "modelverse", "no-such-model")
@@ -154,6 +154,16 @@ type Request struct {
154
154
  // trae/llm/openclaw/dsh/arkclaw 无对应参数。
155
155
  Reasoning string
156
156
 
157
+ // NoThinking 请求引擎关闭模型思考:
158
+ // claude --thinking disabled(官方)
159
+ // codex -c model_reasoning_effort="none" + model_reasoning_summary="none"
160
+ // codebuddy settings alwaysThinkingEnabled=false(官方 settings / SDK thinking:disabled)
161
+ // dsh InitializeParams.reasoningEffort="off"(官方 SDK;dsh-llm-deepseek 会发 thinking.type=disabled)
162
+ // llm 选同线 thinking.disabled 变体;没有变体时传 reasoning_effort="none"
163
+ // (CLI 委托路径用 llm 的官方 -o reasoning_effort none)
164
+ // 其余引擎没有已知开关;调用方(建议生成)仍会标记,失败后再降级 llm。
165
+ NoThinking bool
166
+
157
167
  // JSONSchema 是可选的结构化输出约束。
158
168
  //
159
169
  // 各引擎行为:
@@ -274,6 +284,16 @@ type Response struct {
274
284
  Attempts int
275
285
  }
276
286
 
287
+ // NoThinkingSupportOf 引擎是否有已知的关闭思考手段。
288
+ // 只列官方文档/参数确认过的;未知引擎返回 false,调用方应提示而不是假装关闭。
289
+ func NoThinkingSupportOf(name string) bool {
290
+ switch name {
291
+ case "claude", "codex", "codebuddy", "codebuddy-ai", "dsh", "llm":
292
+ return true
293
+ }
294
+ return false
295
+ }
296
+
277
297
  // Engine 是一个 agent CLI 后端的统一抽象。
278
298
  type Engine interface {
279
299
  // Name 引擎标识("claude" / "codebuddy" / "trae")。
@@ -190,6 +190,42 @@ func TestReasoningEffortArgs(t *testing.T) {
190
190
  t.Errorf("codex model_reasoning_effort 未透传: %v", codexArgs)
191
191
  }
192
192
 
193
+ /* NoThinking(建议生成):每个有原生开关的引擎都要真的关闭,不能只是 minimal。 */
194
+ claudeNo := (&ClaudeEngine{}).buildArgsBase(Request{NoThinking: true, Reasoning: "max"})
195
+ if !hasPair(claudeNo, "--thinking", "disabled") || hasPair(claudeNo, "--effort", "max") {
196
+ t.Errorf("claude NoThinking 应 --thinking disabled 且不传 effort: %v", claudeNo)
197
+ }
198
+ // codebuddy:NoThinking 走官方 settings alwaysThinkingEnabled=false,
199
+ // 不能再传 --effort xhigh(那会把思考档位又打开)。
200
+ cbNo := (&CodeBuddyEngine{}).buildArgsBase(Request{NoThinking: true, Reasoning: "xhigh"})
201
+ if hasPair(cbNo, "--effort", "off") || hasPair(cbNo, "--effort", "xhigh") {
202
+ t.Errorf("codebuddy NoThinking 不应传 --effort: %v", cbNo)
203
+ }
204
+ codexNo := (&CodexEngine{}).buildArgs(Request{NoThinking: true, Reasoning: "max"}, "hi", "", "")
205
+ if !hasPair(codexNo, "-c", `model_reasoning_effort="none"`) ||
206
+ !hasPair(codexNo, "-c", `model_reasoning_summary="none"`) ||
207
+ hasPair(codexNo, "-c", `model_reasoning_effort="max"`) {
208
+ t.Errorf("codex NoThinking 应 none 且不传 max: %v", codexNo)
209
+ }
210
+ // dsh 的 launcher/headless 没有 reasoning 开关:NoThinking 对它保持现状,
211
+ // 不能塞 --effort(实测启动器报 "--profile <name> is required")。
212
+ dshNo := dshHeadlessArgs("headless", "", "hi", false)
213
+ if len(dshNo) < 3 || dshNo[0] != "--profile" || dshNo[1] != "headless" {
214
+ t.Errorf("dsh 参数不应被 NoThinking 改坏: %v", dshNo)
215
+ }
216
+
217
+ // 官方关闭思考能力表:只列有依据的;trae/openclaw/arkclaw 不假装支持。
218
+ for _, e := range []string{"claude", "codex", "codebuddy", "dsh", "llm"} {
219
+ if !NoThinkingSupportOf(e) {
220
+ t.Errorf("NoThinkingSupportOf(%s) 应为 true", e)
221
+ }
222
+ }
223
+ for _, e := range []string{"trae", "openclaw", "arkclaw"} {
224
+ if NoThinkingSupportOf(e) {
225
+ t.Errorf("NoThinkingSupportOf(%s) 应为 false", e)
226
+ }
227
+ }
228
+
193
229
  // off/none/disabled 一律归一为「不传」。
194
230
  for _, v := range []string{"off", "none", "disabled", " OFF "} {
195
231
  if lv := ReasoningLevel(v); lv != "" {
@@ -893,6 +929,22 @@ func TestCodeBuddyMaxTokensSettings(t *testing.T) {
893
929
  }
894
930
  }
895
931
 
932
+ // TestCodeBuddyNoThinkingSettings 官方 settings 键:NoThinking 时 alwaysThinkingEnabled=false。
933
+ func TestCodeBuddyNoThinkingSettings(t *testing.T) {
934
+ dir := t.TempDir()
935
+ w, log := argsCaptureCLI(t, dir, "codebuddy", `echo '{"type":"result","result":"ok"}'`)
936
+ if _, err := w.codebuddy().Complete(context.Background(), Request{
937
+ Messages: []Message{{Role: "user", Content: "hi"}},
938
+ NoThinking: true,
939
+ }); err != nil {
940
+ t.Fatalf("Complete: %v", err)
941
+ }
942
+ data, _ := os.ReadFile(log)
943
+ if !strings.Contains(string(data), `"alwaysThinkingEnabled":false`) {
944
+ t.Errorf("codebuddy 应写官方 settings alwaysThinkingEnabled=false: %q", data)
945
+ }
946
+ }
947
+
896
948
  // TestNoSettingsWhenMaxTokensUnset MaxTokens 未设置时不注入 --settings。
897
949
  func TestNoSettingsWhenMaxTokensUnset(t *testing.T) {
898
950
  cases := []struct {
@@ -145,6 +145,12 @@ func (e *LLMEngine) buildArgs(req Request, prompt string) []string {
145
145
  if req.Temperature != nil {
146
146
  args = append(args, "-o", "temperature", formatTemperature(*req.Temperature))
147
147
  }
148
+ if req.NoThinking {
149
+ // simonw/llm 的官方 model option(llm.datasette.io/en/stable/usage.html:
150
+ // -o/--option key value,OpenAI 系模型暴露 reasoning_effort)。支持该 option
151
+ // 的插件会把它发到端点;不支持时直接报错 —— 显式 --disable-thinking 的预期结果。
152
+ args = append(args, "-o", "reasoning_effort", "none")
153
+ }
148
154
  return append(args, appendAttachmentSection(prompt, nonImage))
149
155
  }
150
156
 
@@ -207,6 +213,11 @@ func (e *LLMEngine) ListModels(ctx context.Context) ([]string, error) {
207
213
  // --json 输出同 `llm logs --json` 的结构(数组,每项含 response /
208
214
  // input_tokens / output_tokens),解析失败时退回纯文本路径,不引入新的失败点。
209
215
  func (e *LLMEngine) Complete(ctx context.Context, req Request) (Response, error) {
216
+ // --disable-thinking:优先换成同一条线上的 no-think 变体(MiniMax-M3.1-Flash
217
+ // 这类模型对 thinking.disabled 直接 400,不能靠请求体硬塞)。
218
+ if req.NoThinking {
219
+ req.Model = NoThinkLLMModel(req.Model)
220
+ }
210
221
  // 配置文件模型(models.json):命中 id → 直连端点(见 llmhttp.go)。
211
222
  // 为什么必须按 id:id 唯一,而真实模型名会重复(MiniMax-M3 与 minimax-nothink
212
223
  // 真实名相同,只差 extraBody)—— 用名字做参数就无法区分。
@@ -317,6 +328,9 @@ func parseLLMJSONEntry(raw string) (llmJSONEntry, bool) {
317
328
  // Stream 实现 Streamer:llm prompt 默认流式,逐行读纯文本 stdout,
318
329
  // thinkSplitter 把标签块路由到 thinking 通道。
319
330
  func (e *LLMEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
331
+ if req.NoThinking {
332
+ req.Model = NoThinkLLMModel(req.Model)
333
+ }
320
334
  // 配置文件模型:命中 id → 直连 SSE(见 llmhttp.go / completeConfigured 上方注释)
321
335
  if m, ok := ResolveLLMConfigModel(req.Model); ok {
322
336
  return e.streamConfigured(ctx, m, req, onEvent)
@@ -104,6 +104,13 @@ func TestLLMBuildArgsModelOptions(t *testing.T) {
104
104
  t.Errorf("buildArgs = %q, want %q (温度 0 必须透传)", got, want)
105
105
  }
106
106
 
107
+ // NoThinking:llm 官方 model option;只在 CLI 委托路径出现
108
+ got = e.buildArgs(Request{NoThinking: true}, "hi")
109
+ want = []string{"prompt", "-o", "reasoning_effort", "none", "hi"}
110
+ if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
111
+ t.Errorf("buildArgs = %q, want %q", got, want)
112
+ }
113
+
107
114
  // 都不给
108
115
  got = e.buildArgs(Request{Model: "minimax-m3"}, "hi")
109
116
  for _, a := range got {
@@ -549,3 +556,20 @@ func TestLLMDetectMissingCli(t *testing.T) {
549
556
  t.Errorf("note = %q, want the missing path", note)
550
557
  }
551
558
  }
559
+
560
+ func TestNoThinkLLMModelPicksAnyVariant(t *testing.T) {
561
+ dir := t.TempDir()
562
+ p := filepath.Join(dir, "models.json")
563
+ raw := `[
564
+ {"id":"MiniMax-M3.1-Flash","model":"MiniMax-M3.1-Flash","url":"http://x/v1","apiKey":"k"},
565
+ {"id":"minimax-nothink","model":"MiniMax-M3","url":"http://x/v1","apiKey":"k",
566
+ "extraBody":{"thinking":{"type":"disabled"}}}
567
+ ]`
568
+ if err := os.WriteFile(p, []byte(raw), 0o600); err != nil {
569
+ t.Fatal(err)
570
+ }
571
+ t.Setenv("MAGIC_AGENT_MODELS", p)
572
+ if got := NoThinkLLMModel("MiniMax-M3.1-Flash"); got != "minimax-nothink" {
573
+ t.Fatalf("NoThinkLLMModel = %q, want minimax-nothink", got)
574
+ }
575
+ }
@@ -92,6 +92,12 @@ func buildConfiguredBody(m LLMConfigModel, req Request, stream bool) (map[string
92
92
  } else if m.Temperature != nil {
93
93
  body["temperature"] = *m.Temperature
94
94
  }
95
+ if req.NoThinking && !LLMModelDisablesThinking(m) {
96
+ // 没有同线 no-think 变体时,用 llm/OpenAI 兼容端点认的官方参数。
97
+ // 若端点不支持该参数会返回 400 —— 这是显式 --disable-thinking 的预期失败,
98
+ // 不静默退回「假装关闭」。
99
+ body["reasoning_effort"] = "none"
100
+ }
95
101
  return body, nil
96
102
  }
97
103
 
@@ -274,6 +274,33 @@ func TestUnknownModelFallsBackToCLI(t *testing.T) {
274
274
  }
275
275
  }
276
276
 
277
+ func TestBuildConfiguredBodyNoThinking(t *testing.T) {
278
+ m := LLMConfigModel{ID: "x", Model: "x", URL: "http://x/v1"}
279
+ body, err := buildConfiguredBody(m, Request{
280
+ Messages: []Message{{Role: "user", Content: "hi"}},
281
+ NoThinking: true,
282
+ }, false)
283
+ if err != nil {
284
+ t.Fatal(err)
285
+ }
286
+ if body["reasoning_effort"] != "none" {
287
+ t.Errorf("没有 no-think 变体时应传 reasoning_effort=none: %#v", body["reasoning_effort"])
288
+ }
289
+
290
+ noThink := LLMConfigModel{ID: "x-nothink", Model: "x", URL: "http://x/v1",
291
+ ExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}
292
+ body, err = buildConfiguredBody(noThink, Request{
293
+ Messages: []Message{{Role: "user", Content: "hi"}},
294
+ NoThinking: true,
295
+ }, false)
296
+ if err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ if _, ok := body["reasoning_effort"]; ok {
300
+ t.Errorf("已有 thinking.disabled 时不应再塞 reasoning_effort: %#v", body)
301
+ }
302
+ }
303
+
277
304
  func TestExpandEnvKeep(t *testing.T) {
278
305
  t.Setenv("LLM_TEST_KEY", "secret")
279
306
  cases := map[string]string{
@@ -76,6 +76,54 @@ func (m LLMConfigModel) Endpoint() string {
76
76
  return strings.TrimRight(u, "/") + "/chat/completions"
77
77
  }
78
78
 
79
+ // LLMModelDisablesThinking 判断 models.json 条目是否声明了不思考变体
80
+ // (extraBody.thinking.type == "disabled")。
81
+ func LLMModelDisablesThinking(m LLMConfigModel) bool {
82
+ if strings.TrimSpace(m.ID) == "" || m.ExtraBody == nil {
83
+ return false
84
+ }
85
+ th, ok := m.ExtraBody["thinking"].(map[string]any)
86
+ if !ok {
87
+ return false
88
+ }
89
+ return strings.EqualFold(strings.TrimSpace(fmt.Sprint(th["type"])), "disabled")
90
+ }
91
+
92
+ // NoThinkLLMModel 给 --disable-thinking 选 no-think 变体:
93
+ // 用户已选 no-think → 原样;否则先找 WireModel 相同、声明 thinking.disabled 的条目,
94
+ // 再退到任意 no-think 条目(M3.1-Flash 只能换模型才能关思考)。都没有则保留原模型
95
+ // (由调用方决定是否再传 reasoning_effort=none)。
96
+ func NoThinkLLMModel(selected string) string {
97
+ m, ok := ResolveLLMConfigModel(selected)
98
+ if !ok {
99
+ return strings.TrimSpace(selected)
100
+ }
101
+ if LLMModelDisablesThinking(m) {
102
+ return m.ID
103
+ }
104
+ list, err := LoadLLMConfigModels()
105
+ if err != nil {
106
+ return m.ID
107
+ }
108
+ // 先找同一条线上的变体(不换真实模型),再退到任意 no-think 变体 ——
109
+ // 显式 --disable-thinking 的目标是「不思考」,MiniMax-M3.1-Flash 这类模型
110
+ // 对 thinking.disabled / reasoning_effort=none 直接 400,只能换到 no-think 条目。
111
+ for _, alt := range list {
112
+ if alt.ID == m.ID || alt.WireModel() != m.WireModel() {
113
+ continue
114
+ }
115
+ if LLMModelDisablesThinking(alt) {
116
+ return alt.ID
117
+ }
118
+ }
119
+ for _, alt := range list {
120
+ if alt.ID != m.ID && LLMModelDisablesThinking(alt) {
121
+ return alt.ID
122
+ }
123
+ }
124
+ return m.ID
125
+ }
126
+
79
127
  // LLMModelsPath 返回 models.json 的路径($MAGIC_AGENT_MODELS 优先)。
80
128
  func LLMModelsPath() string {
81
129
  if p := strings.TrimSpace(os.Getenv("MAGIC_AGENT_MODELS")); p != "" {
@@ -383,6 +383,11 @@ func agentSettingsPayload(req Request, engine string) (string, bool) {
383
383
  if env := maxTokensEnv(req.MaxTokens); env != nil {
384
384
  settings["env"] = env
385
385
  }
386
+ // CodeBuddy 官方 settings 键:始终开启思考。NoThinking 时显式关掉;
387
+ // 这是 codebuddy CLI 能对模型请求施加的官方开关(SDK 另有 thinking.type=disabled)。
388
+ if req.NoThinking {
389
+ settings["alwaysThinkingEnabled"] = false
390
+ }
386
391
 
387
392
  // 沙箱 / autoMode / 权限规则只在真会调用工具时注入。
388
393
  if !toolsIsOff(req) {
@@ -62,21 +62,22 @@ type askOptions struct {
62
62
  login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
63
63
  // repairModels 还原被反向同步写坏的桌面端 models.json(--repair-models <engine>):
64
64
  // 2026-09-30 Windows 事故的收尾手段,见 internal/agent/custom_models.go 的事故还原一节。
65
- repairModels string
66
- events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
67
- control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
68
- jsonOut bool
69
- stream bool
70
- noThinking bool
71
- maxTokens int
72
- temperature float64
73
- tempSet bool
74
- reasoning string // 推理档位(--reasoning;默认 max)
75
- jsonSchema string // 内联 JSON Schema(仅 llm 引擎走 --schema)
76
- session string // 会话续接 id(空 = 新会话,默认)
77
- continueF bool // 续接最近一次会话(-c/--continue;不需要 id)
78
- workspace string // 工作目录(-w/--workspace;空 = 用调用方 cwd)
79
- stop string // 停止指定会话/运行(--stop <session_id|run_id>)
65
+ repairModels string
66
+ events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
67
+ control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
68
+ jsonOut bool
69
+ stream bool
70
+ noThinking bool // 只隐藏思考事件(--no-thinking,历史语义)
71
+ disableThinking bool // 请求模型真正关闭思考(--disable-thinking / --no-think)
72
+ maxTokens int
73
+ temperature float64
74
+ tempSet bool
75
+ reasoning string // 推理档位(--reasoning;默认 max)
76
+ jsonSchema string // 内联 JSON Schema(仅 llm 引擎走 --schema)
77
+ session string // 会话续接 id(空 = 新会话,默认)
78
+ continueF bool // 续接最近一次会话(-c/--continue;不需要 id)
79
+ workspace string // 工作目录(-w/--workspace;空 = 用调用方 cwd)
80
+ stop string // 停止指定会话/运行(--stop <session_id|run_id>)
80
81
  // deleteSession 删除指定会话/运行(--delete-session <session_id|run_id>):
81
82
  // 登记记录 + 事件日志 + 标题覆盖一起删。与插件的 DELETE /desk/session/{id} 同一份实现
82
83
  // (见 internal/session/delete.go)——「一端删了、另一端还在」的根因就是删除没落在
@@ -118,6 +119,11 @@ type askOptions struct {
118
119
  appendTo string // 向常驻会话追加消息(--append <session_id|run_id>)
119
120
  idle time.Duration // 常驻会话空闲收工时长(--idle,仅 --keep-alive 有效)
120
121
 
122
+ // hosted 本轮提问由客户端「会话托管」自动发送(--hosted,2026-10-23)。
123
+ // 只写进会话历史的 user 事件做标识,不改变提示词、引擎或权限语义 ——
124
+ // 执行路径与用户亲手发的完全一样,客户端回放时据此画成「托管发送」。
125
+ hosted bool
126
+
121
127
  // 会话标题(2026-10-15;见 internal/session/title.go)。
122
128
  // 归纳口径收进 core 后,客户端不再各自实现 —— 否则 PC 与移动端标题不一致,
123
129
  // 且从移动端发起的会话没人替它归纳。
@@ -126,6 +132,11 @@ type askOptions struct {
126
132
  titleForce bool // --title-force:已有覆盖也重新归纳
127
133
  titleEngine string // --title-engine:归纳用哪个引擎(默认 llm)
128
134
 
135
+ // 下一轮建议(2026-10-23;见 internal/suggest)。
136
+ // 一轮结束后生成 0-3 条「用户下一句」的快捷回复;一次性旁路调用,不续接会话。
137
+ // 值 = 请求 JSON 的文件路径("-" = stdin)。
138
+ suggestInput string // --suggest-followups <file|->:{"user":"…","reply":"…"}
139
+
129
140
  // 四档权限模型(见 internal/agent/permission.go)。仅 claude / codebuddy 接线。
130
141
  permission string // 档位:manual | accept-edits | auto | full
131
142
  sandboxExclude []string // 始终在沙箱外执行的命令(sandbox.excludedCommands)
@@ -166,6 +177,7 @@ type askOptions struct {
166
177
  reqAll bool // --requirement-all:全量重放
167
178
  reqEngine string // --requirement-engine(默认 llm)
168
179
  reqModel string // --requirement-model
180
+ reqModule string // --requirement-module:只整理这个模块
169
181
  reqTimeout time.Duration // --requirement-timeout
170
182
  reqAuto bool // --requirement-auto:自动归纳,不触发首次初始化
171
183
  }
@@ -209,6 +221,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
209
221
  f.StringVar(&opts.sessionTitle, "session-title", "", "读一条会话**已存**的标题(JSON:{id,title}),不调 LLM。没有覆盖时回落到 prompt_head。列表页取标题走这条,别逐行调 --summarize-title")
210
222
  f.BoolVar(&opts.titleForce, "title-force", false, "配合 --summarize-title:已有标题也重新归纳(默认命中缓存就直接返回,省一次 LLM 调用)")
211
223
  f.StringVar(&opts.titleEngine, "title-engine", "llm", "--summarize-title 用哪个引擎归纳(默认 llm:纯文本、不带工具)")
224
+ f.StringVar(&opts.suggestInput, "suggest-followups", "", "生成下一轮快捷回复:传请求 JSON 的文件路径(\"-\" = stdin):{\"user\":\"…\",\"reply\":\"…\"}。旁路调用,不续接当前会话;引擎走 -e(默认 llm)")
212
225
  f.Uint64Var(&opts.logAfter, "after", 0, "配合 --session-log:增量续读,只回 seq > N 的事件(另附 count/after/lastSeq/snapshotRequired)")
213
226
  f.Uint64Var(&opts.logBefore, "before", 0, "配合 --session-log:向上翻更早的历史,从文件尾跳过 N 条已画事件再取一页(0 = 最新一页;下一页把返回的 nextBefore 传回来。另附 count/before/nextBefore/firstSeq/more;与 --after 互斥)")
214
227
  f.IntVar(&opts.logLimit, "limit", 200, "配合 --before:每页最多返回多少条事件(默认 200)")
@@ -218,6 +231,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
218
231
  f.BoolVar(&opts.keepAlive, "keep-alive", true, "常驻会话:首轮结束后不退出、等 --append 追加(需 --stream)。claude/codebuddy 默认开;dsh 默认关(显式传 --keep-alive 开启);其余引擎不支持")
219
232
  f.StringVar(&opts.appendTo, "append", "", "向常驻会话追加一条消息:传 session_id 或 run_id,内容用 -p/位置参数给")
220
233
  f.DurationVar(&opts.idle, "idle", 5*time.Minute, "常驻会话空闲收工时长(默认 5m;0 = 本轮结束就收工,追加窗口只在任务运行期间)")
234
+ f.BoolVar(&opts.hosted, "hosted", false, "仅标记:本轮提问由客户端「会话托管」自动发送(写进会话历史,执行语义不变)")
221
235
  f.IntVar(&opts.maxTokens, "max-tokens", 0, "输出 token 上限(0=不指定;claude/codebuddy 经 --settings 注入,llm 透传,trae/dsh 忽略)")
222
236
  f.Float64Var(&opts.temperature, "temperature", -1, "采样温度(-1=不指定;仅 llm 引擎透传,其余引擎忽略)")
223
237
  f.StringVar(&opts.reasoning, "reasoning", "max", "推理/思考档位: minimal | low | medium | high | xhigh | max | off(默认 max;claude/codebuddy 走 --effort,codex 走 -c model_reasoning_effort;其余引擎无此参数,忽略)")
@@ -236,7 +250,9 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
236
250
  f.BoolVar(&opts.stream, "stream", false, "流式输出:正文/思考增量实时打到 stdout(text 模式思考走 stderr)")
237
251
  f.BoolVar(&opts.events, "events", false, "配合 --stream:事件流带契约版本与行号(每行加 v / seq,并先发一行 ready),供客户端消费;老消费者不要开(形状与 --stream 不同)")
238
252
  f.BoolVar(&opts.control, "control", false, "配合 --stream:从 stdin 读 NDJSON 控制命令(ping / interrupt / stop / answer),让调用方能打断长任务、回审批")
239
- f.BoolVar(&opts.noThinking, "no-thinking", false, "流式模式下不转发思考过程增量")
253
+ f.BoolVar(&opts.noThinking, "no-thinking", false, "流式模式下不转发思考过程增量(只隐藏,不关闭模型思考)")
254
+ f.BoolVar(&opts.disableThinking, "disable-thinking", false, "请求引擎关闭模型思考(claude/codex/dsh/codebuddy/llm 各自用官方开关)")
255
+ f.BoolVar(&opts.disableThinking, "no-think", false, "同 --disable-thinking")
240
256
  bindTeamFlags(cmd, opts)
241
257
  bindRequirementFlags(cmd, opts)
242
258
  bindPermissionFlags(cmd, opts)
@@ -714,7 +730,7 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
714
730
  }
715
731
  // 附件挂在 user 事件上:引擎事件里没有附件信息(`image` 只对应助手产出),
716
732
  // 只有落这里,历史回放才复原得出「那一轮带了哪张图」。
717
- sessionLog.push(sessionEvent{Kind: "user", Text: m.Content, Attachments: req.Attachments})
733
+ sessionLog.push(sessionEvent{Kind: "user", Text: m.Content, Attachments: req.Attachments, Hosted: opts.hosted})
718
734
  }
719
735
  }
720
736
 
@@ -1171,6 +1187,10 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
1171
1187
  fmt.Fprintf(cmd.ErrOrStderr(),
1172
1188
  "magic-agent: 提示:%s 引擎没有推理档位参数,--reasoning %s 已忽略\n", engine.Name(), reasoning)
1173
1189
  }
1190
+ if opts.disableThinking && !agent.NoThinkingSupportOf(engine.Name()) {
1191
+ fmt.Fprintf(cmd.ErrOrStderr(),
1192
+ "magic-agent: 提示:%s 引擎没有已知的关闭思考参数,--disable-thinking 已忽略\n", engine.Name())
1193
+ }
1174
1194
  toolsMode, err := parseToolsMode(opts.tools)
1175
1195
  if err != nil {
1176
1196
  return nil, "", agent.Request{}, err
@@ -1323,6 +1343,7 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
1323
1343
  Continue: opts.continueF,
1324
1344
  Workspace: workspace,
1325
1345
  Reasoning: reasoning,
1346
+ NoThinking: opts.disableThinking,
1326
1347
  Permission: tier,
1327
1348
  PermissionOptions: agent.PermissionOptions{
1328
1349
  ExcludedCommands: opts.sandboxExclude,
@@ -86,6 +86,7 @@ func runConsolidateRequirements(cmd *cobra.Command, opts *askOptions) error {
86
86
  All: opts.reqAll,
87
87
  Engine: opts.reqEngine,
88
88
  Model: opts.reqModel,
89
+ Module: opts.reqModule,
89
90
  Timeout: timeout,
90
91
  Auto: opts.reqAuto,
91
92
  })
@@ -114,6 +115,7 @@ func bindRequirementFlags(cmd *cobra.Command, opts *askOptions) {
114
115
  f.BoolVar(&opts.reqAll, "requirement-all", false, "配合 --consolidate-requirements:全量重放历史记忆(回填 / 滚动校正)。仍走增量合并,只增不减,不会推翻已有内容")
115
116
  f.StringVar(&opts.reqEngine, "requirement-engine", "", "--consolidate-requirements 用哪个引擎(空 = 自动:首次初始化挑能读目录的引擎,增量归纳用 llm);客户端应传自己的默认引擎")
116
117
  f.StringVar(&opts.reqModel, "requirement-model", "", "配合 --consolidate-requirements:增量归纳用的模型(空 = 引擎默认;首次目录初始化始终用初始化引擎的默认模型)")
118
+ f.StringVar(&opts.reqModule, "requirement-module", "", "配合 --consolidate-requirements:只整理这个模块(空 = 整个空间)。不推进空间游标,不触发首次目录初始化")
117
119
  f.DurationVar(&opts.reqTimeout, "requirement-timeout", requirements.DefaultTimeout, "配合 --consolidate-requirements:单次模型调用超时(默认归纳 4m / 首次目录初始化 9m)")
118
120
  f.BoolVar(&opts.reqAuto, "requirement-auto", false, "配合 --consolidate-requirements:自动归纳(不触发首次按目录初始化;插件轮末 / 每日兜底用)")
119
121
  }
@@ -368,6 +368,8 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
368
368
  一端删了处处都看不到。与插件的 DELETE /desk/session/{id} 是同一份实现
369
369
  (internal/session/delete.go),两条路可互换。
370
370
  magic-agent --delete-session <session_id> # 删掉这条会话(含历史)
371
+ 团队主会话还会沿父指针级联删掉挂在它下面的全部子任务会话(JSON 里
372
+ child_sessions_removed 是连带删掉的子会话条数);只删子会话不会带走主会话。
371
373
  幂等:id 不认识 / 文件本来就不在 → 退出码 0,JSON 里 records_removed=0。
372
374
  ⚠️ 是**物理删除**:删完历史就回放不了了。客户端把它当成「删除」的唯一实现,
373
375
  只在本地藏起来(记墓碑)的话,别的客户端照样列着(2026-10-19 用户报的
@@ -477,6 +479,9 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
477
479
  return runSummarizeTitle(cmd, opts)
478
480
  case flagChanged(cmd, "session-title"):
479
481
  return runSessionTitle(cmd, opts)
482
+ case flagChanged(cmd, "suggest-followups"):
483
+ // 值可以是 "-"(stdin),所以用「传过没有」判,而不是「值非空」。
484
+ return runSuggestFollowups(cmd, opts)
480
485
  case flagChanged(cmd, "title-force"):
481
486
  // --title-force 只在 --summarize-title 下成立:单给它必然是调用方误解了
482
487
  // (想要「强制重新归纳」却没给 id)。明说,不静默忽略。
@@ -646,24 +646,26 @@ func runDeleteSession(cmd *cobra.Command, opts *askOptions) error {
646
646
  }
647
647
  if opts.output == "text" {
648
648
  _, _ = fmt.Fprintf(cmd.OutOrStdout(),
649
- "magic-agent: 已删除会话 %s(登记记录 %d 条 / 历史 %d 份 / 标题 %d 条 / 状态 %d 条)\n",
650
- res.ID, res.RecordsRemoved, res.LogsRemoved, res.TitlesRemoved, res.StatusesRemoved)
649
+ "magic-agent: 已删除会话 %s(登记记录 %d 条 / 历史 %d 份 / 标题 %d 条 / 状态 %d 条 / 子会话 %d 条)\n",
650
+ res.ID, res.RecordsRemoved, res.LogsRemoved, res.TitlesRemoved, res.StatusesRemoved, res.ChildSessionsRemoved)
651
651
  return nil
652
652
  }
653
653
  out := struct {
654
- Type string `json:"type"`
655
- ID string `json:"id"`
656
- RecordsRemoved int `json:"records_removed"`
657
- LogsRemoved int `json:"logs_removed"`
658
- TitlesRemoved int `json:"titles_removed"`
659
- StatusesRemoved int `json:"statuses_removed"`
654
+ Type string `json:"type"`
655
+ ID string `json:"id"`
656
+ RecordsRemoved int `json:"records_removed"`
657
+ LogsRemoved int `json:"logs_removed"`
658
+ TitlesRemoved int `json:"titles_removed"`
659
+ StatusesRemoved int `json:"statuses_removed"`
660
+ ChildSessionsRemoved int `json:"child_sessions_removed"`
660
661
  }{
661
- Type: "delete",
662
- ID: res.ID,
663
- RecordsRemoved: res.RecordsRemoved,
664
- LogsRemoved: res.LogsRemoved,
665
- TitlesRemoved: res.TitlesRemoved,
666
- StatusesRemoved: res.StatusesRemoved,
662
+ Type: "delete",
663
+ ID: res.ID,
664
+ RecordsRemoved: res.RecordsRemoved,
665
+ LogsRemoved: res.LogsRemoved,
666
+ TitlesRemoved: res.TitlesRemoved,
667
+ StatusesRemoved: res.StatusesRemoved,
668
+ ChildSessionsRemoved: res.ChildSessionsRemoved,
667
669
  }
668
670
  return writeJSONLine(cmd.OutOrStdout(), out)
669
671
  }
@@ -122,6 +122,11 @@ type sessionEvent struct {
122
122
  // (`image` 事件只对应助手**产出**),所以只有落在这里,历史回放才复原得出
123
123
  // 「那一轮用了哪张图」—— 移动端据此在历史气泡里画出缩略图。
124
124
  Attachments []agent.Attachment `json:"attachments,omitempty"`
125
+ // Hosted 这条用户提问由客户端的「会话托管」自动发出(仅 kind:"user" 有)。
126
+ // 2026-10-23 只增字段:用户要求「托管发送的要标识 显示时区别于“我”发送」。
127
+ // 它只影响历史回放的画法(机器人头像 + 「托管发送」小标),不参与执行;
128
+ // 老日志没有这个字段 = 用户亲手发的,回放与以前一致。
129
+ Hosted bool `json:"hosted,omitempty"`
125
130
  }
126
131
 
127
132
  // sessionWriter 每条流式调用一个实例;开着就一直 append 到
@@ -85,6 +85,9 @@ func TestStreamAskPersistsUserMessage(t *testing.T) {
85
85
  if evs[0].SessionID != sid {
86
86
  t.Fatalf("首条应带上 session_id(pending 刷盘时回填),实得 %q", evs[0].SessionID)
87
87
  }
88
+ if evs[0].Hosted {
89
+ t.Fatal("普通提问不该带 hosted —— 只有客户端显式传 --hosted 的托管自动发送才标")
90
+ }
88
91
 
89
92
  // ② 引擎事件也都在,且排在 user 之后。
90
93
  sawText := false
@@ -122,6 +125,52 @@ func TestStreamAskPersistsUserMessage(t *testing.T) {
122
125
  }
123
126
  }
124
127
 
128
+ // TestStreamAskPersistsHostedUserMessage `--hosted` 只给 user 事件加一个标识
129
+ // (2026-10-23 用户:「是托管发送的要标识 显示时区别于“我”发送」)。
130
+ //
131
+ // 标识必须落在**会话历史**里:客户端只画实时那条不够 —— 重开会话时气泡是从
132
+ // kind:"user" 回放出来的,历史没这个字段就又会变回「我」。
133
+ func TestStreamAskPersistsHostedUserMessage(t *testing.T) {
134
+ dir := t.TempDir()
135
+ prev := SessionLogDirOverride
136
+ SessionLogDirOverride = dir
137
+ defer func() { SessionLogDirOverride = prev }()
138
+
139
+ const sid = "sess-hosted-log"
140
+ const prompt = "继续下一步"
141
+ registerFake(&userEchoStreamEngine{name: "fake-hosted-log", sessionID: sid})
142
+
143
+ if _, _, err := runAskCmd(t, "", "-e", "fake-hosted-log", "--stream", "--save-history", "--hosted", prompt); err != nil {
144
+ t.Fatalf("--stream --hosted: %v", err)
145
+ }
146
+
147
+ evs, err := ReadSessionLog(dir, sid)
148
+ if err != nil {
149
+ t.Fatalf("读回会话日志失败:%v", err)
150
+ }
151
+ if len(evs) == 0 || evs[0].Kind != "user" {
152
+ t.Fatalf("首条应是 user,实得:%s", dumpKinds(evs))
153
+ }
154
+ if !evs[0].Hosted {
155
+ t.Fatal("--hosted 没有写进 user 事件(历史回放会退回「我」发送)")
156
+ }
157
+ // 正文不能被标识污染:发给引擎 / 回放的都是原句。
158
+ if evs[0].Text != prompt {
159
+ t.Fatalf("user 文本被改动:期望 %q,实得 %q", prompt, evs[0].Text)
160
+ }
161
+ raw := readLines(t, filepath.Join(dir, sid+".jsonl"))
162
+ if len(raw) == 0 {
163
+ t.Fatal("落盘文件是空的")
164
+ }
165
+ var first map[string]any
166
+ if err := json.Unmarshal([]byte(raw[0]), &first); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ if first["hosted"] != true {
170
+ t.Fatalf("落盘 JSON 应带 hosted:true(只增字段),实得 %v", first)
171
+ }
172
+ }
173
+
125
174
  // TestStreamAskNoArchiveByDefault **默认什么都不存**(用户 2026-10-12:
126
175
  // 「加一个参数 默认不存档」+「会话登记也不需要」)。
127
176
  //