lark-remote 0.5.19 → 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 (136) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bridge/approval-actions.d.ts +1 -1
  3. package/dist/bridge/approval-actions.js +1 -1
  4. package/dist/bridge/bash-run.d.ts +71 -0
  5. package/dist/bridge/bash-run.js +217 -0
  6. package/dist/bridge/compact.d.ts +155 -0
  7. package/dist/bridge/compact.js +450 -0
  8. package/dist/bridge/final-usage.js +4 -4
  9. package/dist/bridge/inbound-media.js +72 -46
  10. package/dist/bridge/index.d.ts +106 -153
  11. package/dist/bridge/index.js +400 -1000
  12. package/dist/bridge/job-watch.d.ts +68 -0
  13. package/dist/bridge/job-watch.js +146 -0
  14. package/dist/bridge/queue-manager.d.ts +3 -3
  15. package/dist/bridge/queue-manager.js +10 -29
  16. package/dist/card/approval-render.d.ts +2 -2
  17. package/dist/card/approval-render.js +141 -130
  18. package/dist/card/card-budget.js +123 -98
  19. package/dist/card/card-helpers.d.ts +3 -3
  20. package/dist/card/card-helpers.js +6 -6
  21. package/dist/card/final-answer-file.js +1 -1
  22. package/dist/card/run-renderer.d.ts +3 -3
  23. package/dist/card/run-renderer.js +114 -159
  24. package/dist/card/run-state.d.ts +1 -15
  25. package/dist/card/run-state.js +358 -292
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +21 -17
  28. package/dist/card/usage-format.d.ts +22 -7
  29. package/dist/card/usage-format.js +119 -100
  30. package/dist/cli.js +15 -3
  31. package/dist/clone.js +2 -2
  32. package/dist/common/text-width.js +36 -24
  33. package/dist/common/tool-summary.js +132 -90
  34. package/dist/config/codex-config.js +130 -106
  35. package/dist/config/index.d.ts +16 -2
  36. package/dist/config/index.js +23 -8
  37. package/dist/connector/index.js +6 -6
  38. package/dist/error-classification.js +3 -2
  39. package/dist/inbound/placeholder.d.ts +1 -1
  40. package/dist/inbound/placeholder.js +1 -1
  41. package/dist/index.js +387 -281
  42. package/dist/logger/index.js +6 -6
  43. package/dist/message-handlers.d.ts +0 -5
  44. package/dist/message-handlers.js +254 -237
  45. package/dist/model-catalog.d.ts +2 -2
  46. package/dist/model-catalog.js +3 -8
  47. package/dist/platform/identity.js +1 -1
  48. package/dist/platform/path.d.ts +1 -1
  49. package/dist/platform/path.js +1 -1
  50. package/dist/platform/sleep-blocker.js +1 -1
  51. package/dist/platform/terminator-win32.js +2 -1
  52. package/dist/router/commands/ls.d.ts +3 -0
  53. package/dist/router/commands/ls.js +256 -175
  54. package/dist/router/commands/order.js +184 -159
  55. package/dist/router/commands/resume.js +190 -152
  56. package/dist/router/commands/ws.js +219 -174
  57. package/dist/router/config/dsh.d.ts +4 -8
  58. package/dist/router/config/dsh.js +3 -10
  59. package/dist/router/config/kimi.js +1 -1
  60. package/dist/router/config/types.d.ts +1 -1
  61. package/dist/router/config-diff.js +1 -1
  62. package/dist/router/index.d.ts +56 -11
  63. package/dist/router/index.js +401 -391
  64. package/dist/runner/bash/runner.js +2 -4
  65. package/dist/runner/claude/session.d.ts +1 -1
  66. package/dist/runner/claude/session.js +6 -4
  67. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  68. package/dist/runner/codex/app-server/runner.js +30 -25
  69. package/dist/runner/codex/app-server/translator.d.ts +2 -5
  70. package/dist/runner/codex/app-server/translator.js +291 -238
  71. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  72. package/dist/runner/common/acp/acp-approval.js +1 -1
  73. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  74. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  75. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  76. package/dist/runner/common/acp/base-translator.js +50 -26
  77. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  78. package/dist/runner/common/acp/protocol-types.js +2 -2
  79. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  80. package/dist/runner/common/connection-based-runner.js +32 -2
  81. package/dist/runner/common/jsonl-stream.js +154 -187
  82. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  83. package/dist/runner/common/jsonrpc/client.js +43 -12
  84. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  85. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  86. package/dist/runner/common/spawning-runner.d.ts +6 -1
  87. package/dist/runner/common/spawning-runner.js +9 -4
  88. package/dist/runner/common/turn-started.d.ts +8 -0
  89. package/dist/runner/common/turn-started.js +11 -0
  90. package/dist/runner/dsh/approval.js +84 -51
  91. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  92. package/dist/runner/dsh/catalog-probe.js +10 -0
  93. package/dist/runner/dsh/client.d.ts +7 -11
  94. package/dist/runner/dsh/client.js +356 -289
  95. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  96. package/dist/runner/dsh/exit-cancel.js +2 -2
  97. package/dist/runner/dsh/inflight.d.ts +1 -1
  98. package/dist/runner/dsh/inflight.js +1 -1
  99. package/dist/runner/dsh/mux-stream.js +28 -8
  100. package/dist/runner/dsh/runner.d.ts +44 -4
  101. package/dist/runner/dsh/runner.js +296 -199
  102. package/dist/runner/dsh/translator.d.ts +1 -31
  103. package/dist/runner/dsh/translator.js +180 -178
  104. package/dist/runner/index.d.ts +1 -1
  105. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  106. package/dist/runner/kimi/acp/runner.js +82 -46
  107. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  108. package/dist/runner/kimi/acp/translator.js +3 -4
  109. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  110. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  111. package/dist/runner/opencode/acp/runner.js +4 -4
  112. package/dist/runner/pi/rpc/client.js +4 -13
  113. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  114. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  115. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  116. package/dist/runner/pi/rpc/runner.js +27 -42
  117. package/dist/runner/pi/rpc/translator.js +61 -60
  118. package/dist/runner/types.d.ts +2 -2
  119. package/dist/runner/types.js +1 -1
  120. package/dist/session/claude/sessions.d.ts +45 -0
  121. package/dist/session/claude/sessions.js +160 -122
  122. package/dist/session/codex/rollout-reader.js +181 -182
  123. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  124. package/dist/session/common/throw-session-list-error.js +16 -0
  125. package/dist/session/dsh/sessions.js +291 -235
  126. package/dist/session/kimi/sessions.d.ts +40 -8
  127. package/dist/session/kimi/sessions.js +373 -301
  128. package/dist/session/opencode/sessions.d.ts +142 -1
  129. package/dist/session/opencode/sessions.js +378 -321
  130. package/dist/session/pi/sessions.js +4 -5
  131. package/dist/session/session-store.d.ts +9 -1
  132. package/dist/session/session-store.js +99 -99
  133. package/dist/update/version-check.js +2 -1
  134. package/dist/workspace/index.d.ts +1 -1
  135. package/dist/workspace/index.js +1 -1
  136. package/package.json +1 -1
