mingdao-harness 0.4.3 → 0.4.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,8 +2,8 @@
2
2
  "name": "local-audit",
3
3
  "label": "本地模型审计",
4
4
  "description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 + 只读权限 + 保守参数,配合 v0.3.2 本地自适应长任务不中断",
5
- "systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
6
- "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo"],
5
+ "systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。对于大仓库可分模块派发只读子代理并行审查(task 工具传 readOnly:true,子代理同样只读:read/ls/glob/grep/skill)。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
6
+ "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo", "task"],
7
7
  "permission": "readonly",
8
8
  "maxRounds": 4,
9
9
  "maxOutputTokens": 4096
package/src/agent.js CHANGED
@@ -26,9 +26,10 @@ const SUBAGENT_MAX_STEPS = 24;
26
26
  /**
27
27
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
28
28
  * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
29
- * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any }} params
29
+ * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
30
+ * onUsage?: (modelName: string, usage: any) => void }} params
30
31
  */
31
- export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
32
+ export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage }) {
32
33
  const preset = modelPreset(modelName) || {};
33
34
  // v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
34
35
  // 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
@@ -61,7 +62,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
61
62
  const usedToolNames = new Set();
62
63
  // 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
63
64
  // + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
64
- const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch']);
65
+ // v0.4.4:加 task——审计/调研等只读长任务此前因 task 不在只读档而看不到「派只读子代理」能力
66
+ // (readOnly 子代理只读,权限引擎仍门控写操作,无越权)。
67
+ const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch', 'task']);
65
68
  // 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
66
69
  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;
67
70
  const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
@@ -109,6 +112,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
109
112
  maxSteps: SUBAGENT_MAX_STEPS,
110
113
  mcp,
111
114
  sessionRef, // 子代理的审计记录归入主会话
115
+ onUsage, // P1-5(v0.4.5):透传逐轮入账回调——子代理消耗计入今日费用(子代理内部 activeModel=subModel 正确归属)
112
116
  });
113
117
  const sys =
114
118
  `你是主智能体 MingDao 派出的子代理,独立完成一项子任务。` +
@@ -191,7 +195,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
191
195
  const inFlightCost = () => estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, null, new Date());
192
196
  const usedTodayWithInflight = () => {
193
197
  const today = todayCost();
194
- return today == null ? null : today + inFlightCost();
198
+ if (today == null) return null;
199
+ const inflight = inFlightCost();
200
+ // P0-4(v0.4.5):无价模型在途费用为 null(未知)——整体视为「无法判断」而非 +null→today 的静默忽略
201
+ return inflight == null ? null : today + inflight;
195
202
  };
196
203
  // 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
197
204
  // 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
@@ -220,6 +227,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
220
227
  const turnToolCache = new Map();
