botmux 2.11.1 → 2.12.0

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 (91) 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/schedule-store.d.ts +3 -0
  70. package/dist/services/schedule-store.d.ts.map +1 -1
  71. package/dist/services/schedule-store.js +6 -0
  72. package/dist/services/schedule-store.js.map +1 -1
  73. package/dist/services/session-store.d.ts +10 -0
  74. package/dist/services/session-store.d.ts.map +1 -1
  75. package/dist/services/session-store.js +40 -0
  76. package/dist/services/session-store.js.map +1 -1
  77. package/dist/skills/definitions.d.ts.map +1 -1
  78. package/dist/skills/definitions.js +2 -1
  79. package/dist/skills/definitions.js.map +1 -1
  80. package/dist/types.d.ts +22 -0
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/utils/render-dimensions.d.ts +48 -0
  83. package/dist/utils/render-dimensions.d.ts.map +1 -0
  84. package/dist/utils/render-dimensions.js +55 -0
  85. package/dist/utils/render-dimensions.js.map +1 -0
  86. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  87. package/dist/utils/terminal-renderer.js +5 -2
  88. package/dist/utils/terminal-renderer.js.map +1 -1
  89. package/dist/worker.js +1068 -37
  90. package/dist/worker.js.map +1 -1
  91. package/package.json +1 -1
package/dist/worker.js CHANGED
@@ -13,15 +13,21 @@
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 { dirname } from 'node:path';
18
22
  import { createServer as createHttpServer } from 'node:http';
19
23
  import { WebSocketServer, WebSocket } from 'ws';
20
24
  import { TerminalRenderer } from './utils/terminal-renderer.js';
25
+ 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
26
  import { createCliAdapterSync } from './adapters/cli/registry.js';
22
- import { claudeJsonlPathForSession } from './adapters/cli/claude-code.js';
27
+ import { claudeJsonlPathForSession, resolveJsonlFromPid } from './adapters/cli/claude-code.js';
23
28
  import { PtyBackend } from './adapters/backend/pty-backend.js';
24
29
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
30
+ import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
25
31
  import { IdleDetector } from './utils/idle-detector.js';
26
32
  import { ScreenAnalyzer } from './utils/screen-analyzer.js';
27
33
  import { captureToPng } from './utils/screenshot-renderer.js';
@@ -36,6 +42,10 @@ let backend = null;
36
42
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
37
43
  let idleDetector = null;
38
44
  let isTmuxMode = false;
45
+ /** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
46
+ * web-terminal updates flow through the shared scrollback fan-out instead
47
+ * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
48
+ let isPipeMode = false;
39
49
  let httpServer = null;
40
50
  let wss = null;
41
51
  const wsClients = new Set();
@@ -51,13 +61,848 @@ let isPromptReady = false;
51
61
  /** Mutex for async flushPending — prevents concurrent flush loops. */
52
62
  let isFlushing = false;
53
63
  const pendingMessages = [];
