mocode-ai 1.4.2 → 1.4.3

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.
Files changed (47) hide show
  1. package/README.md +13 -1
  2. package/dist/agent/core.js +14 -936
  3. package/dist/agent/index.js +37 -13
  4. package/dist/agent/model-turn.js +218 -0
  5. package/dist/agent/pipeline.js +18 -0
  6. package/dist/agent/run-contracts.js +1 -0
  7. package/dist/agent/run-coordinator.js +758 -0
  8. package/dist/agent/runtime-context.js +118 -24
  9. package/dist/agent/spawn.js +11 -7
  10. package/dist/agent/stages/context-trimmer.js +63 -0
  11. package/dist/agent/stages/contracts.js +12 -0
  12. package/dist/agent/stages/history-manager.js +178 -0
  13. package/dist/agent/stages/legacy-adapters.js +19 -0
  14. package/dist/agent/stages/model-runner.js +29 -0
  15. package/dist/agent/stages/run-policy.js +73 -0
  16. package/dist/agent/stages/tool-dispatcher.js +341 -0
  17. package/dist/agent/tool-helpers.js +12 -12
  18. package/dist/agent/tool-turn.js +87 -0
  19. package/dist/agent/trace-state.js +97 -101
  20. package/dist/agent/turn-lifecycle.js +110 -0
  21. package/dist/config/index.js +14 -0
  22. package/dist/host/stdio.js +101 -40
  23. package/dist/llm/index.js +51 -35
  24. package/dist/llm/providers/anthropic.js +16 -10
  25. package/dist/llm/runtime.js +1 -0
  26. package/dist/permissions/index.js +21 -5
  27. package/dist/repl/commands/compact.js +2 -2
  28. package/dist/repl/commands/session.js +3 -12
  29. package/dist/repl/message-format.js +5 -0
  30. package/dist/repl/runtime.js +95 -55
  31. package/dist/rollback/index.js +29 -624
  32. package/dist/rollback/store.js +593 -0
  33. package/dist/runtime/index.js +1 -0
  34. package/dist/runtime/runtime.js +307 -0
  35. package/dist/session/compact.js +22 -14
  36. package/dist/session/index.js +1 -0
  37. package/dist/session/persist.js +10 -146
  38. package/dist/session/scheduler.js +28 -16
  39. package/dist/session/state.js +16 -12
  40. package/dist/session/store.js +218 -0
  41. package/dist/session/trace.js +5 -15
  42. package/dist/tools/policy.js +19 -15
  43. package/dist/tools/registry.js +21 -229
  44. package/dist/tools/router.js +5 -3
  45. package/dist/tools/tool-runtime.js +267 -0
  46. package/dist/ui/layout-internal/content-write.js +4 -0
  47. package/package.json +7 -3
package/dist/llm/index.js CHANGED
@@ -4,7 +4,7 @@ import { tools } from '../tools/registry.js';
4
4
  import { getPlanDisabledTools, getProfileDisabledTools } from '../tools/constants.js';
5
5
  import { ThinkTagFilter } from './think-filter.js';
6
6
  import { sanitizeToolSchemas } from './tool-schema.js';
7
- import { anthropicChatOnce } from './providers/anthropic.js';
7
+ import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
8
8
  import { registerModelProvider, getModelProvider, listModelProviders } from './provider.js';
9
9
  // 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
10
10
  // console.log 把请求/响应直写 stdout,会污染 TUI 输入框(并泄露 headers/URL)。
@@ -33,11 +33,31 @@ const RETRY_JITTER = 0.2;
33
33
  * 与 OpenAI 兼容协议的独立 `reasoning_content` 字段不同,这些模型把 thinking 直接嵌进 content
34
34
  * 字符串,期间不调 onText(spinner 持续转 ⠹ 思考中…),也不写入可见 content(history 不被思考段污染)。
35
35
  */
