lark-remote 0.5.19 → 0.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bridge/approval-actions.d.ts +1 -1
  3. package/dist/bridge/approval-actions.js +1 -1
  4. package/dist/bridge/bash-run.d.ts +71 -0
  5. package/dist/bridge/bash-run.js +217 -0
  6. package/dist/bridge/compact.d.ts +155 -0
  7. package/dist/bridge/compact.js +450 -0
  8. package/dist/bridge/final-usage.js +4 -4
  9. package/dist/bridge/inbound-media.js +72 -46
  10. package/dist/bridge/index.d.ts +106 -153
  11. package/dist/bridge/index.js +400 -1000
  12. package/dist/bridge/job-watch.d.ts +68 -0
  13. package/dist/bridge/job-watch.js +146 -0
  14. package/dist/bridge/queue-manager.d.ts +3 -3
  15. package/dist/bridge/queue-manager.js +10 -29
  16. package/dist/card/approval-render.d.ts +2 -2
  17. package/dist/card/approval-render.js +141 -130
  18. package/dist/card/card-budget.js +123 -98
  19. package/dist/card/card-helpers.d.ts +3 -3
  20. package/dist/card/card-helpers.js +6 -6
  21. package/dist/card/final-answer-file.js +1 -1
  22. package/dist/card/run-renderer.d.ts +3 -3
  23. package/dist/card/run-renderer.js +114 -159
  24. package/dist/card/run-state.d.ts +1 -15
  25. package/dist/card/run-state.js +358 -292
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +21 -17
  28. package/dist/card/usage-format.d.ts +22 -7
  29. package/dist/card/usage-format.js +119 -100
  30. package/dist/cli.js +15 -3
  31. package/dist/clone.js +2 -2
  32. package/dist/common/text-width.js +36 -24
  33. package/dist/common/tool-summary.js +132 -90
  34. package/dist/config/codex-config.js +130 -106
  35. package/dist/config/index.d.ts +16 -2
  36. package/dist/config/index.js +23 -8
  37. package/dist/connector/index.js +6 -6
  38. package/dist/error-classification.js +3 -2
  39. package/dist/inbound/placeholder.d.ts +1 -1
  40. package/dist/inbound/placeholder.js +1 -1
  41. package/dist/index.js +387 -281
  42. package/dist/logger/index.js +6 -6
  43. package/dist/message-handlers.d.ts +0 -5
  44. package/dist/message-handlers.js +254 -237
  45. package/dist/model-catalog.d.ts +2 -2
  46. package/dist/model-catalog.js +3 -8
  47. package/dist/platform/identity.js +1 -1
  48. package/dist/platform/path.d.ts +1 -1
  49. package/dist/platform/path.js +1 -1
  50. package/dist/platform/sleep-blocker.js +1 -1
  51. package/dist/platform/terminator-win32.js +2 -1
  52. package/dist/router/commands/ls.d.ts +3 -0
  53. package/dist/router/commands/ls.js +256 -175
  54. package/dist/router/commands/order.js +184 -159
  55. package/dist/router/commands/resume.js +190 -152
  56. package/dist/router/commands/ws.js +219 -174
  57. package/dist/router/config/dsh.d.ts +4 -8
  58. package/dist/router/config/dsh.js +3 -10
  59. package/dist/router/config/kimi.js +1 -1
  60. package/dist/router/config/types.d.ts +1 -1
  61. package/dist/router/config-diff.js +1 -1
  62. package/dist/router/index.d.ts +56 -11
  63. package/dist/router/index.js +401 -391
  64. package/dist/runner/bash/runner.js +2 -4
  65. package/dist/runner/claude/session.d.ts +1 -1
  66. package/dist/runner/claude/session.js +6 -4
  67. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  68. package/dist/runner/codex/app-server/runner.js +30 -25
  69. package/dist/runner/codex/app-server/translator.d.ts +2 -5
  70. package/dist/runner/codex/app-server/translator.js +291 -238
  71. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  72. package/dist/runner/common/acp/acp-approval.js +1 -1
  73. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  74. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  75. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  76. package/dist/runner/common/acp/base-translator.js +50 -26
  77. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  78. package/dist/runner/common/acp/protocol-types.js +2 -2
  79. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  80. package/dist/runner/common/connection-based-runner.js +32 -2
  81. package/dist/runner/common/jsonl-stream.js +154 -187
  82. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  83. package/dist/runner/common/jsonrpc/client.js +43 -12
  84. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  85. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  86. package/dist/runner/common/spawning-runner.d.ts +6 -1
  87. package/dist/runner/common/spawning-runner.js +9 -4
  88. package/dist/runner/common/turn-started.d.ts +8 -0
  89. package/dist/runner/common/turn-started.js +11 -0
  90. package/dist/runner/dsh/approval.js +84 -51
  91. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  92. package/dist/runner/dsh/catalog-probe.js +10 -0
  93. package/dist/runner/dsh/client.d.ts +7 -11
  94. package/dist/runner/dsh/client.js +356 -289
  95. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  96. package/dist/runner/dsh/exit-cancel.js +2 -2
  97. package/dist/runner/dsh/inflight.d.ts +1 -1
  98. package/dist/runner/dsh/inflight.js +1 -1
  99. package/dist/runner/dsh/mux-stream.js +28 -8
  100. package/dist/runner/dsh/runner.d.ts +44 -4
  101. package/dist/runner/dsh/runner.js +296 -199
  102. package/dist/runner/dsh/translator.d.ts +1 -31
  103. package/dist/runner/dsh/translator.js +180 -178
  104. package/dist/runner/index.d.ts +1 -1
  105. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  106. package/dist/runner/kimi/acp/runner.js +82 -46
  107. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  108. package/dist/runner/kimi/acp/translator.js +3 -4
  109. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  110. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  111. package/dist/runner/opencode/acp/runner.js +4 -4
  112. package/dist/runner/pi/rpc/client.js +4 -13
  113. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  114. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  115. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  116. package/dist/runner/pi/rpc/runner.js +27 -42
  117. package/dist/runner/pi/rpc/translator.js +61 -60
  118. package/dist/runner/types.d.ts +2 -2
  119. package/dist/runner/types.js +1 -1
  120. package/dist/session/claude/sessions.d.ts +45 -0
  121. package/dist/session/claude/sessions.js +160 -122
  122. package/dist/session/codex/rollout-reader.js +181 -182
  123. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  124. package/dist/session/common/throw-session-list-error.js +16 -0
  125. package/dist/session/dsh/sessions.js +291 -235
  126. package/dist/session/kimi/sessions.d.ts +40 -8
  127. package/dist/session/kimi/sessions.js +373 -301
  128. package/dist/session/opencode/sessions.d.ts +142 -1
  129. package/dist/session/opencode/sessions.js +378 -321
  130. package/dist/session/pi/sessions.js +4 -5
  131. package/dist/session/session-store.d.ts +9 -1
  132. package/dist/session/session-store.js +99 -99
  133. package/dist/update/version-check.js +2 -1
  134. package/dist/workspace/index.d.ts +1 -1
  135. package/dist/workspace/index.js +1 -1
  136. package/package.json +1 -1
@@ -1,22 +1,19 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { sleep } from '../common/sleep.js';
3
2
  import { isContentFilterFailure } from '../common/turn-failure.js';
4
3
  import { resolveFinalUsage, errorMessage } from './final-usage.js';
5
4
  import { getLogger } from '../logger/index.js';
6
5
  import { RunCardSession } from '../card/run-card-session.js';
7
- import { renderRunCard, renderRunCardDetailed, } from '../card/run-renderer.js';
6
+ import { renderRunCardDetailed } from '../card/run-renderer.js';
8
7
  import { writeFinalAnswerFile, finalAnswerFileFailureCard } from '../card/final-answer-file.js';
9
8
  import { bestEffortUnlink } from '../common/fs.js';
10
- import { renderBashCard } from '../card/bash-renderer.js';
11
- import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
12
- import { isLiveBackgroundJob, isRunActive, isRunTerminal, markBackgroundJobsUnknown, removeBackgroundJob, syncBackgroundJobs, } from '../card/run-state.js';
9
+ import { isRunActive, isRunTerminal } from '../card/run-state.js';
13
10
  import { buildSessionHistoryCard } from '../card/card-helpers.js';
14
11
  import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
15
12
  import { enforceCardBudget } from '../card/card-budget.js';
16
13
  import { RunUsageCollector } from './run-usage.js';
17
- import { BashProcessRunner } from '../runner/index.js';
18
- import { AGENT_KINDS } from '../agent-kinds.js';
19
- import { supportsCompact } from '../runner/types.js';
14
+ import { CompactLifecycle, runnerSupportsCompact, terminalReactionEmoji } from './compact.js';
15
+ import { JobWatchLifecycle } from './job-watch.js';
16
+ import { BashRunLifecycle } from './bash-run.js';
20
17
  import { QueueManager, } from './queue-manager.js';
21
18
  import { ApprovalActions } from './approval-actions.js';
22
19
  import { createRunApprovalCoordinator, remindPendingApproval } from './run-approval.js';
@@ -42,21 +39,6 @@ const MAX_CACHED_RUNNER_WORKSPACES = 10;
42
39
  * message forever.
43
40
  */
44
41
  const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
