lark-remote 0.5.12 → 0.5.14

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 (172) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/README.en.md +1 -14
  3. package/README.md +1 -14
  4. package/dist/agent-kinds.d.ts +27 -0
  5. package/dist/agent-kinds.js +38 -0
  6. package/dist/bridge/approval-actions.d.ts +95 -0
  7. package/dist/bridge/approval-actions.js +104 -0
  8. package/dist/bridge/approval-coordinator.d.ts +1 -0
  9. package/dist/bridge/approval-coordinator.js +12 -24
  10. package/dist/bridge/final-usage.d.ts +8 -17
  11. package/dist/bridge/final-usage.js +1 -18
  12. package/dist/bridge/index.d.ts +52 -38
  13. package/dist/bridge/index.js +255 -433
  14. package/dist/bridge/queue-manager.d.ts +25 -1
  15. package/dist/bridge/queue-manager.js +179 -160
  16. package/dist/bridge/run-approval.d.ts +16 -0
  17. package/dist/bridge/run-approval.js +41 -0
  18. package/dist/bridge/run-usage.d.ts +32 -0
  19. package/dist/bridge/run-usage.js +87 -0
  20. package/dist/bridge/session-writeback.d.ts +19 -0
  21. package/dist/bridge/session-writeback.js +36 -0
  22. package/dist/card/approval-render.d.ts +3 -2
  23. package/dist/card/approval-render.js +3 -2
  24. package/dist/card/bash-card-session.js +2 -5
  25. package/dist/{router → card}/card-helpers.d.ts +7 -7
  26. package/dist/{router → card}/card-helpers.js +8 -8
  27. package/dist/card/card-session.d.ts +6 -2
  28. package/dist/card/card-session.js +28 -28
  29. package/dist/card/card-shared.d.ts +4 -4
  30. package/dist/card/card-shared.js +4 -10
  31. package/dist/card/collapsible.js +3 -2
  32. package/dist/card/final-answer-overflow.d.ts +1 -0
  33. package/dist/card/final-answer-overflow.js +46 -0
  34. package/dist/card/run-card-session.d.ts +2 -2
  35. package/dist/card/run-card-session.js +4 -9
  36. package/dist/card/run-renderer.d.ts +14 -0
  37. package/dist/card/run-renderer.js +126 -23
  38. package/dist/card/run-state.d.ts +40 -51
  39. package/dist/card/run-state.js +96 -34
  40. package/dist/card/usage-format.d.ts +30 -0
  41. package/dist/{router → card}/usage-format.js +3 -10
  42. package/dist/clone.js +6 -1
  43. package/dist/config/codex-config.js +4 -4
  44. package/dist/config/index.d.ts +18 -20
  45. package/dist/config/index.js +6 -20
  46. package/dist/config/pi-config.js +6 -4
  47. package/dist/connector/index.d.ts +2 -4
  48. package/dist/connector/index.js +7 -11
  49. package/dist/{runner/dsh → dsh}/auth.js +3 -4
  50. package/dist/dsh/home.d.ts +2 -0
  51. package/dist/dsh/home.js +8 -0
  52. package/dist/dsh/protocol.d.ts +9 -0
  53. package/dist/dsh/protocol.js +61 -0
  54. package/dist/inbound/turn-assembler.js +6 -0
  55. package/dist/index.js +25 -414
  56. package/dist/message-handlers.d.ts +26 -0
  57. package/dist/message-handlers.js +275 -0
  58. package/dist/model-catalog.d.ts +14 -0
  59. package/dist/model-catalog.js +25 -0
  60. package/dist/platform/path.d.ts +9 -0
  61. package/dist/platform/path.js +18 -0
  62. package/dist/router/active-run-usage.d.ts +4 -0
  63. package/dist/router/active-run-usage.js +12 -0
  64. package/dist/router/card-action-payload.d.ts +1 -1
  65. package/dist/router/card-action-payload.js +1 -1
  66. package/dist/router/commands/ls.d.ts +9 -0
  67. package/dist/router/commands/ls.js +243 -0
  68. package/dist/router/commands/order.d.ts +12 -0
  69. package/dist/router/commands/order.js +178 -0
  70. package/dist/router/commands/resume.d.ts +48 -0
  71. package/dist/router/commands/resume.js +246 -0
  72. package/dist/router/commands/ws.d.ts +15 -0
  73. package/dist/router/commands/ws.js +189 -0
  74. package/dist/router/config/claude.js +5 -4
  75. package/dist/router/config/codex.js +2 -6
  76. package/dist/router/config/common/render.d.ts +5 -1
  77. package/dist/router/config/common/render.js +6 -13
  78. package/dist/router/config/common/turn-idle.d.ts +2 -0
  79. package/dist/router/config/common/turn-idle.js +9 -0
  80. package/dist/router/config/dsh.d.ts +1 -1
  81. package/dist/router/config/index.d.ts +1 -1
  82. package/dist/router/config/kimi.js +2 -6
  83. package/dist/router/config/opencode.js +2 -6
  84. package/dist/router/config/types.d.ts +11 -3
  85. package/dist/router/index.d.ts +55 -33
  86. package/dist/router/index.js +233 -1376
  87. package/dist/router/paging.d.ts +13 -0
  88. package/dist/router/paging.js +20 -0
  89. package/dist/router/path-validation.d.ts +27 -0
  90. package/dist/router/path-validation.js +45 -0
  91. package/dist/runner/agent-choices-common.d.ts +1 -1
  92. package/dist/runner/claude/runner.d.ts +23 -6
  93. package/dist/runner/claude/runner.js +6 -20
  94. package/dist/runner/claude/session.js +1 -1
  95. package/dist/runner/codex/app-server/runner.d.ts +3 -1
  96. package/dist/runner/codex/app-server/translator.d.ts +13 -0
  97. package/dist/runner/codex/app-server/translator.js +114 -151
  98. package/dist/runner/common/acp/base-acp-runner.d.ts +3 -1
  99. package/dist/runner/common/acp/base-translator.d.ts +3 -8
  100. package/dist/runner/common/acp/base-translator.js +16 -7
  101. package/dist/runner/common/connection-based-runner.d.ts +3 -1
  102. package/dist/runner/common/jsonrpc/transport.js +17 -7
  103. package/dist/runner/common/spawning-runner.js +10 -4
  104. package/dist/runner/common/turn-started.d.ts +2 -0
  105. package/dist/runner/common/turn-started.js +10 -0
  106. package/dist/runner/dsh/client.d.ts +2 -10
  107. package/dist/runner/dsh/client.js +13 -220
  108. package/dist/runner/dsh/daemon.d.ts +0 -2
  109. package/dist/runner/dsh/daemon.js +9 -7
  110. package/dist/runner/dsh/errors.d.ts +5 -0
  111. package/dist/runner/dsh/errors.js +11 -0
  112. package/dist/runner/dsh/exit-cancel.d.ts +0 -2
  113. package/dist/runner/dsh/exit-cancel.js +2 -2
  114. package/dist/runner/dsh/inflight.d.ts +0 -2
  115. package/dist/runner/dsh/inflight.js +1 -1
  116. package/dist/runner/dsh/mux-stream.d.ts +26 -0
  117. package/dist/runner/dsh/mux-stream.js +176 -0
  118. package/dist/runner/dsh/runner.d.ts +21 -1
  119. package/dist/runner/dsh/runner.js +76 -73
  120. package/dist/runner/dsh/translator.d.ts +1 -11
  121. package/dist/runner/dsh/translator.js +1 -1
  122. package/dist/runner/index.d.ts +1 -1
  123. package/dist/runner/kimi/acp/runner.d.ts +22 -1
  124. package/dist/runner/kimi/acp/runner.js +83 -60
  125. package/dist/runner/kimi/acp/translator.js +1 -12
  126. package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
  127. package/dist/runner/opencode/acp/error-monitor.js +31 -2
  128. package/dist/runner/opencode/acp/runner.d.ts +3 -1
  129. package/dist/runner/opencode/acp/translator.js +1 -12
  130. package/dist/runner/pi/rpc/runner.d.ts +2 -1
  131. package/dist/runner/pi/rpc/translator.d.ts +2 -8
  132. package/dist/runner/pi/rpc/translator.js +2 -7
  133. package/dist/runner/probe.d.ts +1 -1
  134. package/dist/runner/probe.js +10 -9
  135. package/dist/runner/registry.d.ts +2 -20
  136. package/dist/runner/registry.js +0 -32
  137. package/dist/runner/types.d.ts +37 -155
  138. package/dist/runner/types.js +4 -24
  139. package/dist/session/claude/session-index.d.ts +11 -4
  140. package/dist/session/claude/session-index.js +34 -10
  141. package/dist/session/claude/session-reader.d.ts +1 -1
  142. package/dist/session/claude/sessions.d.ts +2 -2
  143. package/dist/session/claude/sessions.js +41 -26
  144. package/dist/session/codex/rollout-reader.d.ts +1 -1
  145. package/dist/session/codex/rollout-reader.js +14 -13
  146. package/dist/session/codex/sessions.d.ts +1 -1
  147. package/dist/session/codex/sessions.js +4 -2
  148. package/dist/session/common/jsonl.js +7 -1
  149. package/dist/session/common/pagination.d.ts +2 -0
  150. package/dist/session/common/pagination.js +1 -1
  151. package/dist/session/common/recency.d.ts +6 -0
  152. package/dist/session/common/recency.js +7 -5
  153. package/dist/session/common/two-pass.d.ts +1 -1
  154. package/dist/session/common/usage-accumulator.d.ts +1 -1
  155. package/dist/session/dsh/sessions.d.ts +1 -1
  156. package/dist/session/dsh/sessions.js +37 -12
  157. package/dist/session/kimi/sessions.d.ts +1 -1
  158. package/dist/session/kimi/sessions.js +37 -10
  159. package/dist/session/opencode/sessions.d.ts +1 -1
  160. package/dist/session/opencode/sessions.js +53 -48
  161. package/dist/session/pi/sessions.d.ts +1 -1
  162. package/dist/session/pi/sessions.js +23 -9
  163. package/dist/session/registry.d.ts +2 -1
  164. package/dist/session/session-store.d.ts +1 -2
  165. package/dist/session/session-store.js +14 -7
  166. package/dist/session-contract.d.ts +178 -0
  167. package/dist/session-contract.js +9 -0
  168. package/package.json +1 -1
  169. package/dist/router/usage-format.d.ts +0 -60
  170. /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
  171. /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
  172. /package/dist/{runner/dsh → dsh}/types.js +0 -0
