botmux 2.24.4 → 2.24.6

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 (81) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
  4. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
  5. package/dist/adapters/backend/session-backend-selector.js +26 -0
  6. package/dist/adapters/backend/session-backend-selector.js.map +1 -0
  7. package/dist/adapters/backend/tmux-pipe-backend.d.ts +55 -15
  8. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  9. package/dist/adapters/backend/tmux-pipe-backend.js +163 -21
  10. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  11. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  12. package/dist/adapters/cli/claude-code.js +91 -15
  13. package/dist/adapters/cli/claude-code.js.map +1 -1
  14. package/dist/adapters/cli/shared-hints.js +2 -2
  15. package/dist/adapters/cli/shared-hints.js.map +1 -1
  16. package/dist/cli/arg-utils.d.ts +11 -0
  17. package/dist/cli/arg-utils.d.ts.map +1 -0
  18. package/dist/cli/arg-utils.js +25 -0
  19. package/dist/cli/arg-utils.js.map +1 -0
  20. package/dist/cli/quoted-render.d.ts +30 -0
  21. package/dist/cli/quoted-render.d.ts.map +1 -0
  22. package/dist/cli/quoted-render.js +29 -0
  23. package/dist/cli/quoted-render.js.map +1 -0
  24. package/dist/cli.js +65 -16
  25. package/dist/cli.js.map +1 -1
  26. package/dist/core/worker-pool.d.ts.map +1 -1
  27. package/dist/core/worker-pool.js +34 -19
  28. package/dist/core/worker-pool.js.map +1 -1
  29. package/dist/daemon.d.ts.map +1 -1
  30. package/dist/daemon.js +13 -4
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/im/lark/client.d.ts +7 -7
  33. package/dist/im/lark/client.d.ts.map +1 -1
  34. package/dist/im/lark/client.js +14 -15
  35. package/dist/im/lark/client.js.map +1 -1
  36. package/dist/im/lark/md-card.d.ts +21 -0
  37. package/dist/im/lark/md-card.d.ts.map +1 -1
  38. package/dist/im/lark/md-card.js +63 -0
  39. package/dist/im/lark/md-card.js.map +1 -1
  40. package/dist/im/lark/message-parser.d.ts +9 -3
  41. package/dist/im/lark/message-parser.d.ts.map +1 -1
  42. package/dist/im/lark/message-parser.js +13 -5
  43. package/dist/im/lark/message-parser.js.map +1 -1
  44. package/dist/im/lark/quote-hint.d.ts +18 -0
  45. package/dist/im/lark/quote-hint.d.ts.map +1 -0
  46. package/dist/im/lark/quote-hint.js +23 -0
  47. package/dist/im/lark/quote-hint.js.map +1 -0
  48. package/dist/services/coco-transcript.d.ts +11 -8
  49. package/dist/services/coco-transcript.d.ts.map +1 -1
  50. package/dist/services/coco-transcript.js +77 -40
  51. package/dist/services/coco-transcript.js.map +1 -1
  52. package/dist/services/codex-transcript.d.ts +26 -10
  53. package/dist/services/codex-transcript.d.ts.map +1 -1
  54. package/dist/services/codex-transcript.js +95 -30
  55. package/dist/services/codex-transcript.js.map +1 -1
  56. package/dist/skills/definitions.d.ts +4 -0
  57. package/dist/skills/definitions.d.ts.map +1 -1
  58. package/dist/skills/definitions.js +69 -12
  59. package/dist/skills/definitions.js.map +1 -1
  60. package/dist/skills/installer.d.ts +3 -1
  61. package/dist/skills/installer.d.ts.map +1 -1
  62. package/dist/skills/installer.js +18 -3
  63. package/dist/skills/installer.js.map +1 -1
  64. package/dist/types.d.ts +5 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/utils/logger.d.ts +1 -1
  67. package/dist/utils/logger.js +1 -1
  68. package/dist/utils/screenshot-renderer.d.ts.map +1 -1
  69. package/dist/utils/screenshot-renderer.js +63 -26
  70. package/dist/utils/screenshot-renderer.js.map +1 -1
  71. package/dist/utils/terminal-renderer.d.ts +16 -0
  72. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  73. package/dist/utils/terminal-renderer.js +35 -21
  74. package/dist/utils/terminal-renderer.js.map +1 -1
  75. package/dist/utils/transient-snapshot.d.ts +28 -0
  76. package/dist/utils/transient-snapshot.d.ts.map +1 -0
  77. package/dist/utils/transient-snapshot.js +96 -0
  78. package/dist/utils/transient-snapshot.js.map +1 -0
  79. package/dist/worker.js +172 -71
  80. package/dist/worker.js.map +1 -1
  81. package/package.json +1 -1
