mingdao-harness 0.4.4 → 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.4",
3
+ "version": "0.4.5",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -27,7 +27,7 @@ const SUBAGENT_MAX_STEPS = 24;
27
27
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
28
28
  * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
29
29
  * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
30
- * onUsage?: (usage: any) => void }} params
30
+ * onUsage?: (modelName: string, usage: any) => void }} params
31
31
  */
32
32
  export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage }) {
33
33
  const preset = modelPreset(modelName) || {};
@@ -112,6 +112,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
112
112
  maxSteps: SUBAGENT_MAX_STEPS,
113
113
  mcp,
114
114
  sessionRef, // 子代理的审计记录归入主会话
115
+ onUsage, // P1-5(v0.4.5):透传逐轮入账回调——子代理消耗计入今日费用(子代理内部 activeModel=subModel 正确归属)
115
116
  });
116
117
  const sys =
117
118
  `你是主智能体 MingDao 派出的子代理,独立完成一项子任务。` +
@@ -194,7 +195,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
194
195
  const inFlightCost = () => estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, null, new Date());
195
196
  const usedTodayWithInflight = () => {
196
197
  const today = todayCost();
197
- 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;
198
202
  };
199
203
  // 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
200
204
  // 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
@@ -305,8 +309,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
305
309
  for (const m of sanitized) promptTokens += messageTokens(m, count);
306
310
  const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
307
311
  const used = usedTodayWithInflight(); // 含本回合在途费用(防长回合烧穿)