@@ -4,18 +4,22 @@ import { isContentFilterFailure } from '../common/turn-failure.js';
4
4
  import { resolveFinalUsage, errorMessage } from './final-usage.js';
5
5
  import { getLogger } from '../logger/index.js';
6
6
  import { RunCardSession } from '../card/run-card-session.js';
7
- import { renderRunCard } from '../card/run-renderer.js';
7
+ import { renderRunCard, renderRunCardDetailed, } from '../card/run-renderer.js';
8
+ import { splitFinalAnswer } from '../card/final-answer-overflow.js';
8
9
  import { renderBashCard } from '../card/bash-renderer.js';
9
10
  import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
10
- import { isRunTerminal } from '../card/run-state.js';
11
- import { buildSessionHistoryCard } from '../router/card-helpers.js';
11
+ import { isRunActive, isRunTerminal, removeBackgroundJob, } from '../card/run-state.js';
12
+ import { buildSessionHistoryCard } from '../card/card-helpers.js';
12
13
  import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
13
14
  import { enforceCardBudget } from '../card/card-budget.js';
14
- import { normalizeResultUsage } from '../runner/common/result-usage.js';
15
+ import { RunUsageCollector } from './run-usage.js';
15
16
  import { BashProcessRunner } from '../runner/index.js';
16
- import { AGENT_KINDS, supportsCompact } from '../runner/types.js';
17
+ import { AGENT_KINDS } from '../agent-kinds.js';
18
+ import { supportsCompact } from '../runner/types.js';
17
19
  import { QueueManager, } from './queue-manager.js';
18
- import { ApprovalCoordinator, decisionToApprovalAction } from './approval-coordinator.js';
20
+ import { ApprovalActions } from './approval-actions.js';
21
+ import { createRunApprovalCoordinator, remindPendingApproval } from './run-approval.js';
22
+ import { writeBackInitSession, writeBackResultSession, writeBackTurnStarted, } from './session-writeback.js';
19
23
  import { InboundMediaHandler } from './inbound-media.js';
20
24
  /**
21
25
  * Max events to read for the completion notification card (mirror router's
@@ -24,8 +28,6 @@ import { InboundMediaHandler } from './inbound-media.js';
24
28
  * budget.
25
29
  */
26
30
  const COMPLETION_NOTIFICATION_MAX_EVENTS = 5;
27
- /** Approval request timeout (ms) — pending approval expires after this. */
28
- const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
29
31
  /**
30
32
  * runner 缓存的工作区数量上限:超过后创建新工作区槽位时
31
33
  * 顺带回收「非 active 且进程已停」的死缓存,防止随访问过的工作区数量
@@ -141,6 +143,10 @@ export class Bridge {
141
143
  activeRuns = new Map();
142
144
  /** Approval coordinators keyed by runId. */
143
145
  approvalCoordinators = new Map();