package/dist/worker.js CHANGED
@@ -20,7 +20,7 @@ import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './ser
20
20
  import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
21
21
  import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
22
22
  import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
23
- import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff } from './services/codex-transcript.js';
23
+ import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff, extractLastCodexTurn } from './services/codex-transcript.js';
24
24
  import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
25
25
  import { dirname } from 'node:path';
26
26
  import { createServer as createHttpServer } from 'node:http';
@@ -29,13 +29,14 @@ import { TerminalRenderer } from './utils/terminal-renderer.js';
29
29
  import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_ROWS, MIN_RENDER_COLS, MIN_RENDER_ROWS, clamp, resolveRenderDimensions, } from './utils/render-dimensions.js';
30
30
  import { createCliAdapterSync } from './adapters/cli/registry.js';
31
31
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds } from './adapters/cli/claude-code.js';
32
- import { PtyBackend } from './adapters/backend/pty-backend.js';
33
32
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
34
33
  import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
34
+ import { selectSessionBackend } from './adapters/backend/session-backend-selector.js';
35
35
  import { tmuxEnv } from './setup/ensure-tmux.js';
36
36
  import { IdleDetector } from './utils/idle-detector.js';
37
37
  import { ScreenAnalyzer } from './utils/screen-analyzer.js';
38
38
  import { captureToPng } from './utils/screenshot-renderer.js';
39
+ import { snapshotToPng, snapshotToText } from './utils/transient-snapshot.js';
39
40
  import { uploadImageBuffer } from './utils/lark-upload.js';
40
41
  import { config } from './config.js';
41
42
  import * as sessionStore from './services/session-store.js';
@@ -164,6 +165,11 @@ let codexAdoptPendingPid;
164
165
  * but BEFORE the rollout was located still reach the Lark thread. 5s
165
166
  * skew tolerance is applied on top, mirroring the Lark/Claude bridges. */
166
167
  let codexAdoptStartMs;
168
+ /** Adopt-only: 一次性发送的 "/adopt 前最后一轮" preamble 是否已经触发过。
169
+ * codexBridgeAttach 在 split-live 分支会查 history 取最后一对 user/assistant
170
+ * 发给 daemon —— late-attach poller 也会反复走这条分支(每秒一次),所以
171
+ * 必须有标志位防重发。镜像 claude 那套 bridgePreambleSent 的角色。 */
172
+ let codexBridgePreambleSent = false;
167
173
  /** Cap the preamble text so an extremely long previous turn doesn't blow
168
174
  * past Lark's per-message limit. The user only needs enough to recall
169
175
  * context, not the entire transcript. */
@@ -179,41 +185,23 @@ function truncatePreambleText(text, max) {
179
185
  return text;
180
186
  return text.slice(0, max) + '…';
181
187
  }
