mingdao-harness 0.2.6 → 0.2.7

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.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,13 +11,14 @@ description: 准备发布新版本、打包交付前使用
11
11
  2. **版本**:版本号统一(package.json / 配置 / 文档互相一致),遵循语义化版本。
12
12
  3. **变更说明**:changelog 用用户语言描述新增/修复/破坏性变更。
13
13
  4. **构建验证**:实际构建/打包一次,并在干净环境验证产物可安装可运行。
14
- 5. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
14
+ 5. **发布前自检(常设步骤,2026-09-02 起)**:不依赖外部评估报告,纯代码审查主动找 bug——重点覆盖本次改动的调用链、锁/并发、权限/安全、跨平台与「打包期无声、运行期爆炸」类边界;每一条发现必须给出**文件+行号证据并按严重度排序**(高危优先修,不可带着已知高危发布)。历史战例:v0.2.6 自检一次捞出 6 条(含外部六份评估全漏的 `schedule stop` 双守护进程 bug)与 2 条自改回归。
15
+ 6. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
15
16
  - CI 已内置:desktop.yml 在 Linux 腿用 xvfb 运行 `dist/linux-unpacked/mingdao --no-sandbox`(`MINGDAO_DESKTOP_SMOKE=1`),模块求值通过输出 `MINGDAO_DESKTOP_SMOKE_OK` 并退出 0,否则 workflow 失败;
16
17
  - 本地有显示器时可加验:`cd desktop && MINGDAO_DESKTOP_SMOKE=1 dist/linux-unpacked/mingdao --no-sandbox`(AppImage 加 `--appimage-extract-and-run`);
17
18
  - 教训:0.2.2 三平台启动即崩(main.js 顶层调用 createLogWriter 漏导入),打包期不报错、运行期才炸——静态护栏(test/smoke.js「桌面主进程静态护栏」组)与打包冒烟双保险。
18
- 6. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
19
- 7. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
20
- 8. **发布后冒烟**:发布完成后立即做一次端到端验证。
19
+ 7. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
20
+ 8. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
21
+ 9. **发布后冒烟**:发布完成后立即做一次端到端验证。
21
22
 
22
23
  ## 桌面版发布流水线(官网分发 + 镜像 + 转运附件回收)
23
24
 
package/src/cli.js CHANGED
@@ -257,8 +257,16 @@ async function main() {
257
257
  const { readTask } = await import('./tasks.js');
258
258
  const handled = new Set();
259
259
  const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
260
+ const nonce = String(opts.prompt[1] || '');
260
261
  try {
261
262
  for (;;) {
263
+ // 守护进程租约自检(2026-09-03,防双 daemon):pidfile 不再等于「我的 pid nonce」
264
+ // 即视为已被 stop 或已被新 daemon 取代——立即退出,绝不与新 daemon 并跑重复执行任务
265
+ try {
266
+ if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() !== `${process.pid} ${nonce}`) break;
267
+ } catch {
268
+ break; // pidfile 被删 = 已被 stop
269
+ }
262
270
  const jobs = listSchedules(home0);
263
271
  if (!jobs.some((j) => j.status === 'pending' || j.status === 'running')) break;
264
272
  for (const j of jobs) {
@@ -295,8 +303,12 @@ async function main() {
295
303
  await new Promise((r) => setTimeout(r, 2000));
296
304
  }
297
305
  } finally {
306
+ // 只删除「仍指向自己」的 pidfile:租约被改写/被新 daemon 取代而退出时,
307
+ // 绝不误删新 daemon 的 pidfile(否则新 daemon 下一次租约检查会连锁退出,双守护全灭)
298
308
  try {
299
- fs.rmSync(daemonPidFile(home0), { force: true });
309
+ if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() === `${process.pid} ${nonce}`) {
310
+ fs.rmSync(daemonPidFile(home0), { force: true });
311
+ }
300
312
  } catch {}
301
313
  }
302
314
  return;
@@ -368,11 +380,14 @@ async function main() {
368
380
  },
369
381
  };
370
382
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
371
- // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
383
+ // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
384
+ // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
385
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
372
386
  mcpManager = await Promise.race([
373
- startMcpServers(cfg.mcpServers, workingDir).catch(() => null),
387
+ mcpStartP,
374
388
  new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
375
389
  ]);