36
- let client = new OpenAI({
37
- baseURL: config.baseURL,
38
- apiKey: config.apiKey,
39
- maxRetries: 0,
40
- });
36
+ function newOpenAIClient(runtimeConfig) {
37
+ return new OpenAI({
38
+ baseURL: runtimeConfig.baseURL,
39
+ apiKey: runtimeConfig.apiKey,
40
+ maxRetries: 0,
41
+ });
42
+ }
43
+ /** Build mutable client state owned by one runtime; no module-global overrides are consulted. */
44
+ export function createChatClientState(runtimeConfig, overrides = {}) {
45
+ return {
46
+ openAI: newOpenAIClient(runtimeConfig),
47
+ openAICreateImpl: overrides.openAICreateImpl ?? null,
48
+ anthropicFetchImpl: overrides.anthropicFetchImpl ?? fetch,
49
+ };
50
+ }
51
+ /** Rebuild only the OpenAI SDK client while preserving runtime-local test/transport overrides. */
52
+ export function reconfigureChatClient(state, runtimeConfig) {
53
+ state.openAI = newOpenAIClient(runtimeConfig);
54
+ }
55
+ const defaultClientState = createChatClientState(config, { anthropicFetchImpl: defaultAnthropicFetch });
56
+ const defaultProviderRuntime = {
57
+ config,
58
+ getModel: getActiveModel,
59
+ clientState: defaultClientState,
60
+ };
41
61
  /**
42
62
  * 运行时重建 OpenAI 客户端(/model 切换 baseURL/apiKey 后调)。
43
63
  * config.model 已在 chat() 每次读取(热切),但 client 的 baseURL/apiKey 是构造时固化的实例字段,
@@ -45,11 +65,7 @@ let client = new OpenAI({
45
65
  * 子 agent 复用本模块 chat(),故只此一处重建即全链路生效。
46
66
  */
47
67
  export function reconfigureClient() {
48
- client = new OpenAI({
49
- baseURL: config.baseURL,
50
- apiKey: config.apiKey,
51
- maxRetries: 0,
52
- });
68
+ reconfigureChatClient(defaultClientState, config);
53
69
  }
54
70
  // ── 重试 helper(纯函数 + sleep,被 chat() 调用;亦可单测导入验证)──────
55
71
  /** 可被 AbortSignal 取消的 sleep;signal 已 abort 时立即抛 AbortError。 */
@@ -209,10 +225,9 @@ function logRetry(attempt, err, waitMs) {
209
225
  // 不写前导 \n —— contentWrite 由续写位管位置,前置换行会留空行;结尾 \n 由劫持逻辑补。
210
226
  console.error(`[llm] 第 ${attempt}/${RETRY_MAX_ATTEMPTS} 次失败(${tag}: ${msg}),${(waitMs / 1000).toFixed(1)}s 后重试…`);
211
227
  }
212
- let createImplOverride = null;
213
228
  /** 仅供单测用:覆盖 chat() 内部实际调用的 create 桩。生产代码不要碰。 */
214
229
  export function __setChatCreateImpl(impl) {
215
- createImplOverride = impl;
230
+ defaultClientState.openAICreateImpl = impl;
216
231
  }
