botmux 2.12.1 → 2.12.3

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 +17 -0
  26. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  27. package/dist/services/codex-bridge-queue.js +64 -30
  28. package/dist/services/codex-bridge-queue.js.map +1 -1
  29. package/dist/services/codex-transcript.d.ts +33 -0
  30. package/dist/services/codex-transcript.d.ts.map +1 -1
  31. package/dist/services/codex-transcript.js +71 -1
  32. package/dist/services/codex-transcript.js.map +1 -1
  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 +545 -120
  37. package/dist/worker.js.map +1 -1
  38. package/package.json +2 -2
package/dist/worker.js CHANGED
@@ -15,11 +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';
21
22
  import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
22
- import { drainCodexRollout, findCodexRolloutBySessionId } from './services/codex-transcript.js';
23
+ import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff } from './services/codex-transcript.js';
23
24
  import { dirname } from 'node:path';
24
25
  import { createServer as createHttpServer } from 'node:http';
25
26
  import { WebSocketServer, WebSocket } from 'ws';
@@ -80,15 +81,37 @@ let bridgeJsonlPath;
80
81
  * original path would silently stop receiving events. */
81
82
  let bridgeJsonlDir;
82
83
  /** PID + cwd of the adopted Claude Code process. Lets every poll re-read
83
- * ~/.claude/sessions/<pid>.json — Claude's own authoritative record of the
84
- * current sessionId and switch the watched jsonl when Claude rotates
85
- * (via /clear, /resume, --resume etc.) without waiting for a Lark message
86
- * to land in the new file. */
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. */
87
90
  let bridgeCliPid;
88
91
  let bridgeCliCwd;
89
92
  /** Last sessionId we observed via the pid resolver — used to detect
90
93
  * rotations cheaply (string compare instead of stat()ing every jsonl). */
91
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;
92
115
  /** Old jsonl paths we keep polling AFTER a rotation switched
93
116
  * bridgeJsonlPath away — needed when a started turn was stamped with the
94
117
  * old path but its assistant text hasn't been written yet. We continue to
@@ -129,6 +152,16 @@ let codexBridgeTimer = null;
129
152
  * Codex's first user submit, but with some race delay after our submit
130
153
  * returns. Cleared once attached. */
131
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;
132
165
  /** Cap the preamble text so an extremely long previous turn doesn't blow
133
166
  * past Lark's per-message limit. The user only needs enough to recall
134
167
  * context, not the entire transcript. */
@@ -232,6 +265,32 @@ function maybeEmitAdoptPreamble(events) {
232
265
  });
233
266
  log('Bridge adopt preamble emitted (last completed turn from baseline)');
234
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
+ }
235
294
  function bridgeAbsorbBaseline() {
236
295
  if (!bridgeJsonlPath)
237
296
  return;
@@ -248,41 +307,30 @@ function bridgeAbsorbBaseline() {
248
307
  if (lastInitConfig?.adoptMode)
249
308
  maybeEmitAdoptPreamble(result.events);
250
309
  }
251
- /** Detect /clear / /resume: when Claude Code starts a new session in the
252
- * user's pane it writes to a brand-new sessionId.jsonl. We *cannot* use
253
- * "latest-mtime jsonl in the project dir" as the switch trigger — that
254
- * hijacks our watcher whenever a sibling Claude pane in the same cwd
255
- * writes anything. Instead, switch only when:
256
- *
257
- * 1. We have an unstarted pending Lark turn (otherwise no signal to
258
- * chase, and switching would risk grabbing another pane's reply).
259
- * 2. The pending turn's content fingerprint shows up in a candidate
260
- * jsonl other than our current one — that's the user's current
261
- * session because they JUST typed our pane-write into it.
262
- *
263
- * Pending turns are preserved across the switch so the next ingest can
264
- * match the fingerprint and start the turn in the new file. */
265
- function maybeSwitchBridgeJsonl() {
266
- if (!bridgeJsonlDir)
267
- return false;
268
- const pending = bridgeQueue.peek();
269
- const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
270
- if (!candidate || !candidate.contentFingerprint)
271
- return false;
272
- // Bound the search to events written after the turn was marked. Short
273
- // fingerprints ("hello", "test") would otherwise match old user lines
274
- // in unrelated sibling jsonls. 5s skew absorbs clock drift between the
275
- // mark and Claude's transcript write.
276
- const minEventTimestampMs = candidate.markTimeMs !== undefined
277
- ? candidate.markTimeMs - 5_000
278
- : undefined;
279
- const matched = findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
280
- excludePath: bridgeJsonlPath,
281
- includeQueueOperations: true,
282
- minEventTimestampMs,
283
- });
284
- if (!matched)
285
- return false;
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) {
286
334
  // Drain-before-switch: pull in any unread bytes from the old path so a
287
335
  // late assistant append doesn't vanish. We do NOT emit here — emission
288
336
  // only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
@@ -302,7 +350,7 @@ function maybeSwitchBridgeJsonl() {
302
350
  }
303
351
  retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
304
352
  }
