lark-remote 0.5.13 → 0.5.15

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 (130) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/agent-kinds.d.ts +27 -0
  3. package/dist/agent-kinds.js +38 -0
  4. package/dist/bridge/approval-actions.d.ts +95 -0
  5. package/dist/bridge/approval-actions.js +104 -0
  6. package/dist/bridge/approval-coordinator.d.ts +13 -0
  7. package/dist/bridge/approval-coordinator.js +18 -0
  8. package/dist/bridge/final-usage.d.ts +8 -17
  9. package/dist/bridge/final-usage.js +1 -18
  10. package/dist/bridge/index.d.ts +46 -39
  11. package/dist/bridge/index.js +269 -240
  12. package/dist/bridge/queue-manager.d.ts +25 -1
  13. package/dist/bridge/queue-manager.js +179 -160
  14. package/dist/bridge/run-approval.d.ts +2 -1
  15. package/dist/bridge/run-usage.d.ts +21 -30
  16. package/dist/bridge/run-usage.js +47 -51
  17. package/dist/bridge/session-writeback.d.ts +3 -2
  18. package/dist/card/approval-render.d.ts +3 -2
  19. package/dist/card/approval-render.js +3 -2
  20. package/dist/{router → card}/card-helpers.d.ts +7 -7
  21. package/dist/{router → card}/card-helpers.js +8 -8
  22. package/dist/card/card-session.d.ts +4 -2
  23. package/dist/card/card-session.js +8 -6
  24. package/dist/card/card-shared.d.ts +1 -1
  25. package/dist/card/card-shared.js +1 -1
  26. package/dist/card/final-answer-overflow.d.ts +0 -6
  27. package/dist/card/final-answer-overflow.js +1 -1
  28. package/dist/card/run-card-session.d.ts +2 -2
  29. package/dist/card/run-card-session.js +2 -2
  30. package/dist/card/run-renderer.js +29 -30
  31. package/dist/card/run-state.d.ts +28 -50
  32. package/dist/card/run-state.js +23 -0
  33. package/dist/card/usage-format.d.ts +30 -0
  34. package/dist/{router → card}/usage-format.js +3 -10
  35. package/dist/clone.js +6 -1
  36. package/dist/config/index.d.ts +16 -20
  37. package/dist/config/index.js +0 -16
  38. package/dist/config/pi-config.js +6 -4
  39. package/dist/connector/index.d.ts +2 -4
  40. package/dist/connector/index.js +7 -11
  41. package/dist/{runner/dsh → dsh}/auth.js +1 -1
  42. package/dist/dsh/protocol.d.ts +9 -0
  43. package/dist/dsh/protocol.js +61 -0
  44. package/dist/index.js +10 -5
  45. package/dist/model-catalog.d.ts +14 -0
  46. package/dist/model-catalog.js +25 -0
  47. package/dist/platform/path.d.ts +9 -0
  48. package/dist/platform/path.js +18 -0
  49. package/dist/router/active-run-usage.d.ts +4 -0
  50. package/dist/router/active-run-usage.js +12 -0
  51. package/dist/router/commands/ls.js +1 -1
  52. package/dist/router/commands/order.js +1 -1
  53. package/dist/router/commands/resume.d.ts +5 -4
  54. package/dist/router/commands/resume.js +4 -4
  55. package/dist/router/commands/ws.js +1 -1
  56. package/dist/router/config/claude.js +2 -1
  57. package/dist/router/config/common/render.d.ts +5 -1
  58. package/dist/router/config/common/render.js +6 -13
  59. package/dist/router/config/dsh.d.ts +1 -1
  60. package/dist/router/config/index.d.ts +1 -1
  61. package/dist/router/config/types.d.ts +11 -3
  62. package/dist/router/index.d.ts +41 -11
  63. package/dist/router/index.js +169 -233
  64. package/dist/runner/agent-choices-common.d.ts +1 -1
  65. package/dist/runner/claude/runner.d.ts +20 -2
  66. package/dist/runner/claude/runner.js +2 -15
  67. package/dist/runner/codex/app-server/runner.d.ts +3 -1
  68. package/dist/runner/codex/app-server/translator.d.ts +11 -0
  69. package/dist/runner/codex/app-server/translator.js +56 -149
  70. package/dist/runner/common/acp/base-acp-runner.d.ts +19 -1
  71. package/dist/runner/common/acp/base-acp-runner.js +37 -3
  72. package/dist/runner/common/connection-based-runner.d.ts +3 -1
  73. package/dist/runner/common/jsonrpc/transport.js +17 -7
  74. package/dist/runner/dsh/client.d.ts +1 -4
  75. package/dist/runner/dsh/client.js +7 -55
  76. package/dist/runner/dsh/daemon.js +9 -2
  77. package/dist/runner/dsh/exit-cancel.js +1 -1
  78. package/dist/runner/dsh/mux-stream.js +21 -2
  79. package/dist/runner/dsh/runner.d.ts +21 -1
  80. package/dist/runner/dsh/runner.js +97 -78
  81. package/dist/runner/dsh/translator.d.ts +1 -1
  82. package/dist/runner/index.d.ts +1 -1
  83. package/dist/runner/kimi/acp/runner.d.ts +22 -1
  84. package/dist/runner/kimi/acp/runner.js +83 -60
  85. package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
  86. package/dist/runner/opencode/acp/error-monitor.js +31 -2
  87. package/dist/runner/opencode/acp/runner.d.ts +14 -1
  88. package/dist/runner/opencode/acp/runner.js +13 -2
  89. package/dist/runner/pi/rpc/runner.d.ts +2 -1
  90. package/dist/runner/probe.d.ts +1 -1
  91. package/dist/runner/probe.js +10 -9
  92. package/dist/runner/registry.d.ts +2 -1
  93. package/dist/runner/types.d.ts +20 -154
  94. package/dist/runner/types.js +4 -24
  95. package/dist/session/claude/session-index.d.ts +11 -4
  96. package/dist/session/claude/session-index.js +30 -8
  97. package/dist/session/claude/session-reader.d.ts +1 -1
  98. package/dist/session/claude/sessions.d.ts +2 -2
  99. package/dist/session/claude/sessions.js +41 -26
  100. package/dist/session/codex/rollout-reader.d.ts +1 -1
  101. package/dist/session/codex/rollout-reader.js +14 -13
  102. package/dist/session/codex/sessions.d.ts +1 -1
  103. package/dist/session/codex/sessions.js +4 -2
  104. package/dist/session/common/jsonl.js +7 -1
  105. package/dist/session/common/pagination.d.ts +2 -0
  106. package/dist/session/common/pagination.js +1 -1
  107. package/dist/session/common/recency.d.ts +6 -0
  108. package/dist/session/common/recency.js +7 -5
  109. package/dist/session/common/two-pass.d.ts +1 -1
  110. package/dist/session/common/usage-accumulator.d.ts +1 -1
  111. package/dist/session/dsh/sessions.d.ts +1 -1
  112. package/dist/session/dsh/sessions.js +37 -12
  113. package/dist/session/kimi/sessions.d.ts +1 -1
  114. package/dist/session/kimi/sessions.js +72 -27
  115. package/dist/session/opencode/sessions.d.ts +1 -1
  116. package/dist/session/opencode/sessions.js +164 -81
  117. package/dist/session/pi/sessions.d.ts +1 -1
  118. package/dist/session/pi/sessions.js +20 -7
  119. package/dist/session/registry.d.ts +2 -1
  120. package/dist/session/session-store.d.ts +1 -2
  121. package/dist/session/session-store.js +14 -7
  122. package/dist/session-contract.d.ts +178 -0
  123. package/dist/session-contract.js +9 -0
  124. package/package.json +1 -1
  125. package/dist/router/usage-format.d.ts +0 -60
  126. /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
  127. /package/dist/{runner/dsh → dsh}/home.d.ts +0 -0
  128. /package/dist/{runner/dsh → dsh}/home.js +0 -0
  129. /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
  130. /package/dist/{runner/dsh → dsh}/types.js +0 -0