146
+ /** 审批卡动作的实现(只读上面那张表;见 approval-actions.ts)。 */
147
+ approvalActions = new ApprovalActions({
148
+ coordinatorFor: (runId) => this.approvalCoordinators.get(runId),
149
+ });
144
150
  /**
145
151
  * 最近若干次已完成的、runner 有 runCompact 的 run(cwd → runId → 记录)。
146
152
  * run 结束后 activeRuns 已清空,而终态卡上的 Compact 按钮只带 runId,
@@ -223,13 +229,8 @@ export class Bridge {
223
229
  }
224
230
  // Create new runner for this (cwd, kind) slot
225
231
  const runner = this.agentRegistry.get(kind, cwd);
226
- // Call lifecycle methods for agent runners created via factory
227
- if (runner && typeof runner.killOrphan === 'function') {
228
- runner.killOrphan();
229
- }
230
- if (runner && typeof runner.registerExitHandlers === 'function') {
231
- runner.registerExitHandlers();
232
- }
232
+ runner.killOrphan();
233
+ runner.registerExitHandlers();
233
234
  // Store in nested map: Map<cwd, Map<kind, Runner>>。必须重新取一次:
234
235
  // replaceStale 刚把槽位 evict 掉,上面的局部 workspaceMap 可能已随空槽
235
236
  // 从 this.runners 移除——继续写它会生成一个「缓存里查不到」的 runner,
@@ -588,7 +589,7 @@ export class Bridge {
588
589
  for (const [_cwd, active] of this.activeRuns) {
589
590
  const state = active.session.currentState;
590
591
  // Only include non-terminal runs
591
- if (state.terminal === 'running' || state.terminal === 'finalizing') {
592
+ if (isRunActive(state.terminal)) {
592
593
  result.push({
593
594
  runId: active.runId,
594
595
  sessionId: state.sessionId ?? '',
@@ -814,10 +815,33 @@ export class Bridge {
814
815
  return false;
815
816
  }
816
817
  }
818
+ /**
819
+ * Deliver a final answer that cannot fit in the run card. Plain text avoids
820
+ * markdown parser limits and preserves every character; sequence labels make
821
+ * multi-message delivery unambiguous.
822
+ */
823
+ async sendFinalAnswerOverflow(text, ctx) {
824
+ const chunks = splitFinalAnswer(text);
825
+ try {
826
+ for (let i = 0; i < chunks.length; i++) {
827
+ const prefix = chunks.length > 1 ? `完整回答(${i + 1}/${chunks.length})\n\n` : '完整回答\n\n';
828
+ await this.connector.sendWithRetry(ctx.chatId, { text: prefix + chunks[i] }, { replyTo: ctx.messageId });
829
+ }
830
+ }
831
+ catch (err) {
832
+ getLogger().error('[lark-remote] failed to deliver complete final answer:', err);
833
+ try {
834
+ await this.connector.sendWithRetry(ctx.chatId, { text: `⚠️ 完整回答补发失败:${errorMessage(err)}` }, { replyTo: ctx.messageId });
835
+ }
836
+ catch (fallbackErr) {
837
+ getLogger().error('[lark-remote] final-answer failure notice also failed:', fallbackErr);
838
+ }
839
+ }
840
+ }
817
841
  /**
818
842
  * Update an existing card in place (原地更新) instead of sending a new one.
819
843
  *
820
- * Used by /config cardAction handlers (config.toggle/set/input/save) so the
844
+ * Used by /config cardAction handlers (config.set/input/save) so the
821
845
  * user sees the card refresh on the same message bubble rather than getting
822
846
  * a new card per click — same-message-bubble update pattern.
823
847
  *
@@ -863,7 +887,8 @@ export class Bridge {
863
887
  return true;
864
888
  }
865
889
  catch (err) {
866
- getLogger().debug(`[lark-remote] patchCard failed messageId=${messageId}: ${errorMessage(err)}`);
890
+ // 调用方会据此把卡片从集合里永久剔除——这是用户可见的退化,warn 起步。
891
+ getLogger().warn(`[lark-remote] patchCard failed messageId=${messageId}: ${errorMessage(err)}`);
867
892
  return false;
868
893
  }
869
894
  }
@@ -901,10 +926,7 @@ export class Bridge {
901
926
  const killed = await runner.killJob(sessionId, jobId);
902
927
  if (!killed)
903
928
  return false;
904
- snapshot.state = {
905
- ...snapshot.state,
906
- backgroundJobs: (snapshot.state.backgroundJobs ?? []).filter((job) => job.id !== jobId),
907
- };
929
+ snapshot.state = removeBackgroundJob(snapshot.state, jobId);
908
930
  await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
909
931
  return true;
910
932
  }
@@ -968,7 +990,7 @@ export class Bridge {
968
990
  }
969
991
  const entry = this.sessionStore.get(ctx.userId);
970
992
  getLogger().debug(`[lark-remote] forward entry userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} ` +
971
- `agent=${agentKind} sessionId=${opts?.binding?.sessionId ?? entry?.sessions?.get(agentKind) ?? '(none)'} message=${message.slice(0, 100)}`);
993
+ `agent=${agentKind} sessionId=${opts?.binding?.sessionId ?? entry?.sessions.get(agentKind) ?? '(none)'} message=${message.slice(0, 100)}`);
972
994
  // Check if current cwd already has a run in progress
973
995
  if (this.activeRuns.has(cwd)) {
974
996
  getLogger().warn(`[lark-remote] cwd busy, dropping message userId=${ctx.userId} cwd=${cwd}`);
@@ -1156,6 +1178,139 @@ export class Bridge {
1156
1178
  stop,
1157
1179
  };
1158
1180
  }
1181
+ /**
1182
+ * result 事件的 pre-init 守卫(§9.22)。
1183
+ *
1184
+ * Claude CLI `--resume` 会在 system.init 之前先吐一条**上一轮**的陈旧 result。
1185
+ * 认了它就会过早置 sawResult / 抓错 usage / 让 run-state reducer 提前进入
1186
+ * finalizing(reducer 用 sessionId===undefined 守卫,同一条红线)。本轮真正的
1187
+ * result 在 system.init 之后才到。
1188
+ *
1189
+ * 例外:app-server runner(usageAuthority==='live')的 init 是 runner 自己补发
1190
+ * 的 synthetic init(2026-08-13 修复)。此分支保留为防御性兜底:live runner 若
1191
+ * 在无 init 时发出 result(旧构建等),session 写回照常执行,但 sawResult 不置
1192
+ * 真——卡片不会把半初始化的 run 当作已收尾。
1193
+ *
1194
+ * @param dropFromCard 该 result 是陈旧重放,不得进卡片(调用方 continue 跳过 push)
1195
+ */
1196
+ observeResultEvent(event, opts) {
1197
+ const { sawInit, usageAuthority, usage, writeback, runId } = opts;
1198
+ const preInitResult = !sawInit;
1199
+ const preInitTerminal = preInitResult && (event.subtype === 'error' || event.subtype === 'interrupted')
1200
+ ? {
1201
+ subtype: event.subtype,
1202
+ errorMessage: event.errorMessage,
1203
+ }
1204
+ : undefined;
1205
+ if (preInitResult && usageAuthority !== 'live') {
1206
+ getLogger().info(`[lark-remote] pre-init result ignored (resume replay) runId=${runId}`);
1207
+ return { sawResult: false, dropFromCard: true, preInitTerminal };
1208
+ }
1209
+ usage.observeResult(event);
1210
+ getLogger().info(preInitResult
1211
+ ? `[lark-remote] pre-init result from live runner (app-server setup failure) runId=${runId}`
1212
+ : `[lark-remote] result event received runId=${runId}`);
1213
+ // app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)不会有
1214
+ // turn_started 事件,store 不会写回新线程 id;runner 已在 result 里兜底上报。
1215
+ // 仅当 runner 声明 live usage(app-server)且会话指针未动时写回,避免下条消息
1216
+ // 再开一个孤儿线程。其余 agent 不在此列(各自有 system.init / turn_started 写回)。
1217
+ if (usageAuthority === 'live')
1218
+ writeBackResultSession(writeback, event.session_id);
1219
+ return { sawResult: !preInitResult, dropFromCard: false, preInitTerminal };
1220
+ }
1221
+ /**
1222
+ * 审批三事件的派发(requested / resolved / view_updated)。
1223
+ *
1224
+ * coordinator 一 run 一个,首次 requested 时惰性建好并登记,`finally` 里统一
1225
+ * onTurnEnded + 摘除。
1226
+ */
1227
+ dispatchApprovalEvent(event, opts) {
1228
+ const { runId, runner, cardSession, ctx, agentKind } = opts;
1229
+ if (event.type === 'approval_requested') {
1230
+ // 审批请求到达:撤回 Typing 表情再重发,让飞书重新触发提醒,把用户
1231
+ // 注意力拉回待审批的卡片(run 在等待人工决策,Typing 应重新闪烁)。
1232
+ remindPendingApproval(this.connector, ctx.messageId, runId);
1233
+ let coordinator = this.approvalCoordinators.get(runId);
1234
+ if (!coordinator) {
1235
+ coordinator = createRunApprovalCoordinator({
1236
+ runner,
1237
+ cardSession,
1238
+ config: this.config,
1239
+ runId,
1240
+ agentKind,
1241
+ });
1242
+ this.approvalCoordinators.set(runId, coordinator);
1243
+ getLogger().info(`[lark-remote] approval coordinator created runId=${runId.slice(0, 8)} requestId=${event.requestId}`);
1244
+ }
1245
+ coordinator.onRequested(event);
1246
+ return;
1247
+ }
1248
+ const coordinator = this.approvalCoordinators.get(runId);
1249
+ if (event.type === 'approval_resolved') {
1250
+ coordinator?.onResolved(event.requestId);
1251
+ return;
1252
+ }
1253
+ // 乱序流:审批先到、item/started 后到,补全审批卡内容。coordinator
1254
+ // 仅在 pending 时更新(已响应/过期不复活)。
1255
+ coordinator?.updateView(event.requestId, event.view);
1256
+ }
1257
+ /**
1258
+ * 进程退出(for-await 自然结束)后的终态矩阵——它是本轮的唯一终态触发源。
1259
+ *
1260
+ * 1) finalizing(result 已收到)→ 三态映射 + usage meta
1261
+ * 2) running(未收到 result)→ 错误路径;pre-init 终态优先当唯一线索
1262
+ * 3) 已终态 + sawResult → 首终态不动,仅补 usage meta
1263
+ * 4) 已终态且未收到 result(如 spawn 失败后 interrupted)→ 无 usage 可补,跳过
1264
+ */
1265
+ async finishRunAfterStream(opts) {
1266
+ const { cardSession, usage, sawResult, pendingPreInitTerminal, agentKind, runId } = opts;
1267
+ const finalState = cardSession.currentState;
1268
+ if (finalState.terminal === 'finalizing') {
1269
+ // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
1270
+ // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1271
+ const subtype = finalState.resultSubtype;
1272
+ const isError = subtype === 'error' || !!finalState.errorMsg;
1273
+ // goal 仍 active = 宿主会自动起下一轮:这不是"完成",而是"本轮结束"。
1274
+ // 用独立终态而不是 done,既不改 header 文案也不用谎报。
1275
+ const goalContinues = !isError && subtype !== 'interrupted' && finalState.goal?.phase === 'active';
1276
+ const terminal = isError
1277
+ ? 'error'
1278
+ : subtype === 'interrupted'
1279
+ ? 'interrupted'
1280
+ : goalContinues
1281
+ ? 'goal_continuing'
1282
+ : 'done';
1283
+ await cardSession.finish(terminal, {
1284
+ resultSubtype: subtype,
1285
+ errorMsg: finalState.errorMsg,
1286
+ ...usage.meta(),
1287
+ });
1288
+ return;
1289
+ }
1290
+ if (finalState.terminal === 'running') {
1291
+ if (pendingPreInitTerminal) {
1292
+ const terminal = pendingPreInitTerminal.subtype === 'interrupted' ? 'interrupted' : 'error';
1293
+ getLogger().warn(`[lark-remote] stream ended without init; using pre-init ${terminal} result runId=${runId} error=${pendingPreInitTerminal.errorMessage ?? '(none)'}`);
1294
+ await cardSession.finish(terminal, {
1295
+ ...usage.meta(),
1296
+ errorMsg: pendingPreInitTerminal.errorMessage ??
1297
+ agentDisplayName(agentKind) + (terminal === 'interrupted' ? ' 已中断' : ' 启动失败'),
1298
+ });
1299
+ }
1300
+ else {
1301
+ await cardSession.finish('error', {
1302
+ ...usage.meta(),
1303
+ errorMsg: agentDisplayName(agentKind) + ' 输出流已结束,但未收到 result 事件',
1304
+ });
1305
+ }
1306
+ return;
1307
+ }
1308
+ if (sawResult) {
1309
+ // 已终态(interrupted/idle_timeout)+ result 已收到:首终态优先,
1310
+ // 仅补充 usage meta(finalizing 期间被 /stop 或 idle 超时也展示 token 统计)
1311
+ await cardSession.finish(finalState.terminal, usage.meta());
1312
+ }
1313
+ }
1159
1314
  /**
1160
1315
  * Step 2 of forwardToClaude: Run the agent and consume its event stream.
1161
1316
  * Handles idle watchdog, event processing, session sync, and state transitions.
@@ -1173,60 +1328,7 @@ export class Bridge {
1173
1328
  // init the result was the only terminal signal we had. Keep it so the card
1174
1329
  // shows the real error instead of the generic "no result" fallback.
1175
1330
  let pendingPreInitTerminal;
1176
- // 记录 result 是否为 error: error 时跳过 jsonl usage 读取 (见 stream end 处说明)
1177
- let resultNotSuccess = false;
1178
- // Final usage read from jsonl after the run completes. live stream-json does
1179
- // not emit compact_boundary, so the live contextLength is unreliable.
1180
- let finalContextLength;
1181
- let finalContextLimit;
1182
- let finalCompactCount;
1183
- let finalCacheReadTokens;
1184
- let finalCacheCreationTokens;
1185
- let finalTotalTokens;
1186
- // Real input/output tokens captured from the live result event (codex/
1187
- // opencode carry them in ResultEvent.usage). Threaded to the card so the
1188
- // done card shows real values instead of the 10% estimate.
1189
- let liveInputTokens;
1190
- let liveOutputTokens;
1191
- // Real context window limit from the live result event (codex app-server
1192
- // carries it in ResultEvent.usage.context_limit). jsonl wins when present;
1193
- // live is the fallback (app-server error runs / jsonl 未落盘时).
1194
- let liveContextLimit;
1195
- // Final input/output for the done card: live value wins (codex/opencode
1196
- // carry per-run usage in the result event); jsonl is the fallback for
1197
- // agents whose live stream has no usage (kimi).
1198
- let finalInputTokens;
1199
- let finalOutputTokens;
1200
- // Session-cumulative input/output (all runs) read from jsonl; threaded to
1201
- // the done card's "累计" display. Always from jsonl (live has no cumulative).
1202
- let finalCumulativeTotalTokens;
1203
- let finalCumulativeInputTokens;
1204
- let finalCumulativeOutputTokens;
1205
- let finalCumulativeCacheReadTokens;
1206
- let finalCumulativeCacheCreationTokens;
1207
- // Reasoning tokens (pi usage.reasoning) from the live result event.
1208
- let finalReasoningTokens;
1209
- // 统一的 usage meta 构造(finalize 各分支共享,本方法内原先 5 处手写 11-15
1210
- // 字段对象;第 6 处 streamCompact 形状不同未收敛)。
1211
- // catch 路径需覆盖 flow 字段时在展开后覆写即可。catch error 路径亦复用此
1212
- // 闭包,相比旧手写对象补齐了累计 cache 字段(cumulativeCacheReadTokens/
1213
- // cumulativeCacheCreationTokens),属有意的口径对齐(向 done 路径看齐)。
1214
- const usageMeta = () => ({
1215
- contextLength: finalContextLength,
1216
- contextLimit: finalContextLimit,
1217
- compactCount: finalCompactCount,
1218
- cacheReadTokens: finalCacheReadTokens,
1219
- cacheCreationTokens: finalCacheCreationTokens,
1220
- totalTokens: finalTotalTokens,
1221
- inputTokens: finalInputTokens,
1222
- outputTokens: finalOutputTokens,
1223
- cumulativeTotalTokens: finalCumulativeTotalTokens,
1224
- cumulativeInputTokens: finalCumulativeInputTokens,
1225
- cumulativeOutputTokens: finalCumulativeOutputTokens,
1226
- cumulativeCacheReadTokens: finalCumulativeCacheReadTokens,
1227
- cumulativeCacheCreationTokens: finalCumulativeCacheCreationTokens,
1228
- reasoningTokens: finalReasoningTokens,
1229
- });
1331
+ const usage = new RunUsageCollector();
1230
1332
  try {
1231
1333
  try {
1232
1334
  // queue.immediate 的 run-start hold:目标槽位可能在 handler 发最终
@@ -1256,7 +1358,6 @@ export class Bridge {
1256
1358
  runner,
1257
1359
  pendingApprovals: () => this.approvalCoordinators.get(runId)?.pendingCount() ?? 0,
1258
1360
  });
1259
- let contextLength;
1260
1361
  getLogger().info(`[lark-remote] runner.run() begin runId=${runId} message=${message.slice(0, 100)}`);
1261
1362
  // Extract agent-specific options from config
1262
1363
  const agentOpts = this.getAgentRunOptions(agentKind);
@@ -1267,6 +1368,14 @@ export class Bridge {
1267
1368
  // ending (sawResult, usage capture, or cardSession.push would all
1268
1369
  // be wrong for the historical event).
1269
1370
  let sawInit = false;
1371
+ const writeback = {
1372
+ sessionStore: this.sessionStore,
1373
+ userId: ctx.userId,
1374
+ agentKind,
1375
+ cwd,
1376
+ sessionEpochAtStart,
1377
+ runId,
1378
+ };
1270
1379
  for await (const event of runner.run(message, runOpts)) {
1271
1380
  // 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
1272
1381
  // 这样在 result 事件后,后台任务输出仍能被正确处理
@@ -1279,34 +1388,7 @@ export class Bridge {
1279
1388
  // 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
1280
1389
  // 结束后的 usage 读取仍以它为准。
1281
1390
  activeRun.sessionId = event.session_id;
1282
- // 代际守卫:run 在途时 /new(或 new-session 卡片、/cd、/resume、
1283
- // /config 切换)移动了 session 指针,此 init 的写回是 stale 的——
1284
- // 跳过,否则 /new 的清空会被在途 run 静默撤销(2026-08-09 事故:
1285
- // task-notification 注入触发 Claude 重发 init,已终止的 run 被复活)。
1286
- const pointerMoved = this.sessionStore.getSessionEpoch(ctx.userId, agentKind) !== sessionEpochAtStart;
1287
- if (pointerMoved) {
1288
- getLogger().info(`[lark-remote] system.init write-back skipped: session pointer moved since run start runId=${runId} sessionId=${event.session_id}`);
1289
- }
1290
- else {
1291
- // Use session's real directory from event.cwd (not runner's cwd parameter)
1292
- // guard empty string too -- `??` only catches null/undefined, but a
1293
- // translator (or older build) may emit cwd="", which must NOT overwrite
1294
- // the good runner cwd.
1295
- const hasCwd = !!this.sessionStore.getCwd(ctx.userId);
1296
- const realCwd = event.cwd && event.cwd.length > 0 ? event.cwd : cwd;
1297
- if (hasCwd && event.cwd && event.cwd !== cwd) {
1298
- // Session has its own cwd (e.g. EnterWorktree relocate). Keep the
1299
- // cwd unchanged; record the session's actual cwd in sessionCwds.
1300
- getLogger().info(`[lark-remote] system.init: cwd=${cwd}, session cwd=${event.cwd}`);
1301
- this.sessionStore.setSessionIdAndSessionCwd(ctx.userId, agentKind, event.session_id, realCwd);
1302
- }
1303
- else {
1304
- // First use (no cwd yet) or event.cwd matches runner cwd:
1305
- // set sessionId + cwd as before; on first use also bootstrap sessionCwds.
1306
- const bootstrapSessionCwd = !hasCwd && event.cwd && event.cwd.length > 0 ? event.cwd : undefined;
1307
- this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.session_id, realCwd, bootstrapSessionCwd);
1308
- }
1309
- }
1391
+ writeBackInitSession(writeback, event);
1310
1392
  getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
1311
1393
  }
1312
1394
  if (event.type === 'turn_started') {
@@ -1316,160 +1398,39 @@ export class Bridge {
1316
1398
  // 都新建线程(thread/start 而非 thread/resume),会话无法延续。
1317
1399
  // init 缺失时以 threadId 作为本轮身份兜底。
1318
1400
  activeRun.sessionId ??= event.threadId;
1319
- const pointerMoved = this.sessionStore.getSessionEpoch(ctx.userId, agentKind) !== sessionEpochAtStart;
1320
- if (!pointerMoved) {
1321
- this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.threadId, cwd);
1322
- getLogger().info(`[lark-remote] turn_started session write-back runId=${runId} sessionId=${event.threadId}`);
1323
- }
1324
- else {
1325
- getLogger().info(`[lark-remote] turn_started write-back skipped: session pointer moved since run start runId=${runId} sessionId=${event.threadId}`);
1326
- }
1401
+ writeBackTurnStarted(writeback, event);
1327
1402
  }
1328
1403
  // Track context length from compaction events
1329
1404
  if (event.type === 'system' &&
1330
1405
  event.subtype === 'compact_boundary' &&
1331
1406
  event.compactMetadata) {
1332
- contextLength = event.compactMetadata.postTokens;
1407
+ usage.observeCompaction(event.compactMetadata.postTokens);
1333
1408
  }
1334
1409
  if (event.type === 'result') {
1335
- // Pre-init result guard: Claude CLI --resume emits a stale result
1336
- // (from the previous turn) before system.init. Ignoring it prevents
1337
- // premature sawResult/usage capture and the run-state reducer (which
1338
- // also guards on sessionId === undefined) from transitioning to
1339
- // finalizing too early. The real result for this run arrives after
1340
- // system.init.
1341
- //
1342
- // Exception: app-server runners (usageAuthority === 'live') 的 init
1343
- // 是 runner 补发的 synthetic init(2026-08-13 修复)。此分支保留为
1344
- // 防御性兜底:任何 live runner 若在无 init 时发出 result(如旧构建、
1345
- // 其它 live runner),session 写回仍执行,但 sawResult 保持 false,
1346
- // 卡片不会把半初始化的 run 当作已收尾。
1347
- const preInitResult = !sawInit;
1348
- if (preInitResult && usageAuthority !== 'live') {
1349
- getLogger().info(`[lark-remote] pre-init result ignored (resume replay) runId=${runId}`);
1350
- if (event.subtype === 'error' || event.subtype === 'interrupted') {
1351
- pendingPreInitTerminal ??= {
1352
- subtype: event.subtype,
1353
- errorMessage: event.errorMessage,
1354
- };
1355
- }
1356
- // Still reset idle timer — the CLI is alive and producing events.
1410
+ const outcome = this.observeResultEvent(event, {
1411
+ sawInit,
1412
+ usageAuthority,
1413
+ usage,
1414
+ writeback,
1415
+ runId,
1416
+ });
1417
+ if (outcome.preInitTerminal)
1418
+ pendingPreInitTerminal ??= outcome.preInitTerminal;
1419
+ if (outcome.sawResult)
1420
+ sawResult = true;
1421
+ // 陈旧 result 不进卡片:run-state reducer 本来也会因 sessionId 未定而
1422
+ // 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
1423
+ if (outcome.dropFromCard) {
1424
+ // 仍然重置 idle 计时——CLI 活着并且在产事件。
1357
1425
  watchdog?.touch();
1358
- // Do NOT push to cardSession: the run-state reducer would also
1359
- // skip it (sessionId === undefined), but skipping at the bridge
1360
- // layer avoids the push + render overhead for a no-op event.
1361
1426
  continue;
1362
1427
  }
1363
- if (preInitResult && (event.subtype === 'error' || event.subtype === 'interrupted')) {
1364
- pendingPreInitTerminal ??= {
1365
- subtype: event.subtype,
1366
- errorMessage: event.errorMessage,
1367
- };
1368
- }
1369
- if (!preInitResult) {
1370
- sawResult = true;
1371
- }
1372
- resultNotSuccess = event.subtype !== 'success';
1373
- getLogger().info(preInitResult
1374
- ? `[lark-remote] pre-init result from live runner (app-server setup failure) runId=${runId}`
1375
- : `[lark-remote] result event received runId=${runId}`);
1376
- // app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)
1377
- // 不会有 turn_started 事件,store 不会写回新线程 id;runner 已在 result
1378
- // 里兜底上报。此处仅当 runner 声明 live usage(app-server)
1379
- // 且会话指针未动时写回,避免下条消息再开一个孤儿线程。其余 agent 不在
1380
- // 此列(它们各自有 system.init / turn_started 写回)。
1381
- if (usageAuthority === 'live' &&
1382
- event.session_id &&
1383
- this.sessionStore.getSessionEpoch(ctx.userId, agentKind) === sessionEpochAtStart) {
1384
- this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.session_id, cwd);
1385
- getLogger().info(`[lark-remote] result session write-back runId=${runId} sessionId=${event.session_id}`);
1386
- }
1387
- // result 后进入 finalizing 非终态(暂存 subtype/errorMsg),由 for-await
1388
- // 自然结束后在 finally 转 done/error。提取 usage 信息。
1389
- if (event.usage) {
1390
- const u = normalizeResultUsage(event.usage);
1391
- // input_tokens is non-cached (ccusage-aligned), so the context
1392
- // length fallback must include cached tokens. Prefer the agent's
1393
- // declared total; otherwise reconstruct from the parts.
1394
- if (!contextLength) {
1395
- contextLength = u.contextLength;
1396
- }
1397
- // Capture real input/output for the done card (avoiding the 10%
1398
- // estimate in formatUsageStats). live 值优先:有 live input/output 时
1399
- // 所有 flow 字段统一用 live scope(本 run),避免同一张卡片上
1400
- // Input/Output 是本 run、Cache/Total 是 session 累计的混 scope 问题。
1401
- liveInputTokens = u.inputTokens;
1402
- liveOutputTokens = u.outputTokens;
1403
- liveContextLimit = u.contextLimit;
1404
- if (u.cacheReadTokens !== undefined) {
1405
- finalCacheReadTokens = u.cacheReadTokens;
1406
- }
1407
- if (u.cacheCreationTokens !== undefined) {
1408
- finalCacheCreationTokens = u.cacheCreationTokens;
1409
- }
1410
- if (u.totalTokens !== undefined) {
1411
- finalTotalTokens = u.totalTokens;
1412
- }
1413
- if (u.reasoningTokens !== undefined) {
1414
- finalReasoningTokens = u.reasoningTokens;
1415
- }
1416
- }
1417
1428
  }
1418
1429
  // Approval events (Codex app-server mode)
1419
- if (event.type === 'approval_requested') {
1420
- // 审批请求到达:撤回 Typing 表情再重发,让飞书重新触发提醒,把用户
1421
- // 注意力拉回待审批的卡片(run 在等待人工决策,Typing 应重新闪烁)。
1422
- void (async () => {
1423
- try {
1424
- await this.connector.removeReactionByEmoji(ctx.messageId, 'Typing');
1425
- }
1426
- catch (err) {
1427
- getLogger().warn(`[lark-remote] remove Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
1428
- }
1429
- try {
1430
- await this.connector.addReaction(ctx.messageId, 'Typing');
1431
- }
1432
- catch (err) {
1433
- getLogger().warn(`[lark-remote] re-add Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
1434
- }
1435
- })();
1436
- let coordinator = this.approvalCoordinators.get(runId);
1437
- if (!coordinator) {
1438
- coordinator = new ApprovalCoordinator({
1439
- // claude 审批超时走 config(claude.approvalTimeoutMs,默认 5 分钟,
1440
- // 对齐 codex 红线勿改短);codex 维持模块常量。
1441
- approvalTimeoutMs: agentKind === 'claude'
1442
- ? (this.config.claude?.approvalTimeoutMs ?? APPROVAL_TIMEOUT_MS)
1443
- : APPROVAL_TIMEOUT_MS,
1444
- responder: async (requestId, response) => {
1445
- const r = runner;
1446
- if (typeof r.respondApproval === 'function') {
1447
- await r.respondApproval(requestId, response);
1448
- }
1449
- },
1450
- interruptTurn: async () => {
1451
- // DSH may return a StopOutcome; the coordinator only awaits.
1452
- await runner.stop();
1453
- },
1454
- pushToCard: async (events) => {
1455
- for (const ev of events)
1456
- await cardSession.push(ev);
1457
- },
1458
- });
1459
- this.approvalCoordinators.set(runId, coordinator);
1460
- getLogger().info(`[lark-remote] approval coordinator created runId=${runId.slice(0, 8)} requestId=${event.requestId}`);
1461
- }
1462
- coordinator.onRequested(event);
1463
- }
1464
- else if (event.type === 'approval_resolved') {
1465
- const coordinator = this.approvalCoordinators.get(runId);
1466
- coordinator?.onResolved(event.requestId);
1467
- }
1468
- else if (event.type === 'approval_view_updated') {
1469
- // 乱序流:审批先到、item/started 后到,补全审批卡内容。coordinator
1470
- // 仅在 pending 时更新(已响应/过期不复活)。
1471
- const coordinator = this.approvalCoordinators.get(runId);
1472
- coordinator?.updateView(event.requestId, event.view);
1430
+ if (event.type === 'approval_requested' ||
1431
+ event.type === 'approval_resolved' ||
1432
+ event.type === 'approval_view_updated') {
1433
+ this.dispatchApprovalEvent(event, { runId, runner, cardSession, ctx, agentKind });
1473
1434
  }
1474
1435
  // §9.12 红线:每个事件(含 result)无条件刷新
1475
1436
  // lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
@@ -1493,94 +1454,20 @@ export class Bridge {
1493
1454
  // 优先级:卡片状态(本轮 init 写入)→ activeRun(本轮 init/turn_started)
1494
1455
  // → 本轮开始时捕获的 sessionId。三者都没有就跳过 usage,绝不回读全局指针。
1495
1456
  const finalSessionId = cardSession.currentState.sessionId ?? activeRun.sessionId ?? sessionId;
1496
- const finalUsage = resultNotSuccess
1457
+ const finalUsage = usage.resultNotSuccess
1497
1458
  ? undefined
1498
1459
  : resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, finalSessionId, cwd, agentKind);
1499
- finalContextLength = finalUsage?.contextLength ?? contextLength;
1500
- finalContextLimit = finalUsage?.contextLimit ?? liveContextLimit;
1501
- finalCompactCount = finalUsage?.compactCount;
1502
- finalCumulativeTotalTokens = finalUsage?.cumulativeTotalTokens;
1503
- finalCumulativeInputTokens = finalUsage?.cumulativeInputTokens;
1504
- finalCumulativeOutputTokens = finalUsage?.cumulativeOutputTokens;
1505
- finalCumulativeCacheReadTokens = finalUsage?.cumulativeCacheReadTokens;
1506
- finalCumulativeCacheCreationTokens = finalUsage?.cumulativeCacheCreationTokens;
1507
- // 统一 flow 字段 scope:live 优先,jsonl 兜底。
1508
- // 当 live result 事件带了 input/output(即 liveInputTokens/liveOutputTokens
1509
- // 非 undefined),所有 flow 字段统一用 live scope(本 run),不用 jsonl
1510
- // 的 session 累计值覆盖,避免 cache%/Total 分子分母不同源。
1511
- // 无 live usage 时(如 kimi),jsonl 值兜底。
1512
- // contextLength/compactCount 保持 jsonl 优先(它们是水位/历史计数)。
1513
- const codexJsonlFirst = usageAuthority === 'jsonl';
1514
- if (!codexJsonlFirst && liveInputTokens !== undefined && liveOutputTokens !== undefined) {
1515
- // 非 codex 且 live 有 input/output → 所有 flow 字段用 live scope
1516
- finalInputTokens = liveInputTokens;
1517
- finalOutputTokens = liveOutputTokens;
1518
- // cacheReadTokens/cacheCreationTokens/totalTokens
1519
- // 已在上面 result 事件处理中赋了 live 值;若 live 没有则为 undefined,
1520
- // 由 formatUsageStats 的 max/兜底公式处理。
1521
- }
1522
- else {
1523
- // codex(jsonl 优先)或无 live usage → jsonl 兜底所有 flow 字段。
1524
- // jsonl 的非累计字段已是末轮(本 run)scope(claude reader 修复后对齐
1525
- // codex `last_token_usage` 语义,见 scalarScan),不再是 session 累计;
1526
- // session 累计只经 cumulative* 字段透传,卡片"本 run ≤ 累计"不变量成立。
1527
- finalInputTokens = finalUsage?.inputTokens ?? liveInputTokens;
1528
- finalOutputTokens = finalUsage?.outputTokens ?? liveOutputTokens;
1529
- finalCacheReadTokens = finalUsage?.cacheReadTokens ?? finalCacheReadTokens;
1530
- finalCacheCreationTokens = finalUsage?.cacheCreationTokens ?? finalCacheCreationTokens;
1531
- finalTotalTokens = finalUsage?.totalTokens ?? finalTotalTokens;
1532
- }
1533
- getLogger().info(`[lark-remote] final usage runId=${runId} contextLength=${finalContextLength} contextLimit=${finalContextLimit ?? 'undefined'} compactCount=${finalCompactCount ?? 'undefined'} cacheRead=${finalCacheReadTokens ?? 'undefined'} cacheCreate=${finalCacheCreationTokens ?? 'undefined'}`);
1534
- // for-await 自然结束 = stream 关闭 = CLI 进程退出。
1535
- // 进程退出(for-await end)= 唯一终态触发源。
1536
- // 1) finalizing(result 已收到)-> 转 done/error + usage meta
1537
- // 2) running(未收到 result)-> 错误路径
1538
- // 3) 已终态 + sawResult -> 补充 usage meta(不改 terminal)
1539
- const finalState = cardSession.currentState;
1540
- if (finalState.terminal === 'finalizing') {
1541
- // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
1542
- // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1543
- const subtype = finalState.resultSubtype;
1544
- const isError = subtype === 'error' || !!finalState.errorMsg;
1545
- // goal 仍 active = 宿主会自动起下一轮:这不是"完成",而是"本轮结束"。
1546
- // 用独立终态而不是 done,既不改 header 文案也不用谎报。
1547
- const goalContinues = !isError && subtype !== 'interrupted' && finalState.goal?.phase === 'active';
1548
- const terminal = isError
1549
- ? 'error'
1550
- : subtype === 'interrupted'
1551
- ? 'interrupted'
1552
- : goalContinues
1553
- ? 'goal_continuing'
1554
- : 'done';
1555
- await cardSession.finish(terminal, {
1556
- resultSubtype: subtype,
1557
- errorMsg: finalState.errorMsg,
1558
- ...usageMeta(),
1559
- });
1560
- }
1561
- else if (finalState.terminal === 'running') {
1562
- if (pendingPreInitTerminal) {
1563
- const terminal = pendingPreInitTerminal.subtype === 'interrupted' ? 'interrupted' : 'error';
1564
- getLogger().warn(`[lark-remote] stream ended without init; using pre-init ${terminal} result runId=${runId} error=${pendingPreInitTerminal.errorMessage ?? '(none)'}`);
1565
- await cardSession.finish(terminal, {
1566
- ...usageMeta(),
1567
- errorMsg: pendingPreInitTerminal.errorMessage ??
1568
- agentDisplayName(agentKind) + (terminal === 'interrupted' ? ' 已中断' : ' 启动失败'),
1569
- });
1570
- }
1571
- else {
1572
- await cardSession.finish('error', {
1573
- ...usageMeta(),
1574
- errorMsg: agentDisplayName(agentKind) + ' 输出流已结束,但未收到 result 事件',
1575
- });
1576
- }
1577
- }
1578
- else if (sawResult) {
1579
- // 已终态(interrupted/idle_timeout)+ result 已收到:首终态优先,
1580
- // 仅补充 usage meta(finalizing 期间被 /stop 或 idle 超时也展示 token 统计)
1581
- await cardSession.finish(finalState.terminal, usageMeta());
1582
- }
1583
- // else: 已终态且未收到 result(如 spawn 失败后 interrupted)-> 无 usage 可补,跳过
1460
+ usage.finalize(finalUsage, usageAuthority);
1461
+ getLogger().info(usage.logSummary(runId));
1462
+ // for-await 自然结束 = stream 关闭 = CLI 进程退出 → 终态矩阵
1463
+ await this.finishRunAfterStream({
1464
+ cardSession,
1465
+ usage,
1466
+ sawResult,
1467
+ pendingPreInitTerminal,
1468
+ agentKind,
1469
+ runId,
1470
+ });
1584
1471
  }
1585
1472
  catch (err) {
1586
1473
  getLogger().error(`[lark-remote] claude run error runId=${runId}:`, err);
@@ -1595,18 +1482,18 @@ export class Bridge {
1595
1482
  // 终态守卫:running 或 finalizing(非终态)-> 转 error;
1596
1483
  // 已终态(interrupted/idle_timeout/done/error)-> 保留首终态,仅补充 usage。
1597
1484
  const catchTerminal = cardSession.currentState.terminal;
1598
- if (catchTerminal === 'running' || catchTerminal === 'finalizing') {
1485
+ if (isRunActive(catchTerminal)) {
1599
1486
  await cardSession.finish('error', {
1600
- ...usageMeta(),
1601
- inputTokens: liveInputTokens,
1602
- outputTokens: liveOutputTokens,
1487
+ ...usage.meta(),
1488
+ inputTokens: usage.liveInputTokens,
1489
+ outputTokens: usage.liveOutputTokens,
1603
1490
  errorMsg: errorMessage(err),
1604
1491
  });
1605
1492
  }
1606
1493
  else {
1607
1494
  getLogger().info(`[lark-remote] skip error finish: state already terminal (${catchTerminal}) runId=${runId}`);
1608
1495
  if (sawResult) {
1609
- await cardSession.finish(catchTerminal, usageMeta());
1496
+ await cardSession.finish(catchTerminal, usage.meta());
1610
1497
  }
1611
1498
  }
1612
1499
  }
@@ -1659,10 +1546,11 @@ export class Bridge {
1659
1546
  sessionFallbackAvailable: !!(cardSession.currentState.sessionId ??
1660
1547
  this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind)),
1661
1548
  };
1549
+ const finalRender = renderRunCardDetailed(cardSession.currentState, finalRenderOptions);
1662
1550
  this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
1663
1551
  let runCardSent = false;
1664
1552
  if (settleResult === 'unsent') {
1665
- const sent = await this.sendResult({ card: renderRunCard(cardSession.currentState, finalRenderOptions) }, ctx);
1553
+ const sent = await this.sendResult({ card: finalRender.card }, ctx);
1666
1554
  runCardSent = sent;
1667
1555
  }
1668
1556
  else {
@@ -1675,6 +1563,9 @@ export class Bridge {
1675
1563
  if (sessionId && runCardSent) {
1676
1564
  await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind);
1677
1565
  }
1566
+ if (finalRender.finalAnswerStatus === 'overflow' && finalRender.finalAnswerText) {
1567
+ await this.sendFinalAnswerOverflow(finalRender.finalAnswerText, ctx);
1568
+ }
1678
1569
  // Add a terminal-state emoji reaction to the user's original message
1679
1570
  this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
1680
1571
  }
@@ -1758,94 +1649,36 @@ export class Bridge {
1758
1649
  // =============================================================================
1759
1650
  // Approval action handlers
1760
1651
  // =============================================================================
1761
- /**
1762
- * Resolve the ApprovalCoordinator for a run, or throw the standard
1763
- * "已结束" error. Shared preamble of all approval card-action handlers.
1764
- */
1765
- withCoordinator(runId, fn) {
1766
- const coordinator = this.approvalCoordinators.get(runId);
1767
- if (!coordinator) {
1768
- // run 已结束(coordinator 已释放)时点击审批按钮必须给用户明确反馈,
1769
- // 不能静默。router 会把该异常转成 error toast。
1770
- getLogger().warn(`[lark-remote] no approval coordinator for runId=${runId.slice(0, 8)}`);
1771
- throw new Error('任务已结束,审批无法响应');
1772
- }
1773
- return fn(coordinator);
1774
- }
1775
- /**
1776
- * Handle an approval response from a card button click.
1777
- * Routes to the ApprovalCoordinator for the given run.
1778
- */
1652
+ // 实现住在 approval-actions.ts(审计 #32):这 7 个入口只碰
1653
+ // approvalCoordinators 一张表,不碰其余 Bridge 状态。方法名保持 Bridge 的
1654
+ // 公开 API 不变——router 的 APPROVAL_ACTION_SPECS 按名字分发。
1655
+ /** 审批响应(卡片按钮)→ ApprovalCoordinator.submit。 */
1779
1656
  async handleApprovalRespond(opts) {
1780
- const log = getLogger();
1781
- log.info(`[lark-remote] handleApprovalRespond runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} decision=${opts.decision}`);
1782
- return this.withCoordinator(opts.runId, async (coordinator) => {
1783
- // Build ApprovalAction from the decision string
1784
- const action = decisionToApprovalAction(opts.decision);
1785
- await coordinator.submit(action, { requestId: opts.requestId, nonce: opts.nonce });
1786
- });
1657
+ return this.approvalActions.handleApprovalRespond(opts);
1787
1658
  }
