mingdao-harness 0.2.4 → 0.2.6

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 (66) hide show
  1. package/docs/EVALUATION-ROUND2.md +58 -0
  2. package/docs/ROADMAP-NEXT.md +5 -5
  3. package/package.json +2 -2
  4. package/src/agent.js +74 -36
  5. package/src/atomic-write.js +8 -2
  6. package/src/audit.js +2 -2
  7. package/src/batch.js +7 -7
  8. package/src/cachestats.js +9 -9
  9. package/src/cli.js +42 -688
  10. package/src/commands/desktop.js +1 -1
  11. package/src/commands/key.js +1 -1
  12. package/src/commands/repl.js +711 -0
  13. package/src/commands/schedule.js +7 -7
  14. package/src/commands/skill.js +18 -16
  15. package/src/commands/sync.js +5 -5
  16. package/src/commands/update.js +7 -6
  17. package/src/commands/workspace.js +2 -2
  18. package/src/compact.js +6 -6
  19. package/src/config.js +7 -4
  20. package/src/context.js +37 -21
  21. package/src/cost-guard.js +14 -6
  22. package/src/credentials.js +6 -0
  23. package/src/hooks.js +11 -11
  24. package/src/mcp-presets.js +5 -5
  25. package/src/mcp.js +19 -19
  26. package/src/memory.js +16 -16
  27. package/src/model-discovery.js +16 -15
  28. package/src/models.js +4 -4
  29. package/src/notify.js +2 -2
  30. package/src/permissions.js +7 -3
  31. package/src/pricing.js +35 -1
  32. package/src/prompts.js +1 -1
  33. package/src/providers/index.js +7 -7
  34. package/src/providers/openai-compatible.js +13 -11
  35. package/src/routing.js +8 -8
  36. package/src/schedule.js +30 -30
  37. package/src/session-index.js +5 -1
  38. package/src/session.js +11 -2
  39. package/src/skill-lib.js +62 -3
  40. package/src/skill-registry.js +10 -9
  41. package/src/skills.js +14 -12
  42. package/src/sync-server.js +86 -3
  43. package/src/sync.js +53 -20
  44. package/src/tasks/worker.js +126 -0
  45. package/src/tasks.js +14 -14
  46. package/src/titles.js +5 -5
  47. package/src/tokenizer.js +23 -5
  48. package/src/tools/bash.js +12 -12
  49. package/src/tools/fs-tools.js +66 -9
  50. package/src/tools/index.js +7 -7
  51. package/src/ui.js +83 -6
  52. package/src/update.js +8 -8
  53. package/src/web/app.js +16 -5
  54. package/src/web/attachments.js +1 -1
  55. package/src/web/index.html +21 -1
  56. package/src/web/routes/api.js +38 -786
  57. package/src/web/routes/domains/config.js +280 -0
  58. package/src/web/routes/domains/misc.js +129 -0
  59. package/src/web/routes/domains/schedule.js +127 -0
  60. package/src/web/routes/domains/sessions.js +105 -0
  61. package/src/web/routes/domains/skills.js +79 -0
  62. package/src/web/routes/domains/sync.js +101 -0
  63. package/src/web/routes/domains/workspace.js +119 -0
  64. package/src/web/server.js +73 -31
  65. package/src/web/web-io.js +54 -12
  66. package/src/workspace.js +14 -14