@@ -8,15 +8,16 @@ import { renderRunCard, renderRunCardDetailed, } from '../card/run-renderer.js';
8
8
  import { splitFinalAnswer } from '../card/final-answer-overflow.js';
9
9
  import { renderBashCard } from '../card/bash-renderer.js';
10
10
  import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
11
- import { isRunTerminal } from '../card/run-state.js';
12
- 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';
13
13
  import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
14
14
  import { enforceCardBudget } from '../card/card-budget.js';
15
15
  import { RunUsageCollector } from './run-usage.js';
16
16
  import { BashProcessRunner } from '../runner/index.js';
17
- import { AGENT_KINDS, supportsCompact } from '../runner/types.js';
17
+ import { AGENT_KINDS } from '../agent-kinds.js';
18
+ import { supportsCompact } from '../runner/types.js';
18
19
  import { QueueManager, } from './queue-manager.js';
19
- import { decisionToApprovalAction } from './approval-coordinator.js';
20
+ import { ApprovalActions } from './approval-actions.js';
20
21
  import { createRunApprovalCoordinator, remindPendingApproval } from './run-approval.js';
21
22
  import { writeBackInitSession, writeBackResultSession, writeBackTurnStarted, } from './session-writeback.js';
22
23
  import { InboundMediaHandler } from './inbound-media.js';
@@ -49,10 +50,36 @@ const TERMINAL_REACTION_EMOJI = {
49
50
  error: 'ERROR',
50
51
  idle_timeout: 'Alarm',
51
52
  interrupted: 'SHHH',
53
+ // 本轮结束但目标继续:任务还没完,不能打 ✓(Done)。
54
+ goal_continuing: 'OnIt',
52
55
  };
53
56
  function terminalReactionEmoji(terminal) {
54
57
  return TERMINAL_REACTION_EMOJI[terminal] ?? 'Done';
55
58
  }
59
+ /**
60
+ * 完成通知卡的标题 / 配色 / 结果行。
61
+ *
62
+ * 这张卡只在流式卡失败(settle==='unsent')时补发,但终态必须如实反映:
63
+ * 写死「✅ 会话已完成」会把出错/中断的 run 报成成功。
64
+ */
65
+ function completionNotificationPresentation(terminal) {
66
+ switch (terminal) {
67
+ case 'error':
68
+ return { title: '⚠️ 会话出错', template: 'red' };
69
+ case 'interrupted':
70
+ return { title: '⏹ 会话已中断', template: 'grey' };
71
+ case 'idle_timeout':
72
+ return { title: '⏱ 会话已超时', template: 'orange' };
73
+ case 'goal_continuing':
74
+ return { title: '⏳ 本轮结束,目标继续', template: 'orange' };
75
+ case 'finalizing':
76
+ return { title: '⏳ 会话仍在收尾', template: 'orange' };
77
+ case 'running':
78
+ return { title: '🔴 会话运行中', template: 'blue' };
79
+ case 'done':
80
+ return { title: '✅ 会话已完成', template: 'green', result: 'completed' };
81
+ }
82
+ }
56
83
  /**
57
84
  * 单点化:`'runCompact' in runner` 鸭子探测
58
85
  * codex app-server + kimi/opencode ACP runner 满足。
@@ -142,6 +169,10 @@ export class Bridge {
142
169
  activeRuns = new Map();
143
170
  /** Approval coordinators keyed by runId. */
144
171
  approvalCoordinators = new Map();
