lark-remote 0.5.18 → 0.5.20

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 (123) hide show
  1. package/CHANGELOG.md +32 -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 +1 -1
  10. package/dist/bridge/index.d.ts +120 -119
  11. package/dist/bridge/index.js +257 -695
  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 +109 -97
  18. package/dist/card/bash-card-session.js +2 -0
  19. package/dist/card/card-budget.js +1 -1
  20. package/dist/card/card-helpers.d.ts +3 -3
  21. package/dist/card/card-helpers.js +6 -6
  22. package/dist/card/card-session.d.ts +17 -0
  23. package/dist/card/card-session.js +35 -0
  24. package/dist/card/final-answer-file.js +1 -1
  25. package/dist/card/run-card-session.js +2 -0
  26. package/dist/card/run-renderer.d.ts +3 -3
  27. package/dist/card/run-renderer.js +77 -102
  28. package/dist/card/run-state.d.ts +35 -8
  29. package/dist/card/run-state.js +117 -90
  30. package/dist/card/tool-render.d.ts +1 -1
  31. package/dist/card/tool-render.js +1 -1
  32. package/dist/card/usage-format.d.ts +12 -0
  33. package/dist/card/usage-format.js +8 -3
  34. package/dist/cli.js +15 -3
  35. package/dist/clone.js +2 -2
  36. package/dist/config/codex-config.js +130 -106
  37. package/dist/config/index.d.ts +16 -2
  38. package/dist/config/index.js +23 -8
  39. package/dist/connector/index.js +6 -6
  40. package/dist/error-classification.js +3 -2
  41. package/dist/inbound/placeholder.d.ts +1 -1
  42. package/dist/inbound/placeholder.js +1 -1
  43. package/dist/index.js +203 -133
  44. package/dist/logger/index.js +6 -6
  45. package/dist/message-handlers.d.ts +0 -5
  46. package/dist/message-handlers.js +4 -2
  47. package/dist/model-catalog.d.ts +2 -2
  48. package/dist/model-catalog.js +3 -8
  49. package/dist/platform/identity.js +1 -1
  50. package/dist/platform/path.d.ts +1 -1
  51. package/dist/platform/path.js +1 -1
  52. package/dist/platform/sleep-blocker.js +1 -1
  53. package/dist/platform/terminator-win32.js +2 -1
  54. package/dist/router/commands/resume.js +2 -20
  55. package/dist/router/config/dsh.d.ts +4 -8
  56. package/dist/router/config/dsh.js +3 -10
  57. package/dist/router/config/kimi.js +1 -1
  58. package/dist/router/config/types.d.ts +1 -1
  59. package/dist/router/config-diff.js +1 -1
  60. package/dist/router/index.d.ts +56 -11
  61. package/dist/router/index.js +222 -202
  62. package/dist/runner/claude/session.d.ts +1 -1
  63. package/dist/runner/claude/session.js +6 -4
  64. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  65. package/dist/runner/codex/app-server/runner.js +30 -25
  66. package/dist/runner/codex/app-server/translator.d.ts +0 -5
  67. package/dist/runner/codex/app-server/translator.js +4 -17
  68. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  69. package/dist/runner/common/acp/acp-approval.js +1 -1
  70. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  71. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  72. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  73. package/dist/runner/common/acp/base-translator.js +31 -9
  74. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  75. package/dist/runner/common/acp/protocol-types.js +2 -2
  76. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  77. package/dist/runner/common/connection-based-runner.js +32 -2
  78. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  79. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  80. package/dist/runner/common/spawning-runner.d.ts +1 -1
  81. package/dist/runner/common/spawning-runner.js +1 -1
  82. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  83. package/dist/runner/dsh/catalog-probe.js +10 -0
  84. package/dist/runner/dsh/client.d.ts +13 -8
  85. package/dist/runner/dsh/client.js +23 -15
  86. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  87. package/dist/runner/dsh/exit-cancel.js +2 -2
  88. package/dist/runner/dsh/inflight.d.ts +1 -1
  89. package/dist/runner/dsh/inflight.js +1 -1
  90. package/dist/runner/dsh/runner.d.ts +48 -2
  91. package/dist/runner/dsh/runner.js +288 -199
  92. package/dist/runner/dsh/translator.d.ts +1 -1
  93. package/dist/runner/dsh/translator.js +1 -1
  94. package/dist/runner/index.d.ts +1 -1
  95. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  96. package/dist/runner/kimi/acp/runner.js +1 -2
  97. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  98. package/dist/runner/kimi/acp/translator.js +3 -4
  99. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  100. package/dist/runner/opencode/acp/runner.js +4 -4
  101. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  102. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  103. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  104. package/dist/runner/pi/rpc/runner.js +27 -42
  105. package/dist/runner/types.d.ts +25 -8
  106. package/dist/runner/types.js +1 -1
  107. package/dist/session/claude/sessions.d.ts +45 -0
  108. package/dist/session/claude/sessions.js +97 -71
  109. package/dist/session/codex/rollout-reader.js +4 -4
  110. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  111. package/dist/session/common/throw-session-list-error.js +16 -0
  112. package/dist/session/dsh/sessions.js +11 -5
  113. package/dist/session/kimi/sessions.d.ts +40 -1
  114. package/dist/session/kimi/sessions.js +244 -192
  115. package/dist/session/opencode/sessions.d.ts +153 -1
  116. package/dist/session/opencode/sessions.js +354 -321
  117. package/dist/session/pi/sessions.js +4 -5
  118. package/dist/session/session-store.d.ts +1 -1
  119. package/dist/session/session-store.js +1 -1
  120. package/dist/update/version-check.js +2 -1
  121. package/dist/workspace/index.d.ts +1 -1
  122. package/dist/workspace/index.js +1 -1
  123. 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 { isRunActive, isRunTerminal, removeBackgroundJob, } 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,6 @@ 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');
90
- }
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;
111
- }
112
- /**
113
- * 每个 cwd 保留的「可压缩 run」条数上限。Compact 按钮挂在终态 run 卡片上,
114
- * 卡片会被后续消息不断顶上去;无上限会让长跑进程随 run 数无界增长,而用户
115
- * 实际能点到的只有最近几张卡,20 条足以覆盖。
116
- */
117
- const MAX_COMPACTABLE_RUNS_PER_CWD = 20;
118
66
  /**
119
67
  * Bridge owns the per-workspace serial processing queue (§9.6) and the
120
68
  * single-card streaming forwarding loop (§9.12 watchdog + session sync).
@@ -149,7 +97,7 @@ export class Bridge {
149
97
  mediaHandler;
150
98
  /**
151
99
  * Per-workspace runner instances, keyed by (cwd, agentKind).
152
- * Fix 4 (2026-07-18): Each agentKind has its own cache slot, so switching
100
+ * Each agentKind has its own cache slot, so switching
153
101
  * defaultAgent creates a NEW runner without evicting the old one. This
154
102
  * eliminates the need for clearRunners() on agent switch, preventing the
155
103
  * regression where clearRunners() orphaned a running subprocess and
@@ -175,16 +123,11 @@ export class Bridge {
175
123
  coordinatorFor: (runId) => this.approvalCoordinators.get(runId),
176
124
  });
177
125
  /**
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 线上缺陷)。
126
+ * Compact 生命周期(见 compact.ts):可压缩 run 记账、终态卡 / resume 卡
127
+ * 两条 Compact 入口、kimi 在途检测与压缩流式卡。activeRuns 槽位表按引用
128
+ * 共用——压缩注册前确认槽位空闲,否则 /stop 停错对象。
186
129
  */
187
- compactableRuns = new Map();
130
+ compact;
188
131
  /**
189
132
  * 终态卡片的渲染快照(runId → state + options):run 结束后 activeRuns 已
190
133
  * 清空,而卡片上的「⏹ 停止」后台任务按钮(job.kill)还活着,需要按 runId
@@ -195,12 +138,17 @@ export class Bridge {
195
138
  runCardSnapshots = new Map();
196
139
  static RUN_CARD_SNAPSHOT_MAX = 8;
197
140
  /**
198
- * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
199
- * queue and may run in parallel with claude runs / other bash runs in the same
200
- * cwd, so they live outside `activeRuns`. Tracked so `/stop` can reach
201
- * the per-run BashRunner instance via interruptCurrentRun.
141
+ * 终态卡后台任务订阅与停止(见 job-watch.ts):job.kill、名单订阅与
142
+ * PATCH 串行链收在该模块;jobCardPatches 链内渲染读 snapshot.state,
143
+ * 「停止」与「订阅帧」并发不会把卡片写回旧内容。
144
+ */
145
+ jobWatch;
146
+ /**
147
+ * `!` bash 命令执行(见 bash-run.ts):独立 runId 空间、独立登记表,不进
148
+ * activeRuns;绝不 re-chain 到 QueueManager(会自等待死锁)。/stop 的 bash
149
+ * 侧匹配与中断也收在该模块,避免误杀并行跑的 `!` 任务。
202
150
  */