182
- /** Compose a `final_output` payload for a turn synthesised from a user
183
- * prompt the human typed directly into the adopted pane. Shows both the
184
- * user text and assistant text so the Lark thread doesn't see an orphan
185
- * reply with no context. Returns `null` when neither side has anything
186
- * visible the worker should suppress the emit in that case. */
187
- function formatLocalTurnContent(userText, assistantText) {
188
+ /** Prepare a local-turn `final_output` payload. The daemon owns the card
189
+ * chrome (label/quote/markdown body), so we ship the user prompt and
190
+ * assistant text as separate fields see card-builder `buildContextualReplyCard`.
191
+ * Returns null when both sides are empty so the caller can skip the emit. */
192
+ function formatLocalTurnFields(userText, assistantText) {
188
193
  const u = truncatePreambleText(userText.trim(), LOCAL_TURN_USER_MAX);
189
194
  const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
190
195
  if (!u && !a)
191
196
  return null;
192
- return [
193
- '🖥️ 终端本地对话(在 adopted pane 中直接输入,已同步至飞书)',
194
- '',
195
- '👤 你:',
196
- u || '(空)',
197
- '',
198
- `🤖 ${cliName()}:`,
199
- a || '(空)',
200
- ].join('\n');
201
- }
202
- /** Compose a `final_output` payload for a HEADLESS local turn — assistant
203
- * text arrived without a known user side. Typically: daemon restart cut
204
- * off an in-flight model stream; the original user event was absorbed
205
- * at baseline so we have no userUuid to resolve, but the rest of the
206
- * reply still deserves to land in Lark. */
197
+ return { userText: u, content: a };
198
+ }
199
+ /** Same as `formatLocalTurnFields` but for HEADLESS local turns — daemon
200
+ * restart cut off an in-flight model stream so we have an assistant side
201
+ * with no resolvable user prompt. */
207
202
  function formatHeadlessLocalTurnContent(assistantText) {
208
203
  const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
209
- if (!a)
210
- return null;
211
- return [
212
- '🖥️ 终端本地对话续传(daemon 重启时模型正在输出)',
213
- '',
214
- `🤖 ${cliName()}:`,
215
- a,
216
- ].join('\n');
204
+ return a || null;
217
205
  }
218
206
  // ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
219
207
  //
@@ -290,6 +278,30 @@ function maybeEmitAdoptPreamble(events) {
290
278
  });
291
279
  log('Bridge adopt preamble emitted (last completed turn from baseline)');
292
280
  }
