mocode-ai 1.2.0 → 1.2.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.
@@ -554,7 +554,7 @@ export async function runAgentCore(opts) {
554
554
  for (const tc of batch)
555
555
  hooks.onToolHeader?.(tc);
556
556
  hooks.onToolStart?.(batch[0].name);
557
- const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal));
557
+ const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, { callId: tc.id }));
558
558
  for (let k = 0; k < batch.length; k++) {
559
559
  const tc = batch[k];
560
560
  const outcome = await started[k];
@@ -627,6 +627,7 @@ export async function runAgentCore(opts) {
627
627
  const started = entries.map((entry) => entry.denied
628
628
  ? Promise.resolve(entry.denied)
629
629
  : executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
630
+ callId: entry.tc.id,
630
631
  onLockAcquired: (lockedArgs) => {
631
632
  entry.diff = readDiffContext(entry.tc, lockedArgs);
632
633
  },
@@ -715,6 +716,7 @@ export async function runAgentCore(opts) {
715
716
  let diff = readDiffContext(tc, mutationParsed);
716
717
  hooks.onToolStart?.(tc.name);
717
718
  const outcome = await executeToolOutcome(tc.name, tc.arguments, signal, {
719
+ callId: tc.id,
718
720
  onLockAcquired: (lockedArgs) => {
719
721
  if (mutationParsed)
720
722
  diff = readDiffContext(tc, lockedArgs);
@@ -7,6 +7,7 @@ import { Spinner } from '../ui/spinner.js';
7
7
  import { summarizeToolCall, summarizeToolResult, truncateDisplay, fmtElapsed, } from '../ui/render.js';
8
8
  import { renderFileChange } from '../ui/diff.js';
9
9
  import * as layout from '../ui/layout.js';
10
+ import * as content from '../ui/content.js';
10
11
  import * as batch from '../ui/batch.js';
11
12
  import { beginTurn } from '../rollback/index.js';
12
13
  import { config } from '../config/index.js';
@@ -17,6 +18,27 @@ import { isToolErrorOutput } from '../tools/result.js';
17
18
  import { appendCurrentSessionTraceEvent } from '../session/index.js';
18
19
  /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
19
20
  let currentBatchId = null;
21
+ /** 当前活跃的 sub-agent 组容器批 id(同一轮并行派发的多个 sub-agent 合并到这一个顶层摘要行下)。
22
+ * null 表示当前没有进行中的子 agent 组。 */
23
+ let subAgentGroupId = null;
24
+ /** sub-agent 组收口后,与后续内容(普通工具/正文)之间欠一条分隔空行。
25
+ * 由 flushToolBatch / onText 在合适位置补,尾部已空则不再叠。 */
26
+ let subAgentGroupPendingSeparator = false;
27
+ /** 派生子 agent 的工具名。它的调用要独占一批:子 agent 的实时工具明细会挂到这一行下面,
28
+ * 并行派发多个子 agent 时,每个子 agent 才有自己可归属的摘要行。 */
29
+ const SUB_AGENT_TOOL = 'sub-agent';
30
+ /** 缓冲尾部是否已经是空白行(去掉 ANSI 后无可见字符)。用于避免 sub-agent 分隔空行叠成两行。 */
31
+ function isLastContentRowBlank() {
32
+ // 用 committedRows 而不是 totalRows:hasCurrent 那行是未提交的光标等待位,
33
+ // 永远空白,不能把它当作“已经有一条空行分隔”。
34
+ const committed = content.committedRows();
35
+ if (committed === 0)
36
+ return false;
37
+ const line = content.lineAt(committed - 1);
38
+ if (line === null)
39
+ return false;
40
+ return line.replace(/\x1b\[[0-9;]*m/g, '').trim().length === 0;
41
+ }
20
42
  let turnFileChanges = [];
21
43
  function lineDelta(oldText, newText) {
22
44
  const before = oldText ? oldText.split('\n') : [];
@@ -68,20 +90,82 @@ function firstLineOf(ui) {
68
90
  * 重构后改为累积到 BatchRenderer,onToolBatchEnd 时统一打摘要行;
69
91
  * 展开/折叠由 BatchRenderer + 鼠标 release 决定,本函数不再直接写屏。 */
70
92
  function writeToolHeader(tc) {
71
- // 改文件工具是 batch 屏障:先收尾之前的普通工具,确保 mutation 永远独占一批。
72
- if (isMutationTool(tc.name))
93
+ if (tc.name === SUB_AGENT_TOOL) {
94
+ // sub-agent:同一轮并行派发的多个 sub-agent 合并进同一个「组容器批」——
95
+ // 顶层只有一个 ● 探索 N ... sub-agent N 摘要行,下面按 └─ sub-agent {...} 逐条
96
+ // 列出每个子 agent 调用,各自子批(实时工具)更深一层缩进。
97
+ // 注意:sub-agent header 到来时**不要** flushToolBatch,否则会把正在进行的组容器批收口。
98
+ if (subAgentGroupId == null) {
99
+ // 首个 sub-agent 调用前:若前面已经有一个普通工具批在跑,先收口它并补分隔空行,
100
+ // 避免普通批摘要行与 sub-agent 组摘要行粘在一起。
101
+ if (currentBatchId)
102
+ flushToolBatch();
103
+ // 首个 sub-agent 调用:建组容器批(顶层)。label 缺省=「探索」,entries 累计各 sub-agent 调用。
104
+ subAgentGroupId = batch.beginBatch(undefined, { groupParent: true });
105
+ batch.recordCall(subAgentGroupId, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
106
+ batch.bindCall(tc.id, subAgentGroupId);
107
+ batch.showLiveBatch(subAgentGroupId, layout);
108
+ // 立即展开第一组 └─ sub-agent 行(运行态不锚定视口),让子 agent 调用马上可见。
109
+ batch.expandBatch(subAgentGroupId, layout, true);
110
+ }
111
+ else {
112
+ // 同一轮并行派发的后续 sub-agent:向组追加 entry,实时刷新进度。
113
+ batch.recordCall(subAgentGroupId, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
114
+ batch.bindCall(tc.id, subAgentGroupId);
115
+ // 组已展开:追加新的 └─ sub-agent 行(插到已渲染 entry 之后),并刷新顶层摘要行计数。
116
+ batch.refreshBatchExpanded(subAgentGroupId, layout);
117
+ batch.showLiveBatch(subAgentGroupId, layout);
118
+ }
119
+ return;
120
+ }
121
+ // sub-agent 组还在跑但普通/mutation 工具先来了:收口 sub-agent 组,并补一条分隔空行。
122
+ if (subAgentGroupId) {
73
123
  flushToolBatch();
74
- if (!currentBatchId)
75
- currentBatchId = batch.beginBatch();
76
- batch.recordCall(currentBatchId, tc.name, summarizeToolCall(tc.name, tc.arguments));
77
- // 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
78
- batch.showLiveBatch(currentBatchId, layout);
124
+ }
125
+ if (isMutationTool(tc.name)) {
126
+ // mutation 永远独占一批(diff 要紧跟调用行)。先收口当前普通批。
127
+ if (currentBatchId)
128
+ flushToolBatch();
129
+ const id = batch.beginBatch();
130
+ currentBatchId = id;
131
+ batch.bindCall(tc.id, id);
132
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
133
+ batch.showLiveBatch(id, layout);
134
+ }
135
+ else {
136
+ // 普通工具合并到 currentBatchId;同轮并行或连续无正文的工具轮次共享一个摘要行。
137
+ const id = currentBatchId ??= batch.beginBatch();
138
+ // 结果按 tool_call id 归位:并行执行时 currentBatchId 会漂移,只按“当前批”回填会漏填,
139
+ // 摘要行就永远停在 ◇(用户实测:子 agent 跑完主侧菱形没变成实心圆)。
140
+ batch.bindCall(tc.id, id);
141
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
142
+ // 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
143
+ batch.showLiveBatch(id, layout);
144
+ }
79
145
  }
80
146
  /** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮);其余走一行 preview。
81
147
  * 同 writeToolHeader,改为累积到 BatchRenderer(只缓存字符串,不写屏)。 */
82
148
  function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
83
- if (!currentBatchId)
149
+ // 优先按 tool_call id 反查所属批(并行 / sub-agent 组时 currentBatchId 已不是它)。
150
+ const batchId = batch.batchIdForCall(tc.id) ?? currentBatchId;
151
+ if (!batchId)
84
152
  return;
153
+ if (tc.name === SUB_AGENT_TOOL) {
154
+ // sub-agent 调用只是组容器批的一个 entry:结果回填到对应 entry 即可,
155
+ // 无需独立批。全部 entry 完成后收口组容器批(落完成态 + 开放点击展开),
156
+ // 并与后续内容约定一条分隔空行。
157
+ const preview = summarizeToolResult(tc.name, output);
158
+ batch.recordResult(batchId, tc.name, preview, null, output, isToolErrorOutput(output), tc.id);
159
+ if (batch.isBatchComplete(batchId)) {
160
+ batch.endBatch(batchId, layout);
161
+ subAgentGroupId = null;
162
+ subAgentGroupPendingSeparator = true;
163
+ }
164
+ else {
165
+ batch.showLiveBatch(batchId, layout);
166
+ }
167
+ return;
168
+ }
85
169
  let diff = null;
86
170
  if ((tc.name === 'edit_file' || tc.name === 'write_file') && parsed && !isToolErrorOutput(output)) {
87
171
  const oldText = tc.name === 'edit_file' ? String(parsed.old_string ?? '') : preWriteOld;
@@ -102,16 +186,42 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
102
186
  });
103
187
  }
104
188
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
105
- batch.recordResult(currentBatchId, tc.name, preview, diff, output, isToolErrorOutput(output));
106
- batch.showLiveBatch(currentBatchId, layout);
189
+ batch.recordResult(batchId, tc.name, preview, diff, output, isToolErrorOutput(output));
190
+ batch.showLiveBatch(batchId, layout);
107
191
  // mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
108
192
  if (isMutationTool(tc.name))
109
- flushToolBatch(true);
193
+ finishStandaloneBatch(batchId, true);
194
+ }
195
+ /** 收尾一个独占批(mutation / sub-agent)。按 id 收尾而不是按 currentBatchId:
196
+ * 同一轮里 mutation 与 sub-agent 混排时,后者的 header 已经把前者切走,
197
+ * 只认 currentBatchId 会漏掉 diff 展开、把摘要永久留在未完成态。endBatch 幂等。 */
198
+ function finishStandaloneBatch(id, expandSingleEntry) {
199
+ if (currentBatchId === id)
200
+ currentBatchId = null;
201
+ batch.endBatch(id, layout);
202
+ if (expandSingleEntry)
203
+ batch.expandSingleEntryFully(id, layout);
204
+ // mutation 独占批收尾:分隔空行由 flushToolBatch 负责(见 subAgentGroupPendingSeparator);
205
+ // 不再有 sub-agent 独占批——sub-agent 已合并进组容器批。
110
206
  }
111
207
  /** 将跨 LLM 工具轮次累计的调用写入内容区;正文开始或整个 turn 收尾时才切批。 */
112
208
  function flushToolBatch(expandSingleEntry = false) {
113
- if (!currentBatchId)
209
+ if (!currentBatchId) {
210
+ // 异常路径:组容器批尚未收口(理论上最后一个 sub-agent 结果时已收口),这里补收口。
211
+ if (subAgentGroupId) {
212
+ const id = subAgentGroupId;
213
+ subAgentGroupId = null;
214
+ batch.endBatch(id, layout);
215
+ subAgentGroupPendingSeparator = true;
216
+ }
217
+ // 组 / 工具块与后续内容之间补一条分隔空行(尾部已空则不叠,避免空两行)。
218
+ if (subAgentGroupPendingSeparator) {
219
+ if (!isLastContentRowBlank())
220
+ layout.contentWrite('\n');
221
+ subAgentGroupPendingSeparator = false;
222
+ }
114
223
  return;
224
+ }
115
225
  const id = currentBatchId;
116
226
  currentBatchId = null;
117
227
  batch.endBatch(id, layout);
@@ -120,6 +230,7 @@ function flushToolBatch(expandSingleEntry = false) {
120
230
  // 普通批:endBatch 留了 1 个 hasCurrent 空行,break 一次把它提交为分隔空行。
121
231
  // mutation 自动展开:expandSingleEntryFully 自己补 separator (\n),这里不能再补 \n,
122
232
  // 不然 diff 后面就会出现两条空白行。
233
+ subAgentGroupPendingSeparator = false;
123
234
  if (!expandSingleEntry)
124
235
  layout.contentWrite('\n');
125
236
  }
@@ -144,6 +255,8 @@ onContextUpdate) {
144
255
  beginTurn(truncateDisplay(firstLineOf(userInput), 40));
145
256
  layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
146
257
  currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
258
+ subAgentGroupId = null;
259
+ subAgentGroupPendingSeparator = false;
147
260
  turnFileChanges = [];
148
261
  // spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
149
262
  // 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
@@ -164,9 +277,11 @@ onContextUpdate) {
164
277
  onText: (s) => {
165
278
  // 纯空白 chunk 在视觉上不是正文:既不切 batch,也不写入 markdown 缓冲。
166
279
  // 部分兼容后端会在连续工具轮次间流出 " " / "\n",若据此切批会漏掉首个工具。
167
- if (currentBatchId && s.trim().length === 0)
280
+ // sub-agent 独占批不占 currentBatchId,但同样处在“工具块刚结束”的边界上。
281
+ const inToolBlock = currentBatchId !== null || subAgentGroupId !== null || subAgentGroupPendingSeparator;
282
+ if (inToolBlock && s.trim().length === 0)
168
283
  return;
169
- const followsToolBatch = currentBatchId !== null;
284
+ const followsToolBatch = inToolBlock;
170
285
  // batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
171
286
  // 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
172
287
  const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
@@ -1,9 +1,10 @@
1
- // 子 agent 封装:runAgentCore + 静默 hooks。独立 history / 可受限工具子集 / 低步数上限。
1
+ // 子 agent 封装:runAgentCore + (TUI 激活时)实时渲染 hooks。独立 history / 可受限工具子集 / 低步数上限。
2
2
  // 供 task 工具(主 agent 派生子任务)调用——子 agent 的最终摘要回灌主 history。
3
3
  //
4
4
  // 与主 agent 的区别:
5
- // - 不写主屏(layout.contentWrite):中间过程(流式正文 / 工具头 / diff)缓冲到内部字符串,
6
- // 结束返回给 task 工具(task 把它当 tool 结果回灌主 history,主 agent 据此继续)
5
+ // - 主屏渲染可选:全屏 TUI 激活时(layout.isTuiActive()),把子 agent 内部工具调用实时写入
6
+ // 主内容区并复用 batch 折叠机制(运行态逐条展开,执行完自动折叠回单行摘要,鼠标可点开查看);
7
+ // TUI 未激活(host 嵌入 / 非 TTY)时保持纯静默——中间过程只缓冲进 transcript,不写主屏。
7
8
  // - 独立 history:不共享主对话,避免子任务的工具噪声污染主上下文。
8
9
  // - 紧凑系统提示:不复制主 agent 的 memory/skills/项目快照;由 context 传入已知事实。
9
10
  // - 工具子集:写任务默认继承主 Agent 工具(仅禁止递归 task);只读模式按安全语义移除写工具。
@@ -15,8 +16,13 @@ import { chatTools } from '../llm/index.js';
15
16
  import { buildMocodeCorePrompt, config, isSubAgentEnabled } from '../config/index.js';
16
17
  import { effectiveSystemPrompt } from '../skills/index.js';
17
18
  import { ui } from '../ui/theme.js';
19
+ import * as layout from '../ui/layout.js';
20
+ import { isTuiActive } from '../ui/layout.js';
21
+ import * as batch from '../ui/batch.js';
22
+ import { isToolErrorOutput } from '../tools/result.js';
18
23
  import { runAgentCore } from './core.js';
19
24
  import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
25
+ import { t } from '../i18n/index.js';
20
26
  import { createContextState } from '../session/compact.js';
21
27
  import { inOverlay, mergeSubAgentChangeSet } from '../agents/coordinator.js';
22
28
  /** 子 agent 系统提示后缀:角色与约束。 */
@@ -84,6 +90,61 @@ export async function spawnAgent(opts) {
84
90
  buf.push(s);
85
91
  transcript += s;
86
92
  };
93
+ // 主屏实时渲染(子 agent 透明化):TUI 激活时把子 agent 内部步骤实时写入主内容区,
94
+ // 复用 batch 折叠机制(mouse 点击摘要行可展开/收起)。TUI 未激活(host/非 TTY)时
95
+ // 保持纯静默——只缓冲 transcript,不写屏,兼容嵌入宿主。
96
+ const live = isTuiActive();
97
+ let liveBatchId = null;
98
+ const liveLayout = () => ({
99
+ contentWrite: (s) => layout.contentWrite(s),
100
+ contentReplaceLine: (absIdx, line) => layout.contentReplaceLine(absIdx, line),
101
+ contentInsertAfter: (after, lines, keepViewport) => layout.contentInsertAfter(after, lines, keepViewport),
102
+ contentDeleteFrom: (startIdx, n) => layout.contentDeleteFrom(startIdx, n),
103
+ totalRows: () => layout.totalRows(),
104
+ repaintViewport: () => layout.repaintViewport(),
105
+ isScrolled: () => layout.isScrolled(),
106
+ });
107
+ /** 本批是否挂在主侧调用行下(挂上了就由主侧负责分隔空行,自己不能往 buffer 末尾追加)。 */
108
+ let nested = false;
109
+ const ensureLiveBatch = () => {
110
+ if (!live || liveBatchId)
111
+ return;
112
+ // 主侧 sub-agent 组容器批 = 父批;本子 agent 的工具批挂在其下并更深一层缩进,
113
+ // 与同组其它子 agent 各归各的 └─ sub-agent 行(通过 groupChildIndex 定锚点)。
114
+ const parentId = batch.batchIdForCall(opts.callId) ?? undefined;
115
+ nested = parentId != null;
116
+ const childIndex = parentId ? batch.getGroupChildIndex(opts.callId) : undefined;
117
+ liveBatchId = batch.beginBatch(t('subagent.running'), {
118
+ parentId,
119
+ indent: parentId ? ' ' : undefined,
120
+ groupChildIndex: childIndex,
121
+ running: true,
122
+ });
123
+ batch.showLiveBatch(liveBatchId, liveLayout());
124
+ // 默认折叠:只显示一行「子 Agent 运行中 · glob X read_file Y」,不展开明细;
125
+ // 用户点击摘要行后才展开看具体工具调用。
126
+ };
127
+ const finishLiveBatch = (status) => {
128
+ if (!live || !liveBatchId)
129
+ return;
130
+ const id = liveBatchId;
131
+ liveBatchId = null;
132
+ if (status)
133
+ batch.setBatchLabel(id, status === 'complete'
134
+ ? t('subagent.complete')
135
+ : status === 'failed' ? t('subagent.failed') : t('subagent.running'));
136
+ // 收尾:清除运行态标志,摘要行图标从「运行中 ◐」切回完成态 ●。
137
+ batch.setBatchRunning(id, false);
138
+ batch.endBatch(id, liveLayout());
139
+ // 子 agent 完成后自动折叠:endBatch 登记了点击,但默认保持展开态;
140
+ // 这里显式折叠回单行摘要,让执行完的批自动收起(用户可再点开)。
141
+ if (status === 'complete' && batch.isExpanded(id))
142
+ batch.toggleBatch(id, liveLayout());
143
+ // 嵌套批的分隔空行由主侧 flushToolBatch 统一补;这里再写会往 buffer 末尾插孤儿空行
144
+ // (并行子 agent 各写一条,块尾堆出空白)。
145
+ if (!nested)
146
+ layout.contentWrite('\n');
147
+ };
87
148
  let lastChar = '';
88
149
  const hooks = {
89
150
  onText: (s) => {
@@ -101,11 +162,24 @@ export async function spawnAgent(opts) {
101
162
  onToolHeader: (tc) => {
102
163
  const summary = summarizeToolCall(tc.name, tc.arguments);
103
164
  writeBuf(` ● ${tc.name} ${summary}\n`);
165
+ // 实时写入主内容区:子 agent 的每次工具调用累计到摘要行计数。
166
+ ensureLiveBatch();
167
+ if (liveBatchId) {
168
+ batch.recordCall(liveBatchId, tc.name, summary);
169
+ // 默认折叠,只刷新摘要行计数(glob/read_file 数量),不展开明细列表。
170
+ batch.showLiveBatch(liveBatchId, liveLayout());
171
+ }
104
172
  },
105
173
  onToolResult: (tc, output) => {
106
174
  const preview = summarizeToolResult(tc.name, output);
107
175
  if (preview)
108
176
  writeBuf(` ↳ ${preview}\n`);
177
+ if (liveBatchId) {
178
+ // 子 agent 批无 diff(写任务走 overlay,最终由主 agent 合并);空 preview 用占位
179
+ // 标记 entry 已完成,避免折叠后摘要仍显示"运行中"。
180
+ batch.recordResult(liveBatchId, tc.name, preview || t('toolSummary.noOutput'), null, output, isToolErrorOutput(output));
181
+ batch.showLiveBatch(liveBatchId, liveLayout());
182
+ }
109
183
  },
110
184
  onTextEnd: () => {
111
185
  if (lastChar && lastChar !== '\n') {
@@ -113,16 +187,30 @@ export async function spawnAgent(opts) {
113
187
  lastChar = '\n';
114
188
  }
115
189
  },
116
- onToolBatchEnd: () => writeBuf('\n'),
190
+ onToolBatchEnd: () => {
191
+ writeBuf('\n');
192
+ // 子 agent 一轮工具调用结束:仍在运行(可能还有后续轮次),仅推进实时摘要,
193
+ // 不折叠——等 onDone 整体完成才自动收起。
194
+ if (liveBatchId)
195
+ batch.showLiveBatch(liveBatchId, liveLayout());
196
+ },
117
197
  onNoReply: () => writeBuf(`${ui.dim}(无回复)${ui.reset}\n`),
118
- onMaxSteps: () => writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`),
198
+ onMaxSteps: () => {
199
+ writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`);
200
+ finishLiveBatch('failed');
201
+ },
119
202
  onDone: (elapsedMs, usage) => {
120
203
  const tok = usage && usage.totalTokens
121
204
  ? ` · ${usage.totalTokens} tokens${usage.cachedTokens ? ` ↻${usage.cachedTokens} cached` : ''}`
122
205
  : '';
123
206
  writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s${tok}\n`);
207
+ finishLiveBatch('complete');
208
+ },
209
+ onAbort: () => {
210
+ // 中断:子 agent 未完成,收尾批(不折叠——用户可能想看中断前做了什么)。
211
+ finishLiveBatch('aborted');
124
212
  },
125
- // onStepStart / onChatDone / onToolStart / onToolDone / onAbort:子 agent 静默,无需 spinner / 中断渲染。
213
+ // onStepStart / onChatDone / onToolStart / onToolDone:子 agent 静默,无需 spinner 渲染。
126
214
  // abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
127
215
  };
128
216
  // 每个子 agent 独享统计/预算状态。不能保存再恢复模块级单例:多个 task 并发时
@@ -84,7 +84,7 @@ const DEFAULT_VOICE = `## Voice
84
84
  - Give technical recommendations with brief trade-off reasoning when choices exist.
85
85
  - Focus on useful information. Avoid unnecessary greetings, apologies, repetition, or filler.
86
86
  - Match the user's style and language while staying task-focused.
87
- - For long operations, briefly state the plan and expected result. Avoid step-by-step narration.
87
+ - Work quietly: jump straight into tool calls without announcing them; reserve visible text for the final answer and truly important mid-task findings. No step-by-step narration.
88
88
  - State assumptions and ask when uncertain. Do not guess.`;
89
89
  /** 解析用户自定义声音:persona.md 文件优先(项目级 > 全局),其次 env MOCODE_PERSONA。无则返回 ''。 */
90
90
  function readPersonaFile() {
@@ -287,7 +287,7 @@ ${buildCodegraphSection()}
287
287
  ${buildWorkDisciplineSection(inferModelFamily(config.model))}
288
288
 
289
289
  ## Tool policy
290
- - During tool-calling turns, stay silent unless something important enough must reach the user otherwise just call the tool and let it run.
290
+ - Silent Execution: invoke tools directly without preamble. Output visible text ONLY for the final answer and critical mid-task findings. Strictly no step-by-step narration (no "let me…", "让我先…", "now checking…" between calls).
291
291
  - Go directly to a known path or symbol; use discovery tools only when the location is unknown.
292
292
  - Edit against a FRESH read: before any edit_file/write_file, call read_file on the exact path and copy both its latest hash and the exact target text. Never reconstruct old_string from a grep/summary/diff — those lose whitespace and indentation and cause edit failures.
293
293
  - A read_file hash from before a compaction, session resume, edit conflict, or external change is STALE and will be rejected — re-read rather than reuse an old hash.
@@ -209,6 +209,9 @@ const zhCN = {
209
209
  'task.interrupted': '子 agent 被中断,未完成。',
210
210
  'task.noSummary': '子 agent 完成但未返回文本摘要(可能只调了工具或达到步数上限)。',
211
211
  'task.summaryTruncated': '…(子 agent 摘要已截断 {count} 字符)',
212
+ 'subagent.running': '子 Agent 运行中',
213
+ 'subagent.complete': '子 Agent 完成',
214
+ 'subagent.failed': '子 Agent 失败',
212
215
  'subagent.status': '子 Agent:{state}',
213
216
  'subagent.stateOn': '开启',
214
217
  'subagent.stateOff': '关闭',
@@ -461,6 +464,9 @@ const en = {
461
464
  'task.interrupted': 'The sub-agent was interrupted before completion.',
462
465
  'task.noSummary': 'The sub-agent completed without a text summary (it may only have used tools or reached its step limit).',
463
466
  'task.summaryTruncated': '…(sub-agent summary truncated by {count} characters)',
467
+ 'subagent.running': 'Sub-agent running',
468
+ 'subagent.complete': 'Sub-agent complete',
469
+ 'subagent.failed': 'Sub-agent failed',
464
470
  'subagent.status': 'Sub-agent: {state}',
465
471
  'subagent.stateOn': 'enabled',
466
472
  'subagent.stateOff': 'disabled',
@@ -276,9 +276,12 @@ function readPlanStatusFromNotes() {
276
276
  if (!title)
277
277
  return null;
278
278
  const total = (section.match(/^\s*-\s*\[[ xX]\]\s*\d+\./gm) || []).length;
279
- const done = (section.match(/^\s*-\s*\[[xX]\]\s*\d+\./gm) || []).length;
280
- const current = section.match(/^\s*-\s*\[ \]\s*\d+\.\s*(.+)$/m)?.[1].trim();
281
- const summary = `plan: ${title} (${done}/${total})`;
279
+ const currentMatch = section.match(/^\s*-\s*\[ \]\s*(\d+)\.\s*(.+)$/m);
280
+ const current = currentMatch?.[2].trim();
281
+ // 括号进度 = 「当前执行到的步骤序号/总数」(执行第 1 步显示 1/3),与 `▸ 当前步` 后缀自洽;
282
+ // 旧语义 done/total 会永远慢一拍(执行第 2 步显示 1/3)。全勾选时显示 total/total(通常已自动结算为 Done,chip 消失)。
283
+ const activeNo = currentMatch ? Number(currentMatch[1]) : total;
284
+ const summary = `plan: ${title} (${activeNo}/${total})`;
282
285
  // mtime 让“相同内容被重写为一项新计划”也能重新出现,而不被旧轮次误抑制。
283
286
  const fingerprint = `${sessionId}\0${fs.statSync(p).mtimeMs}\0${section}`;
284
287
  return { fingerprint, summary: current ? `${summary} ▸ ${current}` : summary };