1788
- /**
1789
- * 计划审批修改意见(ExitPlanMode 输入框):记录到 coordinator 并回流卡片。
1790
- * 「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
1791
- */
1659
+ /** 计划审批修改意见(ExitPlanMode 输入框)→ coordinator.planFeedback。 */
1792
1660
  async handleApprovalPlanFeedback(opts) {
1793
- return this.withCoordinator(opts.runId, async (coordinator) => {
1794
- await coordinator.planFeedback({ text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce });
1795
- });
1661
+ return this.approvalActions.handleApprovalPlanFeedback(opts);
1796
1662
  }
1797
- /**
1798
- * Handle a permission toggle from a card button click.
1799
- * Routes to the ApprovalCoordinator for the given run.
1800
- */
1663
+ /** 权限条目勾选(卡片按钮)→ coordinator.togglePerm。 */
1801
1664
  async handleApprovalToggle(opts) {
1802
- const log = getLogger();
1803
- log.info(`[lark-remote] handleApprovalToggle runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} permId=${opts.permId}`);
1804
- return this.withCoordinator(opts.runId, async (coordinator) => {
1805
- // selected 由卡片根据当前渲染状态传回(取反),不再硬编码只授不撤。
1806
- const toggleAction = {
1807
- permId: opts.permId,
1808
- selected: opts.selected ?? true,
1809
- };
1810
- await coordinator.togglePerm(toggleAction, { requestId: opts.requestId });
1811
- });
1665
+ return this.approvalActions.handleApprovalToggle(opts);
1812
1666
  }