281
+ /** Codex / CoCo 镜像版:split-live 攒齐 history 后挑最后一对 user/assistant_final
282
+ * 发回 daemon 渲染成 "📜 /adopt 前最后一轮" 卡片。语义、跳过条件、字数截断都
283
+ * 对齐 maybeEmitAdoptPreamble;区别只在事件取出方式(codex/coco 是结构化
284
+ * event,不需要走 claude 那套 jsonl turn assembly)。 */
285
+ function maybeEmitCodexAdoptPreamble(history) {
286
+ if (!lastInitConfig?.adoptMode)
287
+ return;
288
+ if (lastInitConfig?.adoptRestoredFromMetadata)
289
+ return;
290
+ if (codexBridgePreambleSent)
291
+ return;
292
+ const turn = extractLastCodexTurn(history);
293
+ if (!turn)
294
+ return;
295
+ if (!turn.userText.trim() && !turn.assistantText.trim())
296
+ return;
297
+ codexBridgePreambleSent = true;
298
+ send({
299
+ type: 'adopt_preamble',
300
+ userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
301
+ assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
302
+ });
303
+ log('Codex bridge adopt preamble emitted (last completed turn from split-live history)');
304
+ }
293
305
  /** Extract the sessionId from a Claude jsonl path and add it to the
294
306
  * known-sid set. Validates the filename against Claude's UUID-shaped
295
307
  * sessionId pattern so non-Claude jsonls in the project dir (accidental
@@ -1150,18 +1162,31 @@ function emitReadyTurns() {
1150
1162
  // attachment.prompt) so type-ahead'd local input renders the same as
1151
1163
  // a normally-typed pane prompt.
1152
1164
  const userEv = drained.events.find(e => e.uuid === turn.userUuid);
1153
- const userText = userEv ? extractTurnStartText(userEv) : '';
1154
- const content = formatLocalTurnContent(userText, assistantText);
1155
- if (!content)
1165
+ const rawUserText = userEv ? extractTurnStartText(userEv) : '';
1166
+ const fields = formatLocalTurnFields(rawUserText, assistantText);
1167
+ if (!fields)
1156
1168
  continue;
1157
- send({ type: 'final_output', content, lastUuid, turnId: turn.turnId });
1169
+ send({
1170
+ type: 'final_output',
1171
+ content: fields.content,
1172
+ lastUuid,
1173
+ turnId: turn.turnId,
1174
+ kind: 'local-turn',
1175
+ userText: fields.userText,
1176
+ });
1158
1177
  continue;
1159
1178
  }
1160
1179
  // Headless local turn — see formatHeadlessLocalTurnContent for context.
1161
1180
  const headlessContent = formatHeadlessLocalTurnContent(assistantText);
1162
1181
  if (!headlessContent)
1163
1182
  continue;
1164
- send({ type: 'final_output', content: headlessContent, lastUuid, turnId: turn.turnId });
1183
+ send({
1184
+ type: 'final_output',
1185
+ content: headlessContent,
1186
+ lastUuid,
1187
+ turnId: turn.turnId,
1188
+ kind: 'local-turn-headless',
1189
+ });
1165
1190
  continue;
1166
1191
  }
1167
1192
  send({ type: 'final_output', content: assistantText, lastUuid, turnId: turn.turnId });
@@ -1301,6 +1326,7 @@ function codexBridgeAttach(rolloutPath, mode) {
1301
1326
  codexBridgePendingTail = result.pendingTail;
1302
1327
  codexBridgeBaselineDone = true;
1303
1328
  log(`Codex bridge split-live: ${rolloutPath} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${codexBridgeOffset})`);
1329
+ maybeEmitCodexAdoptPreamble(history);
1304
1330
  }
1305
1331
  else if (mode === 'split-live') {
1306
1332
  // split-live requested but file missing — degrade to fresh: the file
@@ -1341,6 +1367,12 @@ function codexBridgeAttach(rolloutPath, mode) {
1341
1367
  catch (err) {
1342
1368
  log(`Codex bridge fs.watch unavailable (${err.message}); relying on poller`);
1343
1369
  }
1370
+ // macOS 上 fs.watch 对 codex/coco 的外部进程追加 rollout / events.jsonl
1371
+ // 经常静默丢事件(FSEvents 跨进程不可靠),所以无论 watcher 是否 attach
1372
+ // 成功,都必须起 1s poller 兜底 —— 不然 split-live 成功的 adopt session
1373
+ // 在 macOS 上会卡死,永远收不到模型回复。Linux 上 poller 多 tick 也无害
1374
+ // (codexBridgeIngest 在 offset 未推进时是 no-op)。
1375
+ codexBridgeStartTimer();
1344
1376
  }
1345
1377
  /** Called from flushPending after writeInput first returns a cliSessionId.
1346
1378
  * Tries to locate the rollout file immediately; if it's not on disk yet,
@@ -1421,12 +1453,19 @@ function emitReadyCodexTurns() {
1421
1453
  if (turn.isLocal) {
1422
1454
  // Local turn (adopt only): user typed in iTerm. Surface both sides
1423
1455
  // so the Lark thread sees a complete exchange instead of an orphan
1424
- // reply. formatLocalTurnContent caps both texts to keep within
1425
- // Lark's per-message limit.
1426
- const content = formatLocalTurnContent(turn.userText ?? '', turn.finalText);
1427
- if (!content)
1456
+ // reply. formatLocalTurnFields caps both texts to keep within
1457
+ // Lark's per-message limit; daemon owns the card chrome.
1458
+ const fields = formatLocalTurnFields(turn.userText ?? '', turn.finalText);
1459
+ if (!fields)
1428
1460
  continue;
1429
- send({ type: 'final_output', content, lastUuid: turn.turnId, turnId: turn.turnId });
1461
+ send({
1462
+ type: 'final_output',
1463
+ content: fields.content,
1464
+ lastUuid: turn.turnId,
1465
+ turnId: turn.turnId,
1466
+ kind: 'local-turn',
1467
+ userText: fields.userText,
1468
+ });
1430
1469
  continue;
1431
1470
  }
1432
1471
  send({ type: 'final_output', content: turn.finalText, lastUuid: turn.turnId, turnId: turn.turnId });
@@ -1536,6 +1575,10 @@ let renderCols = PTY_COLS;
1536
1575
  let renderRows = PTY_ROWS;
1537
1576
  // ─── Headless Terminal for Screen Capture ────────────────────────────────────
1538
1577
  let renderer = null;
1578
+ /** Most recent unfiltered viewport text — kept in sync by the screen_update
1579
+ * timer for pipe-pane backends so ScreenAnalyzer (which is synchronous) has
1580
+ * a fresh snapshot to read without needing its own tmux capture-pane call. */
1581
+ let lastAnalyzerSnapshot = '';
1539
1582
  let screenUpdateTimer = null;
1540
1583
  const SCREEN_UPDATE_INTERVAL_MS = 2_000;
1541
1584
  // ─── Scrollback Buffer (replay to late-connecting WS clients) ───────────────
