botmux 2.11.1 → 2.12.1

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 (99) hide show
  1. package/dist/adapters/backend/pty-backend.d.ts +6 -0
  2. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  3. package/dist/adapters/backend/pty-backend.js +6 -0
  4. package/dist/adapters/backend/pty-backend.js.map +1 -1
  5. package/dist/adapters/backend/tmux-backend.d.ts +16 -2
  6. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  7. package/dist/adapters/backend/tmux-backend.js +40 -10
  8. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  9. package/dist/adapters/backend/tmux-pipe-backend.d.ts +59 -0
  10. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
  11. package/dist/adapters/backend/tmux-pipe-backend.js +288 -0
  12. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
  13. package/dist/adapters/cli/claude-code.d.ts +15 -0
  14. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  15. package/dist/adapters/cli/claude-code.js +205 -24
  16. package/dist/adapters/cli/claude-code.js.map +1 -1
  17. package/dist/adapters/cli/codex.d.ts.map +1 -1
  18. package/dist/adapters/cli/codex.js +78 -16
  19. package/dist/adapters/cli/codex.js.map +1 -1
  20. package/dist/adapters/cli/types.d.ts +10 -0
  21. package/dist/adapters/cli/types.d.ts.map +1 -1
  22. package/dist/cli.js +63 -8
  23. package/dist/cli.js.map +1 -1
  24. package/dist/core/command-handler.d.ts +10 -0
  25. package/dist/core/command-handler.d.ts.map +1 -1
  26. package/dist/core/command-handler.js +29 -1
  27. package/dist/core/command-handler.js.map +1 -1
  28. package/dist/core/scheduler.d.ts +3 -0
  29. package/dist/core/scheduler.d.ts.map +1 -1
  30. package/dist/core/scheduler.js +3 -0
  31. package/dist/core/scheduler.js.map +1 -1
  32. package/dist/core/session-manager.d.ts +17 -0
  33. package/dist/core/session-manager.d.ts.map +1 -1
  34. package/dist/core/session-manager.js +51 -3
  35. package/dist/core/session-manager.js.map +1 -1
  36. package/dist/core/types.d.ts +4 -0
  37. package/dist/core/types.d.ts.map +1 -1
  38. package/dist/core/types.js.map +1 -1
  39. package/dist/core/worker-pool.d.ts +13 -1
  40. package/dist/core/worker-pool.d.ts.map +1 -1
  41. package/dist/core/worker-pool.js +115 -4
  42. package/dist/core/worker-pool.js.map +1 -1
  43. package/dist/daemon.d.ts.map +1 -1
  44. package/dist/daemon.js +59 -120
  45. package/dist/daemon.js.map +1 -1
  46. package/dist/im/lark/card-builder.d.ts +2 -1
  47. package/dist/im/lark/card-builder.d.ts.map +1 -1
  48. package/dist/im/lark/card-builder.js +23 -9
  49. package/dist/im/lark/card-builder.js.map +1 -1
  50. package/dist/im/lark/card-handler.d.ts.map +1 -1
  51. package/dist/im/lark/card-handler.js +26 -93
  52. package/dist/im/lark/card-handler.js.map +1 -1
  53. package/dist/im/lark/merge-forward.d.ts +32 -0
  54. package/dist/im/lark/merge-forward.d.ts.map +1 -0
  55. package/dist/im/lark/merge-forward.js +99 -0
  56. package/dist/im/lark/merge-forward.js.map +1 -0
  57. package/dist/services/bridge-fallback-gate.d.ts +42 -0
  58. package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
  59. package/dist/services/bridge-fallback-gate.js +12 -0
  60. package/dist/services/bridge-fallback-gate.js.map +1 -0
  61. package/dist/services/bridge-turn-queue.d.ts +111 -0
  62. package/dist/services/bridge-turn-queue.d.ts.map +1 -0
  63. package/dist/services/bridge-turn-queue.js +213 -0
  64. package/dist/services/bridge-turn-queue.js.map +1 -0
  65. package/dist/services/claude-transcript.d.ts +168 -0
  66. package/dist/services/claude-transcript.d.ts.map +1 -0
  67. package/dist/services/claude-transcript.js +524 -0
  68. package/dist/services/claude-transcript.js.map +1 -0
  69. package/dist/services/codex-bridge-queue.d.ts +39 -0
  70. package/dist/services/codex-bridge-queue.d.ts.map +1 -0
  71. package/dist/services/codex-bridge-queue.js +116 -0
  72. package/dist/services/codex-bridge-queue.js.map +1 -0
  73. package/dist/services/codex-transcript.d.ts +35 -0
  74. package/dist/services/codex-transcript.d.ts.map +1 -0
  75. package/dist/services/codex-transcript.js +163 -0
  76. package/dist/services/codex-transcript.js.map +1 -0
  77. package/dist/services/schedule-store.d.ts +3 -0
  78. package/dist/services/schedule-store.d.ts.map +1 -1
  79. package/dist/services/schedule-store.js +6 -0
  80. package/dist/services/schedule-store.js.map +1 -1
  81. package/dist/services/session-store.d.ts +10 -0
  82. package/dist/services/session-store.d.ts.map +1 -1
  83. package/dist/services/session-store.js +40 -0
  84. package/dist/services/session-store.js.map +1 -1
  85. package/dist/skills/definitions.d.ts.map +1 -1
  86. package/dist/skills/definitions.js +2 -1
  87. package/dist/skills/definitions.js.map +1 -1
  88. package/dist/types.d.ts +22 -0
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/utils/render-dimensions.d.ts +48 -0
  91. package/dist/utils/render-dimensions.d.ts.map +1 -0
  92. package/dist/utils/render-dimensions.js +55 -0
  93. package/dist/utils/render-dimensions.js.map +1 -0
  94. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  95. package/dist/utils/terminal-renderer.js +5 -2
  96. package/dist/utils/terminal-renderer.js.map +1 -1
  97. package/dist/worker.js +1317 -37
  98. package/dist/worker.js.map +1 -1
  99. package/package.json +1 -1
package/dist/worker.js CHANGED
@@ -13,15 +13,23 @@
13
13
  * 7. On 'restart', kills CLI and re-spawns with --resume
14
14
  */
15
15
  import { randomBytes } from 'node:crypto';
16
- import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
16
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync, readlinkSync, readFileSync, watch as fsWatch } from 'node:fs';
17
17
  import { join } from 'node:path';
18
+ import { drainTranscript, joinAssistantText, findJsonlContainingFingerprint, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent } from './services/claude-transcript.js';
19
+ import { BridgeTurnQueue, makeFingerprint } from './services/bridge-turn-queue.js';
20
+ import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
21
+ import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
22
+ import { drainCodexRollout, findCodexRolloutBySessionId } from './services/codex-transcript.js';
23
+ import { dirname } from 'node:path';
18
24
  import { createServer as createHttpServer } from 'node:http';
19
25
  import { WebSocketServer, WebSocket } from 'ws';
20
26
  import { TerminalRenderer } from './utils/terminal-renderer.js';
27
+ 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';
21
28
  import { createCliAdapterSync } from './adapters/cli/registry.js';
22
- import { claudeJsonlPathForSession } from './adapters/cli/claude-code.js';
29
+ import { claudeJsonlPathForSession, resolveJsonlFromPid } from './adapters/cli/claude-code.js';
23
30
  import { PtyBackend } from './adapters/backend/pty-backend.js';
24
31
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
32
+ import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
25
33
  import { IdleDetector } from './utils/idle-detector.js';
26
34
  import { ScreenAnalyzer } from './utils/screen-analyzer.js';
27
35
  import { captureToPng } from './utils/screenshot-renderer.js';
@@ -36,6 +44,10 @@ let backend = null;
36
44
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
37
45
  let idleDetector = null;
38
46
  let isTmuxMode = false;
47
+ /** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
48
+ * web-terminal updates flow through the shared scrollback fan-out instead
49
+ * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
50
+ let isPipeMode = false;
39
51
  let httpServer = null;
40
52
  let wss = null;
41
53
  const wsClients = new Set();
@@ -51,13 +63,1040 @@ let isPromptReady = false;
51
63
  /** Mutex for async flushPending — prevents concurrent flush loops. */
52
64
  let isFlushing = false;
53
65
  const pendingMessages = [];
