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.
- 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 +17 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -1
- package/dist/services/codex-bridge-queue.js +64 -30
- package/dist/services/codex-bridge-queue.js.map +1 -1
- package/dist/services/codex-transcript.d.ts +33 -0
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +71 -1
- package/dist/services/codex-transcript.js.map +1 -1
- 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 +545 -120
- package/dist/worker.js.map +1 -1
- 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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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} (
|
|
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
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
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
|
-
|
|
367
|
+
bridgeJsonlDir = dirname(matched);
|
|
320
368
|
bridgePendingTail = '';
|
|
321
|
-
//
|
|
322
|
-
// (
|
|
323
|
-
//
|
|
324
|
-
//
|
|
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
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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.
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
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
|
|
358
|
-
* events are split by timestamp against `rotationCutoffMs` (the
|
|
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
|
|
362
|
-
* long-history jsonl NOT replay the entire past as one giant
|
|
363
|
-
*
|
|
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.
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// 'same' is NOT proof
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
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:
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
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
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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
|
|
878
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
993
|
-
log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (
|
|
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 (
|
|
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
|
|
1708
|
-
// turns out-of-band.
|
|
1709
|
-
//
|
|
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
|
-
|
|
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
|
-
:
|
|
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 —
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|