lark-remote 0.5.20 → 0.5.21

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 (55) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/bridge/inbound-media.js +71 -45
  3. package/dist/bridge/index.d.ts +9 -0
  4. package/dist/bridge/index.js +230 -203
  5. package/dist/card/approval-render.js +32 -33
  6. package/dist/card/card-budget.js +123 -98
  7. package/dist/card/run-renderer.js +82 -68
  8. package/dist/card/run-state.d.ts +0 -14
  9. package/dist/card/run-state.js +335 -207
  10. package/dist/card/tool-render.js +20 -16
  11. package/dist/card/usage-format.d.ts +12 -9
  12. package/dist/card/usage-format.js +111 -97
  13. package/dist/common/text-width.js +36 -24
  14. package/dist/common/tool-summary.js +132 -90
  15. package/dist/index.js +226 -190
  16. package/dist/message-handlers.js +253 -236
  17. package/dist/router/commands/ls.d.ts +3 -0
  18. package/dist/router/commands/ls.js +256 -175
  19. package/dist/router/commands/order.js +184 -159
  20. package/dist/router/commands/resume.js +189 -133
  21. package/dist/router/commands/ws.js +219 -174
  22. package/dist/router/index.js +192 -201
  23. package/dist/runner/bash/runner.js +2 -4
  24. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  25. package/dist/runner/codex/app-server/translator.js +287 -221
  26. package/dist/runner/common/acp/base-translator.js +19 -17
  27. package/dist/runner/common/jsonl-stream.js +154 -187
  28. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  29. package/dist/runner/common/jsonrpc/client.js +43 -12
  30. package/dist/runner/common/spawning-runner.d.ts +5 -0
  31. package/dist/runner/common/spawning-runner.js +8 -3
  32. package/dist/runner/common/turn-started.d.ts +8 -0
  33. package/dist/runner/common/turn-started.js +11 -0
  34. package/dist/runner/dsh/approval.js +84 -51
  35. package/dist/runner/dsh/client.d.ts +7 -9
  36. package/dist/runner/dsh/client.js +356 -287
  37. package/dist/runner/dsh/mux-stream.js +28 -8
  38. package/dist/runner/dsh/runner.d.ts +4 -3
  39. package/dist/runner/dsh/runner.js +88 -64
  40. package/dist/runner/dsh/translator.d.ts +1 -31
  41. package/dist/runner/dsh/translator.js +180 -178
  42. package/dist/runner/kimi/acp/runner.js +81 -44
  43. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  44. package/dist/runner/pi/rpc/client.js +4 -13
  45. package/dist/runner/pi/rpc/translator.js +61 -60
  46. package/dist/session/claude/sessions.js +113 -101
  47. package/dist/session/codex/rollout-reader.js +179 -180
  48. package/dist/session/dsh/sessions.js +281 -231
  49. package/dist/session/kimi/sessions.d.ts +0 -7
  50. package/dist/session/kimi/sessions.js +129 -109
  51. package/dist/session/opencode/sessions.d.ts +0 -11
  52. package/dist/session/opencode/sessions.js +161 -137
  53. package/dist/session/session-store.d.ts +8 -0
  54. package/dist/session/session-store.js +98 -98
  55. package/package.json +1 -1
@@ -66,6 +66,13 @@ function pushIf(arr, el) {
66
66
  if (el !== null)
67
67
  arr.push(el);
68
68
  }