@@ -0,0 +1,58 @@
1
+ # 六方技术评估 · 第二轮甄别与迭代方案(v0.2.5 → v0.2.6)
2
+
3
+ > 2026-09-02。对 CodeBuddy / MiniMax / Kimi / OfficeACE / workbuddy / CodeArts Agent 六家对
4
+ > MingDao Harness v0.2.5 的评估报告逐条对照真实代码甄别(每份报告均由独立子代理核实:
5
+ > 文件+行号证据、区分「真实缺陷」与「过时/误判/空泛套话」)。已修复项随本轮提交 `20693ee`。
6
+
7
+ ## 一、本轮已修复(按严重度)
8
+
9
+ ### 崩溃类(0.2.2 教训:打包期无声、运行期爆炸)
10
+ 1. **域路由 400/403 双写崩溃【最高】**:C1 拆分后 `json()` 无返回值,`return json(...)` 被编排器视为「未处理」→ 兜底 404 对已结束响应二次写入 → Node 18 未捕获 write-after-end 整服务崩溃(CI ubuntu-18 腿连续红灯的真因,报告们误归因于 Windows)。修复:`json()` 返回 truthy。
11
+ 2. **reasoning_content 工具轮未完整回传**:DeepSeek thinking_mode 官方要求带 tool_calls 的 assistant 消息在后续请求中原样回传 reasoning_content,否则 400——多轮工具会话真实 API 必崩(mock 测试看不见)。修复:回填完整 reasoning;裁剪逻辑只对纯文本回复生效。
12
+ 3. **降级模型零校验**:`downgradeModel` 填错/跨服务商 → provider.chat 必然 400 终止回合。修复:校验同服务商,失败按 block 处理并给修复指引。
13
+ 4. **reasoning_effort 无门控**:全局配置发给不支持 reasoning 的模型 → 400。修复:仅 `supportsReasoning` 模型发送;`/think off` 改为显式 `thinking:{type:'disabled'}`(此前 off 无效,pro 默认思考开启)。
14
+
15
+ ### 安全类
16
+ 5. **SSRF 域名 DNS 重绑定绕过**:校验不解析域名。修复:`validateRemoteUrl` 对域名做 `dns.lookup` 全量 A/AAAA 复检;IPv6 字面量去括号归一(`[::1]`/`[::ffff:127.0.0.1]` 此前直接放行)。
17
+ 6. **readBody limit 形同虚设**:1MB 注释 vs 实际 40MB 全接口。修复:limit 参数生效 + 超限返回真 413(排水保留连接)+ 契约断言;chat 保留 40MB(附件 base64)。
18
+ 7. **project 级技能无指纹校验**:仓库投毒可直通提示词注入。修复:project 级同样校验 `.source.json` 指纹(无指纹的旧仓库不受影响)。
19
+ 8. **Windows fs-browse 白名单过宽**:家目录覆盖整个用户配置树(AppData)。修复:win32 收紧为 桌面/文档/下载 + 启动/工作目录 + 显式授权;路径比较大小写归一。
20
+
21
+ ### 省钱与正确性类
22
+ 9. **护栏在途费用失明**:长回合内统计文件不变,可烧穿日限。修复:回合内累计 usage 保守估算并入检查/前置拦截。
23
+ 10. **语义回收缺陷**:>80% 即回收(能塞下也截断丢内容);回收窗口与被丢弃前缀不相交(白做)。修复:仅真超预算触发 + 按丢弃边界回收 + 只接受变短替换。
24
+ 11. **工具 Schema 会话内多次变化破坏前缀缓存**(三家共识):每轮剥描述 + 只读阶段翻转 + MCP 晚到 → 前缀反复失配。**列入 v0.2.6 计划**(需要在线基准裁决收益,见下)。
25
+ 12. **英文任务只读死锁/误路由**:写意图与生成意图正则纯中文,英文会话整回合只读、生成任务落 8192 输出。修复:双语正则 + 路由标注集英文子集(113 条)。
26
+ 13. 杂项:PID 归属 `null`(无法校验)不再盲杀;SSE 尾字节并入 buf 统一行解析;原子写陈旧锁回收 TOCTOU 比对;tokenCache 整体清空改 LRU;`todayCost` 读失败返回 null + 一次性告警(不再静默归零)。
27
+
28
+ ### 质量基础设施
29
+ 14. CI 补跑 `api-contracts`(此前漏掉);`run-all` spawn 加 error 监听 + Windows `npm.cmd`;e2e-web 越界用例改用系统目录(Windows %TEMP% 在家目录内的假阴性)。
30
+
31
+ ## 二、下一迭代(v0.2.6)方案
32
+
33
+ **A. 前缀缓存稳定性(省钱核心,唯一需要「先测量再动手」的项)**
34
+ - A1:会话内工具 Schema 两态冻结——回合首轮定档后恒定(不再逐轮剥描述/MCP 晚到突变),回合间才更新;
35
+ - A2:MCP 预热:启动时 await 连接(带超时,超时本会话冻结工具集);
36
+ - A3:新建 `test/bench/bench-cost.mjs`——mock 模型费用对照(schema 收益、前缀命中、护栏拦截),把「48%/1/30/半价」营销数字变成可回归断言;
37
+ - A4:`trimMessages` 前沿对齐(P1-1:轮重写头部消息导致前缀整体失配)——在 A3 基准下裁决是否采纳。
38
+
39
+ **B. 会话与索引扩展**
40
+ - B1:session-index 按会话名分片(>1000 会话时避免全量 JSON.parse);
41
+ - B2:常量单源化(MAX_CONCURRENT=8 实测值、200KB/5MB 附件上限去重,防 app.js/attachments.js/fs-tools.js 漂移)。
42
+
43
+ **C. 桌面与 WebUI**
44
+ - C1:面板推开布局(已随本次提交:轨迹/子代理/任务面板以 body class 推开内容区,≤560px 退化为全宽面板;附件行不再被遮挡);
45
+ - C2:app.js(88KB)原生 ES Modules 零构建拆分(可选,收益=可维护性)。
46
+
47
+ **D. 文档与门禁**
48
+ - D1:docs/QA-REPORT.md 断言数(14→60+)、CONFIG.md 补 `costGuard.action:'downgrade'`;
49
+ - D2:CI 类型门禁统一为 tsconfig.full.json(现仍跑弱版 tsconfig.json 双轨)。
50
+
51
+ **E. 缓做/不采纳(已甄别)**
52
+ - sync-server 非 loopback 无 TLS 拒启(需 `--insecure` 逃生门,改行为需用户确认);87 处空 catch 加 debug 开关(收益低,优先关键路径);WebUI SPA 拆分;i18n;SQLite(需 Node≥22.5,破坏 18/20 兼容矩阵);报告中的 roadmap 型空泛项(收益数字无测量依据)。
53
+
54
+ ## 三、验收门槛(防 0.2.2 式回归)
55
+ 1. 6 套测试全绿 + api-contracts 每域契约 + 新增 bench-cost 断言;
56
+ 2. strict 棘轮 0/0 不回升;覆盖率 ≥60% 门禁;
57
+ 3. **每次发版前 CI 打包冒烟(xvfb 真实运行打包产物主进程)**——已固化,不可省略;
58
+ 4. 新增「Node 18 兼容」意识:CI 已有 ubuntu-18 腿(本次双写崩溃即由它抓住),任何路由改动必须过该腿。
@@ -34,11 +34,11 @@
34
34
  5. ✅ **评测基准扩充**:压缩质量集(5 组长会话样本 → 压缩 → 关键事实存活断言 + 尾部字节不变)、路由标注集扩至 100 条;bench 断言数 38 → 150+;覆盖率阈值 55% → 60%