64
+ // ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
65
+ //
66
+ // In bridge mode the daemon adopted an existing CLI session that we do NOT
67
+ // own; the model never sees botmux. We harvest assistant turns by tailing
68
+ // Claude Code's transcript JSONL and forward only the bytes appended after
69
+ // each Lark-driven user turn — never the historical content present at
70
+ // attach time, never local-terminal-driven turns.
71
+ //
72
+ // Attribution lives in BridgeTurnQueue; this file only manages the
73
+ // fs.watch wakeup, byte-offset bookkeeping, lazy baseline, and IPC emit.
74
+ let bridgeJsonlPath;
75
+ /** Directory enclosing bridgeJsonlPath. We poll this dir for newer jsonl
76
+ * files so the bridge follows `/clear` / `/resume` in the user's CLI —
77
+ * those create a brand-new sessionId.jsonl, and a watcher pinned to the
78
+ * original path would silently stop receiving events. */
79
+ let bridgeJsonlDir;
80
+ /** PID + cwd of the adopted Claude Code process. Lets every poll re-read
81
+ * ~/.claude/sessions/<pid>.json — Claude's own authoritative record of the
82
+ * current sessionId — and switch the watched jsonl when Claude rotates
83
+ * (via /clear, /resume, --resume etc.) without waiting for a Lark message
84
+ * to land in the new file. */
85
+ let bridgeCliPid;
86
+ let bridgeCliCwd;
87
+ /** Last sessionId we observed via the pid resolver — used to detect
88
+ * rotations cheaply (string compare instead of stat()ing every jsonl). */
89
+ let bridgeObservedCliSessionId;
90
+ /** Old jsonl paths we keep polling AFTER a rotation switched
91
+ * bridgeJsonlPath away — needed when a started turn was stamped with the
92
+ * old path but its assistant text hasn't been written yet. We continue to
93
+ * drain each entry on every tick so trailing appends to that file land in
94
+ * the queue against the right turn, and prune the entry once no pending
95
+ * turn references the path anymore. */
96
+ const bridgeSecondaryPaths = new Map(); // path → offset
97
+ let bridgeOffset = 0;
98
+ let bridgePendingTail = '';
99
+ const bridgeQueue = new BridgeTurnQueue();
100
+ let bridgeWatcher = null;
101
+ let bridgeFallbackTimer = null;
102
+ /** True once we successfully baselined the transcript file. Until then,
103
+ * any data we see is treated as history — absorbed into the queue's seen
104
+ * set without being attributed to a pending Lark turn. This protects the
105
+ * first Lark turn from inheriting historical lines if Claude Code creates
106
+ * the JSONL file *after* attach. */
107
+ let bridgeBaselineDone = false;
108
+ /** Once-per-attach flag so a re-baseline after fs.watch lazy-fire doesn't
109
+ * re-send the preamble. Reset only when the bridge teardown happens. */
110
+ let bridgePreambleSent = false;
111
+ /** Cap the preamble text so an extremely long previous turn doesn't blow
112
+ * past Lark's per-message limit. The user only needs enough to recall
113
+ * context, not the entire transcript. */
114
+ const PREAMBLE_USER_MAX = 500;
115
+ const PREAMBLE_ASSISTANT_MAX = 4000;
116
+ /** Same intent as the preamble caps, but for live local-terminal turns
117
+ * forwarded to Lark. A long paste typed locally shouldn't be allowed to
118
+ * blow past Lark's per-message limit. */
119
+ const LOCAL_TURN_USER_MAX = 1000;
120
+ const LOCAL_TURN_ASSISTANT_MAX = 8000;
121
+ function truncatePreambleText(text, max) {
122
+ if (text.length <= max)
123
+ return text;
124
+ return text.slice(0, max) + '…';
125
+ }
126
+ /** Compose a `final_output` payload for a turn synthesised from a user
127
+ * prompt the human typed directly into the adopted pane. Shows both the
128
+ * user text and assistant text so the Lark thread doesn't see an orphan
129
+ * reply with no context. Returns `null` when neither side has anything
130
+ * visible — the worker should suppress the emit in that case. */
131
+ function formatLocalTurnContent(userText, assistantText) {
132
+ const u = truncatePreambleText(userText.trim(), LOCAL_TURN_USER_MAX);
133
+ const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
134
+ if (!u && !a)
135
+ return null;
136
+ return [
137
+ '🖥️ 终端本地对话(在 adopted pane 中直接输入,已同步至飞书)',
138
+ '',
139
+ '👤 你:',
140
+ u || '(空)',
141
+ '',
142
+ `🤖 ${cliName()}:`,
143
+ a || '(空)',
144
+ ].join('\n');
145
+ }
146
+ // ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
147
+ //
148
+ // `botmux send` (cli.ts cmdSend) appends a line `{sentAtMs, messageId}\n` to
149
+ // `<DATA_DIR>/turn-sends/<sid>.jsonl` every time the model successfully posts
150
+ // a reply to its OWN session thread. The worker reads these markers at idle
151
+ // and suppresses transcript-driven final_output for any turn whose time
152
+ // window already contains a send — i.e. the model didn't forget, no fallback
153
+ // needed. Append-only over a shared file (instead of a per-turn marker) is
154
+ // type-ahead safe: type-ahead'd turns each have their own [markTimeMs,
155
+ // nextTurn.markTimeMs) window, and a stray send only fills its own bucket.
156
+ function bridgeMarkerPath() {
157
+ if (!process.env.SESSION_DATA_DIR || !sessionId)
158
+ return undefined;
159
+ return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
160
+ }
161
+ function readSendMarkers() {
162
+ const path = bridgeMarkerPath();
163
+ if (!path || !existsSync(path))
164
+ return [];
165
+ try {
166
+ const out = [];
167
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
168
+ if (!line.trim())
169
+ continue;
170
+ try {
171
+ const parsed = JSON.parse(line);
172
+ if (typeof parsed?.sentAtMs === 'number')
173
+ out.push(parsed);
174
+ }
175
+ catch { /* skip malformed line */ }
176
+ }
177
+ return out;
178
+ }
179
+ catch (err) {
180
+ log(`Bridge marker read failed: ${err.message}`);
181
+ return [];
182
+ }
183
+ }
184
+ function clearSendMarkers() {
185
+ const path = bridgeMarkerPath();
186
+ if (!path)
187
+ return;
188
+ try {
189
+ unlinkSync(path);
190
+ }
191
+ catch { /* already gone or fs.unavailable; not fatal */ }
192
+ }
193
+ function maybeEmitAdoptPreamble(events) {
194
+ // Preamble is an /adopt-only signal: it tells the user "here's the last
195
+ // turn from the Claude session you just attached to, so the Lark thread
196
+ // has context to continue from". In non-adopt sessions the user IS the
197
+ // Lark thread (every turn was already pushed there as a card), so
198
+ // surfacing the last turn again on daemon restart is just noise.
199
+ if (!lastInitConfig?.adoptMode)
200
+ return;
201
+ if (bridgePreambleSent)
202
+ return;
203
+ const turn = extractLastAssistantTurn(events);
204
+ if (!turn)
205
+ return;
206
+ bridgePreambleSent = true;
207
+ send({
208
+ type: 'adopt_preamble',
209
+ userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
210
+ assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
211
+ });
212
+ log('Bridge adopt preamble emitted (last completed turn from baseline)');
213
+ }
214
+ function bridgeAbsorbBaseline() {
215
+ if (!bridgeJsonlPath)
216
+ return;
217
+ const result = drainTranscript(bridgeJsonlPath, 0);
218
+ bridgeOffset = result.newOffset;
219
+ bridgePendingTail = result.pendingTail;
220
+ bridgeQueue.absorb(result.events);
221
+ bridgeBaselineDone = true;
222
+ // After absorb (uuids registered as seen so they won't re-emit as a Lark
223
+ // turn), surface the last completed user/assistant exchange to Lark as a
224
+ // one-shot preamble — but only for real /adopt sessions. Non-adopt
225
+ // claude-code fallback bridge also uses baseline-existing on daemon
226
+ // restart/resume; it must not emit the "/adopt 前最后一轮" message.
227
+ if (lastInitConfig?.adoptMode)
228
+ maybeEmitAdoptPreamble(result.events);
229
+ }
230
+ /** Detect /clear / /resume: when Claude Code starts a new session in the
231
+ * user's pane it writes to a brand-new sessionId.jsonl. We *cannot* use
232
+ * "latest-mtime jsonl in the project dir" as the switch trigger — that
233
+ * hijacks our watcher whenever a sibling Claude pane in the same cwd
234
+ * writes anything. Instead, switch only when:
235
+ *
236
+ * 1. We have an unstarted pending Lark turn (otherwise no signal to
237
+ * chase, and switching would risk grabbing another pane's reply).
238
+ * 2. The pending turn's content fingerprint shows up in a candidate
239
+ * jsonl other than our current one — that's the user's current
240
+ * session because they JUST typed our pane-write into it.
241
+ *
242
+ * Pending turns are preserved across the switch so the next ingest can
243
+ * match the fingerprint and start the turn in the new file. */
244
+ function maybeSwitchBridgeJsonl() {
245
+ if (!bridgeJsonlDir)
246
+ return false;
247
+ const pending = bridgeQueue.peek();
248
+ const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
249
+ if (!candidate || !candidate.contentFingerprint)
250
+ return false;
251
+ // Bound the search to events written after the turn was marked. Short
252
+ // fingerprints ("hello", "test") would otherwise match old user lines
253
+ // in unrelated sibling jsonls. 5s skew absorbs clock drift between the
254
+ // mark and Claude's transcript write.
255
+ const minEventTimestampMs = candidate.markTimeMs !== undefined
256
+ ? candidate.markTimeMs - 5_000
257
+ : undefined;
258
+ const matched = findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
259
+ excludePath: bridgeJsonlPath,
260
+ includeQueueOperations: true,
261
+ minEventTimestampMs,
262
+ });
263
+ if (!matched)
264
+ return false;
265
+ // Drain-before-switch: pull in any unread bytes from the old path so a
266
+ // late assistant append doesn't vanish. We do NOT emit here — emission
267
+ // only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
268
+ // would publish a half-finished assistant turn during fs.watch / poll
269
+ // ticks (drainEmittable's contract is "has visible text", not "model
270
+ // finished"). If the drained user/assistant events still need follow-up
271
+ // appends on the old path, retainSecondaryPathIfStillReferenced() keeps
272
+ // the old path in the polling rotation.
273
+ if (bridgeJsonlPath && bridgeBaselineDone) {
274
+ let postDrainOffset = bridgeOffset;
275
+ try {
276
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
277
+ postDrainOffset = drained.offset;
278
+ }
279
+ catch (err) {
280
+ log(`Bridge final-drain on fingerprint switch failed (${err.message}); continuing`);
281
+ }
282
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
283
+ }
284
+ log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (Lark fingerprint observed in new jsonl — user likely ran /clear or /resume)`);
285
+ if (bridgeWatcher) {
286
+ try {
287
+ bridgeWatcher.close();
288
+ }
289
+ catch { /* ignore */ }
290
+ bridgeWatcher = null;
291
+ }
292
+ // Critically: do NOT clear pending turns. The switch was triggered by
293
+ // the fingerprint of the FIRST pending turn already living in `matched`,
294
+ // so the immediate next ingest from offset 0 will find that user event
295
+ // and start the turn. Clearing here would race-drop exactly the message
296
+ // we're trying to deliver.
297
+ bridgeJsonlPath = matched;
298
+ bridgeOffset = 0;
299
+ bridgePendingTail = '';
300
+ // baselineDone=false would absorb the new file's existing content
301
+ // (including the pending turn's user event) as history — defeating the
302
+ // switch. Skip baseline; fall straight into ingest from offset 0 so
303
+ // BridgeTurnQueue.ingest() can attribute the matching user/assistant.
304
+ bridgeBaselineDone = true;
305
+ try {
306
+ bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
307
+ try {
308
+ bridgeIngest();
309
+ }
310
+ catch (err) {
311
+ log(`Bridge ingest error: ${err.message}`);
312
+ }
313
+ });
314
+ }
315
+ catch (err) {
316
+ log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
317
+ }
318
+ return true;
319
+ }
320
+ /** /clear or /resume in the user's adopted pane creates (or touches) a new
321
+ * jsonl in the same Claude project directory. Neither pid-resolver nor
322
+ * fingerprint switch will fire when the rotation happened mid-process AND
323
+ * there's no pending Lark turn to anchor on (pure local-terminal use), so
324
+ * this fallback owns that case.
325
+ *
326
+ * Detection priority:
327
+ * 1. Linux first-class: read `/proc/<pid>/fd` and pick the .jsonl the
328
+ * adopted Claude process actually has open. This is bound to the real
329
+ * PID — a sibling Claude pane in the same cwd has a different PID and
330
+ * therefore cannot hijack the result.
331
+ * 2. Cross-platform fallback: directory-level mtime heuristic, gated on
332
+ * (a) our current jsonl quiet ≥ QUIET_ROTATION_MS, (b) candidate
333
+ * newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Less
334
+ * robust than fd lookup but the best available without /proc.
335
+ *
336
+ * When a rotation is detected, the new jsonl is drained from offset 0 and
337
+ * events are split by timestamp against `rotationCutoffMs` (the old
338
+ * jsonl's last-write time): events before the cutoff are *history*
339
+ * (absorbed into the seen-set, not emitted), events after are *live*
340
+ * (ingested → local-turn synthesis runs). This is what lets /resume to a
341
+ * long-history jsonl NOT replay the entire past as one giant local turn,
342
+ * while /clear's first new turn still gets forwarded.
343
+ *
344
+ * Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
345
+ * also fires `maybeEmitAdoptPreamble`, which on rotation would surface
346
+ * the *previous session's* last turn as if it were a fresh "/adopt 前最
347
+ * 后一轮" preamble. Preamble belongs only to initial attach. */
348
+ const QUIET_ROTATION_MS = 8_000;
349
+ function statSafe(path) {
350
+ try {
351
+ const st = statSync(path);
352
+ if (!st.isFile())
353
+ return null;
354
+ return { mtimeMs: st.mtimeMs, size: st.size };
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ }
360
+ function isPidAlive(pid) {
361
+ if (!Number.isInteger(pid) || pid <= 0)
362
+ return false;
363
+ try {
364
+ process.kill(pid, 0);
365
+ return true;
366
+ }
367
+ catch {
368
+ return false;
369
+ }
370
+ }
371
+ /** List `.jsonl` files inside `dir` that are currently held open by `pid`.
372
+ * Returns [] on non-Linux platforms or if /proc lookup fails — the caller
373
+ * treats an empty result as "fd info unavailable, fall back to mtime". */
374
+ function findOpenJsonlsForPid(pid, dir) {
375
+ if (!Number.isInteger(pid) || pid <= 0)
376
+ return [];
377
+ if (process.platform !== 'linux')
378
+ return [];
379
+ let entries;
380
+ try {
381
+ entries = readdirSync(`/proc/${pid}/fd`);
382
+ }
383
+ catch {
384
+ return [];
385
+ }
386
+ const out = [];
387
+ for (const name of entries) {
388
+ let target;
389
+ try {
390
+ target = readlinkSync(`/proc/${pid}/fd/${name}`);
391
+ }
392
+ catch {
393
+ continue;
394
+ }
395
+ if (!target.endsWith('.jsonl'))
396
+ continue;
397
+ if (dirname(target) !== dir)
398
+ continue;
399
+ out.push(target);
400
+ }
401
+ return out;
402
+ }
403
+ /** Pick the most recently modified path among `paths`. Returns null if
404
+ * none of them stat. */
405
+ function newestPath(paths) {
406
+ let best = null;
407
+ for (const p of paths) {
408
+ const st = statSafe(p);
409
+ if (!st)
410
+ continue;
411
+ if (!best || st.mtimeMs > best.mtimeMs)
412
+ best = { path: p, mtimeMs: st.mtimeMs };
413
+ }
414
+ return best?.path ?? null;
415
+ }
416
+ /** Switch bridgeJsonlPath to `newPath` and split-baseline its existing
417
+ * content: events with timestamp ≤ `cutoffMs` are absorbed as history
418
+ * (seen-set only, no emission), events strictly after are ingested so
419
+ * local turn synthesis runs against them. The old path is retained in
420
+ * the secondary polling rotation if any started turn still references
421
+ * it. Does NOT emit `adopt_preamble` — that's an initial-attach signal,
422
+ * not a rotation signal. */
423
+ function performRotationSwitch(newPath, cutoffMs, reason) {
424
+ // Drain-before-switch: pull any unread bytes from the old path so a
425
+ // late assistant append doesn't vanish. Mirrors the other rotation
426
+ // helpers.
427
+ if (bridgeJsonlPath && bridgeBaselineDone) {
428
+ let postDrainOffset = bridgeOffset;
429
+ try {
430
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
431
+ postDrainOffset = drained.offset;
432
+ }
433
+ catch (err) {
434
+ log(`Bridge final-drain on rotation (${reason}) failed (${err.message}); continuing`);
435
+ }
436
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
437
+ }
438
+ log(`Bridge transcript switched (${reason}): ${bridgeJsonlPath ?? '(none)'} → ${newPath}`);
439
+ if (bridgeWatcher) {
440
+ try {
441
+ bridgeWatcher.close();
442
+ }
443
+ catch { /* ignore */ }
444
+ bridgeWatcher = null;
445
+ }
446
+ bridgeJsonlPath = newPath;
447
+ bridgeJsonlDir = dirname(newPath);
448
+ bridgePendingTail = '';
449
+ // Drain the new path from 0 ourselves (do NOT call bridgeAbsorbBaseline
450
+ // — that would emit the preamble we want to suppress on rotation).
451
+ const result = drainTranscript(newPath, 0);
452
+ bridgeOffset = result.newOffset;
453
+ bridgePendingTail = result.pendingTail;
454
+ const history = [];
455
+ const live = [];
456
+ for (const ev of result.events) {
457
+ let evMs = Number.NaN;
458
+ if (typeof ev.timestamp === 'string')
459
+ evMs = Date.parse(ev.timestamp);
460
+ if (Number.isFinite(evMs) && evMs <= cutoffMs)
461
+ history.push(ev);
462
+ else
463
+ live.push(ev);
464
+ }
465
+ bridgeQueue.absorb(history);
466
+ if (live.length > 0)
467
+ bridgeQueue.ingest(live, newPath);
468
+ bridgeBaselineDone = true;
469
+ log(`Bridge rotation split: ${history.length} historical events absorbed, ${live.length} live events ingested`);
470
+ try {
471
+ bridgeWatcher = fsWatch(newPath, { persistent: false }, () => {
472
+ try {
473
+ bridgeIngest();
474
+ }
475
+ catch (err) {
476
+ log(`Bridge ingest error: ${err.message}`);
477
+ }
478
+ });
479
+ }
480
+ catch (err) {
481
+ log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
482
+ }
483
+ }
484
+ function maybeFollowQuietRotation() {
485
+ if (!bridgeJsonlDir || !bridgeJsonlPath)
486
+ return;
487
+ // Need a known pid to do safe rotation tracking; if we don't have one,
488
+ // we can't bind to the adopted Claude process and a directory-mtime
489
+ // switch would risk sibling-pane hijack.
490
+ if (bridgeCliPid === undefined)
491
+ return;
492
+ if (!isPidAlive(bridgeCliPid))
493
+ return;
494
+ const currentStat = statSafe(bridgeJsonlPath);
495
+ if (!currentStat)
496
+ return;
497
+ // Path 1: Linux fd-based detection — definitive, can't be hijacked.
498
+ // Read /proc/<pid>/fd, find every .jsonl Claude has open in our cwd's
499
+ // project dir, pick the one with the most recent mtime. Differs from
500
+ // bridgeJsonlPath ⇒ rotation.
501
+ const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
502
+ if (opened.length > 0) {
503
+ const newest = newestPath(opened);
504
+ if (newest && newest !== bridgeJsonlPath) {
505
+ performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
506
+ }
507
+ // fd lookup succeeded — even if it confirmed the current path, the
508
+ // mtime fallback below would only add risk. Stop here.
509
+ return;
510
+ }
511
+ // Path 2: non-Linux fallback (or /proc unavailable). Directory-mtime
512
+ // heuristic with three guards. Less robust than fd lookup; sibling
513
+ // panes could in principle race the conditions, but the QUIET windows
514
+ // make it unlikely in practice.
515
+ const now = Date.now();
516
+ if (now - currentStat.mtimeMs < QUIET_ROTATION_MS)
517
+ return;
518
+ const latest = findLatestJsonl(bridgeJsonlDir);
519
+ if (!latest || latest === bridgeJsonlPath)
520
+ return;
521
+ const latestStat = statSafe(latest);
522
+ if (!latestStat)
523
+ return;
524
+ if (latestStat.mtimeMs - currentStat.mtimeMs < QUIET_ROTATION_MS)
525
+ return;
526
+ performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
527
+ }
528
+ function maybeFollowSessionRotationViaPid() {
529
+ if (!bridgeCliPid || !bridgeCliCwd)
530
+ return 'unavailable';
531
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
532
+ if (!resolved)
533
+ return 'unavailable';
534
+ if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
535
+ bridgeObservedCliSessionId = resolved.cliSessionId;
536
+ }
537
+ if (resolved.path === bridgeJsonlPath)
538
+ return 'same';
539
+ // Drain-before-switch: pull in any unread bytes from the OLD path so a
540
+ // trailing assistant append doesn't vanish. We do NOT emit here — emit
541
+ // is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
542
+ // publish a half-finished assistant during fs.watch / poll-driven
543
+ // bridgeIngest calls. If a started turn still references the old path
544
+ // and its assistant text might still be on the way, the old path stays
545
+ // in the polling rotation via bridgeSecondaryPaths.
546
+ if (bridgeJsonlPath && bridgeBaselineDone) {
547
+ let postDrainOffset = bridgeOffset;
548
+ try {
549
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
550
+ postDrainOffset = drained.offset;
551
+ }
552
+ catch (err) {
553
+ log(`Bridge final-drain on rotation failed (${err.message}); continuing`);
554
+ }
555
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
556
+ }
557
+ log(`Bridge transcript switched (pid resolver): ${bridgeJsonlPath ?? '(none)'} → ${resolved.path}`);
558
+ if (bridgeWatcher) {
559
+ try {
560
+ bridgeWatcher.close();
561
+ }
562
+ catch { /* ignore */ }
563
+ bridgeWatcher = null;
564
+ }
565
+ // Preserve any pending Lark turn so the next ingest can attribute it
566
+ // when Claude appends our user event to the new jsonl. Skip baseline:
567
+ // we want to read from offset 0 so the pending turn's user event is
568
+ // visible to BridgeTurnQueue.ingest(). Turns already started on the
569
+ // old path keep their stamped sourceJsonlPath, so when their assistant
570
+ // text eventually arrives there too it still resolves correctly.
571
+ bridgeJsonlPath = resolved.path;
572
+ bridgeJsonlDir = dirname(resolved.path);
573
+ bridgeOffset = 0;
574
+ bridgePendingTail = '';
575
+ bridgeBaselineDone = true;
576
+ try {
577
+ bridgeWatcher = fsWatch(resolved.path, { persistent: false }, () => {
578
+ try {
579
+ bridgeIngest();
580
+ }
581
+ catch (err) {
582
+ log(`Bridge ingest error: ${err.message}`);
583
+ }
584
+ });
585
+ }
586
+ catch (err) {
587
+ log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
588
+ }
589
+ return 'switched';
590
+ }
591
+ function bridgeIngest() {
592
+ // Drain secondary paths first so any trailing assistant text on an old
593
+ // jsonl reaches the queue before the rotation check considers retiring
594
+ // the path. Strictly read-only on the polling rotation; never triggers
595
+ // a rotate or shifts the primary path.
596
+ drainSecondaryPaths();
597
+ // Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
598
+ // pid file → new sessionId), e.g. daemon restart that re-issues
599
+ // `--resume <id>` and Claude rotates the internal id.
600
+ const pidFollow = maybeFollowSessionRotationViaPid();
601
+ // Fingerprint fallback: catches *in-process* rotations Claude makes
602
+ // via /clear or /resume from the user's pane. Claude's pid file has
603
+ // its sessionId field set ONCE at process start (see binary persistence
604
+ // schema) and is NOT rewritten on /clear, so pid resolver returning
605
+ // 'same' is NOT proof that no rotation happened. We skip the
606
+ // fingerprint scan only when pid resolver actively switched the path
607
+ // — in that case the authoritative source already moved us, and
608
+ // running fingerprint on top would risk a redundant flip.
609
+ let switched = pidFollow === 'switched';
610
+ if (!switched) {
611
+ switched = maybeSwitchBridgeJsonl();
612
+ }
613
+ // Quiet-rotation fallback: catches /clear or /resume in pure-local
614
+ // sessions (no pending Lark turn → no fingerprint to match against).
615
+ // Without this, a user who hits /clear in the adopted pane and then
616
+ // continues in the terminal would never get those replies forwarded
617
+ // to Lark — the watcher stays stuck on the old, frozen jsonl.
618
+ if (!switched) {
619
+ maybeFollowQuietRotation();
620
+ }
621
+ if (!bridgeJsonlPath)
622
+ return;
623
+ if (!bridgeBaselineDone) {
624
+ // Lazy baseline: file didn't exist at attach, baseline the moment it does.
625
+ if (!existsSyncSafe(bridgeJsonlPath))
626
+ return;
627
+ bridgeAbsorbBaseline();
628
+ return;
629
+ }
630
+ const result = drainTranscript(bridgeJsonlPath, bridgeOffset);
631
+ bridgeOffset = result.newOffset;
632
+ bridgePendingTail = result.pendingTail;
633
+ bridgeQueue.ingest(result.events, bridgeJsonlPath);
634
+ }
635
+ function startBridgeWatcher(jsonlPath, opts) {
636
+ bridgeJsonlPath = jsonlPath;
637
+ bridgeJsonlDir = dirname(jsonlPath);
638
+ bridgeCliPid = opts?.cliPid;
639
+ bridgeCliCwd = opts?.cliCwd;
640
+ const mode = opts?.mode ?? 'baseline-existing';
641
+ // Authoritative: prefer Claude's own pid-state record over the path the
642
+ // adopt scan computed. If Claude has already rotated since adopt fired
643
+ // (e.g. user ran /clear before any Lark message arrived), this swaps the
644
+ // initial path before baseline so we don't waste a baseline on a frozen
645
+ // file.
646
+ if (bridgeCliPid && bridgeCliCwd) {
647
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
648
+ if (resolved) {
649
+ bridgeObservedCliSessionId = resolved.cliSessionId;
650
+ if (resolved.path !== bridgeJsonlPath) {
651
+ log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
652
+ bridgeJsonlPath = resolved.path;
653
+ bridgeJsonlDir = dirname(resolved.path);
654
+ }
655
+ }
656
+ }
657
+ if (mode === 'fresh-empty') {
658
+ // Non-adopt fallback: brand-new session, jsonl gets created on the first
659
+ // user submit. We must NOT lazy-absorb the file when it appears — that
660
+ // would treat the first turn's user/assistant events as history and the
661
+ // worker would never emit a final_output for them. Instead declare
662
+ // baseline=done with offset=0 up front: the very first events drained
663
+ // from the file are eligible for attribution against pending Lark turns.
664
+ bridgeOffset = 0;
665
+ bridgePendingTail = '';
666
+ bridgeBaselineDone = true;
667
+ log(`Bridge fresh-empty mode: ${bridgeJsonlPath} (waiting for file to appear; no baseline absorb)`);
668
+ }
669
+ else if (existsSyncSafe(bridgeJsonlPath)) {
670
+ bridgeAbsorbBaseline();
671
+ log(`Bridge baselined: ${bridgeJsonlPath} (offset=${bridgeOffset})`);
672
+ }
673
+ else {
674
+ log(`Bridge transcript not yet present at ${bridgeJsonlPath}; will baseline on first appearance`);
675
+ }
676
+ // fs.watch is best-effort wakeup — actual data source is the byte offset.
677
+ // The fallback poller covers fs.watch's gaps (NFS, rename-rotation, etc.)
678
+ // and also drives lazy baseline when the file shows up after attach.
679
+ try {
680
+ bridgeWatcher = fsWatch(bridgeJsonlPath, { persistent: false }, () => {
681
+ try {
682
+ bridgeIngest();
683
+ }
684
+ catch (err) {
685
+ log(`Bridge ingest error: ${err.message}`);
686
+ }
687
+ });
688
+ }
689
+ catch (err) {
690
+ log(`Bridge fs.watch unavailable (${err.message}); relying on fallback poller`);
691
+ }
692
+ bridgeFallbackTimer = setInterval(() => {
693
+ try {
694
+ bridgeIngest();
695
+ }
696
+ catch (err) {
697
+ log(`Bridge ingest error: ${err.message}`);
698
+ }
699
+ }, 1000);
700
+ }
701
+ function stopBridgeWatcher() {
702
+ if (bridgeWatcher) {
703
+ try {
704
+ bridgeWatcher.close();
705
+ }
706
+ catch { /* ignore */ }
707
+ bridgeWatcher = null;
708
+ }
709
+ if (bridgeFallbackTimer) {
710
+ clearInterval(bridgeFallbackTimer);
711
+ bridgeFallbackTimer = null;
712
+ }
713
+ bridgeCliPid = undefined;
714
+ bridgeCliCwd = undefined;
715
+ bridgeObservedCliSessionId = undefined;
716
+ bridgeSecondaryPaths.clear();
717
+ bridgePreambleSent = false;
718
+ }
719
+ /**
720
+ * Push a pending turn for the next Lark message.
721
+ *
722
+ * Returns true on success, false if bridge-final-output isn't available for
723
+ * this message (transcript not yet baselined). On false, the worker still
724
+ * raw-writes the message into the pane — the user just won't get a
725
+ * transcript-driven final_output reply for it. This keeps the v3 promise:
726
+ * if we can't attribute correctly, we don't attribute at all.
727
+ *
728
+ * `messageText` is the raw Lark message body — we derive a short content
729
+ * fingerprint from it so the next *matching* user event in the transcript
730
+ * (and only that one) starts this turn. Local-terminal input that races
731
+ * with the pane-write will not match the fingerprint and won't hijack the
732
+ * Lark turn.
733
+ */
734
+ function bridgeMarkPendingTurn(messageText) {
735
+ if (!bridgeJsonlPath)
736
+ return false;
737
+ if (!bridgeBaselineDone) {
738
+ log('Bridge baseline not ready — this turn will not have transcript-driven final_output');
739
+ return false;
740
+ }
741
+ const fingerprint = makeFingerprint(messageText);
742
+ bridgeQueue.mark(randomBytes(8).toString('hex'), fingerprint);
743
+ return true;
744
+ }
745
+ function bridgeDrainAndMaybeEmit() {
746
+ if (!bridgeJsonlPath)
747
+ return;
748
+ bridgeIngest();
749
+ emitReadyTurns();
750
+ // Prune AFTER emit so a path is only retired once its turn has actually
751
+ // been published. During non-idle ticks (fs.watch / 1s poll) we never
752
+ // emit, so we never prune — the path stays put until idle resolves it.
753
+ pruneSecondaryPaths();
754
+ }
755
+ /** Pop ready turns and emit their final_output. Resolves uuid → text via
756
+ * each turn's own `sourceJsonlPath` (stamped at turn-start) so an in-flight
757
+ * reply that started in an old jsonl still gets picked up after a sessionId
758
+ * rotation has switched the global `bridgeJsonlPath` to a different file.
759
+ * Falls back to `bridgeJsonlPath` for legacy turns without a stamped source.
760
+ *
761
+ * Caches per-path drains so a batch of turns from the same file only reads
762
+ * the transcript once (O(jsonl size) per distinct path). */
763
+ function emitReadyTurns() {
764
+ const ready = bridgeQueue.drainEmittable();
765
+ if (ready.length === 0)
766
+ return;
767
+ const adoptMode = lastInitConfig?.adoptMode === true;
768
+ // Send markers (`botmux send` landed in own thread) + the queue's first
769
+ // still-unready turn. The latter caps the LAST ready turn's window —
770
+ // without it, a model that's still mid-tool-use for turn N+1 could leak
771
+ // a send credit into turn N's window via shouldSuppressBridgeEmit.
772
+ const markers = adoptMode ? [] : readSendMarkers();
773
+ const remainingPending = bridgeQueue.peek();
774
+ const nextPendingMarkTimeMs = remainingPending.length > 0 ? remainingPending[0].markTimeMs : undefined;
775
+ const cache = new Map();
776
+ for (let i = 0; i < ready.length; i++) {
777
+ const turn = ready[i];
778
+ const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
779
+ if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
780
+ const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
781
+ log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
782
+ continue;
783
+ }
784
+ const path = turn.sourceJsonlPath ?? bridgeJsonlPath;
785
+ if (!path)
786
+ continue;
787
+ let drained = cache.get(path);
788
+ if (!drained) {
789
+ drained = drainTranscript(path, 0);
790
+ cache.set(path, drained);
791
+ }
792
+ const set = new Set(turn.assistantUuids);
793
+ const matched = drained.events.filter(e => e.uuid && set.has(e.uuid));
794
+ const assistantText = joinAssistantText(matched);
795
+ if (assistantText.length === 0)
796
+ continue;
797
+ const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
798
+ if (turn.isLocal) {
799
+ // Local turn (adopt mode only): also surface the user prompt so the
800
+ // Lark thread shows both sides of the exchange. User text comes from
801
+ // the same drained transcript via the userUuid stamped at start time.
802
+ const userEv = turn.userUuid
803
+ ? drained.events.find(e => e.uuid === turn.userUuid)
804
+ : undefined;
805
+ const userText = userEv ? stringifyUserContent(userEv.message?.content) : '';
806
+ const content = formatLocalTurnContent(userText, assistantText);
807
+ if (!content)
808
+ continue;
809
+ send({ type: 'final_output', content, lastUuid, turnId: turn.turnId });
810
+ continue;
811
+ }
812
+ send({ type: 'final_output', content: assistantText, lastUuid, turnId: turn.turnId });
813
+ }
814
+ }
815
+ /** Drain `path` from `fromOffset` and feed the events to the bridge queue
816
+ * with that path as the source stamp. Pure side-effects on bridgeQueue +
817
+ * the returned cursor; does NOT touch bridgeJsonlPath / bridgeOffset, so
818
+ * callers can use it to flush the old path during a rotation without
819
+ * disturbing the watcher's normal cursor. Returns the new offset for the
820
+ * caller to commit (or discard, if it's about to switch paths). */
821
+ function drainPathInto(path, fromOffset) {
822
+ const result = drainTranscript(path, fromOffset);
823
+ bridgeQueue.ingest(result.events, path);
824
+ return { offset: result.newOffset, tail: result.pendingTail };
825
+ }
826
+ /** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
827
+ * whose sourceJsonlPath equals oldPath may still be waiting on assistant
828
+ * text that hasn't landed yet. Add oldPath to the secondary polling set
829
+ * so subsequent ingests continue to drain it; the offset is whatever was
830
+ * reached by the final pre-switch drain so we don't re-scan history. The
831
+ * entry is later pruned after each idle emit when no started turn
832
+ * references it anymore. */
833
+ function retainSecondaryPathIfStillReferenced(oldPath, postDrainOffset) {
834
+ const stillReferenced = bridgeQueue.peek().some(t => t.sourceJsonlPath === oldPath);
835
+ if (!stillReferenced)
836
+ return;
837
+ const existing = bridgeSecondaryPaths.get(oldPath);
838
+ // Don't rewind a higher existing offset — multiple rotations through
839
+ // the same file shouldn't replay drained bytes.
840
+ if (existing === undefined || postDrainOffset > existing) {
841
+ bridgeSecondaryPaths.set(oldPath, postDrainOffset);
842
+ }
843
+ log(`Bridge retaining secondary path ${oldPath} (offset=${postDrainOffset}) for in-flight turn`);
844
+ }
845
+ /** Drain every secondary path once. Mirrors bridgeIngest's primary-path
846
+ * drain but never touches bridgeJsonlPath / bridgeOffset and never
847
+ * triggers further rotation checks — it's strictly a "catch up trailing
848
+ * events on an old file" pass. */
849
+ function drainSecondaryPaths() {
850
+ for (const [path, offset] of bridgeSecondaryPaths) {
851
+ try {
852
+ const result = drainTranscript(path, offset);
853
+ if (result.events.length > 0)
854
+ bridgeQueue.ingest(result.events, path);
855
+ bridgeSecondaryPaths.set(path, result.newOffset);
856
+ }
857
+ catch (err) {
858
+ log(`Bridge secondary-path drain failed (${path}): ${err.message}`);
859
+ }
860
+ }
861
+ }
862
+ /** Drop secondary paths whose started turns are no longer in the queue —
863
+ * i.e. they've been emitted (or discarded). Called after each idle emit so
864
+ * pruning never races with an in-flight turn. */
865
+ function pruneSecondaryPaths() {
866
+ if (bridgeSecondaryPaths.size === 0)
867
+ return;
868
+ const referenced = new Set();
869
+ for (const t of bridgeQueue.peek()) {
870
+ if (t.sourceJsonlPath)
871
+ referenced.add(t.sourceJsonlPath);
872
+ }
873
+ for (const path of [...bridgeSecondaryPaths.keys()]) {
874
+ if (!referenced.has(path)) {
875
+ bridgeSecondaryPaths.delete(path);
876
+ log(`Bridge dropped secondary path ${path} (no remaining turns)`);
877
+ }
878
+ }
879
+ }
880
+ /** Tiny safe-existence check that doesn't throw. */
881
+ function existsSyncSafe(p) {
882
+ try {
883
+ return existsSync(p);
884
+ }
885
+ catch {
886
+ return false;
887
+ }
888
+ }
54
889
  /** Suppress screen updates until first prompt detected (avoids history replay in card on --resume) */
55
890
  let awaitingFirstPrompt = true;
56
891
  // ─── 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;
892
+ // Default for botmux-spawned CLIs: narrow enough for the web terminal to
893
+ // render comfortably and for the card PNG to fit Lark's typical card width.
894
+ // Adopt mode overrides this via resolveRenderDimensions() to match the
895
+ // user's actual pane (often 200-270 cols) so the renderer doesn't wrap
896
+ // wide ANSI into a stair-stepped / duplicated mess.
897
+ const PTY_COLS = DEFAULT_RENDER_COLS;
898
+ const PTY_ROWS = DEFAULT_RENDER_ROWS;
899
+ /** Set in the `init` handler BEFORE startScreenUpdates() so the headless
900
+ * xterm + screenshot canvas are sized to the source pane from the start.
901
+ * Setting them later (after the renderer was built at the default size)
902
+ * wouldn't retroactively re-size what xterm has already buffered,
903
+ * leaving the wrap artefacts in place. */
904
+ let renderCols = PTY_COLS;
905
+ let renderRows = PTY_ROWS;
61
906
  // ─── Headless Terminal for Screen Capture ────────────────────────────────────
62
907
  let renderer = null;
63
908
  let screenUpdateTimer = null;
@@ -117,8 +962,10 @@ function stopScreenAnalyzer() {
117
962
  // ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
118
963
  const SCREENSHOT_INTERVAL_MS = 10_000;
119
964
  const POST_ACTION_DELAY_MS = 1_000;
120
- const SHOT_COLS = 160;
121
- const SHOT_ROWS = 50;
965
+ // PNG dimensions key off the renderer's actual size (renderCols / renderRows),
966
+ // which adopt-mode peg to the source pane so wrap artefacts don't appear.
967
+ // Re-clamping at MAX_RENDER_COLS/ROWS guards against a malformed init
968
+ // payload sneaking past the resolver into a runaway canvas.
122
969
  let displayMode = 'hidden';
123
970
  let screenshotTimer = null;
124
971
  let pendingShotTimer = null;
@@ -178,7 +1025,9 @@ async function captureAndUpload() {
178
1025
  lastShotHash = hash;
179
1026
  let png;
180
1027
  try {
181
- png = captureToPng(term, { cols: SHOT_COLS, rows: SHOT_ROWS, startY });
1028
+ const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
1029
+ const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
1030
+ png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
182
1031
  }
183
1032
  catch (err) {
184
1033
  log(`Screenshot render failed: ${err.message}`);
@@ -377,9 +1226,11 @@ let trustHandled = false;
377
1226
  // ─── Prompt Detection ────────────────────────────────────────────────────────
378
1227
  function onPtyData(data) {
379
1228
  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) {
1229
+ // In tmux-attach mode, each web client has its own tmux attach PTY
1230
+ // no relay needed. In non-tmux mode AND in pipe mode (adopt-bridge),
1231
+ // broadcast through the shared scrollback so all connected web clients
1232
+ // render the same byte stream.
1233
+ if (!isTmuxMode || isPipeMode) {
383
1234
  // Track alt-buffer state so we can restore it in the scrollback prefix.
384
1235
  // Scan for the *last* toggle in this chunk — that's the current state.
385
1236
  let lastToggleIdx = -1;
@@ -453,6 +1304,23 @@ function markPromptReady() {
453
1304
  }
454
1305
  flushPending();
455
1306
  }
1307
+ function persistCliSessionId(cliSessionId) {
1308
+ if (!cliSessionId || !sessionId)
1309
+ return;
1310
+ if (lastInitConfig)
1311
+ lastInitConfig.cliSessionId = cliSessionId;
1312
+ try {
1313
+ const session = sessionStore.getSession(sessionId);
1314
+ if (!session || session.cliSessionId === cliSessionId)
1315
+ return;
1316
+ session.cliSessionId = cliSessionId;
1317
+ sessionStore.updateSession(session);
1318
+ log(`Persisted CLI session id: ${cliSessionId}`);
1319
+ }
1320
+ catch (err) {
1321
+ log(`Failed to persist CLI session id: ${err.message}`);
1322
+ }
1323
+ }
456
1324
  /**
457
1325
  * Drain the pending message queue sequentially.
458
1326
  * Async with isFlushing mutex: awaits each writeInput, then immediately
@@ -467,8 +1335,15 @@ async function flushPending() {
467
1335
  if (pendingMessages.length === 0)
468
1336
  return; // nothing to flush — keep isPromptReady
469
1337
  // Type-ahead adapters flush even while the CLI is busy; others wait for idle.
470
- if (!isPromptReady && !cliAdapter.supportsTypeAhead)
1338
+ // Bridge fallback (non-adopt) disables type-ahead: queued submits land
1339
+ // in jsonl as `attachment(queued_command)` events, NOT `role:user` lines,
1340
+ // so BridgeTurnQueue.ingest never starts the pending turn for them and
1341
+ // the assistant text would be dropped on the floor. Serialise instead —
1342
+ // worker holds messages in pendingMessages until the CLI reaches idle.
1343
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !(bridgeJsonlPath && !lastInitConfig?.adoptMode);
1344
+ if (!isPromptReady && !typeAheadAllowed)
471
1345
  return;
1346
+ const bridgeFallbackActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
472
1347
  isFlushing = true;
473
1348
  if (isPromptReady) {
474
1349
  isPromptReady = false;
@@ -477,8 +1352,28 @@ async function flushPending() {
477
1352
  try {
478
1353
  while (pendingMessages.length > 0 && backend && cliAdapter) {
479
1354
  const msg = pendingMessages.shift();
1355
+ // Bridge fallback: mark immediately before writeInput. Doing it here
1356
+ // (instead of at enqueue time) means markTimeMs anchors to the
1357
+ // moment the message actually starts hitting the PTY — so any
1358
+ // `botmux send` whose sentAtMs lands during turn N's processing
1359
+ // falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
1360
+ // (at IPC arrival) would let a slow-finishing turn N's send leak
1361
+ // into turn N+1's window and falsely suppress its emit.
1362
+ if (bridgeFallbackActive) {
1363
+ try {
1364
+ bridgeIngest();
1365
+ }
1366
+ catch { /* best-effort */ }
1367
+ bridgeMarkPendingTurn(msg);
1368
+ }
480
1369
  log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
481
1370
  const result = await cliAdapter.writeInput(backend, msg);
1371
+ // Persist any sessionId the adapter observed via authoritative sources
1372
+ // (Claude's pid file, Codex's history). Done independently of submit
1373
+ // outcome — the rotation is real even when the current Enter didn't
1374
+ // land, and we want next-resume to use the right id.
1375
+ if (result?.cliSessionId)
1376
+ persistCliSessionId(result.cliSessionId);
482
1377
  if (result && result.submitted === false) {
483
1378
  const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
484
1379
  log(`writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
