mocode-ai 0.7.3 → 1.0.2

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.
@@ -0,0 +1,254 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { estimateTokens } from '../llm/index.js';
4
+ import { canonicalizePath, extractPath, toText } from './utils.js';
5
+ const states = new WeakMap();
6
+ const STALE_PREFIX = '⌦[stale artifact:';
7
+ const ARTIFACT_HEADER = /^\[artifact\s+([^\]]+)\]\n?/;
8
+ function stateFor(state) {
9
+ let current = states.get(state);
10
+ if (!current) {
11
+ current = { artifacts: new Map() };
12
+ states.set(state, current);
13
+ }
14
+ return current;
15
+ }
16
+ function parseArgs(raw) {
17
+ try {
18
+ const value = JSON.parse(raw || '{}');
19
+ return value && typeof value === 'object' ? value : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function callArgs(history, idx) {
26
+ const id = history[idx].tool_call_id;
27
+ if (!id)
28
+ return null;
29
+ for (let cursor = idx - 1; cursor >= 0; cursor--) {
30
+ const message = history[cursor];
31
+ if (message.role !== 'assistant')
32
+ continue;
33
+ const calls = message.tool_calls;
34
+ const hit = calls?.find((call) => call.id === id);
35
+ if (hit?.function?.name)
36
+ return { tool: hit.function.name, argsRaw: hit.function.arguments ?? '{}' };
37
+ }
38
+ return null;
39
+ }
40
+ function sourceType(tool) {
41
+ if (tool === 'read_file')
42
+ return 'read';
43
+ if (tool === 'grep' || tool === 'glob' || tool === 'codegraph')
44
+ return 'search';
45
+ if (tool === 'run_command')
46
+ return 'diagnostic';
47
+ return null;
48
+ }
49
+ function pathsFromOutput(tool, output) {
50
+ const paths = new Set();
51
+ const add = (value) => {
52
+ const normalized = canonicalizePath(value);
53
+ if (normalized)
54
+ paths.add(normalized);
55
+ };
56
+ if (tool === 'grep' || tool === 'codegraph' || tool === 'run_command') {
57
+ const expression = /^(.+?\.[A-Za-z0-9]+):(?:\d+|\s*\d+\s*(?:处匹配|matches?))/gmi;
58
+ let match;
59
+ while ((match = expression.exec(output)))
60
+ add(match[1]);
61
+ }
62
+ else if (tool === 'glob') {
63
+ for (const line of output.split(/\r?\n/)) {
64
+ const value = line.trim();
65
+ if (value && !value.includes(' ') && (value.includes('/') || value.includes('\\')))
66
+ add(value);
67
+ }
68
+ }
69
+ return [...paths];
70
+ }
71
+ function parseReadHash(output) {
72
+ return /\bhash=(sha256:[a-f0-9]{64})\b/i.exec(output)?.[1]?.toLowerCase();
73
+ }
74
+ function currentFileHash(file) {
75
+ if (file === '*')
76
+ return undefined;
77
+ try {
78
+ return `sha256:${createHash('sha256').update(readFileSync(file)).digest('hex')}`;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ function updateStats(state, artifactState) {
85
+ const stats = { fresh: 0, stale: 0, stubbed: 0, tokensBySource: {} };
86
+ for (const artifact of artifactState.artifacts.values()) {
87
+ stats[artifact.freshness] += 1;
88
+ stats.tokensBySource[artifact.source.type] =
89
+ (stats.tokensBySource[artifact.source.type] ?? 0) + artifact.tokenCount;
90
+ }
91
+ state.artifactStats = stats;
92
+ }
93
+ export function recordArtifact(state, history, idx, output, succeeded) {
94
+ if (!succeeded)
95
+ return;
96
+ const call = callArgs(history, idx);
97
+ if (!call)
98
+ return;
99
+ const type = sourceType(call.tool);
100
+ if (!type)
101
+ return;
102
+ const args = parseArgs(call.argsRaw);
103
+ const id = history[idx].tool_call_id ?? `${idx}`;
104
+ const directPath = canonicalizePath(extractPath(call.argsRaw));
105
+ let dependencies = directPath
106
+ ? [{ path: directPath }]
107
+ : pathsFromOutput(call.tool, output).map((path) => ({ path }));
108
+ // Diagnostics and searches with no parseable result conservatively depend on the workspace.
109
+ if (dependencies.length === 0 && type !== 'read')
110
+ dependencies = [{ path: '*' }];
111
+ // Capture dependency versions now; future scheduler steps can detect external changes.
112
+ dependencies = dependencies.map((dependency) => {
113
+ const hashAtCreation = currentFileHash(dependency.path);
114
+ return { ...dependency, ...(hashAtCreation ? { hash: hashAtCreation } : {}) };
115
+ });
116
+ const hash = type === 'read' ? parseReadHash(output) : undefined;
117
+ if (hash && dependencies[0])
118
+ dependencies[0].hash = hash;
119
+ const content = toText(history[idx].content);
120
+ const artifact = {
121
+ id,
122
+ source: { type, tool: call.tool, toolCallId: id },
123
+ ...(hash ? { hash } : {}),
124
+ version: Date.now(),
125
+ dependencies,
126
+ freshness: content.startsWith('⌦[') ? 'stubbed' : 'fresh',
127
+ rebuildable: true,
128
+ tokenCount: estimateTokens(content),
129
+ messageIndex: idx,
130
+ };
131
+ stateFor(state).artifacts.set(id, artifact);
132
+ updateStats(state, stateFor(state));
133
+ }
134
+ function affected(artifact, changed) {
135
+ return artifact.dependencies.some((dependency) => dependency.path === '*' || changed.has(dependency.path));
136
+ }
137
+ /** Mark and immediately stub stale facts; this is stronger than waiting for budget pressure. */
138
+ export function invalidateArtifacts(state, history, changedFiles) {
139
+ const changed = new Set(changedFiles.map(canonicalizePath).filter((item) => !!item));
140
+ if (changed.size === 0)
141
+ return 0;
142
+ const artifactState = stateFor(state);
143
+ let count = 0;
144
+ for (const artifact of artifactState.artifacts.values()) {
145
+ if (artifact.freshness !== 'fresh' || !affected(artifact, changed))
146
+ continue;
147
+ artifact.freshness = 'stale';
148
+ const message = history[artifact.messageIndex];
149
+ if (message?.role === 'tool') {
150
+ const original = toText(message.content);
151
+ const paths = artifact.dependencies.map((item) => item.path).join(', ');
152
+ const stub = `${STALE_PREFIX}${artifact.source.tool}] source=${artifact.id} dependencies=${paths} ` +
153
+ `invalidated-by=${[...changed].join(', ')}; re-run ${artifact.source.tool} before using this fact.`;
154
+ message.content = stub;
155
+ artifact.tokenCount = estimateTokens(stub);
156
+ }
157
+ count++;
158
+ }
159
+ updateStats(state, artifactState);
160
+ return count;
161
+ }
162
+ /** Rebuild indices after resume/compaction while retaining versions for surviving tool_call IDs. */
163
+ export function rehydrateArtifacts(state, history) {
164
+ const artifactState = stateFor(state);
165
+ const previous = new Map(artifactState.artifacts);
166
+ artifactState.artifacts.clear();
167
+ for (let idx = 0; idx < history.length; idx++) {
168
+ const message = history[idx];
169
+ if (message.role !== 'tool')
170
+ continue;
171
+ const call = callArgs(history, idx);
172
+ if (!call || !sourceType(call.tool))
173
+ continue;
174
+ const content = toText(message.content);
175
+ const id = message.tool_call_id ?? `${idx}`;
176
+ const retained = previous.get(id);
177
+ if (retained) {
178
+ retained.messageIndex = idx;
179
+ retained.tokenCount = estimateTokens(content);
180
+ retained.freshness = content.startsWith(STALE_PREFIX)
181
+ ? 'stale'
182
+ : content.startsWith('⌦[') ? 'stubbed' : retained.freshness;
183
+ artifactState.artifacts.set(id, retained);
184
+ }
185
+ else {
186
+ recordArtifact(state, history, idx, content, true);
187
+ const artifact = artifactState.artifacts.get(id);
188
+ if (artifact && content.startsWith(STALE_PREFIX))
189
+ artifact.freshness = 'stale';
190
+ else if (artifact && content.startsWith('⌦['))
191
+ artifact.freshness = 'stubbed';
192
+ }
193
+ }
194
+ updateStats(state, artifactState);
195
+ }
196
+ /** Compare captured dependency versions before each model step to detect external edits. */
197
+ export function refreshArtifactFreshness(state, history) {
198
+ const changed = new Set();
199
+ for (const artifact of stateFor(state).artifacts.values()) {
200
+ if (artifact.freshness !== 'fresh')
201
+ continue;
202
+ for (const dependency of artifact.dependencies) {
203
+ if (!dependency.hash || dependency.path === '*')
204
+ continue;
205
+ if (currentFileHash(dependency.path) !== dependency.hash)
206
+ changed.add(dependency.path);
207
+ }
208
+ }
209
+ return changed.size > 0 ? invalidateArtifacts(state, history, [...changed]) : 0;
210
+ }
211
+ /** Scheduler entry point: stale artifacts are already stubs; normalize any resumed stale message first. */
212
+ export function pruneStaleArtifacts(state, history) {
213
+ const artifactState = stateFor(state);
214
+ let pruned = 0;
215
+ for (const artifact of artifactState.artifacts.values()) {
216
+ if (artifact.freshness !== 'stale')
217
+ continue;
218
+ const message = history[artifact.messageIndex];
219
+ if (message?.role !== 'tool')
220
+ continue;
221
+ const content = toText(message.content);
222
+ if (!content.startsWith(STALE_PREFIX)) {
223
+ message.content = `${STALE_PREFIX}${artifact.source.tool}] source=${artifact.id}; re-run before use.`;
224
+ artifact.tokenCount = estimateTokens(String(message.content));
225
+ pruned++;
226
+ }
227
+ }
228
+ updateStats(state, artifactState);
229
+ return pruned;
230
+ }
231
+ export function collectArtifactRefs(messages) {
232
+ const refs = new Set();
233
+ for (const message of messages) {
234
+ if (message.role !== 'tool')
235
+ continue;
236
+ const id = message.tool_call_id;
237
+ if (id)
238
+ refs.add(id);
239
+ const content = toText(message.content);
240
+ const hash = parseReadHash(content);
241
+ if (hash)
242
+ refs.add(hash);
243
+ }
244
+ return [...refs].slice(0, 24);
245
+ }
246
+ export function formatArtifactTokenSources(stats) {
247
+ if (!stats)
248
+ return 'none';
249
+ const entries = Object.entries(stats.tokensBySource)
250
+ .filter((entry) => typeof entry[1] === 'number' && entry[1] > 0)
251
+ .sort((left, right) => right[1] - left[1]);
252
+ return entries.length > 0 ? entries.map(([source, tokens]) => `${source} ${tokens}`).join(' · ') : 'none';
253
+ }
254
+ export { STALE_PREFIX, ARTIFACT_HEADER };
@@ -37,7 +37,7 @@ const BY_NAME = {
37
37
  memory_update: 'status',
38
38
  memory_forget: 'status',
39
39
  // summary:子 agent 摘要(轻量)
40
- task: 'summary',
40
+ 'sub-agent': 'summary',
41
41
  };
42
42
  /**
43
43
  * 形状启发:对未在 BY_NAME 登记的工具输出做模式识别(为 MCP / 未来工具兜底)。
@@ -6,6 +6,7 @@
6
6
  // (叶子级:仅 stdlib + tools/constants + session/compact 的 capToolResultForHistory 兜底 + config 开关)。
7
7
  export { optimizeToolResult } from './pipeline.js';
8
8
  export { classify, knownToolKinds } from './classifier.js';
9
+ export { recordArtifact, invalidateArtifacts, rehydrateArtifacts, refreshArtifactFreshness, pruneStaleArtifacts, collectArtifactRefs, formatArtifactTokenSources, } from './artifacts.js';
9
10
  export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
10
11
  // ── Context Budget Scheduler ───────────────────────────────────────────────
11
12
  export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, DEFAULT_BUDGET_POLICY, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
@@ -169,6 +169,12 @@ const zhCN = {
169
169
  'agent.validationNoCommand': '未发现验证命令',
170
170
  'agent.validationResult': '自动验证 {command} → {status}',
171
171
  'agent.workedFor': '耗时 {elapsed}',
172
+ 'agent.toolsRunning': '正在探索',
173
+ 'agent.toolsComplete': '探索',
174
+ 'agent.toolsFailed': '探索失败',
175
+ 'agent.changes': '文件变更',
176
+ 'agent.files': '{count} 个文件',
177
+ 'agent.complete': '完成',
172
178
  'toolSummary.lines': '{count} 行',
173
179
  'toolSummary.files': '{count} 个文件',
174
180
  'toolSummary.matches': '{count} 处匹配',
@@ -212,8 +218,8 @@ const zhCN = {
212
218
  'subagent.status': '子 Agent:{state}',
213
219
  'subagent.stateOn': '开启',
214
220
  'subagent.stateOff': '关闭',
215
- 'subagent.changedOn': '已开启子 Agent;task 将从下一次模型请求起可用。',
216
- 'subagent.changedOff': '已关闭子 Agent;task 已从模型工具表移除。',
221
+ 'subagent.changedOn': '已开启子 Agent;sub-agent 将从下一次模型请求起可用。',
222
+ 'subagent.changedOff': '已关闭子 Agent;sub-agent 已从模型工具表移除。',
217
223
  'subagent.usage': '用法:/subagent on|off|status',
218
224
  'plan.ready': '计划已就绪',
219
225
  'plan.approvalDetail': '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
@@ -395,6 +401,12 @@ const en = {
395
401
  'agent.validationNoCommand': 'no validation command',
396
402
  'agent.validationResult': 'Automatic validation {command} → {status}',
397
403
  'agent.workedFor': 'Worked for {elapsed}',
404
+ 'agent.toolsRunning': 'Exploring',
405
+ 'agent.toolsComplete': 'Exploration',
406
+ 'agent.toolsFailed': 'Exploration failed',
407
+ 'agent.changes': 'Changes',
408
+ 'agent.files': '{count} file(s)',
409
+ 'agent.complete': 'Complete',
398
410
  'toolSummary.lines': '{count} lines',
399
411
  'toolSummary.files': '{count} files',
400
412
  'toolSummary.matches': '{count} matches',
@@ -438,8 +450,8 @@ const en = {
438
450
  'subagent.status': 'Sub-agent: {state}',
439
451
  'subagent.stateOn': 'enabled',
440
452
  'subagent.stateOff': 'disabled',
441
- 'subagent.changedOn': 'Sub-agents enabled; task will be available from the next model request.',
442
- 'subagent.changedOff': 'Sub-agents disabled; task has been removed from the model tool list.',
453
+ 'subagent.changedOn': 'Sub-agents enabled; sub-agent will be available from the next model request.',
454
+ 'subagent.changedOff': 'Sub-agents disabled; sub-agent has been removed from the model tool list.',
443
455
  'subagent.usage': 'Usage: /subagent on|off|status',
444
456
  'plan.ready': 'Plan ready',
445
457
  'plan.approvalDetail': 'Switch to auto mode and execute the plan above? (Plan mode is read-only; execution requires auto mode.)',
package/dist/llm/index.js CHANGED
@@ -149,10 +149,10 @@ export function __setChatCreateImpl(impl) {
149
149
  export const chatTools = [];
150
150
  export const planChatTools = [];
151
151
  export function refreshChatTools() {
152
- // task 常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
152
+ // sub-agent 常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
153
153
  const visibleTools = isSubAgentEnabled()
154
154
  ? tools
155
- : tools.filter((tool) => tool.name !== 'task');
155
+ : tools.filter((tool) => tool.name !== 'sub-agent');
156
156
  const next = visibleTools.map((t) => ({
157
157
  type: 'function',
158
158
  function: {
@@ -22,6 +22,7 @@ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools,
22
22
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
23
23
  import { modelSupportsVision } from '../llm/capabilities.js';
24
24
  import { computePruneStats } from '../context/relevance.js';
25
+ import { formatArtifactTokenSources } from '../context/artifacts.js';
25
26
  import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, appendCurrentSessionRuntimeEvent, hashTraceValue, } from '../session/index.js';
26
27
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, getCurrentTurnId, } from '../rollback/index.js';
27
28
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
@@ -223,11 +224,15 @@ function renderContextBar(history) {
223
224
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
224
225
  const lifecycle = contextState.lifecycleStats;
225
226
  const archived = computePruneStats(history);
227
+ const artifactStats = contextState.artifactStats;
228
+ const artifactLine = artifactStats
229
+ ? `\n artifacts · fresh ${artifactStats.fresh} · stale ${artifactStats.stale} · stubbed ${archived.stubbed} · tokens ${formatArtifactTokenSources(artifactStats)}`
230
+ : '\n artifacts · no file-backed facts recorded';
226
231
  const lifecycleLine = lifecycle
227
232
  ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
228
233
  : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
229
234
  const archiveLine = `\n archived tool results · ${archived.stubbed}`;
230
- return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${lifecycleLine}${archiveLine}`;
235
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
231
236
  }
232
237
  /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
233
238
  * 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
@@ -485,6 +490,10 @@ function echoInput(lines, trailingBlank = true) {
485
490
  for (const a of pendingAttachments) {
486
491
  layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
487
492
  }
493
+ // 用户气泡会填满终端整列宽;Windows Terminal 在末列进入 pending-wrap 后,紧随的 LF
494
+ // 偶尔只更新内部滚屏状态,导致上一轮耗时行与新气泡暂时黏连。缓冲中的物理行始终正确,
495
+ // 提交后立即按缓冲重绘,避免必须等滚动或 Agent 结束时的 repaint 才显示正确边界。
496
+ layout.repaintViewport();
488
497
  }
489
498
  /**
490
499
  * 等待 pending 撤回窗口(用户 Enter 后、agent 真发请求前的 500ms 兜底)。
@@ -637,6 +646,12 @@ export function renderHistory(history) {
637
646
  }
638
647
  const tcs = m.tool_calls;
639
648
  if (Array.isArray(tcs) && tcs.length > 0) {
649
+ if (text) {
650
+ // contentWriteMdOnce 会裁掉 markdown 尾部空行,而 history 中的原始 text 是否以 \n
651
+ // 结尾并不能代表当前物理布局。按缓冲中的视觉行归一化,和实时“正文 → 工具”
652
+ // 边界一致地保留一条空行,避免 /resume 回放时工具摘要紧贴正文。
653
+ layout.normalizeMutationBoundary();
654
+ }
640
655
  // 累积到 pendingBatch,顺序 = tool_calls 序
641
656
  for (const tc of tcs) {
642
657
  const name = tc?.function?.name ?? '';
@@ -717,7 +732,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
717
732
  // 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
718
733
  // 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
719
734
  // ③ buildMemorySection 内已自决 ;④ buildMemoryIndexSection 显式传 isMemoryEnabled() 关闭段。
720
- const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt() +
735
+ const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
721
736
  (planMode ? getPlanModeSuffix() : '') +
722
737
  buildMemorySection() +
723
738
  buildMemoryIndexSection(isMemoryEnabled()));
@@ -891,6 +906,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
891
906
  rolledBackTurns: Math.max(0, turnCountBeforeRollback - plan.n),
892
907
  deletedMessages: rollbackResult.deletedMsgs,
893
908
  revertedFiles: rollbackResult.revertedFiles,
909
+ conflictedFiles: rollbackResult.conflictedFiles,
894
910
  requestedFileCount: revertPaths.size,
895
911
  }, plan.cutoffTurnId);
896
912
  try {
@@ -903,6 +919,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
903
919
  // 复显剩余对话(无提示行),输入框预填该轮 user 输入 → 下轮 Enter 重新跑
904
920
  layout.clearContent();
905
921
  renderHistory(history);
922
+ if (rollbackResult.conflictedFiles.length > 0) {
923
+ layout.contentWrite(`${ui.yellow} ⚠ rollback conflict: ${rollbackResult.conflictedFiles.join(', ')} 已在 Agent 提交后变化,未覆盖。${ui.reset}\n`);
924
+ }
906
925
  // 末尾补空行:与后续用户消息(❯ bubble)之间分隔。runTurn 在每个 agent 轮结束后
907
926
  // contentWrite('\n') 做轮次分隔,/resume 后接 \n\n,/theme·/model 后接 \n;
908
927
  // rollbackFlow 原本漏了这一行,renderHistory 末尾的 batch 摘要行 / assistant 文本
@@ -1021,6 +1040,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1021
1040
  layout.contentWrite(`${ui.yellow}${t('repl.loadFailed')}${ui.reset}\n`);
1022
1041
  return;
1023
1042
  }
1043
+ // Bind before rebuilding the prompt, or it can retain the previous session's notes path.
1044
+ currentSessionId = loaded.id;
1045
+ setCurrentSessionId(loaded.id, process.cwd());
1024
1046
  if (loaded.history[0]?.role === 'system') {
1025
1047
  loaded.history[0] = { role: 'system', content: buildSystemMessage(false) };
1026
1048
  }
@@ -1030,8 +1052,6 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1030
1052
  ? [...loaded.queryHistory]
1031
1053
  : queryHistoryFromMessages(loaded.history);
1032
1054
  setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
1033
- currentSessionId = loaded.id;
1034
- setCurrentSessionId(loaded.id, process.cwd()); // 切换会话:确保该会话的 notes.md 存在
1035
1055
  // 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
1036
1056
  if (!loadSnapshots(loaded.id))
1037
1057
  rebuildFromHistory(history);
@@ -1043,6 +1063,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1043
1063
  // 末尾 \n\n:与后续用户消息(❯ bubble)之间空一行。
1044
1064
  layout.contentWrite(`${ui.dim}${t('repl.resumed', { id: loaded.id })}${ui.reset}\n\n`);
1045
1065
  }
1066
+ let hasSubmittedInput = false;
1046
1067
  while (true) {
1047
1068
  // INPUT 态:画底栏输入框 + 状态行,光标入输入框
1048
1069
  refreshStatusBase(history);
@@ -1053,6 +1074,10 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1053
1074
  layout.contentWrite(` ${ui.gray}↳ ${formatReflectResult(reflectRes)}${ui.reset}\n`);
1054
1075
  clearLastReflectResult();
1055
1076
  }
1077
+ // 所有斜杠命令和 Agent 轮次共用同一个输出→输入边界,避免某条命令漏写第二个 \n
1078
+ // 后下一条 ❯ 气泡紧贴确认文案;已有多余空行也会收敛为恰好一行。
1079
+ if (hasSubmittedInput)
1080
+ layout.normalizeInputBoundary();
1056
1081
  layout.enterInputMode(t('repl.idle'));
1057
1082
  let input = null;
1058
1083
  try {
@@ -1080,6 +1105,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1080
1105
  const line = joined.trim();
1081
1106
  if (!line)
1082
1107
  continue;
1108
+ hasSubmittedInput = true;
1083
1109
  if (line === '/exit' || line === '/quit')
1084
1110
  break;
1085
1111
  // RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
@@ -2029,7 +2055,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
2029
2055
  const initPrompt = `请直接初始化或优化当前项目的 Project Skill,并完成写入。
2030
2056
 
2031
2057
  要求:
2032
- 1. 直接由你完成,禁止调用 task 工具或派生任何子 agent。
2058
+ 1. 直接由你完成,禁止调用 sub-agent 工具或派生任何子 agent。
2033
2059
  2. 优先利用系统提示中已有的 Project Snapshot 和 Project Skill;不要重复扫描其中已有的目录、依赖、命令和模块清单。
2034
2060
  3. 最多进行 1 次 codegraph 探索;只有缺少关键依据时,才额外进行少量定点 read_file/grep。禁止全仓 glob 和逐文件扫描。
2035
2061
  4. Skill 只记录 Snapshot 无法提供的 WHY/HOW/GOTCHAS/CONVENTIONS:设计取舍、关键调用链、非直觉边界、项目约定和可操作坑点。使用具体路径和例子,删除重复或过时内容。
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { config } from '../config/index.js';
@@ -52,6 +53,11 @@ function readState(full) {
52
53
  function sameState(a, b) {
53
54
  return a.kind === b.kind && a.data === b.data && a.mode === b.mode;
54
55
  }
56
+ function stateFingerprint(state) {
57
+ return createHash('sha256')
58
+ .update(JSON.stringify([state.kind, state.data ?? null, state.mode ?? null]))
59
+ .digest('hex');
60
+ }
55
61
  function stateFromSnapshot(snapshot) {
56
62
  if (snapshot.kind) {
57
63
  return { kind: snapshot.kind, data: snapshot.before ?? undefined, mode: snapshot.mode };
@@ -64,7 +70,7 @@ function stateFromSnapshot(snapshot) {
64
70
  data: Buffer.from(snapshot.before, 'utf8').toString('base64'),
65
71
  };
66
72
  }
67
- function snapshotFromState(rel, state, sequence, op, createdParents = []) {
73
+ function snapshotFromState(rel, state, sequence, op, createdParents = [], after) {
68
74
  return {
69
75
  turnId: currentTurnId,
70
76
  path: rel,
@@ -75,6 +81,7 @@ function snapshotFromState(rel, state, sequence, op, createdParents = []) {
75
81
  sequence,
76
82
  ops: [op],
77
83
  createdParents: createdParents.length > 0 ? createdParents : undefined,
84
+ afterFingerprint: after ? stateFingerprint(after) : undefined,
78
85
  };
79
86
  }
80
87
  /** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
@@ -90,11 +97,15 @@ function addSnapshot(next) {
90
97
  const existingSequence = existing.sequence ?? Number.MAX_SAFE_INTEGER;
91
98
  const nextSequence = next.sequence ?? Number.MAX_SAFE_INTEGER;
92
99
  const ops = new Set([...(existing.ops ?? []), ...(next.ops ?? [])]);
100
+ const latestAfterFingerprint = nextSequence >= existingSequence
101
+ ? next.afterFingerprint
102
+ : existing.afterFingerprint;
93
103
  if (nextSequence < existingSequence) {
94
- snapshots[existingIndex] = { ...next, ops: [...ops] };
104
+ snapshots[existingIndex] = { ...next, ops: [...ops], afterFingerprint: latestAfterFingerprint };
95
105
  }
96
106
  else {
97
107
  existing.ops = [...ops];
108
+ existing.afterFingerprint = latestAfterFingerprint;
98
109
  }
99
110
  }
100
111
  function missingParents(full) {
@@ -139,14 +150,14 @@ export function endPathMutation(capture, op) {
139
150
  const after = readState(full);
140
151
  if (!sameState(capture.before, after)) {
141
152
  changed = true;
142
- addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents));
153
+ addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents, after));
143
154
  }
144
155
  // write_file 会递归创建父目录;即使最终写文件失败,这些目录也是本轮真实副作用。
145
156
  for (const parentRel of capture.createdParents) {
146
157
  const parent = safeFullPath(parentRel);
147
158
  if (parent && readState(parent).kind !== 'missing') {
148
159
  changed = true;
149
- addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op));
160
+ addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op, [], readState(parent)));
150
161
  }
151
162
  }
152
163
  if (changed)
@@ -202,7 +213,7 @@ export function endWorkspaceMutation(capture, op) {
202
213
  if (sameState(beforeState, afterState))
203
214
  continue;
204
215
  changed = true;
205
- addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op));
216
+ addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op, [], afterState));
206
217
  }
207
218
  if (changed)
208
219
  mutationVersion += 1;
@@ -285,7 +296,11 @@ function restoreSnapshot(snapshot) {
285
296
  const state = stateFromSnapshot(snapshot);
286
297
  try {
287
298
  if (state.kind === 'missing') {
288
- rmSync(full, { recursive: true, force: true });
299
+ const current = readState(full);
300
+ if (current.kind === 'directory')
301
+ rmdirSync(full);
302
+ else
303
+ rmSync(full, { recursive: false, force: true });
289
304
  for (const parentRel of snapshot.createdParents ?? []) {
290
305
  const parent = safeFullPath(parentRel);
291
306
  if (!parent)
@@ -329,9 +344,16 @@ export function applyRollback(plan, history, revertPaths) {
329
344
  const deletedMsgs = history.length - plan.cutoffIndex;
330
345
  history.length = plan.cutoffIndex;
331
346
  const picks = new Map();
347
+ const latest = new Map();
332
348
  for (const snapshot of snapshots) {
333
349
  if (snapshot.turnId <= plan.cutoffTurnId || !revertPaths.has(snapshot.path))
334
350
  continue;
351
+ const latestSnapshot = latest.get(snapshot.path);
352
+ if (!latestSnapshot || snapshot.turnId > latestSnapshot.turnId ||
353
+ (snapshot.turnId === latestSnapshot.turnId &&
354
+ (snapshot.sequence ?? -1) > (latestSnapshot.sequence ?? -1))) {
355
+ latest.set(snapshot.path, snapshot);
356
+ }
335
357
  const existing = picks.get(snapshot.path);
336
358
  if (!existing ||
337
359
  snapshot.turnId < existing.turnId ||
@@ -341,7 +363,18 @@ export function applyRollback(plan, history, revertPaths) {
341
363
  picks.set(snapshot.path, snapshot);
342
364
  }
343
365
  }
344
- const selected = [...picks.values()];
366
+ const selected = [];
367
+ const conflictedFiles = [];
368
+ for (const snapshot of picks.values()) {
369
+ const expected = latest.get(snapshot.path)?.afterFingerprint;
370
+ const full = safeFullPath(snapshot.path);
371
+ if (expected && (!full || stateFingerprint(readState(full)) !== expected)) {
372
+ conflictedFiles.push(snapshot.path);
373
+ }
374
+ else {
375
+ selected.push(snapshot);
376
+ }
377
+ }
345
378
  // 先深到浅删除本轮新建项,再浅到深恢复原目录/文件。
346
379
  const removals = selected
347
380
  .filter((item) => stateFromSnapshot(item).kind === 'missing')
@@ -353,11 +386,13 @@ export function applyRollback(plan, history, revertPaths) {
353
386
  for (const snapshot of [...removals, ...restores]) {
354
387
  if (restoreSnapshot(snapshot))
355
388
  revertedFiles.push(snapshot.path);
389
+ else if (!conflictedFiles.includes(snapshot.path))
390
+ conflictedFiles.push(snapshot.path);
356
391
  }
357
392
  turns = turns.filter((turn) => turn.turnId <= plan.cutoffTurnId);
358
393
  snapshots = snapshots.filter((snapshot) => snapshot.turnId <= plan.cutoffTurnId);
359
394
  currentTurnId = turns.at(-1)?.turnId ?? 0;
360
- return { deletedMsgs, revertedFiles };
395
+ return { deletedMsgs, revertedFiles, conflictedFiles };
361
396
  }
362
397
  export function pruneAfterCompaction(history) {
363
398
  const count = history.filter((message) => message.role === 'user').length;
@@ -1,5 +1,5 @@
1
1
  // 沙箱子系统 barrel。叶子:仅 node:path / node:fs,不反向依赖业务。
2
- export { getSandboxRoot, setSandboxRoot } from './root.js';
2
+ export { getSandboxRoot, setSandboxRoot, withSandboxRoot } from './root.js';
3
3
  export { jailResolve, jailGlobPattern, isInsideRoot } from './jail.js';
4
4
  export { filterEnv, isCommandDenied } from './command.js';
5
5
  export { SANDBOX_EXEMPT_TOOLS, SANDBOX_PATH_TOOLS, enforceSandbox, } from './policy.js';
@@ -8,7 +8,7 @@ import { jailResolve, jailGlobPattern } from './jail.js';
8
8
  * - web_*:跨网络,非文件路径
9
9
  * - ask_human / switch_mode:无文件路径
10
10
  * - codegraph:只读 cwd 下 .codegraph/ 索引(只读、不写盘)
11
- * - task:派生子 agent,继承全局 root(子 agent 同进程天然共享 getSandboxRoot)
11
+ * - sub-agent:派生子 agent,通过 scoped sandbox root 使用隔离 overlay
12
12
  */
13
13
  export const SANDBOX_EXEMPT_TOOLS = new Set([
14
14
  'memory_save', 'memory_update', 'memory_forget', 'memory_search', 'memory_list',
@@ -16,7 +16,7 @@ export const SANDBOX_EXEMPT_TOOLS = new Set([
16
16
  'web_search', 'web_fetch',
17
17
  'ask_human', 'switch_mode',
18
18
  'codegraph',
19
- 'task',
19
+ 'sub-agent',
20
20
  ]);
21
21
  /**
22
22
  * 路径类工具:enforceSandbox 集中把 args.path 重写为牢内绝对路径(默认安全;工具内
@@ -3,12 +3,18 @@
3
3
  // —— 全是「业务 → 叶子」,同 src/agent/mode.ts。
4
4
  //
5
5
  // 设计:sandboxRoot 是纯边界记录(默认 = process.cwd(),不 chdir),避免与 config.sessionDir 等
6
- // 模块加载期计算的值产生错位(若 chdir 会令那些值变陈旧)。子 agent 同进程天然继承全局 root
7
- // (未来 agents/ worktree 隔离时再改为沿 opts 透传,照 signal 同形链路,7 处改动)
6
+ // 模块加载期计算的值产生错位(若 chdir 会令那些值变陈旧)。主 Agent 使用全局 root
7
+ // 并发子 Agent 通过 AsyncLocalStorage 获得互不干扰的 overlay root
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
9
  let currentRoot = null;
10
+ const scopedRoots = new AsyncLocalStorage();
9
11
  /** 当前沙箱根(绝对路径)。未初始化返 null,调用方 ?? process.cwd() 兜底(防御)。 */
10
12
  export function getSandboxRoot() {
11
- return currentRoot;
13
+ return scopedRoots.getStore() ?? currentRoot;
14
+ }
15
+ /** Run one asynchronous agent against an isolated sandbox without changing sibling roots. */
16
+ export function withSandboxRoot(root, run) {
17
+ return scopedRoots.run(root, run);
12
18
  }
13
19
  /** 设置沙箱根。repl startRepl 启动时调一次(默认 process.cwd();--sandbox-root / SANDBOX_ROOT 可覆盖)。
14
20
  * 返回之前的值,供未来 save/restore(子 agent worktree 隔离)用。 */