308
- if (used == null) {
309
- // todayCost 读取失败:无法判断,跳过前置拦截(护栏主检查同样跳过并告警)
312
+ if (used == null || worst == null) {
313
+ // P0-4(v0.4.5):统计不可读 或 无价格数据(最坏成本未知)→ 无法判断,跳过前置拦截
314
+ // (护栏主检查 checkCostGuard 会显式告警「无价格数据」,此处不重复误拦也不静默放行)
310
315
  } else if (used + worst >= Number(g.dailyLimitYuan)) {
311
316
  stripOrphanCalls();
312
317
  return {
@@ -321,7 +326,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
321
326
  // 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
322
327
  // downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
323
328
  if (cfg.costGuard) {
324
- const guard = checkCostGuard();
329
+ const guard = checkCostGuard(activeModel);
325
330
  if (guard) {
326
331
  if (guard.blocked) {
327
332
  stripOrphanCalls();
@@ -410,6 +415,24 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
410
415
  stripOrphanCalls();
411
416
  return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true, durationMs: Date.now() - startedAt, perf: perf() };
412
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
+ }
413
436
  throw err;
414
437
  }
415
438
 
@@ -523,6 +546,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
523
546
  let allowed = false;
524
547
  if (isMcp && mcp?.isReadonly(name)) {
525
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 仍走权限询问
526
552
  } else {
527
553
  try {
528
554
  allowed = await permission.check(name, args);
@@ -740,9 +766,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
740
766
  }
741
767
  // v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
742
768
  if (round < maxRounds - 1 && !aborted) {
743
- // 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账
769
+ // 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账。activeModel 为本轮实际模型
770
+ // (护栏降级/子代理都据此正确归属——P2-5 模型名一致性 + P1-5 子代理费用入账)。
744
771
  try {
745
- onUsage?.({
772
+ onUsage?.(activeModel, {
746
773
  prompt_tokens: usage.prompt_tokens - roundUsageStart.prompt_tokens,
747
774
  completion_tokens: usage.completion_tokens - roundUsageStart.completion_tokens,
748
775
  prompt_cache_hit_tokens: (usage.prompt_cache_hit_tokens || 0) - roundUsageStart.prompt_cache_hit_tokens,
@@ -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 轮询)
@@ -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/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 刷新' : ''}`;
@@ -92,14 +92,20 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
92
92
  config: pc,
93
93
  async chat(/** @type {any} */ opts) {
94
94
  let attempt = 0;
95
+ // P1-7(v0.4.5):总量护栏提到重试循环外层——此前每 attempt 重建 totalTimer,重试序列最坏
96
+ // 放大 retries+1 倍(本地 3×30min=90min)。总时长覆盖整个重试序列,单次首 token/流式空闲仍按 attempt 计。
97
+ let totalExpired = false;
98
+ let currentAc = /** @type {AbortController | null} */ (null);
99
+ const totalTimer = setTimeout(() => {
100
+ totalExpired = true;
101
+ try {
102
+ currentAc?.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
103
+ } catch {}
104
+ }, totalMs);
95
105
  for (;;) {
96
106
  const ac = new AbortController();
97
- let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时
98
- // 总量护栏:整次请求(prefill+生成)的绝对上限
99
- const totalTimer = setTimeout(() => {
100
- timedOut = true;
101
- ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
102
- }, totalMs);
107
+ currentAc = ac;
108
+ let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
103
109
  // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
104
110
  let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
105
111
  timedOut = true;
@@ -125,6 +131,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
125
131
  const onUserAbort = () => ac.abort(opts.signal?.reason);
126
132
  if (opts.signal?.aborted) onUserAbort();
127
133
  else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
134
+ let leaving = true; // 本次 attempt 是否退出(成功/不再重试);将重试则置 false,总量计时器保留覆盖下一 attempt
128
135
  try {
129
136
  return await openaiChat({
130
137
  ...opts,
@@ -135,9 +142,11 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
135
142
  onActivity,
136
143
  });
137
144
  } catch (err) {
138
- // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
139
- const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
145
+ // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试。
146
+ // 总量超时(totalExpired)覆盖整个序列,超了直接抛不再重试。
147
+ const transient = !totalExpired && ((timedOut && !opts.signal?.aborted) || isTransient(err));
140
148
  if (!transient || attempt >= retries) throw err;
149
+ leaving = false; // 将重试:本次不退出,总量计时器继续覆盖下一 attempt
141
150
  attempt += 1;
142
151
  // 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
143
152
  // 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
@@ -147,7 +156,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
147
156
  backoff = Math.min(backoff, 30000);
148
157
  await sleep(backoff);
149
158
  } finally {
150
- clearTimeout(totalTimer);
159
+ if (leaving) clearTimeout(totalTimer); // 成功/最终失败即清——避免悬挂 totalMs 计时器(每请求一个)
151
160
  if (firstTokenTimer) clearTimeout(firstTokenTimer);
152
161
  if (idleTimer) clearTimeout(idleTimer);
153
162
  opts.signal?.removeEventListener('abort', onUserAbort);
@@ -6,6 +6,20 @@
6
6
  * @typedef {Error & { status?: number, headers?: Headers }} ApiError
7
7
  */
8
8
 
9
+ // 部分网关/本地推理框架在拒绝请求(如 507 memory_refusal)时不回非 2xx,而是回
10
+ // 200 + {"error":{"code":507,"message":"memory_refusal"}},或 SSE 首帧夹带 error 对象。
11
+ // 若不识别,会被当作「空输出」吞掉,误报成「模型本轮没有输出正文。」(MacBook 本地 mtplx 507 根因)。
12
+ function extractStreamError(/** @type {any} */ json) {
13
+ const err = json?.error;
14
+ if (!err) return null;
15
+ const code = Number(err?.code || err?.status_code || err?.status || 0);
16
+ const msg = String(err?.message || err?.type || '模型返回错误');
17
+ /** @type {ApiError} */
18
+ const e = new Error(`[流式响应错误${code ? ' ' + code : ''}] ${msg}`);
19
+ if (code) e.status = code;
20
+ return e;
21
+ }
22
+
9
23
  export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
10
24
  const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
11
25
  const payload = /** @type {Record<string, any>} */ ({ model, messages });
@@ -68,6 +82,8 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
68
82
  }
69
83
 
70
84
  export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
85
+ const se = extractStreamError(json);
86
+ if (se) throw se;
71
87
  const choice = json?.choices?.[0];
72
88
  const msg = choice?.message ?? {};
73
89
  if (msg.content) onDelta?.({ text: msg.content });
@@ -113,6 +129,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
113
129
  } catch {
114
130
  return;
115
131
  }
132
+ // 200 流里夹带 error 帧(本地内存拒绝等):立即上抛,不当作空输出吞掉
133
+ const se = extractStreamError(json);
134
+ if (se) throw se;
116
135
  // 注意:DeepSeek/OpenAI 流式最后一块常是 usage-only(choices 为空),必须先取 usage 再判 choices
117
136
  if (json.usage) usage = json.usage;
118
137
  const choice = json?.choices?.[0];
@@ -147,9 +166,31 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
147
166
  if (choice.finish_reason) finish = choice.finish_reason;
148
167
  };
149
168
 
169
+ // P1-6(v0.4.5):[DONE] 后不再无限等待网关关流——此前外层 for 继续 read 至流关闭,
170
+ // 部分网关/代理在 [DONE] 后不主动断开会挂到 streamIdleMs(120s)。但部分网关在 [DONE] 之后
171
+ // 才发 usage-only 终包(且末帧常不带换行),直接退出会丢 usage。因此进入「有界排空」:
172
+ // 继续读残余帧,每次 read 用短超时兜底,超时或流关闭即停止——usage 尾帧仍被捕获,
173
+ // 正常流在 [DONE] 后立即关流,Promise.race 随即以 done 返回,不增加延迟。
174
+ const DRAIN_TIMEOUT_MS = 500;
175
+ const readDrain = async () => {
176
+ let t;
177
+ const timer = new Promise((resolve) => {
178
+ t = setTimeout(() => resolve({ done: false, value: null, timedOut: true }), DRAIN_TIMEOUT_MS);
179
+ });
180
+ try {
181
+ return await Promise.race([reader.read(), timer]);
182
+ } finally {
183
+ clearTimeout(t);
184
+ }
185
+ };
150
186
  for (;;) {
151
- const { done, value } = await reader.read();
187
+ const { done, value } = await (doneFlag ? readDrain() : reader.read());
152
188
  if (done) break;
189
+ if (value == null) {
190
+ // 排空超时:不再等,取消 reader 释放连接(防火墙/网关持流不关的场景)
191
+ reader.cancel().catch(() => {});
192
+ break;
193
+ }
153
194
  buf += decoder.decode(value, { stream: true });
154
195
  let nl;
155
196
  while ((nl = buf.indexOf('\n')) >= 0) {
package/src/schedule.js CHANGED
@@ -171,7 +171,10 @@ export function removeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
171
171
  }
172
172
  }
173
173
  // 正在跑的 worker 同步停止,避免成孤儿继续执行
174
- if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
174
+ // P0-2(v0.4.5):killTask 失败不应阻断「删除」这一更强用户意图——包 try/catch 保证删除照常
175
+ try {
176
+ if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
177
+ } catch {}
175
178
  try {
176
179
  fs.unlinkSync(path.join(scheduleDir(home), id + '.json'));
177
180
  } catch {}
@@ -192,7 +195,10 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
192
195
  } catch {}
193
196
  }
194
197
  }
195
- if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
198
+ // P0-2(v0.4.5):killTask 失败不应阻断「暂停」的状态写
199
+ try {
200
+ if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
201
+ } catch {}
196
202
  writeSchedule(home, { ...job, status: 'paused', pid: null, lastTaskId: null });
197
203
  return true;
198
204
  });
@@ -354,6 +360,15 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
354
360
 
355
361
  const wait = (/** @type {any} */ ms) => new Promise((r) => setTimeout(r, ms));
356
362
 
363
+ // P1-15(v0.4.5):标记 running 前加锁并复查 paused——此前锁外 writeSchedule({...cur,'running'})
364
+ // 与 pauseSchedule 锁内写 paused 交错时,running 会覆盖 pause(任务继续触发,pause 语义失效)。
365
+ const markRunning = () => withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
366
+ const c = readSchedule(home, id);
367
+ if (!c || c.status === 'paused') return false; // 已删除或已暂停:不覆盖、不再触发
368
+ writeSchedule(home, { ...c, status: 'running' });
369
+ return true;
370
+ });
371
+
357
372
  // 依赖检查:支持两种依赖——调度任务 id(chain 编排)或后台任务 id(mingdao run 输出)
358
373
  async function depsSatisfied(/** @type {any} */ deps) {
359
374
  for (const dep of deps) {
@@ -376,8 +391,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
376
391
  // 顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
377
392
  if (job.offpeak && isPeakHour(new Date())) {
378
393
  const defer = deferToOffpeak(new Date());
379
- const curN = readSchedule(home, id);
380
- if (curN) writeSchedule(home, { ...curN, note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行)` });
394
+ // P2-4(v0.4.5):note 更新同样加锁 + 复查 paused(避免「读 curN 到写回之间 pause」被覆盖)
395
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
396
+ const curN = readSchedule(home, id);
397
+ if (curN && curN.status !== 'paused') writeSchedule(home, { ...curN, note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行)` });
398
+ });
381
399
  await wait(defer.getTime() - Date.now() + 2000);
382
400
  }
383
401
  if (job.after?.length) {
@@ -406,26 +424,32 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
406
424
  killTask(home, task.id);
407
425
  t = { ...t, status: 'timedout' };
408
426
  }
409
- const cur0 = readSchedule(home, id);
410
- if (!cur0) return 'failed'; // 任务已被删除:停止后续写入
411
- const history = [...(cur0?.history || [])];
412
- history.push({
413
- taskId: task.id,
414
- status: t?.status || 'unknown',
415
- at: Date.now(),
416
- durationMs: t?.durationMs ?? null,
417
- text: (t?.text || t?.error || '').slice(0, 200),
418
- });
419
- if (history.length > 50) history.shift();
420
427
  const result = t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
421
- writeSchedule(home, {
422
- ...cur0,
423
- lastRunAt: Date.now(),
424
- lastTaskId: task.id,
425
- runs: (cur0?.runs || 0) + 1,
426
- history,
427
- consecutiveFailures: result === 'done' ? 0 : prevFails + 1,
428
+ // 审计(H3 + 自检 P2):runOnce 收尾元数据读-改-写加锁——与 pause/remove(锁内写)跨进程互斥,
429
+ // 杜绝「读 cur0 到写回之间用户 pause」被覆盖(毫秒级窗口,彻底起见与终态写同锁、同复查)。
430
+ const wrote = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
431
+ const curL = readSchedule(home, id);
432
+ if (!curL) return false; // 已删除:停止后续写入
433
+ const historyL = [...(curL?.history || [])];
434
+ historyL.push({
435
+ taskId: task.id,
436
+ status: t?.status || 'unknown',
437
+ at: Date.now(),
438
+ durationMs: t?.durationMs ?? null,
439
+ text: (t?.text || t?.error || '').slice(0, 200),
440
+ });
441
+ if (historyL.length > 50) historyL.shift();
442
+ writeSchedule(home, {
443
+ ...curL,
444
+ lastRunAt: Date.now(),
445
+ lastTaskId: task.id,
446
+ runs: (curL?.runs || 0) + 1,
447
+ history: historyL,
448
+ consecutiveFailures: result === 'done' ? 0 : prevFails + 1,
449
+ });
450
+ return true;
428
451
  });
452
+ if (!wrote) return 'failed'; // 任务已被删除
429
453
  return result;
430
454
  };