172
+ /** 审批卡动作的实现(只读上面那张表;见 approval-actions.ts)。 */
173
+ approvalActions = new ApprovalActions({
174
+ coordinatorFor: (runId) => this.approvalCoordinators.get(runId),
175
+ });
145
176
  /**
146
177
  * 最近若干次已完成的、runner 有 runCompact 的 run(cwd → runId → 记录)。
147
178
  * run 结束后 activeRuns 已清空,而终态卡上的 Compact 按钮只带 runId,
@@ -224,13 +255,8 @@ export class Bridge {
224
255
  }
225
256
  // Create new runner for this (cwd, kind) slot
226
257
  const runner = this.agentRegistry.get(kind, cwd);
227
- // Call lifecycle methods for agent runners created via factory
228
- if (runner && typeof runner.killOrphan === 'function') {
229
- runner.killOrphan();
230
- }
231
- if (runner && typeof runner.registerExitHandlers === 'function') {
232
- runner.registerExitHandlers();
233
- }
258
+ runner.killOrphan();
259
+ runner.registerExitHandlers();
234
260
  // Store in nested map: Map<cwd, Map<kind, Runner>>。必须重新取一次:
235
261
  // replaceStale 刚把槽位 evict 掉,上面的局部 workspaceMap 可能已随空槽
236
262
  // 从 this.runners 移除——继续写它会生成一个「缓存里查不到」的 runner,
@@ -589,7 +615,7 @@ export class Bridge {
589
615
  for (const [_cwd, active] of this.activeRuns) {
590
616
  const state = active.session.currentState;
591
617
  // Only include non-terminal runs
592
- if (state.terminal === 'running' || state.terminal === 'finalizing') {
618
+ if (isRunActive(state.terminal)) {
593
619
  result.push({
594
620
  runId: active.runId,
595
621
  sessionId: state.sessionId ?? '',
@@ -841,7 +867,7 @@ export class Bridge {
841
867
  /**
842
868
  * Update an existing card in place (原地更新) instead of sending a new one.
843
869
  *
844
- * Used by /config cardAction handlers (config.toggle/set/input/save) so the
870
+ * Used by /config cardAction handlers (config.set/input/save) so the
845
871
  * user sees the card refresh on the same message bubble rather than getting
846
872
  * a new card per click — same-message-bubble update pattern.
847
873
  *
@@ -887,7 +913,8 @@ export class Bridge {
887
913
  return true;
888
914
  }
889
915
  catch (err) {
890
- getLogger().debug(`[lark-remote] patchCard failed messageId=${messageId}: ${errorMessage(err)}`);
916
+ // 调用方会据此把卡片从集合里永久剔除——这是用户可见的退化,warn 起步。
917
+ getLogger().warn(`[lark-remote] patchCard failed messageId=${messageId}: ${errorMessage(err)}`);
891
918
  return false;
892
919
  }
893
920
  }
@@ -925,10 +952,7 @@ export class Bridge {
925
952
  const killed = await runner.killJob(sessionId, jobId);
926
953
  if (!killed)
927
954
  return false;
928
- snapshot.state = {
929
- ...snapshot.state,
930
- backgroundJobs: (snapshot.state.backgroundJobs ?? []).filter((job) => job.id !== jobId),
931
- };
955
+ snapshot.state = removeBackgroundJob(snapshot.state, jobId);
932
956
  await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
933
957
  return true;
934
958
  }
@@ -992,7 +1016,7 @@ export class Bridge {
992
1016
  }
993
1017
  const entry = this.sessionStore.get(ctx.userId);
994
1018
  getLogger().debug(`[lark-remote] forward entry userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} ` +
995
- `agent=${agentKind} sessionId=${opts?.binding?.sessionId ?? entry?.sessions?.get(agentKind) ?? '(none)'} message=${message.slice(0, 100)}`);
1019
+ `agent=${agentKind} sessionId=${opts?.binding?.sessionId ?? entry?.sessions.get(agentKind) ?? '(none)'} message=${message.slice(0, 100)}`);
996
1020
  // Check if current cwd already has a run in progress
997
1021
  if (this.activeRuns.has(cwd)) {
998
1022
  getLogger().warn(`[lark-remote] cwd busy, dropping message userId=${ctx.userId} cwd=${cwd}`);
@@ -1180,6 +1204,150 @@ export class Bridge {
1180
1204
  stop,
1181
1205
  };
1182
1206
  }
1207
+ /**
1208
+ * result 事件的 pre-init 守卫(§9.22)。
1209
+ *
1210
+ * Claude CLI `--resume` 会在 system.init 之前先吐一条**上一轮**的陈旧 result。
1211
+ * 认了它就会过早置 sawResult / 抓错 usage / 让 run-state reducer 提前进入
1212
+ * finalizing(reducer 用 sessionId===undefined 守卫,同一条红线)。本轮真正的
1213
+ * result 在 system.init 之后才到。
1214
+ *
1215
+ * 例外:app-server runner(usageAuthority==='live')的 init 是 runner 自己补发
1216
+ * 的 synthetic init(2026-08-13 修复)。此分支保留为防御性兜底:live runner 若
1217
+ * 在无 init 时发出 result(旧构建等),session 写回照常执行,但 sawResult 不置
1218
+ * 真——卡片不会把半初始化的 run 当作已收尾。
1219
+ *
1220
+ * @param dropFromCard 该 result 是陈旧重放,不得进卡片(调用方 continue 跳过 push)
1221
+ */
1222
+ observeResultEvent(event, opts) {
1223
+ const { sawInit, usageAuthority, usage, writeback, runId } = opts;
1224
+ const preInitResult = !sawInit;
1225
+ const preInitTerminal = preInitResult && (event.subtype === 'error' || event.subtype === 'interrupted')
1226
+ ? {
1227
+ subtype: event.subtype,
1228
+ errorMessage: event.errorMessage,
1229
+ }
1230
+ : undefined;
1231
+ if (preInitResult && usageAuthority !== 'live') {
1232
+ getLogger().info(`[lark-remote] pre-init result ignored (resume replay) runId=${runId}`);
1233
+ return { sawResult: false, dropFromCard: true, preInitTerminal };
1234
+ }
1235
+ usage.observeResult(event);
1236
+ getLogger().info(preInitResult
1237
+ ? `[lark-remote] pre-init result from live runner (app-server setup failure) runId=${runId}`
1238
+ : `[lark-remote] result event received runId=${runId}`);
1239
+ // app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)不会有
1240
+ // turn_started 事件,store 不会写回新线程 id;runner 已在 result 里兜底上报。
1241
+ // 仅当 runner 声明 live usage(app-server)且会话指针未动时写回,避免下条消息
1242
+ // 再开一个孤儿线程。其余 agent 不在此列(各自有 system.init / turn_started 写回)。
1243
+ if (usageAuthority === 'live')
1244
+ writeBackResultSession(writeback, event.session_id);
1245
+ return { sawResult: !preInitResult, dropFromCard: false, preInitTerminal };
1246
+ }
1247
+ /**
1248
+ * 审批三事件的派发(requested / resolved / view_updated)。
1249
+ *
1250
+ * coordinator 一 run 一个,首次 requested 时惰性建好并登记,`finally` 里统一
1251
+ * onTurnEnded + 摘除。
1252
+ */
1253
+ dispatchApprovalEvent(event, opts) {
1254
+ const { runId, runner, cardSession, ctx, agentKind } = opts;
1255
+ if (event.type === 'approval_requested') {
1256
+ // 审批请求到达:撤回 Typing 表情再重发,让飞书重新触发提醒,把用户
1257
+ // 注意力拉回待审批的卡片(run 在等待人工决策,Typing 应重新闪烁)。
1258
+ remindPendingApproval(this.connector, ctx.messageId, runId);
1259
+ let coordinator = this.approvalCoordinators.get(runId);
1260
+ if (!coordinator) {
1261
+ coordinator = createRunApprovalCoordinator({
1262
+ runner,
1263
+ cardSession,
1264
+ config: this.config,
1265
+ runId,
1266
+ agentKind,
1267
+ });
1268
+ this.approvalCoordinators.set(runId, coordinator);
1269
+ getLogger().info(`[lark-remote] approval coordinator created runId=${runId.slice(0, 8)} requestId=${event.requestId}`);
1270
+ }
1271
+ coordinator.onRequested(event);
1272
+ return;
1273
+ }
1274
+ const coordinator = this.approvalCoordinators.get(runId);
1275
+ if (event.type === 'approval_resolved') {
1276
+ coordinator?.onResolved(event.requestId);
1277
+ return;
1278
+ }
1279
+ // 乱序流:审批先到、item/started 后到,补全审批卡内容。coordinator
1280
+ // 仅在 pending 时更新(已响应/过期不复活)。
1281
+ coordinator?.updateView(event.requestId, event.view);
1282
+ }
1283
+ /**
1284
+ * 进程退出(for-await 自然结束)后的终态矩阵——它是本轮的唯一终态触发源。
1285
+ *
1286
+ * 1) finalizing(result 已收到)→ 三态映射 + usage meta
1287
+ * 2) running(未收到 result)→ 错误路径;pre-init 终态优先当唯一线索
1288
+ * 3) 已终态 + sawResult → 首终态不动,仅补 usage meta
1289
+ * 4) 已终态且未收到 result(如 spawn 失败后 interrupted)→ 无 usage 可补,跳过
1290
+ */
1291
+ async finishRunAfterStream(opts) {
1292
+ const { cardSession, usage, sawResult, sawAnyResult, pendingPreInitTerminal, agentKind, runId, } = opts;
1293
+ const finalState = cardSession.currentState;
1294
+ if (finalState.terminal === 'finalizing') {
1295
+ // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
1296
+ // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1297
+ const subtype = finalState.resultSubtype;
1298
+ const isError = subtype === 'error' || !!finalState.errorMsg;
1299
+ // goal 仍 active = 宿主会自动起下一轮:这不是"完成",而是"本轮结束"。
1300
+ // 用独立终态而不是 done,既不改 header 文案也不用谎报。
1301
+ const goalContinues = !isError && subtype !== 'interrupted' && finalState.goal?.phase === 'active';
1302
+ const terminal = isError
1303
+ ? 'error'
1304
+ : subtype === 'interrupted'
1305
+ ? 'interrupted'
1306
+ : goalContinues
1307
+ ? 'goal_continuing'
1308
+ : 'done';
1309
+ // 中断归因:用户主动取消审批(cancel)优先于"未归类"。用户 /stop 的
1310
+ // 路径先置了终态(interrupted + user_stop),不会走到这里。
1311
+ const approvalCancelled = terminal === 'interrupted' &&
1312
+ this.approvalCoordinators.get(runId)?.cancelledByUser === true;
1313
+ await cardSession.finish(terminal, {
1314
+ resultSubtype: subtype,
1315
+ errorMsg: finalState.errorMsg,
1316
+ ...(approvalCancelled ? { interruptedReason: 'approval_cancelled' } : {}),
1317
+ ...usage.meta(),
1318
+ });
1319
+ return;
1320
+ }
1321
+ if (finalState.terminal === 'running') {
1322
+ if (pendingPreInitTerminal) {
1323
+ const terminal = pendingPreInitTerminal.subtype === 'interrupted' ? 'interrupted' : 'error';
1324
+ getLogger().warn(`[lark-remote] stream ended without init; using pre-init ${terminal} result runId=${runId} error=${pendingPreInitTerminal.errorMessage ?? '(none)'}`);
1325
+ await cardSession.finish(terminal, {
1326
+ ...usage.meta(),
1327
+ errorMsg: pendingPreInitTerminal.errorMessage ??
1328
+ agentDisplayName(agentKind) + (terminal === 'interrupted' ? ' 已中断' : ' 启动失败'),
1329
+ });
1330
+ }
1331
+ else {
1332
+ await cardSession.finish('error', {
1333
+ ...usage.meta(),
1334
+ // sawAnyResult=true 说明 result 收到了、只是没能落到卡片(reducer 的
1335
+ // sessionId 守卫或 pre-init 守卫挡下)。报文不能再说"未收到 result
1336
+ // 事件",否则排障会去查输出流而不是查守卫。
1337
+ errorMsg: agentDisplayName(agentKind) +
1338
+ (sawAnyResult
1339
+ ? ' 输出流已结束,但本轮结果未能落到卡片上'
1340
+ : ' 输出流已结束,但未收到 result 事件'),
1341
+ });
1342
+ }
1343
+ return;
1344
+ }
1345
+ if (sawResult) {
1346
+ // 已终态(interrupted/idle_timeout)+ result 已收到:首终态优先,
1347
+ // 仅补充 usage meta(finalizing 期间被 /stop 或 idle 超时也展示 token 统计)
1348
+ await cardSession.finish(finalState.terminal, usage.meta());
1349
+ }
1350
+ }
1183
1351
  /**
1184
1352
  * Step 2 of forwardToClaude: Run the agent and consume its event stream.
1185
1353
  * Handles idle watchdog, event processing, session sync, and state transitions.
@@ -1191,6 +1359,8 @@ export class Bridge {
1191
1359
  // §9.12 空闲看门狗:判定口径见 startIdleWatchdog;武装点在 cardSession.start() 之后。
1192
1360
  let watchdog = null;
1193
1361
  let sawResult = false;
1362
+ /** 收到过任何 result(含被守卫丢弃/未进卡片的):收尾报文据此措辞。 */
1363
+ let sawAnyResult = false;
1194
1364
  // A runner can fail before emitting its synthetic system/init (e.g. DSH