66
+ // ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
67
+ //
68
+ // In bridge mode the daemon adopted an existing CLI session that we do NOT
69
+ // own; the model never sees botmux. We harvest assistant turns by tailing
70
+ // Claude Code's transcript JSONL and forward only the bytes appended after
71
+ // each Lark-driven user turn — never the historical content present at
72
+ // attach time, never local-terminal-driven turns.
73
+ //
74
+ // Attribution lives in BridgeTurnQueue; this file only manages the
75
+ // fs.watch wakeup, byte-offset bookkeeping, lazy baseline, and IPC emit.
76
+ let bridgeJsonlPath;
77
+ /** Directory enclosing bridgeJsonlPath. We poll this dir for newer jsonl
78
+ * files so the bridge follows `/clear` / `/resume` in the user's CLI —
79
+ * those create a brand-new sessionId.jsonl, and a watcher pinned to the
80
+ * original path would silently stop receiving events. */
81
+ let bridgeJsonlDir;
82
+ /** PID + cwd of the adopted Claude Code process. Lets every poll re-read
83
+ * ~/.claude/sessions/<pid>.json — Claude's own authoritative record of the
84
+ * current sessionId — and switch the watched jsonl when Claude rotates
85
+ * (via /clear, /resume, --resume etc.) without waiting for a Lark message
86
+ * to land in the new file. */
87
+ let bridgeCliPid;
88
+ let bridgeCliCwd;
89
+ /** Last sessionId we observed via the pid resolver — used to detect
90
+ * rotations cheaply (string compare instead of stat()ing every jsonl). */
91
+ let bridgeObservedCliSessionId;
92
+ /** Old jsonl paths we keep polling AFTER a rotation switched
93
+ * bridgeJsonlPath away — needed when a started turn was stamped with the
94
+ * old path but its assistant text hasn't been written yet. We continue to
95
+ * drain each entry on every tick so trailing appends to that file land in
96
+ * the queue against the right turn, and prune the entry once no pending
97
+ * turn references the path anymore. */
98
+ const bridgeSecondaryPaths = new Map(); // path → offset
99
+ let bridgeOffset = 0;
100
+ let bridgePendingTail = '';
101
+ const bridgeQueue = new BridgeTurnQueue();
102
+ let bridgeWatcher = null;
103
+ let bridgeFallbackTimer = null;
104
+ /** True once we successfully baselined the transcript file. Until then,
105
+ * any data we see is treated as history — absorbed into the queue's seen
106
+ * set without being attributed to a pending Lark turn. This protects the
107
+ * first Lark turn from inheriting historical lines if Claude Code creates
108
+ * the JSONL file *after* attach. */
109
+ let bridgeBaselineDone = false;
110
+ /** Once-per-attach flag so a re-baseline after fs.watch lazy-fire doesn't
111
+ * re-send the preamble. Reset only when the bridge teardown happens. */
112
+ let bridgePreambleSent = false;
113
+ // ─── Codex bridge state ──────────────────────────────────────────────────
114
+ //
115
+ // Parallel to the Claude bridge above. Codex's transcript layout is
116
+ // different enough (separate file location, different event schema) that
117
+ // trying to share storage / readers would obscure both — so we keep state
118
+ // independent. Marker file (`<DATA_DIR>/turn-sends/<sid>.jsonl`) and the
119
+ // gate function are CLI-agnostic and shared.
120
+ let codexBridgeRolloutPath;
121
+ let codexBridgeOffset = 0;
122
+ let codexBridgePendingTail = '';
123
+ let codexBridgeBaselineDone = false;
124
+ const codexBridgeQueue = new CodexBridgeQueue();
125
+ let codexBridgeWatcher = null;
126
+ let codexBridgeTimer = null;
127
+ /** Codex sessionId we received via writeInput but haven't yet resolved a
128
+ * rollout file for. The poller keeps retrying — the file appears on
129
+ * Codex's first user submit, but with some race delay after our submit
130
+ * returns. Cleared once attached. */
131
+ let codexBridgePendingSessionId;
132
+ /** Cap the preamble text so an extremely long previous turn doesn't blow
133
+ * past Lark's per-message limit. The user only needs enough to recall
134
+ * context, not the entire transcript. */
135
+ const PREAMBLE_USER_MAX = 500;
136
+ const PREAMBLE_ASSISTANT_MAX = 4000;
137
+ /** Same intent as the preamble caps, but for live local-terminal turns
138
+ * forwarded to Lark. A long paste typed locally shouldn't be allowed to
139
+ * blow past Lark's per-message limit. */
140
+ const LOCAL_TURN_USER_MAX = 1000;
141
+ const LOCAL_TURN_ASSISTANT_MAX = 8000;
142
+ function truncatePreambleText(text, max) {
143
+ if (text.length <= max)
144
+ return text;
145
+ return text.slice(0, max) + '…';
146
+ }
147
+ /** Compose a `final_output` payload for a turn synthesised from a user
148
+ * prompt the human typed directly into the adopted pane. Shows both the
149
+ * user text and assistant text so the Lark thread doesn't see an orphan
150
+ * reply with no context. Returns `null` when neither side has anything
151
+ * visible — the worker should suppress the emit in that case. */
152
+ function formatLocalTurnContent(userText, assistantText) {
153
+ const u = truncatePreambleText(userText.trim(), LOCAL_TURN_USER_MAX);
154
+ const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
155
+ if (!u && !a)
156
+ return null;
157
+ return [
158
+ '🖥️ 终端本地对话(在 adopted pane 中直接输入,已同步至飞书)',
159
+ '',
160
+ '👤 你:',
161
+ u || '(空)',
162
+ '',
163
+ `🤖 ${cliName()}:`,
164
+ a || '(空)',
165
+ ].join('\n');
166
+ }
167
+ // ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
168
+ //
169
+ // `botmux send` (cli.ts cmdSend) appends a line `{sentAtMs, messageId}\n` to
170
+ // `<DATA_DIR>/turn-sends/<sid>.jsonl` every time the model successfully posts
171
+ // a reply to its OWN session thread. The worker reads these markers at idle
172
+ // and suppresses transcript-driven final_output for any turn whose time
173
+ // window already contains a send — i.e. the model didn't forget, no fallback
174
+ // needed. Append-only over a shared file (instead of a per-turn marker) is
175
+ // type-ahead safe: type-ahead'd turns each have their own [markTimeMs,
176
+ // nextTurn.markTimeMs) window, and a stray send only fills its own bucket.
177
+ function bridgeMarkerPath() {
178
+ if (!process.env.SESSION_DATA_DIR || !sessionId)
179
+ return undefined;
180
+ return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
181
+ }
182
+ function readSendMarkers() {
183
+ const path = bridgeMarkerPath();
184
+ if (!path || !existsSync(path))
185
+ return [];
186
+ try {
187
+ const out = [];
188
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
189
+ if (!line.trim())
190
+ continue;
191
+ try {
192
+ const parsed = JSON.parse(line);
193
+ if (typeof parsed?.sentAtMs === 'number')
194
+ out.push(parsed);
195
+ }
196
+ catch { /* skip malformed line */ }
197
+ }
198
+ return out;
199
+ }
200
+ catch (err) {
201
+ log(`Bridge marker read failed: ${err.message}`);
202
+ return [];
203
+ }
204
+ }
205
+ function clearSendMarkers() {
206
+ const path = bridgeMarkerPath();
207
+ if (!path)
208
+ return;
209
+ try {
210
+ unlinkSync(path);
211
+ }
212
+ catch { /* already gone or fs.unavailable; not fatal */ }
213
+ }
214
+ function maybeEmitAdoptPreamble(events) {
215
+ // Preamble is an /adopt-only signal: it tells the user "here's the last
216
+ // turn from the Claude session you just attached to, so the Lark thread
217
+ // has context to continue from". In non-adopt sessions the user IS the
218
+ // Lark thread (every turn was already pushed there as a card), so
219
+ // surfacing the last turn again on daemon restart is just noise.
220
+ if (!lastInitConfig?.adoptMode)
221
+ return;
222
+ if (bridgePreambleSent)
223
+ return;
224
+ const turn = extractLastAssistantTurn(events);
225
+ if (!turn)
226
+ return;
227
+ bridgePreambleSent = true;
228
+ send({
229
+ type: 'adopt_preamble',
230
+ userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
231
+ assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
232
+ });
233
+ log('Bridge adopt preamble emitted (last completed turn from baseline)');
234
+ }
235
+ function bridgeAbsorbBaseline() {
236
+ if (!bridgeJsonlPath)
237
+ return;
238
+ const result = drainTranscript(bridgeJsonlPath, 0);
239
+ bridgeOffset = result.newOffset;
240
+ bridgePendingTail = result.pendingTail;
241
+ bridgeQueue.absorb(result.events);
242
+ bridgeBaselineDone = true;
243
+ // After absorb (uuids registered as seen so they won't re-emit as a Lark
244
+ // turn), surface the last completed user/assistant exchange to Lark as a
245
+ // one-shot preamble — but only for real /adopt sessions. Non-adopt
246
+ // claude-code fallback bridge also uses baseline-existing on daemon
247
+ // restart/resume; it must not emit the "/adopt 前最后一轮" message.
248
+ if (lastInitConfig?.adoptMode)
249
+ maybeEmitAdoptPreamble(result.events);
250
+ }
251
+ /** Detect /clear / /resume: when Claude Code starts a new session in the
252
+ * user's pane it writes to a brand-new sessionId.jsonl. We *cannot* use
253
+ * "latest-mtime jsonl in the project dir" as the switch trigger — that
254
+ * hijacks our watcher whenever a sibling Claude pane in the same cwd
255
+ * writes anything. Instead, switch only when:
256
+ *
257
+ * 1. We have an unstarted pending Lark turn (otherwise no signal to
258
+ * chase, and switching would risk grabbing another pane's reply).
259
+ * 2. The pending turn's content fingerprint shows up in a candidate
260
+ * jsonl other than our current one — that's the user's current
261
+ * session because they JUST typed our pane-write into it.
262
+ *
263
+ * Pending turns are preserved across the switch so the next ingest can
264
+ * match the fingerprint and start the turn in the new file. */
265
+ function maybeSwitchBridgeJsonl() {
266
+ if (!bridgeJsonlDir)
267
+ return false;
268
+ const pending = bridgeQueue.peek();
269
+ const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
270
+ if (!candidate || !candidate.contentFingerprint)
271
+ return false;
272
+ // Bound the search to events written after the turn was marked. Short
273
+ // fingerprints ("hello", "test") would otherwise match old user lines
274
+ // in unrelated sibling jsonls. 5s skew absorbs clock drift between the
275
+ // mark and Claude's transcript write.
276
+ const minEventTimestampMs = candidate.markTimeMs !== undefined
277
+ ? candidate.markTimeMs - 5_000
278
+ : undefined;
279
+ const matched = findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
280
+ excludePath: bridgeJsonlPath,
281
+ includeQueueOperations: true,
282
+ minEventTimestampMs,
283
+ });
284
+ if (!matched)
285
+ return false;
286
+ // Drain-before-switch: pull in any unread bytes from the old path so a
287
+ // late assistant append doesn't vanish. We do NOT emit here — emission
288
+ // only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
289
+ // would publish a half-finished assistant turn during fs.watch / poll
290
+ // ticks (drainEmittable's contract is "has visible text", not "model
291
+ // finished"). If the drained user/assistant events still need follow-up
292
+ // appends on the old path, retainSecondaryPathIfStillReferenced() keeps
293
+ // the old path in the polling rotation.
294
+ if (bridgeJsonlPath && bridgeBaselineDone) {
295
+ let postDrainOffset = bridgeOffset;
296
+ try {
297
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
298
+ postDrainOffset = drained.offset;
299
+ }
300
+ catch (err) {
301
+ log(`Bridge final-drain on fingerprint switch failed (${err.message}); continuing`);
302
+ }
303
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
304
+ }
305
+ log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (Lark fingerprint observed in new jsonl — user likely ran /clear or /resume)`);
306
+ if (bridgeWatcher) {
307
+ try {
308
+ bridgeWatcher.close();
309
+ }
310
+ catch { /* ignore */ }
311
+ bridgeWatcher = null;
312
+ }
313
+ // Critically: do NOT clear pending turns. The switch was triggered by
314
+ // the fingerprint of the FIRST pending turn already living in `matched`,
315
+ // so the immediate next ingest from offset 0 will find that user event
316
+ // and start the turn. Clearing here would race-drop exactly the message
317
+ // we're trying to deliver.
318
+ bridgeJsonlPath = matched;
319
+ bridgeOffset = 0;
320
+ bridgePendingTail = '';
321
+ // baselineDone=false would absorb the new file's existing content
322
+ // (including the pending turn's user event) as history — defeating the
323
+ // switch. Skip baseline; fall straight into ingest from offset 0 so
324
+ // BridgeTurnQueue.ingest() can attribute the matching user/assistant.
325
+ bridgeBaselineDone = true;
326
+ try {
327
+ bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
328
+ try {
329
+ bridgeIngest();
330
+ }
331
+ catch (err) {
332
+ log(`Bridge ingest error: ${err.message}`);
333
+ }
334
+ });
335
+ }
336
+ catch (err) {
337
+ log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
338
+ }
339
+ return true;
340
+ }
341
+ /** /clear or /resume in the user's adopted pane creates (or touches) a new
342
+ * jsonl in the same Claude project directory. Neither pid-resolver nor
343
+ * fingerprint switch will fire when the rotation happened mid-process AND
344
+ * there's no pending Lark turn to anchor on (pure local-terminal use), so
345
+ * this fallback owns that case.
346
+ *
347
+ * Detection priority:
348
+ * 1. Linux first-class: read `/proc/<pid>/fd` and pick the .jsonl the
349
+ * adopted Claude process actually has open. This is bound to the real
350
+ * PID — a sibling Claude pane in the same cwd has a different PID and
351
+ * therefore cannot hijack the result.
352
+ * 2. Cross-platform fallback: directory-level mtime heuristic, gated on
353
+ * (a) our current jsonl quiet ≥ QUIET_ROTATION_MS, (b) candidate
354
+ * newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Less
355
+ * robust than fd lookup but the best available without /proc.
356
+ *
357
+ * When a rotation is detected, the new jsonl is drained from offset 0 and
358
+ * events are split by timestamp against `rotationCutoffMs` (the old
359
+ * jsonl's last-write time): events before the cutoff are *history*
360
+ * (absorbed into the seen-set, not emitted), events after are *live*
361
+ * (ingested → local-turn synthesis runs). This is what lets /resume to a
362
+ * long-history jsonl NOT replay the entire past as one giant local turn,
363
+ * while /clear's first new turn still gets forwarded.
364
+ *
365
+ * Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
366
+ * also fires `maybeEmitAdoptPreamble`, which on rotation would surface
367
+ * the *previous session's* last turn as if it were a fresh "/adopt 前最
368
+ * 后一轮" preamble. Preamble belongs only to initial attach. */
369
+ const QUIET_ROTATION_MS = 8_000;
370
+ function statSafe(path) {
371
+ try {
372
+ const st = statSync(path);
373
+ if (!st.isFile())
374
+ return null;
375
+ return { mtimeMs: st.mtimeMs, size: st.size };
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }
381
+ function isPidAlive(pid) {
382
+ if (!Number.isInteger(pid) || pid <= 0)
383
+ return false;
384
+ try {
385
+ process.kill(pid, 0);
386
+ return true;
387
+ }
388
+ catch {
389
+ return false;
390
+ }
391
+ }
392
+ /** List `.jsonl` files inside `dir` that are currently held open by `pid`.
393
+ * Returns [] on non-Linux platforms or if /proc lookup fails — the caller
394
+ * treats an empty result as "fd info unavailable, fall back to mtime". */
395
+ function findOpenJsonlsForPid(pid, dir) {
396
+ if (!Number.isInteger(pid) || pid <= 0)
397
+ return [];
398
+ if (process.platform !== 'linux')
399
+ return [];
400
+ let entries;
401
+ try {
402
+ entries = readdirSync(`/proc/${pid}/fd`);
403
+ }
404
+ catch {
405
+ return [];
406
+ }
407
+ const out = [];
408
+ for (const name of entries) {
409
+ let target;
410
+ try {
411
+ target = readlinkSync(`/proc/${pid}/fd/${name}`);
412
+ }
413
+ catch {
414
+ continue;
415
+ }
416
+ if (!target.endsWith('.jsonl'))
417
+ continue;
418
+ if (dirname(target) !== dir)
419
+ continue;
420
+ out.push(target);
421
+ }
422
+ return out;
423
+ }
424
+ /** Pick the most recently modified path among `paths`. Returns null if
425
+ * none of them stat. */
426
+ function newestPath(paths) {
427
+ let best = null;
428
+ for (const p of paths) {
429
+ const st = statSafe(p);
430
+ if (!st)
431
+ continue;
432
+ if (!best || st.mtimeMs > best.mtimeMs)
433
+ best = { path: p, mtimeMs: st.mtimeMs };
434
+ }
435
+ return best?.path ?? null;
436
+ }
437
+ /** Switch bridgeJsonlPath to `newPath` and split-baseline its existing
438
+ * content: events with timestamp ≤ `cutoffMs` are absorbed as history
439
+ * (seen-set only, no emission), events strictly after are ingested so
440
+ * local turn synthesis runs against them. The old path is retained in
441
+ * the secondary polling rotation if any started turn still references
442
+ * it. Does NOT emit `adopt_preamble` — that's an initial-attach signal,
443
+ * not a rotation signal. */
444
+ function performRotationSwitch(newPath, cutoffMs, reason) {
445
+ // Drain-before-switch: pull any unread bytes from the old path so a
446
+ // late assistant append doesn't vanish. Mirrors the other rotation
447
+ // helpers.
448
+ if (bridgeJsonlPath && bridgeBaselineDone) {
449
+ let postDrainOffset = bridgeOffset;
450
+ try {
451
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
452
+ postDrainOffset = drained.offset;
453
+ }
454
+ catch (err) {
455
+ log(`Bridge final-drain on rotation (${reason}) failed (${err.message}); continuing`);
456
+ }
457
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
458
+ }
459
+ log(`Bridge transcript switched (${reason}): ${bridgeJsonlPath ?? '(none)'} → ${newPath}`);
460
+ if (bridgeWatcher) {
461
+ try {
462
+ bridgeWatcher.close();
463
+ }
464
+ catch { /* ignore */ }
465
+ bridgeWatcher = null;
466
+ }
467
+ bridgeJsonlPath = newPath;
468
+ bridgeJsonlDir = dirname(newPath);
469
+ bridgePendingTail = '';
470
+ // Drain the new path from 0 ourselves (do NOT call bridgeAbsorbBaseline
471
+ // — that would emit the preamble we want to suppress on rotation).
472
+ const result = drainTranscript(newPath, 0);
473
+ bridgeOffset = result.newOffset;
474
+ bridgePendingTail = result.pendingTail;
475
+ const history = [];
476
+ const live = [];
477
+ for (const ev of result.events) {
478
+ let evMs = Number.NaN;
479
+ if (typeof ev.timestamp === 'string')
480
+ evMs = Date.parse(ev.timestamp);
481
+ if (Number.isFinite(evMs) && evMs <= cutoffMs)
482
+ history.push(ev);
483
+ else
484
+ live.push(ev);
485
+ }
486
+ bridgeQueue.absorb(history);
487
+ if (live.length > 0)
488
+ bridgeQueue.ingest(live, newPath);
489
+ bridgeBaselineDone = true;
490
+ log(`Bridge rotation split: ${history.length} historical events absorbed, ${live.length} live events ingested`);
491
+ try {
492
+ bridgeWatcher = fsWatch(newPath, { persistent: false }, () => {
493
+ try {
494
+ bridgeIngest();
495
+ }
496
+ catch (err) {
497
+ log(`Bridge ingest error: ${err.message}`);
498
+ }
499
+ });
500
+ }
501
+ catch (err) {
502
+ log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
503
+ }
504
+ }
505
+ function maybeFollowQuietRotation() {
506
+ if (!bridgeJsonlDir || !bridgeJsonlPath)
507
+ return;
508
+ // Need a known pid to do safe rotation tracking; if we don't have one,
509
+ // we can't bind to the adopted Claude process and a directory-mtime
510
+ // switch would risk sibling-pane hijack.
511
+ if (bridgeCliPid === undefined)
512
+ return;
513
+ if (!isPidAlive(bridgeCliPid))
514
+ return;
515
+ const currentStat = statSafe(bridgeJsonlPath);
516
+ if (!currentStat)
517
+ return;
518
+ // Path 1: Linux fd-based detection — definitive, can't be hijacked.
519
+ // Read /proc/<pid>/fd, find every .jsonl Claude has open in our cwd's
520
+ // project dir, pick the one with the most recent mtime. Differs from
521
+ // bridgeJsonlPath ⇒ rotation.
522
+ const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
523
+ if (opened.length > 0) {
524
+ const newest = newestPath(opened);
525
+ if (newest && newest !== bridgeJsonlPath) {
526
+ performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
527
+ }
528
+ // fd lookup succeeded — even if it confirmed the current path, the
529
+ // mtime fallback below would only add risk. Stop here.
530
+ return;
531
+ }
532
+ // Path 2: non-Linux fallback (or /proc unavailable). Directory-mtime
533
+ // heuristic with three guards. Less robust than fd lookup; sibling
534
+ // panes could in principle race the conditions, but the QUIET windows
535
+ // make it unlikely in practice.
536
+ const now = Date.now();
537
+ if (now - currentStat.mtimeMs < QUIET_ROTATION_MS)
538
+ return;
539
+ const latest = findLatestJsonl(bridgeJsonlDir);
540
+ if (!latest || latest === bridgeJsonlPath)
541
+ return;
542
+ const latestStat = statSafe(latest);
543
+ if (!latestStat)
544
+ return;
545
+ if (latestStat.mtimeMs - currentStat.mtimeMs < QUIET_ROTATION_MS)
546
+ return;
547
+ performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
548
+ }
549
+ function maybeFollowSessionRotationViaPid() {
550
+ if (!bridgeCliPid || !bridgeCliCwd)
551
+ return 'unavailable';
552
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
553
+ if (!resolved)
554
+ return 'unavailable';
555
+ if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
556
+ bridgeObservedCliSessionId = resolved.cliSessionId;
557
+ }
558
+ if (resolved.path === bridgeJsonlPath)
559
+ return 'same';
560
+ // Drain-before-switch: pull in any unread bytes from the OLD path so a
561
+ // trailing assistant append doesn't vanish. We do NOT emit here — emit
562
+ // is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
563
+ // publish a half-finished assistant during fs.watch / poll-driven
564
+ // bridgeIngest calls. If a started turn still references the old path
565
+ // and its assistant text might still be on the way, the old path stays
566
+ // in the polling rotation via bridgeSecondaryPaths.
567
+ if (bridgeJsonlPath && bridgeBaselineDone) {
568
+ let postDrainOffset = bridgeOffset;
569
+ try {
570
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
571
+ postDrainOffset = drained.offset;
572
+ }
573
+ catch (err) {
574
+ log(`Bridge final-drain on rotation failed (${err.message}); continuing`);
575
+ }
576
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
577
+ }
578
+ log(`Bridge transcript switched (pid resolver): ${bridgeJsonlPath ?? '(none)'} → ${resolved.path}`);
579
+ if (bridgeWatcher) {
580
+ try {
581
+ bridgeWatcher.close();
582
+ }
583
+ catch { /* ignore */ }
584
+ bridgeWatcher = null;
585
+ }
586
+ // Preserve any pending Lark turn so the next ingest can attribute it
587
+ // when Claude appends our user event to the new jsonl. Skip baseline:
588
+ // we want to read from offset 0 so the pending turn's user event is
589
+ // visible to BridgeTurnQueue.ingest(). Turns already started on the
590
+ // old path keep their stamped sourceJsonlPath, so when their assistant
591
+ // text eventually arrives there too it still resolves correctly.
592
+ bridgeJsonlPath = resolved.path;
593
+ bridgeJsonlDir = dirname(resolved.path);
594
+ bridgeOffset = 0;
595
+ bridgePendingTail = '';
596
+ bridgeBaselineDone = true;
597
+ try {
598
+ bridgeWatcher = fsWatch(resolved.path, { persistent: false }, () => {
599
+ try {
600
+ bridgeIngest();
601
+ }
602
+ catch (err) {
603
+ log(`Bridge ingest error: ${err.message}`);
604
+ }
605
+ });
606
+ }
607
+ catch (err) {
608
+ log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
609
+ }
610
+ return 'switched';
611
+ }
612
+ function bridgeIngest() {
613
+ // Drain secondary paths first so any trailing assistant text on an old
614
+ // jsonl reaches the queue before the rotation check considers retiring
615
+ // the path. Strictly read-only on the polling rotation; never triggers
616
+ // a rotate or shifts the primary path.
617
+ drainSecondaryPaths();
618
+ // Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
619
+ // pid file → new sessionId), e.g. daemon restart that re-issues
620
+ // `--resume <id>` and Claude rotates the internal id.
621
+ const pidFollow = maybeFollowSessionRotationViaPid();
622
+ // Fingerprint fallback: catches *in-process* rotations Claude makes
623
+ // via /clear or /resume from the user's pane. Claude's pid file has
624
+ // its sessionId field set ONCE at process start (see binary persistence
625
+ // schema) and is NOT rewritten on /clear, so pid resolver returning
626
+ // 'same' is NOT proof that no rotation happened. We skip the
627
+ // fingerprint scan only when pid resolver actively switched the path
628
+ // — in that case the authoritative source already moved us, and
629
+ // running fingerprint on top would risk a redundant flip.
630
+ let switched = pidFollow === 'switched';
631
+ if (!switched) {
632
+ switched = maybeSwitchBridgeJsonl();
633
+ }
634
+ // Quiet-rotation fallback: catches /clear or /resume in pure-local
635
+ // sessions (no pending Lark turn → no fingerprint to match against).
636
+ // Without this, a user who hits /clear in the adopted pane and then
637
+ // continues in the terminal would never get those replies forwarded
638
+ // to Lark — the watcher stays stuck on the old, frozen jsonl.
639
+ if (!switched) {
640
+ maybeFollowQuietRotation();
641
+ }
642
+ if (!bridgeJsonlPath)
643
+ return;
644
+ if (!bridgeBaselineDone) {
645
+ // Lazy baseline: file didn't exist at attach, baseline the moment it does.
646
+ if (!existsSyncSafe(bridgeJsonlPath))
647
+ return;
648
+ bridgeAbsorbBaseline();
649
+ return;
650
+ }
651
+ const result = drainTranscript(bridgeJsonlPath, bridgeOffset);
652
+ bridgeOffset = result.newOffset;
653
+ bridgePendingTail = result.pendingTail;
654
+ bridgeQueue.ingest(result.events, bridgeJsonlPath);
655
+ }
656
+ function startBridgeWatcher(jsonlPath, opts) {
657
+ bridgeJsonlPath = jsonlPath;
658
+ bridgeJsonlDir = dirname(jsonlPath);
659
+ bridgeCliPid = opts?.cliPid;
660
+ bridgeCliCwd = opts?.cliCwd;
661
+ const mode = opts?.mode ?? 'baseline-existing';
662
+ // Authoritative: prefer Claude's own pid-state record over the path the
663
+ // adopt scan computed. If Claude has already rotated since adopt fired
664
+ // (e.g. user ran /clear before any Lark message arrived), this swaps the
665
+ // initial path before baseline so we don't waste a baseline on a frozen
666
+ // file.
667
+ if (bridgeCliPid && bridgeCliCwd) {
668
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
669
+ if (resolved) {
670
+ bridgeObservedCliSessionId = resolved.cliSessionId;
671
+ if (resolved.path !== bridgeJsonlPath) {
672
+ log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
673
+ bridgeJsonlPath = resolved.path;
674
+ bridgeJsonlDir = dirname(resolved.path);
675
+ }
676
+ }
677
+ }
678
+ if (mode === 'fresh-empty') {
679
+ // Non-adopt fallback: brand-new session, jsonl gets created on the first
680
+ // user submit. We must NOT lazy-absorb the file when it appears — that
681
+ // would treat the first turn's user/assistant events as history and the
682
+ // worker would never emit a final_output for them. Instead declare
683
+ // baseline=done with offset=0 up front: the very first events drained
684
+ // from the file are eligible for attribution against pending Lark turns.
685
+ bridgeOffset = 0;
686
+ bridgePendingTail = '';
687
+ bridgeBaselineDone = true;
688
+ log(`Bridge fresh-empty mode: ${bridgeJsonlPath} (waiting for file to appear; no baseline absorb)`);
689
+ }
690
+ else if (existsSyncSafe(bridgeJsonlPath)) {
691
+ bridgeAbsorbBaseline();
692
+ log(`Bridge baselined: ${bridgeJsonlPath} (offset=${bridgeOffset})`);
693
+ }
694
+ else {
695
+ log(`Bridge transcript not yet present at ${bridgeJsonlPath}; will baseline on first appearance`);
696
+ }
697
+ // fs.watch is best-effort wakeup — actual data source is the byte offset.
698
+ // The fallback poller covers fs.watch's gaps (NFS, rename-rotation, etc.)
699
+ // and also drives lazy baseline when the file shows up after attach.
700
+ try {
701
+ bridgeWatcher = fsWatch(bridgeJsonlPath, { persistent: false }, () => {
702
+ try {
703
+ bridgeIngest();
704
+ }
705
+ catch (err) {
706
+ log(`Bridge ingest error: ${err.message}`);
707
+ }
708
+ });
709
+ }
710
+ catch (err) {
711
+ log(`Bridge fs.watch unavailable (${err.message}); relying on fallback poller`);
712
+ }
713
+ bridgeFallbackTimer = setInterval(() => {
714
+ try {
715
+ bridgeIngest();
716
+ }
717
+ catch (err) {
718
+ log(`Bridge ingest error: ${err.message}`);
719
+ }
720
+ }, 1000);
721
+ }
722
+ function stopBridgeWatcher() {
723
+ if (bridgeWatcher) {
724
+ try {
725
+ bridgeWatcher.close();
726
+ }
727
+ catch { /* ignore */ }
728
+ bridgeWatcher = null;
729
+ }
730
+ if (bridgeFallbackTimer) {
731
+ clearInterval(bridgeFallbackTimer);
732
+ bridgeFallbackTimer = null;
733
+ }
734
+ bridgeCliPid = undefined;
735
+ bridgeCliCwd = undefined;
736
+ bridgeObservedCliSessionId = undefined;
737
+ bridgeSecondaryPaths.clear();
738
+ bridgePreambleSent = false;
739
+ }
740
+ /**
741
+ * Push a pending turn for the next Lark message.
742
+ *
743
+ * Returns true on success, false if bridge-final-output isn't available for
744
+ * this message (transcript not yet baselined). On false, the worker still
745
+ * raw-writes the message into the pane — the user just won't get a
746
+ * transcript-driven final_output reply for it. This keeps the v3 promise:
747
+ * if we can't attribute correctly, we don't attribute at all.
748
+ *
749
+ * `messageText` is the raw Lark message body — we derive a short content
750
+ * fingerprint from it so the next *matching* user event in the transcript
751
+ * (and only that one) starts this turn. Local-terminal input that races
752
+ * with the pane-write will not match the fingerprint and won't hijack the
753
+ * Lark turn.
754
+ */
755
+ function bridgeMarkPendingTurn(messageText) {
756
+ if (!bridgeJsonlPath)
757
+ return false;
758
+ if (!bridgeBaselineDone) {
759
+ log('Bridge baseline not ready — this turn will not have transcript-driven final_output');
760
+ return false;
761
+ }
762
+ const fingerprint = makeFingerprint(messageText);
763
+ bridgeQueue.mark(randomBytes(8).toString('hex'), fingerprint);
764
+ return true;
765
+ }
766
+ function bridgeDrainAndMaybeEmit() {
767
+ if (!bridgeJsonlPath)
768
+ return;
769
+ bridgeIngest();
770
+ emitReadyTurns();
771
+ // Prune AFTER emit so a path is only retired once its turn has actually
772
+ // been published. During non-idle ticks (fs.watch / 1s poll) we never
773
+ // emit, so we never prune — the path stays put until idle resolves it.
774
+ pruneSecondaryPaths();
775
+ }
776
+ /** Pop ready turns and emit their final_output. Resolves uuid → text via
777
+ * each turn's own `sourceJsonlPath` (stamped at turn-start) so an in-flight
778
+ * reply that started in an old jsonl still gets picked up after a sessionId
779
+ * rotation has switched the global `bridgeJsonlPath` to a different file.
780
+ * Falls back to `bridgeJsonlPath` for legacy turns without a stamped source.
781
+ *
782
+ * Caches per-path drains so a batch of turns from the same file only reads
783
+ * the transcript once (O(jsonl size) per distinct path). */
784
+ function emitReadyTurns() {
785
+ const ready = bridgeQueue.drainEmittable();
786
+ if (ready.length === 0)
787
+ return;
788
+ const adoptMode = lastInitConfig?.adoptMode === true;
789
+ // Send markers (`botmux send` landed in own thread) + the queue's first
790
+ // still-unready turn. The latter caps the LAST ready turn's window —
791
+ // without it, a model that's still mid-tool-use for turn N+1 could leak
792
+ // a send credit into turn N's window via shouldSuppressBridgeEmit.
793
+ const markers = adoptMode ? [] : readSendMarkers();
794
+ const remainingPending = bridgeQueue.peek();
795
+ const nextPendingMarkTimeMs = remainingPending.length > 0 ? remainingPending[0].markTimeMs : undefined;
796
+ const cache = new Map();
797
+ for (let i = 0; i < ready.length; i++) {
798
+ const turn = ready[i];
799
+ const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
800
+ if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
801
+ const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
802
+ log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
803
+ continue;
804
+ }
805
+ const path = turn.sourceJsonlPath ?? bridgeJsonlPath;
806
+ if (!path)
807
+ continue;
808
+ let drained = cache.get(path);
809
+ if (!drained) {
810
+ drained = drainTranscript(path, 0);
811
+ cache.set(path, drained);
812
+ }
813
+ const set = new Set(turn.assistantUuids);
814
+ const matched = drained.events.filter(e => e.uuid && set.has(e.uuid));
815
+ const assistantText = joinAssistantText(matched);
816
+ if (assistantText.length === 0)
817
+ continue;
818
+ const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
819
+ if (turn.isLocal) {
820
+ // Local turn (adopt mode only): also surface the user prompt so the
821
+ // Lark thread shows both sides of the exchange. User text comes from
822
+ // the same drained transcript via the userUuid stamped at start time.
823
+ const userEv = turn.userUuid
824
+ ? drained.events.find(e => e.uuid === turn.userUuid)
825
+ : undefined;
826
+ const userText = userEv ? stringifyUserContent(userEv.message?.content) : '';
827
+ const content = formatLocalTurnContent(userText, assistantText);
828
+ if (!content)
829
+ continue;
830
+ send({ type: 'final_output', content, lastUuid, turnId: turn.turnId });
831
+ continue;
832
+ }
833
+ send({ type: 'final_output', content: assistantText, lastUuid, turnId: turn.turnId });
834
+ }
835
+ }
836
+ /** Drain `path` from `fromOffset` and feed the events to the bridge queue
837
+ * with that path as the source stamp. Pure side-effects on bridgeQueue +
838
+ * the returned cursor; does NOT touch bridgeJsonlPath / bridgeOffset, so
839
+ * callers can use it to flush the old path during a rotation without
840
+ * disturbing the watcher's normal cursor. Returns the new offset for the
841
+ * caller to commit (or discard, if it's about to switch paths). */
842
+ function drainPathInto(path, fromOffset) {
843
+ const result = drainTranscript(path, fromOffset);
844
+ bridgeQueue.ingest(result.events, path);
845
+ return { offset: result.newOffset, tail: result.pendingTail };
846
+ }
847
+ // ─── Codex bridge wiring ─────────────────────────────────────────────────
848
+ //
849
+ // Codex's bridge fallback is intentionally simpler than Claude's: no /adopt
850
+ // surface, no pid-resolver / quiet-rotation / fingerprint-jsonl-switch
851
+ // machinery. The reader watches one rollout file (located by cliSessionId)
852
+ // and the queue's only responsibility is "user fingerprint match → start;
853
+ // assistant_final → close". Everything else (mark / emit gate / send
854
+ // marker IO / type-ahead serialisation / one-write-per-idle break) is
855
+ // shared with the Claude path.
856
+ function codexBridgeFallbackActive() {
857
+ if (lastInitConfig?.adoptMode)
858
+ return false;
859
+ return lastInitConfig?.cliId === 'codex';
860
+ }
861
+ function codexBridgeStartTimer() {
862
+ if (codexBridgeTimer)
863
+ return;
864
+ // Single 1s ticker that handles three jobs: late-attach (poll for the
865
+ // rollout file once we know cliSessionId), ingest (fs.watch backup),
866
+ // and idle-window emit. The last is critical for the late-attach race:
867
+ // if the rollout path appears AFTER the CLI's idle event has fired,
868
+ // the idle callback's emit already ran (and saw an empty queue), so
869
+ // the next emit chance would be at the next idle — i.e. the user has
870
+ // to send another message before the previous turn's fallback shows
871
+ // up. Emitting here when isPromptReady=true closes that window.
872
+ // Codex's queue only releases turns on `assistant_final` (the model's
873
+ // declared end-of-turn), so a tick-driven emit can't accidentally
874
+ // publish a half-streamed response.
875
+ codexBridgeTimer = setInterval(() => {
876
+ try {
877
+ if (!codexBridgeRolloutPath && codexBridgePendingSessionId) {
878
+ const path = findCodexRolloutBySessionId(codexBridgePendingSessionId);
879
+ if (path) {
880
+ codexBridgePendingSessionId = undefined;
881
+ codexBridgeAttach(path, 'fresh-empty');
882
+ }
883
+ }
884
+ codexBridgeIngest();
885
+ if (isPromptReady)
886
+ emitReadyCodexTurns();
887
+ }
888
+ catch (err) {
889
+ log(`Codex bridge tick error: ${err.message}`);
890
+ }
891
+ }, 1000);
892
+ }
893
+ function codexBridgeAttach(rolloutPath, mode) {
894
+ codexBridgeRolloutPath = rolloutPath;
895
+ if (mode === 'fresh-empty') {
896
+ // Brand-new session OR late-attach right after first submit. Either
897
+ // way we want to ingest from offset 0 — pending turns marked before
898
+ // attach are still in the queue, so the user_message that just landed
899
+ // (or is about to land) will fingerprint-match them.
900
+ codexBridgeOffset = 0;
901
+ codexBridgePendingTail = '';
902
+ codexBridgeBaselineDone = true;
903
+ log(`Codex bridge fresh-empty: ${rolloutPath}`);
904
+ }
905
+ else if (existsSync(rolloutPath)) {
906
+ const result = drainCodexRollout(rolloutPath, 0);
907
+ codexBridgeOffset = result.newOffset;
908
+ codexBridgePendingTail = result.pendingTail;
909
+ codexBridgeQueue.absorb(result.events);
910
+ codexBridgeBaselineDone = true;
911
+ log(`Codex bridge baselined: ${rolloutPath} (offset=${codexBridgeOffset})`);
912
+ }
913
+ else {
914
+ // baseline-existing requested but file missing — degrade to fresh
915
+ // semantics so the lazy-appearing file isn't accidentally absorbed.
916
+ codexBridgeOffset = 0;
917
+ codexBridgePendingTail = '';
918
+ codexBridgeBaselineDone = true;
919
+ log(`Codex bridge transcript not yet present at ${rolloutPath}; treating as fresh`);
920
+ }
921
+ try {
922
+ codexBridgeWatcher = fsWatch(rolloutPath, { persistent: false }, () => {
923
+ try {
924
+ codexBridgeIngest();
925
+ }
926
+ catch (err) {
927
+ log(`Codex bridge ingest error: ${err.message}`);
928
+ }
929
+ });
930
+ }
931
+ catch (err) {
932
+ log(`Codex bridge fs.watch unavailable (${err.message}); relying on poller`);
933
+ }
934
+ }
935
+ /** Called from flushPending after writeInput first returns a cliSessionId.
936
+ * Tries to locate the rollout file immediately; if it's not on disk yet,
937
+ * remembers the sid so the 1s poller can keep retrying. */
938
+ function codexBridgeNotifyCliSessionId(cliSessionId) {
939
+ if (!codexBridgeFallbackActive() || codexBridgeRolloutPath)
940
+ return;
941
+ const path = findCodexRolloutBySessionId(cliSessionId);
942
+ if (path) {
943
+ codexBridgePendingSessionId = undefined;
944
+ codexBridgeAttach(path, 'fresh-empty');
945
+ }
946
+ else {
947
+ codexBridgePendingSessionId = cliSessionId;
948
+ codexBridgeStartTimer();
949
+ }
950
+ }
951
+ function codexBridgeIngest() {
952
+ if (!codexBridgeRolloutPath || !codexBridgeBaselineDone)
953
+ return;
954
+ const result = drainCodexRollout(codexBridgeRolloutPath, codexBridgeOffset);
955
+ codexBridgeOffset = result.newOffset;
956
+ codexBridgePendingTail = result.pendingTail;
957
+ codexBridgeQueue.ingest(result.events);
958
+ }
959
+ /** Mark a pending Lark turn for Codex. Crucially this works even before a
960
+ * rollout path is known — the queue is path-agnostic, and ingest after
961
+ * late-attach picks up the user_message and matches the fingerprint. */
962
+ function codexBridgeMarkPendingTurn(messageText) {
963
+ if (!codexBridgeFallbackActive())
964
+ return false;
965
+ const turnId = `codex-${randomBytes(8).toString('hex')}`;
966
+ codexBridgeQueue.mark(turnId, messageText);
967
+ return true;
968
+ }
969
+ function codexBridgeDrainAndMaybeEmit() {
970
+ if (!codexBridgeFallbackActive())
971
+ return;
972
+ if (codexBridgeRolloutPath && codexBridgeBaselineDone) {
973
+ try {
974
+ codexBridgeIngest();
975
+ }
976
+ catch (err) {
977
+ log(`Codex bridge ingest error: ${err.message}`);
978
+ }
979
+ }
980
+ emitReadyCodexTurns();
981
+ }
982
+ function emitReadyCodexTurns() {
983
+ const ready = codexBridgeQueue.drainEmittable();
984
+ if (ready.length === 0)
985
+ return;
986
+ const markers = readSendMarkers();
987
+ const remaining = codexBridgeQueue.peek();
988
+ const nextPendingMarkTimeMs = remaining.length > 0 ? remaining[0].markTimeMs : undefined;
989
+ for (let i = 0; i < ready.length; i++) {
990
+ const turn = ready[i];
991
+ const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
992
+ if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: false }, nextBoundaryMs, markers, false)) {
993
+ log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (model called botmux send within window)`);
994
+ continue;
995
+ }
996
+ if (!turn.finalText)
997
+ continue;
998
+ send({ type: 'final_output', content: turn.finalText, lastUuid: turn.turnId, turnId: turn.turnId });
999
+ }
1000
+ }
1001
+ function stopCodexBridge() {
1002
+ if (codexBridgeWatcher) {
1003
+ try {
1004
+ codexBridgeWatcher.close();
1005
+ }
1006
+ catch { /* ignore */ }
1007
+ codexBridgeWatcher = null;
1008
+ }
1009
+ if (codexBridgeTimer) {
1010
+ clearInterval(codexBridgeTimer);
1011
+ codexBridgeTimer = null;
1012
+ }
1013
+ codexBridgeRolloutPath = undefined;
1014
+ codexBridgeOffset = 0;
1015
+ codexBridgePendingTail = '';
1016
+ codexBridgeBaselineDone = false;
1017
+ codexBridgeQueue.clearPending();
1018
+ codexBridgePendingSessionId = undefined;
1019
+ }
1020
+ /** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
1021
+ * whose sourceJsonlPath equals oldPath may still be waiting on assistant
1022
+ * text that hasn't landed yet. Add oldPath to the secondary polling set
1023
+ * so subsequent ingests continue to drain it; the offset is whatever was
1024
+ * reached by the final pre-switch drain so we don't re-scan history. The
1025
+ * entry is later pruned after each idle emit when no started turn
1026
+ * references it anymore. */
1027
+ function retainSecondaryPathIfStillReferenced(oldPath, postDrainOffset) {
1028
+ const stillReferenced = bridgeQueue.peek().some(t => t.sourceJsonlPath === oldPath);
1029
+ if (!stillReferenced)
1030
+ return;
1031
+ const existing = bridgeSecondaryPaths.get(oldPath);
1032
+ // Don't rewind a higher existing offset — multiple rotations through
1033
+ // the same file shouldn't replay drained bytes.
1034
+ if (existing === undefined || postDrainOffset > existing) {
1035
+ bridgeSecondaryPaths.set(oldPath, postDrainOffset);
1036
+ }
1037
+ log(`Bridge retaining secondary path ${oldPath} (offset=${postDrainOffset}) for in-flight turn`);
1038
+ }
1039
+ /** Drain every secondary path once. Mirrors bridgeIngest's primary-path
1040
+ * drain but never touches bridgeJsonlPath / bridgeOffset and never
1041
+ * triggers further rotation checks — it's strictly a "catch up trailing
1042
+ * events on an old file" pass. */
1043
+ function drainSecondaryPaths() {
1044
+ for (const [path, offset] of bridgeSecondaryPaths) {
1045
+ try {
1046
+ const result = drainTranscript(path, offset);
1047
+ if (result.events.length > 0)
1048
+ bridgeQueue.ingest(result.events, path);
1049
+ bridgeSecondaryPaths.set(path, result.newOffset);
1050
+ }
1051
+ catch (err) {
1052
+ log(`Bridge secondary-path drain failed (${path}): ${err.message}`);
1053
+ }
1054
+ }
1055
+ }
1056
+ /** Drop secondary paths whose started turns are no longer in the queue —
1057
+ * i.e. they've been emitted (or discarded). Called after each idle emit so
1058
+ * pruning never races with an in-flight turn. */
1059
+ function pruneSecondaryPaths() {
1060
+ if (bridgeSecondaryPaths.size === 0)
1061
+ return;
1062
+ const referenced = new Set();
1063
+ for (const t of bridgeQueue.peek()) {
1064
+ if (t.sourceJsonlPath)
1065
+ referenced.add(t.sourceJsonlPath);
1066
+ }
1067
+ for (const path of [...bridgeSecondaryPaths.keys()]) {
1068
+ if (!referenced.has(path)) {
1069
+ bridgeSecondaryPaths.delete(path);
1070
+ log(`Bridge dropped secondary path ${path} (no remaining turns)`);
1071
+ }
1072
+ }
1073
+ }
1074
+ /** Tiny safe-existence check that doesn't throw. */
1075
+ function existsSyncSafe(p) {
1076
+ try {
1077
+ return existsSync(p);
1078
+ }
1079
+ catch {
1080
+ return false;
1081
+ }
1082
+ }
54
1083
  /** Suppress screen updates until first prompt detected (avoids history replay in card on --resume) */