203
- activeBashRuns = new Map();
151
+ bashRuns;
204
152
  constructor(deps) {
205
153
  this.connector = deps.connector;
206
154
  this.sessionStore = deps.sessionStore;
@@ -214,11 +162,36 @@ export class Bridge {
214
162
  resolveCwd: (userId) => this.resolveCwd(userId),
215
163
  getConfig: () => this.config,
216
164
  });
165
+ this.compact = new CompactLifecycle({
166
+ activeRuns: this.activeRuns,
167
+ connector: this.connector,
168
+ sessionStore: this.sessionStore,
169
+ sessionReaderRegistry: this.sessionReaderRegistry,
170
+ getConfig: () => this.config,
171
+ getRunner: (cwd, kind, opts) => this.getRunner(cwd, kind, opts),
172
+ sendResult: (result, ctx, opts) => this.sendResult(result, ctx, opts),
173
+ startIdleWatchdog: (opts) => this.startIdleWatchdog(opts),
174
+ finishTurnReactions: (ctx, emoji) => this.finishTurnReactions(ctx, emoji),
175
+ resolveCwd: (userId) => this.resolveCwd(userId),
176
+ });
177
+ this.jobWatch = new JobWatchLifecycle({
178
+ getRunner: (cwd, kind) => this.getRunner(cwd, kind),
179
+ getRunCardSnapshot: (runId) => this.runCardSnapshots.get(runId),
180
+ updateCardInPlace: (card, ctx) => this.updateCardInPlace(card, ctx),
181
+ patchCard: (messageId, card) => this.patchCard(messageId, card),
182
+ });
183
+ this.bashRuns = new BashRunLifecycle({
184
+ connector: this.connector,
185
+ sessionStore: this.sessionStore,
186
+ resolveCwd: (userId) => this.resolveCwd(userId),
187
+ sendResult: (result, ctx) => this.sendResult(result, ctx),
188
+ finishTurnReactions: (ctx, emoji) => this.finishTurnReactions(ctx, emoji),
189
+ });
217
190
  }
218
191
  /**
219
192
  * Get or create a runner for the given cwd, keyed by current defaultAgent.
220
193
  *
221
- * Fix 4 (2026-07-18): Cache key includes agentKind, so switching defaultAgent
194
+ * Cache key includes agentKind, so switching defaultAgent
222
195
  * creates a NEW runner under a different key without evicting the old one.
223
196
  * This eliminates the need for clearRunners() on agent switch.
224
197
  */