431
455
 
@@ -438,7 +462,7 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
438
462
  await wait(Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000));
439
463
  continue;
440
464
  }
441
- writeSchedule(home, { ...cur, status: 'running' });
465
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused(被暂停则直接退出)
442
466
  const result = await runOnce();
443
467
  // 质检 H3:状态读-改-写加锁(与 pause/remove 互斥,防丢更新)
444
468
  const nextState = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
@@ -456,12 +480,14 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
456
480
  await wait(Math.min(cur.nextRunAt - now, 60000));
457
481
  continue;
458
482
  }
459
- writeSchedule(home, { ...cur, status: 'running' });
483
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused
460
484
  const result = await runOnce();
461
- // 评估 6.4(v0.4.3):once 最终状态读-改-写加锁(与 pause/remove 互斥,防 pause 被 done 覆盖)
485
+ // 评估 6.4(v0.4.3)+ P2-4(v0.4.5):once 最终状态读-改-写加锁,且 paused 不覆盖——
486
+ // 用户执行期间 pause(cur2.status==='paused')绝不被 done/failed 覆盖(every 经 postRunStatus 有防护,此处补齐)
462
487
  withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
463
488
  const cur2 = readSchedule(home, id);
464
- if (cur2) writeSchedule(home, { ...cur2, status: result });
489
+ if (!cur2 || cur2.status === 'paused') return;
490
+ writeSchedule(home, { ...cur2, status: result });
465
491
  });