1195
1365
  // session/create -> 401). The pre-init guard below drops that result to
1196
1366
  // protect Claude resume replay, but if the stream ends without ever seeing
@@ -1214,9 +1384,14 @@ export class Bridge {
1214
1384
  // Card stream failed - continue with static fallback below, but ensure
1215
1385
  // we don't silently fail if the fallback also fails (covered in catch below).
1216
1386
  }
1217
- if (cardSession.currentState.terminal !== 'running') {
1218
- // Card session did not enter running state - still try to send static fallback
1219
- getLogger().info(`[lark-remote] cardSession not running, using static fallback`);
1387
+ // /stop(或队列「立即执行」的中断)可能落在建卡窗口内:卡片已定格为
1388
+ // 终态,但 runner 还没开始跑,runner.stop() 也没有进程可杀——spawn 型
1389
+ // 与连接型 runner 的 stop() 在 run() 之前都是空操作,且 run() 入口会
1390
+ // 重置停止标记。必须在这里收口,否则用户看到「已中断」,agent 仍会把
1391
+ // 这一轮跑完并改写工作区。finalizeRun 仍会照常收尾(settle / reaction)。
1392
+ if (isRunTerminal(cardSession.currentState.terminal)) {
1393
+ getLogger().info(`[lark-remote] card terminal before run start, skip runner runId=${runId} terminal=${cardSession.currentState.terminal}`);
1394
+ return;
1220
1395
  }
1221
1396
  // 单 interval 看门狗。事件到来时只刷新时间戳(无 timer 操作);
1222
1397
  // interval 周期检查静默时长,超时才 finish('idle_timeout') + stop()。
@@ -1276,83 +1451,31 @@ export class Bridge {
1276
1451
  usage.observeCompaction(event.compactMetadata.postTokens);
1277
1452
  }
1278
1453
  if (event.type === 'result') {
1279
- // Pre-init result guard: Claude CLI --resume emits a stale result
1280
- // (from the previous turn) before system.init. Ignoring it prevents
1281
- // premature sawResult/usage capture and the run-state reducer (which
1282
- // also guards on sessionId === undefined) from transitioning to
1283
- // finalizing too early. The real result for this run arrives after
1284
- // system.init.
1285
- //
1286
- // Exception: app-server runners (usageAuthority === 'live') 的 init
1287
- // 是 runner 补发的 synthetic init(2026-08-13 修复)。此分支保留为
1288
- // 防御性兜底:任何 live runner 若在无 init 时发出 result(如旧构建、
1289
- // 其它 live runner),session 写回仍执行,但 sawResult 保持 false,
1290
- // 卡片不会把半初始化的 run 当作已收尾。
1291
- const preInitResult = !sawInit;
1292
- if (preInitResult && usageAuthority !== 'live') {
1293
- getLogger().info(`[lark-remote] pre-init result ignored (resume replay) runId=${runId}`);
1294
- if (event.subtype === 'error' || event.subtype === 'interrupted') {
1295
- pendingPreInitTerminal ??= {
1296
- subtype: event.subtype,
1297
- errorMessage: event.errorMessage,
1298
- };
1299
- }
1300
- // Still reset idle timer — the CLI is alive and producing events.
1454
+ const outcome = this.observeResultEvent(event, {
1455
+ sawInit,
1456
+ usageAuthority,
1457
+ usage,
1458
+ writeback,
1459
+ runId,
1460
+ });
1461
+ if (outcome.preInitTerminal)
1462
+ pendingPreInitTerminal ??= outcome.preInitTerminal;
1463
+ if (outcome.sawResult)
1464
+ sawResult = true;
1465
+ sawAnyResult = true;
1466
+ // 陈旧 result 不进卡片:run-state reducer 本来也会因 sessionId 未定而
1467
+ // 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
1468
+ if (outcome.dropFromCard) {
1469
+ // 仍然重置 idle 计时——CLI 活着并且在产事件。
1301
1470
  watchdog?.touch();
1302
- // Do NOT push to cardSession: the run-state reducer would also
1303
- // skip it (sessionId === undefined), but skipping at the bridge
1304
- // layer avoids the push + render overhead for a no-op event.
1305
1471
  continue;
1306
1472
  }
1307
- if (preInitResult && (event.subtype === 'error' || event.subtype === 'interrupted')) {
1308
- pendingPreInitTerminal ??= {
1309
- subtype: event.subtype,
1310
- errorMessage: event.errorMessage,
1311
- };
1312
- }
1313
- if (!preInitResult) {
1314
- sawResult = true;
1315
- }
1316
- usage.observeResult(event);
1317
- getLogger().info(preInitResult
1318
- ? `[lark-remote] pre-init result from live runner (app-server setup failure) runId=${runId}`
1319
- : `[lark-remote] result event received runId=${runId}`);
1320
- // app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)
1321
- // 不会有 turn_started 事件,store 不会写回新线程 id;runner 已在 result
1322
- // 里兜底上报。此处仅当 runner 声明 live usage(app-server)
1323
- // 且会话指针未动时写回,避免下条消息再开一个孤儿线程。其余 agent 不在
1324
- // 此列(它们各自有 system.init / turn_started 写回)。
1325
- if (usageAuthority === 'live')
1326
- writeBackResultSession(writeback, event.session_id);
1327
1473
  }
1328
1474
  // Approval events (Codex app-server mode)
1329
- if (event.type === 'approval_requested') {
1330
- // 审批请求到达:撤回 Typing 表情再重发,让飞书重新触发提醒,把用户
1331
- // 注意力拉回待审批的卡片(run 在等待人工决策,Typing 应重新闪烁)。
1332
- remindPendingApproval(this.connector, ctx.messageId, runId);
1333
- let coordinator = this.approvalCoordinators.get(runId);
1334
- if (!coordinator) {
1335
- coordinator = createRunApprovalCoordinator({
1336
- runner,
1337
- cardSession,
1338
- config: this.config,
1339
- runId,
1340
- agentKind,
1341
- });
1342
- this.approvalCoordinators.set(runId, coordinator);
1343
- getLogger().info(`[lark-remote] approval coordinator created runId=${runId.slice(0, 8)} requestId=${event.requestId}`);
1344
- }
1345
- coordinator.onRequested(event);
1346
- }
1347
- else if (event.type === 'approval_resolved') {
1348
- const coordinator = this.approvalCoordinators.get(runId);
1349
- coordinator?.onResolved(event.requestId);
1350
- }
1351
- else if (event.type === 'approval_view_updated') {
1352
- // 乱序流:审批先到、item/started 后到,补全审批卡内容。coordinator
1353
- // 仅在 pending 时更新(已响应/过期不复活)。
1354
- const coordinator = this.approvalCoordinators.get(runId);
1355
- coordinator?.updateView(event.requestId, event.view);
1475
+ if (event.type === 'approval_requested' ||
1476
+ event.type === 'approval_resolved' ||
1477
+ event.type === 'approval_view_updated') {
1478
+ this.dispatchApprovalEvent(event, { runId, runner, cardSession, ctx, agentKind });
1356
1479
  }
1357
1480
  // §9.12 红线:每个事件(含 result)无条件刷新
1358
1481
  // lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
@@ -1381,56 +1504,16 @@ export class Bridge {
1381
1504
  : resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, finalSessionId, cwd, agentKind);
1382
1505
  usage.finalize(finalUsage, usageAuthority);
1383
1506
  getLogger().info(usage.logSummary(runId));
1384
- // for-await 自然结束 = stream 关闭 = CLI 进程退出。
1385
- // 进程退出(for-await end)= 唯一终态触发源。
1386
- // 1) finalizing(result 已收到)-> 转 done/error + usage meta
1387
- // 2) running(未收到 result)-> 错误路径
1388
- // 3) 已终态 + sawResult -> 补充 usage meta(不改 terminal)
1389
- const finalState = cardSession.currentState;
1390
- if (finalState.terminal === 'finalizing') {
1391
- // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
1392
- // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1393
- const subtype = finalState.resultSubtype;
1394
- const isError = subtype === 'error' || !!finalState.errorMsg;
1395
- // goal 仍 active = 宿主会自动起下一轮:这不是"完成",而是"本轮结束"。
1396
- // 用独立终态而不是 done,既不改 header 文案也不用谎报。
1397
- const goalContinues = !isError && subtype !== 'interrupted' && finalState.goal?.phase === 'active';
1398
- const terminal = isError
1399
- ? 'error'
1400
- : subtype === 'interrupted'
1401
- ? 'interrupted'
1402
- : goalContinues
1403
- ? 'goal_continuing'
1404
- : 'done';
1405
- await cardSession.finish(terminal, {
1406
- resultSubtype: subtype,
1407
- errorMsg: finalState.errorMsg,
1408
- ...usage.meta(),
1409
- });
1410
- }
1411
- else if (finalState.terminal === 'running') {
1412
- if (pendingPreInitTerminal) {
1413
- const terminal = pendingPreInitTerminal.subtype === 'interrupted' ? 'interrupted' : 'error';
1414
- getLogger().warn(`[lark-remote] stream ended without init; using pre-init ${terminal} result runId=${runId} error=${pendingPreInitTerminal.errorMessage ?? '(none)'}`);
1415
- await cardSession.finish(terminal, {
1416
- ...usage.meta(),
1417
- errorMsg: pendingPreInitTerminal.errorMessage ??
1418
- agentDisplayName(agentKind) + (terminal === 'interrupted' ? ' 已中断' : ' 启动失败'),
1419
- });
1420
- }
1421
- else {
1422
- await cardSession.finish('error', {
1423
- ...usage.meta(),
1424
- errorMsg: agentDisplayName(agentKind) + ' 输出流已结束,但未收到 result 事件',
1425
- });
1426
- }
1427
- }
1428
- else if (sawResult) {
1429
- // 已终态(interrupted/idle_timeout)+ result 已收到:首终态优先,
1430
- // 仅补充 usage meta(finalizing 期间被 /stop 或 idle 超时也展示 token 统计)
1431
- await cardSession.finish(finalState.terminal, usage.meta());
1432
- }
1433
- // else: 已终态且未收到 result(如 spawn 失败后 interrupted)-> 无 usage 可补,跳过
1507
+ // for-await 自然结束 = stream 关闭 = CLI 进程退出 → 终态矩阵
1508
+ await this.finishRunAfterStream({
1509
+ cardSession,
1510
+ usage,
1511
+ sawResult,
1512
+ sawAnyResult,
1513
+ pendingPreInitTerminal,
1514
+ agentKind,
1515
+ runId,
1516
+ });
1434
1517
  }
1435
1518
  catch (err) {
1436
1519
  getLogger().error(`[lark-remote] claude run error runId=${runId}:`, err);
@@ -1445,7 +1528,7 @@ export class Bridge {
1445
1528
  // 终态守卫:running 或 finalizing(非终态)-> 转 error;
1446
1529
  // 已终态(interrupted/idle_timeout/done/error)-> 保留首终态,仅补充 usage。
1447
1530
  const catchTerminal = cardSession.currentState.terminal;
1448
- if (catchTerminal === 'running' || catchTerminal === 'finalizing') {
1531
+ if (isRunActive(catchTerminal)) {
1449
1532
  await cardSession.finish('error', {
1450
1533
  ...usage.meta(),
1451
1534
  inputTokens: usage.liveInputTokens,
@@ -1524,7 +1607,7 @@ export class Bridge {
1524
1607
  // but NOT when streaming succeeded (card already shows completion).
1525
1608
  const sessionId = cardSession.currentState.sessionId;
1526
1609
  if (sessionId && runCardSent) {
1527
- await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind);
1610
+ await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind, cardSession.currentState.terminal);
1528
1611
  }
1529
1612
  if (finalRender.finalAnswerStatus === 'overflow' && finalRender.finalAnswerText) {
1530
1613
  await this.sendFinalAnswerOverflow(finalRender.finalAnswerText, ctx);
@@ -1572,7 +1655,8 @@ export class Bridge {
1572
1655
  * It's sent as a new message (not updating the run card) so users can easily
1573
1656
  * find and return to this session without scrolling up.
1574
1657
  */
1575
- async sendCompletionNotificationCard(sessionId, cwd, ctx, agentKind = this.config.defaultAgent) {
1658
+ async sendCompletionNotificationCard(sessionId, cwd, ctx, agentKind = this.config.defaultAgent, terminal = 'done') {
1659
+ const presentation = completionNotificationPresentation(terminal);
1576
1660
  try {
1577
1661
  // cap the events read for the notification card — without
1578
1662
  // maxEvents claude returns every event after the last user message and
@@ -1589,10 +1673,10 @@ export class Bridge {
1589
1673
  }, {
1590
1674
  agentKind,
1591
1675
  headerText: `📂 \`${cwd}\`\n会话: **${sessionId}**`,
1592
- title: '✅ 会话已完成',
1593
- usageResult: 'completed',
1676
+ title: presentation.title,
1677
+ ...(presentation.result ? { usageResult: presentation.result } : {}),
1594
1678
  actions: [resumeUseButton(sessionId, agentKind)],
1595
- headerTemplate: 'green',
1679
+ headerTemplate: presentation.template,
1596
1680
  });
1597
1681
  // send through sendResult so enforceCardBudget guards the card
1598
1682
  // (previously sent via connector directly, bypassing the budget — long
@@ -1612,90 +1696,36 @@ export class Bridge {
1612
1696
  // =============================================================================
1613
1697
  // Approval action handlers
1614
1698
  // =============================================================================
1615
- /**
1616
- * Resolve the ApprovalCoordinator for a run, or throw the standard
1617
- * "已结束" error. Shared preamble of all approval card-action handlers.
1618
- */
1619
- withCoordinator(runId, fn) {
1620
- const coordinator = this.approvalCoordinators.get(runId);
1621
- if (!coordinator) {
1622
- // run 已结束(coordinator 已释放)时点击审批按钮必须给用户明确反馈,
1623
- // 不能静默。router 会把该异常转成 error toast。
1624
- getLogger().warn(`[lark-remote] no approval coordinator for runId=${runId.slice(0, 8)}`);
1625
- throw new Error('任务已结束,审批无法响应');
1626
- }
1627
- return fn(coordinator);
1628
- }
1629
- /**
1630
- * Handle an approval response from a card button click.
1631
- * Routes to the ApprovalCoordinator for the given run.
1632
- */
1699
+ // 实现住在 approval-actions.ts(审计 #32):这 7 个入口只碰
1700
+ // approvalCoordinators 一张表,不碰其余 Bridge 状态。方法名保持 Bridge 的
1701
+ // 公开 API 不变——router 的 APPROVAL_ACTION_SPECS 按名字分发。
1702
+ /** 审批响应(卡片按钮)→ ApprovalCoordinator.submit。 */
1633
1703
  async handleApprovalRespond(opts) {
1634
- const log = getLogger();
1635
- log.info(`[lark-remote] handleApprovalRespond runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} decision=${opts.decision}`);
1636
- return this.withCoordinator(opts.runId, async (coordinator) => {
1637
- // Build ApprovalAction from the decision string
1638
- const action = decisionToApprovalAction(opts.decision);
1639
- await coordinator.submit(action, { requestId: opts.requestId, nonce: opts.nonce });
1640
- });
1704
+ return this.approvalActions.handleApprovalRespond(opts);
1641
1705
  }
1642
- /**
1643
- * 计划审批修改意见(ExitPlanMode 输入框):记录到 coordinator 并回流卡片。
1644
- * 「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
1645
- */
1706
+ /** 计划审批修改意见(ExitPlanMode 输入框)→ coordinator.planFeedback。 */
1646
1707
  async handleApprovalPlanFeedback(opts) {
1647
- return this.withCoordinator(opts.runId, async (coordinator) => {
1648
- await coordinator.planFeedback({ text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce });
1649
- });
1708
+ return this.approvalActions.handleApprovalPlanFeedback(opts);
1650
1709
  }
1651
- /**
1652
- * Handle a permission toggle from a card button click.
1653
- * Routes to the ApprovalCoordinator for the given run.
1654
- */
1710
+ /** 权限条目勾选(卡片按钮)→ coordinator.togglePerm。 */
1655
1711
  async handleApprovalToggle(opts) {
1656
- const log = getLogger();
1657
- log.info(`[lark-remote] handleApprovalToggle runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} permId=${opts.permId}`);
1658
- return this.withCoordinator(opts.runId, async (coordinator) => {
1659
- // selected 由卡片根据当前渲染状态传回(取反),不再硬编码只授不撤。
1660
- const toggleAction = {
1661
- permId: opts.permId,
1662
- selected: opts.selected ?? true,
1663
- };
1664
- await coordinator.togglePerm(toggleAction, { requestId: opts.requestId });
1665
- });
1712
+ return this.approvalActions.handleApprovalToggle(opts);
1666
1713
  }
1667
- /**
1668
- * Claude AskUserQuestion 选项点击(单选即时提交 / 多选切换勾选)。
1669
- * 路由到 ApprovalCoordinator.toggleAnswer。
1670
- */
1714
+ /** AskUserQuestion 选项点击 → coordinator.toggleAnswer。 */
1671
1715
  async handleApprovalAnswer(opts) {
1672
- return this.dispatchQuestionAction(opts, 'handleApprovalAnswer', (coordinator) => coordinator.toggleAnswer({ questionIndex: opts.questionIndex, option: opts.option }, { requestId: opts.requestId, nonce: opts.nonce }));
1716
+ return this.approvalActions.handleApprovalAnswer(opts);
1673
1717
  }
1674
- /**
1675
- * Claude AskUserQuestion 多选问题的「提交答案」按钮。
1676
- * 路由到 ApprovalCoordinator.submitAnswers。
1677
- */
1718
+ /** AskUserQuestion 多选「提交答案」→ coordinator.submitAnswers。 */
1678
1719
  async handleApprovalAnswerSubmit(opts) {
1679
- return this.dispatchQuestionAction(opts, 'handleApprovalAnswerSubmit', (coordinator) => coordinator.submitAnswers({ questionIndex: opts.questionIndex }, { requestId: opts.requestId, nonce: opts.nonce }));
1720
+ return this.approvalActions.handleApprovalAnswerSubmit(opts);
1680
1721
  }
1681
- /**
1682
- * Claude AskUserQuestion 自定义答案(Other):自由文本直接
1683
- * 作为该单选问题的答案提交。路由到 ApprovalCoordinator.answerCustom。
1684
- */
1722
+ /** AskUserQuestion 自定义答案(Other)→ coordinator.answerCustom。 */
1685
1723
  async handleApprovalAnswerCustom(opts) {
1686
- return this.dispatchQuestionAction(opts, 'handleApprovalAnswerCustom', (coordinator) => coordinator.answerCustom({ questionIndex: opts.questionIndex, text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce }));
1724
+ return this.approvalActions.handleApprovalAnswerCustom(opts);
1687
1725
  }
1688
- /**
1689
- * Codex AskUserQuestion 补充说明(user_note,2026-08-18 live 验证模型理解
1690
- * "user_note: <text>" 条目):记录到 coordinator,随答案一起提交。
1691
- * 路由到 ApprovalCoordinator.answerNote。
1692
- */
1726
+ /** AskUserQuestion 补充说明(user_note)→ coordinator.answerNote。 */
1693
1727
  async handleApprovalAnswerNote(opts) {
1694
- return this.dispatchQuestionAction(opts, 'handleApprovalAnswerNote', (coordinator) => coordinator.answerNote({ questionIndex: opts.questionIndex, text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce }));
1695
- }
1696
- dispatchQuestionAction(opts, action, invoke) {
1697
- getLogger().info(`[lark-remote] ${action} runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} question=${opts.questionIndex}`);
1698
- return this.withCoordinator(opts.runId, invoke);
1728
+ return this.approvalActions.handleApprovalAnswerNote(opts);
1699
1729
  }
1700
1730
  /**
1701
1731
  * 记下一次「可压缩 run」:终态卡上的 Compact 按钮点击时据此反查
@@ -1817,14 +1847,14 @@ export class Bridge {
1817
1847
  });
1818
1848
  }
1819
1849
  /**
1820
- * §5.3: kimi 压缩在途检测——reader 鸭子实现 `readCompactionState` 时,若
1850
+ * §5.3: kimi 压缩在途检测——reader 实现契约的 `readCompactionState` 时,若
1821
1851
  * wire 状态机判定当前有未终结的压缩(最后一次 begin 无 terminal 记录),
1822
1852
  * 直接回文本「已有一次 Compact 正在进行」并返回 true(调用方不开卡、不发
1823
1853
  * session/prompt)。codex/claude 等 reader 无此方法 → 返回 false,行为不变。
1824
1854
  */
1825
1855
  async rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx) {
1826
1856
  const reader = this.sessionReaderRegistry.get(agentKind);
1827
- if (!reader || !('readCompactionState' in reader))
1857
+ if (!reader?.readCompactionState)
1828
1858
  return false;
1829
1859
  try {
1830
1860
  const state = reader.readCompactionState(sessionId, cwd);
@@ -1878,14 +1908,15 @@ export class Bridge {
1878
1908
  }
1879
1909
  await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
1880
1910
  const compactRunId = randomUUID();
1911
+ // 渲染选项同时用于流式卡与「卡片流没建起来」时的静态兜底卡——两者必须
1912
+ // 同门控,否则兜底卡会渲染出与本次 run 不一致的按钮。
1913
+ const renderOptions = { agentKind };
1881
1914
  const cardSession = new RunCardSession({
1882
1915
  connector: this.connector,
1883
1916
  chatId: ctx.chatId,
1884
1917
  replyTo: ctx.messageId,
1885
1918
  runId: compactRunId,
1886
- renderOptions: {
1887
- agentKind,
1888
- },
1919
+ renderOptions,
1889
1920
  });
1890
1921
  // 压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
1891
1922
  // /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
@@ -1952,20 +1983,9 @@ export class Bridge {
1952
1983
  await cardSession.finish(terminal, {
1953
1984
  resultSubtype: resultSubtype ?? 'error',
1954
1985
  errorMsg: terminal === 'error' ? (resultError ?? '未收到压缩结果') : undefined,
1955
- contextLength: finalUsage?.contextLength,
1956
- contextLimit: finalUsage?.contextLimit,
1957
- compactPreContextLength: finalUsage?.compactPreContextLength,
1958
- compactCount: finalUsage?.compactCount,
1959
- cacheReadTokens: finalUsage?.cacheReadTokens,
1960
- cacheCreationTokens: finalUsage?.cacheCreationTokens,
1961
- totalTokens: finalUsage?.totalTokens,
1962
- inputTokens: finalUsage?.inputTokens,
1963
- outputTokens: finalUsage?.outputTokens,
1964
- cumulativeTotalTokens: finalUsage?.cumulativeTotalTokens,
1965
- cumulativeInputTokens: finalUsage?.cumulativeInputTokens,
1966
- cumulativeOutputTokens: finalUsage?.cumulativeOutputTokens,
1967
- cumulativeCacheReadTokens: finalUsage?.cumulativeCacheReadTokens,
1968
- cumulativeCacheCreationTokens: finalUsage?.cumulativeCacheCreationTokens,
1986
+ // finalUsage 的字段形态就是 FinishMeta 的用量部分(同为 UsageSnapshot),
1987
+ // 逐字段抄的那 14 行随单源一起去掉。
1988
+ ...(finalUsage ?? {}),
1969
1989
  });
1970
1990
  log.info(`[lark-remote] streamCompact finished sessionId=${sessionId} sawResult=${sawResult} subtype=${resultSubtype ?? 'none'}`);
1971
1991
  }
@@ -1983,6 +2003,15 @@ export class Bridge {
1983
2003
  }
1984
2004
  finally {
1985
2005
  watchdog?.stop();
2006
+ // 与 run 路径同口径的降级:卡片流没建起来(start 失败 / stream 未收尾)时,
2007
+ // 终态只存在于内存里。settle() 释放 producer 并等 stream 收尾;'unsent'
2008
+ // 表示用户手里没有卡,必须补一张静态终态卡,否则只剩「Compact 已触发」
2009
+ // 和一条 reaction,用户既不知道失败也不知道原因。
2010
+ const settled = await cardSession.settle();
2011
+ if (settled === 'unsent') {
2012
+ const finalRender = renderRunCardDetailed(cardSession.currentState, renderOptions);
2013
+ await this.sendResult({ card: finalRender.card }, ctx);
2014
+ }
1986
2015
  // 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
1987
2016
  if (this.activeRuns.get(cwd) === activeRun) {
1988
2017
  this.activeRuns.delete(cwd);