1813
- /**
1814
- * Claude AskUserQuestion 选项点击(单选即时提交 / 多选切换勾选)。
1815
- * 路由到 ApprovalCoordinator.toggleAnswer。
1816
- */
1667
+ /** AskUserQuestion 选项点击 → coordinator.toggleAnswer。 */
1817
1668
  async handleApprovalAnswer(opts) {
1818
- const log = getLogger();
1819
- log.info(`[lark-remote] handleApprovalAnswer runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
1820
- return this.withCoordinator(opts.runId, (coordinator) => coordinator.toggleAnswer({ questionIndex: opts.questionIndex, option: opts.option }, { requestId: opts.requestId, nonce: opts.nonce }));
1669
+ return this.approvalActions.handleApprovalAnswer(opts);
1821
1670
  }
1822
- /**
1823
- * Claude AskUserQuestion 多选问题的「提交答案」按钮。
1824
- * 路由到 ApprovalCoordinator.submitAnswers。
1825
- */
1671
+ /** AskUserQuestion 多选「提交答案」→ coordinator.submitAnswers。 */
1826
1672
  async handleApprovalAnswerSubmit(opts) {
1827
- const log = getLogger();
1828
- log.info(`[lark-remote] handleApprovalAnswerSubmit runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
1829
- return this.withCoordinator(opts.runId, (coordinator) => coordinator.submitAnswers({ questionIndex: opts.questionIndex }, { requestId: opts.requestId, nonce: opts.nonce }));
1673
+ return this.approvalActions.handleApprovalAnswerSubmit(opts);
1830
1674
  }
1831
- /**
1832
- * Claude AskUserQuestion 自定义答案(Other):自由文本直接
1833
- * 作为该单选问题的答案提交。路由到 ApprovalCoordinator.answerCustom。
1834
- */
1675
+ /** AskUserQuestion 自定义答案(Other)→ coordinator.answerCustom。 */
1835
1676
  async handleApprovalAnswerCustom(opts) {
1836
- const log = getLogger();
1837
- log.info(`[lark-remote] handleApprovalAnswerCustom runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
1838
- return this.withCoordinator(opts.runId, (coordinator) => coordinator.answerCustom({ questionIndex: opts.questionIndex, text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce }));
1677
+ return this.approvalActions.handleApprovalAnswerCustom(opts);
1839
1678
  }
1840
- /**
1841
- * Codex AskUserQuestion 补充说明(user_note,2026-08-18 live 验证模型理解
1842
- * "user_note: <text>" 条目):记录到 coordinator,随答案一起提交。
1843
- * 路由到 ApprovalCoordinator.answerNote。
1844
- */
1679
+ /** AskUserQuestion 补充说明(user_note)→ coordinator.answerNote。 */
1845
1680
  async handleApprovalAnswerNote(opts) {
1846
- const log = getLogger();
1847
- log.info(`[lark-remote] handleApprovalAnswerNote runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
1848
- return this.withCoordinator(opts.runId, (coordinator) => coordinator.answerNote({ questionIndex: opts.questionIndex, text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce }));
1681
+ return this.approvalActions.handleApprovalAnswerNote(opts);
1849
1682
  }
1850
1683
  /**
1851
1684
  * 记下一次「可压缩 run」:终态卡上的 Compact 按钮点击时据此反查
@@ -1967,14 +1800,14 @@ export class Bridge {
1967
1800
  });
1968
1801
  }
1969
1802
  /**
1970
- * §5.3: kimi 压缩在途检测——reader 鸭子实现 `readCompactionState` 时,若
1803
+ * §5.3: kimi 压缩在途检测——reader 实现契约的 `readCompactionState` 时,若
1971
1804
  * wire 状态机判定当前有未终结的压缩(最后一次 begin 无 terminal 记录),
1972
1805
  * 直接回文本「已有一次 Compact 正在进行」并返回 true(调用方不开卡、不发
1973
1806
  * session/prompt)。codex/claude 等 reader 无此方法 → 返回 false,行为不变。
1974
1807
  */
1975
1808
  async rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx) {
1976
1809
  const reader = this.sessionReaderRegistry.get(agentKind);
1977
- if (!reader || !('readCompactionState' in reader))
1810
+ if (!reader?.readCompactionState)
1978
1811
  return false;
1979
1812
  try {
1980
1813
  const state = reader.readCompactionState(sessionId, cwd);
@@ -2102,20 +1935,9 @@ export class Bridge {
2102
1935
  await cardSession.finish(terminal, {
2103
1936
  resultSubtype: resultSubtype ?? 'error',
2104
1937
  errorMsg: terminal === 'error' ? (resultError ?? '未收到压缩结果') : undefined,
2105
- contextLength: finalUsage?.contextLength,
2106
- contextLimit: finalUsage?.contextLimit,
2107
- compactPreContextLength: finalUsage?.compactPreContextLength,
2108
- compactCount: finalUsage?.compactCount,
2109
- cacheReadTokens: finalUsage?.cacheReadTokens,
2110
- cacheCreationTokens: finalUsage?.cacheCreationTokens,
2111
- totalTokens: finalUsage?.totalTokens,
2112
- inputTokens: finalUsage?.inputTokens,
2113
- outputTokens: finalUsage?.outputTokens,
2114
- cumulativeTotalTokens: finalUsage?.cumulativeTotalTokens,
2115
- cumulativeInputTokens: finalUsage?.cumulativeInputTokens,
2116
- cumulativeOutputTokens: finalUsage?.cumulativeOutputTokens,
2117
- cumulativeCacheReadTokens: finalUsage?.cumulativeCacheReadTokens,
2118
- cumulativeCacheCreationTokens: finalUsage?.cumulativeCacheCreationTokens,
1938
+ // finalUsage 的字段形态就是 FinishMeta 的用量部分(同为 UsageSnapshot),
1939
+ // 逐字段抄的那 14 行随单源一起去掉。
1940
+ ...(finalUsage ?? {}),
2119
1941
  });
2120
1942
  log.info(`[lark-remote] streamCompact finished sessionId=${sessionId} sawResult=${sawResult} subtype=${resultSubtype ?? 'none'}`);
2121
1943
  }
@@ -2326,8 +2148,8 @@ export class Bridge {
2326
2148
  try {
2327
2149
  await this.connector.sendWithRetry(ctx.chatId, { card: renderBashCard(cardSession.currentState, {}) }, { replyTo: ctx.messageId });
2328
2150
  }
2329
- catch {
2330
- /* ignore fallback errors */
2151
+ catch (fallbackErr) {
2152
+ getLogger().error(`[lark-remote] bash terminal fallback card failed runId=${runId} chatId=${ctx.chatId}:`, fallbackErr);
2331
2153
  }
2332
2154
  }
2333
2155
  if (this.activeBashRuns.has(runId)) {