@@ -1569,7 +1612,14 @@ function startScreenAnalyzer() {
1569
1612
  extraHeaders: sa.extraHeaders,
1570
1613
  extraBody: sa.extraBody,
1571
1614
  }, {
1572
- getSnapshot: () => renderer?.rawSnapshot() ?? '',
1615
+ getSnapshot: () => {
1616
+ // ScreenAnalyzer is called every ~5s for TUI-prompt detection. We
1617
+ // can't make this async without overhauling the analyzer, so cache
1618
+ // the last pipe-pane text snapshot here and refresh it eagerly.
1619
+ // For pipe-pane backends, the cache is repopulated by the screen
1620
+ // update timer; for others, fall through to the long-lived renderer.
1621
+ return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
1622
+ },
1573
1623
  onAnalyzing: () => { },
1574
1624
  onTuiPrompt: (description, options, multiSelect) => {
1575
1625
  tuiPromptBlocking = true;
@@ -1671,28 +1721,41 @@ async function captureAndUpload() {
1671
1721
  logScreenshotSkip('awaitingFirstPrompt');
1672
1722
  return;
1673
1723
  }
1674
- if (!renderer) {
1675
- logScreenshotSkip('renderer=null');
1676
- return;
1677
- }
1678
1724
  if (!larkAppIdForUpload || !larkAppSecretForUpload) {
1679
1725
  logScreenshotSkip('lark credentials missing');
1680
1726
  return;
1681
1727
  }
1682
- const term = renderer.xterm;
1683
- const startY = term.buffer.active.baseY;
1684
- // Hash dedup — same content → skip upload. Not logged: this is the expected
1685
- // "nothing changed" path and would dominate the log signal.
1686
- const snap = renderer.rawSnapshot();
1687
- const hash = createHash('md5').update(snap).digest('hex');
1688
- if (hash === lastShotHash)
1689
- return;
1690
- lastShotHash = hash;
1691
1728
  let png;
1692
1729
  try {
1693
- const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
1694
- const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
1695
- png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
1730
+ // Preferred path: pipe-pane backends ask tmux for a fresh viewport
1731
+ // snapshot and render it through a transient xterm-headless. This
1732
+ // avoids the accumulated-buffer drift that produced duplicated /
1733
+ // staircase content under the legacy long-lived renderer.
1734
+ const pipeResult = await snapshotToPng(backend, renderCols, renderRows);
1735
+ if (pipeResult) {
1736
+ if (pipeResult.ansi === lastShotHash)
1737
+ return;
1738
+ lastShotHash = pipeResult.ansi;
1739
+ png = pipeResult.png;
1740
+ }
1741
+ else {
1742
+ // Fallback path: non-pipe backends (PtyBackend, legacy TmuxBackend)
1743
+ // still drive the long-lived renderer.
1744
+ if (!renderer) {
1745
+ logScreenshotSkip('renderer=null');
1746
+ return;
1747
+ }
1748
+ const term = renderer.xterm;
1749
+ const startY = term.buffer.active.baseY;
1750
+ const snap = renderer.rawSnapshot();
1751
+ const hash = createHash('md5').update(snap).digest('hex');
1752
+ if (hash === lastShotHash)
1753
+ return;
1754
+ lastShotHash = hash;
1755
+ const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
1756
+ const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
1757
+ png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
1758
+ }
1696
1759
  }
1697
1760
  catch (err) {
1698
1761
  logError(`Screenshot render failed: ${err?.message ?? err}`);
@@ -2149,18 +2212,46 @@ function startScreenUpdates() {
2149
2212
  // content (the live failure that prompted this fix).
2150
2213
  renderer = new TerminalRenderer(renderCols, renderRows);
2151
2214
  let lastSentStatus;
2215
+ let lastTextSnapshotHash = '';
2152
2216
  screenUpdateTimer = setInterval(() => {
2153
- if (!renderer || awaitingFirstPrompt)
2217
+ if (awaitingFirstPrompt)
2154
2218
  return;
2155
- const { content, changed } = renderer.snapshot();
2156
2219
  let status = isPromptReady ? 'idle' : 'working';
2157
2220
  if (screenAnalyzer?.isAnalyzing)
2158
2221
  status = 'analyzing';
2159
- // Send update when content changed OR status changed (e.g. idle → analyzing)
2160
- if (changed || status !== lastSentStatus) {
2161
- lastSentStatus = status;
2162
- send({ type: 'screen_update', content, status });
2163
- }
2222
+ void (async () => {
2223
+ let content;
2224
+ let changed;
2225
+ // Preferred path: pipe-pane backends pull a fresh viewport snapshot
2226
+ // from tmux every tick. This eliminates the accumulated-buffer drift
2227
+ // that produced duplicated/staircase text in 'text' display mode.
2228
+ const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
2229
+ if (pipeText) {
2230
+ content = pipeText.content;
2231
+ const hash = pipeText.ansi;
2232
+ changed = hash !== lastTextSnapshotHash;
2233
+ lastTextSnapshotHash = hash;
2234
+ // Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
2235
+ // tmux call would otherwise need to fire twice per tick.
2236
+ if (changed) {
2237
+ const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
2238
+ if (rawSnap)
2239
+ lastAnalyzerSnapshot = rawSnap.content;
2240
+ }
2241
+ }
2242
+ else if (renderer) {
2243
+ const snap = renderer.snapshot();
2244
+ content = snap.content;
2245
+ changed = snap.changed;
2246
+ }
2247
+ else {
2248
+ return;
2249
+ }
2250
+ if (changed || status !== lastSentStatus) {
2251
+ lastSentStatus = status;
2252
+ send({ type: 'screen_update', content, status });
2253
+ }
2254
+ })();
2164
2255
  }, SCREEN_UPDATE_INTERVAL_MS);
2165
2256
  }
2166
2257
  function stopScreenUpdates() {
@@ -2172,6 +2263,7 @@ function stopScreenUpdates() {
2172
2263
  renderer.dispose();
2173
2264
  renderer = null;
2174
2265
  }
2266
+ lastAnalyzerSnapshot = '';
2175
2267
  }
2176
2268
  // ─── PTY Management ──────────────────────────────────────────────────────────
2177
2269
  function spawnCli(cfg) {
@@ -2357,9 +2449,10 @@ function spawnCli(cfg) {
2357
2449
  log('tmux backend requested but functional probe failed — falling back to PTY backend');
2358
2450
  useTmux = false;
2359
2451
  }
2360
- isTmuxMode = useTmux;
2361
- const tmuxBe = useTmux ? new TmuxBackend(TmuxBackend.sessionName(cfg.sessionId)) : null;
2362
- backend = tmuxBe ?? new PtyBackend();
2452
+ const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, useTmux });
2453
+ isTmuxMode = selectedBackend.isTmuxMode;
2454
+ isPipeMode = selectedBackend.isPipeMode;
2455
+ backend = selectedBackend.backend;
2363
2456
  // Claude Code appends a line to ~/.claude/projects/<cwd-hash>/<sid>.jsonl each
2364
2457
  // time the user submits. The adapter uses this file to verify paste+Enter
2365
2458
  // actually committed (rather than trusting a fixed sleep), so wire it up now.
@@ -2432,9 +2525,6 @@ function spawnCli(cfg) {
2432
2525
  // On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
2433
2526
  // suppressed until the idle detector fires markNewTurn() — this prevents the
2434
2527
  // full tmux scrollback history from leaking into the streaming card.
2435
- if (tmuxBe?.isReattach) {
2436
- log('Re-attached to existing tmux session');
2437
- }
2438
2528
  // Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
2439
2529
  // turn the model finishes WITHOUT calling `botmux send` still gets its
2440
2530
  // assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
@@ -2509,6 +2599,17 @@ function spawnCli(cfg) {
2509
2599
  isPromptReady = false;
2510
2600
  send({ type: 'claude_exit', code, signal });
2511
2601
  });
2602
+ if (isPipeMode && backend instanceof TmuxPipeBackend && backend.isReattach) {
2603
+ log(`Re-attached to existing tmux session via pipe-pane: ${TmuxBackend.sessionName(cfg.sessionId)}`);
2604
+ try {
2605
+ const initial = backend.captureCurrentScreen();
2606
+ if (initial.length > 0)
2607
+ onPtyData(initial);
2608
+ }
2609
+ catch (err) {
2610
+ log(`captureCurrentScreen failed: ${err.message}`);
2611
+ }
2612
+ }
2512
2613
  // Fallback: if the CLI takes too long to show its prompt (e.g. slow
2513
2614
  // plugin init), unblock screen updates so the card doesn't stay at
2514
2615
  // "启动中" forever. markNewTurn() sets a clean baseline at the current