35
35
  - 版本说明:v0.2.3 被 0.2.2 桌面启动崩溃的紧急修复占用,Phase B 顺延为 v0.2.4。
36
36
 
37
- ### Phase C「结构渐进」v0.2.5(约 2 天)
38
- 1. routes/api.js 按域细分(config/sessions/sync/schedule/skills/workspace/misc 7 模块 + 共享中间件),每域配 API 契约测试
39
- 2. cli.js REPL → commands/repl.jsworker → tasks/worker.jse2e-local 16 项全绿做护栏)
40
- 3. strict 棘轮削 300 条(先 credentials/permissions/config/sync-server/session 五个安全关键模块全量注解)
41
- - 验收:tsc full 错误数 ≤1284;结构清单在文档登记。
37
+ ### Phase C「结构渐进」v0.2.5(进行中)
38
+ 1. routes/api.js 按域细分:`src/web/routes/domains/{config,sessions,skills,schedule,sync,workspace,misc}.js`(7 域,handle(ctx,deps,shared) 契约:命中返回 true)+ api.js 瘦身为编排器(访问控制/CSRF/静态壳 + 域循环);`test/api-contracts.js` 8 组契约测试入 run-all;顺带修复 icon 路由路径 bug(routes/icons → web/icons,契约测试捕获)
39
+ 2. cli.js REPL → `src/commands/repl.js`(runRepl(ctx))、worker → `src/tasks/worker.js`(runWorkerTask);e2e-local 16 项全绿护栏
40
+ 3. strict 棘轮削 300 条(五安全关键模块 credentials/permissions/config/sync-server/session 全量注解 + 批量文件注解)
41
+ - 验收:tsc full 错误数 ≤812;结构清单在文档登记。
42
42
 
43
43
  ### Phase D「能力演进」v0.3.0(约 3–5 天,可按需裁剪)
44
44
  1. **跨平台沙箱补位**:Windows Job Object + 降权 token(零依赖原生 API)、macOS seatbelt 规则;敏感命令平台无关拦截清单兜底
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "desktop": "npm --prefix desktop start",
55
55
  "desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
56
56
  "desktop:sync": "node scripts/sync-versions.mjs",
57
- "bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs",
57
+ "bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs && node test/bench/bench-cost.mjs",
58
58
  "coverage": "node test/run-all.mjs --coverage && node scripts/coverage-report.mjs",
59
59
  "typecheck:strict": "node scripts/strict-ratchet.mjs",
60
60
  "preversion": "node test/smoke.js && node test/e2e-local.js && npm run bench",
package/src/agent.js CHANGED
@@ -13,6 +13,7 @@ import { subagentModel } from './routing.js';
13
13
  import { writeAudit, redactSecrets } from './audit.js';
14
14
  import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
15
15
  import { estimateCost } from './pricing.js';
16
+ import { resolveProviderConfig } from './providers/index.js';
16
17
 
17
18
  const MAX_STEPS = 24;
18
19
  const SUBAGENT_MAX_STEPS = 12;
@@ -29,7 +30,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
29
30
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
30
31
  const reasoningEffort = cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
31
32
  const hooks = createHooks(cfg.hooks, workingDir);
32
- const todos = [];
33
+ const todos = /** @type {any[]} */ ([]);
33
34
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
34
35
  const undo = undoStore || { backups: new Map() };
35
36
  const stepLimit = maxSteps || MAX_STEPS;