55
1084
  let awaitingFirstPrompt = true;
56
1085
  // ─── PTY Dimensions ──────────────────────────────────────────────────────────
57
- // Matches SNAPSHOT_COLS / SHOT_COLS (160). Narrow enough for the web terminal
58
- // to render comfortably; the card PNG crops at this width anyway.
59
- const PTY_COLS = 160;
60
- const PTY_ROWS = 50;
1086
+ // Default for botmux-spawned CLIs: narrow enough for the web terminal to
1087
+ // render comfortably and for the card PNG to fit Lark's typical card width.
1088
+ // Adopt mode overrides this via resolveRenderDimensions() to match the
1089
+ // user's actual pane (often 200-270 cols) so the renderer doesn't wrap
1090
+ // wide ANSI into a stair-stepped / duplicated mess.
1091
+ const PTY_COLS = DEFAULT_RENDER_COLS;
1092
+ const PTY_ROWS = DEFAULT_RENDER_ROWS;
1093
+ /** Set in the `init` handler BEFORE startScreenUpdates() so the headless
1094
+ * xterm + screenshot canvas are sized to the source pane from the start.
1095
+ * Setting them later (after the renderer was built at the default size)
1096
+ * wouldn't retroactively re-size what xterm has already buffered,
1097
+ * leaving the wrap artefacts in place. */
1098
+ let renderCols = PTY_COLS;
1099
+ let renderRows = PTY_ROWS;
61
1100
  // ─── Headless Terminal for Screen Capture ────────────────────────────────────
