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.
- package/dist/adapters/cli/claude-code.d.ts +9 -3
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +20 -12
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +11 -1
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +13 -9
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.js +10 -9
- package/dist/daemon.js.map +1 -1
- package/dist/services/bridge-rotation-policy.d.ts +139 -0
- package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
- package/dist/services/bridge-rotation-policy.js +125 -0
- package/dist/services/bridge-rotation-policy.js.map +1 -0
- package/dist/services/bridge-turn-queue.d.ts +9 -1
- package/dist/services/bridge-turn-queue.d.ts.map +1 -1
- package/dist/services/bridge-turn-queue.js +9 -2
- package/dist/services/bridge-turn-queue.js.map +1 -1
- package/dist/services/claude-transcript.d.ts +67 -0
- package/dist/services/claude-transcript.d.ts.map +1 -1
- package/dist/services/claude-transcript.js +228 -1
- package/dist/services/claude-transcript.js.map +1 -1
- package/dist/services/codex-bridge-queue.d.ts +56 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -0
- package/dist/services/codex-bridge-queue.js +150 -0
- package/dist/services/codex-bridge-queue.js.map +1 -0
- package/dist/services/codex-transcript.d.ts +68 -0
- package/dist/services/codex-transcript.d.ts.map +1 -0
- package/dist/services/codex-transcript.js +233 -0
- package/dist/services/codex-transcript.js.map +1 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +15 -4
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/worker.js +743 -114
- package/dist/worker.js.map +1 -1
- 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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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} (
|
|
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
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
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
|
-
|
|
367
|
+
bridgeJsonlDir = dirname(matched);
|
|
299
368
|
bridgePendingTail = '';
|
|
300
|
-
//
|
|
301
|
-
// (
|
|
302
|
-
//
|
|
303
|
-
//
|
|
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
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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.
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
|
337
|
-
* events are split by timestamp against `rotationCutoffMs` (the
|
|
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
|
|
341
|
-
* long-history jsonl NOT replay the entire past as one giant
|
|
342
|
-
*
|
|
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.
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
// 'same' is NOT proof
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
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:
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
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
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
1493
|
-
// turns out-of-band.
|
|
1494
|
-
//
|
|
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
|
-
|
|
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
|
-
:
|
|
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 —
|
|
1596
|
-
//
|
|
1597
|
-
//
|
|
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) {
|