466
492
  return;
467
493
  } else {
@@ -472,15 +498,21 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
472
498
  continue;
473
499
  }
474
500
  if (st === 'failed') {
475
- writeSchedule(home, { ...cur, status: 'skipped' });
501
+ // P2-4(v0.4.5):终态写加锁 + 复查 paused(依赖失败判 skipped 也不得覆盖 pause)
502
+ withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
503
+ const c2 = readSchedule(home, id);
504
+ if (!c2 || c2.status === 'paused') return;
505
+ writeSchedule(home, { ...c2, status: 'skipped' });
506
+ });
476
507
  return;
477
508
  }
478
- writeSchedule(home, { ...cur, status: 'running' });
509
+ if (!markRunning()) return; // P1-15:标记 running 前复查 paused
479
510
  const result = await runOnce();
480
- // 评估 6.4(v0.4.3):after 最终状态读-改-写加锁(同上)
511
+ // 评估 6.4(v0.4.3)+ P2-4(v0.4.5):after 最终状态读-改-写加锁,且 paused 不覆盖
481
512
  withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
482
513
  const cur2 = readSchedule(home, id);
483
- if (cur2) writeSchedule(home, { ...cur2, status: result });
514
+ if (!cur2 || cur2.status === 'paused') return;
515
+ writeSchedule(home, { ...cur2, status: result });
484
516
  });
