mingdao-harness 0.4.3 → 0.4.4
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 +1 -1
- package/presets/local-audit.json +2 -2
- package/src/agent.js +18 -3
- package/src/cli.js +1 -1
- package/src/hooks.js +1 -0
- package/src/mcp.js +19 -4
- package/src/schedule.js +26 -17
- package/src/tasks/worker.js +1 -1
- package/src/web/app.js +18 -7
- package/src/web/attachments.js +2 -1
- package/src/web/routes/domains/workspace.js +3 -0
- package/src/web/server.js +42 -4
- package/src/workspace.js +5 -9
package/package.json
CHANGED
package/presets/local-audit.json
CHANGED
|
@@ -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
|
|
29
|
+
* cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
|
|
30
|
+
* onUsage?: (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
|
-
|
|
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 || ''));
|
|
@@ -220,6 +223,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
220
223
|
const turnToolCache = new Map();
|
|
221
224
|
for (round = 0; round < maxRounds; round++) {
|
|
222
225
|
steps = 0;
|
|
226
|
+
// v0.4.4:每轮结束回调本轮增量 usage(长任务费用逐轮入账——此前只在 runTurn 全结束后才
|
|
227
|
+
// recordUsage,长任务期间「今日费用」恒为 0 被误读为「无统计」;中断时已完成轮次费用也保留)。
|
|
228
|
+
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
229
|
while (steps < stepLimit) {
|
|
224
230
|
steps += 1;
|
|
225
231
|
// 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
|
|
@@ -734,6 +740,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
734
740
|
}
|
|
735
741
|
// v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
|
|
736
742
|
if (round < maxRounds - 1 && !aborted) {
|
|
743
|
+
// 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账
|
|
744
|
+
try {
|
|
745
|
+
onUsage?.({
|
|
746
|
+
prompt_tokens: usage.prompt_tokens - roundUsageStart.prompt_tokens,
|
|
747
|
+
completion_tokens: usage.completion_tokens - roundUsageStart.completion_tokens,
|
|
748
|
+
prompt_cache_hit_tokens: (usage.prompt_cache_hit_tokens || 0) - roundUsageStart.prompt_cache_hit_tokens,
|
|
749
|
+
prompt_cache_miss_tokens: (usage.prompt_cache_miss_tokens || 0) - roundUsageStart.prompt_cache_miss_tokens,
|
|
750
|
+
});
|
|
751
|
+
} catch {}
|
|
737
752
|
const art = deliverables.length ? '已交付文件:' + deliverables.join('、') + '。' : '';
|
|
738
753
|
messages.push({
|
|
739
754
|
role: 'user',
|
package/src/cli.js
CHANGED
|
@@ -427,7 +427,7 @@ async function main() {
|
|
|
427
427
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
428
428
|
// A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
|
|
429
429
|
// 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
|
|
430
|
-
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
|
|
430
|
+
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir, cfg).catch(() => null);
|
|
431
431
|
mcpManager = await Promise.race([
|
|
432
432
|
mcpStartP,
|
|
433
433
|
new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
|
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: { ...
|
|
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
|
-
|
|
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/schedule.js
CHANGED
|
@@ -199,19 +199,22 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
export function resumeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
next = job.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
202
|
+
// 评估 6.3(v0.4.3):读-改-写序列加锁,与 remove/pause 及 sleeper 状态写互斥(防丢更新)
|
|
203
|
+
return withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
204
|
+
const job = readSchedule(home, id);
|
|
205
|
+
if (!job || job.status !== 'paused') return false;
|
|
206
|
+
let next = job.nextRunAt;
|
|
207
|
+
if (job.kind === 'every') {
|
|
208
|
+
next = job.anchor ? nextAnchorAfter(job.anchor, job.interval) || Date.now() + job.interval : Date.now() + job.interval;
|
|
209
|
+
} else if (job.kind === 'once') {
|
|
210
|
+
if (next && next <= Date.now()) next = Date.now() + 30000; // 已过期的一次性任务恢复后 30s 执行
|
|
211
|
+
} else next = Date.now();
|
|
212
|
+
const nextJob = { ...job, status: 'pending', nextRunAt: next };
|
|
213
|
+
writeSchedule(home, nextJob);
|
|
214
|
+
// 审计修复:守护进程在时只更新状态交给 daemon 接管;否则旧式 sleeper 兜底(避免双跑)
|
|
215
|
+
if (process.env.MINGDAO_NO_DAEMON === '1' || !daemonAlive(home)) spawnSleeper(home, nextJob);
|
|
216
|
+
return true;
|
|
217
|
+
});
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
// 每日锚点:锚点时刻(HH:MM)对齐到 now 之后的最近一次
|
|
@@ -455,8 +458,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
455
458
|
}
|
|
456
459
|
writeSchedule(home, { ...cur, status: 'running' });
|
|
457
460
|
const result = await runOnce();
|
|
458
|
-
|
|
459
|
-
|
|
461
|
+
// 评估 6.4(v0.4.3):once 最终状态读-改-写加锁(与 pause/remove 互斥,防 pause 被 done 覆盖)
|
|
462
|
+
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
463
|
+
const cur2 = readSchedule(home, id);
|
|
464
|
+
if (cur2) writeSchedule(home, { ...cur2, status: result });
|
|
465
|
+
});
|
|
460
466
|
return;
|
|
461
467
|
} else {
|
|
462
468
|
// after:轮询依赖,满足即执行一次后结束;任一依赖失败则跳过
|
|
@@ -471,8 +477,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
471
477
|
}
|
|
472
478
|
writeSchedule(home, { ...cur, status: 'running' });
|
|
473
479
|
const result = await runOnce();
|
|
474
|
-
|
|
475
|
-
|
|
480
|
+
// 评估 6.4(v0.4.3):after 最终状态读-改-写加锁(同上)
|
|
481
|
+
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
482
|
+
const cur2 = readSchedule(home, id);
|
|
483
|
+
if (cur2) writeSchedule(home, { ...cur2, status: result });
|
|
484
|
+
});
|
|
476
485
|
return;
|
|
477
486
|
}
|
|
478
487
|
}
|
package/src/tasks/worker.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
|
|
|
68
68
|
if (mcpManager) mcpManager.stop();
|
|
69
69
|
},
|
|
70
70
|
};
|
|
71
|
-
startMcpServers(cfg.mcpServers, workingDir).then((m) => (mcpManager = m)).catch(() => {});
|
|
71
|
+
startMcpServers(cfg.mcpServers, workingDir, cfg).then((m) => (mcpManager = m)).catch(() => {});
|
|
72
72
|
}
|
|
73
73
|
const sessionRef = /** @type {{name: any}} */ ({ name: null });
|
|
74
74
|
let persistedCount = 0;
|
package/src/web/app.js
CHANGED
|
@@ -399,10 +399,14 @@ async function send(){
|
|
|
399
399
|
else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
|
|
400
400
|
});
|
|
401
401
|
}catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
|
|
402
|
-
// 「手点停止 / 看门狗 / fetch
|
|
403
|
-
|
|
402
|
+
// 「手点停止 / 看门狗 / fetch 流静默断裂」。v0.4.3 修:第二参改 JSON 字符串——Electron 日志
|
|
403
|
+
// 对对象只会落 [object Object],异常细节丢失。
|
|
404
|
+
const diag = { name: e && e.name, message: e && e.message, err: String(e), taskId, rawLen: raw.length, steps: stepsCount, elapsedS: Math.round((Date.now() - workT0) / 1000) };
|
|
405
|
+
console.error('[MingDao] chat 流异常 ' + JSON.stringify(diag));
|
|
404
406
|
const d=document.createElement('div'); d.className='errline';
|
|
405
|
-
|
|
407
|
+
// 统一 catch 文案(v0.4.3):非用户中断/看门狗时,无论有无 e.message 都优先给「可续跑」的友好提示,
|
|
408
|
+
// 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
|
|
409
|
+
d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
|
|
406
410
|
msg.appendChild(d); scroll(); }
|
|
407
411
|
finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); activeAiMsg=null; curWorkT0=0; renderWorkStatus(); scroll(); updateTasksPanel(); }
|
|
408
412
|
}
|
|
@@ -583,10 +587,17 @@ setInterval(updateTasksPanel, 2000);
|
|
|
583
587
|
async function refreshCostBadge(){
|
|
584
588
|
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
585
589
|
const j=await r.json().catch(()=>null); if(!j) return;
|
|
586
|
-
const bd=j.breakdown||{}; const gd=j.guard||null;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
+
const bd=j.breakdown||{}; const gd=j.guard||null; const sm=j.summary||{};
|
|
591
|
+
// v0.4.3:本地/自定义模型无定价时费用恒 0——此前显示「¥0.0000」被误读为「无统计」。
|
|
592
|
+
// 有用量但累计费用为 0 即判定为无计价模型,明确标注而非含糊 ¥0。
|
|
593
|
+
let t;
|
|
594
|
+
if ((sm.turns||0) > 0 && (bd.totalCost||0) === 0) {
|
|
595
|
+
t='📊 本地模型不计费 · 累计 ↑'+(sm.prompt||0)+' ↓'+(sm.completion||0)+' tokens';
|
|
596
|
+
} else {
|
|
597
|
+
t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
598
|
+
if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
|
|
599
|
+
if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
600
|
+
}
|
|
590
601
|
$('#costBadge').textContent=t;
|
|
591
602
|
if($('#dashPanel').style.display==='flex') renderDashboard(j);
|
|
592
603
|
}
|
package/src/web/attachments.js
CHANGED
|
@@ -30,7 +30,8 @@ export function buildUserContent(/** @type {any} */ message, /** @type {any} */
|
|
|
30
30
|
} else if (a.type === 'text') {
|
|
31
31
|
const content = String(a.content ?? '');
|
|
32
32
|
if (!content.trim()) continue;
|
|
33
|
-
|
|
33
|
+
// 评估 6.7(v0.4.3):上限按字节数而非字符数(中文每字 3 字节,字符数口径会放大到声明值 3 倍)
|
|
34
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_TEXT_BYTES) {
|
|
34
35
|
return { error: `文本文件过大:${a.name || '未命名'}(≤200KB)` };
|
|
35
36
|
}
|
|
36
37
|
finalText += `${finalText ? '\n\n' : ''}[文件 ${a.name || '未命名'}]\n${content}`;
|
|
@@ -85,6 +85,9 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
85
85
|
if (method === 'GET' && p === '/api/fs-browse') {
|
|
86
86
|
let dir = String(url.searchParams.get('dir') || '').trim();
|
|
87
87
|
if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
|
|
88
|
+
// 评估 6.1(v0.4.3):先 path.resolve 消解 .. 段,再做前缀比较与 stat/readdir——此前字符串
|
|
89
|
+
// 前缀比较用未规范化的 dir,`/home/u/../../etc` 能通过 startsWith('/home/u/') 但 stat 解析到 /etc。
|
|
90
|
+
dir = path.resolve(dir);
|
|
88
91
|
// 质检 A3:目录浏览限定基目录,拒绝越界。Windows(CodeArts 报告):家目录覆盖整个用户配置树
|
|
89
92
|
// (AppData 等)——收紧为 桌面/文档/下载 三常用目录 + 启动目录 + 工作目录 + web.browseRoots 显式授权;
|
|
90
93
|
// 路径比较在 win32 下大小写归一(D:\\ vs d:\\ 不再误拒)。
|
package/src/web/server.js
CHANGED
|
@@ -119,8 +119,8 @@ function readBody(req, limit = 40 * 1024 * 1024) {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/** @param {{ host?: string, port?: number, authToken?: string|null, [key: string]: any }} [opts] */
|
|
123
|
-
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken } = {}) {
|
|
122
|
+
/** @param {{ host?: string, port?: number, authToken?: string|null, onBusy?: (busy: boolean) => void, [key: string]: any }} [opts] */
|
|
123
|
+
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken, onBusy } = {}) {
|
|
124
124
|
const home = ensureHome();
|
|
125
125
|
const cfg = loadConfig();
|
|
126
126
|
if (!cfg) {
|
|
@@ -206,7 +206,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
206
206
|
let mcpManager = null;
|
|
207
207
|
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
208
208
|
// 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
|
|
209
|
-
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
|
|
209
|
+
const mcpStartP = startMcpServers(cfg.mcpServers, workingDir, cfg).catch(() => null);
|
|
210
210
|
mcpManager = await Promise.race([
|
|
211
211
|
mcpStartP,
|
|
212
212
|
new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
|
|
@@ -230,6 +230,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
230
230
|
let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
|
|
231
231
|
const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
|
|
232
232
|
let taskSeq = 0;
|
|
233
|
+
// 忙状态通知(v0.4.3 network error 修复):有 running 任务即「忙」——桌面版据此在生成期
|
|
234
|
+
// 防睡眠/防熄屏(macOS 熄屏会中断 Chromium 网络栈导致 SSE 断连)。onBusy 由调用方注入。
|
|
235
|
+
let lastBusy = false;
|
|
236
|
+
function notifyBusy() {
|
|
237
|
+
const busy = [...tasks.values()].some((/** @type {any} */ t) => t.status === 'running');
|
|
238
|
+
if (busy !== lastBusy) {
|
|
239
|
+
lastBusy = busy;
|
|
240
|
+
try {
|
|
241
|
+
onBusy?.(busy);
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
233
245
|
// 会话文件互斥(质检 C1/M3):同一 session 文件的 append 与 compact 整文件重写必须串行
|
|
234
246
|
const sessionLocks = new Map();
|
|
235
247
|
/** @param {any} file @param {any} fn */
|
|
@@ -321,6 +333,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
321
333
|
const entry = /** @type {any} */ ({ res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 });
|
|
322
334
|
srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
|
|
323
335
|
tasks.set(taskId, entry);
|
|
336
|
+
notifyBusy(); // 进入 running → 忙
|
|
324
337
|
/** @param {any} obj */
|
|
325
338
|
const send = (obj) => {
|
|
326
339
|
try {
|
|
@@ -350,6 +363,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
350
363
|
const built = buildUserContent(userMessage, body.attachments, visionSupported);
|
|
351
364
|
if (built.error) {
|
|
352
365
|
entry.status = 'failed';
|
|
366
|
+
notifyBusy();
|
|
353
367
|
send({ type: 'error', message: built.error });
|
|
354
368
|
clearInterval(progressTimer);
|
|
355
369
|
res.end();
|
|
@@ -490,6 +504,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
490
504
|
providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
|
|
491
505
|
} catch (/** @type {any} */ err) {
|
|
492
506
|
entry.status = 'failed';
|
|
507
|
+
notifyBusy();
|
|
493
508
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
494
509
|
send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
|
|
495
510
|
clearInterval(progressTimer);
|
|
@@ -497,6 +512,17 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
497
512
|
pruneTasks();
|
|
498
513
|
return;
|
|
499
514
|
}
|
|
515
|
+
// v0.4.4:长任务费用逐轮入账——记录已按轮入账的累计 usage,最终补记最后一轮 + 兜底总结的剩余。
|
|
516
|
+
const recorded = { prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0 };
|
|
517
|
+
const onUsage = (/** @type {any} */ delta) => {
|
|
518
|
+
recorded.prompt_tokens += delta?.prompt_tokens || 0;
|
|
519
|
+
recorded.completion_tokens += delta?.completion_tokens || 0;
|
|
520
|
+
recorded.prompt_cache_hit_tokens += delta?.prompt_cache_hit_tokens || 0;
|
|
521
|
+
recorded.prompt_cache_miss_tokens += delta?.prompt_cache_miss_tokens || 0;
|
|
522
|
+
try {
|
|
523
|
+
recordUsage(runModel, delta, null);
|
|
524
|
+
} catch {}
|
|
525
|
+
};
|
|
500
526
|
const agent = createAgent({
|
|
501
527
|
provider: providerNow,
|
|
502
528
|
permission,
|
|
@@ -511,6 +537,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
511
537
|
withSessionLock(session.file, () => rewriteSession(session.file, msgs)).catch(() => {}); // onCompact 为同步回调:入队串行即可
|
|
512
538
|
persistedBefore = msgs.length;
|
|
513
539
|
},
|
|
540
|
+
// 每轮结束回调本轮增量 usage → 逐轮入账(长任务期间「今日费用」实时累计)
|
|
541
|
+
onUsage,
|
|
514
542
|
// 审计记录归入当前会话(P3-5)
|
|
515
543
|
sessionRef: { name: path.basename(session.file) },
|
|
516
544
|
});
|
|
@@ -531,6 +559,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
531
559
|
entry.abortHandler?.();
|
|
532
560
|
} catch {}
|
|
533
561
|
entry.status = 'failed';
|
|
562
|
+
notifyBusy();
|
|
534
563
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
535
564
|
pruneTasks();
|
|
536
565
|
}
|
|
@@ -544,7 +573,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
544
573
|
session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
|
|
545
574
|
await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
|
|
546
575
|
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
547
|
-
|
|
576
|
+
// v0.4.4:补记最后一轮 + 兜底总结的剩余 usage(自动续跑轮已在 onUsage 逐轮入账,避免重复计)
|
|
577
|
+
const remaining = {
|
|
578
|
+
prompt_tokens: Math.max(0, (r.usage?.prompt_tokens || 0) - recorded.prompt_tokens),
|
|
579
|
+
completion_tokens: Math.max(0, (r.usage?.completion_tokens || 0) - recorded.completion_tokens),
|
|
580
|
+
prompt_cache_hit_tokens: Math.max(0, (r.usage?.prompt_cache_hit_tokens || 0) - recorded.prompt_cache_hit_tokens),
|
|
581
|
+
prompt_cache_miss_tokens: Math.max(0, (r.usage?.prompt_cache_miss_tokens || 0) - recorded.prompt_cache_miss_tokens),
|
|
582
|
+
};
|
|
583
|
+
recordUsage(r.perf?.usedModel || runModel, remaining, /** @type {any} */ (r.perf));
|
|
548
584
|
// v0.3.0 P0-3:轮末提取项目记忆(有工具工作才提,fire-and-forget)。写文件供「未来会话」用,
|
|
549
585
|
// 当前会话用快照(上文已注入),故不会中途改系统提示、不破坏前缀缓存。
|
|
550
586
|
if (io.stats().toolCount > 0) {
|
|
@@ -579,6 +615,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
579
615
|
clearTaskState(finalSessionName);
|
|
580
616
|
}
|
|
581
617
|
entry.status = r.aborted ? 'aborted' : 'done';
|
|
618
|
+
notifyBusy();
|
|
582
619
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
583
620
|
srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
|
|
584
621
|
// 预算可视化(评估 A5):会话当前 token 占用 / 预算
|
|
@@ -607,6 +644,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
607
644
|
});
|
|
608
645
|
} catch (/** @type {any} */ err) {
|
|
609
646
|
entry.status = 'failed';
|
|
647
|
+
notifyBusy();
|
|
610
648
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
611
649
|
srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));
|
|
612
650
|
send({ type: 'error', message: String(err?.message || err) });
|
package/src/workspace.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
9
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
9
10
|
|
|
10
11
|
export function workspacesFile() {
|
|
11
12
|
return path.join(mingdaoHome(), 'workspaces.json');
|
|
@@ -23,11 +24,8 @@ export function loadWorkspaces() {
|
|
|
23
24
|
export function saveWorkspaces(/** @type {any} */ ws) {
|
|
24
25
|
try {
|
|
25
26
|
ensureHome();
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
const tmp = target + '.tmp';
|
|
29
|
-
fs.writeFileSync(tmp, JSON.stringify(ws, null, 2) + '\n');
|
|
30
|
-
fs.renameSync(tmp, target);
|
|
27
|
+
// 原子写(评估 6.6):随机 tmp 名 + rename,避免崩溃冲空与跨进程共名 tmp 串扰
|
|
28
|
+
atomicWriteFileSync(workspacesFile(), JSON.stringify(ws, null, 2) + '\n');
|
|
31
29
|
} catch {}
|
|
32
30
|
}
|
|
33
31
|
|
|
@@ -121,10 +119,8 @@ export function loadSessionWorkspaces() {
|
|
|
121
119
|
export function saveSessionWorkspaces(/** @type {any} */ map) {
|
|
122
120
|
try {
|
|
123
121
|
ensureHome();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
fs.writeFileSync(tmp, JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
|
|
127
|
-
fs.renameSync(tmp, target);
|
|
122
|
+
// 原子写(评估 6.6):随机 tmp 名 + rename
|
|
123
|
+
atomicWriteFileSync(sessionWorkspacesFile(), JSON.stringify(map, null, 2) + '\n', { mode: 0o600 });
|
|
128
124
|
} catch {}
|
|
129
125
|
}
|
|
130
126
|
|