lark-remote 0.1.6 → 0.1.7

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 (188) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.en.md +9 -1
  3. package/README.md +14 -2
  4. package/dist/bridge/approval-coordinator.d.ts +57 -34
  5. package/dist/bridge/approval-coordinator.js +191 -40
  6. package/dist/bridge/inbound-media.d.ts +62 -0
  7. package/dist/bridge/inbound-media.js +282 -0
  8. package/dist/bridge/index.d.ts +78 -7
  9. package/dist/bridge/index.js +202 -79
  10. package/dist/bridge/queue-manager.d.ts +2 -1
  11. package/dist/bridge/queue-manager.js +1 -0
  12. package/dist/card/approval-render.js +152 -36
  13. package/dist/card/bash-card-session.d.ts +2 -24
  14. package/dist/card/bash-card-session.js +6 -73
  15. package/dist/card/bash-renderer.js +2 -3
  16. package/dist/card/card-budget.js +29 -75
  17. package/dist/card/card-session.d.ts +48 -0
  18. package/dist/card/card-session.js +86 -0
  19. package/dist/card/card-shared.d.ts +1 -12
  20. package/dist/card/card-shared.js +1 -26
  21. package/dist/card/run-card-session.d.ts +2 -55
  22. package/dist/card/run-card-session.js +6 -104
  23. package/dist/card/run-renderer.js +99 -232
  24. package/dist/card/run-state.d.ts +13 -0
  25. package/dist/card/run-state.js +25 -16
  26. package/dist/card/text-truncate.d.ts +13 -12
  27. package/dist/card/text-truncate.js +13 -63
  28. package/dist/common/fs.d.ts +8 -0
  29. package/dist/common/fs.js +17 -0
  30. package/dist/common/guards.d.ts +0 -20
  31. package/dist/common/guards.js +0 -43
  32. package/dist/common/sleep.d.ts +2 -0
  33. package/dist/common/sleep.js +5 -0
  34. package/dist/common/truncate.d.ts +39 -0
  35. package/dist/common/truncate.js +92 -0
  36. package/dist/config/codex-config.d.ts +1 -1
  37. package/dist/config/codex-config.js +1 -1
  38. package/dist/config/index.d.ts +88 -23
  39. package/dist/config/index.js +105 -34
  40. package/dist/connector/index.d.ts +69 -2
  41. package/dist/connector/index.js +107 -1
  42. package/dist/index.js +123 -105
  43. package/dist/instance-lock.js +4 -12
  44. package/dist/order/alias-resolve.d.ts +13 -0
  45. package/dist/order/alias-resolve.js +22 -0
  46. package/dist/order/alias-store.d.ts +38 -0
  47. package/dist/order/alias-store.js +106 -0
  48. package/dist/persistence/atomic-write.d.ts +8 -2
  49. package/dist/persistence/atomic-write.js +23 -7
  50. package/dist/restart.js +2 -1
  51. package/dist/router/card-helpers.d.ts +63 -0
  52. package/dist/router/card-helpers.js +146 -0
  53. package/dist/router/config/claude.js +25 -1
  54. package/dist/router/config/codex.js +29 -50
  55. package/dist/router/config/common/model-patch.d.ts +22 -0
  56. package/dist/router/config/common/model-patch.js +26 -0
  57. package/dist/router/config/common/render.js +5 -4
  58. package/dist/router/config/index.d.ts +12 -0
  59. package/dist/router/config/index.js +25 -1
  60. package/dist/router/config/kimi.d.ts +4 -1
  61. package/dist/router/config/kimi.js +27 -0
  62. package/dist/router/config/opencode.js +18 -5
  63. package/dist/router/config/pi.js +4 -5
  64. package/dist/router/config/types.d.ts +11 -2
  65. package/dist/router/index.d.ts +56 -21
  66. package/dist/router/index.js +373 -548
  67. package/dist/router/utils.d.ts +2 -0
  68. package/dist/router/utils.js +4 -0
  69. package/dist/runner/agent-choices-common.d.ts +16 -0
  70. package/dist/runner/agent-choices-common.js +31 -0
  71. package/dist/runner/claude/runner.d.ts +47 -10
  72. package/dist/runner/claude/runner.js +117 -46
  73. package/dist/runner/claude/session.d.ts +166 -0
  74. package/dist/runner/claude/session.js +749 -0
  75. package/dist/runner/codex/app-server/protocol-types.d.ts +31 -39
  76. package/dist/runner/codex/app-server/protocol-types.js +1 -12
  77. package/dist/runner/codex/app-server/runner.d.ts +42 -59
  78. package/dist/runner/codex/app-server/runner.js +160 -337
  79. package/dist/runner/codex/app-server/translator.d.ts +1 -21
  80. package/dist/runner/codex/app-server/translator.js +0 -18
  81. package/dist/runner/codex/index.d.ts +0 -2
  82. package/dist/runner/codex/index.js +0 -2
  83. package/dist/runner/common/acp/protocol-helpers.d.ts +14 -0
  84. package/dist/runner/common/acp/protocol-helpers.js +21 -0
  85. package/dist/runner/common/acp/protocol-types.d.ts +238 -0
  86. package/dist/runner/common/acp/protocol-types.js +75 -0
  87. package/dist/runner/common/connection-based-runner.d.ts +111 -0
  88. package/dist/runner/common/connection-based-runner.js +261 -0
  89. package/dist/runner/{codex/app-server → common/jsonrpc}/client.d.ts +14 -7
  90. package/dist/runner/{codex/app-server → common/jsonrpc}/client.js +27 -18
  91. package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
  92. package/dist/runner/{codex/app-server → common/jsonrpc}/connection-manager.js +63 -28
  93. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.d.ts +5 -4
  94. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.js +18 -19
  95. package/dist/runner/common/jsonrpc/types.d.ts +32 -0
  96. package/dist/runner/common/jsonrpc/types.js +9 -0
  97. package/dist/runner/common/process-stopper.d.ts +2 -1
  98. package/dist/runner/common/process-stopper.js +1 -0
  99. package/dist/runner/common/runner-utils.d.ts +6 -13
  100. package/dist/runner/common/runner-utils.js +6 -47
  101. package/dist/runner/common/spawning-runner.d.ts +24 -70
  102. package/dist/runner/common/spawning-runner.js +98 -152
  103. package/dist/runner/index.d.ts +4 -4
  104. package/dist/runner/index.js +4 -4
  105. package/dist/runner/kimi/acp/runner.d.ts +106 -0
  106. package/dist/runner/kimi/acp/runner.js +448 -0
  107. package/dist/runner/kimi/acp/translator.d.ts +124 -0
  108. package/dist/runner/kimi/acp/translator.js +336 -0
  109. package/dist/runner/kimi/index.d.ts +1 -1
  110. package/dist/runner/kimi/index.js +1 -1
  111. package/dist/runner/opencode/acp/index.d.ts +1 -0
  112. package/dist/runner/opencode/acp/index.js +2 -0
  113. package/dist/runner/opencode/acp/runner.d.ts +121 -0
  114. package/dist/runner/opencode/acp/runner.js +429 -0
  115. package/dist/runner/opencode/acp/translator.d.ts +119 -0
  116. package/dist/runner/opencode/acp/translator.js +345 -0
  117. package/dist/runner/opencode/index.d.ts +1 -1
  118. package/dist/runner/opencode/index.js +1 -1
  119. package/dist/runner/pi/index.d.ts +1 -1
  120. package/dist/runner/pi/index.js +1 -1
  121. package/dist/runner/pi/rpc/client.d.ts +52 -0
  122. package/dist/runner/pi/rpc/client.js +114 -0
  123. package/dist/runner/pi/rpc/index.d.ts +4 -0
  124. package/dist/runner/pi/rpc/index.js +4 -0
  125. package/dist/runner/pi/rpc/protocol-types.d.ts +127 -0
  126. package/dist/runner/pi/rpc/protocol-types.js +21 -0
  127. package/dist/runner/pi/rpc/runner.d.ts +80 -0
  128. package/dist/runner/pi/rpc/runner.js +199 -0
  129. package/dist/runner/pi/rpc/translator.d.ts +55 -0
  130. package/dist/runner/pi/rpc/translator.js +258 -0
  131. package/dist/runner/registry.d.ts +7 -8
  132. package/dist/runner/registry.js +3 -4
  133. package/dist/runner/resolve-agent-choices.js +18 -59
  134. package/dist/runner/sync-agent-choices.js +16 -67
  135. package/dist/runner/types.d.ts +43 -13
  136. package/dist/session/claude/session-index.js +2 -3
  137. package/dist/session/claude/session-reader.js +3 -6
  138. package/dist/session/claude/sessions.js +6 -13
  139. package/dist/session/codex/rollout-reader.d.ts +2 -2
  140. package/dist/session/codex/rollout-reader.js +14 -17
  141. package/dist/session/codex/sessions.d.ts +1 -1
  142. package/dist/session/codex/sessions.js +2 -2
  143. package/dist/session/common/content-blocks.d.ts +7 -0
  144. package/dist/session/common/content-blocks.js +13 -9
  145. package/dist/session/common/jsonl.d.ts +8 -25
  146. package/dist/session/common/jsonl.js +126 -261
  147. package/dist/session/common/pagination.d.ts +28 -0
  148. package/dist/session/common/pagination.js +33 -0
  149. package/dist/session/kimi/sessions.d.ts +34 -1
  150. package/dist/session/kimi/sessions.js +208 -98
  151. package/dist/session/opencode/sessions.d.ts +17 -3
  152. package/dist/session/opencode/sessions.js +29 -29
  153. package/dist/session/pi/sessions.d.ts +5 -1
  154. package/dist/session/pi/sessions.js +12 -16
  155. package/dist/session/session-store.d.ts +11 -1
  156. package/dist/session/session-store.js +75 -178
  157. package/dist/update/index.d.ts +2 -2
  158. package/dist/update/index.js +2 -2
  159. package/dist/update/install.d.ts +20 -2
  160. package/dist/update/install.js +51 -7
  161. package/package.json +1 -1
  162. package/dist/runner/codex/app-server/connection-manager.d.ts +0 -56
  163. package/dist/runner/codex/argv.d.ts +0 -33
  164. package/dist/runner/codex/argv.js +0 -40
  165. package/dist/runner/codex/factory.d.ts +0 -29
  166. package/dist/runner/codex/factory.js +0 -39
  167. package/dist/runner/codex/jsonl.d.ts +0 -37
  168. package/dist/runner/codex/jsonl.js +0 -221
  169. package/dist/runner/codex/runner.d.ts +0 -66
  170. package/dist/runner/codex/runner.js +0 -129
  171. package/dist/runner/common/exec-translator.d.ts +0 -73
  172. package/dist/runner/common/exec-translator.js +0 -105
  173. package/dist/runner/kimi/jsonl.d.ts +0 -28
  174. package/dist/runner/kimi/jsonl.js +0 -143
  175. package/dist/runner/kimi/runner.d.ts +0 -49
  176. package/dist/runner/kimi/runner.js +0 -105
  177. package/dist/runner/opencode/argv.d.ts +0 -32
  178. package/dist/runner/opencode/argv.js +0 -35
  179. package/dist/runner/opencode/jsonl.d.ts +0 -68
  180. package/dist/runner/opencode/jsonl.js +0 -295
  181. package/dist/runner/opencode/runner.d.ts +0 -61
  182. package/dist/runner/opencode/runner.js +0 -93
  183. package/dist/runner/pi/jsonl.d.ts +0 -128
  184. package/dist/runner/pi/jsonl.js +0 -249
  185. package/dist/runner/pi/runner.d.ts +0 -36
  186. package/dist/runner/pi/runner.js +0 -76
  187. package/dist/session/common/catch-up.d.ts +0 -12
  188. package/dist/session/common/catch-up.js +0 -31