390
+ if (!mcpManager) mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
376
391
  if (mcpManager) {
377
392
  const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
378
393
  if (io && !opts.prompt.length) {
package/src/log-writer.js CHANGED
@@ -9,7 +9,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
9
9
  try {
10
10
  fs.mkdirSync(path.dirname(file), { recursive: true });
11
11
  const line = new Date().toISOString() + ' ' + String(msg) + '\n';
12
- fs.appendFileSync(file, line);
12
+ // 自查 #5:日志可能含工具参数等敏感信息——创建与轮转一律 600(多用户机器防同机读取)
13
+ if (!fs.existsSync(file)) fs.appendFileSync(file, line, { mode: 0o600 });
14
+ else fs.appendFileSync(file, line);
13
15
  const st = fs.statSync(file);
14
16
  if (st.size > maxBytes) {
15
17
  const raw = fs.readFileSync(file, 'utf8');
@@ -17,8 +19,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
17
19
  const nl = raw.indexOf('\n', cut);
18
20
  const keep = nl === -1 ? raw.slice(cut) : raw.slice(nl + 1); // 行对齐保留尾部
19
21
  const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
20
- fs.writeFileSync(tmp, keep);
22
+ fs.writeFileSync(tmp, keep, { mode: 0o600 }); // 轮转重建保持 600
21
23
  fs.renameSync(tmp, file);
24
+ try { fs.chmodSync(file, 0o600); } catch {} // rename 后兜底收权(历史 644 日志迁移)
22
25
  }
23
26
  } catch {
24
27
  // 日志失败绝不抛错(best-effort)
package/src/pricing.js CHANGED
@@ -42,9 +42,18 @@ export function pricingDataStale() { return externalPricing().stale; }
42
42
  export async function refreshPricingFromSource(cfg) {
43
43
  const src = String(cfg?.pricing?.source || '').trim();
44
44
  if (!src) return { ok: false, lines: ['未配置 pricing.source(config.json 的 pricing.source 填官方价格 JSON 地址后重试)'] };
45
+ // 自查 #4:与 sync/自定义模型同类的第三外联入口——协议白名单 + 响应大小上限(防误配/恶意源灌内存)
46
+ try {
47
+ const u = new URL(src);
48
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, lines: ['价格源仅支持 http/https 地址'] };
49
+ } catch {
50
+ return { ok: false, lines: ['价格源必须是合法的 http(s) URL'] };
51
+ }
45
52
  const res = await fetch(src, { signal: AbortSignal.timeout(15000) });
46
53
  if (!res.ok) return { ok: false, lines: ['价格源请求失败:HTTP ' + res.status] };
47
- const d = await res.json();
54
+ const buf = await res.arrayBuffer();
55
+ if (buf.byteLength > 512 * 1024) return { ok: false, lines: ['价格源响应超过 512KB 上限,已拒绝'] };
56
+ const d = JSON.parse(Buffer.from(buf).toString('utf8'));
48
57
  const models = d && typeof d === 'object' && !Array.isArray(d) ? /** @type {any} */ (d).models : null;
49
58
  if (!models || typeof models !== 'object' || Array.isArray(models)) {
50
59
  return { ok: false, lines: ['价格源格式不符:应为 {"models": {"模型名": {"input":…,"output":…,"cacheHit":…,"peak":{…}}}}'] };
package/src/prompts.js CHANGED
@@ -14,7 +14,7 @@ const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI
14
14
  2. 精准修改:优先用 edit 做小步精确替换;新建文件用 write;保持改动最小化;改错可用 undo 撤销。
15
15
  3. 大内容分批写:单个 write 的参数总长控制在 6000 字符以内;大文件先写核心骨架,再分多次 write/edit 逐步补充——单次输出超限会被截断并导致参数解析失败。
16
16
  4. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
17
- 5. 委托与专注:独立、可并行的子问题(调研、复核、独立实现)用 task 委托子代理,把结果拿回来继续主线。
17
+ 5. 委托与专注:把「互不依赖、可并行」的子问题(同时调研多个方向、复核多份文件、独立实现多模块)用 task 委托子代理;多个只读调研子代理应在同一轮一起派发(readOnly=true,自动并行,效率最高);依赖当前对话细节的小改动自己做,不要委托。子代理返回后拿结果继续主线。
18
18
  6. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
19
19
  7. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
20
20
  8. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
package/src/schedule.js CHANGED
@@ -293,7 +293,9 @@ export function daemonAlive(/** @type {any} */ home) {
293
293
  }
294
294
  export function stopDaemon(/** @type {any} */ home) {
295
295
  try {
296
- const pid = Number(fs.readFileSync(daemonPidFile(home), 'utf8'));
296
+ // pidfile 格式 "<pid> <nonce>"——必须取首段(此前整串 Number()=NaN,SIGTERM 永远不发,
297
+ // 只删 pidfile → 孤儿 daemon 继续跑,新 daemon 再被拉起 → 双守护重复执行任务)
298
+ const pid = Number(String(fs.readFileSync(daemonPidFile(home), 'utf8')).trim().split(/\s+/)[0]);
297
299
  if (pid) {
298
300
  try {
299
301
  process.kill(pid, 'SIGTERM');
@@ -83,9 +83,9 @@ const SKILL_SCHEMA = {
83
83
  const TASK_SCHEMA = {
84
84
  type: 'object',
85
85
  properties: {
86
- description: { type: 'string', description: '子任务一句话描述。' },
87
- prompt: { type: 'string', description: '子代理的完整任务说明。' },
88
- readOnly: { type: 'boolean', description: '只读调研任务,可并行。' },
86
+ description: { type: 'string', description: '子任务一句话描述(用于进度展示)。' },
87
+ prompt: { type: 'string', description: '子代理的完整任务说明(全新上下文,需自带全部必要背景)。' },
88
+ readOnly: { type: 'boolean', description: '只读调研任务(不写文件不执行命令)。多个 readOnly 子任务同一轮派发会并行执行,效率最高。' },
89
89
  },
90
90
  required: ['prompt'],
91
91
  };
@@ -185,7 +185,7 @@ const TOOLS = [
185
185
  type: 'function',
186
186
  function: {
187
187
  name: 'task',
188
- description: '把独立子任务委托给新上下文子代理并返回汇报;readOnly 只读任务可并行。',
188
+ description: '把独立、可并行的子任务委托给全新上下文子代理并返回汇报。适合:多方向调研/复核/独立实现等互不依赖的任务(多个只读调研子任务应同一轮一起派发,自动并行,效率最高);不适合:依赖当前对话细节的小改动。',
189
189
  parameters: TASK_SCHEMA,
190
190
  },
191
191
  },
package/src/web/app.js CHANGED
@@ -101,6 +101,7 @@ $('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#di
101
101
  $('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
102
102
  const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
103
103
  let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
104
+ let bgTasks=[]; // 后台任务列表快照(chip tooltip 详情用,updateTasksPanel 每 2s 刷新)
104
105
  let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
105
106
  const sessionSubs=[]; // 本会话全部子代理(task 工具)条目:{seq, question, result, msg}
106
107
  // 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
@@ -421,9 +422,13 @@ function renderWorkStatus(){
421
422
  let html='';
422
423
  if(generating){
423
424
  const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
424
- html='<span class="ws-busy"><span class="spinner"></span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步</span>';
425
+ html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子代理':'')+'</span></span>';
425
426
  } else if(bgRunning>0){
426
- html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中(下载/定时任务等)— 点击查看,完成后会自动提示</span>';
427
+ // 后台任务 chip(非顶部,位于输入框上方):计数 + 最新任务 + 悬浮详情 tooltip,点击打开详情面板
428
+ const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
429
+ const latest=running.length?running[running.length-1]:null;
430
+ const tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
431
+ html='<span class="ws-bg" title="'+esc(String(tip)).replace(/"/g,'&quot;')+'">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
427
432
  }
428
433
  el.style.display=html?'flex':'none';
429
434
  el.innerHTML=html;
@@ -553,6 +558,7 @@ async function updateTasksPanel(){
553
558
  const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
554
559
  // 质检(等待状态静默):后台任务(worker/调度)并入计数与面板;状态转换时在聊天区弹可见横幅
555
560
  bgRunning=(Number(j.running)||0)+(Number(j.bgRunning)||0);
561
+ bgTasks=j.background||[];
556
562
  for(const t of (j.background||[])){
557
563
  const prev=lastBgStatus.get(t.id);
558
564
  if(prev && prev.status==='running' && t.status!=='running'){
@@ -591,7 +597,7 @@ async function refreshCostBadge(){
591
597
  const bd=j.breakdown||{}; const gd=j.guard||null;
592
598
  let t='今日 ≈¥'+(bd.today||0).toFixed(4);
593
599
  if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
594
- if(gd&&gd.limit>0) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
600
+ if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
595
601
  $('#costBadge').textContent=t;
596
602
  }
597
603
  refreshCostBadge(); setInterval(refreshCostBadge, 15000);
@@ -62,7 +62,8 @@ header select{flex:none}
62
62
  #tasksPanel{position:fixed;right:0;top:52px;bottom:0;width:280px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
63
63
  /* 输入框上方工作状态条(审计:长任务静默硬伤——实时显示执行步数/耗时与后台任务数)。
64
64
  与输入框同宽对齐(复用 #composer 的居中容器),不再横贯整个聊天窗口 */
65
- #workStatus{max-width:860px;margin:0 auto 4px;display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;font-size:12.5px;color:var(--dim);flex:none}
65
+ #workStatus{width:fit-content;max-width:100%;margin:0 auto 4px;display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;font-size:12.5px;color:var(--dim);flex:none;overflow:hidden}
66
+ #workStatus .ws-busy{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
66
67
  /* 状态条 busy 行:承载完整阶段信息(合并原顶部活动条),与输入框同列不遮挡聊天区 */
67
68
  #workStatus .ws-phase{color:var(--accent2);font-weight:600;white-space:nowrap}
68
69
  /* 左侧轨迹固定栏 + 右侧子代理面板(布局重构:轨迹在左、子代理在右) */
package/src/web/server.js CHANGED
@@ -195,11 +195,16 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
195
195
  /** @type {any} */
196
196
  let mcpManager = null;
197
197
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
198
+ // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
199
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
198
200
  mcpManager = await Promise.race([
199
- startMcpServers(cfg.mcpServers, workingDir).catch(() => null),
201
+ mcpStartP,
200
202
  new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
201
203
  ]);
202
- if (!mcpManager) console.error('[MingDao] ⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao web 可重试)');
204
+ if (!mcpManager) {
205
+ console.error('[MingDao] ⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao web 可重试)');
206
+ mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
207
+ }
203
208
  }
204
209
  const mcpFacade = {
205
210
  toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),