@@ -45,27 +46,30 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
45
46
  // 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
46
47
  // + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
47
48
  const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
48
- const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试/;
49
+ // 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
50
+ const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
49
51
  const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
50
- const toolsFor = (/** @type {boolean} */ readOnlyPhase) => {
51
- const schemas = buildToolSchemas(usedToolNames, mcpSchemas());
52
+ // A1(前缀稳定):剥描述集合按「回合冻结快照」——回合内恒定(至多两态:只读档/全量档),
53
+ // 新使用的工具只在下一回合才进入剥描述集合;回合边界本身就有新 user 消息,schema 变化免费。
54
+ const toolsFor = (/** @type {boolean} */ readOnlyPhase, /** @type {Set<string>} */ strippedSet) => {
55
+ const schemas = buildToolSchemas(strippedSet, mcpSchemas());
52
56
  if (!readOnlyPhase) return schemas;
53
57
  return schemas.filter((/** @type {any} */ t) => {
54
58
  const n = t?.function?.name;
55
59
  if (!n) return true;
56
- if (READONLY_TIER_SET.has(n) || usedToolNames.has(n)) return true;
60
+ if (READONLY_TIER_SET.has(n) || strippedSet.has(n)) return true;
57
61
  if (n.startsWith('mcp__')) return mcp ? mcp.isReadonly(n) : false;
58
62
  return false;
59
63
  });
60
64
  };
61
65
 
62
66
  // 子代理:全新上下文 + 同一 Provider/权限(提示带「子任务」标记),独立完成子任务后汇报
63
- async function spawnTask(prompt, { description = '', readOnly = false } = {}) {
67
+ async function spawnTask(/** @type {any} */ prompt, { description = '', readOnly = false } = {}) {
64
68
  const subIo = createIO({ quiet: true });
65
69
  // 只读子代理(评估 A4:可并行):只读工具自动放行、写类直接拒绝,无交互询问
66
70
  const subPermission = readOnly
67
- ? { mode: 'readonly-noask', check: (name) => READONLY_TOOLS_SET.has(name) }
68
- : { check: (name, args) => permission.check(name, args, '(子任务)') };
71
+ ? { mode: 'readonly-noask', check: (/** @type {any} */ name) => READONLY_TOOLS_SET.has(name) }
72
+ : { check: (/** @type {any} */ name, /** @type {any} */ args) => permission.check(name, args, '(子任务)') };
69
73
  // 自动路由:子代理固定走 executor 模型(便宜的执行单元)
70
74
  const subModel = subagentModel(cfg, modelName);
71
75
  const subAgent = createAgent({
@@ -110,19 +114,19 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
110
114
  budget,
111
115
  todos,
112
116
  undoStore: undo,
113
- spawnTask: (prompt, opts) => spawnTask(prompt, opts),
117
+ spawnTask: (/** @type {any} */ prompt, /** @type {any} */ opts) => spawnTask(prompt, opts),
114
118
  };
115
119
  }
116
120
 
117
- async function runTurn(messages) {
121
+ async function runTurn(/** @type {any} */ messages) {
118
122
  let steps = 0;
119
123
  let finish = null;
120
- const usage = { prompt_tokens: 0, completion_tokens: 0 };
124
+ const usage = /** @type {{ prompt_tokens: number, completion_tokens: number, prompt_cache_hit_tokens?: number, prompt_cache_miss_tokens?: number }} */ ({ prompt_tokens: 0, completion_tokens: 0 });
121
125
  const startedAt = Date.now();
122
126
  // 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
123
127
  let llmMsTotal = 0;
124
128
  let toolMsTotal = 0;
125
- let firstTokenAt = null;
129
+ let firstTokenAt = /** @type {any} */ (null);
126
130
  // 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
127
131
  let reasoningTokens = 0;
128
132
  const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
@@ -137,11 +141,18 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
137
141
  });
138
142
  let aborted = false;
139
143
  let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
140
- let currentAc = null;
144
+ let currentAc = /** @type {any} */ (null);
141
145
  // 省钱 B4(护栏降级):action='downgrade' 超限后本回合切换到便宜模型继续执行;
142
146
  // activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
143
147
  let activeModel = modelName;
144
148
  let downgraded = false;
149
+ // 护栏在途费用(MiniMax P0):本回合已累计 usage 的保守估算(无缓存折扣),
150
+ // 护栏检查/前置拦截时并入今日已用——否则长回合内统计文件不变,可烧穿日限。
151
+ const inFlightCost = () => estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, null, new Date());
152
+ const usedTodayWithInflight = () => {
153
+ const today = todayCost();
154
+ return today == null ? null : today + inFlightCost();
155
+ };
145
156
  // 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
146
157
  // 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
147
158
  let readOnlyPhase = true;
@@ -151,6 +162,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
151
162
  } else {
152
163
  readOnlyPhase = false;
153
164
  }
165
+ // A1:本回合的剥描述冻结快照(会话级 usedToolNames 的副本)——回合内 schema 字节不变
166
+ const turnStrippedSet = new Set(usedToolNames);
154
167
  // 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
155
168
  const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
156
169
  const ctx = makeCtx();
@@ -197,15 +210,17 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
197
210
  }
