mocode-ai 0.7.2 → 1.0.1
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/README.md +58 -4
- package/README.zh-CN.md +58 -4
- package/dist/agent/core.js +119 -39
- package/dist/agent/index.js +57 -6
- package/dist/agent/spawn.js +74 -30
- package/dist/agents/coordinator.js +60 -0
- package/dist/changeset/index.js +289 -0
- package/dist/changeset/types.js +1 -0
- package/dist/config/index.js +25 -13
- package/dist/context/artifacts.js +254 -0
- package/dist/context/classifier.js +1 -1
- package/dist/context/index.js +1 -0
- package/dist/i18n/index.js +16 -4
- package/dist/llm/index.js +2 -2
- package/dist/permissions/index.js +7 -1
- package/dist/repl/index.js +31 -5
- package/dist/rollback/index.js +43 -8
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/policy.js +2 -2
- package/dist/sandbox/root.js +9 -3
- package/dist/session/compact.js +9 -3
- package/dist/session/scheduler.js +4 -0
- package/dist/session/state.js +3 -19
- package/dist/tools/builtins/apply-patch.js +174 -0
- package/dist/tools/builtins/edit-file.js +60 -37
- package/dist/tools/builtins/index.js +10 -8
- package/dist/tools/builtins/read-file.js +4 -2
- package/dist/tools/builtins/task.js +47 -16
- package/dist/tools/builtins/web-fetch.js +21 -5
- package/dist/tools/builtins/web-search.js +28 -4
- package/dist/tools/builtins/write-file.js +53 -18
- package/dist/tools/constants.js +3 -3
- package/dist/tools/registry.js +108 -49
- package/dist/tools/retry.js +105 -0
- package/dist/tools/validation.js +80 -0
- package/dist/ui/batch.js +56 -24
- package/dist/ui/layout.js +8 -3
- package/package.json +2 -1
package/dist/agent/index.js
CHANGED
|
@@ -17,6 +17,44 @@ import { isToolErrorOutput } from '../tools/result.js';
|
|
|
17
17
|
import { appendCurrentSessionTraceEvent } from '../session/index.js';
|
|
18
18
|
/** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
|
|
19
19
|
let currentBatchId = null;
|
|
20
|
+
let turnFileChanges = [];
|
|
21
|
+
function lineDelta(oldText, newText) {
|
|
22
|
+
const before = oldText ? oldText.split('\n') : [];
|
|
23
|
+
const after = newText ? newText.split('\n') : [];
|
|
24
|
+
let head = 0;
|
|
25
|
+
while (head < before.length && head < after.length && before[head] === after[head])
|
|
26
|
+
head++;
|
|
27
|
+
let tail = 0;
|
|
28
|
+
while (tail < before.length - head
|
|
29
|
+
&& tail < after.length - head
|
|
30
|
+
&& before[before.length - 1 - tail] === after[after.length - 1 - tail])
|
|
31
|
+
tail++;
|
|
32
|
+
return { added: after.length - head - tail, removed: before.length - head - tail };
|
|
33
|
+
}
|
|
34
|
+
function writeChangeOverview() {
|
|
35
|
+
if (turnFileChanges.length === 0)
|
|
36
|
+
return;
|
|
37
|
+
const merged = new Map();
|
|
38
|
+
for (const change of turnFileChanges) {
|
|
39
|
+
const current = merged.get(change.path);
|
|
40
|
+
if (current) {
|
|
41
|
+
current.added += change.added;
|
|
42
|
+
current.removed += change.removed;
|
|
43
|
+
if (change.kind === 'A')
|
|
44
|
+
current.kind = 'A';
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
merged.set(change.path, { ...change });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const changes = [...merged.values()];
|
|
51
|
+
const added = changes.reduce((n, c) => n + c.added, 0);
|
|
52
|
+
const removed = changes.reduce((n, c) => n + c.removed, 0);
|
|
53
|
+
layout.contentWrite(` ${ui.dim}├─${ui.reset} ${ui.bold}${ui.green}◆${ui.reset} ${t('agent.changes')} ${t('agent.files', { count: changes.length })} ${ui.green}+${added}${ui.reset} ${ui.red}−${removed}${ui.reset}\n`);
|
|
54
|
+
for (const change of changes) {
|
|
55
|
+
layout.contentWrite(` ${ui.dim}│ ${change.kind}${ui.reset} ${change.path} ${ui.green}+${change.added}${ui.reset} ${ui.red}−${change.removed}${ui.reset}\n`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
20
58
|
/** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
|
|
21
59
|
function firstLineOf(ui) {
|
|
22
60
|
if (typeof ui === 'string')
|
|
@@ -43,7 +81,9 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
|
|
|
43
81
|
if (!currentBatchId)
|
|
44
82
|
return;
|
|
45
83
|
let diff = null;
|
|
46
|
-
if (
|
|
84
|
+
if ((tc.name === 'edit_file' || tc.name === 'write_file') && parsed && !isToolErrorOutput(output)) {
|
|
85
|
+
const oldText = tc.name === 'edit_file' ? String(parsed.old_string ?? '') : preWriteOld;
|
|
86
|
+
const newText = String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? '');
|
|
47
87
|
diff = renderFileChange({
|
|
48
88
|
path: String(parsed.path ?? ''),
|
|
49
89
|
kind: tc.name === 'edit_file' ? 'edit' : 'write',
|
|
@@ -53,9 +93,15 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
|
|
|
53
93
|
newStr: String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? ''),
|
|
54
94
|
startLine: tc.name === 'edit_file' ? editStartLine : 1,
|
|
55
95
|
});
|
|
96
|
+
turnFileChanges.push({
|
|
97
|
+
path: String(parsed.path ?? ''),
|
|
98
|
+
kind: tc.name === 'write_file' && preWriteOld == null ? 'A' : 'M',
|
|
99
|
+
...lineDelta(oldText, newText),
|
|
100
|
+
});
|
|
56
101
|
}
|
|
57
102
|
const preview = diff ? '' : summarizeToolResult(tc.name, output);
|
|
58
|
-
batch.recordResult(currentBatchId, tc.name, preview, diff, output);
|
|
103
|
+
batch.recordResult(currentBatchId, tc.name, preview, diff, output, isToolErrorOutput(output));
|
|
104
|
+
batch.showLiveBatch(currentBatchId, layout);
|
|
59
105
|
// mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
|
|
60
106
|
if (isMutationTool(tc.name))
|
|
61
107
|
flushToolBatch(true);
|
|
@@ -96,6 +142,7 @@ onContextUpdate) {
|
|
|
96
142
|
beginTurn(truncateDisplay(firstLineOf(userInput), 40));
|
|
97
143
|
layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
|
|
98
144
|
currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
|
|
145
|
+
turnFileChanges = [];
|
|
99
146
|
// spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
|
|
100
147
|
// 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
|
|
101
148
|
// 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
|
|
@@ -121,6 +168,8 @@ onContextUpdate) {
|
|
|
121
168
|
// batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
|
|
122
169
|
// 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
|
|
123
170
|
const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
|
|
171
|
+
// 正文是工具批次边界:只有“连续且中间没有正文”的工具调用才合并。
|
|
172
|
+
// 一旦模型开始解释阶段结果,立即收尾当前摘要;后续工具重新建立批次。
|
|
124
173
|
if (s)
|
|
125
174
|
flushToolBatch();
|
|
126
175
|
spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
|
|
@@ -203,12 +252,14 @@ onContextUpdate) {
|
|
|
203
252
|
const detail = validation.status === 'skipped' && validation.skipReason
|
|
204
253
|
? `${validation.status}: ${validation.skipReason}`
|
|
205
254
|
: validation.status;
|
|
206
|
-
|
|
255
|
+
const symbol = validation.status === 'passed' ? '◆' : validation.status === 'failed' ? '×' : '!';
|
|
256
|
+
layout.contentWrite(` ${ui.dim}├─${ui.reset} ${color}${symbol}${ui.reset} ${t('agent.validationResult', { command, status: detail })}\n\n`);
|
|
207
257
|
},
|
|
208
258
|
onDone: (elapsedMs, usage) => {
|
|
209
259
|
flushToolBatch();
|
|
260
|
+
writeChangeOverview();
|
|
210
261
|
const tok = formatTurnTokens(usage);
|
|
211
|
-
layout.contentWrite(` ${ui.
|
|
262
|
+
layout.contentWrite(` ${ui.bold}${ui.green}◆${ui.reset} ${t('agent.complete')} ${fmtElapsed(elapsedMs)}${tok}\n`);
|
|
212
263
|
// 内容区触底时,DECSTBM 增量滚屏可能只推进物理终端,未把 Worked 前已在
|
|
213
264
|
// buffer 中的空行完整画出来;用户滚动/点击触发 repaint 后才“突然”出现。
|
|
214
265
|
// 轮次收尾立即按 buffer 原子重画,使未满屏与触底滚屏的布局一致。
|
|
@@ -279,9 +330,9 @@ function formatTurnTokens(usage) {
|
|
|
279
330
|
const billablePrompt = usage.promptTokens - cached;
|
|
280
331
|
const extras = [];
|
|
281
332
|
if (cached > 0)
|
|
282
|
-
extras.push(
|
|
333
|
+
extras.push(`${Math.round((cached / Math.max(1, usage.promptTokens)) * 100)}% cached`);
|
|
283
334
|
if (reasoning > 0)
|
|
284
335
|
extras.push(`reasoning ${fmt(reasoning)}`);
|
|
285
336
|
const extrasStr = extras.length > 0 ? ` · ${extras.join(' · ')}` : '';
|
|
286
|
-
return `
|
|
337
|
+
return ` ${fmt(total)} tokens${extrasStr} ${ui.dim}(↑ ${fmt(billablePrompt)} ↓ ${fmt(usage.completionTokens)})${ui.reset}`;
|
|
287
338
|
}
|
package/dist/agent/spawn.js
CHANGED
|
@@ -5,28 +5,34 @@
|
|
|
5
5
|
// - 不写主屏(layout.contentWrite):中间过程(流式正文 / 工具头 / diff)缓冲到内部字符串,
|
|
6
6
|
// 结束返回给 task 工具(task 把它当 tool 结果回灌主 history,主 agent 据此继续)。
|
|
7
7
|
// - 独立 history:不共享主对话,避免子任务的工具噪声污染主上下文。
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
8
|
+
// - 紧凑系统提示:不复制主 agent 的 memory/skills/项目快照;由 context 传入已知事实。
|
|
9
|
+
// - 工具子集:写任务默认继承主 Agent 工具(仅禁止递归 task);只读模式按安全语义移除写工具。
|
|
10
10
|
// - 不调 beginTurn:子 agent 共享主 agent 当前轮次;其文件修改进入同一回滚事务。
|
|
11
|
-
// -
|
|
11
|
+
// - 步数默认与主 Agent 相同,只作为无限循环保险,不以 token 配额提前终止有效任务。
|
|
12
12
|
// - 中断透传:opts.signal(主 agent 的 abort signal)透传给 runAgentCore → chat/executeTool,
|
|
13
13
|
// 主 Ctrl+C 树杀子 agent(chat 流式 abort + run_command/web_fetch 即时取消)。
|
|
14
14
|
import { chatTools } from '../llm/index.js';
|
|
15
|
-
import {
|
|
15
|
+
import { buildMocodeCorePrompt, config, isSubAgentEnabled } from '../config/index.js';
|
|
16
16
|
import { effectiveSystemPrompt } from '../skills/index.js';
|
|
17
|
-
import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js';
|
|
18
17
|
import { ui } from '../ui/theme.js';
|
|
19
18
|
import { runAgentCore } from './core.js';
|
|
20
19
|
import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
|
|
21
20
|
import { createContextState } from '../session/compact.js';
|
|
21
|
+
import { inOverlay, mergeSubAgentChangeSet } from '../agents/coordinator.js';
|
|
22
22
|
/** 子 agent 系统提示后缀:角色与约束。 */
|
|
23
|
-
const SUBAGENT_SUFFIX = `
|
|
23
|
+
const SUBAGENT_SUFFIX = `
|
|
24
24
|
|
|
25
25
|
## ⛯ SUB-AGENT MODE (you are a sub-agent)
|
|
26
26
|
You are a sub-agent spawned by the main agent to handle an isolated sub-task. You have your own conversation history (independent of the main thread).
|
|
27
|
-
- Focus solely on the assigned sub-task. Do NOT attempt to call the "
|
|
27
|
+
- Focus solely on the assigned sub-task. Do NOT attempt to call the "sub-agent" tool (no recursive spawning).
|
|
28
28
|
- Use the tools available to you to complete the sub-task.
|
|
29
29
|
- When done, your final text reply will be returned to the main agent as a summary — make it concise and actionable: what you did, key findings, files changed, and any issues. The main agent will decide the next step based on your summary.`;
|
|
30
|
+
const SUBAGENT_ROLE = `## Sub-agent execution
|
|
31
|
+
You are executing one delegated sub-task with the same engineering standards and capabilities as mocode.
|
|
32
|
+
- Treat Task context as authoritative facts already established by the main agent; do not rediscover them without evidence they are stale.
|
|
33
|
+
- Focus on the delegated scope, but continue until it is genuinely complete. Do not stop to save tokens.
|
|
34
|
+
- Do not recursively call sub-agent. A write task runs in an isolated overlay; the coordinator merges and performs final unified verification.
|
|
35
|
+
- Return concise findings, changes, verification evidence, and blockers to the coordinator.`;
|
|
30
36
|
/**
|
|
31
37
|
* 派生一个子 agent 执行独立子任务。
|
|
32
38
|
*
|
|
@@ -45,28 +51,25 @@ export async function spawnAgent(opts) {
|
|
|
45
51
|
summary: null,
|
|
46
52
|
completed: false,
|
|
47
53
|
transcript: 'Sub-agent execution is disabled. Enable it with /subagent on.',
|
|
54
|
+
status: 'failed', findings: [], readSet: [], changeSet: null, verification: null,
|
|
55
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, cachedTokens: 0, reasoningTokens: 0 },
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
|
-
const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
// 所以这里直接读 config.systemPrompt 即可;buildMemoryIndexSection 显式按 isMemoryEnabled() 传参,
|
|
54
|
-
// 关闭时该段不进。注意:不能从 spawn.ts 直接 import buildBasePrompt —— 这会
|
|
55
|
-
// 拉起 config → llm → registry → builtins → task → spawn 形成循环求值死锁。
|
|
56
|
-
const systemPrompt = effectiveSystemPrompt(config.systemPrompt +
|
|
57
|
-
buildMemorySection() +
|
|
58
|
-
buildMemoryIndexSection(isMemoryEnabled()) +
|
|
59
|
-
SUBAGENT_SUFFIX +
|
|
58
|
+
const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps;
|
|
59
|
+
// 构造窄 worker prompt;主 Agent 已知事实只通过有界 context 注入,避免重复探索与重复计费。
|
|
60
|
+
const systemPrompt = effectiveSystemPrompt(buildMocodeCorePrompt() + '\n\n' + SUBAGENT_ROLE + SUBAGENT_SUFFIX +
|
|
60
61
|
(opts.systemPromptSuffix ? `\n\n${opts.systemPromptSuffix}` : ''));
|
|
61
|
-
|
|
62
|
+
const taskPrompt = opts.context?.trim()
|
|
63
|
+
? `Task context (authoritative; do not rediscover):\n${opts.context.slice(0, 4000)}\n\nSub-task:\n${opts.prompt}`
|
|
64
|
+
: opts.prompt;
|
|
65
|
+
// 写 worker 保留主 Agent 的完整能力;只读 mode 仅按调用契约移除副作用工具。
|
|
62
66
|
let toolsOverride;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
67
|
+
const mode = opts.mode ?? 'read';
|
|
68
|
+
const requested = opts.tools?.length ? new Set(opts.tools) : null;
|
|
69
|
+
const readOnly = new Set(['read_file', 'glob', 'grep', 'codegraph', 'web_search', 'web_fetch', 'use_skill', 'memory_search', 'memory_list']);
|
|
70
|
+
toolsOverride = chatTools.filter((tool) => tool.function.name !== 'sub-agent' &&
|
|
71
|
+
(!requested || requested.has(tool.function.name)) &&
|
|
72
|
+
(mode === 'write' || readOnly.has(tool.function.name)));
|
|
70
73
|
// 独立 history(子 agent 自己持有,不共享主对话)。
|
|
71
74
|
// 只塞 system;user 消息由 runAgentCore 的 userInput 参数 push(与主 agent 一致)。
|
|
72
75
|
const history = [
|
|
@@ -123,19 +126,60 @@ export async function spawnAgent(opts) {
|
|
|
123
126
|
// 每个子 agent 独享统计/预算状态。不能保存再恢复模块级单例:多个 task 并发时
|
|
124
127
|
// save/restore 会竞态,且 lastEstimate / schedulerLog 仍会污染主 agent。
|
|
125
128
|
const localContextState = createContextState();
|
|
126
|
-
const
|
|
129
|
+
const readSet = new Set();
|
|
130
|
+
const run = () => runAgentCore({
|
|
127
131
|
history,
|
|
128
|
-
userInput:
|
|
129
|
-
signal: opts.signal,
|
|
132
|
+
userInput: taskPrompt,
|
|
133
|
+
signal: opts.signal,
|
|
130
134
|
hooks,
|
|
131
135
|
maxSteps,
|
|
132
136
|
toolsOverride,
|
|
133
137
|
contextState: localContextState,
|
|
134
|
-
autoValidate: false,
|
|
138
|
+
autoValidate: false,
|
|
139
|
+
onToolOutcome: (tool, args) => {
|
|
140
|
+
if (tool === 'read_file' && typeof args.path === 'string')
|
|
141
|
+
readSet.add(args.path);
|
|
142
|
+
else if (['glob', 'grep', 'codegraph'].includes(tool))
|
|
143
|
+
readSet.add('workspace');
|
|
144
|
+
},
|
|
135
145
|
});
|
|
146
|
+
let result;
|
|
147
|
+
let changeSet = null;
|
|
148
|
+
let mergeStatus = 'committed';
|
|
149
|
+
if (opts.mode === 'write') {
|
|
150
|
+
const isolated = await inOverlay(run);
|
|
151
|
+
result = isolated.value;
|
|
152
|
+
changeSet = isolated.changeSet;
|
|
153
|
+
const declared = new Set((opts.writeSet ?? []).map((item) => item.replaceAll('\\', '/').toLowerCase()));
|
|
154
|
+
const outsideDeclaration = declared.size > 0 && changeSet?.changes.some((change) => !declared.has(change.path.replaceAll('\\', '/').toLowerCase()));
|
|
155
|
+
if (!result.completed || outsideDeclaration)
|
|
156
|
+
mergeStatus = 'failed';
|
|
157
|
+
else
|
|
158
|
+
mergeStatus = await mergeSubAgentChangeSet(changeSet, opts.signal);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
result = await run();
|
|
162
|
+
}
|
|
163
|
+
const status = opts.signal?.aborted || result.terminationReason === 'aborted'
|
|
164
|
+
? 'aborted'
|
|
165
|
+
: mergeStatus === 'conflict' ? 'conflict'
|
|
166
|
+
: mergeStatus === 'failed' || !result.completed ? 'failed'
|
|
167
|
+
: 'completed';
|
|
136
168
|
return {
|
|
137
169
|
summary: result.finalText,
|
|
138
|
-
completed: result.completed,
|
|
170
|
+
completed: result.completed && status === 'completed',
|
|
139
171
|
transcript: truncateDisplay(transcript, 20000), // 防过大;调试用,回灌主 history 的是 summary 不是 transcript
|
|
172
|
+
status,
|
|
173
|
+
findings: result.finalText ? [result.finalText] : [],
|
|
174
|
+
readSet: [...readSet].sort(),
|
|
175
|
+
changeSet,
|
|
176
|
+
verification: null, // 主 Agent 在所有 coordinator merge 完成后统一验证
|
|
177
|
+
usage: {
|
|
178
|
+
promptTokens: result.usage?.promptTokens ?? 0,
|
|
179
|
+
completionTokens: result.usage?.completionTokens ?? 0,
|
|
180
|
+
totalTokens: result.usage?.totalTokens ?? 0,
|
|
181
|
+
cachedTokens: result.usage?.cachedTokens ?? 0,
|
|
182
|
+
reasoningTokens: result.usage?.reasoningTokens ?? 0,
|
|
183
|
+
},
|
|
140
184
|
};
|
|
141
185
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { cp, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { commitChangeSet, contentHash, createChangeSet } from '../changeset/index.js';
|
|
5
|
+
import { getSandboxRoot, withSandboxRoot } from '../sandbox/index.js';
|
|
6
|
+
const EXCLUDED = new Set(['.git', 'node_modules', 'dist', '.mocode']);
|
|
7
|
+
async function filesBelow(root, dir = root, out = new Map()) {
|
|
8
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
9
|
+
if (EXCLUDED.has(entry.name))
|
|
10
|
+
continue;
|
|
11
|
+
const absolute = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
await filesBelow(root, absolute, out);
|
|
14
|
+
else if (entry.isFile())
|
|
15
|
+
out.set(path.relative(root, absolute).replaceAll('\\', '/'), await readFile(absolute));
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
function text(buffer) {
|
|
20
|
+
if (buffer.includes(0))
|
|
21
|
+
throw new Error('子 Agent overlay 暂不支持合并二进制文件。');
|
|
22
|
+
return buffer.toString('utf8');
|
|
23
|
+
}
|
|
24
|
+
function diffToChangeSet(before, after) {
|
|
25
|
+
const changes = [];
|
|
26
|
+
for (const file of new Set([...before.keys(), ...after.keys()])) {
|
|
27
|
+
const oldValue = before.get(file);
|
|
28
|
+
const newValue = after.get(file);
|
|
29
|
+
if (oldValue && newValue && oldValue.equals(newValue))
|
|
30
|
+
continue;
|
|
31
|
+
if (!oldValue && newValue)
|
|
32
|
+
changes.push({ path: file, operation: 'create', expectedHash: null, replacement: text(newValue) });
|
|
33
|
+
else if (oldValue && !newValue)
|
|
34
|
+
changes.push({ path: file, operation: 'delete', expectedHash: contentHash(oldValue) });
|
|
35
|
+
else if (oldValue && newValue)
|
|
36
|
+
changes.push({ path: file, operation: 'update', expectedHash: contentHash(oldValue), replacement: text(newValue) });
|
|
37
|
+
}
|
|
38
|
+
return changes.length ? createChangeSet(changes) : null;
|
|
39
|
+
}
|
|
40
|
+
/** Execute a writer in a private filesystem overlay and return, but do not merge, its ChangeSet. */
|
|
41
|
+
export async function inOverlay(run) {
|
|
42
|
+
const base = path.resolve(getSandboxRoot() ?? process.cwd());
|
|
43
|
+
const overlay = await mkdtemp(path.join(os.tmpdir(), 'mocode-subagent-'));
|
|
44
|
+
try {
|
|
45
|
+
await cp(base, overlay, { recursive: true, filter: (source) => !EXCLUDED.has(path.basename(source)) });
|
|
46
|
+
const before = await filesBelow(base);
|
|
47
|
+
const value = await withSandboxRoot(overlay, run);
|
|
48
|
+
return { value, changeSet: diffToChangeSet(before, await filesBelow(overlay)) };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await rm(overlay, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The only merge point: ChangeSet preconditions and canonical resource locks prevent silent overwrite. */
|
|
55
|
+
export async function mergeSubAgentChangeSet(changeSet, signal) {
|
|
56
|
+
if (!changeSet)
|
|
57
|
+
return 'committed';
|
|
58
|
+
const result = await commitChangeSet(changeSet, signal);
|
|
59
|
+
return result.status;
|
|
60
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, copyFile, mkdir, readFile, rename, rm, rmdir, stat, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { jailResolve } from '../sandbox/index.js';
|
|
6
|
+
import { beginPathMutation, endPathMutation } from '../rollback/index.js';
|
|
7
|
+
import { canonicalFileResourceKey, toolResourceLockManager, } from '../tools/resource-lock.js';
|
|
8
|
+
export function contentHash(content) {
|
|
9
|
+
const data = typeof content === 'string' ? Buffer.from(content, 'utf8') : content;
|
|
10
|
+
return `sha256:${createHash('sha256').update(data).digest('hex')}`;
|
|
11
|
+
}
|
|
12
|
+
export function normalizeContentHash(value) {
|
|
13
|
+
const normalized = value.trim().toLowerCase();
|
|
14
|
+
const hex = normalized.startsWith('sha256:') ? normalized.slice(7) : normalized;
|
|
15
|
+
return /^[a-f0-9]{64}$/.test(hex) ? `sha256:${hex}` : null;
|
|
16
|
+
}
|
|
17
|
+
function applyTextEdits(source, edits) {
|
|
18
|
+
const ordered = [...edits].sort((left, right) => left.start - right.start || left.end - right.end);
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
let output = '';
|
|
21
|
+
for (const edit of ordered) {
|
|
22
|
+
if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) ||
|
|
23
|
+
edit.start < cursor || edit.end < edit.start || edit.end > source.length) {
|
|
24
|
+
throw new Error(`无效或重叠的 TextEdit 范围: ${edit.start}..${edit.end}`);
|
|
25
|
+
}
|
|
26
|
+
output += source.slice(cursor, edit.start) + edit.newText;
|
|
27
|
+
cursor = edit.end;
|
|
28
|
+
}
|
|
29
|
+
return output + source.slice(cursor);
|
|
30
|
+
}
|
|
31
|
+
async function readCurrent(file) {
|
|
32
|
+
try {
|
|
33
|
+
const info = await stat(file);
|
|
34
|
+
if (!info.isFile())
|
|
35
|
+
throw new Error(`目标不是普通文件: ${file}`);
|
|
36
|
+
return await readFile(file);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT')
|
|
40
|
+
return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function actualHash(content) {
|
|
45
|
+
return content === null ? null : contentHash(content);
|
|
46
|
+
}
|
|
47
|
+
export function createChangeSet(changes) {
|
|
48
|
+
return { id: randomUUID(), createdAt: Date.now(), changes };
|
|
49
|
+
}
|
|
50
|
+
/** Validate every precondition and calculate every output without touching disk. */
|
|
51
|
+
export async function dryRunChangeSet(changeSet) {
|
|
52
|
+
const prepared = [];
|
|
53
|
+
const conflicts = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const change of changeSet.changes) {
|
|
56
|
+
const absolutePath = jailResolve(change.path);
|
|
57
|
+
const identity = process.platform === 'win32' ? absolutePath.toLowerCase() : absolutePath;
|
|
58
|
+
if (seen.has(identity)) {
|
|
59
|
+
conflicts.push({
|
|
60
|
+
path: change.path,
|
|
61
|
+
expectedHash: change.expectedHash,
|
|
62
|
+
actualHash: null,
|
|
63
|
+
reason: '同一 ChangeSet 不能多次修改同一路径。',
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
seen.add(identity);
|
|
68
|
+
const before = await readCurrent(absolutePath);
|
|
69
|
+
const beforeHash = actualHash(before);
|
|
70
|
+
if (beforeHash !== change.expectedHash) {
|
|
71
|
+
conflicts.push({
|
|
72
|
+
path: change.path,
|
|
73
|
+
expectedHash: change.expectedHash,
|
|
74
|
+
actualHash: beforeHash,
|
|
75
|
+
reason: '文件内容已变化或存在状态与预期不一致。',
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
if (change.operation === 'create' && before !== null)
|
|
81
|
+
throw new Error('创建目标已经存在。');
|
|
82
|
+
if (change.operation !== 'create' && before === null)
|
|
83
|
+
throw new Error('更新或删除目标不存在。');
|
|
84
|
+
if (change.operation === 'delete' && (change.replacement !== undefined || change.edits?.length)) {
|
|
85
|
+
throw new Error('删除操作不能包含 replacement 或 edits。');
|
|
86
|
+
}
|
|
87
|
+
if (change.replacement !== undefined && change.edits?.length) {
|
|
88
|
+
throw new Error('FileChange 不能同时包含 replacement 和 edits。');
|
|
89
|
+
}
|
|
90
|
+
let after = null;
|
|
91
|
+
if (change.operation !== 'delete') {
|
|
92
|
+
const source = before?.toString('utf8') ?? '';
|
|
93
|
+
const next = change.replacement ?? applyTextEdits(source, change.edits ?? []);
|
|
94
|
+
after = Buffer.from(next, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
prepared.push({
|
|
97
|
+
...change,
|
|
98
|
+
absolutePath,
|
|
99
|
+
before,
|
|
100
|
+
after,
|
|
101
|
+
beforeHash,
|
|
102
|
+
afterHash: actualHash(after),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
conflicts.push({
|
|
107
|
+
path: change.path,
|
|
108
|
+
expectedHash: change.expectedHash,
|
|
109
|
+
actualHash: beforeHash,
|
|
110
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return conflicts.length > 0
|
|
115
|
+
? { ok: false, conflicts }
|
|
116
|
+
: { ok: true, changeSet: { ...changeSet, prepared } };
|
|
117
|
+
}
|
|
118
|
+
async function removeIfPresent(target) {
|
|
119
|
+
await rm(target, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
async function verifyPreparedVersions(prepared) {
|
|
122
|
+
const conflicts = [];
|
|
123
|
+
for (const change of prepared) {
|
|
124
|
+
const current = await readCurrent(change.absolutePath);
|
|
125
|
+
const currentHash = actualHash(current);
|
|
126
|
+
if (currentHash !== change.beforeHash) {
|
|
127
|
+
conflicts.push({
|
|
128
|
+
path: change.path,
|
|
129
|
+
expectedHash: change.beforeHash,
|
|
130
|
+
actualHash: currentHash,
|
|
131
|
+
reason: 'dry-run 后文件又被外部修改。',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return conflicts;
|
|
136
|
+
}
|
|
137
|
+
function lockRequests(changeSet) {
|
|
138
|
+
return changeSet.changes.map((change) => ({
|
|
139
|
+
key: canonicalFileResourceKey(change.path),
|
|
140
|
+
scope: 'resource',
|
|
141
|
+
mode: 'write',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
async function missingParentDirectories(file) {
|
|
145
|
+
const result = [];
|
|
146
|
+
let cursor = path.dirname(file);
|
|
147
|
+
while (true) {
|
|
148
|
+
try {
|
|
149
|
+
await stat(cursor);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.code !== 'ENOENT')
|
|
154
|
+
throw error;
|
|
155
|
+
result.push(cursor);
|
|
156
|
+
const parent = path.dirname(cursor);
|
|
157
|
+
if (parent === cursor)
|
|
158
|
+
break;
|
|
159
|
+
cursor = parent;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
/** Commit is process-transactional: every target is prepared first and any failed swap is compensated. */
|
|
165
|
+
export async function commitChangeSet(changeSet, signal) {
|
|
166
|
+
try {
|
|
167
|
+
return await toolResourceLockManager.withLocks(lockRequests(changeSet), signal, async () => {
|
|
168
|
+
if (signal?.aborted) {
|
|
169
|
+
return { status: 'failed', changeSet, error: 'ChangeSet 在提交前被中断。', changedFiles: [] };
|
|
170
|
+
}
|
|
171
|
+
const dryRun = await dryRunChangeSet(changeSet);
|
|
172
|
+
if (!dryRun.ok) {
|
|
173
|
+
return { status: 'conflict', changeSet, conflicts: dryRun.conflicts, changedFiles: [] };
|
|
174
|
+
}
|
|
175
|
+
const effective = dryRun.changeSet.prepared.filter((change) => change.beforeHash !== change.afterHash);
|
|
176
|
+
if (effective.length === 0) {
|
|
177
|
+
return { status: 'committed', changeSet: dryRun.changeSet, changedFiles: [] };
|
|
178
|
+
}
|
|
179
|
+
const captures = effective.map((change) => ({
|
|
180
|
+
change,
|
|
181
|
+
capture: beginPathMutation(change.absolutePath),
|
|
182
|
+
}));
|
|
183
|
+
const createdDirectories = new Set();
|
|
184
|
+
const tempByPath = new Map();
|
|
185
|
+
const backupByPath = new Map();
|
|
186
|
+
const committed = [];
|
|
187
|
+
try {
|
|
188
|
+
// Prepare all parent directories and temp files before replacing any target.
|
|
189
|
+
for (const change of effective) {
|
|
190
|
+
for (const directory of await missingParentDirectories(change.absolutePath)) {
|
|
191
|
+
createdDirectories.add(directory);
|
|
192
|
+
}
|
|
193
|
+
await mkdir(path.dirname(change.absolutePath), { recursive: true });
|
|
194
|
+
if (change.after !== null) {
|
|
195
|
+
const temp = path.join(path.dirname(change.absolutePath), `.${path.basename(change.absolutePath)}.${changeSet.id}.tmp`);
|
|
196
|
+
await writeFile(temp, change.after, { flag: 'wx' });
|
|
197
|
+
if (change.before !== null) {
|
|
198
|
+
const currentMode = (await stat(change.absolutePath)).mode;
|
|
199
|
+
await chmod(temp, currentMode);
|
|
200
|
+
}
|
|
201
|
+
tempByPath.set(change.absolutePath, temp);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Close the dry-run/commit gap before the first visible replacement.
|
|
205
|
+
const conflicts = await verifyPreparedVersions(effective);
|
|
206
|
+
if (conflicts.length > 0) {
|
|
207
|
+
return { status: 'conflict', changeSet, conflicts, changedFiles: [] };
|
|
208
|
+
}
|
|
209
|
+
// Once the first swap starts, finish or compensate even if the caller aborts.
|
|
210
|
+
for (const change of effective) {
|
|
211
|
+
const backup = path.join(path.dirname(change.absolutePath), `.${path.basename(change.absolutePath)}.${changeSet.id}.bak`);
|
|
212
|
+
if (change.before !== null) {
|
|
213
|
+
if (change.after === null)
|
|
214
|
+
await rename(change.absolutePath, backup);
|
|
215
|
+
else
|
|
216
|
+
await copyFile(change.absolutePath, backup);
|
|
217
|
+
backupByPath.set(change.absolutePath, backup);
|
|
218
|
+
}
|
|
219
|
+
committed.push(change);
|
|
220
|
+
const temp = tempByPath.get(change.absolutePath);
|
|
221
|
+
// Same-directory rename is the atomic visibility boundary for creates/updates.
|
|
222
|
+
if (temp)
|
|
223
|
+
await rename(temp, change.absolutePath);
|
|
224
|
+
}
|
|
225
|
+
for (const { change, capture } of captures)
|
|
226
|
+
endPathMutation(capture, `changeset:${changeSet.id}`);
|
|
227
|
+
for (const backup of backupByPath.values()) {
|
|
228
|
+
await removeIfPresent(backup).catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
status: 'committed',
|
|
232
|
+
changeSet: dryRun.changeSet,
|
|
233
|
+
changedFiles: effective.map((change) => change.path),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// Reverse every visible replacement. Backups are kept until the full set succeeds.
|
|
238
|
+
for (const change of [...committed].reverse()) {
|
|
239
|
+
try {
|
|
240
|
+
await removeIfPresent(change.absolutePath);
|
|
241
|
+
const backup = backupByPath.get(change.absolutePath);
|
|
242
|
+
if (backup) {
|
|
243
|
+
await rename(backup, change.absolutePath);
|
|
244
|
+
backupByPath.delete(change.absolutePath);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Continue restoring the remaining files; report the original commit failure below.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
status: 'failed',
|
|
253
|
+
changeSet,
|
|
254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
255
|
+
changedFiles: [],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
for (const temp of tempByPath.values())
|
|
260
|
+
await removeIfPresent(temp).catch(() => undefined);
|
|
261
|
+
// A backup left after compensation failure is deliberately preserved for manual recovery.
|
|
262
|
+
for (const directory of [...createdDirectories].sort((a, b) => b.length - a.length)) {
|
|
263
|
+
await rmdir(directory).catch(() => undefined);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
status: 'failed',
|
|
271
|
+
changeSet,
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
changedFiles: [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function summarizeChangeSet(changeSet) {
|
|
278
|
+
const effective = changeSet.prepared.filter((change) => change.beforeHash !== change.afterHash);
|
|
279
|
+
return {
|
|
280
|
+
id: changeSet.id,
|
|
281
|
+
changedFiles: effective.map((change) => change.path),
|
|
282
|
+
changes: effective.map((change) => ({
|
|
283
|
+
path: change.path,
|
|
284
|
+
operation: change.operation,
|
|
285
|
+
beforeHash: change.beforeHash,
|
|
286
|
+
afterHash: change.afterHash,
|
|
287
|
+
})),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|