magic-agent 2.3.0 → 2.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CLIENT-CONTRACT.md +78 -13
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  7. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  8. package/client-ui/shared/conv/conv.css +17 -0
  9. package/client-ui/shared/conversation.js +209 -39
  10. package/client-ui/shared/transport-http.js +193 -38
  11. package/client-ui-mobile/index.html +733 -107
  12. package/client-ui-mobile/reqs.js +42 -29
  13. package/internal/agent/capability.go +8 -1
  14. package/internal/agent/claude.go +5 -3
  15. package/internal/agent/codebuddy.go +6 -2
  16. package/internal/agent/codex.go +7 -2
  17. package/internal/agent/dsh_sdk.go +8 -0
  18. package/internal/agent/dsh_sdk_test.go +17 -0
  19. package/internal/agent/engine.go +20 -0
  20. package/internal/agent/engine_test.go +52 -0
  21. package/internal/agent/llmengine.go +6 -0
  22. package/internal/agent/llmengine_test.go +7 -0
  23. package/internal/agent/llmhttp.go +6 -0
  24. package/internal/agent/llmhttp_test.go +27 -0
  25. package/internal/agent/llmmodels.go +13 -0
  26. package/internal/agent/permission.go +5 -0
  27. package/internal/cli/ask.go +32 -17
  28. package/internal/cli/requirements.go +2 -0
  29. package/internal/cli/sessionlog.go +5 -0
  30. package/internal/cli/sessionlog_user_test.go +49 -0
  31. package/internal/client/cors_test.go +32 -0
  32. package/internal/client/jobs.go +1160 -0
  33. package/internal/client/jobs_test.go +369 -0
  34. package/internal/client/requirements.go +27 -7
  35. package/internal/client/requirements_test.go +51 -0
  36. package/internal/client/service.go +259 -142
  37. package/internal/client/service_test.go +78 -0
  38. package/internal/requirements/consolidate.go +86 -24
  39. package/internal/requirements/requirements.go +9 -0
  40. package/internal/requirements/requirements_test.go +129 -0
  41. package/internal/requirements/store.go +11 -3
  42. package/internal/session/session.go +15 -1
  43. package/internal/session/workspace_lock.go +165 -0
  44. package/internal/session/workspace_lock_test.go +90 -0
  45. package/internal/suggest/suggest.go +101 -7
  46. package/internal/suggest/suggest_test.go +44 -0
  47. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  48. package/npm/apk/version.json +3 -3
  49. package/npm/dist/darwin-arm64/magic-agent +0 -0
  50. package/npm/dist/darwin-arm64/magic-client +0 -0
  51. package/npm/dist/darwin-x64/magic-agent +0 -0
  52. package/npm/dist/darwin-x64/magic-client +0 -0
  53. package/npm/dist/linux-arm64/magic-agent +0 -0
  54. package/npm/dist/linux-arm64/magic-client +0 -0
  55. package/npm/dist/linux-x64/magic-agent +0 -0
  56. package/npm/dist/linux-x64/magic-client +0 -0
  57. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  58. package/npm/dist/win32-x64/magic-client.exe +0 -0
  59. package/package.json +3 -2