217
232
  /**
218
233
  * 工具 schema 保持稳定数组引用,再在 MCP 发现或扩展变动后原地刷新;
@@ -249,11 +264,11 @@ export function refreshChatTools() {
249
264
  // 新增 provider 只需 registerModelProvider,不必改 chat()。
250
265
  registerModelProvider({
251
266
  name: 'openai',
252
- chatOnce: (messages, handlers, signal, tools) => chatOnce(messages, handlers, signal, tools),
267
+ chatOnce: (messages, handlers, signal, tools, runtime) => chatOnce(messages, handlers, signal, tools, runtime),
253
268
  });
254
269
  registerModelProvider({
255
270
  name: 'anthropic',
256
- chatOnce: (messages, handlers, signal, tools) => anthropicChatOnce(messages, handlers, signal, tools ?? chatTools),
271
+ chatOnce: (messages, handlers, signal, tools, runtime) => anthropicChatOnce(messages, handlers, signal, tools ?? chatTools, runtime),
257
272
  });
258
273
  /**
259
274
  * 多 provider 兼容的 cache / reasoning 字段提取。
@@ -325,29 +340,28 @@ function retryErrorCode(error) {
325
340
  return `HTTP_${value.status}`;
326
341
  return value.code ?? value.name ?? 'RETRYABLE_ERROR';
327
342
  }
328
- /**
329
- * 流式调一次 LLM:增量回调文本,内部累加 tool_calls 片段。
330
- * tool_calls chunk index 累加(id / name / arguments 拼接)
331
- * include_usage 时末尾 chunk 携带 usage,先读再 continue(末尾 chunk 无 delta)。
332
- *
333
- * 包了一层重试:429/5xx/timeout/网络错按指数退避重试(默认 4 次),400/401/用户中断立即抛。
334
- * 重试由 chat() 统一管,chatOnce() 只负责单次请求,职责单一便于单测。
335
- */
336
- export async function chat(messages, handlers = {}, signal,
343
+ /** Bind chat dispatch, retries and built-in providers to one explicit runtime. */
344
+ export function createChatTransport(runtime) {
345
+ return (messages, handlers = {}, signal, toolsOverride) => chatWithRuntime(runtime, messages, handlers, signal, toolsOverride);
346
+ }
347
+ export function chat(messages, handlers = {}, signal,
337
348
  /** 覆盖默认工具 schema;plan 模式传 planChatTools(只读子集),缺省=全量 chatTools。 */
338
349
  toolsOverride) {
350
+ return chatWithRuntime(defaultProviderRuntime, messages, handlers, signal, toolsOverride);
351
+ }
352
+ async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverride) {
339
353
  let lastErr;
340
354
  for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
341
355
  if (signal?.aborted) {
342
356
  throw new DOMException('This operation was aborted', 'AbortError');
343
357
  }
344
358
  try {
345
- const provider = getModelProvider(config.provider);
359
+ const provider = getModelProvider(runtime.config.provider);
346
360
  if (!provider) {
347
361
  // 未注册的 provider:给出可用名单,避免悄悄落到错误实现。
348
- throw new Error(`未知的 LLM provider "${config.provider}";已注册:${listModelProviders().join(', ') || '(空)'}`);
362
+ throw new Error(`未知的 LLM provider "${runtime.config.provider}";已注册:${listModelProviders().join(', ') || '(空)'}`);
349
363
  }
350
- return await provider.chatOnce(messages, handlers, signal, toolsOverride);
364
+ return await provider.chatOnce(messages, handlers, signal, toolsOverride, runtime);
351
365
  }
352
366
  catch (err) {
353
367
  lastErr = err;
@@ -411,7 +425,7 @@ export function normalizeImageDetail(messages) {
411
425
  return changed ? next : messages;
412
426
  }
413
427
  /** 单次流式 LLM 请求(无重试);chat() 的内部实现,可被 __setChatCreateImpl 注入桩以做单测。 */
414
- async function chatOnce(messages, handlers, signal, toolsOverride) {
428
+ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = defaultProviderRuntime) {
415
429
  // 防御:messages 必须至少含一条非空 user 消息,否则 OpenAI/Anthropic 都会 400。
416
430
  // compact force 分支曾把所有 user 丢进摘要 → 重建 history 无 user → 下一轮 400。
417
431
  // 在 transport 边界拦住所有类似回归(compact / 外部注入 / resume 损坏)。
@@ -429,15 +443,17 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
429
443
  throw new Error('messages must contain at least one non-empty user message');
430
444
  }
431
445
  // signal 透传给 SDK 第二参(RequestOptions);abort 后 for await 抛错,chat 不 catch,透传 runAgent 处理。
432
- // createImplOverride 的 body 类型故意宽成 Record(测试桩用),生产走 client 分支时由 OpenAI 自己的类型守门。
433
- const create = createImplOverride
434
- ? createImplOverride
435
- : (body, opts) => client.chat.completions.create(body, opts);
446
+ // openAICreateImpl 的 body 类型故意宽成 Record(测试桩用),生产走 client 分支时由 OpenAI 自己的类型守门。
447
+ const runtimeConfig = runtime.config;
448
+ const runtimeClientState = runtime.clientState;
449
+ const create = runtimeClientState.openAICreateImpl
450
+ ? runtimeClientState.openAICreateImpl
451
+ : (body, opts) => runtimeClientState.openAI.chat.completions.create(body, opts);
436
452
  // transport 边界消毒:剔除部分后端(kimi-k3@dashscope 实测)整请求 400 拒绝的
437
453
  // uniqueItems 关键字。无需改写时返回原引用,前缀缓存逐字节稳定不受影响。
438
454
  const activeTools = sanitizeToolSchemas(toolsOverride ?? chatTools);
439
455
  const stream = await create({
440
- model: getActiveModel(),
456
+ model: runtime.getModel(),
441
457
  messages: normalizeImageDetail(messages),
442
458
  stream: true,
443
459
  // 空工具表(如摘要请求)不发 tools 字段——部分网关拒绝空数组。
@@ -449,8 +465,8 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
449
465
  parallel_tool_calls: true,
450
466
  }
451
467
  : {}),
452
- ...(config.maxTokens ? { max_tokens: config.maxTokens } : {}),
453
- ...(config.includeUsage ? { stream_options: { include_usage: true } } : {}),
468
+ ...(runtimeConfig.maxTokens ? { max_tokens: runtimeConfig.maxTokens } : {}),
469
+ ...(runtimeConfig.includeUsage ? { stream_options: { include_usage: true } } : {}),
454
470
  }, signal ? { signal } : undefined);