198
211
  const trimmed = trimMessages(messages, budget, count);
199
212
 
200
- // reasoning 回填防护(MiniMax P0-2):推理链只在 UI 流式展示、不回传消息(本就不回填);
201
- // 防御性保证——任何携带 reasoning_content 的消息发送前裁剪:>4000 字仅留概括,>1000 字截尾 500。
213
+ // reasoning 回填防护(Kimi P0 修正):带 tool_calls 的 assistant 消息必须「完整」回传
214
+ // reasoning_content(DeepSeek thinking_mode 官方要求:后续请求含 tools 时缺/截断即 400,
215
+ // 多轮工具会话必崩);仅纯文本回复的 reasoning 可裁剪(不回传也不影响)。
202
216
  let sanitized = trimmed;
203
217
  for (const m of sanitized) {
204
218
  const rc = m.reasoning_content;
205
- if (typeof rc === 'string' && rc.length > 4000) {
206
- sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
207
- } else if (typeof rc === 'string' && rc.length > 1000) {
208
- sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
219
+ if (typeof rc !== 'string' || (Array.isArray(m.tool_calls) && m.tool_calls.length)) continue;
220
+ if (rc.length > 4000) {
221
+ sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
222
+ } else if (rc.length > 1000) {
223
+ sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
209
224
  }
210
225
  }
211
226
 
@@ -219,8 +234,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
219
234
  let promptTokens = 0;
220
235
  for (const m of sanitized) promptTokens += messageTokens(m, count);
221
236
  const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
222
- const used = todayCost();
223
- if (used + worst >= Number(g.dailyLimitYuan)) {
237
+ const used = usedTodayWithInflight(); // 含本回合在途费用(防长回合烧穿)
238
+ if (used == null) {
239
+ // todayCost 读取失败:无法判断,跳过前置拦截(护栏主检查同样跳过并告警)
240
+ } else if (used + worst >= Number(g.dailyLimitYuan)) {
224
241
  stripOrphanCalls();
225
242
  return {
226
243
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
@@ -253,15 +270,29 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
253
270
  }
254
271
  if (guard.downgrade && !downgraded) {
255
272
  if (guard.downgradeModel !== activeModel) {
256
- activeModel = guard.downgradeModel;
257
- downgraded = true;
258
- io.print(style(guard.message, C.yellow));
273
+ // MiniMax P0:降级目标零校验会崩溃——必须与当前模型同服务商且已有 Key,
274
+ // 否则 provider.chat 必然 400;校验失败按 block 处理并给修复指引。
275
+ const curPc = resolveProviderConfig(cfg, activeModel);
276
+ const dgPc = resolveProviderConfig(cfg, guard.downgradeModel);
277
+ // Key 归属服务商(provider 级),同服务商即天然共享同一 Key,无需再查 apiKey
278
+ if (dgPc && dgPc.name === curPc.name) {
279
+ activeModel = guard.downgradeModel;
280
+ downgraded = true;
281
+ io.print(style(guard.message, C.yellow));
282
+ } else {
283
+ stripOrphanCalls();
284
+ return {
285
+ text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
286
+ note: `费用护栏想降级到 ${guard.downgradeModel},但它与当前服务商不一致或缺少 API Key——已暂停执行。请把 config.costGuard.downgradeModel 改为与当前模型同服务商(当前:${curPc.name})的模型名,或调高 dailyLimitYuan。`,
287
+ durationMs: Date.now() - startedAt, perf: perf(),
288
+ };
289
+ }
259
290
  } else {
260
291
  // 已经是降级目标模型:无法再降,按 block 处理
261
292
  stripOrphanCalls();
262
293
  return {
263
294
  text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
264
- note: `今日费用已达上限(实际 ¥${String(todayCost().toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
295
+ note: `今日费用已达上限(实际 ¥${String((todayCost() ?? 0).toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
265
296
  durationMs: Date.now() - startedAt, perf: perf(),
266
297
  };
267
298
  }
@@ -284,12 +315,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
284
315
  res = await provider.chat({
285
316
  model: activeModel,
286
317
  messages: sanitized,
287
- tools: toolsFor(readOnlyPhase),
318
+ tools: toolsFor(readOnlyPhase, turnStrippedSet),
288
319
  temperature,
289
320
  maxTokens: maxOutput,
290
- reasoningEffort,
321
+ // MiniMax P0:仅当 activeModel 声明支持 reasoning 才发送(否则 400 终止回合);'off' 显式禁用例外
322
+ reasoningEffort: modelPreset(activeModel)?.supportsReasoning || reasoningEffort === 'off' ? reasoningEffort : undefined,
291
323
  signal: ac.signal,
292
- onDelta(d) {
324
+ onDelta(/** @type {any} */ d) {
293
325
  io.stopSpinner();
294
326
  if (firstTokenAt == null) firstTokenAt = Date.now(); // 首个增量即首 token
295
327
  if (d.text) io.writeText(d.text);
@@ -328,7 +360,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
328
360
 
329
361
  if (res.toolCalls?.length) {
330
362
  io.endTurn();
331
- const assistantMsg = { role: 'assistant', content: res.text || null, tool_calls: res.toolCalls };
363
+ const assistantMsg = {
364
+ role: 'assistant',
365
+ content: res.text || null,
366
+ tool_calls: res.toolCalls,
367
+ // Kimi P0:带工具调用的消息必须携带完整 reasoning_content(否则下一轮 400)
368
+ ...(res.toolCalls?.length && res.reasoning ? { reasoning_content: res.reasoning } : {}),
369
+ };
332
370
  messages.push(assistantMsg);
333
371
 
334
372
  // 只读工具并行(P2-8):同一 response 里的连续 read/ls/glob/grep 无相互依赖,
@@ -339,7 +377,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
339
377
 
340
378
  // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
341
379
  // task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
342
- async function prepTool(tc) {
380
+ async function prepTool(/** @type {any} */ tc) {
343
381
  const name = tc.function?.name || '';
344
382
  // 审计(P3-5):参数与拒绝原因都记录(配置 audit:false 可关)
345
383
  const auditOn = cfg.audit !== false;
@@ -353,7 +391,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
353
391
  args: auditArgs(),
354
392
  ...extra,
355
393
  });
356
- let args = null;
394
+ let args = /** @type {any} */ (null);
357
395
  try {
358
396
  args = JSON.parse(tc.function?.arguments || '{}');
359
397
  } catch {
@@ -403,7 +441,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
403
441
  // 执行单个工具(渲染「执行中」→ dispatch → 捕获异常转错误结果)
404
442
  // 审计 Hermes C4:同回合相同参数的只读工具(read/ls/glob/grep/skill)合并执行一次,
405
443
  // 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
406
- async function runTool(prep) {
444
+ async function runTool(/** @type {any} */ prep) {
407
445
  io.renderToolStart?.(prep.name, prep.args);
408
446
  usedToolNames.add(prep.name); // 省钱 B1:执行过即标记,后续轮次省略其 description
409
447
  const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
@@ -421,13 +459,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
421
459
  }
422
460
  if (dedupKey) turnToolCache.set(dedupKey, result);
423
461
  return result;
424
- } catch (err) {
462
+ } catch (/** @type {any} */ err) {
425
463
  return JSON.stringify({ ok: false, error: String(err?.message || err) });
426
464
  }
427
465
  }
428
466
 
429
467
  // 收尾:渲染结果 → todo 更新 → PostToolUse 钩子 → 回填消息(顺序与串行一致)
430
- function finishTool(prep, result, t0) {
468
+ function finishTool(/** @type {any} */ prep, /** @type {any} */ result, /** @type {any} */ t0) {
431
469
  const ms = Date.now() - t0;
432
470
  toolMsTotal += ms;
433
471
  // 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
@@ -473,7 +511,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
473
511
  const batch = []; // {prep, batchable}
474
512
  while (i < res.toolCalls.length) {
475
513
  const tc = res.toolCalls[i];
476
- const prep = await prepTool(tc);
514
+ const prep = /** @type {any} */ (await prepTool(tc));
477
515
  const name = tc.function?.name || '';
478
516
  let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
479
517
  if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true) batchable = true;
@@ -601,7 +639,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
601
639
  maxOutput,
602
640
  temperature,
603
641
  runTurn,
604
- spawnTask: (prompt, opts) => spawnTask(prompt, opts),
642
+ spawnTask: (/** @type {any} */ prompt, /** @type {any} */ opts) => spawnTask(prompt, opts),
605
643
  getTodos: () => todos.slice(),
606
644
  };
607
645
  }
@@ -57,10 +57,16 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
57
57
  try {
58
58
  const st = fs.statSync(lockPath);
59
59
  if (Date.now() - st.mtimeMs > staleMs) {
60
+ // TOCTOU 防护(OfficeACE 报告):unlink 前读锁内容并二次 stat 比对,
61
+ // 防两个进程同时判定陈旧、后者误删前者刚创建的新锁(互斥失效)
60
62
  try {
61
- fs.unlinkSync(lockPath);
63
+ const before = fs.readFileSync(lockPath, 'utf8');
64
+ const st2 = fs.statSync(lockPath);
65
+ if (st2.mtimeMs === st.mtimeMs && before === fs.readFileSync(lockPath, 'utf8')) {
66
+ fs.unlinkSync(lockPath);
67
+ }
62
68
  } catch {}
63
- continue; // 回收陈旧锁后立即重试
69
+ continue; // 无论是否回收成功都重试一次(若锁刚被他人更新则继续等待)
64
70
  }
65
71
  } catch {
66
72
  continue; // 锁文件刚被释放
package/src/audit.js CHANGED
@@ -17,11 +17,11 @@ export function auditFile() {
17
17
  }
18
18
 
19
19
  // 轻量脱敏:sk- 系 API Key 掩码(审计日志可安全共享排查)
20
- export function redactSecrets(text) {
20
+ export function redactSecrets(/** @type {any} */ text) {
21
21
  return String(text ?? '').replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***');
22
22
  }
23
23
 
24
- export function writeAudit(entry) {
24
+ export function writeAudit(/** @type {any} */ entry) {
25
25
  try {
26
26
  ensureHome();
27
27
  const file = auditFile();
package/src/batch.js CHANGED
@@ -19,7 +19,7 @@ const DEFAULT_WINDOW = '24h';
19
19
  const DEFAULT_ENDPOINT = '/v1/chat/completions';
20
20
 
21
21
  // 批处理端点基址:config.batchBaseUrl 优先;否则取当前服务商 baseUrl 去掉 /v1 后缀
22
- function batchBase(cfg, model) {
22
+ function batchBase(/** @type {any} */ cfg, /** @type {any} */ model) {
23
23
  const explicit = String(cfg?.batchBaseUrl || '').trim().replace(/\/+$/, '');
24
24
  if (explicit) return explicit;
25
25
  const pc = resolveProviderConfig(cfg, model);
@@ -29,7 +29,7 @@ function batchBase(cfg, model) {
29
29
  }
30
30
 
31
31
  /** @returns {Promise<any>} */
32
- async function api(base, apiKey, methodPath, payload, httpMethod = 'POST') {
32
+ async function api(/** @type {any} */ base, /** @type {any} */ apiKey, /** @type {any} */ methodPath, /** @type {any} */ payload, httpMethod = 'POST') {
33
33
  const res = await fetch(base + methodPath, {
34
34
  method: httpMethod,
35
35
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
@@ -44,7 +44,7 @@ async function api(base, apiKey, methodPath, payload, httpMethod = 'POST') {
44
44
  return j;
45
45
  }
46
46
 
47
- async function uploadFile(base, apiKey, jsonl) {
47
+ async function uploadFile(/** @type {any} */ base, /** @type {any} */ apiKey, /** @type {any} */ jsonl) {
48
48
  const form = new FormData();
49
49
  form.append('file', new Blob([jsonl], { type: 'application/jsonl' }), 'mingdao-batch.jsonl');
50
50
  form.append('purpose', 'batch');
@@ -62,7 +62,7 @@ async function uploadFile(base, apiKey, jsonl) {
62
62
  return j.id;
63
63
  }
64
64
 
65
- async function downloadResults(base, apiKey, batch) {
65
+ async function downloadResults(/** @type {any} */ base, /** @type {any} */ apiKey, /** @type {any} */ batch) {
66
66
  // DeepSeek 风格:直接取结果文件;回退 OpenAI 风格:按 output_file_id 取内容
67
67
  const attempts = [
68
68
  `/batches/${batch.id}/files/result`,
@@ -92,7 +92,7 @@ async function downloadResults(base, apiKey, batch) {
92
92
  * ③maxCost 预算上限(提交前按估算费用拦截,超出直接中止不提交)。
93
93
  * @param {{ cfg: any, model: any, questions: any, workingDir?: string, maxTokens?: number, temperature?: any, signal?: any, onStatus?: any, maxCost?: number }} opts */
94
94
  export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus, maxCost = 0 }) {
95
- const list = (questions || []).map((q) => String(q).trim()).filter(Boolean);
95
+ const list = (questions || []).map((/** @type {any} */ q) => String(q).trim()).filter(Boolean);
96
96
  if (!list.length) return { error: '没有可批处理的问题(每行一个问题)' };
97
97
  const pc = resolveProviderConfig(cfg, model);
98
98
  if (!pc.apiKey) return { error: `模型 ${model} 没有可用 API Key(mingdao key set ${pc.name})` };
@@ -192,7 +192,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
192
192
  failures = 0;
193
193
  } catch (err) {
194
194
  failures += 1;
195
- if (failures >= 10) return { error: `轮询失败:${err?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
195
+ if (failures >= 10) return { error: `轮询失败:${(/** @type {any} */ (err))?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
196
196
  await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** failures, 30000)));
197
197
  continue;
198
198
  }
@@ -242,6 +242,6 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
242
242
  return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT, deduped, estimatedCost };
243
243
  } catch (err) {
244
244
  // 批处理端点不支持(404/405 等)→ 明确告知,不静默
245
- return { error: `批处理不可用:${err?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
245
+ return { error: `批处理不可用:${(/** @type {any} */ (err))?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
246
246
  }
247
247
  }
package/src/cachestats.js CHANGED
@@ -16,7 +16,7 @@ const MAX_LINES = 20000;
16
16
  const KEEP_LINES = 10000;
17
17
  let cacheStatsCount = 0;
18
18
 
19
- export function recordCacheStats(entry) {
19
+ export function recordCacheStats(/** @type {any} */ entry) {
20
20
  try {
21
21
  ensureHome(); // 审计 B10:与 audit/journal 一致,目录缺失不静默丢统计
22
22
  const line = JSON.stringify({
@@ -54,13 +54,13 @@ export function recordCacheStats(entry) {
54
54
  // listCacheStats 读取缓存(审计:costGuard 每步 / WebUI 每 15s / /cost 命令都调用——
55
55
  // 四报告共识 P2-2/§3.3-A:全文件读+逐行 parse 会随 JSONL 增长线性放大 IO。
56
56
  // 按 mtimeMs+size 双键缓存;写入追加会改两者,轮转重写同样改,缓存自动失效)
57
- let _statsCache = null;
57
+ let /** @type {any} */ _statsCache = null;
58
58
 
59
59
  export function listCacheStats(limit = 2000) {
60
60
  try {
61
61
  const file = cacheStatsFile();
62
62
  const st = fs.statSync(file);
63
- if (!_statsCache || _statsCache.mtimeMs !== st.mtimeMs || _statsCache.size !== st.size || _statsCache.limit < limit) {
63
+ if (!/** @type {any} */ _statsCache || _statsCache.mtimeMs !== st.mtimeMs || _statsCache.size !== st.size || _statsCache.limit < limit) {
64
64
  const raw = fs.readFileSync(file, 'utf8');
65
65
  const out = [];
66
66
  for (const l of raw.split('\n')) {
@@ -77,8 +77,8 @@ export function listCacheStats(limit = 2000) {
77
77
  }
78
78
  }
79
79
 
80
- export function summarizeCacheStats(entries) {
81
- const sum = { turns: entries.length, prompt: 0, completion: 0, hit: 0, miss: 0, cost: 0, saved: 0, steps: 0, llmMs: 0, toolMs: 0, firstTokenCount: 0, firstTokenSumMs: 0 };
80
+ export function summarizeCacheStats(/** @type {any} */ entries) {
81
+ const sum = /** @type {any} */ ({ turns: entries.length, prompt: 0, completion: 0, hit: 0, miss: 0, cost: 0, saved: 0, steps: 0, llmMs: 0, toolMs: 0, firstTokenCount: 0, firstTokenSumMs: 0 });
82
82
  for (const e of entries) {
83
83
  sum.prompt += e.prompt || 0;
84
84
  sum.completion += e.completion || 0;
@@ -102,7 +102,7 @@ export function summarizeCacheStats(entries) {
102
102
 
103
103
  // 记录一次用量(agent 侧调用):自动计算命中拆分与节省额
104
104
  // 记录一次用量(agent 侧调用):自动计算命中拆分与节省额;perf 为回合性能指标(状态栏)
105
- export function recordUsage(modelName, usage, perf = null) {
105
+ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usage, /** @type {any} */ perf = null) {
106
106
  const split = cacheSplit(usage);
107
107
  const prompt = usage?.prompt_tokens || 0;
108
108
  const completion = usage?.completion_tokens || 0;
@@ -132,8 +132,8 @@ export function recordUsage(modelName, usage, perf = null) {
132
132
  });
133
133
  }
134
134
 
135
- export function formatCacheSummary(sum) {
136
- const fmt = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
135
+ export function formatCacheSummary(/** @type {any} */ sum) {
136
+ const fmt = (/** @type {any} */ n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
137
137
  const lines = [
138
138
  `轮次 ${sum.turns} · ↑${fmt(sum.prompt)} ↓${fmt(sum.completion)} tokens`,
139
139
  `缓存命中率 ${sum.rate != null ? (sum.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}`,
@@ -213,7 +213,7 @@ export function costBreakdown() {
213
213
 
214
214
  // 月度费用报告(/cost 导出):按北京时间月份聚合,month 形如 'YYYY-MM',缺省返回全部月份
215
215
  // 省钱 B3:月度聚合增加 reasoning 与 byTool 维度
216
- export function costMonthlyReport(month) {
216
+ export function costMonthlyReport(/** @type {any} */ month) {
217
217
  const entries = listCacheStats(100000);
218
218
  const want = String(month || '').trim();
219
219
  const months = new Map();