305
- 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})`);
306
354
  if (bridgeWatcher) {
307
355
  try {
308
356
  bridgeWatcher.close();
@@ -311,18 +359,30 @@ function maybeSwitchBridgeJsonl() {
311
359
  bridgeWatcher = null;
312
360
  }
313
361
  // Critically: do NOT clear pending turns. The switch was triggered by
314
- // the fingerprint of the FIRST pending turn already living in `matched`,
315
- // so the immediate next ingest from offset 0 will find that user event
316
- // and start the turn. Clearing here would race-drop exactly the message
317
- // we're trying to deliver.
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.
318
366
  bridgeJsonlPath = matched;
319
- bridgeOffset = 0;
367
+ bridgeJsonlDir = dirname(matched);
320
368
  bridgePendingTail = '';
321
- // baselineDone=false would absorb the new file's existing content
322
- // (including the pending turn's user event) as history defeating the
323
- // switch. Skip baseline; fall straight into ingest from offset 0 so
324
- // BridgeTurnQueue.ingest() can attribute the matching user/assistant.
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);
325
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));
326
386
  try {
327
387
  bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
328
388
  try {
@@ -336,31 +396,122 @@ function maybeSwitchBridgeJsonl() {
336
396
  catch (err) {
337
397
  log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
338
398
  }
339
- return true;
340
399
  }
341
- /** /clear or /resume in the user's adopted pane creates (or touches) a new
342
- * jsonl in the same Claude project directory. Neither pid-resolver nor
343
- * fingerprint switch will fire when the rotation happened mid-process AND
344
- * there's no pending Lark turn to anchor on (pure local-terminal use), so
345
- * this fallback owns that case.
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.
346
493
  *
347
494
  * Detection priority:
348
495
  * 1. Linux first-class: read `/proc/<pid>/fd` and pick the .jsonl the
349
- * adopted Claude process actually has open. This is bound to the real
350
- * PID — a sibling Claude pane in the same cwd has a different PID and
351
- * therefore cannot hijack the result.
352
- * 2. Cross-platform fallback: directory-level mtime heuristic, gated on
353
- * (a) our current jsonl quiet QUIET_ROTATION_MS, (b) candidate
354
- * newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Less
355
- * robust than fd lookup but the best available without /proc.
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.
356
507
  *
357
- * When a rotation is detected, the new jsonl is drained from offset 0 and
358
- * events are split by timestamp against `rotationCutoffMs` (the old
359
- * jsonl's last-write time): events before the cutoff are *history*
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*
360
511
  * (absorbed into the seen-set, not emitted), events after are *live*
361
- * (ingested → local-turn synthesis runs). This is what lets /resume to a
362
- * long-history jsonl NOT replay the entire past as one giant local turn,
363
- * while /clear's first new turn still gets forwarded.
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.
364
515
  *
365
516
  * Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
366
517
  * also fires `maybeEmitAdoptPreamble`, which on rotation would surface
@@ -472,17 +623,7 @@ function performRotationSwitch(newPath, cutoffMs, reason) {
472
623
  const result = drainTranscript(newPath, 0);
473
624
  bridgeOffset = result.newOffset;
474
625
  bridgePendingTail = result.pendingTail;
475
- const history = [];
476
- const live = [];
477
- for (const ev of result.events) {
478
- let evMs = Number.NaN;
479
- if (typeof ev.timestamp === 'string')
480
- evMs = Date.parse(ev.timestamp);
481
- if (Number.isFinite(evMs) && evMs <= cutoffMs)
482
- history.push(ev);
483
- else
484
- live.push(ev);
485
- }
626
+ const { history, live } = splitTranscriptEventsByCutoff(result.events, cutoffMs);
486
627
  bridgeQueue.absorb(history);
487
628
  if (live.length > 0)
488
629
  bridgeQueue.ingest(live, newPath);
@@ -521,6 +662,15 @@ function maybeFollowQuietRotation() {
521
662
  // bridgeJsonlPath ⇒ rotation.
522
663
  const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
523
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);
524
674
  const newest = newestPath(opened);
525
675
  if (newest && newest !== bridgeJsonlPath) {
526
676
  performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
@@ -546,6 +696,43 @@ function maybeFollowQuietRotation() {
546
696
  return;
547
697
  performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
548
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. */
549
736
  function maybeFollowSessionRotationViaPid() {
550
737
  if (!bridgeCliPid || !bridgeCliCwd)
551
738
  return 'unavailable';
@@ -555,8 +742,29 @@ function maybeFollowSessionRotationViaPid() {
555
742
  if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
556
743
  bridgeObservedCliSessionId = resolved.cliSessionId;
557
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);
558
749
  if (resolved.path === bridgeJsonlPath)
559
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';
560
768
  // Drain-before-switch: pull in any unread bytes from the OLD path so a
561
769
  // trailing assistant append doesn't vanish. We do NOT emit here — emit
562
770
  // is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
@@ -615,28 +823,56 @@ function bridgeIngest() {
615
823
  // the path. Strictly read-only on the polling rotation; never triggers
616
824
  // a rotate or shifts the primary path.
617
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();
618
836
  // Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
619
837
  // pid file → new sessionId), e.g. daemon restart that re-issues
620
838
  // `--resume <id>` and Claude rotates the internal id.
621
839
  const pidFollow = maybeFollowSessionRotationViaPid();
622
840
  // Fingerprint fallback: catches *in-process* rotations Claude makes
623
- // via /clear or /resume from the user's pane. Claude's pid file has
624
- // its sessionId field set ONCE at process start (see binary persistence
625
- // schema) and is NOT rewritten on /clear, so pid resolver returning
626
- // 'same' is NOT proof that no rotation happened. We skip the
627
- // fingerprint scan only when pid resolver actively switched the path
628
- // in that case the authoritative source already moved us, and
629
- // running fingerprint on top would risk a redundant flip.
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.
630
854
  let switched = pidFollow === 'switched';
631
855
  if (!switched) {
632
856
  switched = maybeSwitchBridgeJsonl();
633
857
  }
634
- // Quiet-rotation fallback: catches /clear or /resume in pure-local
635
- // sessions (no pending Lark turn no fingerprint to match against).
636
- // Without this, a user who hits /clear in the adopted pane and then
637
- // continues in the terminal would never get those replies forwarded
638
- // to Lark the watcher stays stuck on the old, frozen jsonl.
639
- if (!switched) {
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)) {
640
876
  maybeFollowQuietRotation();
641
877
  }
642
878
  if (!bridgeJsonlPath)
@@ -659,15 +895,19 @@ function startBridgeWatcher(jsonlPath, opts) {
659
895
  bridgeCliPid = opts?.cliPid;
660
896
  bridgeCliCwd = opts?.cliCwd;
661
897
  const mode = opts?.mode ?? 'baseline-existing';
662
- // Authoritative: prefer Claude's own pid-state record over the path the
663
- // adopt scan computed. If Claude has already rotated since adopt fired
664
- // (e.g. user ran /clear before any Lark message arrived), this swaps the
665
- // initial path before baseline so we don't waste a baseline on a frozen
666
- // file.
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.
667
906
  if (bridgeCliPid && bridgeCliCwd) {
668
907
  const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
669
908
  if (resolved) {
670
909
  bridgeObservedCliSessionId = resolved.cliSessionId;
910
+ bridgeRememberSessionIdForPath(resolved.path);
671
911
  if (resolved.path !== bridgeJsonlPath) {
672
912
  log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
673
913
  bridgeJsonlPath = resolved.path;
@@ -675,6 +915,11 @@ function startBridgeWatcher(jsonlPath, opts) {
675
915
  }
676
916
  }
677
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);
678
923
  if (mode === 'fresh-empty') {
679
924
  // Non-adopt fallback: brand-new session, jsonl gets created on the first
680
925
  // user submit. We must NOT lazy-absorb the file when it appears — that
@@ -734,6 +979,8 @@ function stopBridgeWatcher() {
734
979
  bridgeCliPid = undefined;
735
980
  bridgeCliCwd = undefined;
736
981
  bridgeObservedCliSessionId = undefined;
982
+ bridgeKnownSessionIds.clear();
983
+ bridgeStalePidStateSessionId = undefined;
737
984
  bridgeSecondaryPaths.clear();
738
985
  bridgePreambleSent = false;
739
986
  }
@@ -760,7 +1007,15 @@ function bridgeMarkPendingTurn(messageText) {
760
1007
  return false;
761
1008
  }
762
1009
  const fingerprint = makeFingerprint(messageText);
763
- 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);
764
1019
  return true;
765
1020
  }
766
1021
  function bridgeDrainAndMaybeEmit() {
@@ -854,8 +1109,9 @@ function drainPathInto(path, fromOffset) {
854
1109
  // marker IO / type-ahead serialisation / one-write-per-idle break) is
855
1110
  // shared with the Claude path.
856
1111
  function codexBridgeFallbackActive() {
857
- if (lastInitConfig?.adoptMode)
858
- return false;
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.
859
1115
  return lastInitConfig?.cliId === 'codex';
860
1116
  }
861
1117
  function codexBridgeStartTimer() {
@@ -874,11 +1130,33 @@ function codexBridgeStartTimer() {
874
1130
  // publish a half-streamed response.
875
1131
  codexBridgeTimer = setInterval(() => {
876
1132
  try {
877
- if (!codexBridgeRolloutPath && codexBridgePendingSessionId) {
878
- const path = findCodexRolloutBySessionId(codexBridgePendingSessionId);
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
+ }
879
1149
  if (path) {
880
1150
  codexBridgePendingSessionId = undefined;
881
- codexBridgeAttach(path, 'fresh-empty');
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);
882
1160
  }
883
1161
  }
884
1162
  codexBridgeIngest();
@@ -902,6 +1180,35 @@ function codexBridgeAttach(rolloutPath, mode) {
902
1180
  codexBridgeBaselineDone = true;
903
1181
  log(`Codex bridge fresh-empty: ${rolloutPath}`);
904
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
+ }
905
1212
  else if (existsSync(rolloutPath)) {
906
1213
  const result = drainCodexRollout(rolloutPath, 0);
907
1214
  codexBridgeOffset = result.newOffset;
@@ -983,18 +1290,33 @@ function emitReadyCodexTurns() {
983
1290
  const ready = codexBridgeQueue.drainEmittable();
984
1291
  if (ready.length === 0)
985
1292
  return;
986
- const markers = readSendMarkers();
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();
987
1298
  const remaining = codexBridgeQueue.peek();
988
1299
  const nextPendingMarkTimeMs = remaining.length > 0 ? remaining[0].markTimeMs : undefined;
989
1300
  for (let i = 0; i < ready.length; i++) {
990
1301
  const turn = ready[i];
1302
+ if (!turn.finalText)
1303
+ continue;
991
1304
  const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
992
- if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: false }, nextBoundaryMs, markers, false)) {
993
- log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (model called botmux send within window)`);
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)`);
994
1307
  continue;
995
1308
  }
996
- if (!turn.finalText)
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 });
997
1318
  continue;
1319
+ }
998
1320
  send({ type: 'final_output', content: turn.finalText, lastUuid: turn.turnId, turnId: turn.turnId });
999
1321
  }
1000
1322
  }
@@ -1015,7 +1337,10 @@ function stopCodexBridge() {
1015
1337
  codexBridgePendingTail = '';
1016
1338
  codexBridgeBaselineDone = false;
1017
1339
  codexBridgeQueue.clearPending();
1340
+ codexBridgeQueue.setLocalTurns(false);
1018
1341
  codexBridgePendingSessionId = undefined;
1342
+ codexAdoptPendingPid = undefined;
1343
+ codexAdoptStartMs = undefined;
1019
1344
  }
1020
1345
  /** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
1021
1346
  * whose sourceJsonlPath equals oldPath may still be waiting on assistant
@@ -1704,23 +2029,69 @@ function spawnCli(cfg) {
1704
2029
  catch (err) {
1705
2030
  log(`captureCurrentScreen failed: ${err.message}`);
1706
2031
  }
1707
- // Bridge mode: tail Claude Code's transcript JSONL to harvest assistant
1708
- // turns out-of-band. Only enabled when the daemon supplied a path
1709
- // (claude-code adopt with a known sessionId).
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.
1710
2038
  if (cfg.bridgeJsonlPath) {
1711
2039
  startBridgeWatcher(cfg.bridgeJsonlPath, {
1712
2040
  cliPid: cfg.adoptCliPid,
1713
2041
  cliCwd: cfg.adoptCwd,
1714
2042
  });
1715
2043
  }
1716
- // 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
1717
2074
  // completion/ready patterns (e.g. "Worked for Xs") so tool-execution
1718
2075
  // pauses don't trigger a premature emit. Other adopt cases keep the
1719
2076
  // minimal output-quiescence-only detector.
1720
2077
  const idleAdapter = cfg.bridgeJsonlPath
1721
2078
  ? createCliAdapterSync('claude-code', undefined)
1722
- : { completionPattern: undefined, readyPattern: undefined };
2079
+ : cfg.cliId === 'codex'
2080
+ ? createCliAdapterSync('codex', undefined)
2081
+ : { completionPattern: undefined, readyPattern: undefined };
1723
2082
  idleDetector = new IdleDetector(idleAdapter);
2083
+ // Codex adopt write path: route Lark messages through the codex
2084
+ // adapter's writeInput so they pick up the 200 ms paste-detection
2085
+ // delay + Enter-retry + ~/.codex/history.jsonl verification loop
2086
+ // (see src/adapters/cli/codex.ts:125-178). Without it, Codex TUI's
2087
+ // "\n treated as Enter" handling leaves multi-line submits stuck
2088
+ // in the input box. Other adopt CLIs keep the simpler raw
2089
+ // sendText+Enter path — claude-code adopt has its own bridge
2090
+ // verify path; gemini / coco / opencode / aiden haven't surfaced
2091
+ // this failure mode and we don't want to risk regressing them.
2092
+ if (cfg.cliId === 'codex') {
2093
+ cliAdapter = createCliAdapterSync('codex', cfg.cliPathOverride);
2094
+ }
1724
2095
  idleDetector.onIdle(() => {
1725
2096
  log('Prompt detected (idle) — adopt mode');
1726
2097
  try {
@@ -1729,6 +2100,12 @@ function spawnCli(cfg) {
1729
2100
  catch (err) {
1730
2101
  log(`Bridge emit error: ${err.message}`);
1731
2102
  }
2103
+ try {
2104
+ codexBridgeDrainAndMaybeEmit();
2105
+ }
2106
+ catch (err) {
2107
+ log(`Codex bridge emit error: ${err.message}`);
2108
+ }
1732
2109
  markPromptReady();
1733
2110
  });
1734
2111
  backend.onData(onPtyData);
@@ -1807,9 +2184,13 @@ function spawnCli(cfg) {
1807
2184
  }
1808
2185
  }
1809
2186
  // Wire pid + cwd so the claude-code adapter's writeInput can read
1810
- // ~/.claude/sessions/<pid>.json — Claude's authoritative current sessionId.
1811
- // The pinned claudeJsonlPath above is still used as the initial guess; the
1812
- // resolver corrects it on first write when Claude has rotated under us.
2187
+ // ~/.claude/sessions/<pid>.json — the spawn-time pid-state record. Its
2188
+ // `sessionId` is set ONCE at process start (Claude Code 2.1.123); a
2189
+ // `--resume` lookup will surface here, but in-pane `/clear` won't, so a
2190
+ // 'matching sessionId' answer is "no spawn-time rotation observed", not
2191
+ // "no rotation at all". The pinned claudeJsonlPath above is still the
2192
+ // initial guess; the resolver corrects it on first write when Claude was
2193
+ // started with `--resume`.
1813
2194
  if (cfg.cliId === 'claude-code' && cliPid) {
1814
2195
  backend.cliPid = cliPid;
1815
2196
  backend.cliCwd = cfg.workingDir;
@@ -2368,9 +2749,53 @@ process.on('message', async (raw) => {
2368
2749
  catch { /* best effort */ }
2369
2750
  bridgeMarkPendingTurn(content);
2370
2751
  }
2371
- // Adopt mode: raw write to PTY (no adapter writeInput)
2752
+ else if (codexBridgeFallbackActive()) {
2753
+ // Codex adopt: same idea, different bridge. ingest first so any
2754
+ // in-flight events from a local-typed prior turn close before
2755
+ // this Lark turn's fingerprint window opens. Mark works even
2756
+ // pre-attach (queue is path-agnostic).
2757
+ try {
2758
+ codexBridgeIngest();
2759
+ }
2760
+ catch { /* best effort */ }
2761
+ codexBridgeMarkPendingTurn(content);
2762
+ }
2763
+ // Adopt mode write:
2764
+ // - codex routes through cliAdapter.writeInput so the adapter's
2765
+ // paste-detection delay + Enter-retry + history.jsonl verify
2766
+ // loop handles Codex TUI's "\n treated as Enter" submit
2767
+ // behaviour. Without it, Lark messages get stranded in the
2768
+ // input box (user-reported "卡在输入框中").
2769
+ // - everything else keeps the simple raw sendText+Enter — the
2770
+ // claude-code adopt bridge has its own dual-write recovery
2771
+ // path, and the other CLIs' adopt flows haven't surfaced
2772
+ // this submit-detection issue.
2372
2773
  if (backend) {
2373
- if ('sendText' in backend && 'sendSpecialKeys' in backend) {
2774
+ if (lastInitConfig?.cliId === 'codex' && cliAdapter) {
2775
+ // writeInput is async but we're already inside an async
2776
+ // message handler. Errors are best-effort logged; the bridge
2777
+ // ingest path is unaffected because mark already happened
2778
+ // above (codexBridgeMarkPendingTurn / bridgeMarkPendingTurn).
2779
+ try {
2780
+ const result = await cliAdapter.writeInput(backend, content);
2781
+ if (result?.cliSessionId) {
2782
+ persistCliSessionId(result.cliSessionId);
2783
+ codexBridgeNotifyCliSessionId(result.cliSessionId);
2784
+ }
2785
+ if (result && result.submitted === false) {
2786
+ const preview = content.length > 60 ? content.slice(0, 60) + '…' : content;
2787
+ log(`Codex adopt writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
2788
+ send({
2789
+ type: 'user_notify',
2790
+ message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 3 次仍未在 Codex history 中看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
2791
+ });
2792
+ }
2793
+ }
2794
+ catch (err) {
2795
+ log(`Codex adopt writeInput error: ${err.message}`);
2796
+ }
2797
+ }
2798
+ else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
2374
2799
  backend.sendText(content);
2375
2800
  backend.sendSpecialKeys('Enter');
2376
2801
  }