455
471
  // content 内嵌 think 标签由独立增量状态机过滤。它只暂存“可能组成标签”的后缀,
456
472
  // 因而既能覆盖标签任意位置跨 chunk,也不会让普通正文固定延迟数个字符。
@@ -4,6 +4,10 @@ let fetchImplOverride = null;
4
4
  export function __setAnthropicFetchImpl(impl) {
5
5
  fetchImplOverride = impl;
6
6
  }
7
+ /** Default runtime keeps the historical process-level test override behavior. */
8
+ export function defaultAnthropicFetch(input, init) {
9
+ return (fetchImplOverride ?? fetch)(input, init);
10
+ }
7
11
  function parseJsonObject(value) {
8
12
  if (!value.trim())
9
13
  return {};
@@ -142,12 +146,13 @@ function endpoint(baseURL) {
142
146
  return base;
143
147
  return `${base}/v1/messages`;
144
148
  }
145
- export function buildAnthropicRequest(messages, tools) {
146
- const encoded = encodeAnthropicMessages(messages);
147
- const anthropicTools = encodeAnthropicTools(tools);
149
+ export function buildAnthropicRequest(messages, tools, runtime) {
150
+ const runtimeConfig = runtime?.config ?? config;
151
+ const encoded = encodeAnthropicMessages(messages, runtimeConfig.anthropicPromptCache);
152
+ const anthropicTools = encodeAnthropicTools(tools, runtimeConfig.anthropicPromptCache);
148
153
  return {
149
- model: getActiveModel(),
150
- max_tokens: config.maxTokens ?? 8192,
154
+ model: runtime?.getModel() ?? getActiveModel(),
155
+ max_tokens: runtimeConfig.maxTokens ?? 8192,
151
156
  stream: true,
152
157
  system: encoded.system,
153
158
  messages: encoded.messages,
@@ -249,17 +254,18 @@ function addLiveCount(text, state) {
249
254
  }
250
255
  }
251
256
  /** Anthropic Messages API 单次请求;外层 chat() 继续统一负责重试。 */
252
- export async function anthropicChatOnce(messages, handlers, signal, tools) {
253
- const fetchImpl = fetchImplOverride ?? fetch;
254
- const response = await fetchImpl(endpoint(config.baseURL), {
257
+ export async function anthropicChatOnce(messages, handlers, signal, tools, runtime) {
258
+ const runtimeConfig = runtime?.config ?? config;
259
+ const fetchImpl = runtime?.clientState.anthropicFetchImpl ?? defaultAnthropicFetch;
260
+ const response = await fetchImpl(endpoint(runtimeConfig.baseURL), {
255
261
  method: 'POST',
256
262
  headers: {
257
263
  'content-type': 'application/json',
258
264
  accept: 'text/event-stream',
259
- 'x-api-key': config.apiKey,
265
+ 'x-api-key': runtimeConfig.apiKey,
260
266
  'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
261
267
  },
262
- body: JSON.stringify(buildAnthropicRequest(messages, tools)),
268
+ body: JSON.stringify(buildAnthropicRequest(messages, tools, runtime)),
263
269
  signal,
264
270
  });
265
271
  if (!response.ok)
@@ -0,0 +1 @@
1
+ export {};
@@ -147,8 +147,8 @@ function matches(grant, tool, fingerprint, projectRoot) {
147
147
  grant.fingerprint === fingerprint &&
148
148
  (grant.scope !== 'project' || grant.projectRoot === projectRoot));
149
149
  }
150
- export async function checkPermission(tool, args, signal, options = {}) {
151
- if (!config.permissionEnabled || getToolRisk(tool) === 'safe')
150
+ async function checkPermissionWithSessionGrants(runtimeConfig, runtimeSessionGrants, tool, args, signal, options = {}) {
151
+ if (!runtimeConfig.permissionEnabled || getToolRisk(tool) === 'safe')
152
152
  return 'allow';
153
153
  if (signal?.aborted)
154
154
  return 'deny';
@@ -165,13 +165,13 @@ export async function checkPermission(tool, args, signal, options = {}) {
165
165
  const fingerprint = permissionFingerprint(tool, args);
166
166
  const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
167
167
  if (!forceOnce) {
168
- if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
168
+ if (runtimeSessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
169
169
  return 'allow';
170
170
  if (permanentGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
171
171
  return 'allow';
172
172
  }
173
173
  // CI/pipes must fail closed. Operators can deliberately restore unattended behavior.
174
- if (!process.stdin.isTTY && !config.permissionNonInteractiveAllow && !options.prompt)
174
+ if (!process.stdin.isTTY && !runtimeConfig.permissionNonInteractiveAllow && !options.prompt)
175
175
  return 'deny';
176
176
  if (signal?.aborted)
177
177
  return 'deny';
@@ -214,7 +214,7 @@ export async function checkPermission(tool, args, signal, options = {}) {
214
214
  if (signal?.aborted || result.action === 'cancelled' || result.value === denyOption)
215
215
  return 'deny';
216
216
  if (result.value === sessionOption) {
217
- sessionGrants.push({ tool: tool.name, fingerprint, scope: 'session' });
217
+ runtimeSessionGrants.push({ tool: tool.name, fingerprint, scope: 'session' });
218
218
  }
219
219
  else if (result.value === projectOption) {
220
220
  const grant = { tool: tool.name, fingerprint, scope: 'project', projectRoot };
@@ -230,6 +230,22 @@ export async function checkPermission(tool, args, signal, options = {}) {
230
230
  }
231
231
  return 'allow';
232
232
  }
233
+ /** 兼容入口:显式 config 仍共享进程级 session grants。 */
234
+ export function checkPermissionWithConfig(runtimeConfig, tool, args, signal, options = {}) {
235
+ return checkPermissionWithSessionGrants(runtimeConfig, sessionGrants, tool, args, signal, options);
236
+ }
237
+ /** 为独立 RuntimeContext 创建私有 session grants;项目/永久授权仍按原语义进程共享。 */
238
+ export function createPermissionChecker(runtimeConfig, defaultProjectRoot) {
239
+ const runtimeSessionGrants = [];
240
+ return (tool, args, signal, options = {}) => checkPermissionWithSessionGrants(runtimeConfig, runtimeSessionGrants, tool, args, signal, {
241
+ ...options,
242
+ projectRoot: options.projectRoot ?? defaultProjectRoot,
243
+ });
244
+ }
245
+ /** 默认兼容入口:继续读取进程级 config。独立 RuntimeContext 使用 checkPermissionWithConfig。 */
246
+ export function checkPermission(tool, args, signal, options = {}) {
247
+ return checkPermissionWithConfig(config, tool, args, signal, options);
248
+ }
233
249
  export function revokePermanentAllow(toolName, fingerprint) {
234
250
  loadPermanent();
235
251
  permanentGrants = permanentGrants.filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
@@ -12,7 +12,7 @@
12
12
  import * as layout from '../../ui/layout.js';
13
13
  import { ui } from '../../ui/theme.js';
14
14
  import { t } from '../../i18n/index.js';
15
- import { manualCompact, appendCurrentSessionRuntimeEvent, hashTraceValue } from '../../session/index.js';
15
+ import { appendCurrentSessionRuntimeEvent, hashTraceValue } from '../../session/index.js';
16
16
  import { startRunningListener, stopRunningListener } from '../running-input.js';
17
17
  import { unhandled, next } from './types.js';
18
18
  export const compactCommands = [
@@ -38,7 +38,7 @@ export const compactCommands = [
38
38
  const log = await (async () => {
39
39
  const signal = startRunningListener(t('running.compacting'));
40
40
  try {
41
- return await manualCompact(ctx.history, focus, { force, signal });
41
+ return await ctx.runtime.compact(ctx.history, { focus, force, signal, contextState: ctx.contextState });
42
42
  }
43
43
  finally {
44
44
  stopRunningListener();
@@ -8,9 +8,6 @@ import * as layout from '../../ui/layout.js';
8
8
  import { ui } from '../../ui/theme.js';
9
9
  import { bannerLines } from '../../ui/render.js';
10
10
  import { t } from '../../i18n/index.js';
11
- import { newSessionId, listSessions } from '../../session/index.js';
12
- import { resetState } from '../../rollback/index.js';
13
- import { setCurrentSessionId } from '../../session/state.js';
14
11
  import { promptSessionPicker } from '../../ui/prompt.js';
15
12
  import { unhandled, next } from './types.js';
16
13
  export const sessionCommands = [
@@ -18,13 +15,7 @@ export const sessionCommands = [
18
15
  if (ctx.line !== '/clear')
19
16
  return unhandled();
20
17
  ctx.history.length = 1; // 保留 system 提示
21
- resetState(); // 同步清空回滚轮次/快照
22
- // /clear 立刻换新会话 id:之前延后到 turn 收尾分配,但 runAgent 期间模型
23
- // 调 plan_update / note_append 会命中「no active session」——它们写 notes.md 靠
24
- // getNotesFilePath(getCurrentSessionId()),而下一个 turn 还没走完。改为与启动对齐
25
- // 立即分配:notes.md 由写入时按需建文件,这里预分配 id 不触盘。
26
- ctx.state.currentSessionId = newSessionId();
27
- setCurrentSessionId(ctx.state.currentSessionId, process.cwd()); // 同步到 session/state
18
+ ctx.state.currentSessionId = ctx.runtime.session.clear();
28
19
  ctx.state.turnCount = 0; // 反思 cadence 重新计数
29
20
  ctx.contextState.lastUsage = undefined;
30
21
  ctx.contextState.lifecycleStats = undefined;
@@ -44,7 +35,7 @@ export const sessionCommands = [
44
35
  async (ctx) => {
45
36
  if (ctx.line !== '/sessions')
46
37
  return unhandled();
47
- const sessions = listSessions(); // 不传 limit = 全量
38
+ const sessions = ctx.runtime.session.list(); // 不传 limit = 全量
48
39
  if (sessions.length === 0) {
49
40
  layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
50
41
  return next();
@@ -71,7 +62,7 @@ export const sessionCommands = [
71
62
  async (ctx) => {
72
63
  if (ctx.line !== '/resume')
73
64
  return unhandled();
74
- const sessions = listSessions(10);
65
+ const sessions = ctx.runtime.session.list(10);
75
66
  if (sessions.length === 0) {
76
67
  layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
77
68
  return next();
@@ -57,10 +57,15 @@ export function renderHistory(history) {
57
57
  let pendingBatches = [];
58
58
  let normalBatch = null;
59
59
  const flushBatch = () => {
60
+ if (pendingBatches.length === 0)
61
+ return false;
62
+ // 气泡/正文 → 摘要行边界:尾部视觉空行幂等收成 1 条(与实时 writeToolHeader 同语义)。
63
+ layout.normalizeMutationBoundary();
60
64
  for (const entries of pendingBatches)
61
65
  batch.writeSummaryOnly(entries, layout);
62
66
  pendingBatches = [];
63
67
  normalBatch = null;
68
+ return true;
64
69
  };
65
70
  for (let idx = 0; idx < history.length; idx++) {
66
71
  const m = history[idx];