@@ -1,11 +1,10 @@
1
1
  import { terminalToColor, terminalToLabel, newSessionButton, stopButton, compactButton, agentDisplayName, } from './card-shared.js';
2
2
  import { collapsibleMarkdownPanel, markdownDiv } from './collapsible.js';
3
3
  import { toolBodyMd, toolHeaderText } from './tool-render.js';
4
- import { truncateUtf8, truncateMarkdownTables } from './text-truncate.js';
4
+ import { truncateUtf8, truncateMarkdownTables, CARD_BUDGET_BYTES } from './text-truncate.js';
5
5
  import { formatTimestamp } from './time.js';
6
- import { formatUsageStats } from '../router/index.js';
6
+ import { formatUsageStats } from '../router/utils.js';
7
7
  import { renderApprovalArea } from './approval-render.js';
8
- const CARD_BUDGET_BYTES = 28_000;
9
8
  const REASONING_BYTES = 4_500;
10
9
  const TEXT_BYTES = 10_000;
11
10
  const DEGRADED_THINKING_KEEP = 2;
@@ -189,83 +188,20 @@ function toolTitle(tool) {
189
188
  const title = toolHeaderText(tool);
190
189
  return ts ? `${title} (${ts})` : title;
191
190
  }
192
- /**
193
- * Build degraded elements when full card exceeds 28KB budget.
194
- * Strategy: text fully preserved, thinking keeps last 2, tools keep last N (compressed).
195
- *
196
- * 性能优化:单遍遍历 state.blocks 按 kind 分桶(thinking/tool/other),替代原先
197
- * 多次 state.blocks.filter() 扫描。分桶时保持原相对顺序,后续用 groupBlocks
198
- * 渲染时序不变。
199
- */
200
- function buildDegradedElements(state, options) {
201
- const elements = [];
202
- elements.push(statusRow(state));
203
- // 单遍分桶:thinking / tool / other(保持原相对顺序)
204
- const { thinking: allThinkingBlocks, tool: allToolBlocks } = bucketThinkingAndTool(state.blocks);
205
- if (allThinkingBlocks.length > DEGRADED_THINKING_KEEP) {
206
- const omitted = allThinkingBlocks.length - DEGRADED_THINKING_KEEP;
207
- elements.push(markdownDiv(`_💡 ${omitted} 个早期思考已省略_`));
208
- }
209
- // Keep only the last N thinking blocks for display
210
- const thinkingBlocksToShow = allThinkingBlocks.slice(-DEGRADED_THINKING_KEEP);
211
- const toolsToShow = allToolBlocks.slice(-DEGRADED_TOOL_KEEP);
212
- const toolsOmitted = allToolBlocks.length - DEGRADED_TOOL_KEEP;
213
- // Add omit hint for tools if any were omitted
214
- if (toolsOmitted > 0) {
215
- elements.push(markdownDiv(`_💡 另外 ${toolsOmitted} 个工具调用已省略_`));
216
- }
217
- // Build a filtered blocks list that only includes the thinking blocks we want to show
218
- // and the tool blocks we want to show.
219
- // P2-6: use object references (like the extreme path) instead of timestamps.
220
- // RunBlock.timestamp has millisecond precision; ≥2 thinking blocks sharing a
221
- // timestamp collapse to one Set entry, so `has(block.timestamp)` lets ALL
222
- // same-timestamp blocks (including ones meant to be omitted) pass through —
223
- // inflating the degraded card past 28KB and contradicting the "N omitted"
224
- // hint. `thinkingBlocksToShow` is a slice of `allThinkingBlocks`, so the
225
- // blocks retain stable references and the Set membership check is exact.
226
- const thinkingSetToShow = new Set(thinkingBlocksToShow);
227
- const toolIdsToShow = new Set(toolsToShow.map((b) => b.tool.id));
228
- const filteredBlocks = state.blocks.filter((block) => {
229
- if (block.kind === 'thinking') {
230
- return thinkingSetToShow.has(block);
231
- }
232
- if (block.kind === 'tool') {
233
- return toolIdsToShow.has(block.tool.id);
234
- }
235
- return true; // keep all non-thinking, non-tool blocks
236
- });
237
- // Render thinking/text/tool in chronological order using groupBlocks
238
- for (const group of groupBlocks(filteredBlocks)) {
239
- if (group.kind === 'thinking') {
240
- if (options.showThinking === false)
241
- continue;
242
- const ts = formatTimestamp(group.timestamp);
243
- const title = '💭 **思考完成**';
244
- const content = truncateUtf8(group.content, REASONING_BYTES);
245
- const header = ts ? `${title} (${ts})` : title;
246
- elements.push(collapsibleMarkdownPanel({
247
- title: header,
248
- expanded: false,
249
- border: 'grey',
250
- content,
251
- textSize: 'notation',
252
- }));
253
- }
254
- else if (group.kind === 'text') {
255
- const content = truncateUtf8(group.content, TEXT_BYTES, true, '…(已截断)\n');
256
- const ts = formatTimestamp(group.timestamp);
257
- const els = renderTextBlock(content, ts, true);
258
- elements.push(...els);
259
- }
260
- else if (group.kind === 'tool') {
261
- // Degraded: show tool individually (each tool is independent now)
262
- if (options.showToolUse === false)
263
- continue;
264
- elements.push(renderTool(group.tool, true, options.showToolResult !== false)); // finalized=true
265
- }
266
- }
267
- elements.push(...buildSummaryContent(state));
268
- // Bottom action row: stop (if running) + compact (if applicable) + new session (always)
191
+ const DEGRADED_TIER = {
192
+ thinkingKeep: DEGRADED_THINKING_KEEP,
193
+ thinkingBytes: REASONING_BYTES,
194
+ toolKeep: DEGRADED_TOOL_KEEP,
195
+ textBytes: TEXT_BYTES,
196
+ };
197
+ const EXTREME_TIER = {
198
+ thinkingKeep: 1,
199
+ thinkingBytes: DEGRADED_THINKING_BYTES,
200
+ toolKeep: EXTREME_TOOL_KEEP,
201
+ textBytes: DEGRADED_TEXT_BYTES,
202
+ };
203
+ /** Bottom action row: stop (if running) + compact (if applicable) + new session (always). */
204
+ function actionRow(state) {
269
205
  const actionButtons = [];
270
206
  // finalizing 也显示停止按钮(进程未退出,用户可 /stop)
271
207
  const showStop = state.terminal === 'running' || state.terminal === 'finalizing';
@@ -277,8 +213,10 @@ function buildDegradedElements(state, options) {
277
213
  actionButtons.push(compactButton(state.runId));
278
214
  }
279
215
  actionButtons.push(newSessionButton());
280
- if (actionButtons.length > 0) {
281
- elements.push({ tag: 'div', text: { content: '‎', tag: 'lark_md' } }, // spacer
216
+ if (actionButtons.length === 0)
217
+ return [];
218
+ return [
219
+ { tag: 'div', text: { content: '‎', tag: 'lark_md' } }, // spacer
282
220
  {
283
221
  tag: 'column_set',
284
222
  columns: actionButtons.map((btn) => ({
@@ -286,10 +224,12 @@ function buildDegradedElements(state, options) {
286
224
  width: 'auto',
287
225
  elements: [btn],
288
226
  })),
289
- });
290
- }
291
- // 审批区位于 body 最底部(底部操作行之后):待审批时决策按钮不遮挡内容流。
292
- // 多槽渲染(review P2-3):并发审批全部展示,互不顶掉。
227
+ },
228
+ ];
229
+ }
230
+ /** 审批区位于 body 最底部(底部操作行之后):待审批时决策按钮不遮挡内容流。 */
231
+ function approvalArea(state) {
232
+ const elements = [];
293
233
  for (const slot of state.approvals ?? []) {
294
234
  elements.push(...renderApprovalArea(slot.view, {
295
235
  expired: slot.expired,
@@ -299,49 +239,65 @@ function buildDegradedElements(state, options) {
299
239
  }
300
240
  return elements;
301
241
  }
242
+ /** Assemble the card shell (schema/config/header) around body elements. */
243
+ function assembleRunCard(state, options, elements) {
244
+ return {
245
+ schema: '2.0',
246
+ config: { wide_screen_mode: true, update_multi: true },
247
+ header: {
248
+ template: headerTemplate2(state),
249
+ title: { content: headerTitle2(state, options), tag: 'plain_text' },
250
+ },
251
+ body: { elements },
252
+ };
253
+ }
302
254
  /**
303
- * Extreme fallback when even degraded card exceeds 28KB.
304
- * Strategy: text truncated to 5KB tail, thinking only last 1 truncated to 1KB,
305
- * tools keep last N (more aggressive than degraded).
306
- *
307
- * 性能优化:单遍分桶替代多次 state.blocks.filter()。
255
+ * Build a budget-fallback tier (degraded or extreme): keep the last N thinking
256
+ * blocks and last N tools, truncate text/thinking per tier, then render in
257
+ * chronological order. Shared by buildDegradedElements / buildExtremeFallback.
308
258
  */
309
- function buildExtremeFallbackElements(state, options) {
259
+ function buildFallbackElements(state, options, tier) {
310
260
  const elements = [];
311
261
  elements.push(statusRow(state));
312
- // 单遍分桶:thinking / tool
262
+ // 单遍分桶:thinking / tool(保持原相对顺序)
313
263
  const { thinking: allThinkingBlocks, tool: allToolBlocks } = bucketThinkingAndTool(state.blocks);
314
- // Thinking: only keep last 1 for extreme fallback
315
- if (allThinkingBlocks.length > 1) {
316
- const omitted = allThinkingBlocks.length - 1;
264
+ if (allThinkingBlocks.length > tier.thinkingKeep) {
265
+ const omitted = allThinkingBlocks.length - tier.thinkingKeep;
317
266
  elements.push(markdownDiv(`_💡 ${omitted} 个早期思考已省略_`));
318
267
  }
319
- const thinkingBlockToShow = allThinkingBlocks.length > 0 ? allThinkingBlocks[allThinkingBlocks.length - 1] : null;
320
- const toolsToShow = allToolBlocks.slice(-EXTREME_TOOL_KEEP);
321
- const toolsOmitted = allToolBlocks.length - EXTREME_TOOL_KEEP;
268
+ const thinkingBlocksToShow = allThinkingBlocks.slice(-tier.thinkingKeep);
269
+ const toolsToShow = allToolBlocks.slice(-tier.toolKeep);
270
+ const toolsOmitted = allToolBlocks.length - tier.toolKeep;
322
271
  // Add omit hint for tools if any were omitted
323
272
  if (toolsOmitted > 0) {
324
273
  elements.push(markdownDiv(`_💡 另外 ${toolsOmitted} 个工具调用已省略_`));
325
274
  }
326
- // Build filtered blocks: only last thinking, last N tools, all text
275
+ // P2-6: use object references (like the extreme path) instead of timestamps.
276
+ // RunBlock.timestamp has millisecond precision; ≥2 thinking blocks sharing a
277
+ // timestamp collapse to one Set entry, so `has(block.timestamp)` lets ALL
278
+ // same-timestamp blocks (including ones meant to be omitted) pass through —
279
+ // inflating the card past 28KB and contradicting the "N omitted" hint.
280
+ // `thinkingBlocksToShow` is a slice of `allThinkingBlocks`, so the blocks
281
+ // retain stable references and the Set membership check is exact.
282
+ const thinkingSetToShow = new Set(thinkingBlocksToShow);
327
283
  const toolIdsToShow = new Set(toolsToShow.map((b) => b.tool.id));
328
284
  const filteredBlocks = state.blocks.filter((block) => {
329
285
  if (block.kind === 'thinking') {
330
- return block === thinkingBlockToShow;
286
+ return thinkingSetToShow.has(block);
331
287
  }
332
288
  if (block.kind === 'tool') {
333
289
  return toolIdsToShow.has(block.tool.id);
334
290
  }
335
- return true; // keep all text and other blocks
291
+ return true; // keep all non-thinking, non-tool blocks
336
292
  });
337
- // Render in chronological order
293
+ // Render thinking/text/tool in chronological order using groupBlocks
338
294
  for (const group of groupBlocks(filteredBlocks)) {
339
295
  if (group.kind === 'thinking') {
340
296
  if (options.showThinking === false)
341
297
  continue;
342
298
  const ts = formatTimestamp(group.timestamp);
343
299
  const title = '💭 **思考完成**';
344
- const content = truncateUtf8(group.content, DEGRADED_THINKING_BYTES);
300
+ const content = truncateUtf8(group.content, tier.thinkingBytes);
345
301
  const header = ts ? `${title} (${ts})` : title;
346
302
  elements.push(collapsibleMarkdownPanel({
347
303
  title: header,
@@ -352,56 +308,40 @@ function buildExtremeFallbackElements(state, options) {
352
308
  }));
353
309
  }
354
310
  else if (group.kind === 'text') {
355
- const content = truncateUtf8(group.content, DEGRADED_TEXT_BYTES, true, '…(已截断)\n');
311
+ const content = truncateUtf8(group.content, tier.textBytes, true, '…(已截断)\n');
356
312
  const ts = formatTimestamp(group.timestamp);
357
- const els = renderTextBlock(content, ts, true);
358
- elements.push(...els);
313
+ elements.push(...renderTextBlock(content, ts, true));
359
314
  }
360
315
  else if (group.kind === 'tool') {
361
- // Extreme fallback: show tool individually
316
+ // Show tool individually (each tool is independent now)
362
317
  if (options.showToolUse === false)
363
318
  continue;
364
- elements.push(renderTool(group.tool, true, options.showToolResult !== false));
319
+ elements.push(renderTool(group.tool, true, options.showToolResult !== false)); // finalized=true
365
320
  }
366
321
  }
367
- if (elements.length === 0) {
322
+ if (elements.length === 0 && tier === EXTREME_TIER) {
368
323
  elements.push(markdownDiv('_暂无输出_'));
369
324
  }
370
325
  elements.push(...buildSummaryContent(state));
371
- // Bottom action row: stop (if running) + compact (if applicable) + new session (always)
372
- const actionButtons = [];
373
- // finalizing 也显示停止按钮(进程未退出,用户可 /stop)
374
- const showStop = state.terminal === 'running' || state.terminal === 'finalizing';
375
- if (showStop) {
376
- actionButtons.push(stopButton(state.runId));
377
- }
378
- // Compact 按钮:普通 turn 到达任意终态即可压缩(含异常退出),compaction 卡除外
379
- if (shouldShowCompactButton(state)) {
380
- actionButtons.push(compactButton(state.runId));
381
- }
382
- actionButtons.push(newSessionButton());
383
- if (actionButtons.length > 0) {
384
- elements.push({ tag: 'div', text: { content: '‎', tag: 'lark_md' } }, // spacer
385
- {
386
- tag: 'column_set',
387
- columns: actionButtons.map((btn) => ({
388
- tag: 'column',
389
- width: 'auto',
390
- elements: [btn],
391
- })),
392
- });
393
- }
394
- // 审批区位于 body 最底部(底部操作行之后):待审批时决策按钮不遮挡内容流。
395
- // 多槽渲染(review P2-3):并发审批全部展示,互不顶掉。
396
- for (const slot of state.approvals ?? []) {
397
- elements.push(...renderApprovalArea(slot.view, {
398
- expired: slot.expired,
399
- runId: state.runId,
400
- terminal: state.terminal,
401
- }));
402
- }
326
+ elements.push(...actionRow(state));
327
+ elements.push(...approvalArea(state));
403
328
  return elements;
404
329
  }
330
+ /**
331
+ * Build degraded elements when full card exceeds 28KB budget.
332
+ * Strategy: text fully preserved, thinking keeps last 2, tools keep last N (compressed).
333
+ */
334
+ function buildDegradedElements(state, options) {
335
+ return buildFallbackElements(state, options, DEGRADED_TIER);
336
+ }
337
+ /**
338
+ * Extreme fallback when even degraded card exceeds 28KB.
339
+ * Strategy: text truncated to 5KB tail, thinking only last 1 truncated to 1KB,
340
+ * tools keep last N (more aggressive than degraded).
341
+ */
342
+ function buildExtremeFallbackElements(state, options) {
343
+ return buildFallbackElements(state, options, EXTREME_TIER);
344
+ }
405
345
  /** Build the status row element (shared across full/degraded/extreme). */
406
346
  function statusRow(state) {
407
347
  const statusLabel = statusTagLabel(state.terminal);
@@ -442,24 +382,7 @@ function buildSkeletonElements(state) {
442
382
  elements.push(...buildSummaryContent(state));
443
383
  // Bottom action row: stop (if running/finalizing) + compact (if applicable) + new session (always).
444
384
  // Degraded paths must keep the action buttons reachable (design constraint).
445
- const actionButtons = [];
446
- const showStop = state.terminal === 'running' || state.terminal === 'finalizing';
447
- if (showStop) {
448
- actionButtons.push(stopButton(state.runId));
449
- }
450
- if (shouldShowCompactButton(state)) {
451
- actionButtons.push(compactButton(state.runId));
452
- }
453
- actionButtons.push(newSessionButton());
454
- elements.push({ tag: 'div', text: { content: '‎', tag: 'lark_md' } }, // spacer
455
- {
456
- tag: 'column_set',
457
- columns: actionButtons.map((btn) => ({
458
- tag: 'column',
459
- width: 'auto',
460
- elements: [btn],
461
- })),
462
- });
385
+ elements.push(...actionRow(state));
463
386
  return elements;
464
387
  }
465
388
  /**
@@ -616,105 +539,41 @@ function measurePanelBytes(title, expanded, border, content) {
616
539
  * CardKit 2.0 renderer - simplified for streaming (no tabs)
617
540
  */
618
541
  export function renderRunCard(state, options = {}) {
619
- const { terminal, runId } = state;
620
542
  // 先估后建:用廉价估算预测是否超 28KB 预算,明显超预算直接跳过完整卡构建走
621
543
  // degraded。P1-2:groups/prepared 只算一次,测量与正式渲染共用同一份截断结果,
622
544
  // 避免影子测量 + 正式渲染对每个超限块双倍截断。
623
545
  const groups = groupBlocks(state.blocks);
624
546
  const prepared = prepareGroupContent(groups);
625
547
  const estimate = estimateCardBytes(state, options, { groups, prepared });
626
- // Stop button: show when running or finalizing (process not yet exited)
627
- const showStop = terminal === 'running' || terminal === 'finalizing';
628
548
  // 构建完整卡(仅在估算低于阈值时,避免大 state 浪费 render + stringify)
629
549
  if (estimate < DEGRADED_THRESHOLD) {
630
550
  const elements = [
631
551
  statusRow(state),
632
552
  ...buildChronologicalContent(state, options, prepared, groups),
633
553
  ...buildSummaryContent(state),
554
+ ...actionRow(state),
555
+ ...approvalArea(state),
634
556
  ];
635
- // Bottom action row: stop (if running) + compact (if applicable) + new session (always)
636
- const actionButtons = [];
637
- if (showStop) {
638
- actionButtons.push(stopButton(runId));
639
- }
640
- if (shouldShowCompactButton(state)) {
641
- actionButtons.push(compactButton(runId));
642
- }
643
- actionButtons.push(newSessionButton());
644
- if (actionButtons.length > 0) {
645
- elements.push({ tag: 'div', text: { content: '‎', tag: 'lark_md' } }, // spacer
646
- {
647
- tag: 'column_set',
648
- columns: actionButtons.map((btn) => ({
649
- tag: 'column',
650
- width: 'auto',
651
- elements: [btn],
652
- })),
653
- });
654
- }
655
- // 审批区位于 body 最底部(底部操作行之后):待审批时决策按钮不遮挡内容流。
656
- // 多槽渲染(review P2-3):并发审批全部展示,互不顶掉。
657
- for (const slot of state.approvals ?? []) {
658
- elements.push(...renderApprovalArea(slot.view, {
659
- expired: slot.expired,
660
- runId: state.runId,
661
- terminal: state.terminal,
662
- }));
663
- }
664
- const card = {
665
- schema: '2.0',
666
- config: { wide_screen_mode: true, update_multi: true },
667
- header: {
668
- template: headerTemplate2(state),
669
- title: { content: headerTitle2(state, options), tag: 'plain_text' },
670
- },
671
- body: { elements },
672
- };
557
+ const card = assembleRunCard(state, options, elements);
673
558
  // Check budget — stringify 兜底(估算可能低估,仍保留安全网)
674
559
  if (Buffer.byteLength(JSON.stringify(card), 'utf8') <= CARD_BUDGET_BYTES)
675
560
  return card;
676
561
  // 超预算 → 继续走 degraded(不 return,fallthrough)
677
562
  }
678
563
  // 估算 ≥ 阈值 或 完整卡 stringify 超预算 — 走 degraded 渲染
679
- const degradedElements = buildDegradedElements(state, options);
680
- const degradedCard = {
681
- schema: '2.0',
682
- config: { wide_screen_mode: true, update_multi: true },
683
- header: {
684
- template: headerTemplate2(state),
685
- title: { content: headerTitle2(state, options), tag: 'plain_text' },
686
- },
687
- body: { elements: degradedElements },
688
- };
564
+ const degradedCard = assembleRunCard(state, options, buildDegradedElements(state, options));
689
565
  if (Buffer.byteLength(JSON.stringify(degradedCard), 'utf8') <= CARD_BUDGET_BYTES) {
690
566
  return degradedCard;
691
567
  }
692
568
  // Extreme fallback: even degraded exceeds budget
693
- const extremeElements = buildExtremeFallbackElements(state, options);
694
- const extremeCard = {
695
- schema: '2.0',
696
- config: { wide_screen_mode: true, update_multi: true },
697
- header: {
698
- template: headerTemplate2(state),
699
- title: { content: headerTitle2(state, options), tag: 'plain_text' },
700
- },
701
- body: { elements: extremeElements },
702
- };
569
+ const extremeCard = assembleRunCard(state, options, buildExtremeFallbackElements(state, options));
703
570
  // Final safety net: extreme fallback 是最后一层,但高转义内容(反斜杠 4× 膨胀)
704
571
  // 可能让 extreme 产物本身 >28KB。补上 stringify 兜底(与 normal/degraded 对称):
705
572
  // 超预算则降级到结构性保证 ≤28KB 的 skeleton 卡,避免飞书 11310 整卡不可用。
706
573
  if (Buffer.byteLength(JSON.stringify(extremeCard), 'utf8') <= CARD_BUDGET_BYTES) {
707
574
  return extremeCard;
708
575
  }
709
- const skeletonCard = {
710
- schema: '2.0',
711
- config: { wide_screen_mode: true, update_multi: true },
712
- header: {
713
- template: headerTemplate2(state),
714
- title: { content: headerTitle2(state, options), tag: 'plain_text' },
715
- },
716
- body: { elements: buildSkeletonElements(state) },
717
- };
576
+ const skeletonCard = assembleRunCard(state, options, buildSkeletonElements(state));
718
577
  // skeleton 卡是静态结构,理论上必 ≤28KB;保留兜底断言以防未来结构膨胀
719
578
  if (Buffer.byteLength(JSON.stringify(skeletonCard), 'utf8') <= CARD_BUDGET_BYTES) {
720
579
  return skeletonCard;
@@ -823,8 +682,16 @@ function buildSummaryContent(state) {
823
682
  else if (state.terminal === 'interrupted') {
824
683
  // 审批超时(approval_expired 已标记)是「无人响应被自动取消」,不是用户
825
684
  // 主动终止;如实展示原因,避免用户误判为 Agent 出错或自己操作过。
826
- const expiredApproval = (state.approvals ?? []).some((a) => a.expired);
827
- elements.push(markdownDiv(expiredApproval ? '⏰ **审批超时未响应,已自动取消**' : '⏹ **已被用户终止**'));
685
+ // 顶层 approvalExpired 优先:approval_resolved 会移除审批条目,但标记
686
+ // 必须活到终态渲染(2026-08-15 事故回归)。
687
+ const expiredApproval = state.interruptedReason === 'approval_timeout' ||
688
+ state.approvalExpired === true ||
689
+ (state.approvals ?? []).some((a) => a.expired);
690
+ elements.push(markdownDiv(expiredApproval
691
+ ? '⏰ **审批超时未响应,已自动取消**'
692
+ : state.interruptedReason === 'approval_cancelled'
693
+ ? '⏹ **已取消审批,任务终止**'
694
+ : '⏹ **已被用户终止**'));
828
695
  }
829
696
  else if (state.terminal === 'idle_timeout') {
830
697
  elements.push(markdownDiv(`⏱ **已超时**\n\n${state.idleTimeoutMinutes ?? 0} 分钟无响应,已自动终止`));
@@ -1,6 +1,8 @@
1
1
  import type { AgentEvent, ApprovalView } from '../runner/index.js';
2
2
  export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interrupted' | 'idle_timeout';
3
3
  export type RunFooter = 'thinking' | 'tool_running' | 'streaming' | null;
4
+ /** 中断来源(interrupted 终态的原因),供卡片如实归因。 */
5
+ export type InterruptedReason = 'approval_timeout' | 'user_stop' | 'approval_cancelled';
4
6
  type ToolStatus = 'running' | 'ok' | 'error';
5
7
  export interface ToolEntry {
6
8
  id: string;
@@ -61,6 +63,8 @@ export interface RunState {
61
63
  blocks: RunBlock[];
62
64
  sessionId?: string;
63
65
  resultSubtype?: 'success' | 'error' | 'interrupted';
66
+ /** interrupted 终态的中断来源;undefined = 未归类(渲染默认文案)。 */
67
+ interruptedReason?: InterruptedReason;
64
68
  contextLength?: number;
65
69
  /** 当前模型 context window 上限(codex jsonl 提供);卡片据此渲染百分比。 */
66
70
  contextLimit?: number;
@@ -98,9 +102,18 @@ export interface RunState {
98
102
  view: ApprovalView;
99
103
  expired?: boolean;
100
104
  }>;
105
+ /**
106
+ * 审批是否曾过期(桥侧 timer 触发,approval_expired 事件置位)。
107
+ * 独立于 approvals 数组持久保存:cancel 送达后 server 会回
108
+ * approval_resolved,把对应审批条目从数组移除,但过期原因必须活到
109
+ * 终态渲染(2026-08-15 事故:标记被消费后卡片误报「已被用户终止」)。
110
+ */
111
+ approvalExpired?: boolean;
101
112
  }
102
113
  export interface FinishMeta {
103
114
  resultSubtype?: 'success' | 'error' | 'interrupted';
115
+ /** 中断来源:由桥侧在 finish 时显式传入(/stop → user_stop、审批卡取消 → approval_cancelled)。 */
116
+ interruptedReason?: InterruptedReason;
104
117
  contextLength?: number;
105
118
  /** 当前模型 context window 上限;透传到 RunState 供 done 卡片显示百分比。 */
106
119
  contextLimit?: number;
@@ -1,3 +1,4 @@
1
+ import { keepTail } from '../common/truncate.js';
1
2
  const MAX_REASONING_CHARS = 4_000;
2
3
  const MAX_TEXT_CHARS = 12_000;
3
4
  const MAX_TOOL_DETAIL_CHARS = 3_000;
@@ -64,6 +65,7 @@ export function reduceRunState(state, event) {
64
65
  if (event.type === 'approval_expired') {
65
66
  return {
66
67
  ...state,
68
+ approvalExpired: true,
67
69
  approvals: (state.approvals ?? []).map((a) => a.view.requestId === event.requestId ? { ...a, expired: true } : a),
68
70
  };
69
71
  }
@@ -100,14 +102,14 @@ function reduceTurnDiffEvent(state, event) {
100
102
  blocks: keepLatestBlocks(upsertSnapshotBlock(blocks, (b) => b.kind === 'text' && b.itemId === event.itemId, () => ({
101
103
  kind: 'text',
102
104
  itemId: event.itemId,
103
- content: keepLatest(event.text, MAX_TEXT_CHARS),
105
+ content: keepTail(event.text, MAX_TEXT_CHARS),
104
106
  timestamp: ts,
105
107
  ...(complete ? { completedAt: ts } : {}),
106
108
  }), (existing) => {
107
109
  const textBlock = existing;
108
110
  return {
109
111
  ...textBlock,
110
- content: keepLatest(event.text, MAX_TEXT_CHARS),
112
+ content: keepTail(event.text, MAX_TEXT_CHARS),
111
113
  ...(complete ? { completedAt: ts } : {}),
112
114
  };
113
115
  }, !complete)),
@@ -120,7 +122,7 @@ function reduceTurnDiffEvent(state, event) {
120
122
  blocks: keepLatestBlocks(upsertSnapshotBlock(next.blocks, (b) => b.kind === 'thinking' && b.itemId === event.itemId, () => ({
121
123
  kind: 'thinking',
122
124
  itemId: event.itemId,
123
- content: keepLatest(event.reasoning, MAX_REASONING_CHARS),
125
+ content: keepTail(event.reasoning, MAX_REASONING_CHARS),
124
126
  active: !complete,
125
127
  timestamp: ts,
126
128
  ...(complete ? { completedAt: ts } : {}),
@@ -128,7 +130,7 @@ function reduceTurnDiffEvent(state, event) {
128
130
  const thinkingBlock = existing;
129
131
  return {
130
132
  ...thinkingBlock,
131
- content: keepLatest(event.reasoning, MAX_REASONING_CHARS),
133
+ content: keepTail(event.reasoning, MAX_REASONING_CHARS),
132
134
  active: !complete,
133
135
  ...(complete ? { completedAt: ts } : {}),
134
136
  };
@@ -145,7 +147,7 @@ function reduceTurnDiffEvent(state, event) {
145
147
  id: event.itemId,
146
148
  name: 'command',
147
149
  input: '',
148
- output: keepLatest(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
150
+ output: keepTail(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
149
151
  status: complete ? (event.toolStatus ?? 'ok') : 'running',
150
152
  startedAt: ts,
151
153
  ...(complete ? { completedAt: ts } : {}),
@@ -154,7 +156,7 @@ function reduceTurnDiffEvent(state, event) {
154
156
  kind: 'tool',
155
157
  tool: {
156
158
  ...existing.tool,
157
- output: keepLatest(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
159
+ output: keepTail(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
158
160
  status: complete
159
161
  ? (event.toolStatus ?? 'ok')
160
162
  : existing.tool.status,
@@ -165,7 +167,7 @@ function reduceTurnDiffEvent(state, event) {
165
167
  };
166
168
  }
167
169
  if (event.plan !== undefined) {
168
- const newPlan = keepLatest(event.plan, MAX_REASONING_CHARS * 2);
170
+ const newPlan = keepTail(event.plan, MAX_REASONING_CHARS * 2);
169
171
  next = {
170
172
  ...next,
171
173
  plan: newPlan,
@@ -298,7 +300,7 @@ function reduceAssistantEvent(state, event) {
298
300
  ...next.blocks.slice(0, -1),
299
301
  {
300
302
  kind: 'thinking',
301
- content: keepLatest(last.content + '\n' + content.thinking, MAX_REASONING_CHARS),
303
+ content: keepTail(last.content + '\n' + content.thinking, MAX_REASONING_CHARS),
302
304
  active: true,
303
305
  timestamp: last.timestamp ?? event.timestamp,
304
306
  },
@@ -307,7 +309,7 @@ function reduceAssistantEvent(state, event) {
307
309
  ...next.blocks,
308
310
  {
309
311
  kind: 'thinking',
310
- content: keepLatest(content.thinking, MAX_REASONING_CHARS),
312
+ content: keepTail(content.thinking, MAX_REASONING_CHARS),
311
313
  active: true,
312
314
  timestamp: event.timestamp,
313
315
  },
@@ -326,7 +328,7 @@ function reduceAssistantEvent(state, event) {
326
328
  ...blocks.slice(0, -1),
327
329
  {
328
330
  kind: 'text',
329
- content: keepLatest(last.content + content.text, MAX_TEXT_CHARS),
331
+ content: keepTail(last.content + content.text, MAX_TEXT_CHARS),
330
332
  timestamp: last.timestamp ?? event.timestamp,
331
333
  },
332
334
  ]
@@ -334,7 +336,7 @@ function reduceAssistantEvent(state, event) {
334
336
  ...blocks,
335
337
  {
336
338
  kind: 'text',
337
- content: keepLatest(content.text, MAX_TEXT_CHARS),
339
+ content: keepTail(content.text, MAX_TEXT_CHARS),
338
340
  timestamp: event.timestamp,
339
341
  },
340
342
  ];
@@ -412,7 +414,7 @@ function reducePlanEvent(state, event) {
412
414
  if (event.type !== 'plan')
413
415
  return state;
414
416
  const currentPlan = state.plan ?? '';
415
- const newPlan = keepLatest(currentPlan + '\n' + event.plan, MAX_REASONING_CHARS * 2);
417
+ const newPlan = keepTail(currentPlan + '\n' + event.plan, MAX_REASONING_CHARS * 2);
416
418
  // Plan is a single evolving document — replace the existing plan block
417
419
  // instead of appending a new one (avoids N overlapping blocks after N events).
418
420
  const withoutOldPlan = state.blocks.filter((b) => b.kind !== 'plan');
@@ -458,12 +460,18 @@ export function finishRun(state, terminal, meta = {}) {
458
460
  return state;
459
461
  return { ...state, ...sanitizeMeta(meta) };
460
462
  }
463
+ // interrupted 终态的中断来源:显式传入优先;否则从顶层持久标记推导
464
+ // (approval_expired 已置位但调用方未传 reason 的路径,如协调器过期自动
465
+ // cancel。原因必须在终态确定,不依赖调用方记得传参)。
466
+ const derivedReason = meta.interruptedReason ??
467
+ (terminal === 'interrupted' && state.approvalExpired === true ? 'approval_timeout' : undefined);
461
468
  return {
462
469
  ...state,
463
470
  terminal,
464
471
  footer: null,
465
472
  blocks: markThinkingInactive(state.blocks),
466
473
  ...sanitizeMeta(meta),
474
+ ...(derivedReason !== undefined ? { interruptedReason: derivedReason } : {}),
467
475
  };
468
476
  }
469
477
  /** Remove undefined/null meta fields to avoid overwriting existing state with nullish values. */
@@ -483,7 +491,11 @@ function stringifyUnknown(value) {
483
491
  if (typeof value === 'string')
484
492
  return value;
485
493
  try {
486
- return JSON.stringify(value);
494
+ // JSON.stringify(undefined) returns the value undefined, which would
495
+ // violate this function's string contract and crash callers that take
496
+ // .length on the result (2026-08-17 kimi ACP tool_call without rawInput
497
+ // → truncateDetail TypeError). Normalize to ''.
498
+ return JSON.stringify(value) ?? '';
487
499
  }
488
500
  catch {
489
501
  return String(value);
@@ -507,9 +519,6 @@ function tryParseRecord(input) {
507
519
  return null;
508
520
  }
509
521
  }
510
- function keepLatest(value, maxChars) {
511
- return value.length <= maxChars ? value : value.slice(-maxChars);
512
- }
513
522
  function keepLatestBlocks(blocks) {
514
523
  return blocks.length <= MAX_BLOCKS ? blocks : blocks.slice(-MAX_BLOCKS);
515
524
  }