69
+ const APPROVAL_AREA_RENDERERS = new Map([
70
+ ['command', renderCommandApproval],
71
+ ['tool', renderToolApproval],
72
+ ['file', renderFileApproval],
73
+ ['permissions', renderPermissionsApproval],
74
+ ['question', renderQuestionApproval],
75
+ ]);
69
76
  /**
70
77
  * Render the approval area for a run card.
71
78
  * Returns an array of CardKit 2.0 elements to append to the card body.
@@ -83,21 +90,10 @@ export function renderApprovalArea(approval, opts) {
83
90
  text: { tag: 'lark_md', content: `ℹ️ ${neutralized}` },
84
91
  });
85
92
  }
86
- if (approval.kind === 'command') {
87
- elements.push(...renderCommandApproval(approval, opts?.expired, opts?.runId));
88
- }
89
- else if (approval.kind === 'tool') {
90
- elements.push(...renderToolApproval(approval, opts?.expired, opts?.runId));
91
- }
92
- else if (approval.kind === 'file') {
93
- elements.push(...renderFileApproval(approval, opts?.expired, opts?.runId));
94
- }
95
- else if (approval.kind === 'permissions') {
96
- elements.push(...renderPermissionsApproval(approval, opts?.expired, opts?.runId));
97
- }
98
- else if (approval.kind === 'question') {
99
- elements.push(...renderQuestionApproval(approval, opts?.expired, opts?.runId));
100
- }
93
+ // kind → 渲染器查表(Map 保持插入顺序,未知 kind 与原先无 else 分支一致:不追加)。
94
+ const render = APPROVAL_AREA_RENDERERS.get(approval.kind);
95
+ if (render)
96
+ elements.push(...render(approval, opts?.expired, opts?.runId));
101
97
  // 响应发送失败:明确告知原因 + 可重试,而不是静默停在可点状态。
102
98
  if (approval.deliveryError) {
103
99
  const neutralized = capApprovalText(approval.deliveryError);
@@ -429,9 +425,26 @@ function renderFileDiff(diff) {
429
425
  }
430
426
  /** Per-diff render cap (chars), see TOTAL_DIFF_CHARS for the approval-area cap. */
431
427
  const MAX_DIFF_CHARS = 1200;