@@ -5,7 +5,7 @@
5
5
  * 现在 index 只留两个空容器(#reqs / #reqDetail)+ 一次 mount,本文件自包含:
6
6
  * · 一级:空间目录(空间 → 模块,功能点不画);
7
7
  * · 二级:点模块进去,**只看这个模块**的需求列表;
8
- * · 「整理」:POST /desk/requirements/consolidate(全量 / 单空间 / 二级页单空间)。
8
+ * · 「整理」:POST /desk/requirements/consolidate(全量 / 单空间 / 二级页单模块)。
9
9
  *
10
10
  * 与宿主(index.html)的边界:只通过 mount(host) 注入的能力交互 ——
11
11
  * getBase / getToken(切环境后是活的,必须 getter,不能传快照)、
@@ -26,6 +26,7 @@
26
26
  var REQ_DATA = null; // 最近一次清单(重画用,避免每次点都重新拉)
27
27
  var REQ_BUSY = false; // 「整理」进行中(防重复点击)
28
28
  var REQ_BUSY_LABEL = '全部整理'; // 顶栏按钮的进行中文案(重渲染后要能恢复)
29
+ var REQ_BUSY_KEY = ''; // 本次整理的组头(空间 key;空 = 全部整理,所有组头都置忙)
29
30
  var REQ_OPEN = {}; // 工作区展开状态(key = 工作区 id),与会话列表的 WS_OPEN 同构
30
31
  var REQ_ALIVE = null; // 仍有会话的空间路径集合;null = 拿不到会话清单(不筛)
31
32
  var REQ_DETAIL = null; // 二级视图:{key: 工作区 key, mod: 模块名, ws: 工作区对象}
@@ -41,7 +42,7 @@
41
42
  '<div class="history__top">' +
42
43
  '<button class="topbar__icon" id="btnReqDetailBack" aria-label="返回"><svg width="20" height="20"><use href="#i-back"/></svg></button>' +
43
44
  '<span class="history__title" id="reqDetailTitle">需求</span>' +
44
- '<button class="history__disconnect" id="btnReqDetailSync" type="button">整理</button>' +
45
+ '<button class="history__disconnect" id="btnReqDetailSync" type="button" title="只整理这个模块">整理</button>' +
45
46
  '</div>' +
46
47
  '<div class="history__list" id="reqDetailList"></div>';
47
48
 
@@ -330,17 +331,19 @@
330
331
  服务端按 state 判断走哪条路:
331
332
  · 没初始化过 → 先按空间目录反推需求基线(首次可能跑几分钟,按钮要能等);
332
333
  · 已初始化 → 只消费上次整理之后的新会话。
333
- 入口有两个:
334
+ 入口有三个:
334
335
  · 每个空间组头的「整理」——只整理这个空间(用户点哪个就是哪个,不猜);
335
- · 右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
336
- 两个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
337
- function reqPostConsolidate(ws) {
336
+ · 二级页右上角「整理」——只整理当前模块(body 带 module,不推进空间游标);
337
+ · 一级页右上角「全部整理」——按空间逐个串行跑一遍,汇总结果(一键触发全部)。
338
+ 三个入口都带「我的 → 默认引擎/模型」,服务端不再自动替换引擎。 */
339
+ function reqPostConsolidate(ws, module) {
338
340
  var hdr = { 'Content-Type': 'application/json' };
339
341
  if (H.getToken()) hdr['X-Magic-Token'] = H.getToken();
340
342
  return fetch(H.getBase() + '/desk/requirements/consolidate', {
341
343
  method: 'POST', headers: hdr,
342
344
  body: JSON.stringify({
343
345
  workspace: ws || '',
346
+ module: module || '',
344
347
  engine: H.getRunPref().engine || '',
345
348
  model: (H.getRunPref().engine && H.getRunPref().model) || ''
346
349
  })
@@ -354,10 +357,13 @@
354
357
  });
355
358
  }
356
359
 
357
- /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用。
358
- label 只改顶部;组头统一显示「整理中…」/「整理」,不各自变文案。
359
- 二级页那枚「整理」也跟着禁用 —— 归纳期间它和组头一样不能重复点。 */
360
- function reqSetBusy(on, label) {
360
+ /* 统一置忙:顶部主按钮 + 所有空间组头的「整理」一起禁用(防重复点)。
361
+ 只有**真正在整理的那个入口**显示「整理中…」:
362
+ · 点某个空间 / 某个模块的「整理」→ 只有这一组的文案变;
363
+ · 点「全部整理」→ 所有组头一起变(那才是全部在跑)。
364
+ 其余按钮只置灰、保持「整理」—— 否则点一个模块会让人以为所有空间都在整理。 */
365
+ function reqSetBusy(on, label, busyKey) {
366
+ if (busyKey === undefined) busyKey = REQ_BUSY_KEY;
361
367
  if (on) { if (label !== undefined) REQ_BUSY_LABEL = label; }
362
368
  else REQ_BUSY_LABEL = '全部整理';
363
369
  var btn = els.sync;
@@ -372,38 +378,43 @@
372
378
  }
373
379
  Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
374
380
  b.disabled = !!on;
375
- b.textContent = on ? '整理中…' : '整理';
381
+ if (!on) { b.textContent = '整理'; return; }
382
+ b.textContent = (!busyKey || b.getAttribute('data-ws') === busyKey) ? '整理中…' : '整理';
376
383
  });
377
384
  }
