botmux 2.12.0 → 2.12.2

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 (38) hide show
  1. package/dist/adapters/cli/claude-code.d.ts +9 -3
  2. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/cli/claude-code.js +20 -12
  4. package/dist/adapters/cli/claude-code.js.map +1 -1
  5. package/dist/core/session-discovery.d.ts.map +1 -1
  6. package/dist/core/session-discovery.js +11 -1
  7. package/dist/core/session-discovery.js.map +1 -1
  8. package/dist/core/worker-pool.d.ts.map +1 -1
  9. package/dist/core/worker-pool.js +13 -9
  10. package/dist/core/worker-pool.js.map +1 -1
  11. package/dist/daemon.js +10 -9
  12. package/dist/daemon.js.map +1 -1
  13. package/dist/services/bridge-rotation-policy.d.ts +139 -0
  14. package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
  15. package/dist/services/bridge-rotation-policy.js +125 -0
  16. package/dist/services/bridge-rotation-policy.js.map +1 -0
  17. package/dist/services/bridge-turn-queue.d.ts +9 -1
  18. package/dist/services/bridge-turn-queue.d.ts.map +1 -1
  19. package/dist/services/bridge-turn-queue.js +9 -2
  20. package/dist/services/bridge-turn-queue.js.map +1 -1
  21. package/dist/services/claude-transcript.d.ts +67 -0
  22. package/dist/services/claude-transcript.d.ts.map +1 -1
  23. package/dist/services/claude-transcript.js +228 -1
  24. package/dist/services/claude-transcript.js.map +1 -1
  25. package/dist/services/codex-bridge-queue.d.ts +56 -0
  26. package/dist/services/codex-bridge-queue.d.ts.map +1 -0
  27. package/dist/services/codex-bridge-queue.js +150 -0
  28. package/dist/services/codex-bridge-queue.js.map +1 -0
  29. package/dist/services/codex-transcript.d.ts +68 -0
  30. package/dist/services/codex-transcript.d.ts.map +1 -0
  31. package/dist/services/codex-transcript.js +233 -0
  32. package/dist/services/codex-transcript.js.map +1 -0
  33. package/dist/utils/idle-detector.d.ts.map +1 -1
  34. package/dist/utils/idle-detector.js +15 -4
  35. package/dist/utils/idle-detector.js.map +1 -1
  36. package/dist/worker.js +743 -114
  37. package/dist/worker.js.map +1 -1
  38. package/package.json +2 -2
package/dist/worker.js CHANGED
@@ -15,9 +15,12 @@
15
15
  import { randomBytes } from 'node:crypto';
16
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';
18
+ import { drainTranscript, joinAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
19
+ import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
20
20
  import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
21
+ import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
22
+ import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
23
+ import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff } from './services/codex-transcript.js';
21
24
  import { dirname } from 'node:path';
22
25
  import { createServer as createHttpServer } from 'node:http';
23
26
  import { WebSocketServer, WebSocket } from 'ws';
@@ -78,15 +81,37 @@ let bridgeJsonlPath;
78
81
  * original path would silently stop receiving events. */
79
82
  let bridgeJsonlDir;
80
83
  /** 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. */
84
+ * ~/.claude/sessions/<pid>.json — Claude's own pid-state record. Empirical
85
+ * scope (Claude Code 2.1.123): the pid file's `sessionId` is set ONCE at
86
+ * process start. `--resume` (which spawns a new process) does rotate the
87
+ * recorded sessionId; `/clear` / in-pane `/resume` do NOT — those rely on
88
+ * the fingerprint fallback (which anchors on a pending Lark turn) to
89
+ * follow the new jsonl. */
85
90
  let bridgeCliPid;
86
91
  let bridgeCliCwd;
87
92
  /** Last sessionId we observed via the pid resolver — used to detect
88
93
  * rotations cheaply (string compare instead of stat()ing every jsonl). */
89
94
  let bridgeObservedCliSessionId;
95
+ /** Sibling-pane hijack guard state.
96
+ *
97
+ * Every sessionId we have evidence of belonging to our adopted Claude pid:
98
+ * initial attach path, pid resolver hits, `/proc/<pid>/fd` hits. The
99
+ * fingerprint fallback's two-phase decision (`decideFingerprintSwitch`
100
+ * in `src/services/bridge-rotation-policy.ts`) consumes this set:
101
+ * Phase 1 substring match runs against trusted sids only; Phase 2
102
+ * exact-content recovery runs against UNTRUSTED sids only. Unknown
103
+ * sessionIds never pass Phase 1 even when the file looks freshly
104
+ * created — freshness/timestamp signals cannot prove pane ownership
105
+ * across siblings in the same project dir. */
106
+ const bridgeKnownSessionIds = new Set();
107
+ /** Set when the fingerprint fallback accepts a candidate whose sessionId
108
+ * doesn't match the pid file's current sessionId (Claude's pid file isn't
109
+ * refreshed by in-pane `/clear`, so it keeps reporting the spawn-time sid
110
+ * even after the user rotated). Suppresses pid resolver from pulling the
111
+ * watcher back to that spawn-time sid every tick. Cleared when pid file
112
+ * reports a NEW sid (fresh `--resume` / spawn), at which point a real
113
+ * rotation has happened and we should follow it. */
114
+ let bridgeStalePidStateSessionId;
90
115
  /** Old jsonl paths we keep polling AFTER a rotation switched
91
116
  * bridgeJsonlPath away — needed when a started turn was stamped with the
92
117
  * old path but its assistant text hasn't been written yet. We continue to
@@ -108,6 +133,35 @@ let bridgeBaselineDone = false;
108
133
  /** Once-per-attach flag so a re-baseline after fs.watch lazy-fire doesn't
109
134
  * re-send the preamble. Reset only when the bridge teardown happens. */
110
135
  let bridgePreambleSent = false;
136
+ // ─── Codex bridge state ──────────────────────────────────────────────────
137
+ //
138
+ // Parallel to the Claude bridge above. Codex's transcript layout is
139
+ // different enough (separate file location, different event schema) that
140
+ // trying to share storage / readers would obscure both — so we keep state
141
+ // independent. Marker file (`<DATA_DIR>/turn-sends/<sid>.jsonl`) and the
142
+ // gate function are CLI-agnostic and shared.
143
+ let codexBridgeRolloutPath;
144
+ let codexBridgeOffset = 0;
145
+ let codexBridgePendingTail = '';
146
+ let codexBridgeBaselineDone = false;
147
+ const codexBridgeQueue = new CodexBridgeQueue();
148
+ let codexBridgeWatcher = null;
149
+ let codexBridgeTimer = null;
150
+ /** Codex sessionId we received via writeInput but haven't yet resolved a
151
+ * rollout file for. The poller keeps retrying — the file appears on
152
+ * Codex's first user submit, but with some race delay after our submit
153
+ * returns. Cleared once attached. */
154
+ let codexBridgePendingSessionId;
155
+ /** Adopt-only: PID of the externally-running Codex process. Used by the
156
+ * poller to fall back to /proc/<pid>/fd discovery when sessionId is
157
+ * unknown (e.g. discovery probe missed the rollout fd). */
158
+ let codexAdoptPendingPid;
159
+ /** Adopt-only: wall-clock millis at adopt-spawn time. Late-attach uses
160
+ * this as the cutoff for splitting an existing rollout into "history"
161
+ * (absorb) vs "live" (ingest) — so events the user produced AFTER adopt
162
+ * but BEFORE the rollout was located still reach the Lark thread. 5s
163
+ * skew tolerance is applied on top, mirroring the Lark/Claude bridges. */
164
+ let codexAdoptStartMs;
111
165
  /** Cap the preamble text so an extremely long previous turn doesn't blow
112
166
  * past Lark's per-message limit. The user only needs enough to recall
113
167
  * context, not the entire transcript. */