@@ -66,13 +66,18 @@ 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.
72
79
  */
73
80
  export function renderApprovalArea(approval, opts) {
74
- if (!approval)
75
- return [];
76
81
  // 终态(done/error/interrupted/idle_timeout)下 run 已结束:coordinator 已
77
82
  // 释放,按钮点击无处响应,隐藏整个审批区避免"点了没反应"。
78
83
  if (isTerminalHidden(opts))
@@ -85,21 +90,10 @@ export function renderApprovalArea(approval, opts) {
85
90
  text: { tag: 'lark_md', content: `ℹ️ ${neutralized}` },
86
91
  });
87
92
  }
88
- if (approval.kind === 'command') {
89
- elements.push(...renderCommandApproval(approval, opts?.expired, opts?.runId));
90
- }
91
- else if (approval.kind === 'tool') {
92
- elements.push(...renderToolApproval(approval, opts?.expired, opts?.runId));
93
- }
94
- else if (approval.kind === 'file') {
95
- elements.push(...renderFileApproval(approval, opts?.expired, opts?.runId));
96
- }
97
- else if (approval.kind === 'permissions') {
98
- elements.push(...renderPermissionsApproval(approval, opts?.expired, opts?.runId));
99
- }
100
- else if (approval.kind === 'question') {
101
- elements.push(...renderQuestionApproval(approval, opts?.expired, opts?.runId));
102
- }
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));
103
97
  // 响应发送失败:明确告知原因 + 可重试,而不是静默停在可点状态。