45
- /**
46
- * Emoji reaction per run terminal state, so a glance at the user's original
47
- * message distinguishes how the run ended. Unknown/non-terminal states fall
48
- * back to 'Done' (default).
49
- */
50
- const TERMINAL_REACTION_EMOJI = {
51
- error: 'ERROR',
52
- idle_timeout: 'Alarm',
53
- interrupted: 'SHHH',
54
- // 本轮结束但目标继续:任务还没完,不能打 ✓(Done)。
55
- goal_continuing: 'OnIt',
56
- };
57
- function terminalReactionEmoji(terminal) {
58
- return TERMINAL_REACTION_EMOJI[terminal] ?? 'Done';
59
- }
60
42
  /**
61
43
  * 完成通知卡的标题 / 配色 / 结果行。
62
44
  *
@@ -81,40 +63,36 @@ function completionNotificationPresentation(terminal) {
81
63
  return { title: '✅ 会话已完成', template: 'green', result: 'completed' };
82
64
  }
83
65
  }
84
- /**
85
- * 单点化:`'runCompact' in runner` 鸭子探测
86
- * codex app-server + kimi/opencode ACP runner 满足。
87
- */
88
- function runnerHasRunCompact(runner) {
89
- return ('runCompact' in runner && typeof runner.runCompact === 'function');
66
+ // --- approvalModeSettingsFor 的逐 agent 快照(字段口径单表 + 单函数) ---
67
+ // Map 查表避免 constructor/toString 之类键命中 Object.prototype;字段顺序
68
+ // 与原 switch 分支逐字一致。不碰 Bridge 实例状态,避免 this 绑定问题。
69
+ /** 各 agent 审批设置快照的字段口径(顺序 = 原分支逐字)。 */
70
+ const APPROVAL_SETTINGS_FIELDS = new Map([
71
+ ['codex', ['approvalPolicy', 'sandbox']],
72
+ ['kimi', ['permissionMode']],
73
+ ['opencode', ['mode']],
74
+ ['dsh', ['permissionPreset']],
75
+ ]);
76
+ function approvalSettingsFor(agents, kind) {
77
+ const fields = APPROVAL_SETTINGS_FIELDS.get(kind);
78
+ if (!fields)
79
+ return undefined;
80
+ const cfg = agents?.[kind];
81
+ if (!cfg)
82
+ return undefined;
83
+ const out = {};
84
+ for (const field of fields)
85
+ out[field] = cfg[field];
86
+ return out;
90
87
  }
91
- /**
92
- * 已告警过的能力不一致 kind。能力表(`COMPACT_CAPABILITY`)是声明,runner 方法
93
- * 是实现;两者不一致是开发期缺陷,只在首次冲突时打 error,避免每次渲染刷屏。
94
- */
95
- const compactMismatchWarned = new Set();
96
- /**
97
- * Compact 能力的双重门控:声明的能力表 && runner 实际实现。
98
- *
99
- * 只做鸭子探测会把「漏实现」降级成静默隐藏按钮(dsh 就这样漏了),所以声明表
100
- * 是必需项;只信声明表则可能渲染出点不动的死按钮,所以实现检查也保留。两者
101
- * 不一致时打 error 日志,让缺陷在运行日志里可见。
102
- */
103
- function runnerSupportsCompact(runner, kind) {
104
- const declared = supportsCompact(kind);
105
- const implemented = runnerHasRunCompact(runner);
106
- if (declared !== implemented && !compactMismatchWarned.has(kind)) {
107
- compactMismatchWarned.add(kind);
108
- getLogger().error(`[lark-remote] compact capability mismatch kind=${kind} declared=${declared} implemented=${implemented}`);
109
- }
110
- return declared && implemented;
88
+ /** runner 是否使用 live result 事件(显式传 runner + usageAuthority 状态)。 */
89
+ function computeLiveResultEvents(runner, usageAuthority) {
90
+ return runner.usesLiveResultEvents?.() ?? usageAuthority === 'live';
91
+ }
92
+ /** 待审批计数(startIdleWatchdog 的 pendingApprovals 回调,显式传 coordinators 表)。 */
93
+ function pendingApprovalCount(coordinators, runId) {
94
+ return coordinators.get(runId)?.pendingCount() ?? 0;
111
95
  }
112
- /**
113
- * 每个 cwd 保留的「可压缩 run」条数上限。Compact 按钮挂在终态 run 卡片上,
114
- * 卡片会被后续消息不断顶上去;无上限会让长跑进程随 run 数无界增长,而用户
115
- * 实际能点到的只有最近几张卡,20 条足以覆盖。
116
- */
117
- const MAX_COMPACTABLE_RUNS_PER_CWD = 20;
118
96
  /**
119
97
  * Bridge owns the per-workspace serial processing queue (§9.6) and the
120
98
  * single-card streaming forwarding loop (§9.12 watchdog + session sync).
@@ -149,7 +127,7 @@ export class Bridge {
149
127
  mediaHandler;
150
128
  /**
151
129
  * Per-workspace runner instances, keyed by (cwd, agentKind).
152
- * Fix 4 (2026-07-18): Each agentKind has its own cache slot, so switching
130
+ * Each agentKind has its own cache slot, so switching
153
131
  * defaultAgent creates a NEW runner without evicting the old one. This
154
132
  * eliminates the need for clearRunners() on agent switch, preventing the
155
133
  * regression where clearRunners() orphaned a running subprocess and
@@ -175,16 +153,11 @@ export class Bridge {
175
153
  coordinatorFor: (runId) => this.approvalCoordinators.get(runId),
176
154
  });
177
155
  /**
178
- * 最近若干次已完成的、runner 有 runCompact 的 run(cwd → runId → 记录)。
179
- * run 结束后 activeRuns 已清空,而终态卡上的 Compact 按钮只带 runId,
180
- * 需要它反查 sessionId + agentKind。
181
- *
182
- * 为什么按 runId 存而不是「每 cwd 只留最新一条」:compact 卡片动作走串行
183
- * 队列(会被正在跑的 run 挡住),点击到执行之间可能夹着别的 run 收尾。
184
- * 单槽会被覆盖 —— 旧卡片的压缩必然报「该任务已结束或不属于当前会话」,
185
- * 与 session 是否还在、能否压缩无关(2026-10-08 线上缺陷)。
156
+ * Compact 生命周期(见 compact.ts):可压缩 run 记账、终态卡 / resume 卡
157
+ * 两条 Compact 入口、kimi 在途检测与压缩流式卡。activeRuns 槽位表按引用
158
+ * 共用——压缩注册前确认槽位空闲,否则 /stop 停错对象。
186
159
  */
187
- compactableRuns = new Map();
160
+ compact;
188
161
  /**
189
162
  * 终态卡片的渲染快照(runId → state + options):run 结束后 activeRuns 已
190
163
  * 清空,而卡片上的「⏹ 停止」后台任务按钮(job.kill)还活着,需要按 runId
@@ -195,26 +168,17 @@ export class Bridge {
195
168
  runCardSnapshots = new Map();
196
169
  static RUN_CARD_SNAPSHOT_MAX = 8;
197
170
  /**
198
- * 终态卡后台任务订阅(runId → 中止句柄)。
199
- *
200
- * 与卡片快照同生命周期:快照被淘汰、任务全结束、patch 失败时退订。上限天然
201
- * 受 RUN_CARD_SNAPSHOT_MAX 约束(一张卡最多一条流)。
171
+ * 终态卡后台任务订阅与停止(见 job-watch.ts):job.kill、名单订阅与
172
+ * PATCH 串行链收在该模块;jobCardPatches 链内渲染读 snapshot.state,
173
+ * 「停止」与「订阅帧」并发不会把卡片写回旧内容。
202
174
  */
203
- jobWatches = new Map();
175
+ jobWatch;
204
176
  /**
205
- * 同一张卡的 patch 串行链(runId → 链尾)。
206
- *
207
- * 「停止按钮」和「订阅帧」都会改同一张终态卡:不串行化时两条 PATCH 并发,
208
- * 后发先至的那条会把卡片写回旧内容(任务又变回「运行中」)。
209
- */
210
- jobCardPatches = new Map();
211
- /**
212
- * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
213
- * queue and may run in parallel with claude runs / other bash runs in the same
214
- * cwd, so they live outside `activeRuns`. Tracked so `/stop` can reach
215
- * the per-run BashRunner instance via interruptCurrentRun.
177
+ * `!` bash 命令执行(见 bash-run.ts):独立 runId 空间、独立登记表,不进
178
+ * activeRuns;绝不 re-chain 到 QueueManager(会自等待死锁)。/stop 的 bash
179
+ * 侧匹配与中断也收在该模块,避免误杀并行跑的 `!` 任务。
216
180
  */
217
- activeBashRuns = new Map();
181
+ bashRuns;
218
182
  constructor(deps) {
219
183
  this.connector = deps.connector;
220
184
  this.sessionStore = deps.sessionStore;
@@ -228,11 +192,36 @@ export class Bridge {
228
192
  resolveCwd: (userId) => this.resolveCwd(userId),
229
193
  getConfig: () => this.config,
230
194
  });
195
+ this.compact = new CompactLifecycle({
196
+ activeRuns: this.activeRuns,
197
+ connector: this.connector,
198
+ sessionStore: this.sessionStore,
199
+ sessionReaderRegistry: this.sessionReaderRegistry,
200
+ getConfig: () => this.config,
201
+ getRunner: (cwd, kind, opts) => this.getRunner(cwd, kind, opts),
202
+ sendResult: (result, ctx, opts) => this.sendResult(result, ctx, opts),
203
+ startIdleWatchdog: (opts) => this.startIdleWatchdog(opts),
204
+ finishTurnReactions: (ctx, emoji) => this.finishTurnReactions(ctx, emoji),
205
+ resolveCwd: (userId) => this.resolveCwd(userId),
206
+ });
207
+ this.jobWatch = new JobWatchLifecycle({
208
+ getRunner: (cwd, kind) => this.getRunner(cwd, kind),
209
+ getRunCardSnapshot: (runId) => this.runCardSnapshots.get(runId),
210
+ updateCardInPlace: (card, ctx) => this.updateCardInPlace(card, ctx),
211
+ patchCard: (messageId, card) => this.patchCard(messageId, card),
212
+ });
213
+ this.bashRuns = new BashRunLifecycle({
214
+ connector: this.connector,
215
+ sessionStore: this.sessionStore,
216
+ resolveCwd: (userId) => this.resolveCwd(userId),
217
+ sendResult: (result, ctx) => this.sendResult(result, ctx),
218
+ finishTurnReactions: (ctx, emoji) => this.finishTurnReactions(ctx, emoji),
219
+ });
231
220
  }
232
221
  /**
233
222
  * Get or create a runner for the given cwd, keyed by current defaultAgent.
234
223
  *
235
- * Fix 4 (2026-07-18): Cache key includes agentKind, so switching defaultAgent
224
+ * Cache key includes agentKind, so switching defaultAgent
236
225
  * creates a NEW runner under a different key without evicting the old one.
237
226
  * This eliminates the need for clearRunners() on agent switch.
238
227
  */
@@ -317,38 +306,7 @@ export class Bridge {
317
306
  }
318
307
  /** Per-agent approval-mode settings snapshot from the current config. */
319
308
  approvalModeSettingsFor(kind) {
320
- const agents = this.config.agents;
321
- switch (kind) {
322
- case 'codex': {
323
- const codexConfig = agents?.codex;
324
- if (!codexConfig)
325
- return undefined;
326
- return {
327
- approvalPolicy: codexConfig.approvalPolicy,
328
- sandbox: codexConfig.sandbox,
329
- };
330
- }
331
- case 'kimi': {
332
- const kimiConfig = agents?.kimi;
333
- if (!kimiConfig)
334
- return undefined;
335
- return { permissionMode: kimiConfig.permissionMode };
336
- }
337
- case 'opencode': {
338
- const opencodeConfig = agents?.opencode;
339
- if (!opencodeConfig)
340
- return undefined;
341
- return { mode: opencodeConfig.mode };
342
- }
343
- case 'dsh': {
344
- const dshConfig = agents?.dsh;
345
- if (!dshConfig)
346
- return undefined;
347
- return { permissionPreset: dshConfig.permissionPreset };
348
- }
349
- default:
350
- return undefined;
351
- }
309
+ return approvalSettingsFor(this.config.agents, kind);
352
310
  }
353
311
  /** Get the current runner for a cwd. Used by router for agent-specific commands (.2). */
354
312
  getCurrentRunner(cwd) {
@@ -379,9 +337,9 @@ export class Bridge {
379
337
  */
380
338
  clearRunners() {
381
339
  // Skip workspaces with an active run: evicting their runner would orphan
382
- // the instance that owns the live subprocess (regression 2026-07-18 -- the
383
- // running runner became unreachable, stop() hit a fresh empty runner, the
384
- // real subprocess was never killed and the queue chain blocked forever).
340
+ // the instance that owns the live subprocess — the running runner would
341
+ // become unreachable, stop() would hit a fresh empty runner, the real
342
+ // subprocess would never be killed and the queue chain would block forever.
385
343
  // The running run already started with its (possibly now-stale) config, so
386
344
  // keeping it is correct; finalizeRun deletes the cache entry when the run
387
345
  // ends, and the next run picks up the new config/agent via getRunner.
@@ -645,25 +603,7 @@ export class Bridge {
645
603
  }
646
604
  /** Get all active bash runs with their state for /active command. */
647
605
  getActiveBashRuns() {
648
- const result = [];
649
- for (const [runId, bashRun] of this.activeBashRuns) {
650
- const state = bashRun.cardSession.currentState;
651
- // Only include non-terminal runs (bash has no finalizing state)
652
- if (state.terminal === 'running') {
653
- result.push({
654
- runId,
655
- cwd: bashRun.cwd,
656
- userId: bashRun.userId,
657
- chatId: bashRun.chatId,
658
- terminal: state.terminal,
659
- command: bashRun.command,
660
- });
661
- }
662
- }
663
- return result;
664
- }
665
- async interruptCurrentRun(input) {
666
- return (await this.interruptCurrentRunDetailed(input)).stopped;
606
+ return this.bashRuns.getActiveBashRuns();
667
607
  }
668
608
  /**
669
609
  * Same as {@link interruptCurrentRun}, plus what the runner's stop actually
@@ -719,45 +659,11 @@ export class Bridge {
719
659
  stop = stopResult;
720
660
  }
721
661
  }
722
- // Then check active bash runs (! commands). These bypass the serial queue
723
- // and are tracked separately; stop the per-run BashRunner directly.
724
- // `/stop`(无 runId、无 cwd)语义是「停掉这个会话的一切」,所以并行跑的
725
- // `!` 命令也要停——旧实现命中 claude run 就 return,这些 bash 会被漏掉。
726
- // 带范围的中断(停止卡 runId、queue.immediate 的 cwd)保持旧语义:只有
727
- // 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
728
- const cardScoped = input.runId !== undefined || input.cardMessageId !== undefined;
729
- const includeBashRuns = (input.runId === undefined && input.cardMessageId === undefined && input.cwd === undefined) ||
730
- activeTargets.length === 0;
731
- const bashTargets = includeBashRuns
732
- ? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
733
- if (input.cwd !== undefined && b.cwd !== input.cwd)
734
- return false;
735
- if (b.userId !== input.userId || b.chatId !== input.chatId)
736
- return false;
737
- if (input.runId) {
738
- if (runId === input.runId)
739
- return true;
740
- // Scoped card clicks may fall back only through their own card id.
741
- if (cardScoped)
742
- return false;
743
- }
744
- if (input.cardMessageId) {
745
- return b.cardSession.cardMessageId === input.cardMessageId;
746
- }
747
- return true;
748
- })
749
- : [];
750
- for (const [runId, b] of bashTargets) {
751
- getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
752
- await b.bashRunner.stop({ immediate: true });
753
- // Update card to show interrupted state. exitCode 不传(保持 null):
754
- // bash-renderer 的「已手动终止」文案要求 exitCode === null;传 -1 会让
755
- // 该分支不可达,中断卡退化成「退出码: -1」的失败样式。
756
- await b.cardSession.finish('interrupted');
757
- this.activeBashRuns.delete(runId);
758
- getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
759
- stoppedCount++;
760
- }
662
+ // Then check active bash runs (! commands). 实现在 bash-run.ts
663
+ // (bridge 结构治理第 3 刀):bash 用独立 runId 空间、独立登记表,匹配
664
+ // 与停止整体收在 stopMatchingBashRuns 里——`/stop` 语义与「不误杀并行
665
+ // `!` 任务」的守卫注释随迁。
666
+ stoppedCount += await this.bashRuns.stopMatchingBashRuns(input, activeTargets.length);
761
667
  if (stoppedCount === 0) {
762
668
  getLogger().warn(`[lark-remote] interrupt miss userId=${input.userId} ` +
763
669
  `requestedRunId=${input.runId ?? '(none)'} ` +
@@ -804,8 +710,8 @@ export class Bridge {
804
710
  // Stop the EXACT runner instance that is running (carried by activeRun),
805
711
  // not whatever getRunner(cwd) would return now. clearRunners() may have
806
712
  // emptied the cache mid-run, in which case getRunner creates a fresh empty
807
- // runner whose stop() is a no-op and the real subprocess is never killed
808
- // (regression 2026-07-18: queue chain blocked forever).
713
+ // runner whose stop() is a no-op and the real subprocess is never killed,
714
+ // blocking the queue chain forever.
809
715
  const runner = active.runner;
810
716
  // Bind the queue reset to THIS task's execution slot: the stop window
811
717
  // below may outlive the task's settle, letting the serial chain advance
@@ -813,18 +719,28 @@ export class Bridge {
813
719
  // unconditional reset would then zero the successor's count and mark
814
720
  // its slot interrupted, hiding it from queue cards.
815
721
  const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
816
- const [, stopResult] = await Promise.allSettled([
722
+ const [finishResult, stopResult] = await Promise.allSettled([
817
723
  // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
818
724
  // interruptedReason,与审批超时自动取消(approval_timeout)、审批
819
725
  // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
820
726
  active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
821
727
  runner.stop({ immediate: true }),
822
728
  ]);
823
- const stop = stopResult.status === 'fulfilled' &&
824
- stopResult.value !== null &&
825
- typeof stopResult.value === 'object'
826
- ? stopResult.value
827
- : undefined;
729
+ // allSettled 的两个结果都必须检查:finish 被飞书拒绝时卡片会永久停在
730
+ // 「运行中」;runner.stop 失败时进程可能没死而槽位已被释放——两者都
731
+ // 不能静默吞掉(此前第一个结果整体丢弃、stop 失败仍记 "stop done")。
732
+ if (finishResult.status === 'rejected') {
733
+ getLogger().error(`[lark-remote] stop: terminal card finish failed runId=${active.runId}:`, finishResult.reason);
734
+ }
735
+ let stop;
736
+ if (stopResult.status === 'rejected') {
737
+ getLogger().error(`[lark-remote] stop: runner.stop failed runId=${active.runId} — 运行可能未终止:`, stopResult.reason);
738
+ // 上报「可能仍在跑」,调用方按 unconfirmed 口径聚合(不承诺已停止)。
739
+ stop = { status: 'unconfirmed', detail: errorMessage(stopResult.reason) };
740
+ }
741
+ else {
742
+ stop = stopResult.value ?? undefined;
743
+ }
828
744
  // The chain may have advanced while the stop was in flight: a new run
829
745
  // (from a task that began during the stop window) may now occupy this
830
746
  // cwd. Only delete the entry we actually stopped.
@@ -840,8 +756,8 @@ export class Bridge {
840
756
  // interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
841
757
  // getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
842
758
  // 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
843
- // agent 侧会话写锁(codex "thread already has an active writer",
844
- // 线上实测)。改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
759
+ // agent 侧会话写锁(codex "thread already has an active writer")。
760
+ // 改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
845
761
  // 换新实例(见 getRunner 的 replaceStale),本次 run 的 finalize 收尾时
846
762
  // 也会消费该标记;两者都保证不会有活跃 run 使用被 dispose 的实例。
847
763
  // run-per-message runner 无长驻连接,保持立即 evict 不变。
@@ -863,7 +779,9 @@ export class Bridge {
863
779
  if (stoppedSlot !== undefined) {
864
780
  this.queueManager.resetExecutingCount(cwd, stoppedSlot);
865
781
  }
866
- getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
782
+ if (stop?.status !== 'unconfirmed') {
783
+ getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
784
+ }
867
785
  return stop;
868
786
  }
869
787
  /**
@@ -914,25 +832,22 @@ export class Bridge {
914
832
  /**
915
833
  * Deliver a final answer that cannot fit in the run card as a Markdown file
916
834
  * attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
917
- * (2026-11-02 定规:纯文本与卡片重复,也丢 Markdown 结构)。
835
+ * (纯文本与卡片重复,也丢 Markdown 结构)。
918
836
  *
919
- * 失败时发一张提示卡(不是纯文本),并把完整回答留在临时文件里记日志,
920
- * 便于事后找回。
837
+ * 发送成功即清理临时文件;失败时发一张提示卡(不是纯文本),完整回答
838
+ * 留在临时文件里(路径见错误日志),便于事后找回。
921
839
  */
922
840
  async sendFinalAnswerFile(text, ctx) {
923
841
  let filePath;
924
842
  try {
925
843
  filePath = writeFinalAnswerFile(text);
926
844
  await this.connector.sendFile(ctx.chatId, filePath);
845
+ bestEffortUnlink(filePath);
927
846
  }
928
847
  catch (err) {
929
848
  getLogger().error(`[lark-remote] failed to deliver complete final answer as file (kept at ${filePath ?? 'not written'}):`, err);
930
849
  await this.sendResult({ card: finalAnswerFileFailureCard(errorMessage(err)) }, ctx);
931
850
  }
932
- finally {
933
- if (filePath)
934
- bestEffortUnlink(filePath);
935
- }
936
851
  }
937
852
  /**
938
853
  * Update an existing card in place (原地更新) instead of sending a new one.
@@ -1005,138 +920,23 @@ export class Bridge {
1005
920
  this.runCardSnapshots.set(runId, snapshot);
1006
921
  while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
1007
922
  const oldest = this.runCardSnapshots.keys().next().value;
1008
- if (oldest === undefined)
1009
- break;
1010
923
  this.runCardSnapshots.delete(oldest);
1011
924
  // 卡片已经不在窗口里:继续订阅只是往一个没人看的 messageId 打 PATCH。
1012
- this.stopJobWatch(oldest);
925
+ this.jobWatch.stopJobWatch(oldest);
1013
926
  }
1014
927
  return snapshot;
1015
928
  }
1016
- /** 退订一张终态卡的后台任务名单(幂等)。 */
1017
- stopJobWatch(runId) {
1018
- this.jobWatches.get(runId)?.abort();
1019
- this.jobWatches.delete(runId);
1020
- this.jobCardPatches.delete(runId);
1021
- }
1022
- /**
1023
- * 停止终态卡上的一个后台任务。
1024
- *
1025
- * 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
1026
- * 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
1027
- * 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
1028
- * 会留在卡片上而任务早已结束。
1029
- */
929
+ // =============================================================================
930
+ // 后台任务停止与订阅
931
+ // =============================================================================
932
+ // 实现住在 job-watch.ts(bridge 结构治理第 2 刀):job.kill、名单订阅、
933
+ // markJobsUnknown 与 PATCH 串行链整体搬过去。这里只留 killBackgroundJob
934
+ // 薄转发——router 的 job.kill 卡片动作按名字分发到 bridge 公开 API;
935
+ // 快照淘汰(rememberRunCardSnapshot)与收尾订阅(finalizeRun)直接调
936
+ // this.jobWatch 的同名方法。
937
+ /** 停止终态卡上的一个后台任务(job.kill 卡片动作)。 */
1030
938
  async killBackgroundJob(runId, jobId, ctx) {
1031
- const snapshot = this.runCardSnapshots.get(runId);
1032
- const sessionId = snapshot?.state.sessionId;
1033
- if (!snapshot || !sessionId)
1034
- return false;
1035
- const runner = this.getRunner(snapshot.cwd, snapshot.agentKind);
1036
- if (typeof runner.killJob !== 'function')
1037
- return false;
1038
- const killed = await runner.killJob(sessionId, jobId);
1039
- if (!killed)
1040
- return false;
1041
- snapshot.state = removeBackgroundJob(snapshot.state, jobId);
1042
- // 与订阅帧共用一条串行链:两条 PATCH 并发时,后发先至的那条会把卡片写回
1043
- // 旧内容(刚停掉的任务又显示成「运行中」)。
1044
- await this.enqueueJobCardPatch(runId, async () => {
1045
- await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
1046
- return true;
1047
- });
1048
- // 卡片上已经没有在跑的任务:不必再等宿主推下一帧,直接退订。
1049
- if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
1050
- this.stopJobWatch(runId);
1051
- }
1052
- return true;
1053
- }
1054
- /**
1055
- * 让终态卡的后台任务小节跟着宿主继续更新。
1056
- *
1057
- * run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
1058
- * 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
1059
- * 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
1060
- * 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
1061
- */
1062
- startBackgroundJobWatch(runId, snapshot, runner) {
1063
- const live = (snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob);
1064
- if (!live)
1065
- return;
1066
- const sessionId = snapshot.state.sessionId;
1067
- const messageId = snapshot.messageId;
1068
- const watchJobs = runner.watchJobs;
1069
- if (!sessionId || !messageId || typeof watchJobs !== 'function') {
1070
- // 没有卡 id 就没有 patch 的落点:保留收尾快照,不另发一张卡。
1071
- getLogger().info(`[lark-remote] background jobs stay on the turn snapshot runId=${runId} ` +
1072
- `(sessionId=${sessionId ?? '-'} messageId=${messageId ?? '-'} watchable=${typeof watchJobs === 'function'})`);
1073
- return;
1074
- }
1075
- const controller = new AbortController();
1076
- this.jobWatches.set(runId, controller);
1077
- void this.consumeJobRoster(runId, snapshot, controller, (signal) => watchJobs.call(runner, sessionId, signal));
1078
- }
1079
- /** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
1080
- async consumeJobRoster(runId, snapshot, controller, watch) {
1081
- const messageId = snapshot.messageId;
1082
- if (!messageId)
1083
- return;
1084
- let allEnded = false;
1085
- try {
1086
- for await (const roster of watch(controller.signal)) {
1087
- const next = syncBackgroundJobs(snapshot.state, roster);
1088
- // 名单没变化就不打 PATCH:宿主可能重复推同一份。
1089
- if (next === snapshot.state)
1090
- continue;
1091
- snapshot.state = next;
1092
- const ok = await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
1093
- if (!ok) {
1094
- getLogger().warn(`[lark-remote] background job card update failed, stop watching runId=${runId}`);
1095
- return;
1096
- }
1097
- if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
1098
- allEnded = true;
1099
- return;
1100
- }
1101
- }
1102
- }
1103
- catch (err) {
1104
- if (!controller.signal.aborted) {
1105
- getLogger().warn(`[lark-remote] background job watch failed runId=${runId}: ${errorMessage(err)}`);
1106
- }
1107
- }
1108
- finally {
1109
- const externallyAborted = controller.signal.aborted;
1110
- controller.abort();
1111
- // 流断了而任务没结算:监控中断 ≠ 任务结束,标成未知而不是继续写「运行中」。
1112
- if (!allEnded && !externallyAborted)
1113
- await this.markJobsUnknown(runId, snapshot);
1114
- this.jobWatches.delete(runId);
1115
- this.jobCardPatches.delete(runId);
1116
- }
1117
- }
1118
- /** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
1119
- async markJobsUnknown(runId, snapshot) {
1120
- const next = markBackgroundJobsUnknown(snapshot.state);
1121
- if (next === snapshot.state)
1122
- return;
1123
- snapshot.state = next;
1124
- const messageId = snapshot.messageId;
1125
- if (!messageId)
1126
- return;
1127
- await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
1128
- }
1129
- /**
1130
- * 一张终态卡的 PATCH 串行链。
1131
- *
1132
- * 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
1133
- * 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
1134
- */
1135
- enqueueJobCardPatch(runId, task) {
1136
- const previous = this.jobCardPatches.get(runId) ?? Promise.resolve();
1137
- const next = previous.then(task, task);
1138
- this.jobCardPatches.set(runId, next.then(() => undefined, () => undefined));
1139
- return next;
939
+ return this.jobWatch.killBackgroundJob(runId, jobId, ctx);
1140
940
  }
1141
941
  /** Send a file to the user via Feishu. */
1142
942
  async sendFile(filePath, ctx) {
@@ -1210,7 +1010,7 @@ export class Bridge {
1210
1010
  // sessionId 钉死语义——binding 有 sessionId 时用它(入队时刻快照),
1211
1011
  // 否则跟随 live store(无 binding 或入队时无 session 的正常路径)。
1212
1012
  const sessionId = opts?.binding?.sessionId ?? this.sessionStore.getSessionId(ctx.userId, agentKind);
1213
- // 会话代际快照(2026-08-09,2026-10-07 前移):/new、/cd、/resume 会 bump
1013
+ // 会话代际快照:/new、/cd、/resume 会 bump
1214
1014
  // epoch,该 run 后续 system.init 的 sessionId 写回即判 stale 跳过。
1215
1015
  // 快照点优先取 binding.sessionEpoch(入队/commit 时刻,与 sessionId 同刻):
1216
1016
  // 排队消息在「commit 后、开跑前」被 reset 时,执行起点捕获会漏判(epoch 已是
@@ -1220,7 +1020,7 @@ export class Bridge {
1220
1020
  // Resolve the runner BEFORE creating the run session so the exact instance
1221
1021
  // that will run is captured into activeRun. interruptCurrentRun stops THAT
1222
1022
  // instance directly -- never a fresh runner re-resolved from a cache that
1223
- // clearRunners() may have emptied mid-run (regression 2026-07-18).
1023
+ // clearRunners() may have emptied mid-run.
1224
1024
  const runner = this.getRunner(cwd, agentKind, { replaceStale: true });
1225
1025
  // Step 1: Create run session (runId, cardSession, activeRun tracking)
1226
1026
  const { runId, cardSession, activeRun } = this.createRunSession(ctx, cwd, runner, agentKind);
@@ -1252,7 +1052,7 @@ export class Bridge {
1252
1052
  *
1253
1053
  * DSH 把上游 `finish_reason: content_filter` 归一成 PI_AI_ERROR:既不在默认
1254
1054
  * 重试码里(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT),也不会换模型,
1255
- * 于是一次(多数是误报的)判定就把整个 turn 判死。2026-10-21 现场:60 步、
1055
+ * 于是一次(多数是误报的)判定就把整个 turn 判死。60 步、
1256
1056
  * 10 分钟的 turn 在最后一步被截断,已写入的文件还在,用户只看到「运行出错」
1257
1057
  * 加一句英文报文。
1258
1058
  *
@@ -1371,12 +1171,23 @@ export class Bridge {
1371
1171
  }
1372
1172
  stop();
1373
1173
  getLogger().warn(`[lark-remote] claude idle timeout, stopping process runId=${opts.runId}`);
1174
+ // 看门狗清理的两个结果都必须留痕:卡片 patch 失败时终态会停在
1175
+ // 「运行中」;runner.stop 失败时挂死的 run 没被杀、cwd 槽位未释放,
1176
+ // 串行队列从此永久堵死(用户视角「机器人不回消息」,日志却只有
1177
+ // 前面一条 warn)。对照 claude session.ts idle timer 的 .catch(log)。
1374
1178
  void Promise.allSettled([
1375
1179
  opts.session.finish('idle_timeout', {
1376
1180
  idleTimeoutMinutes: Math.max(1, Math.round(this.idleTimeoutMs / 60_000)),
1377
1181
  }),
1378
1182
  opts.runner.stop(),
1379
- ]);
1183
+ ]).then(([finishResult, stopResult]) => {
1184
+ if (finishResult.status === 'rejected') {
1185
+ getLogger().error(`[lark-remote] idle-timeout cleanup failed runId=${opts.runId} finish:`, finishResult.reason);
1186
+ }
1187
+ if (stopResult.status === 'rejected') {
1188
+ getLogger().error(`[lark-remote] idle-timeout cleanup failed runId=${opts.runId} stop:`, stopResult.reason);
1189
+ }
1190
+ });
1380
1191
  }, tickMs);
1381
1192
  }
1382
1193
  return {
@@ -1395,7 +1206,7 @@ export class Bridge {
1395
1206
  * result 在 system.init 之后才到。
1396
1207
  *
1397
1208
  * 例外:app-server runner(usageAuthority==='live')的 init 是 runner 自己补发
1398
- * 的 synthetic init(2026-08-13 修复)。此分支保留为防御性兜底:live runner 若
1209
+ * 的 synthetic init。此分支保留为防御性兜底:live runner 若
1399
1210
  * 在无 init 时发出 result(旧构建等),session 写回照常执行,但 sawResult 不置
1400
1211
  * 真——卡片不会把半初始化的 run 当作已收尾。
1401
1212
  *
@@ -1474,7 +1285,7 @@ export class Bridge {
1474
1285
  const { cardSession, usage, sawResult, sawAnyResult, pendingPreInitTerminal, agentKind, runId, } = opts;
1475
1286
  const finalState = cardSession.currentState;
1476
1287
  if (finalState.terminal === 'finalizing') {
1477
- // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
1288
+ // 三态终态映射:interrupted(审批超时/取消、/stop)是独立
1478
1289
  // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1479
1290
  const subtype = finalState.resultSubtype;
1480
1291
  const isError = subtype === 'error' || !!finalState.errorMsg;
@@ -1540,203 +1351,259 @@ export class Bridge {
1540
1351
  // 'jsonl'。result 事件的 live 语义(pre-init/写回)单独声明,不能和
1541
1352
  // 用量来源共用一个字段。
1542
1353
  const usageAuthority = runner.getUsageAuthority?.();
1543
- const liveResultEvents = runner.usesLiveResultEvents?.() ?? usageAuthority === 'live';
1544
- // §9.12 空闲看门狗:判定口径见 startIdleWatchdog;武装点在 cardSession.start() 之后。
1545
- let watchdog = null;
1546
- let sawResult = false;
1547
- /** 收到过任何 result(含被守卫丢弃/未进卡片的):收尾报文据此措辞。 */
1548
- let sawAnyResult = false;
1549
- // A runner can fail before emitting its synthetic system/init (e.g. DSH
1550
- // session/create -> 401). The pre-init guard below drops that result to
1551
- // protect Claude resume replay, but if the stream ends without ever seeing
1552
- // init the result was the only terminal signal we had. Keep it so the card
1553
- // shows the real error instead of the generic "no result" fallback.
1554
- let pendingPreInitTerminal;
1555
- const usage = new RunUsageCollector();
1354
+ const liveResultEvents = computeLiveResultEvents(runner, usageAuthority);
1355
+ // 运行期状态打包成显式状态对象传给各抽出方法(等价替换原闭包捕获的局部变量)。
1356
+ const run = {
1357
+ runner,
1358
+ cardSession,
1359
+ activeRun,
1360
+ sessionId,
1361
+ sessionEpochAtStart,
1362
+ ctx,
1363
+ cwd,
1364
+ runId,
1365
+ message,
1366
+ agentKind,
1367
+ usage: new RunUsageCollector(),
1368
+ usageAuthority,
1369
+ liveResultEvents,
1370
+ state: {
1371
+ watchdog: null,
1372
+ sawResult: false,
1373
+ sawAnyResult: false,
1374
+ pendingPreInitTerminal: undefined,
1375
+ },
1376
+ };
1556
1377
  try {
1557
- try {
1558
- // queue.immediate 的 run-start hold:目标槽位可能在 handler 发最终
1559
- // toast 之前就 begin(interrupt/PATCH 的 await 窗口)。activeRun 已
1560
- // 登记(队列语义照旧),只把建卡这一步挡到 toast 之后——run 卡片才是
1561
- // 点击后最后一条新消息,聊天列表预览跟随它的标题变化。
1562
- await this.queueManager.awaitTaskRunStart(cwd);
1563
- getLogger().debug(`[lark-remote] cardSession.start() begin runId=${runId}`);
1564
- await cardSession.start();
1565
- getLogger().info(`[lark-remote] cardSession.start() ok runId=${runId}`);
1566
- }
1567
- catch (err) {
1568
- getLogger().warn('[lark-remote] card stream unavailable, will use static fallback:', err);
1569
- // Card stream failed - continue with static fallback below, but ensure
1570
- // we don't silently fail if the fallback also fails (covered in catch below).
1571
- }
1572
- // /stop(或队列「立即执行」的中断)可能落在建卡窗口内:卡片已定格为
1573
- // 终态,但 runner 还没开始跑,runner.stop() 也没有进程可杀——spawn 型
1574
- // 与连接型 runner 的 stop() 在 run() 之前都是空操作,且 run() 入口会
1575
- // 重置停止标记。必须在这里收口,否则用户看到「已中断」,agent 仍会把
1576
- // 这一轮跑完并改写工作区。finalizeRun 仍会照常收尾(settle / reaction)。
1577
- if (isRunTerminal(cardSession.currentState.terminal)) {
1578
- getLogger().info(`[lark-remote] card terminal before run start, skip runner runId=${runId} terminal=${cardSession.currentState.terminal}`);
1579
- return;
1580
- }
1581
- // 单 interval 看门狗。事件到来时只刷新时间戳(无 timer 操作);
1582
- // interval 周期检查静默时长,超时才 finish('idle_timeout') + stop()。
1583
- // 武装点在建卡之后,与原实现一致(宽限期不含 cardSession.start() 耗时)。
1584
- watchdog = this.startIdleWatchdog({
1585
- runId,
1586
- session: cardSession,
1587
- runner,
1588
- pendingApprovals: () => this.approvalCoordinators.get(runId)?.pendingCount() ?? 0,
1589
- });
1590
- getLogger().info(`[lark-remote] runner.run() begin runId=${runId} message=${message.slice(0, 100)}`);
1591
- // Extract agent-specific options from config
1592
- const agentOpts = this.getAgentRunOptions(agentKind);
1593
- const runOpts = { cwd, sessionId, ...agentOpts };
1594
- // Track whether system.init has been received for this run.
1595
- // Claude CLI --resume emits a stale result (from the previous turn)
1596
- // before sending system.init; bridge must not treat that as the run
1597
- // ending (sawResult, usage capture, or cardSession.push would all
1598
- // be wrong for the historical event).
1599
- let sawInit = false;
1600
- const writeback = {
1601
- sessionStore: this.sessionStore,
1602
- userId: ctx.userId,
1603
- agentKind,
1604
- cwd,
1605
- sessionEpochAtStart,
1606
- runId,
1607
- };
1608
- for await (const event of runner.run(message, runOpts)) {
1609
- // 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
1610
- // 这样在 result 事件后,后台任务输出仍能被正确处理
1611
- const terminal = cardSession.currentState.terminal;
1612
- if (isRunTerminal(terminal)) {
1613
- continue;
1614
- }
1615
- if (event.type === 'system' && event.subtype === 'init') {
1616
- sawInit = true;
1617
- // 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
1618
- // 结束后的 usage 读取仍以它为准。
1619
- activeRun.sessionId = event.session_id;
1620
- writeBackInitSession(writeback, event);
1621
- getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
1622
- }
1623
- if (event.type === 'turn_started') {
1624
- // app-server runner 在 turn setup 成功后补发 synthetic system.init
1625
- // (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
1626
- // threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
1627
- // 都新建线程(thread/start 而非 thread/resume),会话无法延续。
1628
- // init 缺失时以 threadId 作为本轮身份兜底。
1629
- activeRun.sessionId ??= event.threadId;
1630
- writeBackTurnStarted(writeback, event);
1631
- }
1632
- // Track context length from compaction events
1633
- if (event.type === 'system' &&
1634
- event.subtype === 'compact_boundary' &&
1635
- event.compactMetadata) {
1636
- usage.observeCompaction(event.compactMetadata.postTokens);
1637
- }
1638
- if (event.type === 'result') {
1639
- const outcome = this.observeResultEvent(event, {
1640
- sawInit,
1641
- liveResultEvents,
1642
- usage,
1643
- writeback,
1644
- runId,
1645
- });
1646
- if (outcome.preInitTerminal)
1647
- pendingPreInitTerminal ??= outcome.preInitTerminal;
1648
- if (outcome.sawResult)
1649
- sawResult = true;
1650
- sawAnyResult = true;
1651
- // 陈旧 result 不进卡片:run-state reducer 本来也会因 sessionId 未定而
1652
- // 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
1653
- if (outcome.dropFromCard) {
1654
- // 仍然重置 idle 计时——CLI 活着并且在产事件。
1655
- watchdog?.touch();
1656
- continue;
1657
- }
1658
- }
1659
- // Approval events (Codex app-server mode)
1660
- if (event.type === 'approval_requested' ||
1661
- event.type === 'approval_resolved' ||
1662
- event.type === 'approval_view_updated') {
1663
- this.dispatchApprovalEvent(event, { runId, runner, cardSession, ctx, agentKind });
1664
- }
1665
- // §9.12 红线:每个事件(含 result)无条件刷新
1666
- // lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
1667
- // 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
1668
- // idle_timeout。旧实现 `else { resetIdle(); }` 漏掉 result 分支。
1669
- watchdog?.touch();
1670
- await cardSession.push(event);
1671
- }
1672
- getLogger().info(`[lark-remote] runner stream end runId=${runId} sawResult=${sawResult}`);
1673
- // §9.12 红线:for-await 自然结束 = CLI 已退出,
1674
- // 看门狗使命结束,立即摘除 interval(不再等 finally),堵掉「stream 已结束、
1675
- // 收尾处理跨过死线被误标 idle_timeout」的同源小窗口(随本修复一并关闭)。
1676
- watchdog?.stop();
1677
- // stream 结束 = CLI 退出 = jsonl 已落盘。读 jsonl 拿准确的 contextLength + compactCount:
1678
- // live stream-json 不发 compact_boundary,live contextLength 会被 result 的 input+output 兜底失真。
1679
- // 但 result=error 时跳过: 进程可能立即退出未写新 usage (如 kimi 0.26+ 拒绝 -p+--auto
1680
- // 立即 exit=1), 此时 jsonl 里是上一次成功 turn 的历史 usage, 会被误当作本次 usage 显示,
1681
- // 误导用户以为本次出错的请求也消耗了 token。error 时只用 live 捕获的 usage (若有)。
1682
- // 本轮使用的 session:run 期间 /new //resume //cd 会移动全局
1683
- // 指针,结束时再读 store 会把别条会话的 token/context 显示到这张卡上。
1684
- // 优先级:卡片状态(本轮 init 写入)→ activeRun(本轮 init/turn_started)
1685
- // → 本轮开始时捕获的 sessionId。三者都没有就跳过 usage,绝不回读全局指针。
1686
- const finalSessionId = cardSession.currentState.sessionId ?? activeRun.sessionId ?? sessionId;
1687
- const finalUsage = usage.resultNotSuccess
1688
- ? undefined
1689
- : resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, finalSessionId, cwd, agentKind);
1690
- usage.finalize(finalUsage, usageAuthority);
1691
- getLogger().info(usage.logSummary(runId));
1692
- // for-await 自然结束 = stream 关闭 = CLI 进程退出 → 终态矩阵
1693
- await this.finishRunAfterStream({
1694
- cardSession,
1695
- usage,
1696
- sawResult,
1697
- sawAnyResult,
1698
- pendingPreInitTerminal,
1699
- agentKind,
1700
- runId,
1701
- });
1378
+ await this.executeStream(run);
1702
1379
  }
1703
1380
  catch (err) {
1704
- getLogger().error(`[lark-remote] claude run error runId=${runId}:`, err);
1705
- // 双保险:run() 的 finally 已负责杀子进程,但 catch 路径再补一次
1706
- // runner.stop()(与 bash 路径对齐),覆盖任何 finally 之外的残留窗口。
1707
- try {
1708
- await runner.stop();
1381
+ await this.handleStreamFailure(err, run);
1382
+ }
1383
+ finally {
1384
+ this.cleanupStream(run);
1385
+ }
1386
+ }
1387
+ /**
1388
+ * runAgentStreamToEnd 的 try 主体(建卡 → 事件循环 → 收尾),抽成独立方法
1389
+ * 降低外层圈复杂度;state 为跨方法共享的可变状态。逻辑与文案逐字保留。
1390
+ */
1391
+ async executeStream(run) {
1392
+ const { runner, cardSession, activeRun, sessionId, sessionEpochAtStart, ctx, cwd, runId, message, agentKind, usage, usageAuthority, liveResultEvents, state, } = run;
1393
+ try {
1394
+ // queue.immediate 的 run-start hold:目标槽位可能在 handler 发最终
1395
+ // toast 之前就 begin(interrupt/PATCH 的 await 窗口)。activeRun 已
1396
+ // 登记(队列语义照旧),只把建卡这一步挡到 toast 之后——run 卡片才是
1397
+ // 点击后最后一条新消息,聊天列表预览跟随它的标题变化。
1398
+ await this.queueManager.awaitTaskRunStart(cwd);
1399
+ getLogger().debug(`[lark-remote] cardSession.start() begin runId=${runId}`);
1400
+ await cardSession.start();
1401
+ getLogger().info(`[lark-remote] cardSession.start() ok runId=${runId}`);
1402
+ }
1403
+ catch (err) {
1404
+ getLogger().warn('[lark-remote] card stream unavailable, will use static fallback:', err);
1405
+ // Card stream failed - continue with static fallback below, but ensure
1406
+ // we don't silently fail if the fallback also fails (covered in catch below).
1407
+ }
1408
+ // /stop(或队列「立即执行」的中断)可能落在建卡窗口内:卡片已定格为
1409
+ // 终态,但 runner 还没开始跑,runner.stop() 也没有进程可杀——spawn 型
1410
+ // 与连接型 runner 的 stop() 在 run() 之前都是空操作,且 run() 入口会
1411
+ // 重置停止标记。必须在这里收口,否则用户看到「已中断」,agent 仍会把
1412
+ // 这一轮跑完并改写工作区。finalizeRun 仍会照常收尾(settle / reaction)。
1413
+ if (isRunTerminal(cardSession.currentState.terminal)) {
1414
+ getLogger().info(`[lark-remote] card terminal before run start, skip runner runId=${runId} terminal=${cardSession.currentState.terminal}`);
1415
+ return;
1416
+ }
1417
+ // 单 interval 看门狗。事件到来时只刷新时间戳(无 timer 操作);
1418
+ // interval 周期检查静默时长,超时才 finish('idle_timeout') + stop()。
1419
+ // 武装点在建卡之后,与原实现一致(宽限期不含 cardSession.start() 耗时)。
1420
+ state.watchdog = this.startIdleWatchdog({
1421
+ runId,
1422
+ session: cardSession,
1423
+ runner,
1424
+ pendingApprovals: pendingApprovalCount.bind(null, this.approvalCoordinators, runId),
1425
+ });
1426
+ getLogger().info(`[lark-remote] runner.run() begin runId=${runId} message=${message.slice(0, 100)}`);
1427
+ // Extract agent-specific options from config
1428
+ const agentOpts = this.getAgentRunOptions(agentKind);
1429
+ const runOpts = { cwd, sessionId, ...agentOpts };
1430
+ // Track whether system.init has been received for this run.
1431
+ // Claude CLI --resume emits a stale result (from the previous turn)
1432
+ // before sending system.init; bridge must not treat that as the run
1433
+ // ending (sawResult, usage capture, or cardSession.push would all
1434
+ // be wrong for the historical event).
1435
+ let sawInit = false;
1436
+ const writeback = {
1437
+ sessionStore: this.sessionStore,
1438
+ userId: ctx.userId,
1439
+ agentKind,
1440
+ cwd,
1441
+ sessionEpochAtStart,
1442
+ runId,
1443
+ };
1444
+ for await (const event of runner.run(message, runOpts)) {
1445
+ // 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
1446
+ // 这样在 result 事件后,后台任务输出仍能被正确处理
1447
+ const terminal = cardSession.currentState.terminal;
1448
+ if (isRunTerminal(terminal)) {
1449
+ continue;
1709
1450
  }
1710
- catch (stopErr) {
1711
- getLogger().warn(`[lark-remote] runner stop failed:`, stopErr);
1451
+ // 归一化:init/turn_started 的 session 写回 + compact_boundary 用量观察。
1452
+ // 返回 true = 本轮 system.init 已到(调用方置 sawInit,供 pre-init 守卫用)。
1453
+ if (this.maybeSyncSession(event, { activeRun, writeback, usage, runId })) {
1454
+ sawInit = true;
1712
1455
  }
1713
- // 终态守卫:running 或 finalizing(非终态)-> 转 error;
1714
- // 已终态(interrupted/idle_timeout/done/error)-> 保留首终态,仅补充 usage。
1715
- const catchTerminal = cardSession.currentState.terminal;
1716
- if (isRunActive(catchTerminal)) {
1717
- await cardSession.finish('error', {
1718
- ...usage.meta(),
1719
- inputTokens: usage.liveInputTokens,
1720
- outputTokens: usage.liveOutputTokens,
1721
- errorMsg: errorMessage(err),
1456
+ // pre-init result 守卫(§9.22,observeResultEvent):resume 重放的陈旧
1457
+ // result 不置 sawResult、不进卡片。
1458
+ if (event.type === 'result') {
1459
+ const outcome = this.observeResultEvent(event, {
1460
+ sawInit,
1461
+ liveResultEvents,
1462
+ usage,
1463
+ writeback,
1464
+ runId,
1722
1465
  });
1723
- }
1724
- else {
1725
- getLogger().info(`[lark-remote] skip error finish: state already terminal (${catchTerminal}) runId=${runId}`);
1726
- if (sawResult) {
1727
- await cardSession.finish(catchTerminal, usage.meta());
1466
+ if (outcome.preInitTerminal)
1467
+ state.pendingPreInitTerminal ??= outcome.preInitTerminal;
1468
+ if (outcome.sawResult)
1469
+ state.sawResult = true;
1470
+ state.sawAnyResult = true;
1471
+ // 陈旧 result 不进卡片:run-state reducer 本来也会因 sessionId 未定而
1472
+ // 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
1473
+ if (outcome.dropFromCard) {
1474
+ // 仍然重置 idle 计时——CLI 活着并且在产事件。
1475
+ this.handleEventForWatchdog(state.watchdog);
1476
+ continue;
1728
1477
  }
1729
1478
  }
1479
+ // Approval events (Codex app-server mode)。审批先到、item/started 后到的
1480
+ // 乱序流也在此补全(dispatchApprovalEvent 内 updateView)。
1481
+ if (event.type === 'approval_requested' ||
1482
+ event.type === 'approval_resolved' ||
1483
+ event.type === 'approval_view_updated') {
1484
+ this.dispatchApprovalEvent(event, { runId, runner, cardSession, ctx, agentKind });
1485
+ }
1486
+ // §9.12 红线:每个事件(含 result)无条件刷新
1487
+ // lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
1488
+ // 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
1489
+ // idle_timeout。旧实现 `else { resetIdle(); }` 漏掉 result 分支。
1490
+ this.handleEventForWatchdog(state.watchdog);
1491
+ // 渲染:reducer(run-state)在 cardSession.push 内部。
1492
+ await cardSession.push(event);
1493
+ }
1494
+ getLogger().info(`[lark-remote] runner stream end runId=${runId} sawResult=${state.sawResult}`);
1495
+ // §9.12 红线:for-await 自然结束 = CLI 已退出,
1496
+ // 看门狗使命结束,立即摘除 interval(不再等 finally),堵掉「stream 已结束、
1497
+ // 收尾处理跨过死线被误标 idle_timeout」的同源小窗口(随本修复一并关闭)。
1498
+ state.watchdog?.stop();
1499
+ // stream 结束 = CLI 退出 = jsonl 已落盘。读 jsonl 拿准确的 contextLength + compactCount:
1500
+ // live stream-json 不发 compact_boundary,live contextLength 会被 result 的 input+output 兜底失真。
1501
+ // 但 result=error 时跳过: 进程可能立即退出未写新 usage (如 kimi 0.26+ 拒绝 -p+--auto
1502
+ // 立即 exit=1), 此时 jsonl 里是上一次成功 turn 的历史 usage, 会被误当作本次 usage 显示,
1503
+ // 误导用户以为本次出错的请求也消耗了 token。error 时只用 live 捕获的 usage (若有)。
1504
+ // 本轮使用的 session:run 期间 /new //resume //cd 会移动全局
1505
+ // 指针,结束时再读 store 会把别条会话的 token/context 显示到这张卡上。
1506
+ // 优先级:卡片状态(本轮 init 写入)→ activeRun(本轮 init/turn_started)
1507
+ // → 本轮开始时捕获的 sessionId。三者都没有就跳过 usage,绝不回读全局指针。
1508
+ const finalSessionId = cardSession.currentState.sessionId ?? activeRun.sessionId ?? sessionId;
1509
+ const finalUsage = usage.resultNotSuccess
1510
+ ? undefined
1511
+ : resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, finalSessionId, cwd, agentKind);
1512
+ usage.finalize(finalUsage, usageAuthority);
1513
+ getLogger().info(usage.logSummary(runId));
1514
+ // for-await 自然结束 = stream 关闭 = CLI 进程退出 → 终态矩阵
1515
+ await this.finishRunAfterStream({
1516
+ cardSession,
1517
+ usage,
1518
+ sawResult: state.sawResult,
1519
+ sawAnyResult: state.sawAnyResult,
1520
+ pendingPreInitTerminal: state.pendingPreInitTerminal,
1521
+ agentKind,
1522
+ runId,
1523
+ });
1524
+ }
1525
+ /** runAgentStreamToEnd 的 catch 主体(抽成独立方法,state.sawResult 沿用循环累计值)。 */
1526
+ async handleStreamFailure(err, run) {
1527
+ const { runner, cardSession, runId, usage, state } = run;
1528
+ getLogger().error(`[lark-remote] claude run error runId=${runId}:`, err);
1529
+ // 双保险:run() 的 finally 已负责杀子进程,但 catch 路径再补一次
1530
+ // runner.stop()(与 bash 路径对齐),覆盖任何 finally 之外的残留窗口。
1531
+ try {
1532
+ await runner.stop();
1730
1533
  }
1731
- finally {
1732
- watchdog?.stop();
1733
- const coordinator = this.approvalCoordinators.get(runId);
1734
- if (coordinator) {
1735
- coordinator.onTurnEnded();
1736
- this.approvalCoordinators.delete(runId);
1534
+ catch (stopErr) {
1535
+ getLogger().warn(`[lark-remote] runner stop failed:`, stopErr);
1536
+ }
1537
+ // 终态守卫:running 或 finalizing(非终态)-> 转 error;
1538
+ // 已终态(interrupted/idle_timeout/done/error)-> 保留首终态,仅补充 usage。
1539
+ const catchTerminal = cardSession.currentState.terminal;
1540
+ if (isRunActive(catchTerminal)) {
1541
+ await cardSession.finish('error', {
1542
+ ...usage.meta(),
1543
+ inputTokens: usage.liveInputTokens,
1544
+ outputTokens: usage.liveOutputTokens,
1545
+ errorMsg: errorMessage(err),
1546
+ });
1547
+ }
1548
+ else {
1549
+ getLogger().info(`[lark-remote] skip error finish: state already terminal (${catchTerminal}) runId=${runId}`);
1550
+ if (state.sawResult) {
1551
+ await cardSession.finish(catchTerminal, usage.meta());
1737
1552
  }
1738
1553
  }
1739
1554
  }
1555
+ /** runAgentStreamToEnd 的 finally 主体(抽成独立方法)。 */
1556
+ cleanupStream(run) {
1557
+ const { runId, state } = run;
1558
+ state.watchdog?.stop();
1559
+ const coordinator = this.approvalCoordinators.get(runId);
1560
+ if (coordinator) {
1561
+ coordinator.onTurnEnded();
1562
+ this.approvalCoordinators.delete(runId);
1563
+ }
1564
+ }
1565
+ /**
1566
+ * 事件循环的看门狗喂狗(runAgentStreamToEnd 抽出的纯向方法)。
1567
+ *
1568
+ * §9.12:事件到达只 `touch()` 刷新时间戳(无 timer 操作),interval 周期检查
1569
+ * 静默时长;喂狗点与红线口径见 runAgentStreamToEnd 循环内两处调用点注释
1570
+ * (每个事件含 result 都必须喂,被守卫丢弃的 result 也喂——CLI 活着就在产事件)。
1571
+ */
1572
+ handleEventForWatchdog(watchdog) {
1573
+ watchdog?.touch();
1574
+ }
1575
+ /**
1576
+ * 事件循环的 session 同步(runAgentStreamToEnd 抽出的纯向方法):
1577
+ * init / turn_started 两写回点 + compact_boundary 用量观察。
1578
+ *
1579
+ * @returns 事件是否为本轮 `system.init`(调用方据此置 sawInit,供 pre-init
1580
+ * result 守卫判定)。init 分支的全部副作用完成后才返回,与原内联顺序一致。
1581
+ */
1582
+ maybeSyncSession(event, opts) {
1583
+ const { activeRun, writeback, usage, runId } = opts;
1584
+ if (event.type === 'system' && event.subtype === 'init') {
1585
+ // 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
1586
+ // 结束后的 usage 读取仍以它为准。
1587
+ activeRun.sessionId = event.session_id;
1588
+ writeBackInitSession(writeback, event);
1589
+ getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
1590
+ return true;
1591
+ }
1592
+ if (event.type === 'turn_started') {
1593
+ // app-server runner 在 turn setup 成功后补发 synthetic system.init
1594
+ // (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
1595
+ // threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
1596
+ // 都新建线程(thread/start 而非 thread/resume),会话无法延续。
1597
+ // init 缺失时以 threadId 作为本轮身份兜底。
1598
+ activeRun.sessionId ??= event.threadId;
1599
+ writeBackTurnStarted(writeback, event);
1600
+ }
1601
+ // Track context length from compaction events
1602
+ if (event.type === 'system' && event.subtype === 'compact_boundary' && event.compactMetadata) {
1603
+ usage.observeCompaction(event.compactMetadata.postTokens);
1604
+ }
1605
+ return false;
1606
+ }
1740
1607
  /**
1741
1608
  * 终态 reaction 收尾:撤掉 Typing、打上下方终态表情。
1742
1609
  *
@@ -1806,7 +1673,7 @@ export class Bridge {
1806
1673
  this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
1807
1674
  // 后台任务跨 turn:卡片收尾只是"这一轮的投影结束了",任务本身可能还在跑。
1808
1675
  // 订阅放在最后:卡片已落地,patch 才有落点。
1809
- this.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
1676
+ this.jobWatch.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
1810
1677
  }
1811
1678
  finally {
1812
1679
  // cleanup must survive mid-finalize errors. The only unguarded
@@ -1822,7 +1689,7 @@ export class Bridge {
1822
1689
  // runId,只能回「该任务已结束或不属于当前会话」。守卫只回答「cwd 槽位还
1823
1690
  // 归不归本 run」(决定能不能删槽位/evict runner),记账回答「这次 run
1824
1691
  // 结束了吗、用的哪个 sessionId」——后者与所有权无关,放在守卫外。
1825
- this.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
1692
+ this.compact.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
1826
1693
  if (this.activeRuns.get(cwd) === activeRun) {
1827
1694
  this.activeRuns.delete(cwd);
1828
1695
  // Workspace-lifetime runners (codex app-server) keep their persistent
@@ -1920,498 +1787,31 @@ export class Bridge {
1920
1787
  async handleApprovalAnswerNote(opts) {
1921
1788
  return this.approvalActions.handleApprovalAnswerNote(opts);
1922
1789
  }
1923
- /**
1924
- * 记下一次「可压缩 run」:终态卡上的 Compact 按钮点击时据此反查
1925
- * sessionId + agentKind。runId 是按钮自带的唯一键,同 cwd 内唯一。
1926
- * 超出上限按插入序淘汰最旧的一条(Map 保持插入序;重复写同一 runId
1927
- * 先 delete 再 set 以刷新位置)。
1928
- */
1929
- recordCompactableRun(cwd, runId, sessionId, agentKind) {
1930
- let byRunId = this.compactableRuns.get(cwd);
1931
- if (!byRunId) {
1932
- byRunId = new Map();
1933
- this.compactableRuns.set(cwd, byRunId);
1934
- }
1935
- byRunId.delete(runId);
1936
- byRunId.set(runId, { sessionId, agentKind, cwd });
1937
- while (byRunId.size > MAX_COMPACTABLE_RUNS_PER_CWD) {
1938
- const oldest = byRunId.keys().next().value;
1939
- if (oldest === undefined)
1940
- break;
1941
- byRunId.delete(oldest);
1942
- }
1943
- }
1944
- /**
1945
- * 终态 Compact 记账:把本次 run 实际使用的 sessionId 记进
1946
- * {@link compactableRuns},供终态卡上的 Compact 按钮按 runId 反查。
1947
- *
1948
- * 调用点固定在 `finalizeRun` 的 `finally` 里、**槽位所有权守卫之外**。
1949
- * 所有权守卫只回答「cwd 槽位还归不归本 run」(决定能否删槽位 / evict
1950
- * runner);记账回答「本次 run 结束了吗、用的哪个 sessionId」。两者在不同
1951
- * 维度上:外部中断(`/stop`、停止卡按钮、`queue.immediate`)会先摘槽位,
1952
- * 记账若跟着守卫走就永远不会落,中断卡片的按钮必然反查不到(中断
1953
- * 后点 Compact 一律回「该任务已结束或不属于当前会话」)。
1954
- *
1955
- * 覆盖所有终态入口(正常结束、error、interrupted、idle_timeout、
1956
- * goal_continuing)——这些卡片都渲染 Compact 按钮(`goal` 仍 active 时
1957
- * 除外)。记账失败不能影响清理,故内部兜错。
1958
- */
1959
- recordCompactableRunForTerminal(cardSession, activeRun, ctx) {
1960
- try {
1961
- if (!runnerSupportsCompact(activeRun.runner, activeRun.agentKind))
1962
- return;
1963
- if (activeRun.runner.lifetime !== 'workspace')
1964
- return;
1965
- const terminal = cardSession.currentState.terminal;
1966
- // 与事件消费守卫、Compact 按钮共用同一份终态集合(run-state.ts)。
1967
- if (!isRunTerminal(terminal))
1968
- return;
1969
- // 卡片状态优先(本轮 init/turn_started 写入);缺失时回退 store——
1970
- // 启动失败但已 /stop 的 run 没有自己的 init,压缩此前会话仍是有意义
1971
- // 的动作。
1972
- const compactSessionId = cardSession.currentState.sessionId ??
1973
- this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
1974
- if (!compactSessionId)
1975
- return;
1976
- this.recordCompactableRun(activeRun.cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
1977
- }
1978
- catch (err) {
1979
- getLogger().warn(`[lark-remote] compact record failed runId=${activeRun.runId}: ${errorMessage(err)}`);
1980
- }
1981
- }
1982
- /**
1983
- * 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
1984
- *
1985
- * 为什么不带 cwd 过滤:按钮属于「那一次 run」,与用户此刻在哪个工作目录、
1986
- * 排队期间有没有 /cd 无关。`/stop` 的 interruptCurrentRun 同样是全局按 runId
1987
- * 找(从不查当前 cwd),compact 与它保持一致。用当前 cwd 收窄会让「切了工作区
1988
- * 再点旧卡片」和「排队期间 /cd」两种情况误报「该任务已结束或不属于当前会话」。
1989
- */
1990
- findCompactableRun(runId) {
1991
- for (const byRunId of this.compactableRuns.values()) {
1992
- const record = byRunId.get(runId);
1993
- if (record)
1994
- return record;
1995
- }
1996
- return undefined;
1997
- }
1998
- /**
1999
- * Handle compact card action — trigger a compaction request for any
2000
- * runCompact-capable runner(codex/kimi/opencode/pi/claude)。按钮带的是
2001
- * 那次 run 的 runId,据此反查该 run 实际使用的 sessionId + agentKind
2002
- * + cwd(compactableRuns,按 runId 记录),再调 runner 的 runCompact()。
2003
- */
1790
+ // =============================================================================
1791
+ // Compact 动作入口
1792
+ // =============================================================================
1793
+ // 实现住在 compact.ts(bridge 结构治理第 1 刀):可压缩 run 记账、终态卡 /
1794
+ // resume 卡两条入口、kimi 在途检测与压缩流式卡全部搬过去;这里只留薄转发,
1795
+ // router 的卡片动作分发(handleCardAction → bridge.handleCompactAction /
1796
+ // handleResumeCompact)与 finalizeRun 的终态记账调用路径不变。
1797
+ /** 终态卡 Compact 按钮:按 runId 反查那次 run 的会话并发起压缩。 */
2004
1798
  async handleCompactAction(value, ctx) {
2005
- const log = getLogger();
2006
- log.info(`[lark-remote] handleCompactAction userId=${ctx.userId} runId=${value.runId?.slice(0, 8)}...`);
2007
- const { runId } = value;
2008
- if (!runId) {
2009
- await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 runId' }, ctx);
2010
- return;
2011
- }
2012
- // 校验:run 结束后 activeRuns 已清空,只能靠按钮自带的 runId 反查记录。
2013
- // 按 runId 存(不按「最新的那一条」),点击到执行之间夹了新 run 收尾也不失效;
2014
- // 查找也不按「当前 cwd」收窄(见 findCompactableRun)。
2015
- const target = this.findCompactableRun(runId);
2016
- if (!target) {
2017
- await this.sendResult({ text: '⚠️ 该任务已结束或不属于当前会话' }, ctx);
2018
- return;
2019
- }
2020
- // 压缩必须用那次 run 自己的 cwd(session 文件 + runner 实例都按 cwd 解析)。
2021
- const cwd = target.cwd;
2022
- // §5.3: kimi 压缩在途检测(wire 状态机)。第二次点击在本地毫秒级如实回答,
2023
- // 不开卡、不发 session/prompt——否则 kimi 服务端拒绝并产生「秒回」假完成。
2024
- if (await this.rejectIfCompactionInFlight(target.sessionId, cwd, target.agentKind, ctx)) {
2025
- return;
2026
- }
2027
- // Check that the runner has runCompact method(声明表 && runner 实现)
2028
- const runner = this.getRunner(cwd, target.agentKind, { replaceStale: true });
2029
- if (!runnerSupportsCompact(runner, target.agentKind)) {
2030
- await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
2031
- return;
2032
- }
2033
- log.info(`[lark-remote] handleCompactAction executing runCompact for runId=${runId} cwd=${cwd}`);
2034
- await this.streamCompact({
2035
- sessionId: target.sessionId,
2036
- cwd,
2037
- agentKind: target.agentKind,
2038
- runner,
2039
- ctx,
2040
- });
2041
- }
2042
- /**
2043
- * §5.3: kimi 压缩在途检测——reader 实现契约的 `readCompactionState` 时,若
2044
- * wire 状态机判定当前有未终结的压缩(最后一次 begin 无 terminal 记录),
2045
- * 直接回文本「已有一次 Compact 正在进行」并返回 true(调用方不开卡、不发
2046
- * session/prompt)。codex/claude 等 reader 无此方法 → 返回 false,行为不变。
2047
- */
2048
- async rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx) {
2049
- const reader = this.sessionReaderRegistry.get(agentKind);
2050
- if (!reader?.readCompactionState)
2051
- return false;
2052
- try {
2053
- const state = reader.readCompactionState(sessionId, cwd);
2054
- if (state?.inFlight) {
2055
- const since = state.inFlightSince;
2056
- let timeLabel = '未知时刻';
2057
- if (since !== undefined) {
2058
- const d = new Date(since);
2059
- const hh = String(d.getHours()).padStart(2, '0');
2060
- const mm = String(d.getMinutes()).padStart(2, '0');
2061
- timeLabel = `${hh}:${mm}`;
2062
- }
2063
- await this.sendResult({ text: `⚠️ 已有一次 Compact 正在进行(开始于 ${timeLabel}),请等待完成` }, ctx);
2064
- return true;
2065
- }
2066
- }
2067
- catch (err) {
2068
- getLogger().warn(`[lark-remote] readCompactionState failed sessionId=${sessionId}: ${errorMessage(err)}`);
2069
- }
2070
- return false;
1799
+ await this.compact.handleCompactAction(value, ctx);
2071
1800
  }
2072
- /**
2073
- * Stream a compaction to a card: start a RunCardSession with
2074
- * operationKind='compaction' (no recursive Compact button), consume
2075
- * runner.runCompact() events, read authoritative jsonl usage, and finish
2076
- * the card. Shared by handleCompactAction (run card button) and
2077
- * handleResumeCompact (resume cards) so both flows stay in sync.
2078
- *
2079
- * Precondition: callers have already validated that `runner` implements
2080
- * runCompact and that the session exists in `cwd`.
2081
- */
2082
- async streamCompact(opts) {
2083
- const log = getLogger();
2084
- const { sessionId, cwd, agentKind, runner, ctx } = opts;
2085
- log.info(`[lark-remote] streamCompact sessionId=${sessionId} cwd=${cwd}`);
2086
- // 防御性能力检查:调用方已校验(runId / resume 两条路径),此处双保险并
2087
- // 让 TS 收窄 runner 类型('runCompact' in runner 之后的 cast 才合法)。
2088
- if (!runnerSupportsCompact(runner, agentKind) ||
2089
- !('runCompact' in runner) ||
2090
- typeof runner.runCompact !== 'function') {
2091
- await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
2092
- return;
2093
- }
2094
- // activeRuns 以 cwd 为键:注册前先确认槽位空闲,否则压缩会覆盖在跑的 run 的
2095
- // 记录,令 /stop 只停到最后注册的那个。
2096
- const busyWith = this.activeRuns.get(cwd);
2097
- if (busyWith) {
2098
- log.info(`[lark-remote] compact refused: cwd busy cwd=${cwd} runId=${busyWith.runId}`);
2099
- await this.sendResult({ text: '⚠️ 该工作区正在运行任务,请等待结束或先 /stop' }, ctx);
2100
- return;
2101
- }
2102
- await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
2103
- const compactRunId = randomUUID();
2104
- // 渲染选项同时用于流式卡与「卡片流没建起来」时的静态兜底卡——两者必须
2105
- // 同门控,否则兜底卡会渲染出与本次 run 不一致的按钮。
2106
- const renderOptions = { agentKind };
2107
- const cardSession = new RunCardSession({
2108
- connector: this.connector,
2109
- chatId: ctx.chatId,
2110
- replyTo: ctx.messageId,
2111
- runId: compactRunId,
2112
- renderOptions,
2113
- });
2114
- // 压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
2115
- // /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
2116
- // 压缩都只能等 runner 级超时,用户既停不掉也看不出它还在跑。
2117
- const activeRun = {
2118
- runId: compactRunId,
2119
- userId: ctx.userId,
2120
- chatId: ctx.chatId,
2121
- session: cardSession,
2122
- cwd,
2123
- runner,
2124
- agentKind,
2125
- };
2126
- this.activeRuns.set(cwd, activeRun);
2127
- getLogger().info(`[lark-remote] activeRuns.set cwd=${cwd} runId=${compactRunId} compact=true`);
2128
- let watchdog = null;
2129
- try {
2130
- await cardSession.start();
2131
- watchdog = this.startIdleWatchdog({
2132
- runId: compactRunId,
2133
- session: cardSession,
2134
- runner,
2135
- });
2136
- // operationKind=compaction:卡片不渲染 Compact 按钮(防递归 Compact)。
2137
- await cardSession.push({
2138
- type: 'turn_started',
2139
- threadId: sessionId,
2140
- turnId: '',
2141
- operationKind: 'compaction',
2142
- });
2143
- let sawResult = false;
2144
- let resultSubtype;
2145
- let resultError;
2146
- for await (const event of runner.runCompact('', { cwd, sessionId })) {
2147
- // 已终态(/stop、空闲看门狗)后不再消费事件:与 runAgentStreamToEnd 同口径,
2148
- // 否则迟到的 result 会把终态卡改写成「已完成」。
2149
- const cardTerminal = cardSession.currentState.terminal;
2150
- if (isRunTerminal(cardTerminal)) {
2151
- continue;
2152
- }
2153
- if (event.type === 'result') {
2154
- sawResult = true;
2155
- resultSubtype = event.subtype;
2156
- resultError = event.errorMessage;
2157
- }
2158
- watchdog?.touch();
2159
- await cardSession.push(event);
2160
- }
2161
- // §5.3: 卡片终态必须等于引擎真实终态。旧实现只看 sawResult 布尔——error
2162
- // subtype 的 result 也被 finish 'done'(放大器 bug,2026-08-31 事故)。
2163
- const terminal = resultSubtype === 'success'
2164
- ? 'done'
2165
- : resultSubtype === 'interrupted'
2166
- ? 'interrupted'
2167
- : 'error';
2168
- // 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
2169
- // token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
2170
- // (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
2171
- // 不支持 compactCount 的 reader(opencode/dsh)不重试——否则每次 compact
2172
- // 都会固定多跑两次完整 export/读取,且永远等不到该字段。
2173
- const compactCountSupported = this.sessionReaderRegistry.tryGet(agentKind)?.supportsCompactCount !== false;
2174
- let finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
2175
- for (let attempt = 0; compactCountSupported && !finalUsage?.compactCount && attempt < 2; attempt++) {
2176
- await sleep(150);
2177
- finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
2178
- }
2179
- await cardSession.finish(terminal, {
2180
- resultSubtype: resultSubtype ?? 'error',
2181
- errorMsg: terminal === 'error' ? (resultError ?? '未收到压缩结果') : undefined,
2182
- // finalUsage 的字段形态就是 FinishMeta 的用量部分(同为 UsageSnapshot),
2183
- // 逐字段抄的那 14 行随单源一起去掉。
2184
- ...(finalUsage ?? {}),
2185
- });
2186
- log.info(`[lark-remote] streamCompact finished sessionId=${sessionId} sawResult=${sawResult} subtype=${resultSubtype ?? 'none'}`);
2187
- }
2188
- catch (err) {
2189
- log.error(`[lark-remote] streamCompact failed: ${errorMessage(err)}`);
2190
- // 终态守卫(与 runAgentStreamToEnd 同口径):running/finalizing 才转 error,
2191
- // 已终态(interrupted/idle_timeout)保留首终态,不被迟到的异常改写。
2192
- const catchTerminal = cardSession.currentState.terminal;
2193
- if (!isRunTerminal(catchTerminal)) {
2194
- await cardSession.finish('error', { errorMsg: errorMessage(err) });
2195
- }
2196
- else {
2197
- log.info(`[lark-remote] skip compact error finish: state already terminal (${catchTerminal}) runId=${compactRunId}`);
2198
- }
2199
- }
2200
- finally {
2201
- watchdog?.stop();
2202
- // 与 run 路径同口径的降级:卡片流没建起来(start 失败 / stream 未收尾)时,
2203
- // 终态只存在于内存里。settle() 释放 producer 并等 stream 收尾;'unsent'
2204
- // 表示用户手里没有卡,必须补一张静态终态卡,否则只剩「Compact 已触发」
2205
- // 和一条 reaction,用户既不知道失败也不知道原因。
2206
- const settled = await cardSession.settle();
2207
- if (settled === 'unsent') {
2208
- const finalRender = renderRunCardDetailed(cardSession.currentState, renderOptions);
2209
- await this.sendResult({ card: finalRender.card }, ctx);
2210
- }
2211
- // 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
2212
- if (this.activeRuns.get(cwd) === activeRun) {
2213
- this.activeRuns.delete(cwd);
2214
- getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${compactRunId}`);
2215
- }
2216
- // 终态 reaction:与普通 run 同一口径(done→Done / error→ERROR /
2217
- // interrupted→SHHH / idle_timeout→Alarm)。
2218
- this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
2219
- }
2220
- }
2221
- /**
2222
- * Handle resume.compact card action — compact a session directly from a
2223
- * resume card (auto-resume / `/resume <id>`), without a runId.
2224
- *
2225
- * Unlike handleCompactAction (which validates against the last finished run's
2226
- * runId), the resume card carries the sessionId + agent it was rendered for.
2227
- * Validation: the session must exist in the current cwd (same rule as
2228
- * cmdResume / resume.use) and the runner must implement runCompact.
2229
- */
1801
+ /** resume 卡 Compact 按钮:直接压缩卡上指定的 session(无需 runId)。 */
2230
1802
  async handleResumeCompact(value, ctx) {
2231
- const log = getLogger();
2232
- log.info(`[lark-remote] handleResumeCompact userId=${ctx.userId} sessionId=${value.sessionId?.slice(0, 16)} agent=${value.agent ?? 'default'}`);
2233
- const { sessionId } = value;
2234
- if (!sessionId) {
2235
- await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 sessionId' }, ctx);
2236
- return;
2237
- }
2238
- const cwd = this.resolveCwd(ctx.userId);
2239
- if (!cwd) {
2240
- await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
2241
- return;
2242
- }
2243
- // Resolve agent kind the same way resume.use does: card value wins,
2244
- // otherwise fall back to defaultAgent. 名单来自单源 AGENT_KINDS —— 手抄一份
2245
- // 必然再次漂移(这份曾经漏 dsh:带 dsh 的卡片会静默退回 defaultAgent,
2246
- // 用错 reader 报「未找到 session」)。
2247
- const agentKind = value.agent && AGENT_KINDS.includes(value.agent)
2248
- ? value.agent
2249
- : this.config.defaultAgent;
2250
- // Session must exist in the current cwd (same rule as cmdResume /
2251
- // resume.use): reject ghosts so a stale card cannot compact a session
2252
- // from another cwd. Read failures get visible feedback (card click
2253
- // 红线:不许静默失败)。
2254
- let content;
2255
- try {
2256
- content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd);
2257
- }
2258
- catch (err) {
2259
- await this.sendResult({ text: `⚠️ 读取 session ${sessionId} 失败: ${errorMessage(err)}` }, ctx);
2260
- return;
2261
- }
2262
- if (content.events.length === 0 &&
2263
- !content.usage &&
2264
- !content.aiTitle &&
2265
- !content.recap &&
2266
- !content.displayTitle) {
2267
- await this.sendResult({ text: `⚠️ 未找到 session ${sessionId}(当前目录: ${cwd})` }, ctx);
2268
- return;
2269
- }
2270
- // §5.3: kimi 压缩在途检测(与 handleCompactAction 同一入口)。
2271
- if (await this.rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx))
2272
- return;
2273
- // Check that the runner has runCompact(声明表 && runner 实现;
2274
- // codex/kimi/opencode/pi/claude/dsh 均已实现)。
2275
- const runner = this.getRunner(cwd, agentKind);
2276
- if (!runnerSupportsCompact(runner, agentKind)) {
2277
- await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
2278
- return;
2279
- }
2280
- log.info(`[lark-remote] handleResumeCompact executing runCompact sessionId=${sessionId} cwd=${cwd}`);
2281
- await this.streamCompact({ sessionId, cwd, agentKind, runner, ctx });
1803
+ await this.compact.handleResumeCompact(value, ctx);
2282
1804
  }
2283
- /**
2284
- * Execute a bash command and stream output to a card.
2285
- * `!` commands bypass the serial queue entirely (see body comment + §9.6):
2286
- * bash runs in parallel with same-workspace agent runs, tracked in
2287
- * `activeBashRuns` separately from `activeRuns`.
2288
- */
1805
+ // =============================================================================
1806
+ // `!` bash 命令
1807
+ // =============================================================================
1808
+ // 实现住在 bash-run.ts(bridge 结构治理第 3 刀):executeBash 薄壳、事件流
1809
+ // 消费、独立登记表(activeBashRuns,不进 activeRuns)与 /stop 的 bash 侧
1810
+ // 匹配全部搬过去。这里只留薄转发——router 的 `!` 分发(executeBash)与
1811
+ // /active 投影(getActiveBashRuns)按 bridge 公开 API 调用。
1812
+ /** Execute a bash command and stream output to a card(`!` 命令入口)。 */
2289
1813
  async executeBash(command, ctx) {
2290
- const cwd = this.resolveCwd(ctx.userId);
2291
- getLogger().info(`[lark-remote] executeBash start userId=${ctx.userId} sessionCwd=${this.sessionStore.get(ctx.userId)?.cwd} command="${command.slice(0, 30)}..."`);
2292
- if (!cwd) {
2293
- getLogger().info(`[lark-remote] executeBash no cwd, sending error`);
2294
- await this.sendResult({ text: '请先使用 /cd <path> 设置工作目录,或保存一个 workspace (/ws save <name>)' }, ctx);
2295
- return;
2296
- }
2297
- getLogger().info(`[lark-remote] executeBash proceeding cwd=${cwd}`);
2298
- // `!` commands bypass the serial queue entirely (design.md §9.6):
2299
- // index.ts dispatches `!` to router.handle → executeBash directly, NOT via
2300
- // bridge.enqueue. bash runs in parallel with same-workspace claude runs.
2301
- // bash runs are tracked in `activeBashRuns` (keyed by runId), separate from
2302
- // claude's `activeRuns` (keyed by cwd) — they must not collide or `/stop`
2303
- // would kill the wrong task. Do NOT re-chain onto the serial queue
2304
- // (QueueManager) here: that would block on the still-pending outer promise
2305
- // and deadlock self-wait.
2306
- await this.executeBashInternal(command, ctx, cwd);
2307
- }
2308
- /**
2309
- * Internal execution of bash command (called directly from executeBash,
2310
- * bypassing the serial queue — see executeBash).
2311
- */
2312
- async executeBashInternal(command, ctx, cwd) {
2313
- getLogger().debug(`[lark-remote] executeBashInternal userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} command="${command.slice(0, 50)}..."`);
2314
- // Note: We don't check activeRuns here because bash bypasses the serial
2315
- // queue and runs in parallel with same-workspace agent runs (§9.6). The
2316
- // activeRuns check in forwardToClaude is for a different purpose (detecting
2317
- // concurrent agent runs that would corrupt session state). Bash commands
2318
- // don't have that issue.
2319
- const runId = randomUUID();
2320
- const bashRunner = new BashProcessRunner();
2321
- // Single streaming card across the whole run (initial → output patches →
2322
- // terminal), instead of sending many independent cards. BashCardSession is
2323
- // the bash analogue of RunCardSession.
2324
- const cardSession = new BashCardSession({
2325
- connector: this.connector,
2326
- chatId: ctx.chatId,
2327
- replyTo: ctx.messageId,
2328
- runId,
2329
- command,
2330
- renderOptions: {},
2331
- });
2332
- // Track this bash run independently from claude's `activeRuns`. Bash runs
2333
- // bypass the serial queue and may run in parallel with a claude run in the
2334
- // same cwd, so they must NOT share activeRuns (which is keyed by cwd
2335
- // and would let bash/claude overwrite each other). Keyed by runId so
2336
- // multiple `!` commands can run concurrently. Tracked so /stop can reach
2337
- // the bashRunner (which is otherwise a local) via interruptCurrentRun.
2338
- this.activeBashRuns.set(runId, {
2339
- bashRunner,
2340
- cardSession,
2341
- userId: ctx.userId,
2342
- chatId: ctx.chatId,
2343
- cwd,
2344
- command,
2345
- });
2346
- let output = '';
2347
- let stderr = '';
2348
- let exitCode = null;
2349
- try {
2350
- getLogger().info(`[lark-remote] executeBashInternal about to start stream chatId=${ctx.chatId} messageId=${ctx.messageId}`);
2351
- try {
2352
- await cardSession.start();
2353
- getLogger().info(`[lark-remote] executeBash stream started runId=${runId}`);
2354
- }
2355
- catch (err) {
2356
- getLogger().warn('[lark-remote] bash card stream unavailable:', err);
2357
- }
2358
- for await (const event of bashRunner.run(command, { cwd })) {
2359
- if (event.type === 'stdout') {
2360
- // 层④:bridge 本地 output 也必须 store-time 截断——仅 session state
2361
- // 有界并不会让 `output +=` 自然有界(「store 有界则本地拼接自然有界」
2362
- // 不成立),!yes 洪峰下本地字符串仍全量驻留。capBashOutput 增量调用
2363
- // O(CAP+chunk),不会带回 O(n²) 截断成本(output 恒 ≤ CAP,拼接后
2364
- // ≤ CAP+chunk 再 slice 尾部)。
2365
- output = capBashOutput(output + event.content);
2366
- await cardSession.update({ output, stderr });
2367
- }
2368
- else if (event.type === 'stderr') {
2369
- stderr = capBashOutput(stderr + event.content);
2370
- await cardSession.update({ output, stderr });
2371
- }
2372
- else if (event.type === 'exit') {
2373
- exitCode = event.exitCode ?? 0;
2374
- }
2375
- }
2376
- await cardSession.finish(exitCode === 0 ? 'done' : 'error', { exitCode, output, stderr });
2377
- }
2378
- catch (err) {
2379
- getLogger().error(`[lark-remote] bash run error runId=${runId}:`, err);
2380
- // If for-await threw mid-stream the bash child may still be running.
2381
- // Stop it before finalizing the card — otherwise the finally block
2382
- // removes the activeBashRuns entry and /stop can no longer reach it,
2383
- // leaving an orphan process.
2384
- try {
2385
- await bashRunner.stop();
2386
- }
2387
- catch (stopErr) {
2388
- getLogger().warn(`[lark-remote] bash stop failed:`, stopErr);
2389
- }
2390
- await cardSession.finish('error', {
2391
- exitCode: 1,
2392
- output,
2393
- stderr: stderr + `\n执行错误: ${errorMessage(err)}`,
2394
- });
2395
- }
2396
- finally {
2397
- const settleResult = await cardSession.settle();
2398
- if (settleResult === 'unsent') {
2399
- // Stream never established a card — send a static terminal card as fallback.
2400
- // Use 2.0 schema to match the streaming card (avoid 2.0→1.x downgrade).
2401
- try {
2402
- await this.connector.sendWithRetry(ctx.chatId, { card: renderBashCard(cardSession.currentState, {}) }, { replyTo: ctx.messageId });
2403
- }
2404
- catch (fallbackErr) {
2405
- getLogger().error(`[lark-remote] bash terminal fallback card failed runId=${runId} chatId=${ctx.chatId}:`, fallbackErr);
2406
- }
2407
- }
2408
- if (this.activeBashRuns.has(runId)) {
2409
- this.activeBashRuns.delete(runId);
2410
- getLogger().info(`[lark-remote] executeBash done runId=${runId} exitCode=${exitCode}`);
2411
- }
2412
- // Add a Done emoji reaction to the user's original message to signal completion
2413
- this.finishTurnReactions(ctx, 'Done');
2414
- }
1814
+ await this.bashRuns.executeBash(command, ctx);
2415
1815
  }
2416
1816
  }
2417
1817
  //# sourceMappingURL=index.js.map