@@ -211,6 +265,32 @@ function maybeEmitAdoptPreamble(events) {
211
265
  });
212
266
  log('Bridge adopt preamble emitted (last completed turn from baseline)');
213
267
  }
268
+ /** Extract the sessionId from a Claude jsonl path and add it to the
269
+ * known-sid set. Validates the filename against Claude's UUID-shaped
270
+ * sessionId pattern so non-Claude jsonls in the project dir (accidental
271
+ * drops, third-party tooling) can't poison the trust set. No-op on
272
+ * parse failure. */
273
+ function bridgeRememberSessionIdForPath(path) {
274
+ if (!path)
275
+ return;
276
+ const sid = sessionIdFromJsonlPath(path);
277
+ if (!SESSION_ID_FILENAME_RE.test(sid))
278
+ return;
279
+ bridgeKnownSessionIds.add(sid);
280
+ }
281
+ /** Cheap per-tick probe: read /proc/<bridgeCliPid>/fd and add every jsonl
282
+ * the adopted Claude pid currently has open into the known-sid set. fd
283
+ * observation is intermittent (Claude opens-writes-closes per event), so
284
+ * running this every tick raises our chances of catching a post-/clear
285
+ * sessionId before the user's next Lark message arrives. No-op when there
286
+ * is no pid or /proc isn't available. */
287
+ function bridgeProbeOpenSessionIds() {
288
+ if (bridgeCliPid === undefined || !bridgeJsonlDir)
289
+ return;
290
+ const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
291
+ for (const path of opened)
292
+ bridgeRememberSessionIdForPath(path);
293
+ }
214
294
  function bridgeAbsorbBaseline() {
215
295
  if (!bridgeJsonlPath)
216
296
  return;
@@ -227,41 +307,30 @@ function bridgeAbsorbBaseline() {
227
307
  if (lastInitConfig?.adoptMode)
228
308
  maybeEmitAdoptPreamble(result.events);
229
309
  }
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;
310
+ /** Record `bridgeStalePidStateSessionId` if the pid file's current sid
311
+ * disagrees with the just-accepted candidate's sid. Stops the next pid
312
+ * resolver tick from pulling the watcher back to the stale spawn-time
313
+ * path Claude wrote into the pid file which it never refreshes on
314
+ * in-pane `/clear`. No-op when pid file is unavailable or already
315
+ * agrees. */
316
+ function bridgeMarkStalePidStateForAcceptedSid(acceptedSid) {
317
+ if (bridgeCliPid === undefined || bridgeCliCwd === undefined)
318
+ return;
319
+ const pidResolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
320
+ if (pidResolved && pidResolved.cliSessionId !== acceptedSid) {
321
+ bridgeStalePidStateSessionId = pidResolved.cliSessionId;
322
+ }
323
+ }
324
+ /** Apply a fingerprint-driven switch: drain old path, retire watcher,
325
+ * pivot bridgeJsonlPath to `matched`, split the new path's existing
326
+ * content by `cutoffMs` (history → absorbed into the seen set, live →
327
+ * ingested), and install a new fs.watch. The split-live step is what
328
+ * prevents the "switched into a long-lived /clear file → all prior
329
+ * iTerm-typed turns get re-emitted as 🖥️ 终端本地对话" symptom: any
330
+ * user/assistant events written before the Lark mark are pre-existing
331
+ * pane history, not events to forward. `cutoffMs` should be the same
332
+ * `markTimeMs - 5s` used for the fingerprint scan's lower bound. */
333
+ function bridgeApplyFingerprintSwitch(matched, reason, cutoffMs) {
265
334
  // Drain-before-switch: pull in any unread bytes from the old path so a
266
335
  // late assistant append doesn't vanish. We do NOT emit here — emission
267
336
  // only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
@@ -281,7 +350,7 @@ function maybeSwitchBridgeJsonl() {
281
350
  }
282
351
  retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
283
352
  }
284
- log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (Lark fingerprint observed in new jsonl — user likely ran /clear or /resume)`);
353
+ log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (${reason})`);
285
354
  if (bridgeWatcher) {
286
355
  try {
287
356
  bridgeWatcher.close();
@@ -290,18 +359,30 @@ function maybeSwitchBridgeJsonl() {
290
359
  bridgeWatcher = null;
291
360
  }
292
361
  // 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.
362
+ // the FIRST pending turn already living in `matched`, so the immediate
363
+ // next ingest from offset 0 will find that user event and start the
364
+ // turn. Clearing here would race-drop exactly the message we're
365
+ // trying to deliver.
297
366
  bridgeJsonlPath = matched;
298
- bridgeOffset = 0;
367
+ bridgeJsonlDir = dirname(matched);
299
368
  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.
369
+ // Split-live: drain `matched` from offset 0, partition by cutoffMs.
370
+ // History (pre-mark) is absorbed into the seen set so the iTerm-side
371
+ // turns the user accumulated before this Lark message DON'T re-emit
372
+ // as "🖥️ 终端本地对话" cards. Live (post-mark) goes through ingest
373
+ // so the Lark fingerprint can start its turn. Mirrors what
374
+ // performRotationSwitch already does for fd-rotation rotations.
375
+ const drained = drainTranscript(matched, 0);
376
+ bridgeOffset = drained.newOffset;
377
+ bridgePendingTail = drained.pendingTail;
378
+ const { history, live } = splitTranscriptEventsByCutoff(drained.events, cutoffMs);
379
+ bridgeQueue.absorb(history);
380
+ if (live.length > 0)
381
+ bridgeQueue.ingest(live, matched);
304
382
  bridgeBaselineDone = true;
383
+ log(`Bridge fingerprint switch split: ${history.length} historical events absorbed, ${live.length} live events ingested (cutoff=${cutoffMs})`);
384
+ bridgeRememberSessionIdForPath(matched);
385
+ bridgeMarkStalePidStateForAcceptedSid(sessionIdFromJsonlPath(matched));
305
386
  try {
306
387
  bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
307
388
  try {
@@ -315,31 +396,122 @@ function maybeSwitchBridgeJsonl() {
315
396
  catch (err) {
316
397
  log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
317
398
  }
318
- return true;
319
399
  }
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.
400
+ /** Detect /clear / /resume: when Claude Code starts a new session in the
401
+ * user's pane it writes to a brand-new sessionId.jsonl. Two-phase scan:
402
+ *
403
+ * - Phase 1 (known-sid substring): cheap path for trusted candidates
404
+ * only. Same content fingerprint substring search as before — safe
405
+ * here because we've gated it on the pid-derived trust set, so a
406
+ * sibling pane in the same project dir (different sessionId) can
407
+ * never be the match even when its content includes the fingerprint.
408
+ *
409
+ * - Phase 2 (unknown-sid exact-content recovery): in-pane `/clear`
410
+ * creates a new sessionId Claude does NOT write into its pid file.
411
+ * If the fd probe didn't catch the brief open window, the new sid is
412
+ * untrusted and Phase 1 rejects it. Phase 2 falls back to scanning
413
+ * every UNTRUSTED candidate jsonl for a user/queue event whose
414
+ * NORMALISED content equals our just-marked Lark message in full
415
+ * (not a substring) — strong enough that "test" doesn't false-match
416
+ * "run tests". When exactly one untrusted candidate matches, accept
417
+ * it; when multiple match, abstain and surface an unambiguous log
418
+ * line so the user can take recovery action.
419
+ *
420
+ * Pending turns are preserved across the switch so the next ingest
421
+ * can match and start the turn in the new file. */
422
+ function maybeSwitchBridgeJsonl() {
423
+ if (!bridgeJsonlDir)
424
+ return false;
425
+ const pending = bridgeQueue.peek();
426
+ const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
427
+ if (!candidate || !candidate.contentFingerprint)
428
+ return false;
429
+ // Bound the search to events written after the turn was marked. Short
430
+ // fingerprints ("hello", "test") would otherwise match old user lines
431
+ // in unrelated sibling jsonls. 5s skew absorbs clock drift between the
432
+ // mark and Claude's transcript write.
433
+ const minEventTimestampMs = candidate.markTimeMs !== undefined
434
+ ? candidate.markTimeMs - 5_000
435
+ : undefined;
436
+ const fingerprintScanOptions = {
437
+ excludePath: bridgeJsonlPath,
438
+ includeQueueOperations: true,
439
+ minEventTimestampMs,
440
+ };
441
+ const decision = decideFingerprintSwitch({
442
+ contentFingerprint: candidate.contentFingerprint,
443
+ contentNormalized: candidate.contentNormalized,
444
+ knownSessionIds: bridgeKnownSessionIds,
445
+ findSubstring: (acceptCandidate) => findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
446
+ ...fingerprintScanOptions,
447
+ acceptCandidate,
448
+ }),
449
+ findExact: (acceptCandidate) => candidate.contentNormalized
450
+ ? findJsonlsContainingExactContent(bridgeJsonlDir, candidate.contentNormalized, {
451
+ ...fingerprintScanOptions,
452
+ acceptCandidate,
453
+ })
454
+ : [],
455
+ });
456
+ if (decision.action === 'switch') {
457
+ const reason = decision.reason === 'known-sid-substring'
458
+ ? 'known-sid fingerprint match'
459
+ : 'unknown-sid exact-content recovery (in-pane /clear with stale pid file)';
460
+ // Boundary alignment with the fingerprint scanner:
461
+ //
462
+ // scanner.minEventTimestampMs is INCLUSIVE — events with
463
+ // timestamp >= (markTimeMs - 5s) are eligible to start the turn.
464
+ // splitTranscriptEventsByCutoff puts timestamp <= cutoffMs in
465
+ // history (absorbed) and > cutoffMs in live (ingested).
466
+ //
467
+ // If we hand split the same value as the scanner's lower bound, an
468
+ // event AT exactly that timestamp (e.g. the user's just-arrived
469
+ // Lark user event) is matched-eligible by the scanner — driving
470
+ // the switch — but absorbed as history by split, leaving the
471
+ // pending turn unstarted and the message silent. Subtract 1ms to
472
+ // make split's history strictly older than the scanner's
473
+ // eligibility floor.
474
+ const historyCutoffMs = ((candidate.markTimeMs ?? Date.now()) - 5_000) - 1;
475
+ bridgeApplyFingerprintSwitch(decision.path, reason, historyCutoffMs);
476
+ return true;
477
+ }
478
+ if (decision.action === 'abstain') {
479
+ log(`Bridge fingerprint switch ABSTAINED (${decision.reason}): ${decision.candidates.length} unknown jsonls have an exact-content match for the pending Lark turn (${decision.candidates.join(', ')}). User should re-/adopt or send a longer disambiguating message.`);
480
+ return false;
481
+ }
482
+ return false;
483
+ }
484
+ /** Last-resort rotation follower for the case where pid resolver returned
485
+ * `'unavailable'` (no /proc, missing/invalid pid file). Originally also
486
+ * ran on `'same'` to catch in-pane `/clear` with no pending Lark turn,
487
+ * but that path is now intentionally dropped — the directory-mtime
488
+ * heuristic in Path 2 below cannot tell our pane's rotation from a
489
+ * sibling Claude pane in the same cwd, and the sibling-pane hijack
490
+ * silently corrupts every multi-pane adopt setup (see
491
+ * `bridge-rotation-policy.ts`). The Lark-message-driven /clear recovery
492
+ * flow (fingerprint fallback) covers the dominant case.
325
493
  *
326
494
  * Detection priority:
327
495
  * 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.
496
+ * adopted Claude process actually has open. Bound to the real PID
497
+ * — a sibling Claude pane has a different PID and cannot hijack
498
+ * the result. Note: Claude Code opens-writes-closes per event, so
499
+ * this often returns 0 entries between writes; the gate above
500
+ * ensures we still skip Path 2 in that case when pid resolver
501
+ * confirmed our path.
502
+ * 2. Cross-platform fallback: directory-level mtime heuristic, gated
503
+ * on (a) our current jsonl quiet ≥ QUIET_ROTATION_MS, (b) candidate
504
+ * newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Only
505
+ * runs when Path 1 returns 0 entries AND pid resolver was
506
+ * unavailable.
335
507
  *
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*
508
+ * When a rotation is detected, the new jsonl is drained from offset 0
509
+ * and events are split by timestamp against `rotationCutoffMs` (the
510
+ * old jsonl's last-write time): events before the cutoff are *history*
339
511
  * (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.
512
+ * (ingested → local-turn synthesis runs). This is what lets a rotation
513
+ * to a long-history jsonl NOT replay the entire past as one giant
514
+ * local turn.
343
515
  *
344
516
  * Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
345
517
  * also fires `maybeEmitAdoptPreamble`, which on rotation would surface
@@ -451,17 +623,7 @@ function performRotationSwitch(newPath, cutoffMs, reason) {
451
623
  const result = drainTranscript(newPath, 0);
452
624
  bridgeOffset = result.newOffset;
453
625
  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
- }
626
+ const { history, live } = splitTranscriptEventsByCutoff(result.events, cutoffMs);
465
627
  bridgeQueue.absorb(history);
466
628
  if (live.length > 0)
467
629
  bridgeQueue.ingest(live, newPath);
@@ -500,6 +662,15 @@ function maybeFollowQuietRotation() {
500
662
  // bridgeJsonlPath ⇒ rotation.
501
663
  const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
502
664
  if (opened.length > 0) {
665
+ // Every fd-observed jsonl belongs to our pid — feed all of them
666
+ // into the sibling-pane hijack guard's trust list, not just the
667
+ // newest. This is how a post-/clear sessionId enters the trust
668
+ // set: Claude opens the new jsonl briefly during the /clear
669
+ // handshake; if a fd probe lands in that window, fingerprint
670
+ // fallback can later accept the new sessionId on the user's next
671
+ // Lark message.
672
+ for (const path of opened)
673
+ bridgeRememberSessionIdForPath(path);
503
674
  const newest = newestPath(opened);
504
675
  if (newest && newest !== bridgeJsonlPath) {
505
676
  performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
@@ -525,6 +696,43 @@ function maybeFollowQuietRotation() {
525
696
  return;
526
697
  performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
527
698
  }
699
+ /** Pid-state rotation follow: re-read ~/.claude/sessions/<cliPid>.json
700
+ * and switch bridgeJsonlPath whenever the recorded sessionId differs
701
+ * from what we're watching. Same source as the writeInput pid resolver,
702
+ * with the same cwd + procStart validation.
703
+ *
704
+ * Empirical scope (Claude Code 2.1.123): the pid file's `sessionId` is
705
+ * written ONCE at process start. `--resume` rewrites it (it's a fresh
706
+ * spawn → fresh pid file). In-pane `/clear` does NOT rewrite it —
707
+ * `updatedAt` and `status` change but `sessionId` stays. So this probe
708
+ * catches spawn-time / `--resume` rotations; `/clear` (and in-pane
709
+ * `/resume` if Claude treats it the same) is left to the fingerprint
710
+ * fallback that anchors on a pending Lark turn. Returns a tri-state
711
+ * result rather than a bool so the caller can distinguish 'switched'
712
+ * (we moved) from 'same' (path confirmed) from 'unavailable' (no
713
+ * reliable answer) — the downstream gates use that distinction. */
714
+ /** Tri-state result so callers can distinguish "pid file unreadable, fall
715
+ * back to fingerprint heuristic" from "pid file confirmed current path"
716
+ * vs "pid file said rotate to a new path".
717
+ *
718
+ * Used by two downstream gates:
719
+ * - Fingerprint fallback (`maybeSwitchBridgeJsonl`): runs whenever the
720
+ * pid resolver did not actively switch (`!= 'switched'`). Safe even
721
+ * on `'same'` because the fingerprint scan requires a pending Lark
722
+ * turn — no risk of hijacking to a sibling pane.
723
+ * - Quiet-mtime fallback (`maybeFollowQuietRotation`): runs only on
724
+ * `'unavailable'`. The mtime heuristic can't distinguish our pane's
725
+ * rotation from a sibling pane in the same cwd, so even when pid
726
+ * resolver's `'same'` is not proof against in-process /clear (it
727
+ * isn't — Claude doesn't refresh `sessionId` on /clear), we still
728
+ * skip the heuristic. The cost is that a pure-local /clear with no
729
+ * pending Lark turn won't auto-follow until the user sends a Lark
730
+ * message; the alternative (running mtime fallback on 'same') would
731
+ * silently corrupt every multi-pane adopt setup.
732
+ *
733
+ * Type imported from `./services/bridge-rotation-policy` — the gate
734
+ * function lives there so it's testable without dragging worker fs/IPC
735
+ * side-effects into the unit suite. */
528
736
  function maybeFollowSessionRotationViaPid() {
529
737
  if (!bridgeCliPid || !bridgeCliCwd)
530
738
  return 'unavailable';
@@ -534,8 +742,29 @@ function maybeFollowSessionRotationViaPid() {
534
742
  if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
535
743
  bridgeObservedCliSessionId = resolved.cliSessionId;
536
744
  }
745
+ // Pid resolver always reports the spawn-time sessionId — this is a sid
746
+ // that genuinely belongs to our adopted Claude pid, so remember it for
747
+ // the sibling-pane hijack guard.
748
+ bridgeRememberSessionIdForPath(resolved.path);
537
749
  if (resolved.path === bridgeJsonlPath)
538
750
  return 'same';
751
+ // Stale-pid suppression: when the fingerprint fallback accepted a
752
+ // post-/clear jsonl (Claude's pid file isn't refreshed by in-pane
753
+ // /clear, so it keeps reporting the spawn-time sid), pid resolver
754
+ // would otherwise pull the watcher back to that spawn-time sid every
755
+ // tick — re-creating the flap loop the user reported. The decision
756
+ // lives in `bridge-rotation-policy.evaluatePidResolverPullback` so
757
+ // the four-cell matrix can be unit-tested in isolation.
758
+ const pullback = evaluatePidResolverPullback({
759
+ resolvedCliSessionId: resolved.cliSessionId,
760
+ resolvedPath: resolved.path,
761
+ currentBridgeJsonlPath: bridgeJsonlPath,
762
+ stalePidStateSessionId: bridgeStalePidStateSessionId,
763
+ });
764
+ if (pullback.clearStale)
765
+ bridgeStalePidStateSessionId = undefined;
766
+ if (pullback.suppress)
767
+ return 'same';
539
768
  // Drain-before-switch: pull in any unread bytes from the OLD path so a
540
769
  // trailing assistant append doesn't vanish. We do NOT emit here — emit
541
770
  // is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
@@ -594,28 +823,56 @@ function bridgeIngest() {
594
823
  // the path. Strictly read-only on the polling rotation; never triggers
595
824
  // a rotate or shifts the primary path.
596
825
  drainSecondaryPaths();
826
+ // Cheap probe: catch any jsonls our adopted pid currently has open
827
+ // and add their sessionIds to the sibling-pane hijack guard's trust
828
+ // list. Runs every tick (independent of rotation gates) because
829
+ // Claude opens-writes-closes the jsonl per event — fd observation
830
+ // is therefore intermittent, and more ticks = more chances to
831
+ // catch a post-/clear sessionId. This is the only hook by which
832
+ // an in-pane /clear becomes followable: without an fd-probe hit
833
+ // the fingerprint fallback will reject the new (unknown) sessionId
834
+ // and the user must re-adopt to recover.
835
+ bridgeProbeOpenSessionIds();
597
836
  // Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
598
837
  // pid file → new sessionId), e.g. daemon restart that re-issues
599
838
  // `--resume <id>` and Claude rotates the internal id.
600
839
  const pidFollow = maybeFollowSessionRotationViaPid();
601
840
  // 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.
841
+ // via /clear or /resume from the user's pane. Empirically (verified
842
+ // on Claude Code 2.1.123) the pid file's `sessionId` field is set
843
+ // ONCE at process start; /clear refreshes `updatedAt` but does NOT
844
+ // rewrite `sessionId`, so pid resolver returning 'same' is NOT proof
845
+ // that no rotation happened. We skip the fingerprint scan only when
846
+ // pid resolver actively switched the path in that case the
847
+ // authoritative source already moved us, and running fingerprint on
848
+ // top would risk a redundant flip. Sibling-pane hijack protection is
849
+ // NOT delegated to the markTimeMs-5s event filter (short fingerprints
850
+ // substring-match unrelated content like "test" → "run tests"); the
851
+ // real gate is the sibling guard inside `maybeSwitchBridgeJsonl` that
852
+ // rejects every candidate whose sessionId isn't in the pid-derived
853
+ // trust set.
609
854
  let switched = pidFollow === 'switched';
610
855
  if (!switched) {
611
856
  switched = maybeSwitchBridgeJsonl();
612
857
  }
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) {
858
+ // Quiet-rotation fallback: directory-mtime heuristic that picks the
859
+ // newest jsonl in the same project dir when our current path goes
860
+ // quiet. Originally the safety net for "user runs /clear purely in
861
+ // iTerm with no pending Lark turn, so fingerprint fallback can't
862
+ // anchor on anything". Trade-off: when the user has a SIBLING Claude
863
+ // pane in the same cwd, that pane's busier jsonl always wins this
864
+ // race and the bridge gets hijacked, ingesting the sibling pane's
865
+ // user/assistant events as `isLocal: true` local turns and forwarding
866
+ // them to the adopted Lark thread (the user-reported "/adopt 一对话
867
+ // 出来一堆历史会话" symptom).
868
+ //
869
+ // We accept the asymmetry: sibling-pane hijack is silent, persistent
870
+ // and corrupts every adopted multi-pane setup; pure-local /clear
871
+ // without a pending Lark turn is a narrow corner case the user can
872
+ // unstick by sending one Lark message (which arms fingerprint
873
+ // fallback). So we ONLY consult the mtime heuristic when the pid
874
+ // probe was unavailable (non-Linux, missing/invalid pid file).
875
+ if (shouldRunQuietRotation(pidFollow, switched)) {
619
876
  maybeFollowQuietRotation();
620
877
  }
621
878
  if (!bridgeJsonlPath)
@@ -638,15 +895,19 @@ function startBridgeWatcher(jsonlPath, opts) {
638
895
  bridgeCliPid = opts?.cliPid;
639
896
  bridgeCliCwd = opts?.cliCwd;
640
897
  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.
898
+ // Pid-state record ranks above the path the adopt scan computed. If
899
+ // Claude was launched with `--resume` (or the adopt scan picked a
900
+ // stale jsonl), the pid file points at the actual current sessionId
901
+ // and we swap to it before baseline so we don't waste a baseline on
902
+ // a frozen file. Note: in-pane `/clear` between adopt and worker
903
+ // spawn would NOT show up here (pid file's `sessionId` is set once
904
+ // at process start) — that case is recovered later by the
905
+ // fingerprint fallback once a Lark turn arrives.
646
906
  if (bridgeCliPid && bridgeCliCwd) {
647
907
  const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
648
908
  if (resolved) {
649
909
  bridgeObservedCliSessionId = resolved.cliSessionId;
910
+ bridgeRememberSessionIdForPath(resolved.path);
650
911
  if (resolved.path !== bridgeJsonlPath) {
651
912
  log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
652
913
  bridgeJsonlPath = resolved.path;
@@ -654,6 +915,11 @@ function startBridgeWatcher(jsonlPath, opts) {
654
915
  }
655
916
  }
656
917
  }
918
+ // Remember the initial path's sessionId — this is the ground-truth
919
+ // anchor for the sibling-pane hijack guard. Subsequent fingerprint
920
+ // candidates are accepted only if their sessionId is in this set
921
+ // (populated here, by pid resolver hits, and by per-tick fd probes).
922
+ bridgeRememberSessionIdForPath(bridgeJsonlPath);
657
923
  if (mode === 'fresh-empty') {
658
924
  // Non-adopt fallback: brand-new session, jsonl gets created on the first
659
925
  // user submit. We must NOT lazy-absorb the file when it appears — that
@@ -713,6 +979,8 @@ function stopBridgeWatcher() {
713
979
  bridgeCliPid = undefined;
714
980
  bridgeCliCwd = undefined;
715
981
  bridgeObservedCliSessionId = undefined;
982
+ bridgeKnownSessionIds.clear();
983
+ bridgeStalePidStateSessionId = undefined;
716
984
  bridgeSecondaryPaths.clear();
717
985
  bridgePreambleSent = false;
718
986
  }
@@ -739,7 +1007,15 @@ function bridgeMarkPendingTurn(messageText) {
739
1007
  return false;
740
1008
  }
741
1009
  const fingerprint = makeFingerprint(messageText);
742
- bridgeQueue.mark(randomBytes(8).toString('hex'), fingerprint);
1010
+ // Full normalised content powers the unknown-sid recovery path. When a
1011
+ // user runs `/clear` and the bridge can't see the new sessionId yet
1012
+ // (pid file lags, fd probe missed the brief open window), we fall back
1013
+ // to scanning every untrusted candidate jsonl for an EXACT equality
1014
+ // with this normalised string — substantially harder for a sibling
1015
+ // pane to false-match than the 30-char substring fingerprint.
1016
+ const normalised = normaliseForFingerprint(messageText);
1017
+ const contentNormalized = normalised.length > 0 ? normalised : undefined;
1018
+ bridgeQueue.mark(randomBytes(8).toString('hex'), fingerprint, Date.now(), contentNormalized);
743
1019
  return true;
744
1020
  }
745
1021
  function bridgeDrainAndMaybeEmit() {
@@ -823,6 +1099,249 @@ function drainPathInto(path, fromOffset) {
823
1099
  bridgeQueue.ingest(result.events, path);
824
1100
  return { offset: result.newOffset, tail: result.pendingTail };
825
1101
  }
1102
+ // ─── Codex bridge wiring ─────────────────────────────────────────────────
1103
+ //
1104
+ // Codex's bridge fallback is intentionally simpler than Claude's: no /adopt
1105
+ // surface, no pid-resolver / quiet-rotation / fingerprint-jsonl-switch
1106
+ // machinery. The reader watches one rollout file (located by cliSessionId)
1107
+ // and the queue's only responsibility is "user fingerprint match → start;
1108
+ // assistant_final → close". Everything else (mark / emit gate / send
1109
+ // marker IO / type-ahead serialisation / one-write-per-idle break) is
1110
+ // shared with the Claude path.
1111
+ function codexBridgeFallbackActive() {
1112
+ // True for both adopt and non-adopt Codex sessions. The two modes
1113
+ // differ in: emit gate (adopt skips marker check), local-turn synthesis
1114
+ // (adopt only), and how the rollout path is resolved.
1115
+ return lastInitConfig?.cliId === 'codex';
1116
+ }
1117
+ function codexBridgeStartTimer() {
1118
+ if (codexBridgeTimer)
1119
+ return;
1120
+ // Single 1s ticker that handles three jobs: late-attach (poll for the
1121
+ // rollout file once we know cliSessionId), ingest (fs.watch backup),
1122
+ // and idle-window emit. The last is critical for the late-attach race:
1123
+ // if the rollout path appears AFTER the CLI's idle event has fired,
1124
+ // the idle callback's emit already ran (and saw an empty queue), so
1125
+ // the next emit chance would be at the next idle — i.e. the user has
1126
+ // to send another message before the previous turn's fallback shows
1127
+ // up. Emitting here when isPromptReady=true closes that window.
1128
+ // Codex's queue only releases turns on `assistant_final` (the model's
1129
+ // declared end-of-turn), so a tick-driven emit can't accidentally
1130
+ // publish a half-streamed response.
1131
+ codexBridgeTimer = setInterval(() => {
1132
+ try {
1133
+ if (!codexBridgeRolloutPath) {
1134
+ // Two discovery paths, in order: cliSessionId (known via writeInput
1135
+ // result for non-adopt or daemon-side probe for adopt) → exact
1136
+ // file by name; PID (adopt only) → walk /proc/<pid>/fd. Adopt
1137
+ // attaches via split-live (history absorbed, live ingested);
1138
+ // non-adopt uses fresh-empty (queue's markTimeMs - 5s lower bound
1139
+ // gates historical fingerprint matches without needing a split).
1140
+ let path;
1141
+ if (codexBridgePendingSessionId) {
1142
+ path = findCodexRolloutBySessionId(codexBridgePendingSessionId);
1143
+ }
1144
+ if (!path && codexAdoptPendingPid) {
1145
+ const probed = findCodexRolloutByPid(codexAdoptPendingPid);
1146
+ if (probed)
1147
+ path = probed.path;
1148
+ }
1149
+ if (path) {
1150
+ codexBridgePendingSessionId = undefined;
1151
+ codexAdoptPendingPid = undefined;
1152
+ // Adopt mode: split-live partitions drained events by
1153
+ // codexAdoptStartMs so anything the user did AFTER adopt but
1154
+ // BEFORE we found the rollout still emits (history is absorbed,
1155
+ // live is ingested). Non-adopt: fresh-empty as before — queue's
1156
+ // markTimeMs - 5s lower bound is enough since there's no
1157
+ // local-turn synthesis on that path.
1158
+ const mode = lastInitConfig?.adoptMode ? 'split-live' : 'fresh-empty';
1159
+ codexBridgeAttach(path, mode);
1160
+ }
1161
+ }
1162
+ codexBridgeIngest();
1163
+ if (isPromptReady)
1164
+ emitReadyCodexTurns();
1165
+ }
1166
+ catch (err) {
1167
+ log(`Codex bridge tick error: ${err.message}`);
1168
+ }
1169
+ }, 1000);
1170
+ }
1171
+ function codexBridgeAttach(rolloutPath, mode) {
1172
+ codexBridgeRolloutPath = rolloutPath;
1173
+ if (mode === 'fresh-empty') {
1174
+ // Brand-new session OR late-attach right after first submit. Either
1175
+ // way we want to ingest from offset 0 — pending turns marked before
1176
+ // attach are still in the queue, so the user_message that just landed
1177
+ // (or is about to land) will fingerprint-match them.
1178
+ codexBridgeOffset = 0;
1179
+ codexBridgePendingTail = '';
1180
+ codexBridgeBaselineDone = true;
1181
+ log(`Codex bridge fresh-empty: ${rolloutPath}`);
1182
+ }
1183
+ else if (mode === 'split-live' && existsSync(rolloutPath)) {
1184
+ // Adopt mode: drain everything, then split by adoptStartMs. History
1185
+ // (pre-adopt) is `absorb()`-ed so it can't replay; live (post-adopt)
1186
+ // is `ingest()`-ed so a Lark turn already marked or an iTerm-typed
1187
+ // local turn that landed before we found the rollout still gets
1188
+ // attributed. Without this split, baseline-existing would absorb()
1189
+ // the live events too, silently dropping anything the user did
1190
+ // between adopt and rollout-discovery — that's the user-reported
1191
+ // "iTerm 手动输入飞书没收到" symptom under late-attach.
1192
+ const result = drainCodexRollout(rolloutPath, 0);
1193
+ const cutoff = (codexAdoptStartMs ?? Date.now()) - 5_000;
1194
+ const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
1195
+ codexBridgeQueue.absorb(history);
1196
+ codexBridgeQueue.ingest(live);
1197
+ codexBridgeOffset = result.newOffset;
1198
+ codexBridgePendingTail = result.pendingTail;
1199
+ codexBridgeBaselineDone = true;
1200
+ log(`Codex bridge split-live: ${rolloutPath} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${codexBridgeOffset})`);
1201
+ }
1202
+ else if (mode === 'split-live') {
1203
+ // split-live requested but file missing — degrade to fresh: the file
1204
+ // will appear later via fs.watch / poller, and ingest from offset 0
1205
+ // will pick up everything as live (consistent with split semantics
1206
+ // when there's no history to absorb).
1207
+ codexBridgeOffset = 0;
1208
+ codexBridgePendingTail = '';
1209
+ codexBridgeBaselineDone = true;
1210
+ log(`Codex bridge split-live degraded to fresh (file missing): ${rolloutPath}`);
1211
+ }
1212
+ else if (existsSync(rolloutPath)) {
1213
+ const result = drainCodexRollout(rolloutPath, 0);
1214
+ codexBridgeOffset = result.newOffset;
1215
+ codexBridgePendingTail = result.pendingTail;
1216
+ codexBridgeQueue.absorb(result.events);
1217
+ codexBridgeBaselineDone = true;
1218
+ log(`Codex bridge baselined: ${rolloutPath} (offset=${codexBridgeOffset})`);
1219
+ }
1220
+ else {
1221
+ // baseline-existing requested but file missing — degrade to fresh
1222
+ // semantics so the lazy-appearing file isn't accidentally absorbed.
1223
+ codexBridgeOffset = 0;
1224
+ codexBridgePendingTail = '';
1225
+ codexBridgeBaselineDone = true;
1226
+ log(`Codex bridge transcript not yet present at ${rolloutPath}; treating as fresh`);
1227
+ }
1228
+ try {
1229
+ codexBridgeWatcher = fsWatch(rolloutPath, { persistent: false }, () => {
1230
+ try {
1231
+ codexBridgeIngest();
1232
+ }
1233
+ catch (err) {
1234
+ log(`Codex bridge ingest error: ${err.message}`);
1235
+ }
1236
+ });
1237
+ }
1238
+ catch (err) {
1239
+ log(`Codex bridge fs.watch unavailable (${err.message}); relying on poller`);
1240
+ }
1241
+ }
1242
+ /** Called from flushPending after writeInput first returns a cliSessionId.
1243
+ * Tries to locate the rollout file immediately; if it's not on disk yet,
1244
+ * remembers the sid so the 1s poller can keep retrying. */
1245
+ function codexBridgeNotifyCliSessionId(cliSessionId) {
1246
+ if (!codexBridgeFallbackActive() || codexBridgeRolloutPath)
1247
+ return;
1248
+ const path = findCodexRolloutBySessionId(cliSessionId);
1249
+ if (path) {
1250
+ codexBridgePendingSessionId = undefined;
1251
+ codexBridgeAttach(path, 'fresh-empty');
1252
+ }
1253
+ else {
1254
+ codexBridgePendingSessionId = cliSessionId;
1255
+ codexBridgeStartTimer();
1256
+ }
1257
+ }
1258
+ function codexBridgeIngest() {
1259
+ if (!codexBridgeRolloutPath || !codexBridgeBaselineDone)
1260
+ return;
1261
+ const result = drainCodexRollout(codexBridgeRolloutPath, codexBridgeOffset);
1262
+ codexBridgeOffset = result.newOffset;
1263
+ codexBridgePendingTail = result.pendingTail;
1264
+ codexBridgeQueue.ingest(result.events);
1265
+ }
1266
+ /** Mark a pending Lark turn for Codex. Crucially this works even before a
1267
+ * rollout path is known — the queue is path-agnostic, and ingest after
1268
+ * late-attach picks up the user_message and matches the fingerprint. */
1269
+ function codexBridgeMarkPendingTurn(messageText) {
1270
+ if (!codexBridgeFallbackActive())
1271
+ return false;
1272
+ const turnId = `codex-${randomBytes(8).toString('hex')}`;
1273
+ codexBridgeQueue.mark(turnId, messageText);
1274
+ return true;
1275
+ }
1276
+ function codexBridgeDrainAndMaybeEmit() {
1277
+ if (!codexBridgeFallbackActive())
1278
+ return;
1279
+ if (codexBridgeRolloutPath && codexBridgeBaselineDone) {
1280
+ try {
1281
+ codexBridgeIngest();
1282
+ }
1283
+ catch (err) {
1284
+ log(`Codex bridge ingest error: ${err.message}`);
1285
+ }
1286
+ }
1287
+ emitReadyCodexTurns();
1288
+ }
1289
+ function emitReadyCodexTurns() {
1290
+ const ready = codexBridgeQueue.drainEmittable();
1291
+ if (ready.length === 0)
1292
+ return;
1293
+ const adoptMode = lastInitConfig?.adoptMode === true;
1294
+ // Adopt mode: model is the user's external Codex, no botmux send to
1295
+ // gate against — every assistant turn (Lark-driven OR locally typed)
1296
+ // should reach the thread. Skip marker IO entirely.
1297
+ const markers = adoptMode ? [] : readSendMarkers();
1298
+ const remaining = codexBridgeQueue.peek();
1299
+ const nextPendingMarkTimeMs = remaining.length > 0 ? remaining[0].markTimeMs : undefined;
1300
+ for (let i = 0; i < ready.length; i++) {
1301
+ const turn = ready[i];
1302
+ if (!turn.finalText)
1303
+ continue;
1304
+ const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
1305
+ if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
1306
+ log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
1307
+ continue;
1308
+ }
1309
+ if (turn.isLocal) {
1310
+ // Local turn (adopt only): user typed in iTerm. Surface both sides
1311
+ // so the Lark thread sees a complete exchange instead of an orphan
1312
+ // reply. formatLocalTurnContent caps both texts to keep within
1313
+ // Lark's per-message limit.
1314
+ const content = formatLocalTurnContent(turn.userText ?? '', turn.finalText);
1315
+ if (!content)
1316
+ continue;
1317
+ send({ type: 'final_output', content, lastUuid: turn.turnId, turnId: turn.turnId });
1318
+ continue;
1319
+ }
1320
+ send({ type: 'final_output', content: turn.finalText, lastUuid: turn.turnId, turnId: turn.turnId });
1321
+ }
1322
+ }
1323
+ function stopCodexBridge() {
1324
+ if (codexBridgeWatcher) {
1325
+ try {
1326
+ codexBridgeWatcher.close();
1327
+ }
1328
+ catch { /* ignore */ }
1329
+ codexBridgeWatcher = null;
1330
+ }
1331
+ if (codexBridgeTimer) {
1332
+ clearInterval(codexBridgeTimer);
1333
+ codexBridgeTimer = null;
1334
+ }
1335
+ codexBridgeRolloutPath = undefined;
1336
+ codexBridgeOffset = 0;
1337
+ codexBridgePendingTail = '';
1338
+ codexBridgeBaselineDone = false;
1339
+ codexBridgeQueue.clearPending();
1340
+ codexBridgeQueue.setLocalTurns(false);
1341
+ codexBridgePendingSessionId = undefined;
1342
+ codexAdoptPendingPid = undefined;
1343
+ codexAdoptStartMs = undefined;
1344
+ }
826
1345
  /** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
827
1346
  * whose sourceJsonlPath equals oldPath may still be waiting on assistant
828
1347
  * text that hasn't landed yet. Add oldPath to the secondary polling set
@@ -1340,10 +1859,17 @@ async function flushPending() {
1340
1859
  // so BridgeTurnQueue.ingest never starts the pending turn for them and
1341
1860
  // the assistant text would be dropped on the floor. Serialise instead —
1342
1861
  // worker holds messages in pendingMessages until the CLI reaches idle.
1343
- const typeAheadAllowed = cliAdapter.supportsTypeAhead && !(bridgeJsonlPath && !lastInitConfig?.adoptMode);
1862
+ const claudeBridgeActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
1863
+ const codexBridgeActive = codexBridgeFallbackActive();
1864
+ const bridgeFallbackActive = claudeBridgeActive || codexBridgeActive;
1865
+ // Type-ahead must be disabled for any active bridge fallback (claude or
1866
+ // codex). Claude type-ahead's queued submits never become role:user
1867
+ // events; Codex doesn't declare supportsTypeAhead so this is mostly a
1868
+ // belt-and-braces gate, but keep symmetry so future adapters with
1869
+ // type-ahead get the same protection automatically.
1870
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !bridgeFallbackActive;
1344
1871
  if (!isPromptReady && !typeAheadAllowed)
1345
1872
  return;
1346
- const bridgeFallbackActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
1347
1873
  isFlushing = true;
1348
1874
  if (isPromptReady) {
1349
1875
  isPromptReady = false;
@@ -1359,21 +1885,34 @@ async function flushPending() {
1359
1885
  // falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
1360
1886
  // (at IPC arrival) would let a slow-finishing turn N's send leak
1361
1887
  // into turn N+1's window and falsely suppress its emit.
1362
- if (bridgeFallbackActive) {
1888
+ if (claudeBridgeActive) {
1363
1889
  try {
1364
1890
  bridgeIngest();
1365
1891
  }
1366
1892
  catch { /* best-effort */ }
1367
1893
  bridgeMarkPendingTurn(msg);
1368
1894
  }
1895
+ else if (codexBridgeActive) {
1896
+ // Codex mark works even before the rollout path is known: the
1897
+ // queue is path-agnostic, and the late-attach below will start
1898
+ // ingest from offset 0 so the user_message that lands shortly
1899
+ // after still fingerprint-matches this turn.
1900
+ codexBridgeMarkPendingTurn(msg);
1901
+ }
1369
1902
  log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
1370
1903
  const result = await cliAdapter.writeInput(backend, msg);
1371
1904
  // Persist any sessionId the adapter observed via authoritative sources
1372
1905
  // (Claude's pid file, Codex's history). Done independently of submit
1373
1906
  // outcome — the rotation is real even when the current Enter didn't
1374
1907
  // land, and we want next-resume to use the right id.
1375
- if (result?.cliSessionId)
1908
+ if (result?.cliSessionId) {
1376
1909
  persistCliSessionId(result.cliSessionId);
1910
+ // First successful Codex submit also reveals the rollout path.
1911
+ // Late-attach now so subsequent assistant_final events get
1912
+ // attributed to this turn.
1913
+ if (codexBridgeActive)
1914
+ codexBridgeNotifyCliSessionId(result.cliSessionId);
1915
+ }
1377
1916
  if (result && result.submitted === false) {
1378
1917
  const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
1379
1918
  log(`writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
@@ -1415,8 +1954,9 @@ function sendToPty(content) {
1415
1954
  // Tear down the prompt card so the user doesn't see stale options.
1416
1955
  send({ type: 'tui_prompt_resolved', selectedText: 'user-override' });
1417
1956
  }
1418
- // See flushPending: bridge fallback gates type-ahead off.
1419
- const typeAheadAllowed = cliAdapter.supportsTypeAhead && !(bridgeJsonlPath && !lastInitConfig?.adoptMode);
1957
+ // See flushPending: bridge fallback gates type-ahead off (claude OR codex).
1958
+ const bridgeFallbackActive = (!!bridgeJsonlPath && !lastInitConfig?.adoptMode) || codexBridgeFallbackActive();
1959
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !bridgeFallbackActive;
1420
1960
  if (isPromptReady || isFlushing || typeAheadAllowed) {
1421
1961
  log(`Writing to PTY: "${content.substring(0, 80)}"`);
1422
1962
  flushPending(); // fire-and-forget async; no-op if already flushing
@@ -1489,22 +2029,56 @@ function spawnCli(cfg) {
1489
2029
  catch (err) {
1490
2030
  log(`captureCurrentScreen failed: ${err.message}`);
1491
2031
  }
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).
2032
+ // Bridge mode: tail the adopted CLI's transcript to harvest assistant
2033
+ // turns out-of-band. Two paths:
2034
+ // - claude-code: cfg.bridgeJsonlPath is set when adopt knew the sid.
2035
+ // - codex: locate rollout via cliSessionId (daemon's discovery probe)
2036
+ // or by reading /proc/<pid>/fd. Both modes enable adopt-only local
2037
+ // turn synthesis so iTerm-typed conversation also reaches Lark.
1495
2038
  if (cfg.bridgeJsonlPath) {
1496
2039
  startBridgeWatcher(cfg.bridgeJsonlPath, {
1497
2040
  cliPid: cfg.adoptCliPid,
1498
2041
  cliCwd: cfg.adoptCwd,
1499
2042
  });
1500
2043
  }
1501
- // Idle detection. In bridge mode we use Claude Code's real
2044
+ else if (cfg.cliId === 'codex') {
2045
+ const adoptStartMs = Date.now();
2046
+ codexAdoptStartMs = adoptStartMs;
2047
+ codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2048
+ let rolloutPath;
2049
+ if (cfg.cliSessionId)
2050
+ rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
2051
+ if (!rolloutPath && cfg.adoptCliPid) {
2052
+ const probed = findCodexRolloutByPid(cfg.adoptCliPid);
2053
+ if (probed)
2054
+ rolloutPath = probed.path;
2055
+ }
2056
+ if (rolloutPath) {
2057
+ // Adopt-time attach: split-live so any iTerm activity that
2058
+ // happened in the brief window between adopt detection and worker
2059
+ // spawn (or between codex's own startup writes and now) lands as
2060
+ // live, not absorbed history.
2061
+ codexBridgeAttach(rolloutPath, 'split-live');
2062
+ }
2063
+ else {
2064
+ // Couldn't locate yet — start poller. The 1s timer keeps trying
2065
+ // both findCodexRolloutBySessionId (if cliSessionId is set) and
2066
+ // findCodexRolloutByPid (passed via the discovery hooks below).
2067
+ if (cfg.cliSessionId)
2068
+ codexBridgePendingSessionId = cfg.cliSessionId;
2069
+ codexAdoptPendingPid = cfg.adoptCliPid;
2070
+ codexBridgeStartTimer();
2071
+ }
2072
+ }
2073
+ // Idle detection. In bridge mode we use the adopted CLI's real
1502
2074
  // completion/ready patterns (e.g. "Worked for Xs") so tool-execution
1503
2075
  // pauses don't trigger a premature emit. Other adopt cases keep the
1504
2076
  // minimal output-quiescence-only detector.
1505
2077
  const idleAdapter = cfg.bridgeJsonlPath
1506
2078
  ? createCliAdapterSync('claude-code', undefined)
1507
- : { completionPattern: undefined, readyPattern: undefined };
2079
+ : cfg.cliId === 'codex'
2080
+ ? createCliAdapterSync('codex', undefined)
2081
+ : { completionPattern: undefined, readyPattern: undefined };
1508
2082
  idleDetector = new IdleDetector(idleAdapter);
1509
2083
  idleDetector.onIdle(() => {
1510
2084
  log('Prompt detected (idle) — adopt mode');
@@ -1514,6 +2088,12 @@ function spawnCli(cfg) {
1514
2088
  catch (err) {
1515
2089
  log(`Bridge emit error: ${err.message}`);
1516
2090
  }
2091
+ try {
2092
+ codexBridgeDrainAndMaybeEmit();
2093
+ }
2094
+ catch (err) {
2095
+ log(`Codex bridge emit error: ${err.message}`);
2096
+ }
1517
2097
  markPromptReady();
1518
2098
  });
1519
2099
  backend.onData(onPtyData);
@@ -1592,9 +2172,13 @@ function spawnCli(cfg) {
1592
2172
  }
1593
2173
  }
1594
2174
  // 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.
2175
+ // ~/.claude/sessions/<pid>.json — the spawn-time pid-state record. Its
2176
+ // `sessionId` is set ONCE at process start (Claude Code 2.1.123); a
2177
+ // `--resume` lookup will surface here, but in-pane `/clear` won't, so a
2178
+ // 'matching sessionId' answer is "no spawn-time rotation observed", not
2179
+ // "no rotation at all". The pinned claudeJsonlPath above is still the
2180
+ // initial guess; the resolver corrects it on first write when Claude was
2181
+ // started with `--resume`.
1598
2182
  if (cfg.cliId === 'claude-code' && cliPid) {
1599
2183
  backend.cliPid = cliPid;
1600
2184
  backend.cliCwd = cfg.workingDir;
@@ -1621,6 +2205,31 @@ function spawnCli(cfg) {
1621
2205
  mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
1622
2206
  });
1623
2207
  }
2208
+ // Codex bridge fallback: same intent as the Claude block above but a
2209
+ // different transcript layout. Resume-with-known-cliSessionId can attach
2210
+ // immediately; new sessions / resume-without-id rely on flushPending to
2211
+ // late-attach once writeInput returns the cliSessionId.
2212
+ if (cfg.cliId === 'codex') {
2213
+ if (cfg.cliSessionId) {
2214
+ const rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
2215
+ if (rolloutPath) {
2216
+ codexBridgeAttach(rolloutPath, 'baseline-existing');
2217
+ }
2218
+ else {
2219
+ // Resume but the rollout file isn't where we expected — start the
2220
+ // poller so we keep looking; if the user submits and a new file
2221
+ // appears, late-attach kicks in via writeInput's cliSessionId.
2222
+ codexBridgePendingSessionId = cfg.cliSessionId;
2223
+ codexBridgeStartTimer();
2224
+ }
2225
+ }
2226
+ else {
2227
+ // Brand-new Codex session: no path until first submit. Start the
2228
+ // poller anyway so the CLI is ready to attach the moment we have
2229
+ // a cliSessionId.
2230
+ codexBridgeStartTimer();
2231
+ }
2232
+ }
1624
2233
  // Set up idle detection
1625
2234
  idleDetector = new IdleDetector(cliAdapter);
1626
2235
  idleDetector.onIdle(() => {
@@ -1637,6 +2246,14 @@ function spawnCli(cfg) {
1637
2246
  log(`Bridge emit error: ${err.message}`);
1638
2247
  }
1639
2248
  }
2249
+ if (codexBridgeFallbackActive()) {
2250
+ try {
2251
+ codexBridgeDrainAndMaybeEmit();
2252
+ }
2253
+ catch (err) {
2254
+ log(`Codex bridge emit error: ${err.message}`);
2255
+ }
2256
+ }
1640
2257
  markPromptReady();
1641
2258
  });
1642
2259
  backend.onData(onPtyData);
@@ -1670,6 +2287,7 @@ function killCli() {
1670
2287
  // restart with the proper mode based on the new cfg. Leaving it running
1671
2288
  // would dangle a watcher pinned to a stale jsonl path.
1672
2289
  stopBridgeWatcher();
2290
+ stopCodexBridge();
1673
2291
  // Clean up CLI PID marker
1674
2292
  if (cliPidMarker) {
1675
2293
  try {
@@ -2119,6 +2737,17 @@ process.on('message', async (raw) => {
2119
2737
  catch { /* best effort */ }
2120
2738
  bridgeMarkPendingTurn(content);
2121
2739
  }
2740
+ else if (codexBridgeFallbackActive()) {
2741
+ // Codex adopt: same idea, different bridge. ingest first so any
2742
+ // in-flight events from a local-typed prior turn close before
2743
+ // this Lark turn's fingerprint window opens. Mark works even
2744
+ // pre-attach (queue is path-agnostic).
2745
+ try {
2746
+ codexBridgeIngest();
2747
+ }
2748
+ catch { /* best effort */ }
2749
+ codexBridgeMarkPendingTurn(content);
2750
+ }
2122
2751
  // Adopt mode: raw write to PTY (no adapter writeInput)
2123
2752
  if (backend) {
2124
2753
  if ('sendText' in backend && 'sendSpecialKeys' in backend) {