432
- // =============================================================================
433
- // Permissions Approval
434
- // =============================================================================
428
+ /** 单个权限条目:label div + toggle 按钮(模块级命名函数,供循环逐个调用)。 */
429
+ function renderPermissionItem(item, requestId, runId) {
430
+ const icon = item.selected ? '✅' : '⬜';
431
+ // label 仅展示(payload 用 item.id),可安全截断。
432
+ const label = capApprovalText(item.label, APPROVAL_LABEL_BYTES);
433
+ return [
434
+ {
435
+ tag: 'div',
436
+ text: { tag: 'lark_md', content: `${icon} ${label}` },
437
+ },
438
+ cardButton(item.selected ? '取消授予' : '授予', {
439
+ cmd: 'approval.toggle',
440
+ requestId,
441
+ permId: item.id,
442
+ selected: !item.selected,
443
+ runId,
444
+ nonce: mkNonce(),
445
+ }, { type: item.selected ? 'default' : 'primary', size: 'small' }),
446
+ ];
447
+ }
435
448
  function renderPermissionsApproval(approval, expired, runId) {
436
449
  const elements = [];
437
450
  elements.push({
@@ -445,21 +458,7 @@ function renderPermissionsApproval(approval, expired, runId) {
445
458
  const items = approval.permissions.items.slice(0, MAX_RENDERED_CHANGE_ITEMS);
446
459
  const omittedItems = approval.permissions.items.length - items.length;
447
460
  for (const item of items) {
448
- const icon = item.selected ? '✅' : '⬜';
449
- // label 仅展示(payload 用 item.id),可安全截断。
450
- const label = capApprovalText(item.label, APPROVAL_LABEL_BYTES);
451
- elements.push({
452
- tag: 'div',
453
- text: { tag: 'lark_md', content: `${icon} ${label}` },
454
- });
455
- elements.push(cardButton(item.selected ? '取消授予' : '授予', {
456
- cmd: 'approval.toggle',
457
- requestId: approval.requestId,
458
- permId: item.id,
459
- selected: !item.selected,
460
- runId,
461
- nonce: mkNonce(),
462
- }, { type: item.selected ? 'default' : 'primary', size: 'small' }));
461
+ elements.push(...renderPermissionItem(item, approval.requestId, runId));
463
462
  }
464
463
  if (omittedItems > 0) {
465
464
  elements.push({
@@ -160,125 +160,130 @@ function enforceElementBudget(card, opts = {}, bytesBefore) {
160
160
  // 它最终会落到最小卡(节点数固定很小)。
161
161
  return enforceByteBudget(card, opts, bytesBefore);
162
162
  }
163
- /**
164
- * Byte-budget enforcement, extracted so table-limit fix can delegate to it
165
- * when table truncation alone doesn't bring the card under the byte limit.
166
- *
167
- * 裁剪判定是**结构化**的:所有 collapsible_panel 都是待裁剪对象,不看面板
168
- * 标题文本。历史教训:旧实现用
169
- * emoji 标题模式(🤖👤💭🔧🟢🔴)识别「会话事件面板」,但真实
170
- * sessionEventPanel 产出的标题是裸英文 type 名(emoji 在事件正文而非标题),
171
- * 导致裁剪阶段对真实 resume 卡零命中,任何 >28KB 的卡片无裁剪直通极端降级,
172
- * 整卡被替换成一句话。
173
- *
174
- * 渐进式降级(每级仍超限才进入下一级):
175
- * 阶段1 逐面板截断内容(maxPanelContentBytes)
176
- * 阶段2 限制面板数量(删除最老,maxEventPanels)
177
- * 阶段3 丢弃全部面板,保留骨架(header/usage/按钮)+ 省略提示
178
- * 阶段4 截断剩余顶层 div 文本
179
- * 阶段5 最终兜底 minimal card(骨架本身已超限的病理场景)
180
- */
181
- function enforceByteBudget(truncated, opts = {}, bytesBefore) {
163
+ function makeByteBudgetContext(truncated, opts, bytesBefore) {
182
164
  const { maxEventPanels = 5, maxPanelContentBytes = 2000, truncationHint = '📜 还有 {count} 个事件未显示', } = opts;
183
- const reasons = [];
184
- const elements = truncated.body?.elements ?? [];
185
- const size = () => Buffer.byteLength(JSON.stringify(truncated), 'utf8');
186
- const done = (wasTruncated) => ({
187
- card: truncated,
165
+ return {
166
+ truncated,
167
+ elements: truncated.body?.elements ?? [],
168
+ reasons: [],
169
+ omitted: 0,
170
+ modifications: 0,
171
+ textTruncations: 0,
172
+ maxEventPanels,
173
+ maxPanelContentBytes,
174
+ truncationHint,
175
+ bytesBefore,
176
+ };
177
+ }
178
+ /** 原 size 闭包:当前卡片序列化字节数。 */
179
+ function byteBudgetSize(ctx) {
180
+ return Buffer.byteLength(JSON.stringify(ctx.truncated), 'utf8');
181
+ }
182
+ /** 原 done 闭包:构造结果对象(reason 由各阶段累积)。 */
183
+ function byteBudgetDone(ctx, wasTruncated) {
184
+ return {
185
+ card: ctx.truncated,
188
186
  wasTruncated,
189
- reason: reasons.join('+') || undefined,
190
- bytesBefore: bytesBefore ?? size(),
191
- bytesAfter: size(),
192
- });
193
- // ========== 阶段1:裁剪所有面板的内容 ==========
194
- let modifications = 0;
195
- for (const el of elements) {
187
+ reason: ctx.reasons.join('+') || undefined,
188
+ bytesBefore: ctx.bytesBefore ?? byteBudgetSize(ctx),
189
+ bytesAfter: byteBudgetSize(ctx),
190
+ };
191
+ }
192
+ /** 原 elementsWithinBudgetWithHint 闭包(含 hint 预留位)。 */
193
+ function byteBudgetElementsWithin(ctx) {
194
+ return countCardElements(ctx.truncated) + (ctx.omitted > 0 ? 1 : 0) <= MAX_CARD_ELEMENTS;
195
+ }
196
+ /** 原 `size() <= ... && elementsWithinBudgetWithHint()` 达标判定。 */
197
+ function byteBudgetFits(ctx) {
198
+ return byteBudgetSize(ctx) <= CARD_BUDGET_BYTES && byteBudgetElementsWithin(ctx);
199
+ }
200
+ function comparePanelIndexDesc(a, b) {
201
+ return b - a;
202
+ }
203
+ /** 原 dropOldestPanels 闭包:按文档顺序删最旧的 count 个面板。 */
204
+ function dropOldestPanels(ctx, count) {
205
+ const indices = getPanelIndices(ctx.elements).slice(0, count).sort(comparePanelIndexDesc);
206
+ for (const idx of indices) {
207
+ ctx.elements.splice(idx, 1);
208
+ ctx.omitted++;
209
+ }
210
+ }
211
+ /** 达标则插入 hint 并返回结果;未达标返回 undefined,交给下一阶段继续降级。 */
212
+ function byteBudgetTryFinish(ctx, wasTruncated) {
213
+ if (!byteBudgetFits(ctx))
214
+ return undefined;
215
+ insertOmittedHint(ctx.elements, ctx.omitted, ctx.truncationHint);
216
+ return byteBudgetDone(ctx, wasTruncated);
217
+ }
218
+ // ========== 阶段1:裁剪所有面板的内容 ==========
219
+ function truncatePanelContents(ctx) {
220
+ for (const el of ctx.elements) {
196
221
  if (el.tag !== 'collapsible_panel')
197
222
  continue;
198
223
  for (const inner of el.elements ?? []) {
199
224
  if (inner.tag === 'div' && inner.text?.content) {
200
225
  const content = inner.text.content;
201
- if (Buffer.byteLength(content, 'utf8') > maxPanelContentBytes) {
202
- inner.text.content = truncateUtf8(content, maxPanelContentBytes);
203
- modifications++;
226
+ if (Buffer.byteLength(content, 'utf8') > ctx.maxPanelContentBytes) {
227
+ inner.text.content = truncateUtf8(content, ctx.maxPanelContentBytes);
228
+ ctx.modifications++;
204
229
  }
205
230
  }
206
231
  }
207
232
  }
208
- if (modifications > 0) {
209
- reasons.push('panel_content_truncated');
210
- }
211
- // ========== 阶段2:限制面板数量(删除最老) ==========
212
- let omitted = 0;
213
- const dropOldestPanels = (count) => {
214
- const indices = getPanelIndices(elements)
215
- .slice(0, count)
216
- .sort((a, b) => b - a);
217
- for (const idx of indices) {
218
- elements.splice(idx, 1);
219
- omitted++;
220
- }
221
- };
222
- const panelCount = getPanelIndices(elements).length;
223
- if (panelCount > maxEventPanels) {
224
- dropOldestPanels(panelCount - maxEventPanels);
225
- reasons.push('event_count_limited');
226
- }
227
- // 元素数预算必须与字节预算同查(含 hint 预留位):字节路径的返回条件此前
228
- // 只看字节——元素超限但卡内无 collapsible_panel 时(如 95 个小 div),
229
- // 阶段1-4 全是空操作,超限卡原样直通(实测 wasTruncated:false、95 元素
230
- // > 90 → 飞书 11310 整卡报废,正是文件头注释记载的 /ws、/order 故障形态)。
231
- const elementsWithinBudgetWithHint = () => countCardElements(truncated) + (omitted > 0 ? 1 : 0) <= MAX_CARD_ELEMENTS;
232
- if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
233
- insertOmittedHint(elements, omitted, truncationHint);
234
- return done(modifications > 0 || omitted > 0);
233
+ if (ctx.modifications > 0) {
234
+ ctx.reasons.push('panel_content_truncated');
235
235
  }
236
- // ========== 阶段3:丢弃全部面板,保留卡片骨架 ==========
237
- // header(cwd/sessionId/标题)、usage、操作按钮等信息量高、体积小的元素
238
- // 必须保留——整卡替换为一句话是信息损失最大的降级方式。
239
- dropOldestPanels(getPanelIndices(elements).length);
240
- if (omitted > 0) {
241
- reasons.push('panels_dropped');
236
+ }
237
+ // ========== 阶段2:限制面板数量(删除最老) ==========
238
+ function limitPanelCount(ctx) {
239
+ const panelCount = getPanelIndices(ctx.elements).length;
240
+ if (panelCount > ctx.maxEventPanels) {
241
+ dropOldestPanels(ctx, panelCount - ctx.maxEventPanels);
242
+ ctx.reasons.push('event_count_limited');
242
243
  }
243
- if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
244
- insertOmittedHint(elements, omitted, truncationHint);
245
- return done(true);
244
+ }
245
+ // ========== 阶段3:丢弃全部面板,保留卡片骨架 ==========
246
+ // header(cwd/sessionId/标题)、usage、操作按钮等信息量高、体积小的元素
247
+ // 必须保留——整卡替换为一句话是信息损失最大的降级方式。
248
+ function dropAllPanels(ctx) {
249
+ dropOldestPanels(ctx, getPanelIndices(ctx.elements).length);
250
+ if (ctx.omitted > 0) {
251
+ ctx.reasons.push('panels_dropped');
246
252
  }
247
- // ========== 阶段4:截断剩余顶层 div 文本 ==========
248
- // 骨架元素(header/usage 等)自身超限的情况(如超大 displayTitle)。
249
- let textTruncations = 0;
250
- for (const el of elements) {
253
+ }
254
+ // ========== 阶段4:截断剩余顶层 div 文本 ==========
255
+ // 骨架元素(header/usage 等)自身超限的情况(如超大 displayTitle)。
256
+ function truncateTopLevelDivs(ctx) {
257
+ for (const el of ctx.elements) {
251
258
  if (el.tag === 'div' && el.text?.content) {
252
259
  const content = el.text.content;
253
- if (Buffer.byteLength(content, 'utf8') > maxPanelContentBytes) {
254
- el.text.content = truncateUtf8(content, maxPanelContentBytes);
255
- textTruncations++;
260
+ if (Buffer.byteLength(content, 'utf8') > ctx.maxPanelContentBytes) {
261
+ el.text.content = truncateUtf8(content, ctx.maxPanelContentBytes);
262
+ ctx.textTruncations++;
256
263
  }
257
264
  }
258
265
  }
259
- if (textTruncations > 0) {
260
- reasons.push('text_truncated');
266
+ if (ctx.textTruncations > 0) {
267
+ ctx.reasons.push('text_truncated');
261
268
  }
262
- if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
263
- insertOmittedHint(elements, omitted, truncationHint);
264
- return done(true);
265
- }
266
- // ========== 阶段4.5:元素数兜底(无面板可删的元素超限) ==========
267
- // 阶段1-4 只会裁内容/删面板;元素超限且无面板时在此按序丢弃最旧的顶层
268
- // 元素直到达标(与 table/panel 的「删最旧、留最新」口径一致,尾部操作
269
- // 按钮保留)。留 ≥3 个元素兜底,仍不达标则交给阶段5 的最小卡。
270
- if (countCardElements(truncated) > MAX_CARD_ELEMENTS) {
271
- while (countCardElements(truncated) > MAX_CARD_ELEMENTS && elements.length > 3) {
272
- elements.shift();
273
- omitted++;
274
- }
275
- reasons.push('element_budget_elements_dropped');
276
- if (size() <= CARD_BUDGET_BYTES && elementsWithinBudgetWithHint()) {
277
- insertOmittedHint(elements, omitted, truncationHint);
278
- return done(true);
269
+ }
270
+ // ========== 阶段4.5:元素数兜底(无面板可删的元素超限) ==========
271
+ // 阶段1-4 只会裁内容/删面板;元素超限且无面板时在此按序丢弃最旧的顶层
272
+ // 元素直到达标(与 table/panel 的「删最旧、留最新」口径一致,尾部操作
273
+ // 按钮保留)。留 ≥3 个元素兜底,仍不达标则交给阶段5 的最小卡。
274
+ function dropExcessElements(ctx) {
275
+ if (countCardElements(ctx.truncated) > MAX_CARD_ELEMENTS) {
276
+ while (countCardElements(ctx.truncated) > MAX_CARD_ELEMENTS && ctx.elements.length > 3) {
277
+ ctx.elements.shift();
278
+ ctx.omitted++;
279
279
  }
280
+ ctx.reasons.push('element_budget_elements_dropped');
281
+ return byteBudgetTryFinish(ctx, true);
280
282
  }
281
- // ========== 阶段5:最终兜底(原 extreme_fallback) ==========
283
+ return undefined;
284
+ }
285
+ // ========== 阶段5:最终兜底(原 extreme_fallback) ==========
286
+ function byteBudgetMinimalCard(ctx) {
282
287
  const minimalCard = cardWithHeader({
283
288
  template: 'orange',
284
289
  title: '⚠️ 内容已截断',
@@ -296,10 +301,30 @@ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
296
301
  card: minimalCard,
297
302
  wasTruncated: true,
298
303
  reason: 'extreme_fallback',
299
- bytesBefore: bytesBefore ?? size(),
304
+ bytesBefore: ctx.bytesBefore ?? byteBudgetSize(ctx),
300
305
  bytesAfter: Buffer.byteLength(JSON.stringify(minimalCard), 'utf8'),
301
306
  };
302
307
  }
308
+ function enforceByteBudget(truncated, opts = {}, bytesBefore) {
309
+ const ctx = makeByteBudgetContext(truncated, opts, bytesBefore);
310
+ truncatePanelContents(ctx);
311
+ limitPanelCount(ctx);
312
+ let result = byteBudgetTryFinish(ctx, ctx.modifications > 0 || ctx.omitted > 0);
313
+ if (result)
314
+ return result;
315
+ dropAllPanels(ctx);
316
+ result = byteBudgetTryFinish(ctx, true);
317
+ if (result)
318
+ return result;
319
+ truncateTopLevelDivs(ctx);
320
+ result = byteBudgetTryFinish(ctx, true);
321
+ if (result)
322
+ return result;
323
+ result = dropExcessElements(ctx);
324
+ if (result)
325
+ return result;
326
+ return byteBudgetMinimalCard(ctx);
327
+ }
303
328
  /**
304
329
  * 在卡片头部插入「N 个事件未显示」提示(仅在有面板被移除时)。
305
330
  */
@@ -1048,6 +1048,86 @@ function buildChronologicalContent(state, prepared, groups) {
1048
1048
  }
1049
1049
  return elements;
1050
1050
  }
1051
+ /** running 时不显示统计(仍在生成);原分支直接 return,不追加元素。 */
1052
+ function summaryRunning(_state, _elements) { }
1053
+ function summaryFinalizing(_state, elements) {
1054
+ elements.push(markdownDiv('⏳ **等待进程退出…**'));
1055
+ }
1056
+ function summaryError(state, elements) {
1057
+ // 服务方内容审核截断(多为误报)不是「程序出错」:照抄英文内部报文会让
1058
+ // 用户以为代码或配置坏了。文案说清三件事——谁拦的、改动还在、下一步。
1059
+ if (isContentFilterFailure(state.errorMsg)) {
1060
+ elements.push(markdownDiv('⚠️ **服务方内容审核拦截**\n\n' +
1061
+ '模型输出被上游安全策略截断(`content_filter`),多数是误报,不是代码或配置问题。\n' +
1062
+ '本回合已中止;已写入磁盘的改动都还在。重试仍被拦截时,可换模型或稍后再试。'));
1063
+ }
1064
+ else {
1065
+ elements.push(markdownDiv(`⚠️ **运行出错**\n\n${state.errorMsg ?? '未知错误'}`));
1066
+ }
1067
+ }
1068
+ function summaryInterrupted(state, elements) {
1069
+ // 中断来源三元:审批超时 / 用户取消审批 / 用户主动终止。
1070
+ // 显式原因优先:approvalExpired 是粘性标记(cancel 语义下 agent 可能继续跑),
1071
+ // 用户随后主动 /stop 时若仍按过期渲染,就把「我自己停的」误报成自动取消。
1072
+ const expiredApproval = state.interruptedReason === 'approval_timeout' ||
1073
+ (state.interruptedReason === undefined &&
1074
+ (state.approvalExpired === true || (state.approvals ?? []).some((a) => a.expired)));
1075
+ elements.push(markdownDiv(expiredApproval
1076
+ ? '⏰ **审批超时未响应,已自动取消**'
1077
+ : state.interruptedReason === 'approval_cancelled'
1078
+ ? '⏹ **已取消审批,任务终止**'
1079
+ : '⏹ **已被用户终止**'));
1080
+ }
1081
+ function summaryIdleTimeout(state, elements) {
1082
+ elements.push(markdownDiv(`⏱ **已超时**\n\n${state.idleTimeoutMinutes ?? 0} 分钟无响应,已自动终止`));
1083
+ }
1084
+ /** 成功/目标继续/其它终态:usage 统计 + 后台任务(原 else 兜底分支)。 */
1085
+ function summaryResult(state, elements) {
1086
+ const result = state.resultSubtype ?? 'success';
1087
+ const hasContent = state.blocks.length > 0 || !!state.finalAnswer?.content.trim();
1088
+ // 压缩(operationKind='compaction')不会返回 agent 正文——引擎只压缩上下文
1089
+ // 窗口,空内容属正常,不能沿用普通 run 的「(未返回内容)」异常信号。
1090
+ // 所有会压缩的 agent(codex/claude/kimi/opencode/pi)
1091
+ // 都经 streamCompact 走这条共享渲染路径,一处覆盖全部。
1092
+ const empty = hasContent
1093
+ ? ''
1094
+ : state.operationKind === 'compaction'
1095
+ ? '\n\n🗜 Compact 完成'
1096
+ : '\n\n(未返回内容)';
1097
+ // goal 本 run 读不到(known=false)时不能写「已完成」:这是 GoalStateEvent
1098
+ // 契约里明令禁止的推断(读不到 ≠ 没有目标)。run 本身成功,只是目标维度未知。
1099
+ const goalUnknown = state.terminal === 'done' && state.goalKnown === false;
1100
+ // RunState 的用量字段就是 formatUsageStats 的入参形态(同为
1101
+ // `UsageSnapshot`)——原先这里逐字段抄了 17 行,加一个字段要同时改
1102
+ // RunState、FinishMeta 和这张清单(§9.21)。
1103
+ const usageStatsStr = formatUsageStats(state, {
1104
+ showResult: true,
1105
+ result,
1106
+ // 本轮结束但目标继续:不能报"已完成",也不能说"出错"。
1107
+ ...(state.terminal === 'goal_continuing'
1108
+ ? { completedLabel: '⏳ 本轮结束 · 目标仍在继续' }
1109
+ : goalUnknown
1110
+ ? { completedLabel: '✅ 本轮完成 · 目标状态未确认' }
1111
+ : {}),
1112
+ });
1113
+ elements.push(markdownDiv(usageStatsStr + empty));
1114
+ // 后台任务:跨 turn 生命周期,必须显式告知并可逐个停止。
1115
+ elements.push(...buildBackgroundJobsSection(state));
1116
+ }
1117
+ /**
1118
+ * terminal → summary handler。用 Map 而非对象字面量(constructor/toString 等
1119
+ * 键会命中 Object.prototype);未登记的 terminal 走 summaryResult,与原 else
1120
+ * 兜底分支一致。
1121
+ */
1122
+ const SUMMARY_HANDLERS = new Map([
1123
+ ['running', summaryRunning],
1124
+ ['finalizing', summaryFinalizing],
1125
+ ['error', summaryError],
1126
+ ['interrupted', summaryInterrupted],
1127
+ ['idle_timeout', summaryIdleTimeout],
1128
+ ['done', summaryResult],
1129
+ ['goal_continuing', summaryResult],
1130
+ ]);
1051
1131
  /** Build summary tab content */
1052
1132
  function buildSummaryContent(state, options = {}) {
1053
1133
  const elements = [];
@@ -1059,74 +1139,8 @@ function buildSummaryContent(state, options = {}) {
1059
1139
  elements.push(renderNoticeRow(notice));
1060
1140
  }
1061
1141
  }
1062
- // running 时不显示统计(仍在生成);finalizing 显示等待提示
1063
- if (state.terminal === 'running') {
1064
- return elements;
1065
- }
1066
- if (state.terminal === 'finalizing') {
1067
- elements.push(markdownDiv('⏳ **等待进程退出…**'));
1068
- return elements;
1069
- }
1070
- if (state.terminal === 'error') {
1071
- // 服务方内容审核截断(多为误报)不是「程序出错」:照抄英文内部报文会让
1072
- // 用户以为代码或配置坏了。文案说清三件事——谁拦的、改动还在、下一步。
1073
- if (isContentFilterFailure(state.errorMsg)) {
1074
- elements.push(markdownDiv('⚠️ **服务方内容审核拦截**\n\n' +
1075
- '模型输出被上游安全策略截断(`content_filter`),多数是误报,不是代码或配置问题。\n' +
1076
- '本回合已中止;已写入磁盘的改动都还在。重试仍被拦截时,可换模型或稍后再试。'));
1077
- }
1078
- else {
1079
- elements.push(markdownDiv(`⚠️ **运行出错**\n\n${state.errorMsg ?? '未知错误'}`));
1080
- }
1081
- }
1082
- else if (state.terminal === 'interrupted') {
1083
- // 中断来源三元:审批超时 / 用户取消审批 / 用户主动终止。
1084
- // 显式原因优先:approvalExpired 是粘性标记(cancel 语义下 agent 可能继续跑),
1085
- // 用户随后主动 /stop 时若仍按过期渲染,就把「我自己停的」误报成自动取消。
1086
- const expiredApproval = state.interruptedReason === 'approval_timeout' ||
1087
- (state.interruptedReason === undefined &&
1088
- (state.approvalExpired === true || (state.approvals ?? []).some((a) => a.expired)));
1089
- elements.push(markdownDiv(expiredApproval
1090
- ? '⏰ **审批超时未响应,已自动取消**'
1091
- : state.interruptedReason === 'approval_cancelled'
1092
- ? '⏹ **已取消审批,任务终止**'
1093
- : '⏹ **已被用户终止**'));
1094
- }
1095
- else if (state.terminal === 'idle_timeout') {
1096
- elements.push(markdownDiv(`⏱ **已超时**\n\n${state.idleTimeoutMinutes ?? 0} 分钟无响应,已自动终止`));
1097
- }
1098
- else {
1099
- const result = state.resultSubtype ?? 'success';
1100
- const hasContent = state.blocks.length > 0 || !!state.finalAnswer?.content.trim();
1101
- // 压缩(operationKind='compaction')不会返回 agent 正文——引擎只压缩上下文
1102
- // 窗口,空内容属正常,不能沿用普通 run 的「(未返回内容)」异常信号。
1103
- // 所有会压缩的 agent(codex/claude/kimi/opencode/pi)
1104
- // 都经 streamCompact 走这条共享渲染路径,一处覆盖全部。
1105
- const empty = hasContent
1106
- ? ''
1107
- : state.operationKind === 'compaction'
1108
- ? '\n\n🗜 Compact 完成'
1109
- : '\n\n(未返回内容)';
1110
- // goal 本 run 读不到(known=false)时不能写「已完成」:这是 GoalStateEvent
1111
- // 契约里明令禁止的推断(读不到 ≠ 没有目标)。run 本身成功,只是目标维度未知。
1112
- const goalUnknown = state.terminal === 'done' && state.goalKnown === false;
1113
- // RunState 的用量字段就是 formatUsageStats 的入参形态(同为
1114
- // `UsageSnapshot`)——原先这里逐字段抄了 17 行,加一个字段要同时改
1115
- // RunState、FinishMeta 和这张清单(§9.21)。
1116
- const usageStatsStr = formatUsageStats(state, {
1117
- showResult: true,
1118
- result,
1119
- // 本轮结束但目标继续:不能报"已完成",也不能说"出错"。
1120
- ...(state.terminal === 'goal_continuing'
1121
- ? { completedLabel: '⏳ 本轮结束 · 目标仍在继续' }
1122
- : goalUnknown
1123
- ? { completedLabel: '✅ 本轮完成 · 目标状态未确认' }
1124
- : {}),
1125
- });
1126
- elements.push(markdownDiv(usageStatsStr + empty));
1127
- // 后台任务:跨 turn 生命周期,必须显式告知并可逐个停止。
1128
- elements.push(...buildBackgroundJobsSection(state));
1129
- }
1142
+ const handler = SUMMARY_HANDLERS.get(state.terminal) ?? summaryResult;
1143
+ handler(state, elements);
1130
1144
  return elements;
1131
1145
  }
1132
1146
  /** 终态卡最多列几个后台任务(多出来的只报数)。 */
@@ -213,20 +213,6 @@ export declare function removeBackgroundJob(state: RunState, jobId: string): Run
213
213
  * 按钮,等于逼用户拿「停止」当查询用。
214
214
  */
215
215
  export declare function isLiveBackgroundJob(job: BackgroundJobRoster): boolean;
216
- /**
217
- * 收尾后把宿主推来的整份任务名单同步进终态卡状态。
218
- *
219
- * 与 reducer 的分工:reducer 处理的是"收尾那一刻的名单",只在 run 还在消费
220
- * 事件时有效;终态之后 `reduceRunState` 直接早退(终态幂等),名单只能由
221
- * 这里更新。
222
- *
223
- * 三条规则:
224
- * - 名单里消失 = 宿主已结算(`job/list` 是整份替换,摘掉即结束)→ `ended`;
225
- * - 名单里还在、宿主却给了终态词 → `ended`(有些宿主会把已结束的行留在名单里);
226
- * - `ended` 是终态,不回退成 running —— 同一个 job id 不会重新开始跑。
227
- *
228
- * 无变化时返回**同一个 state 引用**:bridge 靠它跳过无谓的卡片 patch。
229
- */
230
216
  export declare function syncBackgroundJobs(state: RunState, roster: ReadonlyArray<BackgroundJobRoster>): RunState;
231
217
  /**
232
218
  * 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」。