104
98
  if (approval.deliveryError) {
105
99
  const neutralized = capApprovalText(approval.deliveryError);
@@ -123,8 +117,6 @@ export function renderApprovalArea(approval, opts) {
123
117
  * 结构性保证 ≤ 几个元素 + ≤ 1KB 文本,审批入口(红线)在任何降级档都活着。
124
118
  */
125
119
  export function renderApprovalAreaCompact(approval, opts) {
126
- if (!approval)
127
- return [];
128
120
  if (isTerminalHidden(opts))
129
121
  return [];
130
122
  const elements = [
@@ -433,9 +425,26 @@ function renderFileDiff(diff) {
433
425
  }
434
426
  /** Per-diff render cap (chars), see TOTAL_DIFF_CHARS for the approval-area cap. */
435
427
  const MAX_DIFF_CHARS = 1200;
436
- // =============================================================================
437
- // Permissions Approval
438
- // =============================================================================
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
+ }
439
448
  function renderPermissionsApproval(approval, expired, runId) {
440
449
  const elements = [];
441
450
  elements.push({
@@ -449,21 +458,7 @@ function renderPermissionsApproval(approval, expired, runId) {
449
458
  const items = approval.permissions.items.slice(0, MAX_RENDERED_CHANGE_ITEMS);
450
459
  const omittedItems = approval.permissions.items.length - items.length;
451
460
  for (const item of items) {
452
- const icon = item.selected ? '✅' : '⬜';
453
- // label 仅展示(payload 用 item.id),可安全截断。
454
- const label = capApprovalText(item.label, APPROVAL_LABEL_BYTES);
455
- elements.push({
456
- tag: 'div',
457
- text: { tag: 'lark_md', content: `${icon} ${label}` },
458
- });
459
- elements.push(cardButton(item.selected ? '取消授予' : '授予', {
460
- cmd: 'approval.toggle',
461
- requestId: approval.requestId,
462
- permId: item.id,
463
- selected: !item.selected,
464
- runId,
465
- nonce: mkNonce(),
466
- }, { type: item.selected ? 'default' : 'primary', size: 'small' }));
461
+ elements.push(...renderPermissionItem(item, approval.requestId, runId));
467
462
  }
468
463
  if (omittedItems > 0) {
469
464
  elements.push({
@@ -533,112 +528,128 @@ function renderQuestionApproval(approval, expired, runId) {
533
528
  break;
534
529
  }
535
530
  renderedQuestions++;
536
- const q = questions[i];
537
- const selected = q.selected ?? [];
538
- const header = capApprovalText(q.header || `问题 ${i + 1}`, APPROVAL_LABEL_BYTES);
539
- const freeText = (q.options?.length ?? 0) === 0;
531
+ elements.push(...renderQuestionBlock(approval, i, runId));
532
+ }
533
+ // 提问卡底部统一「跳过回答」:走现有 approval.respond + decision=decline
534
+ // (coordinator 对 question 允许 decline 作为安全兜底),runner 按协议映射
535
+ // 跳过语义(Claude deny / Codex 空 answers / Kimi action decline / Pi cancelled)。
536
+ if (!expired) {
537
+ elements.push(cardButton('⏭️ 跳过回答', {
538
+ cmd: 'approval.respond',
539
+ decision: 'decline',
540
+ requestId: approval.requestId,
541
+ runId,
542
+ nonce: mkNonce(),
543
+ }, { type: 'default' }));
544
+ }
545
+ return elements;
546
+ }
547
+ /**
548
+ * 渲染单题区块:题面 + 详情面板 + 选项行/输入框 + (多选)提交按钮。
549
+ * 从 renderQuestionApproval 的循环体抽出;循环保留题数/元素预算裁剪与
550
+ * 卡片装配。交互组件(按钮/输入框)继续直挂 body elements——本函数输出
551
+ * 必须始终被 expectNoV1ActionContainer 断言覆盖(200861 红线,见
552
+ * run-renderer.test.ts 的 question 系列);nonce 仍由各交互元素生成时
553
+ * 各自调用一次 mkNonce(),不在块外缓存复用。
554
+ */
555
+ function renderQuestionBlock(approval, qIndex, runId) {
556
+ const q = approval.questions?.[qIndex];
557
+ // 越界防御(正常调用由循环边界保证);不虚构题面。
558
+ if (!q)
559
+ return [];
560
+ const elements = [];
561
+ const selected = q.selected ?? [];
562
+ const header = capApprovalText(q.header || `问题 ${qIndex + 1}`, APPROVAL_LABEL_BYTES);
563
+ const freeText = (q.options?.length ?? 0) === 0;
564
+ elements.push({
565
+ tag: 'div',
566
+ text: {
567
+ tag: 'lark_md',
568
+ content: `**${header}**${q.multiSelect ? '(可多选)' : ''}\n${capApprovalText(q.question)}`,
569
+ },
570
+ });
571
+ if (q.detail) {
572
+ // 题面外的补充正文(DSH plan-review 提问把计划全文放这里):必须随题
573
+ // 渲染,否则用户是在「看不见计划」的情况下批准。与 ExitPlanMode 的
574
+ // 计划面板同口径:反引号中和 + 表格限数 + 字节预算折叠展示。
575
+ const neutralized = q.detail.replace(/`/g, '·');
576
+ const content = truncateUtf8(truncateMarkdownTables(neutralized), PLAN_PANEL_BYTES);
577
+ elements.push(collapsibleMarkdownPanel({
578
+ title: '📋 详情',
579
+ expanded: false,
580
+ border: 'blue',
581
+ content,
582
+ }));
583
+ }
584
+ if (freeText) {
585
+ // 自由文本题(Codex options:null / Pi extension input):无选项行,
586
+ // 直接渲染题面 + 输入框,提交走 answerCustom(coordinator 不要求有选项)。
587
+ elements.push(renderAnswerInput(approval.requestId, qIndex, q.placeholder ?? '输入回答…', runId));
588
+ // 已答回显:input_value 不跨卡片更新保留,多题场景下用户提交后若还有
589
+ // 其他问题未答,卡片必须展示已答文本(与自定义答案选中态同模式)。
590
+ const answered = q.selected?.find((s) => s.length > 0);
591
+ if (answered) {
592
+ elements.push({
593
+ tag: 'div',
594
+ text: { tag: 'lark_md', content: `✍️ 已答:${capApprovalText(answered)}` },
595
+ });
596
+ }
597
+ return elements;
598
+ }
599
+ // 选项渲染上限(label 是回调 payload 不可截断,只能按条数折叠)。
600
+ const options = q.options ?? [];
601
+ for (const option of options.slice(0, MAX_RENDERED_OPTIONS)) {
602
+ const isSelected = selected.includes(option.label);
603
+ // 单选/多选图标区分:单选 ⚪/🔵(radio 隐喻),多选 ⬜/☑️(checkbox 隐喻)。
604
+ const icon = q.multiSelect ? (isSelected ? '☑️' : '⬜') : isSelected ? '🔵' : '⚪';
605
+ elements.push(renderOptionRow(approval.requestId, qIndex, option, isSelected, icon, runId));
606
+ }
607
+ if (options.length > MAX_RENDERED_OPTIONS) {
540
608
  elements.push({
541
609
  tag: 'div',
542
610
  text: {
543
611
  tag: 'lark_md',
544
- content: `**${header}**${q.multiSelect ? '(可多选)' : ''}\n${capApprovalText(q.question)}`,
612
+ content: `_…另有 ${options.length - MAX_RENDERED_OPTIONS} 个选项未展示(卡片元素预算)_`,
545
613
  },
546
614
  });
547
- if (q.detail) {
548
- // 题面外的补充正文(DSH plan-review 提问把计划全文放这里):必须随题
549
- // 渲染,否则用户是在「看不见计划」的情况下批准。与 ExitPlanMode 的
550
- // 计划面板同口径:反引号中和 + 表格限数 + 字节预算折叠展示。
551
- const neutralized = q.detail.replace(/`/g, '·');
552
- const content = truncateUtf8(truncateMarkdownTables(neutralized), PLAN_PANEL_BYTES);
553
- elements.push(collapsibleMarkdownPanel({
554
- title: '📋 详情',
555
- expanded: false,
556
- border: 'blue',
557
- content,
558
- }));
559
- }
560
- if (freeText) {
561
- // 自由文本题(Codex options:null / Pi extension input):无选项行,
562
- // 直接渲染题面 + 输入框,提交走 answerCustom(coordinator 不要求有选项)。
563
- elements.push(renderAnswerInput(approval.requestId, i, q.placeholder ?? '输入回答…', runId));
564
- // 已答回显:input_value 不跨卡片更新保留,多题场景下用户提交后若还有
565
- // 其他问题未答,卡片必须展示已答文本(与自定义答案选中态同模式)。
566
- const answered = q.selected?.find((s) => s.length > 0);
567
- if (answered) {
568
- elements.push({
569
- tag: 'div',
570
- text: { tag: 'lark_md', content: `✍️ 已答:${capApprovalText(answered)}` },
571
- });
572
- }
573
- continue;
574
- }
575
- // 选项渲染上限(label 是回调 payload 不可截断,只能按条数折叠)。
576
- const options = q.options ?? [];
577
- for (const option of options.slice(0, MAX_RENDERED_OPTIONS)) {
578
- const isSelected = selected.includes(option.label);
579
- // 单选/多选图标区分:单选 ⚪/🔵(radio 隐喻),多选 ⬜/☑️(checkbox 隐喻)。
580
- const icon = q.multiSelect ? (isSelected ? '☑️' : '⬜') : isSelected ? '🔵' : '⚪';
581
- elements.push(renderOptionRow(approval.requestId, i, option, isSelected, icon, runId));
582
- }
583
- if (options.length > MAX_RENDERED_OPTIONS) {
584
- elements.push({
585
- tag: 'div',
586
- text: {
587
- tag: 'lark_md',
588
- content: `_…另有 ${options.length - MAX_RENDERED_OPTIONS} 个选项未展示(卡片元素预算)_`,
589
- },
590
- });
591
- }
592
- // 自定义答案(Other)的选中态展示:选项按钮无法表示自由文本,单独显示
593
- // 已选文本,避免「点了没反应」的困惑。
594
- const customSelected = q.selected?.find((s) => !q.options.some((o) => o.label === s));
595
- if (customSelected) {
615
+ }
616
+ // 自定义答案(Other)的选中态展示:选项按钮无法表示自由文本,单独显示
617
+ // 已选文本,避免「点了没反应」的困惑。
618
+ const customSelected = q.selected?.find((s) => !q.options.some((o) => o.label === s));
619
+ if (customSelected) {
620
+ elements.push({
621
+ tag: 'div',
622
+ text: { tag: 'lark_md', content: `✍️ 自定义答案:${capApprovalText(customSelected)}` },
623
+ });
624
+ }
625
+ // 单选问题提供自定义答案(Other)输入——输入文本后提交
626
+ // (移动端键盘回车/完成键,桌面端输入框右侧提交图标),input_value 经
627
+ // connector raw 事件回传。
628
+ // 显隐按 isOther !== false:Kimi form 会丢弃非声明选项值,翻译时置
629
+ // isOther=false 隐藏输入;Claude 未设置该字段默认显示(行为不变)。
630
+ if (!q.multiSelect && q.isOther !== false) {
631
+ elements.push(renderAnswerInput(approval.requestId, qIndex, '自定义答案(Other)…', runId, 'approval.answerCustom'));
632
+ }
633
+ // Codex user_note:选项之外的补充说明(allowNote 数据驱动门控)。
634
+ // 提交走 answerNote(coordinator 记录 note,随答案一起提交),已填回显。
635
+ if (q.allowNote === true) {
636
+ elements.push(renderAnswerInput(approval.requestId, qIndex, '补充说明(可选)…', runId, 'approval.answerNote'));
637
+ if (q.note) {
596
638
  elements.push({
597
639
  tag: 'div',
598
- text: { tag: 'lark_md', content: `✍️ 自定义答案:${capApprovalText(customSelected)}` },
640
+ text: { tag: 'lark_md', content: `📝 ${capApprovalText(q.note)}` },
599
641
  });
600
642
  }
601
- // 单选问题提供自定义答案(Other)输入——输入文本后提交
602
- // (移动端键盘回车/完成键,桌面端输入框右侧提交图标),input_value 经
603
- // connector raw 事件回传。
604
- // 显隐按 isOther !== false:Kimi form 会丢弃非声明选项值,翻译时置
605
- // isOther=false 隐藏输入;Claude 未设置该字段默认显示(行为不变)。
606
- if (!q.multiSelect && q.isOther !== false) {
607
- elements.push(renderAnswerInput(approval.requestId, i, '自定义答案(Other)…', runId, 'approval.answerCustom'));
608
- }
609
- // Codex user_note:选项之外的补充说明(allowNote 数据驱动门控)。
610
- // 提交走 answerNote(coordinator 记录 note,随答案一起提交),已填回显。
611
- if (q.allowNote === true) {
612
- elements.push(renderAnswerInput(approval.requestId, i, '补充说明(可选)…', runId, 'approval.answerNote'));
613
- if (q.note) {
614
- elements.push({
615
- tag: 'div',
616
- text: { tag: 'lark_md', content: `📝 ${capApprovalText(q.note)}` },
617
- });
618
- }
619
- }
620
- // 多选问题:有勾选才给提交按钮(单选在协调器内即时提交)。
621
- if (q.multiSelect && selected.length > 0) {
622
- elements.push(cardButton(`✅ 提交答案(已选 ${selected.length} 项)`, {
623
- cmd: 'approval.answerSubmit',
624
- requestId: approval.requestId,
625
- questionIndex: i,
626
- runId,
627
- nonce: mkNonce(),
628
- }, { type: 'primary' }));
629
- }
630
643
  }
631
- // 提问卡底部统一「跳过回答」:走现有 approval.respond + decision=decline
632
- // (coordinator 对 question 允许 decline 作为安全兜底),runner 按协议映射
633
- // 跳过语义(Claude deny / Codex 空 answers / Kimi action decline / Pi cancelled)。
634
- if (!expired) {
635
- elements.push(cardButton('⏭️ 跳过回答', {
636
- cmd: 'approval.respond',
637
- decision: 'decline',
644
+ // 多选问题:有勾选才给提交按钮(单选在协调器内即时提交)。
645
+ if (q.multiSelect && selected.length > 0) {
646
+ elements.push(cardButton(`✅ 提交答案(已选 ${selected.length} 项)`, {
647
+ cmd: 'approval.answerSubmit',
638
648
  requestId: approval.requestId,
649
+ questionIndex: qIndex,
639
650
  runId,
640
651
  nonce: mkNonce(),
641
- }, { type: 'default' }));
652
+ }, { type: 'primary' }));
642
653
  }
643
654
  return elements;
644
655
  }
@@ -693,7 +704,7 @@ function renderOptionRow(requestId, questionIndex, option, isSelected, icon, run
693
704
  function renderAnswerInput(requestId, questionIndex, placeholder, runId, cmd = 'approval.answerCustom') {
694
705
  // name 必须含 cmd,保证同一题同时渲染多个输入框(如单选选项题的
695
706
  // Other 输入 + Note 输入)时元素 name 唯一——飞书对同卡内重复 name
696
- // 报 ErrCode 11310 拒绝整卡(2026-08-19 线上:name(answer-custom-0-0) duplicate)。
707
+ // 报 ErrCode 11310 拒绝整卡。
697
708
  const kind = cmd === 'approval.answerNote' ? 'note' : 'custom';
698
709
  return {
699
710
  tag: 'input',
@@ -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
- * 标题文本。历史教训(2026-08-08):旧实现用
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
  */
@@ -27,7 +27,7 @@ export declare function parseJumpOffset(inputValue: unknown, pageSize: number):
27
27
  * Build the CardKit 2.0 pagination block shared by /ls, /ws, /resume, /active,
28
28
  * /order.
29
29
  *
30
- * 第一性原理(2026-09-10 窄屏重设计):分页栏要同时承载「页码信息」和「翻页
30
+ * 第一性原理(窄屏):分页栏要同时承载「页码信息」和「翻页
31
31
  * 控件」两类需求。旧布局把两者塞进同一行 `column_set`(文案 weighted 4 : 输入
32
32
  * weighted 2),手机窄屏下 auto 按钮先占走固有宽度,剩余宽度按 4:2 切分后文案
33
33
  * 列只有百来像素、输入框只有几十像素——文案折行、输入框窄到没法点。
@@ -104,8 +104,8 @@ export declare function searchBar(opts: {
104
104
  *
105
105
  * 旧口径是 `events.slice(-max)`:按**原始事件条数**截尾。工具往返(bash 调用 →
106
106
  * job 登记 → 结果)一段就能把窗口占满,于是重返会话的用户真正需要的两条信息
107
- * ——最后一句我说了什么、最后一句它答了什么——被挤出窗口。2026-10-19 实测:
108
- * 自动恢复卡 5 个面板全是 bash 调用,一条用户消息、一条 assistant 正文都没有。
107
+ * ——最后一句我说了什么、最后一句它答了什么——被挤出窗口:自动恢复卡 5 个
108
+ * 面板全是 bash 调用,一条用户消息、一条 assistant 正文都没有。
109
109
  *
110
110
  * 契约:先取最新 `max` 条,再把最后一条 `user` 与最后一条 `assistant`(正文)
111
111
  * 不在窗口内时补进来——补位时淘汰最早的**非锚点**事件。输出保持时间顺序,