485
517
  return;
486
518
  }
package/src/skill-lib.js CHANGED
@@ -188,10 +188,26 @@ export function validateSkillMarkdown(text, hint) {
188
188
  return { ok: true, name, description: desc };
189
189
  }
190
190
 
191
- /**
192
- * @param {any} dir
193
- * @param {any} hint
194
- */
191
+ // P1-5(v0.4.5):检测目录树内是否含符号链接——symlink 可越权读任意本机文件并架空 sha256 指纹检测。
192
+ function containsSymlink(/** @type {string} */ dir) {
193
+ const stack = [dir];
194
+ while (stack.length) {
195
+ const d = /** @type {string} */ (stack.pop());
196
+ let entries;
197
+ try {
198
+ entries = fs.readdirSync(d, { withFileTypes: true });
199
+ } catch {
200
+ continue;
201
+ }
202
+ for (const e of entries) {
203
+ if (e.isSymbolicLink()) return true;
204
+ if (e.isDirectory()) stack.push(path.join(d, e.name));
205
+ }
206
+ }
207
+ return false;
208
+ }
209
+
210
+ /** @param {any} dir @param {any} hint */
195
211
  export function validateSkillDir(dir, hint) {
196
212
  const skillMd = path.join(dir, 'SKILL.md');
197
213
  let text;
@@ -200,6 +216,9 @@ export function validateSkillDir(dir, hint) {
200
216
  } catch {
201
217
  return { error: `未找到 SKILL.md:${skillMd}` };
202
218
  }
219
+ if (containsSymlink(dir)) {
220
+ return { error: '技能目录含符号链接,已拒绝(防越权读取本机文件/绕过篡改检测)' };
221
+ }
203
222
  return validateSkillMarkdown(text, hint);
204
223
  }
205
224
 
package/src/skills.js CHANGED
@@ -63,7 +63,7 @@ export function tamperedSkillNames(/** @type {any} */ workingDir) {
63
63
  if (!e.isDirectory()) continue;
64
64
  const skillDir = path.join(dir, e.name);
65
65
  try {
66
- if (!fs.statSync(path.join(skillDir, 'SKILL.md')).isFile()) continue;
66
+ if (!fs.lstatSync(path.join(skillDir, 'SKILL.md')).isFile()) continue;
67
67
  } catch {
68
68
  continue;
69
69
  }
@@ -89,7 +89,7 @@ export function listSkills(/** @type {any} */ workingDir) {
89
89
  if (!e.isDirectory() || seen.has(e.name)) continue;
90
90
  const skillMd = path.join(dir, e.name, 'SKILL.md');
91
91
  try {
92
- if (!fs.statSync(skillMd).isFile()) continue;
92
+ if (!fs.lstatSync(skillMd).isFile()) continue;
93
93
  } catch {
94
94
  continue;
95
95
  }
@@ -104,7 +104,10 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
104
104
  if (title) renameSessionFile(fs, path, home, session, title);
105
105
  } catch {}
106
106
  }