378
385
 
379
- function consolidateRequirements(target) {
386
+ /* 整理一个空间(module 为空)或一个模块(module 非空)。
387
+ · 空间级:消费该空间游标之后的新会话,可能同时更新多个模块;
388
+ · 模块级:只更新这个模块,**不推进空间游标** —— 别的模块的需求仍留在
389
+ pending,等空间级「整理」/下一轮自动归纳处理,不会被这一下吞掉。
390
+ 服务端按 state 判断走哪条路:没初始化过 → 先按空间目录反推基线;
391
+ 模块级不会触发首次初始化(目录基线天然是空间级的)。 */
392
+ function consolidateRequirements(target, module) {
380
393
  if (REQ_BUSY || !target) return;
381
394
  var ws = target.dir || target.id || '';
382
395
  if (!ws) return;
383
- var firstTime = target.initialized === false;
384
- REQ_BUSY = true;
385
- reqSetBusy(true, firstTime ? '初始化中…' : '整理中…');
396
+ module = String(module || '').trim();
397
+ var firstTime = target.initialized === false && !module;
386
398
  var targetKey = target.id || target.dir || '';
387
- var groupBtn = null;
388
- Array.prototype.forEach.call(document.querySelectorAll('.wsrow__sync'), function (b) {
389
- if (!groupBtn && targetKey && b.getAttribute('data-ws') === targetKey) groupBtn = b;
390
- });
391
- /* 点的是哪个空间,就只让哪个组头显示「初始化中…」;其它组头保持「整理中…」。 */
392
- if (groupBtn) groupBtn.textContent = firstTime ? '初始化中…' : '整理中…';
393
- reqPostConsolidate(ws)
399
+ REQ_BUSY = true;
400
+ REQ_BUSY_KEY = targetKey;
401
+ reqSetBusy(true, firstTime ? '初始化中…' : '整理中…', targetKey);
402
+ reqPostConsolidate(ws, module)
394
403
  .then(function (d) {
404
+ var prefix = module ? '模块「' + module + '」' : '';
395
405
  if (d.bootstrap) {
396
406
  if (d.created || d.updated) toast('已按空间目录生成需求:新建 ' + (d.created || 0) + ' · 更新 ' + (d.updated || 0));
397
407
  else toast('已按空间目录初始化:暂未识别到功能点');
398
408
  }
399
- else if (d.noop) toast('没有新的对话需要整理');
400
- else if (d.updated || d.created) toast('整理完成:更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
401
- else toast('整理完成');
409
+ else if (d.noop) toast(prefix ? prefix + '没有新的对话需要整理' : '没有新的对话需要整理');
410
+ else if (d.updated || d.created) toast((prefix || '整理完成') + ':更新 ' + (d.updated || 0) + ' · 新建 ' + (d.created || 0));
411
+ else toast(prefix ? prefix + '整理完成' : '整理完成');
402
412
  loadRequirements(true); // 后台刷新(不闪「读取中」)
403
413
  })
404
414
  .catch(function (e) { toast('整理失败:' + ((e && e.message) || e)); })
405
415
  .then(function () {
406
416
  REQ_BUSY = false;
417
+ REQ_BUSY_KEY = '';
407
418
  reqSetBusy(false, '全部整理');
408
419
  });
409
420
  }
@@ -438,10 +449,12 @@
438
449
  return;
439
450
  }
440
451
  REQ_BUSY = true;
452
+ REQ_BUSY_KEY = ''; // 空 = 全部整理:所有组头一起显示「整理中…」
441
453
  reqSetBusy(true, '整理中 0/' + list.length);
442
454
  var i = 0, ok = 0, fail = 0, created = 0, updated = 0, firstErr = '';
443
455
  function done() {
444
456
  REQ_BUSY = false;
457
+ REQ_BUSY_KEY = '';
445
458
  reqSetBusy(false, '全部整理');
446
459
  loadRequirements(true);
447
460
  if (fail) {
@@ -456,7 +469,7 @@
456
469
  REQ_BUSY_LABEL = '整理中 ' + i + '/' + list.length;
457
470
  var btn = els.sync;
458
471
  if (btn) btn.textContent = REQ_BUSY_LABEL;
459
- reqPostConsolidate(w.dir || w.id || '')
472
+ reqPostConsolidate(w.dir || w.id || '', '')
460
473
  .then(function (d) {
461
474
  ok++;
462
475
  created += Number(d.created) || 0;
@@ -487,13 +500,13 @@
487
500
  els.sync = root.querySelector('#btnReqSync');
488
501
  els.detailBack = detail.querySelector('#btnReqDetailBack');
489
502
  els.detailSync = detail.querySelector('#btnReqDetailSync');
490
- /* 两个动作:全部整理 / 返回一级;二级页的「整理」只作用于当前空间
491
- (不是全部空间 —— 二级页的作用域就是这个空间,标成「全部整理」会让人以为还是全局动作)。 */
503
+ /* 两个动作:全部整理 / 返回一级;二级页的「整理」只作用于**当前模块**
504
+ (不是整空间、更不是全部空间 —— 用户点哪个模块,就只整理哪个模块)。 */
492
505
  if (els.sync) els.sync.onclick = function () { consolidateAllRequirements(); };
493
506
  if (els.detailBack) els.detailBack.onclick = function () { closeReqDetail(); };
494
507
  if (els.detailSync) els.detailSync.onclick = function () {
495
508
  if (!REQ_DETAIL || !REQ_DETAIL.ws) return;
496
- consolidateRequirements(REQ_DETAIL.ws);
509
+ consolidateRequirements(REQ_DETAIL.ws, REQ_DETAIL.mod);
497
510
  };
498
511
  }
499
512
 
@@ -149,6 +149,13 @@ const (
149
149
  FeatureSessionEvents = "feature.session.events"
150
150
  // FeatureSessionControl `--stream --control`:stdin NDJSON 控制通道(打断 / 收工 / 回审批)。
151
151
  FeatureSessionControl = "feature.session.control"
152
+ // FeatureSessionHosted `--hosted`:这一轮的用户提问由客户端「会话托管」自动发出。
153
+ //
154
+ // 为什么归 core:托管本身是**纯客户端策略**(执行仍走普通 ask),但「这句是托管的」
155
+ // 要写进会话历史,回放时才能把气泡画成「托管发送」而不是「我」—— 历史在 core 手里,
156
+ // 所以标识只能由 core 落。客户端据此决定要不要把 hosted 透传给 core;老 core 没有
157
+ // 这个位时插件忽略该字段(实时气泡仍由客户端自己标),不阻断发送。
158
+ FeatureSessionHosted = "feature.session.hosted"
152
159
  // FeatureSessionTeam `--team`:团队模式(主引擎拆任务、子引擎分头执行、主引擎汇总)。
153
160
  //
154
161
  // 为什么归 core 而不是某个引擎:它是**会话层的一种运行模式**,任何能当主引擎的
@@ -164,5 +171,5 @@ const (
164
171
 
165
172
  // CoreFeatures 返回 core 自身提供的通道能力(字典序,稳定)。
166
173
  func CoreFeatures() []string {
167
- return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionTeam}
174
+ return []string{FeatureRequirements, FeatureSessionControl, FeatureSessionEvents, FeatureSessionHosted, FeatureSessionTeam}
168
175
  }
@@ -238,9 +238,11 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
238
238
  if m := stripModelPrefix(req.Model); m != "" {
239
239
  args = append(args, "--model", m)
240
240
  }
241
- // 推理档位:claude 的 --effort 只认 low/medium/high/xhigh/max(无 minimal),
242
- // 传 minimal 时降级为 low,避免 clap 直接拒绝整轮调用。
243
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
241
+ // 推理 / 思考:建议这类短任务显式关闭(--thinking disabled);
242
+ // 其余调用仍按 --effort 档位。两者互斥,关闭时不传 effort,避免又落回默认高档。
243
+ if req.NoThinking {
244
+ args = append(args, "--thinking", "disabled")
245
+ } else if lv := ReasoningLevel(req.Reasoning); lv != "" {
244
246
  if lv == "minimal" {
245
247
  lv = "low"
246
248
  }
@@ -375,8 +375,12 @@ func (c codebuddyCore) buildArgsBase(req Request) []string {
375
375
  args = append(args, "--model", c.model)
376
376
  }
377
377
  // 推理档位:codebuddy 的 --effort 认 minimal/low/medium/high/xhigh/max。
378
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
379
- args = append(args, "--effort", lv)
378
+ // NoThinking 走官方 settings alwaysThinkingEnabled=false(见 agentSettingsPayload),
379
+ // 不传 --effort off(该值不在 --effort 合法枚举里)。
380
+ if !req.NoThinking {
381
+ if lv := ReasoningLevel(req.Reasoning); lv != "" {
382
+ args = append(args, "--effort", lv)
383
+ }
380
384
  }
381
385
  // 统一参数矩阵:`--settings` 只接受一份载荷,故 MaxTokens 的 env 注入与
382
386
  // 四档模型的 sandbox / autoMode / permissions 合并进同一个 JSON。
@@ -540,8 +540,13 @@ func (e *CodexEngine) buildArgs(req Request, prompt, outFile, schemaFile string)
540
540
  }
541
541
  }
542
542
  // 推理档位:`-c` 在 `exec` 与 `exec resume` 下都可用(同 model 覆写),
543
- // 故不按子命令分流。codex 的取值:minimal/low/medium/high/xhigh/max。
544
- if lv := ReasoningLevel(req.Reasoning); lv != "" {
543
+ // 故不按子命令分流。codex 的取值:none/minimal/low/medium/high/xhigh/max。
544
+ // 建议这类短任务用 none 明确关闭思考(不支持 none 的模型会在服务端报错,
545
+ // 由建议层降级到 llm,不会拖住用户会话)。
546
+ if req.NoThinking {
547
+ args = append(args, "-c", "model_reasoning_effort="+tomlBasicString("none"),
548
+ "-c", "model_reasoning_summary="+tomlBasicString("none"))
549
+ } else if lv := ReasoningLevel(req.Reasoning); lv != "" {
545
550
  args = append(args, "-c", "model_reasoning_effort="+tomlBasicString(lv))
546
551
  }
547
552
  // SystemPrompt → -c developer_instructions=<TOML 字符串>
@@ -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 直连请求体传 reasoning_effort="none";CLI 委托路径传
163
+ // 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
 
@@ -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 {
@@ -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,19 @@ 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
+
79
92
  // LLMModelsPath 返回 models.json 的路径($MAGIC_AGENT_MODELS 优先)。
80
93
  func LLMModelsPath() string {
81
94
  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
  // 且从移动端发起的会话没人替它归纳。
@@ -171,6 +177,7 @@ type askOptions struct {
171
177
  reqAll bool // --requirement-all:全量重放
172
178
  reqEngine string // --requirement-engine(默认 llm)
173
179
  reqModel string // --requirement-model
180
+ reqModule string // --requirement-module:只整理这个模块
174
181
  reqTimeout time.Duration // --requirement-timeout
175
182
  reqAuto bool // --requirement-auto:自动归纳,不触发首次初始化
176
183
  }
@@ -224,6 +231,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
224
231
  f.BoolVar(&opts.keepAlive, "keep-alive", true, "常驻会话:首轮结束后不退出、等 --append 追加(需 --stream)。claude/codebuddy 默认开;dsh 默认关(显式传 --keep-alive 开启);其余引擎不支持")
225
232
  f.StringVar(&opts.appendTo, "append", "", "向常驻会话追加一条消息:传 session_id 或 run_id,内容用 -p/位置参数给")
226
233
  f.DurationVar(&opts.idle, "idle", 5*time.Minute, "常驻会话空闲收工时长(默认 5m;0 = 本轮结束就收工,追加窗口只在任务运行期间)")
234
+ f.BoolVar(&opts.hosted, "hosted", false, "仅标记:本轮提问由客户端「会话托管」自动发送(写进会话历史,执行语义不变)")
227
235
  f.IntVar(&opts.maxTokens, "max-tokens", 0, "输出 token 上限(0=不指定;claude/codebuddy 经 --settings 注入,llm 透传,trae/dsh 忽略)")
228
236
  f.Float64Var(&opts.temperature, "temperature", -1, "采样温度(-1=不指定;仅 llm 引擎透传,其余引擎忽略)")
229
237
  f.StringVar(&opts.reasoning, "reasoning", "max", "推理/思考档位: minimal | low | medium | high | xhigh | max | off(默认 max;claude/codebuddy 走 --effort,codex 走 -c model_reasoning_effort;其余引擎无此参数,忽略)")
@@ -242,7 +250,9 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
242
250
  f.BoolVar(&opts.stream, "stream", false, "流式输出:正文/思考增量实时打到 stdout(text 模式思考走 stderr)")
243
251
  f.BoolVar(&opts.events, "events", false, "配合 --stream:事件流带契约版本与行号(每行加 v / seq,并先发一行 ready),供客户端消费;老消费者不要开(形状与 --stream 不同)")
244
252
  f.BoolVar(&opts.control, "control", false, "配合 --stream:从 stdin 读 NDJSON 控制命令(ping / interrupt / stop / answer),让调用方能打断长任务、回审批")
245
- 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")
246
256
  bindTeamFlags(cmd, opts)
247
257
  bindRequirementFlags(cmd, opts)
248
258
  bindPermissionFlags(cmd, opts)
@@ -720,7 +730,7 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
720
730
  }
721
731
  // 附件挂在 user 事件上:引擎事件里没有附件信息(`image` 只对应助手产出),
722
732
  // 只有落这里,历史回放才复原得出「那一轮带了哪张图」。
723
- 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})
724
734
  }
725
735
  }
726
736
 
@@ -1177,6 +1187,10 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
1177
1187
  fmt.Fprintf(cmd.ErrOrStderr(),
1178
1188
  "magic-agent: 提示:%s 引擎没有推理档位参数,--reasoning %s 已忽略\n", engine.Name(), reasoning)
1179
1189
  }
1190
+ if opts.disableThinking && !agent.NoThinkingSupportOf(engine.Name()) {
1191
+ fmt.Fprintf(cmd.ErrOrStderr(),
1192
+ "magic-agent: 提示:%s 引擎没有已知的关闭思考参数,--disable-thinking 已忽略\n", engine.Name())
1193
+ }
1180
1194
  toolsMode, err := parseToolsMode(opts.tools)
1181
1195
  if err != nil {
1182
1196
  return nil, "", agent.Request{}, err
@@ -1329,6 +1343,7 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
1329
1343
  Continue: opts.continueF,
1330
1344
  Workspace: workspace,
1331
1345
  Reasoning: reasoning,
1346
+ NoThinking: opts.disableThinking,
1332
1347
  Permission: tier,
1333
1348
  PermissionOptions: agent.PermissionOptions{
1334
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
  }
@@ -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 到