@@ -487,6 +1382,16 @@ async function flushPending() {
487
1382
  message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 3 次仍未在会话 JSONL 中看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
488
1383
  });
489
1384
  }
1385
+ // Bridge fallback: stop after one writeInput. Subsequent submits
1386
+ // would be type-ahead'd into Claude's queue, which jsonl records as
1387
+ // queued_command attachments (not role:user lines) — BridgeTurnQueue
1388
+ // can't attribute those, so the fallback would silently drop them.
1389
+ // We resume on the next idle, by which point Claude has finished
1390
+ // and the next message can be a normal role:user submit. Scoped to
1391
+ // bridgeFallbackActive so non-bridge CLIs (codex/gemini/...) keep
1392
+ // the original "one idle drains all pending" behaviour.
1393
+ if (bridgeFallbackActive && pendingMessages.length > 0)
1394
+ break;
490
1395
  }
491
1396
  }
492
1397
  finally {
@@ -497,11 +1402,22 @@ function sendToPty(content) {
497
1402
  if (!backend || !cliAdapter)
498
1403
  return;
499
1404
  pendingMessages.push(content);
1405
+ // User-override semantics: a fresh Lark message while a TUI prompt is "active"
1406
+ // takes precedence over the AI-detected prompt. The screen analyzer can be
1407
+ // wrong (false positive on a question that has no rendered options) and a
1408
+ // wedged blocking flag silently swallows every subsequent message — without
1409
+ // this override the user has no way to recover from Lark. Mirrors the
1410
+ // web-terminal text-input path (handleTuiTextInput).
500
1411
  if (tuiPromptBlocking) {
501
- log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — TUI prompt active`);
502
- return;
1412
+ log(`User override: incoming Lark message clears tuiPromptBlocking "${content.substring(0, 80)}"`);
1413
+ tuiPromptBlocking = false;
1414
+ screenAnalyzer?.notifySelection('lark-input');
1415
+ // Tear down the prompt card so the user doesn't see stale options.
1416
+ send({ type: 'tui_prompt_resolved', selectedText: 'user-override' });
503
1417
  }
504
- if (isPromptReady || isFlushing || cliAdapter.supportsTypeAhead) {
1418
+ // See flushPending: bridge fallback gates type-ahead off.
1419
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !(bridgeJsonlPath && !lastInitConfig?.adoptMode);
1420
+ if (isPromptReady || isFlushing || typeAheadAllowed) {
505
1421
  log(`Writing to PTY: "${content.substring(0, 80)}"`);
506
1422
  flushPending(); // fire-and-forget async; no-op if already flushing
507
1423
  }
@@ -511,7 +1427,12 @@ function sendToPty(content) {
511
1427
  }
512
1428
  // ─── Screen Update Timer ─────────────────────────────────────────────────────
513
1429
  function startScreenUpdates() {
514
- renderer = new TerminalRenderer(PTY_COLS, PTY_ROWS);
1430
+ // renderCols / renderRows were set by the init handler from cfg, so
1431
+ // adopt-mode panes (e.g. 270x57) get an xterm-headless of matching
1432
+ // width. With a too-narrow renderer, ANSI meant for the source pane
1433
+ // would wrap and the screenshot would show duplicated / stair-stepped
1434
+ // content (the live failure that prompted this fix).
1435
+ renderer = new TerminalRenderer(renderCols, renderRows);
515
1436
  let lastSentStatus;
516
1437
  screenUpdateTimer = setInterval(() => {
517
1438
  if (!renderer || awaitingFirstPrompt)
@@ -539,36 +1460,73 @@ function stopScreenUpdates() {
539
1460
  }
540
1461
  // ─── PTY Management ──────────────────────────────────────────────────────────
541
1462
  function spawnCli(cfg) {
542
- // ── Adopt mode: attach to an existing tmux pane (no CLI spawn) ──
1463
+ // ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
543
1464
  if (cfg.adoptMode && cfg.adoptTmuxTarget) {
1465
+ // We mark BOTH isTmuxMode and isPipeMode: the former keeps idle/spawn
1466
+ // logic on the tmux track; the latter tells the WS handler to route
1467
+ // updates through the shared scrollback fan-out (because there is no
1468
+ // PTY-per-WS — we don't attach to anything).
544
1469
  isTmuxMode = true;
1470
+ isPipeMode = true;
545
1471
  const cols = cfg.adoptPaneCols ?? PTY_COLS;
546
1472
  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, {
1473
+ const pipeBe = new TmuxPipeBackend(cfg.adoptTmuxTarget);
1474
+ backend = pipeBe;
1475
+ pipeBe.spawn('', [], {
550
1476
  cwd: cfg.workingDir,
551
1477
  cols,
552
1478
  rows,
553
1479
  env: process.env,
554
1480
  });
555
- // Minimal idle detection (output quiescence only)
556
- idleDetector = new IdleDetector({ completionPattern: undefined, readyPattern: undefined });
1481
+ // Seed the shared scrollback with the pane's current screen so any
1482
+ // already-connected (or future) WS clients render meaningful content
1483
+ // immediately, instead of waiting for the next byte tmux pipes through.
1484
+ try {
1485
+ const initial = pipeBe.captureCurrentScreen();
1486
+ if (initial.length > 0)
1487
+ onPtyData(initial);
1488
+ }
1489
+ catch (err) {
1490
+ log(`captureCurrentScreen failed: ${err.message}`);
1491
+ }
1492
+ // Bridge mode: tail Claude Code's transcript JSONL to harvest assistant
1493
+ // turns out-of-band. Only enabled when the daemon supplied a path
1494
+ // (claude-code adopt with a known sessionId).
1495
+ if (cfg.bridgeJsonlPath) {
1496
+ startBridgeWatcher(cfg.bridgeJsonlPath, {
1497
+ cliPid: cfg.adoptCliPid,
1498
+ cliCwd: cfg.adoptCwd,
1499
+ });
1500
+ }
1501
+ // Idle detection. In bridge mode we use Claude Code's real
1502
+ // completion/ready patterns (e.g. "Worked for Xs") so tool-execution
1503
+ // pauses don't trigger a premature emit. Other adopt cases keep the
1504
+ // minimal output-quiescence-only detector.
1505
+ const idleAdapter = cfg.bridgeJsonlPath
1506
+ ? createCliAdapterSync('claude-code', undefined)
1507
+ : { completionPattern: undefined, readyPattern: undefined };
1508
+ idleDetector = new IdleDetector(idleAdapter);
557
1509
  idleDetector.onIdle(() => {
558
1510
  log('Prompt detected (idle) — adopt mode');
1511
+ try {
1512
+ bridgeDrainAndMaybeEmit();
1513
+ }
1514
+ catch (err) {
1515
+ log(`Bridge emit error: ${err.message}`);
1516
+ }
559
1517
  markPromptReady();
560
1518
  });
561
1519
  backend.onData(onPtyData);
562
1520
  backend.onExit((code, signal) => {
563
- log(`Adopted session exited (code: ${code}, signal: ${signal})`);
1521
+ log(`Adopted pipe-pane stream ended (code: ${code}, signal: ${signal})`);
564
1522
  backend = null;
565
1523
  isPromptReady = false;
1524
+ stopBridgeWatcher();
566
1525
  send({ type: 'claude_exit', code, signal });
567
1526
  });
568
- // CLI is already running — unblock screen updates immediately
569
1527
  awaitingFirstPrompt = false;
570
1528
  renderer?.markNewTurn();
571
- log(`Adopt mode: attached to ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
1529
+ log(`Adopt mode (pipe): observing ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
572
1530
  return;
573
1531
  }
574
1532
  cliAdapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride);
@@ -588,6 +1546,7 @@ function spawnCli(cfg) {
588
1546
  const args = cliAdapter.buildArgs({
589
1547
  sessionId: cfg.sessionId,
590
1548
  resume: cfg.resume ?? false,
1549
+ resumeSessionId: cfg.cliSessionId,
591
1550
  initialPrompt: cfg.prompt || undefined,
592
1551
  botName: cfg.botName,
593
1552
  botOpenId: cfg.botOpenId,
@@ -632,16 +1591,52 @@ function spawnCli(cfg) {
632
1591
  log(`Failed to write CLI PID marker: ${err.message}`);
633
1592
  }
634
1593
  }
1594
+ // Wire pid + cwd so the claude-code adapter's writeInput can read
1595
+ // ~/.claude/sessions/<pid>.json — Claude's authoritative current sessionId.
1596
+ // The pinned claudeJsonlPath above is still used as the initial guess; the
1597
+ // resolver corrects it on first write when Claude has rotated under us.
1598
+ if (cfg.cliId === 'claude-code' && cliPid) {
1599
+ backend.cliPid = cliPid;
1600
+ backend.cliCwd = cfg.workingDir;
1601
+ }
635
1602
  // On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
636
1603
  // suppressed until the idle detector fires markNewTurn() — this prevents the
637
1604
  // full tmux scrollback history from leaking into the streaming card.
638
1605
  if (tmuxBe?.isReattach) {
639
1606
  log('Re-attached to existing tmux session');
640
1607
  }
1608
+ // Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
1609
+ // turn the model finishes WITHOUT calling `botmux send` still gets its
1610
+ // assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
1611
+ // the emit when a send did happen). Adopt mode wires this up separately
1612
+ // (with baseline-existing); here we use fresh-empty for new sessions so
1613
+ // the file Claude creates on first submit isn't absorbed as history,
1614
+ // and baseline-existing on resume so prior-run turns ARE absorbed (we
1615
+ // don't want to re-emit yesterday's conversation as fresh turns).
1616
+ if (cfg.cliId === 'claude-code' && cfg.sessionId) {
1617
+ const claudeJsonl = claudeJsonlPathForSession(cfg.sessionId, cfg.workingDir);
1618
+ startBridgeWatcher(claudeJsonl, {
1619
+ cliPid: cliPid ?? undefined,
1620
+ cliCwd: cfg.workingDir,
1621
+ mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
1622
+ });
1623
+ }
641
1624
  // Set up idle detection
642
1625
  idleDetector = new IdleDetector(cliAdapter);
643
1626
  idleDetector.onIdle(() => {
644
1627
  log('Prompt detected (idle)');
1628
+ // Bridge drain MUST run before markPromptReady() — the latter calls
1629
+ // flushPending() which can immediately fire the next queued message
1630
+ // (type-ahead adapters), shifting bridgeQueue's notion of "current
1631
+ // turn" before we've had a chance to emit the previous one.
1632
+ if (bridgeJsonlPath) {
1633
+ try {
1634
+ bridgeDrainAndMaybeEmit();
1635
+ }
1636
+ catch (err) {
1637
+ log(`Bridge emit error: ${err.message}`);
1638
+ }
1639
+ }
645
1640
  markPromptReady();
646
1641
  });
647
1642
  backend.onData(onPtyData);
@@ -671,6 +1666,10 @@ function killCli() {
671
1666
  stopScreenUpdates();
672
1667
  backend?.kill();
673
1668
  backend = null;
1669
+ // Tear down the bridge watcher (if any). spawnCli will rebuild it on
1670
+ // restart with the proper mode based on the new cfg. Leaving it running
1671
+ // would dangle a watcher pinned to a stale jsonl path.
1672
+ stopBridgeWatcher();
674
1673
  // Clean up CLI PID marker
675
1674
  if (cliPidMarker) {
676
1675
  try {
@@ -703,8 +1702,8 @@ function startWebServer(host, preferredPort) {
703
1702
  if (hasWrite)
704
1703
  authedClients.add(ws);
705
1704
  log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
706
- if (isTmuxMode && sessionId) {
707
- // ── Tmux mode: per-client attach ──
1705
+ if (isTmuxMode && !isPipeMode && sessionId) {
1706
+ // ── Tmux-attach mode: per-client attach ──
708
1707
  // Each WS client gets its own `tmux attach-session` PTY.
709
1708
  // Scrollback is handled natively by tmux (history-limit).
710
1709
  // In adopt mode, attach to the user's original pane; otherwise use bmx-* session.
@@ -864,11 +1863,11 @@ body{display:flex;flex-direction:column}
864
1863
  color:#565f89;background:#1a1b26cc;padding:2px 8px;border-radius:4px}
865
1864
  #status.ok{color:#9ece6a}
866
1865
  #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}
1866
+ #readonly-banner{display:none;position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:50;
1867
+ padding:4px 10px;font:12px monospace;color:#f7768e;white-space:nowrap;cursor:pointer;
1868
+ background:rgba(247,118,142,0.12);border:1px solid rgba(247,118,142,0.35);border-radius:4px;
1869
+ backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
1870
+ #readonly-banner.show{display:inline-block}
872
1871
  </style>
873
1872
  </head>
874
1873
  <body>
@@ -894,7 +1893,7 @@ body{display:flex;flex-direction:column}
894
1893
  var isTouch='ontouchstart'in window||navigator.maxTouchPoints>0;
895
1894
  if(isTouch)document.getElementById('vp').content='width=1100,viewport-fit=cover';
896
1895
  var hasToken=${hasWrite};
897
- if(!hasToken)document.getElementById('readonly-banner').classList.add('show');
1896
+ if(!hasToken){var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
898
1897
 
899
1898
  var term=new Terminal({
900
1899
  theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
@@ -971,7 +1970,7 @@ window.addEventListener('resize',function(){fit.fit();sendResize()});
971
1970
  })();
972
1971
 
973
1972
  // ── Read-only scroll handling ──
974
- if(!hasToken&&!${isTmuxMode}){
1973
+ if(!hasToken&&!${isTmuxMode && !isPipeMode}){
975
1974
  // Non-tmux read-only: CLI mouse mode blocks local scroll, override with scrollLines
976
1975
  document.getElementById('terminal').addEventListener('wheel',function(e){
977
1976
  e.preventDefault();term.scrollLines(e.deltaY>0?3:-3);
@@ -981,7 +1980,7 @@ if(!hasToken&&!${isTmuxMode}){
981
1980
  // ── Scroll helper (shared by toolbar buttons & two-finger touch) ──
982
1981
  function _sendScroll(up,n){
983
1982
  n=n||3;
984
- if(${isTmuxMode}){
1983
+ if(${isTmuxMode && !isPipeMode}){
985
1984
  // SGR mouse wheel: 64=up 65=down — tmux enters copy-mode and scrolls
986
1985
  var seq='\\x1b[<'+(up?64:65)+';1;1M';
987
1986
  for(var i=0;i<n;i++){if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'input',data:seq}))}
@@ -1070,7 +2069,15 @@ process.on('message', async (raw) => {
1070
2069
  // Capture credentials for direct image upload from worker
1071
2070
  larkAppIdForUpload = msg.larkAppId;
1072
2071
  larkAppSecretForUpload = msg.larkAppSecret;
1073
- log(`Init: session=${sessionId}, cwd=${msg.workingDir}`);
2072
+ // Resolve render dimensions BEFORE startScreenUpdates() the
2073
+ // headless xterm and PNG canvas need to know the source pane size
2074
+ // up-front. Setting them later (after the renderer was built at
2075
+ // 160x50) wouldn't unwrap content xterm has already buffered, so
2076
+ // adopt-mode wide-pane content would still come out stair-stepped.
2077
+ const dims = resolveRenderDimensions(msg);
2078
+ renderCols = dims.cols;
2079
+ renderRows = dims.rows;
2080
+ log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
1074
2081
  try {
1075
2082
  const port = await startWebServer('0.0.0.0', msg.webPort);
1076
2083
  startScreenUpdates();
@@ -1079,6 +2086,8 @@ process.on('message', async (raw) => {
1079
2086
  // Queue the initial prompt — flushed when CLI shows idle.
1080
2087
  // Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
1081
2088
  // prompt into CLI args, so we skip queuing to avoid double-send.
2089
+ // Bridge mark is deferred to flushPending — see flushPending
2090
+ // comment for why marking at enqueue is wrong.
1082
2091
  if (msg.prompt && !cliAdapter?.passesInitialPromptViaArgs) {
1083
2092
  pendingMessages.push(msg.prompt);
1084
2093
  }
@@ -1098,6 +2107,18 @@ process.on('message', async (raw) => {
1098
2107
  exitTmuxScrollMode();
1099
2108
  const content = msg.content;
1100
2109
  if (lastInitConfig?.adoptMode) {
2110
+ // Bridge mode: capture transcript baseline BEFORE writing to the pane,
2111
+ // so any assistant uuids appended after this point are attributed to
2112
+ // *this* Lark turn (not local user activity in the pane). Mark may
2113
+ // return false (baseline not ready) — we still write to the pane;
2114
+ // user just won't get a final_output for this message.
2115
+ if (bridgeJsonlPath) {
2116
+ try {
2117
+ bridgeIngest();
2118
+ }
2119
+ catch { /* best effort */ }
2120
+ bridgeMarkPendingTurn(content);
2121
+ }
1101
2122
  // Adopt mode: raw write to PTY (no adapter writeInput)
1102
2123
  if (backend) {
1103
2124
  if ('sendText' in backend && 'sendSpecialKeys' in backend) {
@@ -1112,6 +2133,11 @@ process.on('message', async (raw) => {
1112
2133
  }
1113
2134
  }
1114
2135
  else {
2136
+ // Non-adopt: enqueue only. Bridge mark is deferred to flushPending
2137
+ // so markTimeMs anchors to the actual PTY-write moment, not IPC
2138
+ // arrival. Marking now would race with a still-running previous
2139
+ // turn whose `botmux send` could sneak its sentAtMs past this
2140
+ // turn's markTimeMs and falsely suppress its fallback.
1115
2141
  sendToPty(content);
1116
2142
  }
1117
2143
  break;
@@ -1192,6 +2218,11 @@ process.on('message', async (raw) => {
1192
2218
  // destroySession kills tmux session permanently; kill() only detaches
1193
2219
  backend?.destroySession?.();
1194
2220
  killCli();
2221
+ // Bridge marker file outlives a single CLI process (we keep it across
2222
+ // restarts so a mid-flight send is still credited), but a real close
2223
+ // tears down the session — purge the file so a future re-use of the
2224
+ // same sessionId starts clean.
2225
+ clearSendMarkers();
1195
2226
  cleanup();
1196
2227
  process.exit(0);
1197
2228
  }