62
1101
  let renderer = null;
63
1102
  let screenUpdateTimer = null;
@@ -117,8 +1156,10 @@ function stopScreenAnalyzer() {
117
1156
  // ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
118
1157
  const SCREENSHOT_INTERVAL_MS = 10_000;
119
1158
  const POST_ACTION_DELAY_MS = 1_000;
120
- const SHOT_COLS = 160;
121
- const SHOT_ROWS = 50;
1159
+ // PNG dimensions key off the renderer's actual size (renderCols / renderRows),
1160
+ // which adopt-mode peg to the source pane so wrap artefacts don't appear.
1161
+ // Re-clamping at MAX_RENDER_COLS/ROWS guards against a malformed init
1162
+ // payload sneaking past the resolver into a runaway canvas.
122
1163
  let displayMode = 'hidden';
123
1164
  let screenshotTimer = null;
124
1165
  let pendingShotTimer = null;
@@ -178,7 +1219,9 @@ async function captureAndUpload() {
178
1219
  lastShotHash = hash;
179
1220
  let png;
180
1221
  try {
181
- png = captureToPng(term, { cols: SHOT_COLS, rows: SHOT_ROWS, startY });
1222
+ const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
1223
+ const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
1224
+ png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
182
1225
  }
183
1226
  catch (err) {
184
1227
  log(`Screenshot render failed: ${err.message}`);
@@ -377,9 +1420,11 @@ let trustHandled = false;
377
1420
  // ─── Prompt Detection ────────────────────────────────────────────────────────
378
1421
  function onPtyData(data) {
379
1422
  renderer?.write(data);
380
- // In tmux mode, web clients have their own tmux attach — no relay needed.
381
- // In non-tmux mode, broadcast to all WS clients via shared scrollback.
382
- if (!isTmuxMode) {
1423
+ // In tmux-attach mode, each web client has its own tmux attach PTY
1424
+ // no relay needed. In non-tmux mode AND in pipe mode (adopt-bridge),
1425
+ // broadcast through the shared scrollback so all connected web clients
1426
+ // render the same byte stream.
1427
+ if (!isTmuxMode || isPipeMode) {
383
1428
  // Track alt-buffer state so we can restore it in the scrollback prefix.
384
1429
  // Scan for the *last* toggle in this chunk — that's the current state.
385
1430
  let lastToggleIdx = -1;
@@ -453,6 +1498,23 @@ function markPromptReady() {
453
1498
  }
454
1499
  flushPending();
455
1500
  }
1501
+ function persistCliSessionId(cliSessionId) {
1502
+ if (!cliSessionId || !sessionId)
1503
+ return;
1504
+ if (lastInitConfig)
1505
+ lastInitConfig.cliSessionId = cliSessionId;
1506
+ try {
1507
+ const session = sessionStore.getSession(sessionId);
1508
+ if (!session || session.cliSessionId === cliSessionId)
1509
+ return;
1510
+ session.cliSessionId = cliSessionId;
1511
+ sessionStore.updateSession(session);
1512
+ log(`Persisted CLI session id: ${cliSessionId}`);
1513
+ }
1514
+ catch (err) {
1515
+ log(`Failed to persist CLI session id: ${err.message}`);
1516
+ }
1517
+ }
456
1518
  /**
457
1519
  * Drain the pending message queue sequentially.
458
1520
  * Async with isFlushing mutex: awaits each writeInput, then immediately
@@ -467,7 +1529,21 @@ async function flushPending() {
467
1529
  if (pendingMessages.length === 0)
468
1530
  return; // nothing to flush — keep isPromptReady
469
1531
  // Type-ahead adapters flush even while the CLI is busy; others wait for idle.
470
- if (!isPromptReady && !cliAdapter.supportsTypeAhead)
1532
+ // Bridge fallback (non-adopt) disables type-ahead: queued submits land
1533
+ // in jsonl as `attachment(queued_command)` events, NOT `role:user` lines,
1534
+ // so BridgeTurnQueue.ingest never starts the pending turn for them and
1535
+ // the assistant text would be dropped on the floor. Serialise instead —
1536
+ // worker holds messages in pendingMessages until the CLI reaches idle.
1537
+ const claudeBridgeActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
1538
+ const codexBridgeActive = codexBridgeFallbackActive();
1539
+ const bridgeFallbackActive = claudeBridgeActive || codexBridgeActive;
1540
+ // Type-ahead must be disabled for any active bridge fallback (claude or
1541
+ // codex). Claude type-ahead's queued submits never become role:user
1542
+ // events; Codex doesn't declare supportsTypeAhead so this is mostly a
1543
+ // belt-and-braces gate, but keep symmetry so future adapters with
1544
+ // type-ahead get the same protection automatically.
1545
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !bridgeFallbackActive;
1546
+ if (!isPromptReady && !typeAheadAllowed)
471
1547
  return;
472
1548
  isFlushing = true;
473
1549
  if (isPromptReady) {
@@ -477,8 +1553,41 @@ async function flushPending() {
477
1553
  try {
478
1554
  while (pendingMessages.length > 0 && backend && cliAdapter) {
479
1555
  const msg = pendingMessages.shift();
1556
+ // Bridge fallback: mark immediately before writeInput. Doing it here
1557
+ // (instead of at enqueue time) means markTimeMs anchors to the
1558
+ // moment the message actually starts hitting the PTY — so any
1559
+ // `botmux send` whose sentAtMs lands during turn N's processing
1560
+ // falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
1561
+ // (at IPC arrival) would let a slow-finishing turn N's send leak
1562
+ // into turn N+1's window and falsely suppress its emit.
1563
+ if (claudeBridgeActive) {
1564
+ try {
1565
+ bridgeIngest();
1566
+ }
1567
+ catch { /* best-effort */ }
1568
+ bridgeMarkPendingTurn(msg);
1569
+ }
1570
+ else if (codexBridgeActive) {
1571
+ // Codex mark works even before the rollout path is known: the
1572
+ // queue is path-agnostic, and the late-attach below will start
1573
+ // ingest from offset 0 so the user_message that lands shortly
1574
+ // after still fingerprint-matches this turn.
1575
+ codexBridgeMarkPendingTurn(msg);
1576
+ }
480
1577
  log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
481
1578
  const result = await cliAdapter.writeInput(backend, msg);
1579
+ // Persist any sessionId the adapter observed via authoritative sources
1580
+ // (Claude's pid file, Codex's history). Done independently of submit
1581
+ // outcome — the rotation is real even when the current Enter didn't
1582
+ // land, and we want next-resume to use the right id.
1583
+ if (result?.cliSessionId) {
1584
+ persistCliSessionId(result.cliSessionId);
1585
+ // First successful Codex submit also reveals the rollout path.
1586
+ // Late-attach now so subsequent assistant_final events get
1587
+ // attributed to this turn.
1588
+ if (codexBridgeActive)
1589
+ codexBridgeNotifyCliSessionId(result.cliSessionId);
1590
+ }
482
1591
  if (result && result.submitted === false) {
483
1592
  const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
484
1593
  log(`writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
@@ -487,6 +1596,16 @@ async function flushPending() {
487
1596
  message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 3 次仍未在会话 JSONL 中看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
488
1597
  });
489
1598
  }
1599
+ // Bridge fallback: stop after one writeInput. Subsequent submits
1600
+ // would be type-ahead'd into Claude's queue, which jsonl records as
1601
+ // queued_command attachments (not role:user lines) — BridgeTurnQueue
1602
+ // can't attribute those, so the fallback would silently drop them.
1603
+ // We resume on the next idle, by which point Claude has finished
1604
+ // and the next message can be a normal role:user submit. Scoped to
1605
+ // bridgeFallbackActive so non-bridge CLIs (codex/gemini/...) keep
1606
+ // the original "one idle drains all pending" behaviour.
1607
+ if (bridgeFallbackActive && pendingMessages.length > 0)
1608
+ break;
490
1609
  }
491
1610
  }
492
1611
  finally {
@@ -497,11 +1616,23 @@ function sendToPty(content) {
497
1616
  if (!backend || !cliAdapter)
498
1617
  return;
499
1618
  pendingMessages.push(content);
1619
+ // User-override semantics: a fresh Lark message while a TUI prompt is "active"
1620
+ // takes precedence over the AI-detected prompt. The screen analyzer can be
1621
+ // wrong (false positive on a question that has no rendered options) and a
1622
+ // wedged blocking flag silently swallows every subsequent message — without
1623
+ // this override the user has no way to recover from Lark. Mirrors the
1624
+ // web-terminal text-input path (handleTuiTextInput).
500
1625
  if (tuiPromptBlocking) {
501
- log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — TUI prompt active`);
502
- return;
1626
+ log(`User override: incoming Lark message clears tuiPromptBlocking "${content.substring(0, 80)}"`);
1627
+ tuiPromptBlocking = false;
1628
+ screenAnalyzer?.notifySelection('lark-input');
1629
+ // Tear down the prompt card so the user doesn't see stale options.
1630
+ send({ type: 'tui_prompt_resolved', selectedText: 'user-override' });
503
1631
  }
504
- if (isPromptReady || isFlushing || cliAdapter.supportsTypeAhead) {
1632
+ // See flushPending: bridge fallback gates type-ahead off (claude OR codex).
1633
+ const bridgeFallbackActive = (!!bridgeJsonlPath && !lastInitConfig?.adoptMode) || codexBridgeFallbackActive();
1634
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !bridgeFallbackActive;
1635
+ if (isPromptReady || isFlushing || typeAheadAllowed) {
505
1636
  log(`Writing to PTY: "${content.substring(0, 80)}"`);
506
1637
  flushPending(); // fire-and-forget async; no-op if already flushing
507
1638
  }
@@ -511,7 +1642,12 @@ function sendToPty(content) {
511
1642
  }
512
1643
  // ─── Screen Update Timer ─────────────────────────────────────────────────────
513
1644
  function startScreenUpdates() {
514
- renderer = new TerminalRenderer(PTY_COLS, PTY_ROWS);
1645
+ // renderCols / renderRows were set by the init handler from cfg, so
1646
+ // adopt-mode panes (e.g. 270x57) get an xterm-headless of matching
1647
+ // width. With a too-narrow renderer, ANSI meant for the source pane
1648
+ // would wrap and the screenshot would show duplicated / stair-stepped
1649
+ // content (the live failure that prompted this fix).
1650
+ renderer = new TerminalRenderer(renderCols, renderRows);
515
1651
  let lastSentStatus;
516
1652
  screenUpdateTimer = setInterval(() => {
517
1653
  if (!renderer || awaitingFirstPrompt)
@@ -539,36 +1675,73 @@ function stopScreenUpdates() {
539
1675
  }
540
1676
  // ─── PTY Management ──────────────────────────────────────────────────────────
541
1677
  function spawnCli(cfg) {
542
- // ── Adopt mode: attach to an existing tmux pane (no CLI spawn) ──
1678
+ // ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
543
1679
  if (cfg.adoptMode && cfg.adoptTmuxTarget) {
1680
+ // We mark BOTH isTmuxMode and isPipeMode: the former keeps idle/spawn
1681
+ // logic on the tmux track; the latter tells the WS handler to route
1682
+ // updates through the shared scrollback fan-out (because there is no
1683
+ // PTY-per-WS — we don't attach to anything).
544
1684
  isTmuxMode = true;
1685
+ isPipeMode = true;
545
1686
  const cols = cfg.adoptPaneCols ?? PTY_COLS;
546
1687
  const rows = cfg.adoptPaneRows ?? PTY_ROWS;
547
- const tmuxBe = new TmuxBackend('adopt-' + cfg.sessionId.slice(0, 8), { ownsSession: false });
548
- backend = tmuxBe;
549
- tmuxBe.attachToExisting(cfg.adoptTmuxTarget, {
1688
+ const pipeBe = new TmuxPipeBackend(cfg.adoptTmuxTarget);
1689
+ backend = pipeBe;
1690
+ pipeBe.spawn('', [], {
550
1691
  cwd: cfg.workingDir,
551
1692
  cols,
552
1693
  rows,
553
1694
  env: process.env,
554
1695
  });
555
- // Minimal idle detection (output quiescence only)
556
- idleDetector = new IdleDetector({ completionPattern: undefined, readyPattern: undefined });
1696
+ // Seed the shared scrollback with the pane's current screen so any
1697
+ // already-connected (or future) WS clients render meaningful content
1698
+ // immediately, instead of waiting for the next byte tmux pipes through.
1699
+ try {
1700
+ const initial = pipeBe.captureCurrentScreen();
1701
+ if (initial.length > 0)
1702
+ onPtyData(initial);
1703
+ }
1704
+ catch (err) {
1705
+ log(`captureCurrentScreen failed: ${err.message}`);
1706
+ }
1707
+ // Bridge mode: tail Claude Code's transcript JSONL to harvest assistant
1708
+ // turns out-of-band. Only enabled when the daemon supplied a path
1709
+ // (claude-code adopt with a known sessionId).
1710
+ if (cfg.bridgeJsonlPath) {
1711
+ startBridgeWatcher(cfg.bridgeJsonlPath, {
1712
+ cliPid: cfg.adoptCliPid,
1713
+ cliCwd: cfg.adoptCwd,
1714
+ });
1715
+ }
1716
+ // Idle detection. In bridge mode we use Claude Code's real
1717
+ // completion/ready patterns (e.g. "Worked for Xs") so tool-execution
1718
+ // pauses don't trigger a premature emit. Other adopt cases keep the
1719
+ // minimal output-quiescence-only detector.
1720
+ const idleAdapter = cfg.bridgeJsonlPath
1721
+ ? createCliAdapterSync('claude-code', undefined)
1722
+ : { completionPattern: undefined, readyPattern: undefined };
1723
+ idleDetector = new IdleDetector(idleAdapter);
557
1724
  idleDetector.onIdle(() => {
558
1725
  log('Prompt detected (idle) — adopt mode');
1726
+ try {
1727
+ bridgeDrainAndMaybeEmit();
1728
+ }
1729
+ catch (err) {
1730
+ log(`Bridge emit error: ${err.message}`);
1731
+ }
559
1732
  markPromptReady();
560
1733
  });
561
1734
  backend.onData(onPtyData);
562
1735
  backend.onExit((code, signal) => {
563
- log(`Adopted session exited (code: ${code}, signal: ${signal})`);
1736
+ log(`Adopted pipe-pane stream ended (code: ${code}, signal: ${signal})`);
564
1737
  backend = null;
565
1738
  isPromptReady = false;
1739
+ stopBridgeWatcher();
566
1740
  send({ type: 'claude_exit', code, signal });
567
1741
  });
568
- // CLI is already running — unblock screen updates immediately
569
1742
  awaitingFirstPrompt = false;
570
1743
  renderer?.markNewTurn();
571
- log(`Adopt mode: attached to ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
1744
+ log(`Adopt mode (pipe): observing ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
572
1745
  return;
573
1746
  }
574
1747
  cliAdapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride);
@@ -588,6 +1761,7 @@ function spawnCli(cfg) {
588
1761
  const args = cliAdapter.buildArgs({
589
1762
  sessionId: cfg.sessionId,
590
1763
  resume: cfg.resume ?? false,
1764
+ resumeSessionId: cfg.cliSessionId,
591
1765
  initialPrompt: cfg.prompt || undefined,
592
1766
  botName: cfg.botName,
593
1767
  botOpenId: cfg.botOpenId,
@@ -632,16 +1806,85 @@ function spawnCli(cfg) {
632
1806
  log(`Failed to write CLI PID marker: ${err.message}`);
633
1807
  }
634
1808
  }
1809
+ // Wire pid + cwd so the claude-code adapter's writeInput can read
1810
+ // ~/.claude/sessions/<pid>.json — Claude's authoritative current sessionId.
1811
+ // The pinned claudeJsonlPath above is still used as the initial guess; the
1812
+ // resolver corrects it on first write when Claude has rotated under us.
1813
+ if (cfg.cliId === 'claude-code' && cliPid) {
1814
+ backend.cliPid = cliPid;
1815
+ backend.cliCwd = cfg.workingDir;
1816
+ }
635
1817
  // On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
636
1818
  // suppressed until the idle detector fires markNewTurn() — this prevents the
637
1819
  // full tmux scrollback history from leaking into the streaming card.
638
1820
  if (tmuxBe?.isReattach) {
639
1821
  log('Re-attached to existing tmux session');
640
1822
  }
1823
+ // Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
1824
+ // turn the model finishes WITHOUT calling `botmux send` still gets its
1825
+ // assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
1826
+ // the emit when a send did happen). Adopt mode wires this up separately
1827
+ // (with baseline-existing); here we use fresh-empty for new sessions so
1828
+ // the file Claude creates on first submit isn't absorbed as history,
1829
+ // and baseline-existing on resume so prior-run turns ARE absorbed (we
1830
+ // don't want to re-emit yesterday's conversation as fresh turns).
1831
+ if (cfg.cliId === 'claude-code' && cfg.sessionId) {
1832
+ const claudeJsonl = claudeJsonlPathForSession(cfg.sessionId, cfg.workingDir);
1833
+ startBridgeWatcher(claudeJsonl, {
1834
+ cliPid: cliPid ?? undefined,
1835
+ cliCwd: cfg.workingDir,
1836
+ mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
1837
+ });
1838
+ }
1839
+ // Codex bridge fallback: same intent as the Claude block above but a
1840
+ // different transcript layout. Resume-with-known-cliSessionId can attach
1841
+ // immediately; new sessions / resume-without-id rely on flushPending to
1842
+ // late-attach once writeInput returns the cliSessionId.
1843
+ if (cfg.cliId === 'codex') {
1844
+ if (cfg.cliSessionId) {
1845
+ const rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
1846
+ if (rolloutPath) {
1847
+ codexBridgeAttach(rolloutPath, 'baseline-existing');
1848
+ }
1849
+ else {
1850
+ // Resume but the rollout file isn't where we expected — start the
1851
+ // poller so we keep looking; if the user submits and a new file
1852
+ // appears, late-attach kicks in via writeInput's cliSessionId.
1853
+ codexBridgePendingSessionId = cfg.cliSessionId;
1854
+ codexBridgeStartTimer();
1855
+ }
1856
+ }
1857
+ else {
1858
+ // Brand-new Codex session: no path until first submit. Start the
1859
+ // poller anyway so the CLI is ready to attach the moment we have
1860
+ // a cliSessionId.
1861
+ codexBridgeStartTimer();
1862
+ }
1863
+ }
641
1864
  // Set up idle detection
642
1865
  idleDetector = new IdleDetector(cliAdapter);
643
1866
  idleDetector.onIdle(() => {
644
1867
  log('Prompt detected (idle)');
1868
+ // Bridge drain MUST run before markPromptReady() — the latter calls
1869
+ // flushPending() which can immediately fire the next queued message
1870
+ // (type-ahead adapters), shifting bridgeQueue's notion of "current
1871
+ // turn" before we've had a chance to emit the previous one.
1872
+ if (bridgeJsonlPath) {
1873
+ try {
1874
+ bridgeDrainAndMaybeEmit();
1875
+ }
1876
+ catch (err) {
1877
+ log(`Bridge emit error: ${err.message}`);
1878
+ }
1879
+ }
1880
+ if (codexBridgeFallbackActive()) {
1881
+ try {
1882
+ codexBridgeDrainAndMaybeEmit();
1883
+ }
1884
+ catch (err) {
1885
+ log(`Codex bridge emit error: ${err.message}`);
1886
+ }
1887
+ }
645
1888
  markPromptReady();
646
1889
  });
647
1890
  backend.onData(onPtyData);
@@ -671,6 +1914,11 @@ function killCli() {
671
1914
  stopScreenUpdates();
672
1915
  backend?.kill();
673
1916
  backend = null;
1917
+ // Tear down the bridge watcher (if any). spawnCli will rebuild it on
1918
+ // restart with the proper mode based on the new cfg. Leaving it running
1919
+ // would dangle a watcher pinned to a stale jsonl path.
1920
+ stopBridgeWatcher();
1921
+ stopCodexBridge();
674
1922
  // Clean up CLI PID marker
675
1923
  if (cliPidMarker) {
676
1924
  try {
@@ -703,8 +1951,8 @@ function startWebServer(host, preferredPort) {
703
1951
  if (hasWrite)
704
1952
  authedClients.add(ws);
705
1953
  log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
706
- if (isTmuxMode && sessionId) {
707
- // ── Tmux mode: per-client attach ──
1954
+ if (isTmuxMode && !isPipeMode && sessionId) {
1955
+ // ── Tmux-attach mode: per-client attach ──
708
1956
  // Each WS client gets its own `tmux attach-session` PTY.
709
1957
  // Scrollback is handled natively by tmux (history-limit).
710
1958
  // In adopt mode, attach to the user's original pane; otherwise use bmx-* session.
@@ -864,11 +2112,11 @@ body{display:flex;flex-direction:column}
864
2112
  color:#565f89;background:#1a1b26cc;padding:2px 8px;border-radius:4px}
865
2113
  #status.ok{color:#9ece6a}
866
2114
  #status.err{color:#f7768e}
867
- #readonly-banner{display:none;position:fixed;top:0;left:0;right:0;z-index:50;
868
- padding:6px 12px;text-align:center;font:12px monospace;color:#f7768e;
869
- background:rgba(247,118,142,0.12);border-bottom:1px solid rgba(247,118,142,0.35);
870
- backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);pointer-events:none}
871
- #readonly-banner.show{display:block}
2115
+ #readonly-banner{display:none;position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:50;
2116
+ padding:4px 10px;font:12px monospace;color:#f7768e;white-space:nowrap;cursor:pointer;
2117
+ background:rgba(247,118,142,0.12);border:1px solid rgba(247,118,142,0.35);border-radius:4px;
2118
+ backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
2119
+ #readonly-banner.show{display:inline-block}
872
2120
  </style>
873
2121
  </head>
874
2122
  <body>
@@ -894,7 +2142,7 @@ body{display:flex;flex-direction:column}
894
2142
  var isTouch='ontouchstart'in window||navigator.maxTouchPoints>0;
895
2143
  if(isTouch)document.getElementById('vp').content='width=1100,viewport-fit=cover';
896
2144
  var hasToken=${hasWrite};
897
- if(!hasToken)document.getElementById('readonly-banner').classList.add('show');
2145
+ if(!hasToken){var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
898
2146
 
899
2147
  var term=new Terminal({
900
2148
  theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
@@ -971,7 +2219,7 @@ window.addEventListener('resize',function(){fit.fit();sendResize()});
971
2219
  })();
972
2220
 
973
2221
  // ── Read-only scroll handling ──
974
- if(!hasToken&&!${isTmuxMode}){
2222
+ if(!hasToken&&!${isTmuxMode && !isPipeMode}){
975
2223
  // Non-tmux read-only: CLI mouse mode blocks local scroll, override with scrollLines
976
2224
  document.getElementById('terminal').addEventListener('wheel',function(e){
977
2225
  e.preventDefault();term.scrollLines(e.deltaY>0?3:-3);
@@ -981,7 +2229,7 @@ if(!hasToken&&!${isTmuxMode}){
981
2229
  // ── Scroll helper (shared by toolbar buttons & two-finger touch) ──
982
2230
  function _sendScroll(up,n){
983
2231
  n=n||3;
984
- if(${isTmuxMode}){
2232
+ if(${isTmuxMode && !isPipeMode}){
985
2233
  // SGR mouse wheel: 64=up 65=down — tmux enters copy-mode and scrolls
986
2234
  var seq='\\x1b[<'+(up?64:65)+';1;1M';
987
2235
  for(var i=0;i<n;i++){if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'input',data:seq}))}
@@ -1070,7 +2318,15 @@ process.on('message', async (raw) => {
1070
2318
  // Capture credentials for direct image upload from worker
1071
2319
  larkAppIdForUpload = msg.larkAppId;
1072
2320
  larkAppSecretForUpload = msg.larkAppSecret;
1073
- log(`Init: session=${sessionId}, cwd=${msg.workingDir}`);
2321
+ // Resolve render dimensions BEFORE startScreenUpdates() the
2322
+ // headless xterm and PNG canvas need to know the source pane size
2323
+ // up-front. Setting them later (after the renderer was built at
2324
+ // 160x50) wouldn't unwrap content xterm has already buffered, so
2325
+ // adopt-mode wide-pane content would still come out stair-stepped.
2326
+ const dims = resolveRenderDimensions(msg);
2327
+ renderCols = dims.cols;
2328
+ renderRows = dims.rows;
2329
+ log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
1074
2330
  try {
1075
2331
  const port = await startWebServer('0.0.0.0', msg.webPort);
1076
2332
  startScreenUpdates();
@@ -1079,6 +2335,8 @@ process.on('message', async (raw) => {
1079
2335
  // Queue the initial prompt — flushed when CLI shows idle.
1080
2336
  // Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
1081
2337
  // prompt into CLI args, so we skip queuing to avoid double-send.
2338
+ // Bridge mark is deferred to flushPending — see flushPending
2339
+ // comment for why marking at enqueue is wrong.
1082
2340
  if (msg.prompt && !cliAdapter?.passesInitialPromptViaArgs) {
1083
2341
  pendingMessages.push(msg.prompt);
1084
2342
  }
@@ -1098,6 +2356,18 @@ process.on('message', async (raw) => {
1098
2356
  exitTmuxScrollMode();
1099
2357
  const content = msg.content;
1100
2358
  if (lastInitConfig?.adoptMode) {
2359
+ // Bridge mode: capture transcript baseline BEFORE writing to the pane,
2360
+ // so any assistant uuids appended after this point are attributed to
2361
+ // *this* Lark turn (not local user activity in the pane). Mark may
2362
+ // return false (baseline not ready) — we still write to the pane;
2363
+ // user just won't get a final_output for this message.
2364
+ if (bridgeJsonlPath) {
2365
+ try {
2366
+ bridgeIngest();
2367
+ }
2368
+ catch { /* best effort */ }
2369
+ bridgeMarkPendingTurn(content);
2370
+ }
1101
2371
  // Adopt mode: raw write to PTY (no adapter writeInput)
1102
2372
  if (backend) {
1103
2373
  if ('sendText' in backend && 'sendSpecialKeys' in backend) {
@@ -1112,6 +2382,11 @@ process.on('message', async (raw) => {
1112
2382
  }
1113
2383
  }
1114
2384
  else {
2385
+ // Non-adopt: enqueue only. Bridge mark is deferred to flushPending
2386
+ // so markTimeMs anchors to the actual PTY-write moment, not IPC
2387
+ // arrival. Marking now would race with a still-running previous
2388
+ // turn whose `botmux send` could sneak its sentAtMs past this
2389
+ // turn's markTimeMs and falsely suppress its fallback.
1115
2390
  sendToPty(content);
1116
2391
  }
1117
2392
  break;
@@ -1192,6 +2467,11 @@ process.on('message', async (raw) => {
1192
2467
  // destroySession kills tmux session permanently; kill() only detaches
1193
2468
  backend?.destroySession?.();
1194
2469
  killCli();
2470
+ // Bridge marker file outlives a single CLI process (we keep it across
2471
+ // restarts so a mid-flight send is still credited), but a real close
2472
+ // tears down the session — purge the file so a future re-use of the
2473
+ // same sessionId starts clean.
2474
+ clearSendMarkers();
1195
2475
  cleanup();
1196
2476
  process.exit(0);
1197
2477
  }