@@ -365,9 +338,9 @@ export class Bridge {
365
338
  */
366
339
  clearRunners() {
367
340
  // Skip workspaces with an active run: evicting their runner would orphan
368
- // the instance that owns the live subprocess (regression 2026-07-18 -- the
369
- // running runner became unreachable, stop() hit a fresh empty runner, the
370
- // real subprocess was never killed and the queue chain blocked forever).
341
+ // the instance that owns the live subprocess — the running runner would
342
+ // become unreachable, stop() would hit a fresh empty runner, the real
343
+ // subprocess would never be killed and the queue chain would block forever.
371
344
  // The running run already started with its (possibly now-stale) config, so
372
345
  // keeping it is correct; finalizeRun deletes the cache entry when the run
373
346
  // ends, and the next run picks up the new config/agent via getRunner.
@@ -631,25 +604,7 @@ export class Bridge {
631
604
  }
632
605
  /** Get all active bash runs with their state for /active command. */
633
606
  getActiveBashRuns() {
634
- const result = [];
635
- for (const [runId, bashRun] of this.activeBashRuns) {
636
- const state = bashRun.cardSession.currentState;
637
- // Only include non-terminal runs (bash has no finalizing state)
638
- if (state.terminal === 'running') {
639
- result.push({
640
- runId,
641
- cwd: bashRun.cwd,
642
- userId: bashRun.userId,
643
- chatId: bashRun.chatId,
644
- terminal: state.terminal,
645
- command: bashRun.command,
646
- });
647
- }
648
- }
649
- return result;
650
- }
651
- async interruptCurrentRun(input) {
652
- return (await this.interruptCurrentRunDetailed(input)).stopped;
607
+ return this.bashRuns.getActiveBashRuns();
653
608
  }
654
609
  /**
655
610
  * Same as {@link interruptCurrentRun}, plus what the runner's stop actually
@@ -664,15 +619,31 @@ export class Bridge {
664
619
  // 并行跑;旧实现停在「第一条命中」就 return —— 两个 workspace 并行时
665
620
  // `/stop` 只停一个却回「已停止」,另一个无声继续。
666
621
  // 限定 cwd(queue.immediate)或带 runId(停止卡按钮)时仍只有一条命中。
667
- const activeTargets = [];
622
+ const ownedActiveRuns = [];
668
623
  for (const [cwd, active] of this.activeRuns) {
669
624
  if (input.cwd !== undefined && cwd !== input.cwd)
670
625
  continue;
671
626
  if (active.userId !== input.userId || active.chatId !== input.chatId)
672
627
  continue;
673
- if (input.runId && active.runId !== input.runId)
674
- continue;
675
- activeTargets.push([cwd, active]);
628
+ ownedActiveRuns.push([cwd, active]);
629
+ }
630
+ // Card stop prefers the authoritative runId. If the click is scoped to a
631
+ // run card but that value is stale/absent, accept only the active run whose
632
+ // server-provided card messageId is exactly the clicked card. This repairs
633
+ // callbacks that lose behavior-value without ever stopping a historical or
634
+ // dashboard card against a newer run.
635
+ let activeTargets;
636
+ if (input.runId) {
637
+ activeTargets = ownedActiveRuns.filter(([, active]) => active.runId === input.runId);
638
+ if (activeTargets.length === 0) {
639
+ activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
640
+ }
641
+ }
642
+ else if (input.cardMessageId) {
643
+ activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
644
+ }
645
+ else {
646
+ activeTargets = ownedActiveRuns;
676
647
  }
677
648
  let stoppedCount = 0;
678
649
  let agentKind;
@@ -689,37 +660,16 @@ export class Bridge {
689
660
  stop = stopResult;
690
661
  }
691
662
  }
692
- // Then check active bash runs (! commands). These bypass the serial queue
693
- // and are tracked separately; stop the per-run BashRunner directly.
694
- // `/stop`(无 runId、无 cwd)语义是「停掉这个会话的一切」,所以并行跑的
695
- // `!` 命令也要停——旧实现命中 claude run 就 return,这些 bash 会被漏掉。
696
- // 带范围的中断(停止卡 runId、queue.immediate 的 cwd)保持旧语义:只有
697
- // 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
698
- const includeBashRuns = (input.runId === undefined && input.cwd === undefined) || activeTargets.length === 0;
699
- const bashTargets = includeBashRuns
700
- ? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
701
- if (input.cwd !== undefined && b.cwd !== input.cwd)
702
- return false;
703
- if (b.userId !== input.userId || b.chatId !== input.chatId)
704
- return false;
705
- if (input.runId && runId !== input.runId)
706
- return false;
707
- return true;
708
- })
709
- : [];
710
- for (const [runId, b] of bashTargets) {
711
- getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
712
- await b.bashRunner.stop({ immediate: true });
713
- // Update card to show interrupted state. exitCode 不传(保持 null):
714
- // bash-renderer 的「已手动终止」文案要求 exitCode === null;传 -1 会让
715
- // 该分支不可达,中断卡退化成「退出码: -1」的失败样式。
716
- await b.cardSession.finish('interrupted');
717
- this.activeBashRuns.delete(runId);
718
- getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
719
- stoppedCount++;
720
- }
663
+ // Then check active bash runs (! commands). 实现在 bash-run.ts
664
+ // (bridge 结构治理第 3 刀):bash 用独立 runId 空间、独立登记表,匹配
665
+ // 与停止整体收在 stopMatchingBashRuns 里——`/stop` 语义与「不误杀并行
666
+ // `!` 任务」的守卫注释随迁。
667
+ stoppedCount += await this.bashRuns.stopMatchingBashRuns(input, activeTargets.length);
721
668
  if (stoppedCount === 0) {
722
- getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
669
+ getLogger().warn(`[lark-remote] interrupt miss userId=${input.userId} ` +
670
+ `requestedRunId=${input.runId ?? '(none)'} ` +
671
+ `cardMessageId=${input.cardMessageId ?? '(none)'} ` +
672
+ `active=${ownedActiveRuns.map(([, active]) => active.runId).join(',') || '(none)'}`);
723
673
  return { stopped: false };
724
674
  }
725
675
  return {
@@ -729,6 +679,26 @@ export class Bridge {
729
679
  ...(stop ? { stop } : {}),
730
680
  };
731
681
  }
682
+ /**
683
+ * Resolve a scoped card click by card identity. Empty input or multiple
684
+ * matches fail closed; production message ids are unique, but refusing an
685
+ * ambiguous stop is safer than killing several workspaces.
686
+ */
687
+ activeRunsForCard(ownedActiveRuns, cardMessageId) {
688
+ if (!cardMessageId)
689
+ return [];
690
+ const matches = ownedActiveRuns.filter(([, active]) => active.session.cardMessageId === cardMessageId);
691
+ if (matches.length === 0)
692
+ return [];
693
+ if (matches.length > 1) {
694
+ getLogger().warn(`[lark-remote] interrupt ambiguous cardMessageId=${cardMessageId} ` +
695
+ `active=${matches.map(([, active]) => active.runId).join(',')}`);
696
+ return [];
697
+ }
698
+ getLogger().warn(`[lark-remote] interrupt using active card fallback ` +
699
+ `runId=${matches[0][1].runId} cardMessageId=${cardMessageId}`);
700
+ return matches;
701
+ }
732
702
  /**
733
703
  * 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
734
704
  * 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
@@ -741,8 +711,8 @@ export class Bridge {
741
711
  // Stop the EXACT runner instance that is running (carried by activeRun),
742
712
  // not whatever getRunner(cwd) would return now. clearRunners() may have
743
713
  // emptied the cache mid-run, in which case getRunner creates a fresh empty
744
- // runner whose stop() is a no-op and the real subprocess is never killed
745
- // (regression 2026-07-18: queue chain blocked forever).
714
+ // runner whose stop() is a no-op and the real subprocess is never killed,
715
+ // blocking the queue chain forever.
746
716
  const runner = active.runner;
747
717
  // Bind the queue reset to THIS task's execution slot: the stop window
748
718
  // below may outlive the task's settle, letting the serial chain advance
@@ -750,18 +720,28 @@ export class Bridge {
750
720
  // unconditional reset would then zero the successor's count and mark
751
721
  // its slot interrupted, hiding it from queue cards.
752
722
  const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
753
- const [, stopResult] = await Promise.allSettled([
723
+ const [finishResult, stopResult] = await Promise.allSettled([
754
724
  // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
755
725
  // interruptedReason,与审批超时自动取消(approval_timeout)、审批
756
726
  // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
757
727
  active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
758
728
  runner.stop({ immediate: true }),
759
729
  ]);
760
- const stop = stopResult.status === 'fulfilled' &&
761
- stopResult.value !== null &&
762
- typeof stopResult.value === 'object'
763
- ? stopResult.value
764
- : undefined;
730
+ // allSettled 的两个结果都必须检查:finish 被飞书拒绝时卡片会永久停在
731
+ // 「运行中」;runner.stop 失败时进程可能没死而槽位已被释放——两者都
732
+ // 不能静默吞掉(此前第一个结果整体丢弃、stop 失败仍记 "stop done")。
733
+ if (finishResult.status === 'rejected') {
734
+ getLogger().error(`[lark-remote] stop: terminal card finish failed runId=${active.runId}:`, finishResult.reason);
735
+ }
736
+ let stop;
737
+ if (stopResult.status === 'rejected') {
738
+ getLogger().error(`[lark-remote] stop: runner.stop failed runId=${active.runId} — 运行可能未终止:`, stopResult.reason);
739
+ // 上报「可能仍在跑」,调用方按 unconfirmed 口径聚合(不承诺已停止)。
740
+ stop = { status: 'unconfirmed', detail: errorMessage(stopResult.reason) };
741
+ }
742
+ else {
743
+ stop = stopResult.value ?? undefined;
744
+ }
765
745
  // The chain may have advanced while the stop was in flight: a new run
766
746
  // (from a task that began during the stop window) may now occupy this
767
747
  // cwd. Only delete the entry we actually stopped.
@@ -777,8 +757,8 @@ export class Bridge {
777
757
  // interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
778
758
  // getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
779
759
  // 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
780
- // agent 侧会话写锁(codex "thread already has an active writer",
781
- // 线上实测)。改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
760
+ // agent 侧会话写锁(codex "thread already has an active writer")。
761
+ // 改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
782
762
  // 换新实例(见 getRunner 的 replaceStale),本次 run 的 finalize 收尾时
783
763
  // 也会消费该标记;两者都保证不会有活跃 run 使用被 dispose 的实例。
784
764
  // run-per-message runner 无长驻连接,保持立即 evict 不变。
@@ -800,11 +780,18 @@ export class Bridge {
800
780
  if (stoppedSlot !== undefined) {
801
781
  this.queueManager.resetExecutingCount(cwd, stoppedSlot);
802
782
  }
803
- getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
783
+ if (stop?.status !== 'unconfirmed') {
784
+ getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
785
+ }
804
786
  return stop;
805
787
  }
806
- /** Send a result message to the user (§7). Returns true on success, false on failure. */
807
- async sendResult(result, ctx) {
788
+ /**
789
+ * Send a result message to the user (§7). Returns true on success, false on failure.
790
+ *
791
+ * `opts.onCardSent` 回传卡片的 messageId:终态卡收尾后还要靠它接收后台任务
792
+ * 更新(流式卡从 `cardSession.cardMessageId` 取,兜底发送只能从这里拿)。
793
+ */
794
+ async sendResult(result, ctx, opts) {
808
795
  try {
809
796
  if (result.card) {
810
797
  // 静态卡片体积保护:确保卡片不超过飞书大小限制
@@ -813,7 +800,8 @@ export class Bridge {
813
800
  getLogger().info(`[lark-remote] card size exceeded limit, applied budget enforcement: reason=${reason} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter}`);
814
801
  }
815
802
  getLogger().debug(`[lark-remote] sendResult kind=card chatId=${ctx.chatId}`);
816
- await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
803
+ const messageId = await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
804
+ opts?.onCardSent?.(messageId);
817
805
  return true;
818
806
  }
819
807
  else if (result.markdown) {
@@ -845,25 +833,22 @@ export class Bridge {
845
833
  /**
846
834
  * Deliver a final answer that cannot fit in the run card as a Markdown file
847
835
  * attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
848
- * (2026-11-02 定规:纯文本与卡片重复,也丢 Markdown 结构)。
836
+ * (纯文本与卡片重复,也丢 Markdown 结构)。
849
837
  *
850
- * 失败时发一张提示卡(不是纯文本),并把完整回答留在临时文件里记日志,
851
- * 便于事后找回。
838
+ * 发送成功即清理临时文件;失败时发一张提示卡(不是纯文本),完整回答
839
+ * 留在临时文件里(路径见错误日志),便于事后找回。
852
840
  */
853
841
  async sendFinalAnswerFile(text, ctx) {
854
842
  let filePath;
855
843
  try {
856
844
  filePath = writeFinalAnswerFile(text);
857
845
  await this.connector.sendFile(ctx.chatId, filePath);
846
+ bestEffortUnlink(filePath);
858
847
  }
859
848
  catch (err) {
860
849
  getLogger().error(`[lark-remote] failed to deliver complete final answer as file (kept at ${filePath ?? 'not written'}):`, err);
861
850
  await this.sendResult({ card: finalAnswerFileFailureCard(errorMessage(err)) }, ctx);
862
851
  }
863
- finally {
864
- if (filePath)
865
- bestEffortUnlink(filePath);
866
- }
867
852
  }
868
853
  /**
869
854
  * Update an existing card in place (原地更新) instead of sending a new one.
@@ -925,37 +910,34 @@ export class Bridge {
925
910
  * 不放在 finish() 里:bridge 才知道 hasRunCompact 等渲染选项,且 eviction
926
911
  * 与 activeRuns 生命周期在同一个地方收口。
927
912
  */
928
- rememberRunCardSnapshot(runId, state, options, cwd, agentKind) {
929
- this.runCardSnapshots.set(runId, { state, options, cwd, agentKind });
913
+ rememberRunCardSnapshot(runId, state, options, cwd, agentKind, messageId) {
914
+ const snapshot = {
915
+ state,
916
+ options,
917
+ cwd,
918
+ agentKind,
919
+ ...(messageId === undefined ? {} : { messageId }),
920
+ };
921
+ this.runCardSnapshots.set(runId, snapshot);
930
922
  while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
931
923
  const oldest = this.runCardSnapshots.keys().next().value;
932
- if (oldest === undefined)
933
- break;
934
924
  this.runCardSnapshots.delete(oldest);
925
+ // 卡片已经不在窗口里:继续订阅只是往一个没人看的 messageId 打 PATCH。
926
+ this.jobWatch.stopJobWatch(oldest);
935
927
  }
928
+ return snapshot;
936
929
  }
937
- /**
938
- * 停止终态卡上的一个后台任务。
939
- *
940
- * 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
941
- * 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
942
- * 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
943
- * 会留在卡片上而任务早已结束。
944
- */
930
+ // =============================================================================
931
+ // 后台任务停止与订阅
932
+ // =============================================================================
933
+ // 实现住在 job-watch.ts(bridge 结构治理第 2 刀):job.kill、名单订阅、
934
+ // markJobsUnknown 与 PATCH 串行链整体搬过去。这里只留 killBackgroundJob
935
+ // 薄转发——router 的 job.kill 卡片动作按名字分发到 bridge 公开 API;
936
+ // 快照淘汰(rememberRunCardSnapshot)与收尾订阅(finalizeRun)直接调
937
+ // this.jobWatch 的同名方法。
938
+ /** 停止终态卡上的一个后台任务(job.kill 卡片动作)。 */
945
939
  async killBackgroundJob(runId, jobId, ctx) {
946
- const snapshot = this.runCardSnapshots.get(runId);
947
- const sessionId = snapshot?.state.sessionId;
948
- if (!snapshot || !sessionId)
949
- return false;
950
- const runner = this.getRunner(snapshot.cwd, snapshot.agentKind);
951
- if (typeof runner.killJob !== 'function')
952
- return false;
953
- const killed = await runner.killJob(sessionId, jobId);
954
- if (!killed)
955
- return false;
956
- snapshot.state = removeBackgroundJob(snapshot.state, jobId);
957
- await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
958
- return true;
940
+ return this.jobWatch.killBackgroundJob(runId, jobId, ctx);
959
941
  }
960
942
  /** Send a file to the user via Feishu. */
961
943
  async sendFile(filePath, ctx) {
@@ -1029,7 +1011,7 @@ export class Bridge {
1029
1011
  // sessionId 钉死语义——binding 有 sessionId 时用它(入队时刻快照),
1030
1012
  // 否则跟随 live store(无 binding 或入队时无 session 的正常路径)。
1031
1013
  const sessionId = opts?.binding?.sessionId ?? this.sessionStore.getSessionId(ctx.userId, agentKind);
1032
- // 会话代际快照(2026-08-09,2026-10-07 前移):/new、/cd、/resume 会 bump
1014
+ // 会话代际快照:/new、/cd、/resume 会 bump
1033
1015
  // epoch,该 run 后续 system.init 的 sessionId 写回即判 stale 跳过。
1034
1016
  // 快照点优先取 binding.sessionEpoch(入队/commit 时刻,与 sessionId 同刻):
1035
1017
  // 排队消息在「commit 后、开跑前」被 reset 时,执行起点捕获会漏判(epoch 已是
@@ -1039,7 +1021,7 @@ export class Bridge {
1039
1021
  // Resolve the runner BEFORE creating the run session so the exact instance
1040
1022
  // that will run is captured into activeRun. interruptCurrentRun stops THAT
1041
1023
  // instance directly -- never a fresh runner re-resolved from a cache that
1042
- // clearRunners() may have emptied mid-run (regression 2026-07-18).
1024
+ // clearRunners() may have emptied mid-run.
1043
1025
  const runner = this.getRunner(cwd, agentKind, { replaceStale: true });
1044
1026
  // Step 1: Create run session (runId, cardSession, activeRun tracking)
1045
1027
  const { runId, cardSession, activeRun } = this.createRunSession(ctx, cwd, runner, agentKind);
@@ -1071,7 +1053,7 @@ export class Bridge {
1071
1053
  *
1072
1054
  * DSH 把上游 `finish_reason: content_filter` 归一成 PI_AI_ERROR:既不在默认
1073
1055
  * 重试码里(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT),也不会换模型,
1074
- * 于是一次(多数是误报的)判定就把整个 turn 判死。2026-10-21 现场:60 步、
1056
+ * 于是一次(多数是误报的)判定就把整个 turn 判死。60 步、
1075
1057
  * 10 分钟的 turn 在最后一步被截断,已写入的文件还在,用户只看到「运行出错」
1076
1058
  * 加一句英文报文。
1077
1059
  *
@@ -1190,12 +1172,23 @@ export class Bridge {
1190
1172
  }
1191
1173
  stop();
1192
1174
  getLogger().warn(`[lark-remote] claude idle timeout, stopping process runId=${opts.runId}`);
1175
+ // 看门狗清理的两个结果都必须留痕:卡片 patch 失败时终态会停在
1176
+ // 「运行中」;runner.stop 失败时挂死的 run 没被杀、cwd 槽位未释放,
1177
+ // 串行队列从此永久堵死(用户视角「机器人不回消息」,日志却只有
1178
+ // 前面一条 warn)。对照 claude session.ts idle timer 的 .catch(log)。
1193
1179
  void Promise.allSettled([
1194
1180
  opts.session.finish('idle_timeout', {
1195
1181
  idleTimeoutMinutes: Math.max(1, Math.round(this.idleTimeoutMs / 60_000)),
1196
1182
  }),
1197
1183
  opts.runner.stop(),
1198
- ]);
1184
+ ]).then(([finishResult, stopResult]) => {
1185
+ if (finishResult.status === 'rejected') {
1186
+ getLogger().error(`[lark-remote] idle-timeout cleanup failed runId=${opts.runId} finish:`, finishResult.reason);
1187
+ }
1188
+ if (stopResult.status === 'rejected') {
1189
+ getLogger().error(`[lark-remote] idle-timeout cleanup failed runId=${opts.runId} stop:`, stopResult.reason);
1190
+ }
1191
+ });
1199
1192
  }, tickMs);
1200
1193
  }
1201
1194
  return {
@@ -1214,7 +1207,7 @@ export class Bridge {
1214
1207
  * result 在 system.init 之后才到。
1215
1208
  *
1216
1209
  * 例外:app-server runner(usageAuthority==='live')的 init 是 runner 自己补发
1217
- * 的 synthetic init(2026-08-13 修复)。此分支保留为防御性兜底:live runner 若
1210
+ * 的 synthetic init。此分支保留为防御性兜底:live runner 若
1218
1211
  * 在无 init 时发出 result(旧构建等),session 写回照常执行,但 sawResult 不置
1219
1212
  * 真——卡片不会把半初始化的 run 当作已收尾。
1220
1213
  *
@@ -1293,7 +1286,7 @@ export class Bridge {
1293
1286
  const { cardSession, usage, sawResult, sawAnyResult, pendingPreInitTerminal, agentKind, runId, } = opts;
1294
1287
  const finalState = cardSession.currentState;
1295
1288
  if (finalState.terminal === 'finalizing') {
1296
- // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
1289
+ // 三态终态映射:interrupted(审批超时/取消、/stop)是独立
1297
1290
  // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1298
1291
  const subtype = finalState.resultSubtype;
1299
1292
  const isError = subtype === 'error' || !!finalState.errorMsg;
@@ -1431,29 +1424,13 @@ export class Bridge {
1431
1424
  if (isRunTerminal(terminal)) {
1432
1425
  continue;
1433
1426
  }
1434
- if (event.type === 'system' && event.subtype === 'init') {
1427
+ // 归一化:init/turn_started 的 session 写回 + compact_boundary 用量观察。
1428
+ // 返回 true = 本轮 system.init 已到(调用方置 sawInit,供 pre-init 守卫用)。
1429
+ if (this.maybeSyncSession(event, { activeRun, writeback, usage, runId })) {
1435
1430
  sawInit = true;
1436
- // 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
1437
- // 结束后的 usage 读取仍以它为准。
1438
- activeRun.sessionId = event.session_id;
1439
- writeBackInitSession(writeback, event);
1440
- getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
1441
- }
1442
- if (event.type === 'turn_started') {
1443
- // app-server runner 在 turn setup 成功后补发 synthetic system.init
1444
- // (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
1445
- // threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
1446
- // 都新建线程(thread/start 而非 thread/resume),会话无法延续。
1447
- // init 缺失时以 threadId 作为本轮身份兜底。
1448
- activeRun.sessionId ??= event.threadId;
1449
- writeBackTurnStarted(writeback, event);
1450
- }
1451
- // Track context length from compaction events
1452
- if (event.type === 'system' &&
1453
- event.subtype === 'compact_boundary' &&
1454
- event.compactMetadata) {
1455
- usage.observeCompaction(event.compactMetadata.postTokens);
1456
1431
  }
1432
+ // pre-init result 守卫(§9.22,observeResultEvent):resume 重放的陈旧
1433
+ // result 不置 sawResult、不进卡片。
1457
1434
  if (event.type === 'result') {
1458
1435
  const outcome = this.observeResultEvent(event, {
1459
1436
  sawInit,
@@ -1471,11 +1448,12 @@ export class Bridge {
1471
1448
  // 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
1472
1449
  if (outcome.dropFromCard) {
1473
1450
  // 仍然重置 idle 计时——CLI 活着并且在产事件。
1474
- watchdog?.touch();
1451
+ this.handleEventForWatchdog(watchdog);
1475
1452
  continue;
1476
1453
  }
1477
1454
  }
1478
- // Approval events (Codex app-server mode)
1455
+ // Approval events (Codex app-server mode)。审批先到、item/started 后到的
1456
+ // 乱序流也在此补全(dispatchApprovalEvent 内 updateView)。
1479
1457
  if (event.type === 'approval_requested' ||
1480
1458
  event.type === 'approval_resolved' ||
1481
1459
  event.type === 'approval_view_updated') {
@@ -1485,7 +1463,8 @@ export class Bridge {
1485
1463
  // lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
1486
1464
  // 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
1487
1465
  // idle_timeout。旧实现 `else { resetIdle(); }` 漏掉 result 分支。
1488
- watchdog?.touch();
1466
+ this.handleEventForWatchdog(watchdog);
1467
+ // 渲染:reducer(run-state)在 cardSession.push 内部。
1489
1468
  await cardSession.push(event);
1490
1469
  }
1491
1470
  getLogger().info(`[lark-remote] runner stream end runId=${runId} sawResult=${sawResult}`);
@@ -1556,6 +1535,48 @@ export class Bridge {
1556
1535
  }
1557
1536
  }
1558
1537
  }
1538
+ /**
1539
+ * 事件循环的看门狗喂狗(runAgentStreamToEnd 抽出的纯向方法)。
1540
+ *
1541
+ * §9.12:事件到达只 `touch()` 刷新时间戳(无 timer 操作),interval 周期检查
1542
+ * 静默时长;喂狗点与红线口径见 runAgentStreamToEnd 循环内两处调用点注释
1543
+ * (每个事件含 result 都必须喂,被守卫丢弃的 result 也喂——CLI 活着就在产事件)。
1544
+ */
1545
+ handleEventForWatchdog(watchdog) {
1546
+ watchdog?.touch();
1547
+ }
1548
+ /**
1549
+ * 事件循环的 session 同步(runAgentStreamToEnd 抽出的纯向方法):
1550
+ * init / turn_started 两写回点 + compact_boundary 用量观察。
1551
+ *
1552
+ * @returns 事件是否为本轮 `system.init`(调用方据此置 sawInit,供 pre-init
1553
+ * result 守卫判定)。init 分支的全部副作用完成后才返回,与原内联顺序一致。
1554
+ */
1555
+ maybeSyncSession(event, opts) {
1556
+ const { activeRun, writeback, usage, runId } = opts;
1557
+ if (event.type === 'system' && event.subtype === 'init') {
1558
+ // 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
1559
+ // 结束后的 usage 读取仍以它为准。
1560
+ activeRun.sessionId = event.session_id;
1561
+ writeBackInitSession(writeback, event);
1562
+ getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
1563
+ return true;
1564
+ }
1565
+ if (event.type === 'turn_started') {
1566
+ // app-server runner 在 turn setup 成功后补发 synthetic system.init
1567
+ // (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
1568
+ // threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
1569
+ // 都新建线程(thread/start 而非 thread/resume),会话无法延续。
1570
+ // init 缺失时以 threadId 作为本轮身份兜底。
1571
+ activeRun.sessionId ??= event.threadId;
1572
+ writeBackTurnStarted(writeback, event);
1573
+ }
1574
+ // Track context length from compaction events
1575
+ if (event.type === 'system' && event.subtype === 'compact_boundary' && event.compactMetadata) {
1576
+ usage.observeCompaction(event.compactMetadata.postTokens);
1577
+ }
1578
+ return false;
1579
+ }
1559
1580
  /**
1560
1581
  * 终态 reaction 收尾:撤掉 Typing、打上下方终态表情。
1561
1582
  *
@@ -1597,10 +1618,15 @@ export class Bridge {
1597
1618
  this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind)),
1598
1619
  };
1599
1620
  const finalRender = renderRunCardDetailed(cardSession.currentState, finalRenderOptions);
1600
- this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
1621
+ const snapshot = this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind, cardSession.cardMessageId);
1601
1622
  let runCardSent = false;
1602
1623
  if (settleResult === 'unsent') {
1603
- const sent = await this.sendResult({ card: finalRender.card }, ctx);
1624
+ const sent = await this.sendResult({ card: finalRender.card }, ctx, {
1625
+ // 兜底发送的卡也要能接收后台任务更新 —— 流式卡没建起来时这是唯一来源。
1626
+ onCardSent: (messageId) => {
1627
+ snapshot.messageId = messageId;
1628
+ },
1629
+ });
1604
1630
  runCardSent = sent;
1605
1631
  }
1606
1632
  else {
@@ -1618,6 +1644,9 @@ export class Bridge {
1618
1644
  }
1619
1645
  // Add a terminal-state emoji reaction to the user's original message
1620
1646
  this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
1647
+ // 后台任务跨 turn:卡片收尾只是"这一轮的投影结束了",任务本身可能还在跑。
1648
+ // 订阅放在最后:卡片已落地,patch 才有落点。
1649
+ this.jobWatch.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
1621
1650
  }
1622
1651
  finally {
1623
1652
  // cleanup must survive mid-finalize errors. The only unguarded
@@ -1633,7 +1662,7 @@ export class Bridge {
1633
1662
  // runId,只能回「该任务已结束或不属于当前会话」。守卫只回答「cwd 槽位还
1634
1663
  // 归不归本 run」(决定能不能删槽位/evict runner),记账回答「这次 run
1635
1664
  // 结束了吗、用的哪个 sessionId」——后者与所有权无关,放在守卫外。
1636
- this.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
1665
+ this.compact.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
1637
1666
  if (this.activeRuns.get(cwd) === activeRun) {
1638
1667
  this.activeRuns.delete(cwd);
1639
1668
  // Workspace-lifetime runners (codex app-server) keep their persistent
@@ -1731,498 +1760,31 @@ export class Bridge {
1731
1760
  async handleApprovalAnswerNote(opts) {
1732
1761
  return this.approvalActions.handleApprovalAnswerNote(opts);
1733
1762
  }
1734
- /**
1735
- * 记下一次「可压缩 run」:终态卡上的 Compact 按钮点击时据此反查
1736
- * sessionId + agentKind。runId 是按钮自带的唯一键,同 cwd 内唯一。
1737
- * 超出上限按插入序淘汰最旧的一条(Map 保持插入序;重复写同一 runId
1738
- * 先 delete 再 set 以刷新位置)。
1739
- */
1740
- recordCompactableRun(cwd, runId, sessionId, agentKind) {
1741
- let byRunId = this.compactableRuns.get(cwd);
1742
- if (!byRunId) {
1743
- byRunId = new Map();
1744
- this.compactableRuns.set(cwd, byRunId);
1745
- }
1746
- byRunId.delete(runId);
1747
- byRunId.set(runId, { sessionId, agentKind, cwd });
1748
- while (byRunId.size > MAX_COMPACTABLE_RUNS_PER_CWD) {
1749
- const oldest = byRunId.keys().next().value;
1750
- if (oldest === undefined)
1751
- break;
1752
- byRunId.delete(oldest);
1753
- }
1754
- }
1755
- /**
1756
- * 终态 Compact 记账:把本次 run 实际使用的 sessionId 记进
1757
- * {@link compactableRuns},供终态卡上的 Compact 按钮按 runId 反查。
1758
- *
1759
- * 调用点固定在 `finalizeRun` 的 `finally` 里、**槽位所有权守卫之外**。
1760
- * 所有权守卫只回答「cwd 槽位还归不归本 run」(决定能否删槽位 / evict
1761
- * runner);记账回答「本次 run 结束了吗、用的哪个 sessionId」。两者在不同
1762
- * 维度上:外部中断(`/stop`、停止卡按钮、`queue.immediate`)会先摘槽位,
1763
- * 记账若跟着守卫走就永远不会落,中断卡片的按钮必然反查不到(中断
1764
- * 后点 Compact 一律回「该任务已结束或不属于当前会话」)。
1765
- *
1766
- * 覆盖所有终态入口(正常结束、error、interrupted、idle_timeout、
1767
- * goal_continuing)——这些卡片都渲染 Compact 按钮(`goal` 仍 active 时
1768
- * 除外)。记账失败不能影响清理,故内部兜错。
1769
- */
1770
- recordCompactableRunForTerminal(cardSession, activeRun, ctx) {
1771
- try {
1772
- if (!runnerSupportsCompact(activeRun.runner, activeRun.agentKind))
1773
- return;
1774
- if (activeRun.runner.lifetime !== 'workspace')
1775
- return;
1776
- const terminal = cardSession.currentState.terminal;
1777
- // 与事件消费守卫、Compact 按钮共用同一份终态集合(run-state.ts)。
1778
- if (!isRunTerminal(terminal))
1779
- return;
1780
- // 卡片状态优先(本轮 init/turn_started 写入);缺失时回退 store——
1781
- // 启动失败但已 /stop 的 run 没有自己的 init,压缩此前会话仍是有意义
1782
- // 的动作。
1783
- const compactSessionId = cardSession.currentState.sessionId ??
1784
- this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
1785
- if (!compactSessionId)
1786
- return;
1787
- this.recordCompactableRun(activeRun.cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
1788
- }
1789
- catch (err) {
1790
- getLogger().warn(`[lark-remote] compact record failed runId=${activeRun.runId}: ${errorMessage(err)}`);
1791
- }
1792
- }
1793
- /**
1794
- * 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
1795
- *
1796
- * 为什么不带 cwd 过滤:按钮属于「那一次 run」,与用户此刻在哪个工作目录、
1797
- * 排队期间有没有 /cd 无关。`/stop` 的 interruptCurrentRun 同样是全局按 runId
1798
- * 找(从不查当前 cwd),compact 与它保持一致。用当前 cwd 收窄会让「切了工作区
1799
- * 再点旧卡片」和「排队期间 /cd」两种情况误报「该任务已结束或不属于当前会话」。
1800
- */
1801
- findCompactableRun(runId) {
1802
- for (const byRunId of this.compactableRuns.values()) {
1803
- const record = byRunId.get(runId);
1804
- if (record)
1805
- return record;
1806
- }
1807
- return undefined;
1808
- }
1809
- /**
1810
- * Handle compact card action — trigger a compaction request for any
1811
- * runCompact-capable runner(codex/kimi/opencode/pi/claude)。按钮带的是
1812
- * 那次 run 的 runId,据此反查该 run 实际使用的 sessionId + agentKind
1813
- * + cwd(compactableRuns,按 runId 记录),再调 runner 的 runCompact()。
1814
- */
1763
+ // =============================================================================
1764
+ // Compact 动作入口
1765
+ // =============================================================================
1766
+ // 实现住在 compact.ts(bridge 结构治理第 1 刀):可压缩 run 记账、终态卡 /
1767
+ // resume 卡两条入口、kimi 在途检测与压缩流式卡全部搬过去;这里只留薄转发,
1768
+ // router 的卡片动作分发(handleCardAction → bridge.handleCompactAction /
1769
+ // handleResumeCompact)与 finalizeRun 的终态记账调用路径不变。
1770
+ /** 终态卡 Compact 按钮:按 runId 反查那次 run 的会话并发起压缩。 */
1815
1771
  async handleCompactAction(value, ctx) {
1816
- const log = getLogger();
1817
- log.info(`[lark-remote] handleCompactAction userId=${ctx.userId} runId=${value.runId?.slice(0, 8)}...`);
1818
- const { runId } = value;
1819
- if (!runId) {
1820
- await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 runId' }, ctx);
1821
- return;
1822
- }
1823
- // 校验:run 结束后 activeRuns 已清空,只能靠按钮自带的 runId 反查记录。
1824
- // 按 runId 存(不按「最新的那一条」),点击到执行之间夹了新 run 收尾也不失效;
1825
- // 查找也不按「当前 cwd」收窄(见 findCompactableRun)。
1826
- const target = this.findCompactableRun(runId);
1827
- if (!target) {
1828
- await this.sendResult({ text: '⚠️ 该任务已结束或不属于当前会话' }, ctx);
1829
- return;
1830
- }
1831
- // 压缩必须用那次 run 自己的 cwd(session 文件 + runner 实例都按 cwd 解析)。
1832
- const cwd = target.cwd;
1833
- // §5.3: kimi 压缩在途检测(wire 状态机)。第二次点击在本地毫秒级如实回答,
1834
- // 不开卡、不发 session/prompt——否则 kimi 服务端拒绝并产生「秒回」假完成。
1835
- if (await this.rejectIfCompactionInFlight(target.sessionId, cwd, target.agentKind, ctx)) {
1836
- return;
1837
- }
1838
- // Check that the runner has runCompact method(声明表 && runner 实现)
1839
- const runner = this.getRunner(cwd, target.agentKind, { replaceStale: true });
1840
- if (!runnerSupportsCompact(runner, target.agentKind)) {
1841
- await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1842
- return;
1843
- }
1844
- log.info(`[lark-remote] handleCompactAction executing runCompact for runId=${runId} cwd=${cwd}`);
1845
- await this.streamCompact({
1846
- sessionId: target.sessionId,
1847
- cwd,
1848
- agentKind: target.agentKind,
1849
- runner,
1850
- ctx,
1851
- });
1852
- }
1853
- /**
1854
- * §5.3: kimi 压缩在途检测——reader 实现契约的 `readCompactionState` 时,若
1855
- * wire 状态机判定当前有未终结的压缩(最后一次 begin 无 terminal 记录),
1856
- * 直接回文本「已有一次 Compact 正在进行」并返回 true(调用方不开卡、不发
1857
- * session/prompt)。codex/claude 等 reader 无此方法 → 返回 false,行为不变。
1858
- */
1859
- async rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx) {
1860
- const reader = this.sessionReaderRegistry.get(agentKind);
1861
- if (!reader?.readCompactionState)
1862
- return false;
1863
- try {
1864
- const state = reader.readCompactionState(sessionId, cwd);
1865
- if (state?.inFlight) {
1866
- const since = state.inFlightSince;
1867
- let timeLabel = '未知时刻';
1868
- if (since !== undefined) {
1869
- const d = new Date(since);
1870
- const hh = String(d.getHours()).padStart(2, '0');
1871
- const mm = String(d.getMinutes()).padStart(2, '0');
1872
- timeLabel = `${hh}:${mm}`;
1873
- }
1874
- await this.sendResult({ text: `⚠️ 已有一次 Compact 正在进行(开始于 ${timeLabel}),请等待完成` }, ctx);
1875
- return true;
1876
- }
1877
- }
1878
- catch (err) {
1879
- getLogger().warn(`[lark-remote] readCompactionState failed sessionId=${sessionId}: ${errorMessage(err)}`);
1880
- }
1881
- return false;
1772
+ await this.compact.handleCompactAction(value, ctx);
1882
1773
  }
1883
- /**
1884
- * Stream a compaction to a card: start a RunCardSession with
1885
- * operationKind='compaction' (no recursive Compact button), consume
1886
- * runner.runCompact() events, read authoritative jsonl usage, and finish
1887
- * the card. Shared by handleCompactAction (run card button) and
1888
- * handleResumeCompact (resume cards) so both flows stay in sync.
1889
- *
1890
- * Precondition: callers have already validated that `runner` implements
1891
- * runCompact and that the session exists in `cwd`.
1892
- */
1893
- async streamCompact(opts) {
1894
- const log = getLogger();
1895
- const { sessionId, cwd, agentKind, runner, ctx } = opts;
1896
- log.info(`[lark-remote] streamCompact sessionId=${sessionId} cwd=${cwd}`);
1897
- // 防御性能力检查:调用方已校验(runId / resume 两条路径),此处双保险并
1898
- // 让 TS 收窄 runner 类型('runCompact' in runner 之后的 cast 才合法)。
1899
- if (!runnerSupportsCompact(runner, agentKind) ||
1900
- !('runCompact' in runner) ||
1901
- typeof runner.runCompact !== 'function') {
1902
- await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1903
- return;
1904
- }
1905
- // activeRuns 以 cwd 为键:注册前先确认槽位空闲,否则压缩会覆盖在跑的 run 的
1906
- // 记录,令 /stop 只停到最后注册的那个。
1907
- const busyWith = this.activeRuns.get(cwd);
1908
- if (busyWith) {
1909
- log.info(`[lark-remote] compact refused: cwd busy cwd=${cwd} runId=${busyWith.runId}`);
1910
- await this.sendResult({ text: '⚠️ 该工作区正在运行任务,请等待结束或先 /stop' }, ctx);
1911
- return;
1912
- }
1913
- await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
1914
- const compactRunId = randomUUID();
1915
- // 渲染选项同时用于流式卡与「卡片流没建起来」时的静态兜底卡——两者必须
1916
- // 同门控,否则兜底卡会渲染出与本次 run 不一致的按钮。
1917
- const renderOptions = { agentKind };
1918
- const cardSession = new RunCardSession({
1919
- connector: this.connector,
1920
- chatId: ctx.chatId,
1921
- replyTo: ctx.messageId,
1922
- runId: compactRunId,
1923
- renderOptions,
1924
- });
1925
- // 压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
1926
- // /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
1927
- // 压缩都只能等 runner 级超时,用户既停不掉也看不出它还在跑。
1928
- const activeRun = {
1929
- runId: compactRunId,
1930
- userId: ctx.userId,
1931
- chatId: ctx.chatId,
1932
- session: cardSession,
1933
- cwd,
1934
- runner,
1935
- agentKind,
1936
- };
1937
- this.activeRuns.set(cwd, activeRun);
1938
- getLogger().info(`[lark-remote] activeRuns.set cwd=${cwd} runId=${compactRunId} compact=true`);
1939
- let watchdog = null;
1940
- try {
1941
- await cardSession.start();
1942
- watchdog = this.startIdleWatchdog({
1943
- runId: compactRunId,
1944
- session: cardSession,
1945
- runner,
1946
- });
1947
- // operationKind=compaction:卡片不渲染 Compact 按钮(防递归 Compact)。
1948
- await cardSession.push({
1949
- type: 'turn_started',
1950
- threadId: sessionId,
1951
- turnId: '',
1952
- operationKind: 'compaction',
1953
- });
1954
- let sawResult = false;
1955
- let resultSubtype;
1956
- let resultError;
1957
- for await (const event of runner.runCompact('', { cwd, sessionId })) {
1958
- // 已终态(/stop、空闲看门狗)后不再消费事件:与 runAgentStreamToEnd 同口径,
1959
- // 否则迟到的 result 会把终态卡改写成「已完成」。
1960
- const cardTerminal = cardSession.currentState.terminal;
1961
- if (isRunTerminal(cardTerminal)) {
1962
- continue;
1963
- }
1964
- if (event.type === 'result') {
1965
- sawResult = true;
1966
- resultSubtype = event.subtype;
1967
- resultError = event.errorMessage;
1968
- }
1969
- watchdog?.touch();
1970
- await cardSession.push(event);
1971
- }
1972
- // §5.3: 卡片终态必须等于引擎真实终态。旧实现只看 sawResult 布尔——error
1973
- // subtype 的 result 也被 finish 'done'(放大器 bug,2026-08-31 事故)。
1974
- const terminal = resultSubtype === 'success'
1975
- ? 'done'
1976
- : resultSubtype === 'interrupted'
1977
- ? 'interrupted'
1978
- : 'error';
1979
- // 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
1980
- // token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
1981
- // (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
1982
- // 不支持 compactCount 的 reader(opencode/dsh)不重试——否则每次 compact
1983
- // 都会固定多跑两次完整 export/读取,且永远等不到该字段。
1984
- const compactCountSupported = this.sessionReaderRegistry.tryGet(agentKind)?.supportsCompactCount !== false;
1985
- let finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
1986
- for (let attempt = 0; compactCountSupported && !finalUsage?.compactCount && attempt < 2; attempt++) {
1987
- await sleep(150);
1988
- finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
1989
- }
1990
- await cardSession.finish(terminal, {
1991
- resultSubtype: resultSubtype ?? 'error',
1992
- errorMsg: terminal === 'error' ? (resultError ?? '未收到压缩结果') : undefined,
1993
- // finalUsage 的字段形态就是 FinishMeta 的用量部分(同为 UsageSnapshot),
1994
- // 逐字段抄的那 14 行随单源一起去掉。
1995
- ...(finalUsage ?? {}),
1996
- });
1997
- log.info(`[lark-remote] streamCompact finished sessionId=${sessionId} sawResult=${sawResult} subtype=${resultSubtype ?? 'none'}`);
1998
- }
1999
- catch (err) {
2000
- log.error(`[lark-remote] streamCompact failed: ${errorMessage(err)}`);
2001
- // 终态守卫(与 runAgentStreamToEnd 同口径):running/finalizing 才转 error,
2002
- // 已终态(interrupted/idle_timeout)保留首终态,不被迟到的异常改写。
2003
- const catchTerminal = cardSession.currentState.terminal;
2004
- if (!isRunTerminal(catchTerminal)) {
2005
- await cardSession.finish('error', { errorMsg: errorMessage(err) });
2006
- }
2007
- else {
2008
- log.info(`[lark-remote] skip compact error finish: state already terminal (${catchTerminal}) runId=${compactRunId}`);
2009
- }
2010
- }
2011
- finally {
2012
- watchdog?.stop();
2013
- // 与 run 路径同口径的降级:卡片流没建起来(start 失败 / stream 未收尾)时,
2014
- // 终态只存在于内存里。settle() 释放 producer 并等 stream 收尾;'unsent'
2015
- // 表示用户手里没有卡,必须补一张静态终态卡,否则只剩「Compact 已触发」
2016
- // 和一条 reaction,用户既不知道失败也不知道原因。
2017
- const settled = await cardSession.settle();
2018
- if (settled === 'unsent') {
2019
- const finalRender = renderRunCardDetailed(cardSession.currentState, renderOptions);
2020
- await this.sendResult({ card: finalRender.card }, ctx);
2021
- }
2022
- // 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
2023
- if (this.activeRuns.get(cwd) === activeRun) {
2024
- this.activeRuns.delete(cwd);
2025
- getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${compactRunId}`);
2026
- }
2027
- // 终态 reaction:与普通 run 同一口径(done→Done / error→ERROR /
2028
- // interrupted→SHHH / idle_timeout→Alarm)。
2029
- this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
2030
- }
2031
- }
2032
- /**
2033
- * Handle resume.compact card action — compact a session directly from a
2034
- * resume card (auto-resume / `/resume <id>`), without a runId.
2035
- *
2036
- * Unlike handleCompactAction (which validates against the last finished run's
2037
- * runId), the resume card carries the sessionId + agent it was rendered for.
2038
- * Validation: the session must exist in the current cwd (same rule as
2039
- * cmdResume / resume.use) and the runner must implement runCompact.
2040
- */
1774
+ /** resume 卡 Compact 按钮:直接压缩卡上指定的 session(无需 runId)。 */
2041
1775
  async handleResumeCompact(value, ctx) {
2042
- const log = getLogger();
2043
- log.info(`[lark-remote] handleResumeCompact userId=${ctx.userId} sessionId=${value.sessionId?.slice(0, 16)} agent=${value.agent ?? 'default'}`);
2044
- const { sessionId } = value;
2045
- if (!sessionId) {
2046
- await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 sessionId' }, ctx);
2047
- return;
2048
- }
2049
- const cwd = this.resolveCwd(ctx.userId);
2050
- if (!cwd) {
2051
- await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
2052
- return;
2053
- }
2054
- // Resolve agent kind the same way resume.use does: card value wins,
2055
- // otherwise fall back to defaultAgent. 名单来自单源 AGENT_KINDS —— 手抄一份
2056
- // 必然再次漂移(这份曾经漏 dsh:带 dsh 的卡片会静默退回 defaultAgent,
2057
- // 用错 reader 报「未找到 session」)。
2058
- const agentKind = value.agent && AGENT_KINDS.includes(value.agent)
2059
- ? value.agent
2060
- : this.config.defaultAgent;
2061
- // Session must exist in the current cwd (same rule as cmdResume /
2062
- // resume.use): reject ghosts so a stale card cannot compact a session
2063
- // from another cwd. Read failures get visible feedback (card click
2064
- // 红线:不许静默失败)。
2065
- let content;
2066
- try {
2067
- content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd);
2068
- }
2069
- catch (err) {
2070
- await this.sendResult({ text: `⚠️ 读取 session ${sessionId} 失败: ${errorMessage(err)}` }, ctx);
2071
- return;
2072
- }
2073
- if (content.events.length === 0 &&
2074
- !content.usage &&
2075
- !content.aiTitle &&
2076
- !content.recap &&
2077
- !content.displayTitle) {
2078
- await this.sendResult({ text: `⚠️ 未找到 session ${sessionId}(当前目录: ${cwd})` }, ctx);
2079
- return;
2080
- }
2081
- // §5.3: kimi 压缩在途检测(与 handleCompactAction 同一入口)。
2082
- if (await this.rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx))
2083
- return;
2084
- // Check that the runner has runCompact(声明表 && runner 实现;
2085
- // codex/kimi/opencode/pi/claude/dsh 均已实现)。
2086
- const runner = this.getRunner(cwd, agentKind);
2087
- if (!runnerSupportsCompact(runner, agentKind)) {
2088
- await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
2089
- return;
2090
- }
2091
- log.info(`[lark-remote] handleResumeCompact executing runCompact sessionId=${sessionId} cwd=${cwd}`);
2092
- await this.streamCompact({ sessionId, cwd, agentKind, runner, ctx });
1776
+ await this.compact.handleResumeCompact(value, ctx);
2093
1777
  }
2094
- /**
2095
- * Execute a bash command and stream output to a card.
2096
- * `!` commands bypass the serial queue entirely (see body comment + §9.6):
2097
- * bash runs in parallel with same-workspace agent runs, tracked in
2098
- * `activeBashRuns` separately from `activeRuns`.
2099
- */
1778
+ // =============================================================================
1779
+ // `!` bash 命令
1780
+ // =============================================================================
1781
+ // 实现住在 bash-run.ts(bridge 结构治理第 3 刀):executeBash 薄壳、事件流
1782
+ // 消费、独立登记表(activeBashRuns,不进 activeRuns)与 /stop 的 bash 侧
1783
+ // 匹配全部搬过去。这里只留薄转发——router 的 `!` 分发(executeBash)与
1784
+ // /active 投影(getActiveBashRuns)按 bridge 公开 API 调用。
1785
+ /** Execute a bash command and stream output to a card(`!` 命令入口)。 */
2100
1786
  async executeBash(command, ctx) {
2101
- const cwd = this.resolveCwd(ctx.userId);
2102
- getLogger().info(`[lark-remote] executeBash start userId=${ctx.userId} sessionCwd=${this.sessionStore.get(ctx.userId)?.cwd} command="${command.slice(0, 30)}..."`);
2103
- if (!cwd) {
2104
- getLogger().info(`[lark-remote] executeBash no cwd, sending error`);
2105
- await this.sendResult({ text: '请先使用 /cd <path> 设置工作目录,或保存一个 workspace (/ws save <name>)' }, ctx);
2106
- return;
2107
- }
2108
- getLogger().info(`[lark-remote] executeBash proceeding cwd=${cwd}`);
2109
- // `!` commands bypass the serial queue entirely (design.md §9.6):
2110
- // index.ts dispatches `!` to router.handle → executeBash directly, NOT via
2111
- // bridge.enqueue. bash runs in parallel with same-workspace claude runs.
2112
- // bash runs are tracked in `activeBashRuns` (keyed by runId), separate from
2113
- // claude's `activeRuns` (keyed by cwd) — they must not collide or `/stop`
2114
- // would kill the wrong task. Do NOT re-chain onto the serial queue
2115
- // (QueueManager) here: that would block on the still-pending outer promise
2116
- // and deadlock self-wait.
2117
- await this.executeBashInternal(command, ctx, cwd);
2118
- }
2119
- /**
2120
- * Internal execution of bash command (called directly from executeBash,
2121
- * bypassing the serial queue — see executeBash).
2122
- */
2123
- async executeBashInternal(command, ctx, cwd) {
2124
- getLogger().debug(`[lark-remote] executeBashInternal userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} command="${command.slice(0, 50)}..."`);
2125
- // Note: We don't check activeRuns here because bash bypasses the serial
2126
- // queue and runs in parallel with same-workspace agent runs (§9.6). The
2127
- // activeRuns check in forwardToClaude is for a different purpose (detecting
2128
- // concurrent agent runs that would corrupt session state). Bash commands
2129
- // don't have that issue.
2130
- const runId = randomUUID();
2131
- const bashRunner = new BashProcessRunner();
2132
- // Single streaming card across the whole run (initial → output patches →
2133
- // terminal), instead of sending many independent cards. BashCardSession is
2134
- // the bash analogue of RunCardSession.
2135
- const cardSession = new BashCardSession({
2136
- connector: this.connector,
2137
- chatId: ctx.chatId,
2138
- replyTo: ctx.messageId,
2139
- runId,
2140
- command,
2141
- renderOptions: {},
2142
- });
2143
- // Track this bash run independently from claude's `activeRuns`. Bash runs
2144
- // bypass the serial queue and may run in parallel with a claude run in the
2145
- // same cwd, so they must NOT share activeRuns (which is keyed by cwd
2146
- // and would let bash/claude overwrite each other). Keyed by runId so
2147
- // multiple `!` commands can run concurrently. Tracked so /stop can reach
2148
- // the bashRunner (which is otherwise a local) via interruptCurrentRun.
2149
- this.activeBashRuns.set(runId, {
2150
- bashRunner,
2151
- cardSession,
2152
- userId: ctx.userId,
2153
- chatId: ctx.chatId,
2154
- cwd,
2155
- command,
2156
- });
2157
- let output = '';
2158
- let stderr = '';
2159
- let exitCode = null;
2160
- try {
2161
- getLogger().info(`[lark-remote] executeBashInternal about to start stream chatId=${ctx.chatId} messageId=${ctx.messageId}`);
2162
- try {
2163
- await cardSession.start();
2164
- getLogger().info(`[lark-remote] executeBash stream started runId=${runId}`);
2165
- }
2166
- catch (err) {
2167
- getLogger().warn('[lark-remote] bash card stream unavailable:', err);
2168
- }
2169
- for await (const event of bashRunner.run(command, { cwd })) {
2170
- if (event.type === 'stdout') {
2171
- // 层④:bridge 本地 output 也必须 store-time 截断——仅 session state
2172
- // 有界并不会让 `output +=` 自然有界(「store 有界则本地拼接自然有界」
2173
- // 不成立),!yes 洪峰下本地字符串仍全量驻留。capBashOutput 增量调用
2174
- // O(CAP+chunk),不会带回 O(n²) 截断成本(output 恒 ≤ CAP,拼接后
2175
- // ≤ CAP+chunk 再 slice 尾部)。
2176
- output = capBashOutput(output + event.content);
2177
- await cardSession.update({ output, stderr });
2178
- }
2179
- else if (event.type === 'stderr') {
2180
- stderr = capBashOutput(stderr + event.content);
2181
- await cardSession.update({ output, stderr });
2182
- }
2183
- else if (event.type === 'exit') {
2184
- exitCode = event.exitCode ?? 0;
2185
- }
2186
- }
2187
- await cardSession.finish(exitCode === 0 ? 'done' : 'error', { exitCode, output, stderr });
2188
- }
2189
- catch (err) {
2190
- getLogger().error(`[lark-remote] bash run error runId=${runId}:`, err);
2191
- // If for-await threw mid-stream the bash child may still be running.
2192
- // Stop it before finalizing the card — otherwise the finally block
2193
- // removes the activeBashRuns entry and /stop can no longer reach it,
2194
- // leaving an orphan process.
2195
- try {
2196
- await bashRunner.stop();
2197
- }
2198
- catch (stopErr) {
2199
- getLogger().warn(`[lark-remote] bash stop failed:`, stopErr);
2200
- }
2201
- await cardSession.finish('error', {
2202
- exitCode: 1,
2203
- output,
2204
- stderr: stderr + `\n执行错误: ${errorMessage(err)}`,
2205
- });
2206
- }
2207
- finally {
2208
- const settleResult = await cardSession.settle();
2209
- if (settleResult === 'unsent') {
2210
- // Stream never established a card — send a static terminal card as fallback.
2211
- // Use 2.0 schema to match the streaming card (avoid 2.0→1.x downgrade).
2212
- try {
2213
- await this.connector.sendWithRetry(ctx.chatId, { card: renderBashCard(cardSession.currentState, {}) }, { replyTo: ctx.messageId });
2214
- }
2215
- catch (fallbackErr) {
2216
- getLogger().error(`[lark-remote] bash terminal fallback card failed runId=${runId} chatId=${ctx.chatId}:`, fallbackErr);
2217
- }
2218
- }
2219
- if (this.activeBashRuns.has(runId)) {
2220
- this.activeBashRuns.delete(runId);
2221
- getLogger().info(`[lark-remote] executeBash done runId=${runId} exitCode=${exitCode}`);
2222
- }
2223
- // Add a Done emoji reaction to the user's original message to signal completion
2224
- this.finishTurnReactions(ctx, 'Done');
2225
- }
1787
+ await this.bashRuns.executeBash(command, ctx);
2226
1788
  }
2227
1789
  }
2228
1790
  //# sourceMappingURL=index.js.map