221
228
  for (round = 0; round < maxRounds; round++) {
222
229
  steps = 0;
230
+ // v0.4.4:每轮结束回调本轮增量 usage(长任务费用逐轮入账——此前只在 runTurn 全结束后才
231
+ // recordUsage,长任务期间「今日费用」恒为 0 被误读为「无统计」;中断时已完成轮次费用也保留)。
232
+ const roundUsageStart = { prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens || 0, prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens || 0 };
223
233
  while (steps < stepLimit) {
224
234
  steps += 1;
225
235
  // 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
@@ -299,8 +309,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
299
309
  for (const m of sanitized) promptTokens += messageTokens(m, count);
300
310
  const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
301
311
  const used = usedTodayWithInflight(); // 含本回合在途费用(防长回合烧穿)
302
- if (used == null) {
303
- // todayCost 读取失败:无法判断,跳过前置拦截(护栏主检查同样跳过并告警)
312
+ if (used == null || worst == null) {
313
+ // P0-4(v0.4.5):统计不可读 或 无价格数据(最坏成本未知)→ 无法判断,跳过前置拦截
314
+ // (护栏主检查 checkCostGuard 会显式告警「无价格数据」,此处不重复误拦也不静默放行)
304
315
  } else if (used + worst >= Number(g.dailyLimitYuan)) {
305
316
  stripOrphanCalls();
306
317
  return {
@@ -315,7 +326,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
315
326
  // 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
316
327
  // downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
317
328
  if (cfg.costGuard) {
318
- const guard = checkCostGuard();
329
+ const guard = checkCostGuard(activeModel);
319
330
  if (guard) {
320
331
  if (guard.blocked) {
321
332
  stripOrphanCalls();
@@ -404,6 +415,24 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
404
415
  stripOrphanCalls();
405
416
  return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true, durationMs: Date.now() - startedAt, perf: perf() };
406
417
  }
418
+ // MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
419
+ // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
420
+ const e = /** @type {any} */ (err);
421
+ if (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || ''))) {
422
+ stripOrphanCalls();
423
+ return {
424
+ text: null,
425
+ reasoning: '',
426
+ usage,
427
+ steps,
428
+ finish,
429
+ truncated: false,
430
+ aborted: false,
431
+ note: '本地模型内存不足(507 memory_refusal)——请压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后再继续。',
432
+ durationMs: Date.now() - startedAt,
433
+ perf: perf(),
434
+ };
435
+ }
407
436
  throw err;
408
437
  }
409
438
 
@@ -517,6 +546,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
517
546
  let allowed = false;
518
547
  if (isMcp && mcp?.isReadonly(name)) {
519
548
  allowed = true; // MCP 工具的只读标注自动放行
549
+ } else if (name === 'task' && args.readOnly === true) {
550
+ allowed = true; // P2-2(v0.4.5):只读子代理自动放行——它本身只读(readOnly 子代理只能读),
551
+ // 与 release note「可派 readOnly 子代理」意图一致;非 readOnly 的 task 仍走权限询问
520
552
  } else {
521
553
  try {
522
554
  allowed = await permission.check(name, args);
@@ -734,6 +766,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
734
766
  }
735
767
  // v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
736
768
  if (round < maxRounds - 1 && !aborted) {
769
+ // 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账。activeModel 为本轮实际模型
770
+ // (护栏降级/子代理都据此正确归属——P2-5 模型名一致性 + P1-5 子代理费用入账)。
771
+ try {
772
+ onUsage?.(activeModel, {
773
+ prompt_tokens: usage.prompt_tokens - roundUsageStart.prompt_tokens,
774
+ completion_tokens: usage.completion_tokens - roundUsageStart.completion_tokens,
775
+ prompt_cache_hit_tokens: (usage.prompt_cache_hit_tokens || 0) - roundUsageStart.prompt_cache_hit_tokens,
776
+ prompt_cache_miss_tokens: (usage.prompt_cache_miss_tokens || 0) - roundUsageStart.prompt_cache_miss_tokens,
777
+ });
778
+ } catch {}
737
779
  const art = deliverables.length ? '已交付文件:' + deliverables.join('、') + '。' : '';
738
780
  messages.push({
739
781
  role: 'user',
@@ -31,10 +31,17 @@ export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any
31
31
 
32
32
  // 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步);异常/完成释放。
33
33
  // 进程崩溃遗留的陈旧锁(> staleMs 未更新)自动回收,避免永久卡死。
34
+ // 可重入(P0-1 修复,v0.4.5):本进程已持该锁时直接执行 fn——O_EXCL 锁不可重入,此前
35
+ // killTask 持锁内调 patchTask 二次抢同一把锁自死锁 5s(tasks kill/pause/remove 全失效)。
34
36
  const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
35
37
  const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
38
+ const heldLocks = new Set(); // 本进程当前持有的锁路径(可重入判定)
36
39
 
37
40
  export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
41
+ // 可重入:同一调用栈内已持该锁 → 直接执行,不再二次抢锁
42
+ if (heldLocks.has(lockPath)) {
43
+ return fn();
44
+ }
38
45
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
39
46
  const t0 = Date.now();
40
47
  for (;;) {
@@ -45,9 +52,11 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
45
52
  } finally {
46
53
  fs.closeSync(fd);
47
54
  }
55
+ heldLocks.add(lockPath);
48
56
  try {
49
57
  return fn();
50
58
  } finally {
59
+ heldLocks.delete(lockPath);
51
60
  try {
52
61
  fs.unlinkSync(lockPath);
53
62
  } catch {}
package/src/cachestats.js CHANGED
@@ -5,7 +5,6 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { mingdaoHome, ensureHome } from './config.js';
7
7
  import { estimateCost, cacheSplit, beijingDayStart, beijingParts } from './pricing.js';
8
- import { modelPreset } from './models.js';
9
8
  import { withFileLockSync, atomicWriteFileSync } from './atomic-write.js';
10
9
 
11
10
  export function cacheStatsFile() {
@@ -114,10 +113,12 @@ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usa
114
113
  let cost = null;
115
114
  let saved = null;
116
115
  if (split) {
116
+ const base = estimateCost(modelName, prompt, completion, null);
117
117
  cost = estimateCost(modelName, prompt, completion, split);
118
- saved = estimateCost(modelName, prompt, completion, null) - cost;
119
- } else if (modelPreset(modelName)?.pricing) {
120
- // 审计 B10:无缓存字段时按全未命中估算(不再计 0 元,费用护栏口径更真实)
118
+ if (base != null && cost != null) saved = base - cost;
119
+ } else {
120
+ // P0-4(v0.4.5):estimateCost 无价返 null,直接记录 null(未知)而非 0(免费)——
121
+ // 覆盖内置定价 + 外部定价 + config.pricing.overrides 三条来源,不再依赖 modelPreset 单一判断。
121
122
  cost = estimateCost(modelName, prompt, completion, null);
122
123
  }
123
124
  recordCacheStats({
package/src/cli.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  reconcileSchedules,
33
33
  runSleeper,
34
34
  formatScheduleRow,
35
+ postRunStatus,
35
36
  } from './schedule.js';
36
37
  import { createAgent } from './agent.js';
37
38
  import { saveTaskStateMerge, clearTaskState } from './task-state.js';
@@ -120,21 +121,28 @@ function printHelpLines(/** @type {any} */ out) {
120
121
 
121
122
  function parseArgs(/** @type {any} */ argv) {
122
123
  const opts = /** @type {Record<string, any>} */ ({ prompt: [], model: null, continueSession: false, resume: false, format: 'text' });
124
+ // P1-2 / P2-24(v0.4.5):遇第一个位置参数后停止解析全局 flag——此前贪婪匹配任意位置的 --model/init,
125
+ // 导致 `mingdao run "任务" --model X` 的 --model 被顶层剥除(后台/定时指定模型静默失效),
126
+ // 且提问文本含「init」即误触发初始化向导。此后子命令 flag 原样收入 prompt 交子命令解析。
127
+ let seenArg = false;
123
128
  for (let i = 0; i < argv.length; i++) {
124
129
  const a = argv[i];
125
- if (a === '-h' || a === '--help') opts.help = true;
126
- else if (a === '-v' || a === '--version') opts.version = true;
127
- else if (a === '-c' || a === '--continue') opts.continueSession = true;
128
- else if (a === '--journal') opts.journal = true;
129
- else if (a === '-p' || a === '--preset') opts.preset = argv[++i];
130
- else if (a.startsWith('--preset=')) opts.preset = a.slice(9);
131
- else if (a === '-r' || a === '--resume') opts.resume = true;
132
- else if (a === '--init' || a === 'init') opts.init = true;
133
- else if (a === '-m' || a === '--model') opts.model = argv[++i];
134
- else if (a.startsWith('--model=')) opts.model = a.slice(8);
135
- else if (a === '-f' || a === '--format') opts.format = argv[++i] || 'text';
136
- else if (a.startsWith('--format=')) opts.format = a.slice(9);
137
- else opts.prompt.push(a);
130
+ if (!seenArg) {
131
+ if (a === '-h' || a === '--help') { opts.help = true; continue; }
132
+ else if (a === '-v' || a === '--version') { opts.version = true; continue; }
133
+ else if (a === '-c' || a === '--continue') { opts.continueSession = true; continue; }
134
+ else if (a === '--journal') { opts.journal = true; continue; }
135
+ else if (a === '-p' || a === '--preset') { opts.preset = argv[++i]; continue; }
136
+ else if (a.startsWith('--preset=')) { opts.preset = a.slice(9); continue; }
137
+ else if (a === '-r' || a === '--resume') { opts.resume = true; continue; }
138
+ else if (a === '--init' || a === 'init') { opts.init = true; continue; }
139
+ else if (a === '-m' || a === '--model') { opts.model = argv[++i]; continue; }
140
+ else if (a.startsWith('--model=')) { opts.model = a.slice(8); continue; }
141
+ else if (a === '-f' || a === '--format') { opts.format = argv[++i] || 'text'; continue; }
142
+ else if (a.startsWith('--format=')) { opts.format = a.slice(9); continue; }
143
+ }
144
+ seenArg = true;
145
+ opts.prompt.push(a);
138
146
  }
139
147
  if (!['text', 'json'].includes(opts.format)) opts.format = 'text';
140
148
  return opts;
@@ -289,7 +297,14 @@ async function main() {
289
297
  }
290
298
  if (t.status !== 'running') {
291
299
  const result = t.status === 'done' ? 'done' : t.status === 'timedout' ? 'timedout' : 'failed';
292
- await writeSchedule(home0, { ...j, status: result, lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
300
+ // P1-4(v0.4.5):every 周期任务崩溃恢复不能终态化——复用 postRunStatus 重排下一期回 pending,
301
+ // 否则周期任务静默永停(此前与 once/after 一视同仁写成 done/failed,reconcile 永远跳过)。
302
+ if (j.kind === 'every') {
303
+ const ns = postRunStatus(j, result);
304
+ await writeSchedule(home0, { ...j, ...(ns || { status: result }), lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
305
+ } else {
306
+ await writeSchedule(home0, { ...j, status: result, lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
307
+ }
293
308
  continue;
294
309
  }
295
310
  continue; // worker 仍在跑:等它(外层 2s 轮询)
@@ -427,7 +442,7 @@ async function main() {
427
442
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
428
443
  // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
429
444
  // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
430
- const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
445
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir, cfg).catch(() => null);
431
446
  mcpManager = await Promise.race([
432
447
  mcpStartP,
433
448
  new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
@@ -617,13 +617,14 @@ export async function runRepl(ctx) {
617
617
  );
618
618
  } else io.print('尚无用量记录。');
619
619
  } else if (cmd === '/status') {
620
+ const sc = estimateCost(modelName, stats.promptTokens, stats.completionTokens);
620
621
  io.box('会话状态', [
621
622
  `模型 ${modelName} · 权限 ${permission.mode}`,
622
623
  `沙箱 ${cfg.sandbox || 'off'}${routing ? ` · 自动路由 ${routingEnabled ? '开' : '关'}(${routing.planner}⇄${routing.executor})` : ''}`,
623
624
  `会话 ${path.basename(session.file)}`,
624
625
  `轮次 ${stats.turns} · 消息 ${messages.length} 条`,
625
626
  `Tokens ↑${stats.promptTokens} ↓${stats.completionTokens}`,
626
- `费用 ≈¥${estimateCost(modelName, stats.promptTokens, stats.completionTokens).toFixed(5)}(累计·按当前模型计价)`,
627
+ `费用 ${sc == null ? '≈¥—(无价格数据)' : '≈¥' + sc.toFixed(5)}(累计·按当前模型计价)`,
627
628
  `计划模式 ${planMode ? '开' : '关'} · 思考显示 ${io.showReasoning ? '开' : '关'} · 任务 ${agent.getTodos().length} 项`,
628
629
  ]);
629
630
  } else if (cmd === '/cost') {
@@ -634,7 +635,7 @@ export async function runRepl(ctx) {
634
635
  `缓存命中率 ${bd.rate != null ? (bd.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}${bd.batchCost > 0 ? ` · Batch 半价任务 ≈¥${bd.batchCost.toFixed(5)}` : ''}`,
635
636
  ...bd.byModel.slice(0, 8).map((m) => ` ${m.model}:${m.turns} 轮(${m.batchTurns ? m.batchTurns + ' 批' : ''})· ↑${m.prompt} ↓${m.completion} · ≈¥${m.cost.toFixed(5)}${m.saved > 0 ? ` · 省 ¥${m.saved.toFixed(5)}` : ''}`),
636
637
  ]);
637
- const guard = costGuardStatus();
638
+ const guard = costGuardStatus(modelName);
638
639
  if (guard) {
639
640
  io.print(
640
641
  style(
@@ -643,7 +644,8 @@ export async function runRepl(ctx) {
643
644
  )
644
645
  );
645
646
  }
646
- io.print(style('会话内累计(本次)≈¥' + estimateCost(modelName, stats.promptTokens, stats.completionTokens).toFixed(5), C.dim));
647
+ const sc2 = estimateCost(modelName, stats.promptTokens, stats.completionTokens);
648
+ io.print(style('会话内累计(本次)' + (sc2 == null ? '≈¥—(无价格数据)' : '≈¥' + sc2.toFixed(5)), C.dim));
647
649
  } else if (cmd === '/cache') {
648
650
  const entries = listCacheStats();
649
651
  if (!entries.length) {
package/src/cost-guard.js CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import fs from 'node:fs';
8
8
  import { listCacheStats, cacheStatsFile } from './cachestats.js';
9
- import { beijingDayStart } from './pricing.js';
9
+ import { beijingDayStart, hasPricing } from './pricing.js';
10
10
  import { loadConfig } from './config.js';
11
11
 
12
12
  export function costGuardConfig() {
@@ -44,13 +44,22 @@ export function todayCost() {
44
44
  }
45
45
  }
46
46
 
47
- export function costGuardStatus() {
47
+ /**
48
+ * @param {any} [modelName] 实际使用模型;缺省回退 config.model(仪表盘/无会话上下文)
49
+ */
50
+ export function costGuardStatus(modelName) {
48
51
  const g = costGuardConfig();
49
52
  if (!g) return null;
50
53
  const cost = todayCost();
51
54
  const limit = Number(g.dailyLimitYuan) || 0;
52
55
  const warnAt = Number(g.warnAtYuan) || (limit > 0 ? limit * 0.8 : 0);
53
56
  const action = g.action === 'block' || g.action === 'downgrade' ? g.action : 'warn';
57
+ // P0-4(v0.4.5):当前模型无价格数据时费用护栏静默失效(estimateCost 恒 0、overLimit 恒 false)。
58
+ // 显式标记 noPricing,调用方据此告警而非静默放行。modelName 优先取「实际使用模型」(agent 传 activeModel),
59
+ // 缺省回退 config.model(仪表盘/无会话上下文);两者都无则无法判断,不误标 noPricing。
60
+ const cfg = loadConfig();
61
+ const model = modelName ?? cfg?.model ?? null;
62
+ const noPricing = limit > 0 && model != null && !hasPricing(model);
54
63
  return {
55
64
  cost,
56
65
  limit,
@@ -58,16 +67,27 @@ export function costGuardStatus() {
58
67
  action,
59
68
  downgradeModel: String(g.downgradeModel || 'deepseek-v4-flash'),
60
69
  degraded: cost === null, // 统计不可读:护栏降级为「无法判断」
61
- overWarn: cost !== null && limit > 0 && cost >= warnAt,
62
- overLimit: cost !== null && limit > 0 && cost >= limit,
70
+ noPricing, // 无价格数据:费用护栏无法累计,需显式告警
71
+ overWarn: cost !== null && !noPricing && limit > 0 && cost >= warnAt,
72
+ overLimit: cost !== null && !noPricing && limit > 0 && cost >= limit,
63
73
  };
64
74
  }
65
75
 
66
76
  // Agent 每轮开始前调用:返回 null 放行;blocked=true 应暂停本轮;downgrade=true 应切换便宜模型
67
- export function checkCostGuard() {
68
- const st = costGuardStatus();
77
+ /**
78
+ * @param {any} [modelName] 实际使用模型;缺省回退 config.model
79
+ */
80
+ export function checkCostGuard(modelName) {
81
+ const st = costGuardStatus(modelName);
69
82
  if (!st) return null;
70
83
  if (st.degraded) return null; // 统计不可读:不误拦也不放水(已告警),按无法判断处理
84
+ if (st.noPricing) {
85
+ // P0-4(v0.4.5):无价格数据时护栏无法累计——显式告警而非静默放行(绝不当「没花钱」)
86
+ return {
87
+ blocked: false,
88
+ message: '⚠ 费用护栏:当前模型无价格数据,今日费用无法累计、dailyLimitYuan 不生效——请在 config.pricing.overrides 为模型补充定价,或改用有价的 DeepSeek 模型(deepseek-v4-pro/flash)。',
89
+ };
90
+ }
71
91
  if (st.overLimit && st.action === 'block') {
72
92
  return {
73
93
  blocked: true,
package/src/hooks.js CHANGED
@@ -48,6 +48,7 @@ export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir, /** @t
48
48
  cwd: workingDir,
49
49
  env: childEnv,
50
50
  stdio: ['pipe', 'pipe', 'pipe'],
51
+ detached: true, // 评估 6.5:自成进程组,超时 process.kill(-pid) 整组清理(否则只杀 shell,孙进程孤儿)
51
52
  });
52
53
  let out = '';
53
54
  let err = '';
package/src/mcp.js CHANGED
@@ -8,6 +8,17 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import { spawn } from 'node:child_process';
11
+ import { isSensitiveEnv } from './tools/bash.js';
12
+
13
+ // 评估 6.2(v0.4.3):MCP 子进程不再继承完整 process.env(含 API Key)——与 bash/hooks 同口径
14
+ // 默认过滤敏感变量;config.mcpEnvKeep 按名放行、config.mcpEnvFilter=false 整体关闭。
15
+ function filteredProcessEnv(/** @type {Set<string>} */ keepSet) {
16
+ const out = /** @type {any} */ ({});
17
+ for (const [k, v] of Object.entries(process.env)) {
18
+ if (!isSensitiveEnv(k) || keepSet.has(k)) out[k] = v;
19
+ }
20
+ return out;
21
+ }
11
22
 
12
23
  // 客户端版本读 package.json(评估 P3-9:此前硬编码 '0.6.0' 与真实版本脱节)
13
24
  const CLIENT_VERSION = (() => {
@@ -25,11 +36,12 @@ const HANDSHAKE_TIMEOUT_MS = 20000;
25
36
  let nextId = 1;
26
37
 
27
38
  export class McpClient {
28
- constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {}, trusted = false }, /** @type {any} */ workingDir) {
39
+ constructor(/** @type {any} */ name, /** @type {any} */ { command, args = [], env = {}, trusted = false }, /** @type {any} */ workingDir, /** @type {any} */ baseEnv = process.env) {
29
40
  this.name = name;
30
41
  this.command = command;
31
42
  this.args = args;
32
43
  this.env = env;
44
+ this.baseEnv = baseEnv; // 过滤敏感变量后的基础环境(startMcpServers 注入)
33
45
  this.trusted = trusted === true; // v0.4.1 P0:仅 trusted 服务器的 readOnlyHint 才被信任自动放行
34
46
  this.workingDir = workingDir;
35
47
  this.tools = /** @type {any[]} */ ([]);
@@ -45,7 +57,7 @@ export class McpClient {
45
57
  if (this.child) return this;
46
58
  this.child = spawn(this.command, this.args, {
47
59
  cwd: this.workingDir,
48
- env: { ...process.env, ...this.env },
60
+ env: { ...this.baseEnv, ...this.env }, // 敏感变量已过滤(this.baseEnv),this.env 为用户显式覆盖
49
61
  stdio: ['pipe', 'pipe', 'pipe'],
50
62
  detached: true, // 自成进程组:stop 时整组清理(npx 孙进程不成孤儿)
51
63
  });
@@ -215,13 +227,16 @@ export class McpClient {
215
227
  }
216
228
 
217
229
  // 多服务器管理器:部分服务器启动失败不影响其余
218
- export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any} */ workingDir) {
230
+ // topCfg 为顶层 config.json(提供 mcpEnvKeep/mcpEnvFilter 环境过滤口径,与 bash/hooks 一致)
231
+ export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any} */ workingDir, /** @type {any} */ topCfg = {}) {
232
+ const keep = new Set((topCfg?.mcpEnvKeep || []).map(String));
233
+ const baseEnv = topCfg?.mcpEnvFilter === false ? process.env : filteredProcessEnv(keep);
219
234
  const clients = new Map();
220
235
  const entries = Object.entries(mcpCfg || {});
221
236
  await Promise.all(
222
237
  entries.map(async ([name, cfg]) => {
223
238
  if (!cfg || typeof cfg.command !== 'string' || !cfg.command.trim()) return;
224
- const client = new McpClient(name, cfg, workingDir);
239
+ const client = new McpClient(name, cfg, workingDir, baseEnv);
225
240
  clients.set(name, client);
226
241
  try {
227
242
  await client.start();
package/src/memory.js CHANGED
@@ -9,6 +9,7 @@ import path from 'node:path';
9
9
  import { mingdaoHome, ensureHome } from './config.js';
10
10
  import { beijingParts } from './pricing.js';
11
11
  import { tokenize } from './session-index.js';
12
+ import { atomicWriteFileSync } from './atomic-write.js';
12
13
 
13
14
  export function memoryFile() {
14
15
  return path.join(mingdaoHome(), 'AGENTS.md');
@@ -47,7 +48,7 @@ function backupMemory() {
47
48
  export function writeMemory(/** @type {any} */ content) {
48
49
  backupMemory();
49
50
  ensureHome();
50
- fs.writeFileSync(memoryFile(), String(content ?? ''));
51
+ atomicWriteFileSync(memoryFile(), String(content ?? ''));
51
52
  }
52
53
 
53
54
  // 去重:忽略日期前缀后内容相同的条目只保留第一条
@@ -70,7 +71,7 @@ export function dedupeMemory() {
70
71
  }
71
72
  if (removed > 0) {
72
73
  backupMemory();
73
- fs.writeFileSync(memoryFile(), kept.join('\n') + '\n');
74
+ atomicWriteFileSync(memoryFile(), kept.join('\n') + '\n');
74
75
  }
75
76
  return removed;
76
77
  }
@@ -91,7 +92,7 @@ export function removeMemoryLines(/** @type {any} */ keyword) {
91
92
  }
92
93
  if (removed > 0) {
93
94
  backupMemory();
94
- fs.writeFileSync(memoryFile(), kept.join('\n'));
95
+ atomicWriteFileSync(memoryFile(), kept.join('\n'));
95
96
  }
96
97
  return removed;
97
98
  }
@@ -117,7 +118,7 @@ export function appendJournal(/** @type {any} */ home, /** @type {any} */ entry)
117
118
  const raw = fs.readFileSync(journalFile(), 'utf8');
118
119
  const lines = raw.split('\n').filter(Boolean);
119
120
  if (lines.length > 600) {
120
- fs.writeFileSync(journalFile(), lines.slice(-500).join('\n') + '\n');
121
+ atomicWriteFileSync(journalFile(), lines.slice(-500).join('\n') + '\n');
121
122
  journalCount = 500;
122
123
  }
123
124
  } catch {}
@@ -294,7 +295,7 @@ export function dedupeProjectMemory(/** @type {any} */ workingDir) {
294
295
  kept.push(t);
295
296
  }
296
297
  if (removed > 0) {
297
- try { fs.writeFileSync(projectMemoryFile(workingDir), kept.join('\n') + '\n'); } catch {}
298
+ try { atomicWriteFileSync(projectMemoryFile(workingDir), kept.join('\n') + '\n'); } catch {}
298
299
  }
299
300
  return removed;
300
301
  }
package/src/model-caps.js CHANGED
@@ -7,6 +7,8 @@ import { modelPreset } from './models.js';
7
7
  // fc00::/7、fe80::/10、::、::1、IPv4-mapped)——此前只查 IPv4 与 ::1,IPv6 本地模型被误判远程
8
8
  // (超时档位错),且与 fetch 工具/SSRF 判定各维护一份、口径漂移。
9
9
  import { isPrivateHost } from './tools/fetch.js';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
10
12
 
11
13
  // 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
12
14
  export const UNKNOWN_LOCAL_WINDOW = 32768;
@@ -21,11 +23,45 @@ export const COMFORT_RATIO = 0.75;
21
23
  export const EDGE_RATIO = 0.85;
22
24
 
23
25
  /** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
26
+ // MacBook 本地 507 根因(v0.4.5):自定义主机名(如 mtplx.server.openai 经 /etc/hosts 指向 127.0.0.1)
27
+ // 的字面量判定抓不到——isPrivateHost(hostname) 对非 IP/非 localhost 恒 false,导致本地模型被误判远程:
28
+ // 只读子代理不串行(9 路大 prefill 并发击穿内存)、压缩触发线用远程档(0.8 而非 0.6)、超时档位错。
29
+ // 同步查 /etc/hosts(含 Windows)复检「主机名 → 私网/回环 IP」的映射,命中即视为本地。
30
+ const hostsCache = /** @type {Map<string, boolean>} */ (new Map()); // hostname → 是否 /etc/hosts 映射私网
31
+ function hostsMapsToPrivate(/** @type {string} */ hostname) {
32
+ if (hostsCache.has(hostname)) return Boolean(hostsCache.get(hostname));
33
+ let mapped = false;
34
+ try {
35
+ const file = process.platform === 'win32'
36
+ ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
37
+ : '/etc/hosts';
38
+ const text = fs.readFileSync(file, 'utf8');
39
+ for (const line of text.split(/\r?\n/)) {
40
+ const clean = line.replace(/#.*/, '').trim();
41
+ if (!clean) continue;
42
+ const parts = clean.split(/\s+/);
43
+ const ip = parts[0];
44
+ if (!ip || !isPrivateHost(ip)) continue;
45
+ if (parts.slice(1).some((h) => h.toLowerCase() === hostname)) {
46
+ mapped = true;
47
+ break;
48
+ }
49
+ }
50
+ } catch {
51
+ mapped = false; // 无 /etc/hosts 或不可读:维持字面量判定
52
+ }
53
+ hostsCache.set(hostname, mapped);
54
+ return mapped;
55
+ }
56
+
24
57
  export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
25
58
  try {
26
59
  const u = new URL(String(baseUrl || ''));
27
60
  if (!u.hostname) return false;
28
- return isPrivateHost(u.hostname);
61
+ const host = u.hostname.toLowerCase();
62
+ if (isPrivateHost(host)) return true;
63
+ // 自定义主机名(经 /etc/hosts 指向 127.0.0.1 等私网地址)复检;否则维持「远程」
64
+ return hostsMapsToPrivate(host);
29
65
  } catch {
30
66
  return false;
31
67
  }
@@ -41,7 +77,10 @@ export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ mode
41
77
  const preset = modelPreset(modelName);
42
78
  const cm = (cfg?.customModels || {})[modelName] || {};
43
79
  const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
44
- const isLocal = isLocalBaseUrl(baseUrl);
80
+ // 显式声明优先:customModels.<name>.local=true/isLocal=true 强制按本地模型处理(压缩/串行/超时走本地档),
81
+ // 覆盖 baseUrl 字面量/hosts 判定不到的场景(如经公网反代回本机、特殊主机名)。
82
+ const explicitLocal = cm.local === true || cm.isLocal === true;
83
+ const isLocal = explicitLocal || isLocalBaseUrl(baseUrl);
45
84
  const contextWindow =
46
85
  Number(cm.contextWindow) > 0
47
86
  ? Number(cm.contextWindow)
package/src/pricing.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { modelPreset } from './models.js';
10
+ import { atomicWriteFileSync } from './atomic-write.js';
10
11
  import { mingdaoHome } from './config.js';
11
12
 
12
13
  // 内置价格表的数据时点(定价可能调整,配置覆盖可随时更新)
@@ -63,7 +64,7 @@ export async function refreshPricingFromSource(cfg) {
63
64
  }
64
65
  const file = pricingFilePath();
65
66
  fs.mkdirSync(path.dirname(file), { recursive: true });
66
- fs.writeFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
67
+ atomicWriteFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
67
68
  extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
68
69
  const names = Object.keys(models).join('、');
69
70
  return { ok: true, lines: ['✓ 价格表已刷新(' + names + '),TTL 内费用估算/护栏/避峰自动跟随'] };
@@ -214,6 +215,13 @@ function pricingOverrides() {
214
215
  /**
215
216
  * @param {any} modelName
216
217
  */
218
+ // P0-4(v0.4.5):判断模型是否有价格数据(内置定价或外部定价或 overrides)——
219
+ // 无价模型 estimateCost 恒 0,费用护栏/仪表盘/分账静默失真,必须显式暴露给调用方告警。
220
+ export function hasPricing(/** @type {any} */ modelName) {
221
+ return Boolean(modelName && effectivePricing(modelName));
222
+ }
223
+
224
+ /** @param {any} modelName */
217
225
  function effectivePricing(modelName) {
218
226
  const preset = modelPreset(modelName);
219
227
  const ext = externalPricing().data?.models?.[modelName] || null;
@@ -250,7 +258,9 @@ function effectivePricing(modelName) {
250
258
  */
251
259
  export function estimateCost(modelName, promptTokens, completionTokens, cache = null, date = new Date()) {
252
260
  const pricing = effectivePricing(modelName);
253
- if (!pricing) return 0;
261
+ // P0-4(v0.4.5):无价返 null 而非 0——0 与「未知」语义完全混淆(0 被当作「免费/没花钱」),
262
+ // 下游护栏/分账/仪表盘据此静默失真。返回 null 强制调用方显式区分「未知」与「免费(0)」(技术评估 P0-4)。
263
+ if (!pricing) return null;
254
264
  const price = isPeakHour(date) ? pricing.peak : pricing.offpeak;
255
265
  if (cache && Number.isFinite(cache.hit) && Number.isFinite(cache.miss)) {
256
266
  const hitPrice = price.cacheHit ?? 0;
@@ -268,10 +278,11 @@ export function estimateCost(modelName, promptTokens, completionTokens, cache =
268
278
  * @param {any} [usage]
269
279
  */
270
280
  export function estimateCostLabel(modelName, promptTokens, completionTokens, usage = null) {
271
- const pricing = effectivePricing(modelName);
272
- if (!pricing) return '';
273
281
  const cache = cacheSplit(usage);
274
- const yuan = estimateCost(modelName, promptTokens, completionTokens, cache).toFixed(5);
282
+ const c = estimateCost(modelName, promptTokens, completionTokens, cache);
283
+ // P0-4(v0.4.5):无价返 null → 标签置空(不显示「≈¥0.0000」冒充免费)
284
+ if (c == null) return '';
285
+ const yuan = c.toFixed(5);
275
286
  const hitPart =
276
287
  cache && cache.hit + cache.miss > 0 ? ` · 缓存命中 ${(cache.rate * 100).toFixed(0)}%` : ' · 未计缓存折扣';
277
288
  return ` ≈¥${yuan}(${isPeakHour() ? '高峰' : '闲时'}${hitPart})${pricingDataStale() ? ' · ⚠ 价格表过期,运行 mingdao update --pricing 刷新' : ''}`;