107
- const finalStatus = res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
107
+ // P1-3(v0.4.5):capHit(跑满步数上限后收尾)语义是「未真正完成、可续跑」——此前 worker 不判
108
+ // capHit 直接判 done,后台任务/链式编排把未完成任务当成功(假完成)。
109
+ const finalStatus = res.capHit ? 'failed' : res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
110
+ if (res.capHit && !note) note = '达到步数上限,任务未完成(可续跑)。';
108
111
  recordUsage(res.perf?.usedModel || modelName, res.usage, /** @type {any} */ (res.perf));
109
112
  finish({
110
113
  status: finalStatus,
package/src/tasks.js CHANGED
@@ -115,7 +115,10 @@ function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
115
115
  try {
116
116
  owned = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').includes(id);
117
117
  } catch {}
118
- if (owned === true) { // Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
118
+ // P0-3(v0.4.5):owned===null(非 Linux /proc,或无法读取)时降级为「按 pid 存活即杀」——
119
+ // 此前 null 直接跳过,导致 macOS/Windows 上 kill 只改状态不杀进程、worker 继续跑完覆盖状态。
120
+ // 任务 id 含随机 + 启动时 pid,PID 复用误杀概率极低,且任务 id 本就是用户显式指定的目标。
121
+ if (owned === true || owned === null) {
119
122
  try {
120
123
  // worker 是 detached 进程(自成进程组):优先杀整组,避免工具子进程成孤儿
121
124
  process.kill(-t.pid, 'SIGTERM');
@@ -49,7 +49,6 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
49
49
  let cur = u;
50
50
  let res = /** @type {any} */ (null);
51
51
  for (let hop = 0; hop <= 5; hop++) {
52
- if (hop > 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
53
52
  const ch = String(cur.hostname || '').toLowerCase();
54
53
  let hopBlocked = isPrivateHost(ch);
55
54
  if (!hopBlocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
@@ -63,6 +62,8 @@ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx)
63
62
  if (hopBlocked) return { ok: false, error: `拒绝访问内网/本机地址(${ch})——SSRF 重定向防护。` };
64
63
  res = await fetch(cur, { signal: ac.signal, redirect: 'manual' });
65
64
  if (res.status >= 300 && res.status < 400) {
65
+ // P2-3(v0.4.5):原 `if(hop>5)` 死代码(hop 最大 5 永不触发)——超限应在 3xx 分支内判定
66
+ if (hop >= 5) return { ok: false, error: '重定向次数超过上限(5 跳)。' };
66
67
  const loc = res.headers.get('location');
67
68
  if (!loc) break; // 无 Location:按最终响应处理
68
69
  try {
package/src/tools/git.js CHANGED
@@ -3,6 +3,9 @@
3
3
  import { execFile } from 'node:child_process';
4
4
 
5
5
  const GIT_READONLY = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'branch', 'tag', 'ls-files', 'shortlog']);
6
+ // P1-8(v0.4.5):参数级过滤——此前只校验子命令首词,`diff --no-index` 可越界读任意文件(git∈
7
+ // READONLY_TOOLS 免权限确认)、`branch -D`/`tag -f/-d` 破坏元数据、`--output` 写文件全部放行。
8
+ const BANNED_FLAGS = new Set(['--no-index', '--output', '-o', '--delete', '-D', '-d', '--force', '-f', '-m', '--move', '-M', '--rename']);
6
9
 
7
10
  export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
8
11
  const command = String(args.command ?? '').trim();
@@ -14,6 +17,11 @@ export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
14
17
  if (!GIT_READONLY.has(sub)) {
15
18
  return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
16
19
  }
20
+ // P1-8(v0.4.5):拒绝破坏性/越界 flag——`--no-index` 越界读、`--output` 写、`-D/-d/-f/-m` 破坏元数据
21
+ const banned = argv.find((/** @type {string} */ a) => BANNED_FLAGS.has(a) || a.startsWith('--output='));
22
+ if (banned) {
23
+ return { ok: false, error: `git ${sub} 含被禁止的参数 ${banned}——只读工具不允许写文件/越界读/破坏元数据(写操作请用 bash)。` };
24
+ }
17
25
  // 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)——
18
26
  // v0.4.1 P2 修复:此前注释声明限量但无实现,git log -p 在大型仓库会撞 maxBuffer 4MB 报 ENOBUFS。
19
27
  let effectiveArgv = argv;
package/src/web/server.js CHANGED
@@ -85,15 +85,30 @@ function readBody(req, limit = 40 * 1024 * 1024) {
85
85
  /** @type {any[]} */
86
86
  const chunks = [];
87
87
  let size = 0;
88
+ let settled = false;
89
+ const settle = (/** @type {any} */ fn, /** @type {any} */ v) => {
90
+ if (settled) return;
91
+ settled = true;
92
+ clearTimeout(slowTimer);
93
+ fn(v);
94
+ };
88
95
  // 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
89
96
  const slowTimer = setTimeout(() => {
90
97
  const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传超时(60s)'));
91
98
  err.status = 408;
92
99
  req.destroy();
93
- reject(err);
100
+ settle(reject, err);
94
101
  }, 60000);
95
- req.on('end', () => clearTimeout(slowTimer));
96
- req.on('close', () => clearTimeout(slowTimer));
102
+ // P1-6(v0.4.5):body 上传中断(客户端刷新/abort)时 Node 通常只触发 close 不发 error/end——
103
+ // 此前 close clearTimeout 不 reject → Promise 永久 pending → /api/chat 的 inflight 槽永久泄漏,
104
+ // 重复 N 次后服务对所有会话 429。close 且未 settle 时 reject(status 499)。
105
+ req.on('close', () => {
106
+ if (!settled) {
107
+ const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传中断(客户端断开)'));
108
+ err.status = 499;
109
+ settle(reject, err);
110
+ }
111
+ });
97
112
  req.on('data', (/** @type {any} */ d) => {
98
113
  size += d.length;
99
114
  if (size > MAX_BODY) {
@@ -102,7 +117,7 @@ function readBody(req, limit = 40 * 1024 * 1024) {
102
117
  // 排空残余数据但保留连接:让上层 catch 返回真 413(此前 destroy 导致客户端只收到连接重置)
103
118
  req.pause();
104
119
  req.resume();
105
- reject(err);
120
+ settle(reject, err);
106
121
  return;
107
122
  }
108
123
  chunks.push(d);
@@ -110,12 +125,12 @@ function readBody(req, limit = 40 * 1024 * 1024) {
110
125
  req.on('end', () => {
111
126
  const raw = Buffer.concat(chunks).toString('utf8');
112
127
  try {
113
- resolve(raw ? JSON.parse(raw) : {});
128
+ settle(resolve, raw ? JSON.parse(raw) : {});
114
129
  } catch {
115
- resolve({});
130
+ settle(resolve, {});
116
131
  }
117
132
  });
118
- req.on('error', reject);
133
+ req.on('error', (/** @type {any} */ e) => settle(reject, e));
119
134
  });
120
135
  }
121
136
 
@@ -513,14 +528,15 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
513
528
  return;
514
529
  }
515
530
  // v0.4.4:长任务费用逐轮入账——记录已按轮入账的累计 usage,最终补记最后一轮 + 兜底总结的剩余。
531
+ // v0.4.5:onUsage 收 (modelName, delta)——主/子代理各自按实际模型归属(子代理经 spawnTask 透传,费用不漏计)。
516
532
  const recorded = { prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0 };
517
- const onUsage = (/** @type {any} */ delta) => {
533
+ const onUsage = (/** @type {string} */ modelName, /** @type {any} */ delta) => {
518
534
  recorded.prompt_tokens += delta?.prompt_tokens || 0;
519
535
  recorded.completion_tokens += delta?.completion_tokens || 0;
520
536
  recorded.prompt_cache_hit_tokens += delta?.prompt_cache_hit_tokens || 0;
521
537
  recorded.prompt_cache_miss_tokens += delta?.prompt_cache_miss_tokens || 0;
522
538
  try {
523
- recordUsage(runModel, delta, null);
539
+ recordUsage(modelName, delta, null);
524
540
  } catch {}
525
541
  };
526
542
  const agent = createAgent({