botmux 2.9.1 → 2.9.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/README.en.md +140 -76
- package/README.md +134 -75
- package/dist/adapters/backend/pty-backend.d.ts +6 -0
- package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
- package/dist/adapters/backend/pty-backend.js +10 -0
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
- package/dist/adapters/backend/session-backend-selector.js +26 -0
- package/dist/adapters/backend/session-backend-selector.js.map +1 -0
- package/dist/adapters/backend/tmux-backend.d.ts +80 -3
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +301 -49
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +100 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
- package/dist/adapters/backend/tmux-pipe-backend.js +473 -0
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
- package/dist/adapters/cli/aiden.d.ts.map +1 -1
- package/dist/adapters/cli/aiden.js +5 -0
- package/dist/adapters/cli/aiden.js.map +1 -1
- package/dist/adapters/cli/claude-code.d.ts +40 -1
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +470 -49
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/coco.d.ts.map +1 -1
- package/dist/adapters/cli/coco.js +191 -9
- package/dist/adapters/cli/coco.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +94 -17
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/shared-hints.d.ts +2 -2
- package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
- package/dist/adapters/cli/shared-hints.js +7 -5
- package/dist/adapters/cli/shared-hints.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +38 -1
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/autostart.d.ts +14 -0
- package/dist/autostart.d.ts.map +1 -0
- package/dist/autostart.js +357 -0
- package/dist/autostart.js.map +1 -0
- package/dist/bot-registry.d.ts +29 -3
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +91 -12
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/arg-utils.d.ts +11 -0
- package/dist/cli/arg-utils.d.ts.map +1 -0
- package/dist/cli/arg-utils.js +25 -0
- package/dist/cli/arg-utils.js.map +1 -0
- package/dist/cli/create-group-resolver.d.ts +32 -0
- package/dist/cli/create-group-resolver.d.ts.map +1 -0
- package/dist/cli/create-group-resolver.js +70 -0
- package/dist/cli/create-group-resolver.js.map +1 -0
- package/dist/cli/quoted-render.d.ts +30 -0
- package/dist/cli/quoted-render.d.ts.map +1 -0
- package/dist/cli/quoted-render.js +29 -0
- package/dist/cli/quoted-render.js.map +1 -0
- package/dist/cli.js +916 -272
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -8
- package/dist/config.js.map +1 -1
- package/dist/core/command-handler.d.ts +43 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +167 -64
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-events.d.ts +57 -0
- package/dist/core/dashboard-events.d.ts.map +1 -0
- package/dist/core/dashboard-events.js +23 -0
- package/dist/core/dashboard-events.js.map +1 -0
- package/dist/core/dashboard-ipc-server.d.ts +43 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -0
- package/dist/core/dashboard-ipc-server.js +481 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -0
- package/dist/core/dashboard-locate.d.ts +20 -0
- package/dist/core/dashboard-locate.d.ts.map +1 -0
- package/dist/core/dashboard-locate.js +26 -0
- package/dist/core/dashboard-locate.js.map +1 -0
- package/dist/core/dashboard-rows.d.ts +31 -0
- package/dist/core/dashboard-rows.d.ts.map +1 -0
- package/dist/core/dashboard-rows.js +65 -0
- package/dist/core/dashboard-rows.js.map +1 -0
- package/dist/core/inherit-peer.d.ts +14 -0
- package/dist/core/inherit-peer.d.ts.map +1 -0
- package/dist/core/inherit-peer.js +32 -0
- package/dist/core/inherit-peer.js.map +1 -0
- package/dist/core/scheduler.d.ts +24 -0
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +93 -2
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/session-activity.d.ts +3 -0
- package/dist/core/session-activity.d.ts.map +1 -0
- package/dist/core/session-activity.js +20 -0
- package/dist/core/session-activity.js.map +1 -0
- package/dist/core/session-discovery.d.ts +39 -0
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +114 -21
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts +72 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +396 -106
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/types.d.ts +27 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +14 -3
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +72 -3
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +459 -38
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +601 -309
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/aggregator.d.ts +41 -0
- package/dist/dashboard/aggregator.d.ts.map +1 -0
- package/dist/dashboard/aggregator.js +125 -0
- package/dist/dashboard/aggregator.js.map +1 -0
- package/dist/dashboard/auth.d.ts +23 -0
- package/dist/dashboard/auth.d.ts.map +1 -0
- package/dist/dashboard/auth.js +66 -0
- package/dist/dashboard/auth.js.map +1 -0
- package/dist/dashboard/operator-selector.d.ts +20 -0
- package/dist/dashboard/operator-selector.d.ts.map +1 -0
- package/dist/dashboard/operator-selector.js +39 -0
- package/dist/dashboard/operator-selector.js.map +1 -0
- package/dist/dashboard/registry.d.ts +35 -0
- package/dist/dashboard/registry.d.ts.map +1 -0
- package/dist/dashboard/registry.js +74 -0
- package/dist/dashboard/registry.js.map +1 -0
- package/dist/dashboard/web/app.d.ts +2 -0
- package/dist/dashboard/web/app.d.ts.map +1 -0
- package/dist/dashboard/web/app.js +45 -0
- package/dist/dashboard/web/app.js.map +1 -0
- package/dist/dashboard/web/bot-defaults.d.ts +2 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
- package/dist/dashboard/web/bot-defaults.js +201 -0
- package/dist/dashboard/web/bot-defaults.js.map +1 -0
- package/dist/dashboard/web/groups.d.ts +16 -0
- package/dist/dashboard/web/groups.d.ts.map +1 -0
- package/dist/dashboard/web/groups.js +584 -0
- package/dist/dashboard/web/groups.js.map +1 -0
- package/dist/dashboard/web/schedules.d.ts +2 -0
- package/dist/dashboard/web/schedules.d.ts.map +1 -0
- package/dist/dashboard/web/schedules.js +105 -0
- package/dist/dashboard/web/schedules.js.map +1 -0
- package/dist/dashboard/web/sessions.d.ts +2 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -0
- package/dist/dashboard/web/sessions.js +374 -0
- package/dist/dashboard/web/sessions.js.map +1 -0
- package/dist/dashboard/web/store.d.ts +23 -0
- package/dist/dashboard/web/store.d.ts.map +1 -0
- package/dist/dashboard/web/store.js +82 -0
- package/dist/dashboard/web/store.js.map +1 -0
- package/dist/dashboard-web/app.js +263 -0
- package/dist/dashboard-web/index.html +23 -0
- package/dist/dashboard-web/style.css +93 -0
- package/dist/dashboard.d.ts +2 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +639 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/im/lark/card-builder.d.ts +18 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +70 -9
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +123 -109
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +35 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +114 -11
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +88 -6
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +398 -62
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/forwarded-renderer.d.ts +23 -0
- package/dist/im/lark/forwarded-renderer.d.ts.map +1 -0
- package/dist/im/lark/forwarded-renderer.js +105 -0
- package/dist/im/lark/forwarded-renderer.js.map +1 -0
- package/dist/im/lark/md-card.d.ts +73 -0
- package/dist/im/lark/md-card.d.ts.map +1 -0
- package/dist/im/lark/md-card.js +332 -0
- package/dist/im/lark/md-card.js.map +1 -0
- package/dist/im/lark/merge-forward.d.ts +32 -0
- package/dist/im/lark/merge-forward.d.ts.map +1 -0
- package/dist/im/lark/merge-forward.js +110 -0
- package/dist/im/lark/merge-forward.js.map +1 -0
- package/dist/im/lark/message-parser.d.ts +9 -3
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +48 -13
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/quote-hint.d.ts +18 -0
- package/dist/im/lark/quote-hint.d.ts.map +1 -0
- package/dist/im/lark/quote-hint.js +23 -0
- package/dist/im/lark/quote-hint.js.map +1 -0
- package/dist/services/bridge-fallback-gate.d.ts +42 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
- package/dist/services/bridge-fallback-gate.js +12 -0
- package/dist/services/bridge-fallback-gate.js.map +1 -0
- 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 +154 -0
- package/dist/services/bridge-turn-queue.d.ts.map +1 -0
- package/dist/services/bridge-turn-queue.js +316 -0
- package/dist/services/bridge-turn-queue.js.map +1 -0
- package/dist/services/chat-first-seen-store.d.ts +27 -0
- package/dist/services/chat-first-seen-store.d.ts.map +1 -0
- package/dist/services/chat-first-seen-store.js +114 -0
- package/dist/services/chat-first-seen-store.js.map +1 -0
- package/dist/services/claude-transcript.d.ts +268 -0
- package/dist/services/claude-transcript.d.ts.map +1 -0
- package/dist/services/claude-transcript.js +798 -0
- package/dist/services/claude-transcript.js.map +1 -0
- package/dist/services/coco-transcript.d.ts +35 -0
- package/dist/services/coco-transcript.d.ts.map +1 -0
- package/dist/services/coco-transcript.js +192 -0
- package/dist/services/coco-transcript.js.map +1 -0
- 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 +84 -0
- package/dist/services/codex-transcript.d.ts.map +1 -0
- package/dist/services/codex-transcript.js +298 -0
- package/dist/services/codex-transcript.js.map +1 -0
- package/dist/services/group-creator.d.ts +23 -0
- package/dist/services/group-creator.d.ts.map +1 -0
- package/dist/services/group-creator.js +75 -0
- package/dist/services/group-creator.js.map +1 -0
- package/dist/services/groups-store.d.ts +98 -0
- package/dist/services/groups-store.d.ts.map +1 -0
- package/dist/services/groups-store.js +213 -0
- package/dist/services/groups-store.js.map +1 -0
- package/dist/services/oncall-store.d.ts +80 -8
- package/dist/services/oncall-store.d.ts.map +1 -1
- package/dist/services/oncall-store.js +265 -55
- package/dist/services/oncall-store.js.map +1 -1
- package/dist/services/project-scanner.d.ts +1 -2
- package/dist/services/project-scanner.d.ts.map +1 -1
- package/dist/services/project-scanner.js +118 -68
- package/dist/services/project-scanner.js.map +1 -1
- package/dist/services/schedule-store.d.ts +5 -0
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +77 -1
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/services/session-store.d.ts +22 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +62 -4
- package/dist/services/session-store.js.map +1 -1
- package/dist/setup/bots-store.d.ts +3 -0
- package/dist/setup/bots-store.d.ts.map +1 -0
- package/dist/setup/bots-store.js +24 -0
- package/dist/setup/bots-store.js.map +1 -0
- package/dist/setup/detect-platform.d.ts +14 -0
- package/dist/setup/detect-platform.d.ts.map +1 -0
- package/dist/setup/detect-platform.js +139 -0
- package/dist/setup/detect-platform.js.map +1 -0
- package/dist/setup/ensure-fonts.d.ts +13 -0
- package/dist/setup/ensure-fonts.d.ts.map +1 -0
- package/dist/setup/ensure-fonts.js +225 -0
- package/dist/setup/ensure-fonts.js.map +1 -0
- package/dist/setup/ensure-tmux.d.ts +60 -0
- package/dist/setup/ensure-tmux.d.ts.map +1 -0
- package/dist/setup/ensure-tmux.js +236 -0
- package/dist/setup/ensure-tmux.js.map +1 -0
- package/dist/setup/index.d.ts +9 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +46 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/setup/lark-scopes.json +301 -0
- package/dist/setup/register-app.d.ts +52 -0
- package/dist/setup/register-app.d.ts.map +1 -0
- package/dist/setup/register-app.js +91 -0
- package/dist/setup/register-app.js.map +1 -0
- package/dist/setup/verify-permissions.d.ts +115 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -0
- package/dist/setup/verify-permissions.js +207 -0
- package/dist/setup/verify-permissions.js.map +1 -0
- package/dist/skills/definitions.d.ts +4 -0
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +133 -19
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/installer.d.ts +3 -1
- package/dist/skills/installer.d.ts.map +1 -1
- package/dist/skills/installer.js +18 -3
- package/dist/skills/installer.js.map +1 -1
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +6 -0
- package/dist/utils/bot-routing.d.ts.map +1 -0
- package/dist/utils/bot-routing.js +50 -0
- package/dist/utils/bot-routing.js.map +1 -0
- package/dist/utils/file-lock.d.ts +2 -0
- package/dist/utils/file-lock.d.ts.map +1 -0
- package/dist/utils/file-lock.js +114 -0
- package/dist/utils/file-lock.js.map +1 -0
- package/dist/utils/font-installer.js +1 -1
- package/dist/utils/font-installer.js.map +1 -1
- package/dist/utils/idle-detector.d.ts +6 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +25 -4
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +60 -8
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/render-dimensions.d.ts +48 -0
- package/dist/utils/render-dimensions.d.ts.map +1 -0
- package/dist/utils/render-dimensions.js +55 -0
- package/dist/utils/render-dimensions.js.map +1 -0
- package/dist/utils/screen-analyzer.d.ts.map +1 -1
- package/dist/utils/screen-analyzer.js +24 -0
- package/dist/utils/screen-analyzer.js.map +1 -1
- package/dist/utils/screenshot-renderer.d.ts.map +1 -1
- package/dist/utils/screenshot-renderer.js +67 -23
- package/dist/utils/screenshot-renderer.js.map +1 -1
- package/dist/utils/terminal-renderer.d.ts +16 -0
- package/dist/utils/terminal-renderer.d.ts.map +1 -1
- package/dist/utils/terminal-renderer.js +40 -23
- package/dist/utils/terminal-renderer.js.map +1 -1
- package/dist/utils/transient-snapshot.d.ts +28 -0
- package/dist/utils/transient-snapshot.d.ts.map +1 -0
- package/dist/utils/transient-snapshot.js +96 -0
- package/dist/utils/transient-snapshot.js.map +1 -0
- package/dist/worker.js +2220 -83
- package/dist/worker.js.map +1 -1
- package/package.json +12 -5
package/dist/worker.js
CHANGED
|
@@ -13,18 +13,30 @@
|
|
|
13
13
|
* 7. On 'restart', kills CLI and re-spawns with --resume
|
|
14
14
|
*/
|
|
15
15
|
import { randomBytes } from 'node:crypto';
|
|
16
|
-
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync, readlinkSync, readFileSync, watch as fsWatch } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
|
+
import { drainTranscript, joinAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
|
|
19
|
+
import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
|
|
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, extractLastCodexTurn } from './services/codex-transcript.js';
|
|
24
|
+
import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
|
|
25
|
+
import { dirname } from 'node:path';
|
|
18
26
|
import { createServer as createHttpServer } from 'node:http';
|
|
19
27
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
20
28
|
import { TerminalRenderer } from './utils/terminal-renderer.js';
|
|
29
|
+
import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_ROWS, MIN_RENDER_COLS, MIN_RENDER_ROWS, clamp, resolveRenderDimensions, } from './utils/render-dimensions.js';
|
|
21
30
|
import { createCliAdapterSync } from './adapters/cli/registry.js';
|
|
22
|
-
import { claudeJsonlPathForSession } from './adapters/cli/claude-code.js';
|
|
23
|
-
import { PtyBackend } from './adapters/backend/pty-backend.js';
|
|
31
|
+
import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds } from './adapters/cli/claude-code.js';
|
|
24
32
|
import { TmuxBackend } from './adapters/backend/tmux-backend.js';
|
|
33
|
+
import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
|
|
34
|
+
import { selectSessionBackend } from './adapters/backend/session-backend-selector.js';
|
|
35
|
+
import { tmuxEnv } from './setup/ensure-tmux.js';
|
|
25
36
|
import { IdleDetector } from './utils/idle-detector.js';
|
|
26
37
|
import { ScreenAnalyzer } from './utils/screen-analyzer.js';
|
|
27
38
|
import { captureToPng } from './utils/screenshot-renderer.js';
|
|
39
|
+
import { snapshotToPng, snapshotToText } from './utils/transient-snapshot.js';
|
|
28
40
|
import { uploadImageBuffer } from './utils/lark-upload.js';
|
|
29
41
|
import { config } from './config.js';
|
|
30
42
|
import * as sessionStore from './services/session-store.js';
|
|
@@ -36,6 +48,10 @@ let backend = null;
|
|
|
36
48
|
let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
|
|
37
49
|
let idleDetector = null;
|
|
38
50
|
let isTmuxMode = false;
|
|
51
|
+
/** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
|
|
52
|
+
* web-terminal updates flow through the shared scrollback fan-out instead
|
|
53
|
+
* of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
|
|
54
|
+
let isPipeMode = false;
|
|
39
55
|
let httpServer = null;
|
|
40
56
|
let wss = null;
|
|
41
57
|
const wsClients = new Set();
|
|
@@ -51,15 +67,1571 @@ let isPromptReady = false;
|
|
|
51
67
|
/** Mutex for async flushPending — prevents concurrent flush loops. */
|
|
52
68
|
let isFlushing = false;
|
|
53
69
|
const pendingMessages = [];
|
|
70
|
+
// ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// In bridge mode the daemon adopted an existing CLI session that we do NOT
|
|
73
|
+
// own; the model never sees botmux. We harvest assistant turns by tailing
|
|
74
|
+
// Claude Code's transcript JSONL and forward only the bytes appended after
|
|
75
|
+
// each Lark-driven user turn — never the historical content present at
|
|
76
|
+
// attach time, never local-terminal-driven turns.
|
|
77
|
+
//
|
|
78
|
+
// Attribution lives in BridgeTurnQueue; this file only manages the
|
|
79
|
+
// fs.watch wakeup, byte-offset bookkeeping, lazy baseline, and IPC emit.
|
|
80
|
+
let bridgeJsonlPath;
|
|
81
|
+
/** Directory enclosing bridgeJsonlPath. We poll this dir for newer jsonl
|
|
82
|
+
* files so the bridge follows `/clear` / `/resume` in the user's CLI —
|
|
83
|
+
* those create a brand-new sessionId.jsonl, and a watcher pinned to the
|
|
84
|
+
* original path would silently stop receiving events. */
|
|
85
|
+
let bridgeJsonlDir;
|
|
86
|
+
/** PID + cwd of the adopted Claude Code process. Lets every poll re-read
|
|
87
|
+
* ~/.claude/sessions/<pid>.json — Claude's own pid-state record. Empirical
|
|
88
|
+
* scope (Claude Code 2.1.123): the pid file's `sessionId` is set ONCE at
|
|
89
|
+
* process start. `--resume` (which spawns a new process) does rotate the
|
|
90
|
+
* recorded sessionId; `/clear` / in-pane `/resume` do NOT — those rely on
|
|
91
|
+
* the fingerprint fallback (which anchors on a pending Lark turn) to
|
|
92
|
+
* follow the new jsonl. */
|
|
93
|
+
let bridgeCliPid;
|
|
94
|
+
let bridgeCliCwd;
|
|
95
|
+
/** Last sessionId we observed via the pid resolver — used to detect
|
|
96
|
+
* rotations cheaply (string compare instead of stat()ing every jsonl). */
|
|
97
|
+
let bridgeObservedCliSessionId;
|
|
98
|
+
/** Sibling-pane hijack guard state.
|
|
99
|
+
*
|
|
100
|
+
* Every sessionId we have evidence of belonging to our adopted Claude pid:
|
|
101
|
+
* initial attach path, pid resolver hits, `/proc/<pid>/fd` hits. The
|
|
102
|
+
* fingerprint fallback's two-phase decision (`decideFingerprintSwitch`
|
|
103
|
+
* in `src/services/bridge-rotation-policy.ts`) consumes this set:
|
|
104
|
+
* Phase 1 substring match runs against trusted sids only; Phase 2
|
|
105
|
+
* exact-content recovery runs against UNTRUSTED sids only. Unknown
|
|
106
|
+
* sessionIds never pass Phase 1 even when the file looks freshly
|
|
107
|
+
* created — freshness/timestamp signals cannot prove pane ownership
|
|
108
|
+
* across siblings in the same project dir. */
|
|
109
|
+
const bridgeKnownSessionIds = new Set();
|
|
110
|
+
/** Set when the fingerprint fallback accepts a candidate whose sessionId
|
|
111
|
+
* doesn't match the pid file's current sessionId (Claude's pid file isn't
|
|
112
|
+
* refreshed by in-pane `/clear`, so it keeps reporting the spawn-time sid
|
|
113
|
+
* even after the user rotated). Suppresses pid resolver from pulling the
|
|
114
|
+
* watcher back to that spawn-time sid every tick. Cleared when pid file
|
|
115
|
+
* reports a NEW sid (fresh `--resume` / spawn), at which point a real
|
|
116
|
+
* rotation has happened and we should follow it. */
|
|
117
|
+
let bridgeStalePidStateSessionId;
|
|
118
|
+
/** Old jsonl paths we keep polling AFTER a rotation switched
|
|
119
|
+
* bridgeJsonlPath away — needed when a started turn was stamped with the
|
|
120
|
+
* old path but its assistant text hasn't been written yet. We continue to
|
|
121
|
+
* drain each entry on every tick so trailing appends to that file land in
|
|
122
|
+
* the queue against the right turn, and prune the entry once no pending
|
|
123
|
+
* turn references the path anymore. */
|
|
124
|
+
const bridgeSecondaryPaths = new Map(); // path → offset
|
|
125
|
+
let bridgeOffset = 0;
|
|
126
|
+
let bridgePendingTail = '';
|
|
127
|
+
const bridgeQueue = new BridgeTurnQueue();
|
|
128
|
+
let bridgeWatcher = null;
|
|
129
|
+
let bridgeFallbackTimer = null;
|
|
130
|
+
/** True once we successfully baselined the transcript file. Until then,
|
|
131
|
+
* any data we see is treated as history — absorbed into the queue's seen
|
|
132
|
+
* set without being attributed to a pending Lark turn. This protects the
|
|
133
|
+
* first Lark turn from inheriting historical lines if Claude Code creates
|
|
134
|
+
* the JSONL file *after* attach. */
|
|
135
|
+
let bridgeBaselineDone = false;
|
|
136
|
+
/** Once-per-attach flag so a re-baseline after fs.watch lazy-fire doesn't
|
|
137
|
+
* re-send the preamble. Reset only when the bridge teardown happens. */
|
|
138
|
+
let bridgePreambleSent = false;
|
|
139
|
+
// ─── Codex bridge state ──────────────────────────────────────────────────
|
|
140
|
+
//
|
|
141
|
+
// Parallel to the Claude bridge above. Codex's transcript layout is
|
|
142
|
+
// different enough (separate file location, different event schema) that
|
|
143
|
+
// trying to share storage / readers would obscure both — so we keep state
|
|
144
|
+
// independent. Marker file (`<DATA_DIR>/turn-sends/<sid>.jsonl`) and the
|
|
145
|
+
// gate function are CLI-agnostic and shared.
|
|
146
|
+
let codexBridgeRolloutPath;
|
|
147
|
+
let codexBridgeOffset = 0;
|
|
148
|
+
let codexBridgePendingTail = '';
|
|
149
|
+
let codexBridgeBaselineDone = false;
|
|
150
|
+
const codexBridgeQueue = new CodexBridgeQueue();
|
|
151
|
+
let codexBridgeWatcher = null;
|
|
152
|
+
let codexBridgeTimer = null;
|
|
153
|
+
/** Codex sessionId we received via writeInput but haven't yet resolved a
|
|
154
|
+
* rollout file for. The poller keeps retrying — the file appears on
|
|
155
|
+
* Codex's first user submit, but with some race delay after our submit
|
|
156
|
+
* returns. Cleared once attached. */
|
|
157
|
+
let codexBridgePendingSessionId;
|
|
158
|
+
/** Adopt-only: PID of the externally-running Codex process. Used by the
|
|
159
|
+
* poller to fall back to /proc/<pid>/fd discovery when sessionId is
|
|
160
|
+
* unknown (e.g. discovery probe missed the rollout fd). */
|
|
161
|
+
let codexAdoptPendingPid;
|
|
162
|
+
/** Adopt-only: wall-clock millis at adopt-spawn time. Late-attach uses
|
|
163
|
+
* this as the cutoff for splitting an existing rollout into "history"
|
|
164
|
+
* (absorb) vs "live" (ingest) — so events the user produced AFTER adopt
|
|
165
|
+
* but BEFORE the rollout was located still reach the Lark thread. 5s
|
|
166
|
+
* skew tolerance is applied on top, mirroring the Lark/Claude bridges. */
|
|
167
|
+
let codexAdoptStartMs;
|
|
168
|
+
/** Adopt-only: 一次性发送的 "/adopt 前最后一轮" preamble 是否已经触发过。
|
|
169
|
+
* codexBridgeAttach 在 split-live 分支会查 history 取最后一对 user/assistant
|
|
170
|
+
* 发给 daemon —— late-attach poller 也会反复走这条分支(每秒一次),所以
|
|
171
|
+
* 必须有标志位防重发。镜像 claude 那套 bridgePreambleSent 的角色。 */
|
|
172
|
+
let codexBridgePreambleSent = false;
|
|
173
|
+
/** Cap the preamble text so an extremely long previous turn doesn't blow
|
|
174
|
+
* past Lark's per-message limit. The user only needs enough to recall
|
|
175
|
+
* context, not the entire transcript. */
|
|
176
|
+
const PREAMBLE_USER_MAX = 500;
|
|
177
|
+
const PREAMBLE_ASSISTANT_MAX = 4000;
|
|
178
|
+
/** Same intent as the preamble caps, but for live local-terminal turns
|
|
179
|
+
* forwarded to Lark. A long paste typed locally shouldn't be allowed to
|
|
180
|
+
* blow past Lark's per-message limit. */
|
|
181
|
+
const LOCAL_TURN_USER_MAX = 1000;
|
|
182
|
+
const LOCAL_TURN_ASSISTANT_MAX = 8000;
|
|
183
|
+
function truncatePreambleText(text, max) {
|
|
184
|
+
if (text.length <= max)
|
|
185
|
+
return text;
|
|
186
|
+
return text.slice(0, max) + '…';
|
|
187
|
+
}
|
|
188
|
+
/** Prepare a local-turn `final_output` payload. The daemon owns the card
|
|
189
|
+
* chrome (label/quote/markdown body), so we ship the user prompt and
|
|
190
|
+
* assistant text as separate fields — see card-builder `buildContextualReplyCard`.
|
|
191
|
+
* Returns null when both sides are empty so the caller can skip the emit. */
|
|
192
|
+
function formatLocalTurnFields(userText, assistantText) {
|
|
193
|
+
const u = truncatePreambleText(userText.trim(), LOCAL_TURN_USER_MAX);
|
|
194
|
+
const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
|
|
195
|
+
if (!u && !a)
|
|
196
|
+
return null;
|
|
197
|
+
return { userText: u, content: a };
|
|
198
|
+
}
|
|
199
|
+
/** Same as `formatLocalTurnFields` but for HEADLESS local turns — daemon
|
|
200
|
+
* restart cut off an in-flight model stream so we have an assistant side
|
|
201
|
+
* with no resolvable user prompt. */
|
|
202
|
+
function formatHeadlessLocalTurnContent(assistantText) {
|
|
203
|
+
const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
|
|
204
|
+
return a || null;
|
|
205
|
+
}
|
|
206
|
+
// ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
|
|
207
|
+
//
|
|
208
|
+
// `botmux send` (cli.ts cmdSend) appends a line `{sentAtMs, messageId}\n` to
|
|
209
|
+
// `<DATA_DIR>/turn-sends/<sid>.jsonl` every time the model successfully posts
|
|
210
|
+
// a reply to its OWN session thread. The worker reads these markers at idle
|
|
211
|
+
// and suppresses transcript-driven final_output for any turn whose time
|
|
212
|
+
// window already contains a send — i.e. the model didn't forget, no fallback
|
|
213
|
+
// needed. Append-only over a shared file (instead of a per-turn marker) is
|
|
214
|
+
// type-ahead safe: type-ahead'd turns each have their own [markTimeMs,
|
|
215
|
+
// nextTurn.markTimeMs) window, and a stray send only fills its own bucket.
|
|
216
|
+
function bridgeMarkerPath() {
|
|
217
|
+
if (!process.env.SESSION_DATA_DIR || !sessionId)
|
|
218
|
+
return undefined;
|
|
219
|
+
return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
|
|
220
|
+
}
|
|
221
|
+
function readSendMarkers() {
|
|
222
|
+
const path = bridgeMarkerPath();
|
|
223
|
+
if (!path || !existsSync(path))
|
|
224
|
+
return [];
|
|
225
|
+
try {
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
228
|
+
if (!line.trim())
|
|
229
|
+
continue;
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(line);
|
|
232
|
+
if (typeof parsed?.sentAtMs === 'number')
|
|
233
|
+
out.push(parsed);
|
|
234
|
+
}
|
|
235
|
+
catch { /* skip malformed line */ }
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
log(`Bridge marker read failed: ${err.message}`);
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function clearSendMarkers() {
|
|
245
|
+
const path = bridgeMarkerPath();
|
|
246
|
+
if (!path)
|
|
247
|
+
return;
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(path);
|
|
250
|
+
}
|
|
251
|
+
catch { /* already gone or fs.unavailable; not fatal */ }
|
|
252
|
+
}
|
|
253
|
+
function maybeEmitAdoptPreamble(events) {
|
|
254
|
+
// Preamble is an /adopt-only signal: it tells the user "here's the last
|
|
255
|
+
// turn from the Claude session you just attached to, so the Lark thread
|
|
256
|
+
// has context to continue from". In non-adopt sessions the user IS the
|
|
257
|
+
// Lark thread (every turn was already pushed there as a card), so
|
|
258
|
+
// surfacing the last turn again on daemon restart is just noise.
|
|
259
|
+
if (!lastInitConfig?.adoptMode)
|
|
260
|
+
return;
|
|
261
|
+
// Same logic for /adopt sessions restored after a daemon restart: the
|
|
262
|
+
// Lark thread already has every prior turn pushed as cards, AND the
|
|
263
|
+
// baseline jsonl persisted in session metadata may be stale (Claude
|
|
264
|
+
// could have /clear'd since the original /adopt), so a preamble here
|
|
265
|
+
// would surface old, out-of-context content.
|
|
266
|
+
if (lastInitConfig?.adoptRestoredFromMetadata)
|
|
267
|
+
return;
|
|
268
|
+
if (bridgePreambleSent)
|
|
269
|
+
return;
|
|
270
|
+
const turn = extractLastAssistantTurn(events);
|
|
271
|
+
if (!turn)
|
|
272
|
+
return;
|
|
273
|
+
bridgePreambleSent = true;
|
|
274
|
+
send({
|
|
275
|
+
type: 'adopt_preamble',
|
|
276
|
+
userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
|
|
277
|
+
assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
|
|
278
|
+
});
|
|
279
|
+
log('Bridge adopt preamble emitted (last completed turn from baseline)');
|
|
280
|
+
}
|
|
281
|
+
/** Codex / CoCo 镜像版:split-live 攒齐 history 后挑最后一对 user/assistant_final
|
|
282
|
+
* 发回 daemon 渲染成 "📜 /adopt 前最后一轮" 卡片。语义、跳过条件、字数截断都
|
|
283
|
+
* 对齐 maybeEmitAdoptPreamble;区别只在事件取出方式(codex/coco 是结构化
|
|
284
|
+
* event,不需要走 claude 那套 jsonl turn assembly)。 */
|
|
285
|
+
function maybeEmitCodexAdoptPreamble(history) {
|
|
286
|
+
if (!lastInitConfig?.adoptMode)
|
|
287
|
+
return;
|
|
288
|
+
if (lastInitConfig?.adoptRestoredFromMetadata)
|
|
289
|
+
return;
|
|
290
|
+
if (codexBridgePreambleSent)
|
|
291
|
+
return;
|
|
292
|
+
const turn = extractLastCodexTurn(history);
|
|
293
|
+
if (!turn)
|
|
294
|
+
return;
|
|
295
|
+
if (!turn.userText.trim() && !turn.assistantText.trim())
|
|
296
|
+
return;
|
|
297
|
+
codexBridgePreambleSent = true;
|
|
298
|
+
send({
|
|
299
|
+
type: 'adopt_preamble',
|
|
300
|
+
userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
|
|
301
|
+
assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
|
|
302
|
+
});
|
|
303
|
+
log('Codex bridge adopt preamble emitted (last completed turn from split-live history)');
|
|
304
|
+
}
|
|
305
|
+
/** Extract the sessionId from a Claude jsonl path and add it to the
|
|
306
|
+
* known-sid set. Validates the filename against Claude's UUID-shaped
|
|
307
|
+
* sessionId pattern so non-Claude jsonls in the project dir (accidental
|
|
308
|
+
* drops, third-party tooling) can't poison the trust set. No-op on
|
|
309
|
+
* parse failure. */
|
|
310
|
+
function bridgeRememberSessionIdForPath(path) {
|
|
311
|
+
if (!path)
|
|
312
|
+
return;
|
|
313
|
+
const sid = sessionIdFromJsonlPath(path);
|
|
314
|
+
if (!SESSION_ID_FILENAME_RE.test(sid))
|
|
315
|
+
return;
|
|
316
|
+
bridgeKnownSessionIds.add(sid);
|
|
317
|
+
}
|
|
318
|
+
/** Cheap per-tick probe: read /proc/<bridgeCliPid>/fd and add every jsonl
|
|
319
|
+
* the adopted Claude pid currently has open into the known-sid set. fd
|
|
320
|
+
* observation is intermittent (Claude opens-writes-closes per event), so
|
|
321
|
+
* running this every tick raises our chances of catching a post-/clear
|
|
322
|
+
* sessionId before the user's next Lark message arrives. No-op when there
|
|
323
|
+
* is no pid or /proc isn't available. */
|
|
324
|
+
function bridgeProbeOpenSessionIds() {
|
|
325
|
+
if (bridgeCliPid === undefined || !bridgeJsonlDir)
|
|
326
|
+
return;
|
|
327
|
+
const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
|
|
328
|
+
for (const path of opened)
|
|
329
|
+
bridgeRememberSessionIdForPath(path);
|
|
330
|
+
}
|
|
331
|
+
function bridgeAbsorbBaseline() {
|
|
332
|
+
if (!bridgeJsonlPath)
|
|
333
|
+
return;
|
|
334
|
+
const result = drainTranscript(bridgeJsonlPath, 0);
|
|
335
|
+
bridgeOffset = result.newOffset;
|
|
336
|
+
bridgePendingTail = result.pendingTail;
|
|
337
|
+
bridgeQueue.absorb(result.events);
|
|
338
|
+
bridgeBaselineDone = true;
|
|
339
|
+
// After absorb (uuids registered as seen so they won't re-emit as a Lark
|
|
340
|
+
// turn), surface the last completed user/assistant exchange to Lark as a
|
|
341
|
+
// one-shot preamble — but only for real /adopt sessions. Non-adopt
|
|
342
|
+
// claude-code fallback bridge also uses baseline-existing on daemon
|
|
343
|
+
// restart/resume; it must not emit the "/adopt 前最后一轮" message.
|
|
344
|
+
if (lastInitConfig?.adoptMode)
|
|
345
|
+
maybeEmitAdoptPreamble(result.events);
|
|
346
|
+
}
|
|
347
|
+
/** Record `bridgeStalePidStateSessionId` if the pid file's current sid
|
|
348
|
+
* disagrees with the just-accepted candidate's sid. Stops the next pid
|
|
349
|
+
* resolver tick from pulling the watcher back to the stale spawn-time
|
|
350
|
+
* path Claude wrote into the pid file — which it never refreshes on
|
|
351
|
+
* in-pane `/clear`. No-op when pid file is unavailable or already
|
|
352
|
+
* agrees. */
|
|
353
|
+
function bridgeMarkStalePidStateForAcceptedSid(acceptedSid) {
|
|
354
|
+
if (bridgeCliPid === undefined || bridgeCliCwd === undefined)
|
|
355
|
+
return;
|
|
356
|
+
const pidResolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
|
|
357
|
+
if (pidResolved && pidResolved.cliSessionId !== acceptedSid) {
|
|
358
|
+
bridgeStalePidStateSessionId = pidResolved.cliSessionId;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Apply a fingerprint-driven switch: drain old path, retire watcher,
|
|
362
|
+
* pivot bridgeJsonlPath to `matched`, split the new path's existing
|
|
363
|
+
* content by `cutoffMs` (history → absorbed into the seen set, live →
|
|
364
|
+
* ingested), and install a new fs.watch. The split-live step is what
|
|
365
|
+
* prevents the "switched into a long-lived /clear file → all prior
|
|
366
|
+
* iTerm-typed turns get re-emitted as 🖥️ 终端本地对话" symptom: any
|
|
367
|
+
* user/assistant events written before the Lark mark are pre-existing
|
|
368
|
+
* pane history, not events to forward. `cutoffMs` should be the same
|
|
369
|
+
* `markTimeMs - 5s` used for the fingerprint scan's lower bound. */
|
|
370
|
+
function bridgeApplyFingerprintSwitch(matched, reason, cutoffMs) {
|
|
371
|
+
// Drain-before-switch: pull in any unread bytes from the old path so a
|
|
372
|
+
// late assistant append doesn't vanish. We do NOT emit here — emission
|
|
373
|
+
// only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
|
|
374
|
+
// would publish a half-finished assistant turn during fs.watch / poll
|
|
375
|
+
// ticks (drainEmittable's contract is "has visible text", not "model
|
|
376
|
+
// finished"). If the drained user/assistant events still need follow-up
|
|
377
|
+
// appends on the old path, retainSecondaryPathIfStillReferenced() keeps
|
|
378
|
+
// the old path in the polling rotation.
|
|
379
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
380
|
+
let postDrainOffset = bridgeOffset;
|
|
381
|
+
try {
|
|
382
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
383
|
+
postDrainOffset = drained.offset;
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
log(`Bridge final-drain on fingerprint switch failed (${err.message}); continuing`);
|
|
387
|
+
}
|
|
388
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
389
|
+
}
|
|
390
|
+
log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (${reason})`);
|
|
391
|
+
if (bridgeWatcher) {
|
|
392
|
+
try {
|
|
393
|
+
bridgeWatcher.close();
|
|
394
|
+
}
|
|
395
|
+
catch { /* ignore */ }
|
|
396
|
+
bridgeWatcher = null;
|
|
397
|
+
}
|
|
398
|
+
// Critically: do NOT clear pending turns. The switch was triggered by
|
|
399
|
+
// the FIRST pending turn already living in `matched`, so the immediate
|
|
400
|
+
// next ingest from offset 0 will find that user event and start the
|
|
401
|
+
// turn. Clearing here would race-drop exactly the message we're
|
|
402
|
+
// trying to deliver.
|
|
403
|
+
bridgeJsonlPath = matched;
|
|
404
|
+
bridgeJsonlDir = dirname(matched);
|
|
405
|
+
bridgePendingTail = '';
|
|
406
|
+
// Split-live: drain `matched` from offset 0, partition by cutoffMs.
|
|
407
|
+
// History (pre-mark) is absorbed into the seen set so the iTerm-side
|
|
408
|
+
// turns the user accumulated before this Lark message DON'T re-emit
|
|
409
|
+
// as "🖥️ 终端本地对话" cards. Live (post-mark) goes through ingest
|
|
410
|
+
// so the Lark fingerprint can start its turn. Mirrors what
|
|
411
|
+
// performRotationSwitch already does for fd-rotation rotations.
|
|
412
|
+
const drained = drainTranscript(matched, 0);
|
|
413
|
+
bridgeOffset = drained.newOffset;
|
|
414
|
+
bridgePendingTail = drained.pendingTail;
|
|
415
|
+
const { history, live } = splitTranscriptEventsByCutoff(drained.events, cutoffMs);
|
|
416
|
+
bridgeQueue.absorb(history);
|
|
417
|
+
if (live.length > 0)
|
|
418
|
+
bridgeQueue.ingest(live, matched);
|
|
419
|
+
bridgeBaselineDone = true;
|
|
420
|
+
log(`Bridge fingerprint switch split: ${history.length} historical events absorbed, ${live.length} live events ingested (cutoff=${cutoffMs})`);
|
|
421
|
+
bridgeRememberSessionIdForPath(matched);
|
|
422
|
+
bridgeMarkStalePidStateForAcceptedSid(sessionIdFromJsonlPath(matched));
|
|
423
|
+
try {
|
|
424
|
+
bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
|
|
425
|
+
try {
|
|
426
|
+
bridgeIngest();
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** Detect /clear / /resume: when Claude Code starts a new session in the
|
|
438
|
+
* user's pane it writes to a brand-new sessionId.jsonl. Two-phase scan:
|
|
439
|
+
*
|
|
440
|
+
* - Phase 1 (known-sid substring): cheap path for trusted candidates
|
|
441
|
+
* only. Same content fingerprint substring search as before — safe
|
|
442
|
+
* here because we've gated it on the pid-derived trust set, so a
|
|
443
|
+
* sibling pane in the same project dir (different sessionId) can
|
|
444
|
+
* never be the match even when its content includes the fingerprint.
|
|
445
|
+
*
|
|
446
|
+
* - Phase 2 (unknown-sid exact-content recovery): in-pane `/clear`
|
|
447
|
+
* creates a new sessionId Claude does NOT write into its pid file.
|
|
448
|
+
* If the fd probe didn't catch the brief open window, the new sid is
|
|
449
|
+
* untrusted and Phase 1 rejects it. Phase 2 falls back to scanning
|
|
450
|
+
* every UNTRUSTED candidate jsonl for a user/queue event whose
|
|
451
|
+
* NORMALISED content equals our just-marked Lark message in full
|
|
452
|
+
* (not a substring) — strong enough that "test" doesn't false-match
|
|
453
|
+
* "run tests". When exactly one untrusted candidate matches, accept
|
|
454
|
+
* it; when multiple match, abstain and surface an unambiguous log
|
|
455
|
+
* line so the user can take recovery action.
|
|
456
|
+
*
|
|
457
|
+
* Pending turns are preserved across the switch so the next ingest
|
|
458
|
+
* can match and start the turn in the new file. */
|
|
459
|
+
/** Per-fingerprint rate limit for the full-directory fingerprint scan.
|
|
460
|
+
* Without this, a wedged pending turn (e.g. writeInput's Enter eaten by a
|
|
461
|
+
* Claude TUI prompt so the user line never lands in any jsonl) drives this
|
|
462
|
+
* function every 1s from the fallback timer and every idle tick — each
|
|
463
|
+
* call reads the trailing 1MB of every jsonl in the project dir (hundreds
|
|
464
|
+
* of files, 100s of MB total), pegging the worker at 99% CPU until
|
|
465
|
+
* restart. The cleanup paths in #1/#2 (dropPendingTurn / pruneExpired)
|
|
466
|
+
* are what actually *removes* the stuck mark; this rate limit just keeps
|
|
467
|
+
* the windows in between cheap.
|
|
468
|
+
*
|
|
469
|
+
* 10s is much wider than the milliseconds Claude needs to write a normal
|
|
470
|
+
* user line, but `maybeSwitchBridgeJsonl` is only consulted when the
|
|
471
|
+
* primary jsonl scan in `bridgeIngest` already failed to find the line —
|
|
472
|
+
* i.e. Claude rotated the file via `/clear` / `/resume`. Those rotations
|
|
473
|
+
* happen hours apart in practice, so a 10s detection delay is invisible. */
|
|
474
|
+
const BRIDGE_FINGERPRINT_SCAN_MIN_INTERVAL_MS = 10_000;
|
|
475
|
+
const bridgeFingerprintScanLastMs = new Map();
|
|
476
|
+
/** Pending+unstarted bridge marks expire after this long. Defensive TTL:
|
|
477
|
+
* every known path that creates a mark also has an explicit
|
|
478
|
+
* `dropPendingTurn` path, but TTL guarantees self-healing if a future
|
|
479
|
+
* code path forgets one. 120s is well past Claude's deferred recheck
|
|
480
|
+
* window (20s) and any plausible jsonl-flush delay; the only marks left
|
|
481
|
+
* this long are real failures. */
|
|
482
|
+
const BRIDGE_PENDING_TURN_TTL_MS = 120_000;
|
|
483
|
+
function maybeSwitchBridgeJsonl() {
|
|
484
|
+
if (!bridgeJsonlDir)
|
|
485
|
+
return false;
|
|
486
|
+
const pending = bridgeQueue.peek();
|
|
487
|
+
const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
|
|
488
|
+
if (!candidate || !candidate.contentFingerprint)
|
|
489
|
+
return false;
|
|
490
|
+
// Per-fingerprint rate limit — see BRIDGE_FINGERPRINT_SCAN_MIN_INTERVAL_MS.
|
|
491
|
+
const lastScan = bridgeFingerprintScanLastMs.get(candidate.contentFingerprint);
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
if (lastScan !== undefined && now - lastScan < BRIDGE_FINGERPRINT_SCAN_MIN_INTERVAL_MS) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
bridgeFingerprintScanLastMs.set(candidate.contentFingerprint, now);
|
|
497
|
+
// Bound the search to events written after the turn was marked. Short
|
|
498
|
+
// fingerprints ("hello", "test") would otherwise match old user lines
|
|
499
|
+
// in unrelated sibling jsonls. 5s skew absorbs clock drift between the
|
|
500
|
+
// mark and Claude's transcript write.
|
|
501
|
+
const minEventTimestampMs = candidate.markTimeMs !== undefined
|
|
502
|
+
? candidate.markTimeMs - 5_000
|
|
503
|
+
: undefined;
|
|
504
|
+
const fingerprintScanOptions = {
|
|
505
|
+
excludePath: bridgeJsonlPath,
|
|
506
|
+
includeQueueOperations: true,
|
|
507
|
+
minEventTimestampMs,
|
|
508
|
+
};
|
|
509
|
+
const decision = decideFingerprintSwitch({
|
|
510
|
+
contentFingerprint: candidate.contentFingerprint,
|
|
511
|
+
contentNormalized: candidate.contentNormalized,
|
|
512
|
+
knownSessionIds: bridgeKnownSessionIds,
|
|
513
|
+
findSubstring: (acceptCandidate) => findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
|
|
514
|
+
...fingerprintScanOptions,
|
|
515
|
+
acceptCandidate,
|
|
516
|
+
}),
|
|
517
|
+
findExact: (acceptCandidate) => candidate.contentNormalized
|
|
518
|
+
? findJsonlsContainingExactContent(bridgeJsonlDir, candidate.contentNormalized, {
|
|
519
|
+
...fingerprintScanOptions,
|
|
520
|
+
acceptCandidate,
|
|
521
|
+
})
|
|
522
|
+
: [],
|
|
523
|
+
});
|
|
524
|
+
if (decision.action === 'switch') {
|
|
525
|
+
const reason = decision.reason === 'known-sid-substring'
|
|
526
|
+
? 'known-sid fingerprint match'
|
|
527
|
+
: 'unknown-sid exact-content recovery (in-pane /clear with stale pid file)';
|
|
528
|
+
// Boundary alignment with the fingerprint scanner:
|
|
529
|
+
//
|
|
530
|
+
// scanner.minEventTimestampMs is INCLUSIVE — events with
|
|
531
|
+
// timestamp >= (markTimeMs - 5s) are eligible to start the turn.
|
|
532
|
+
// splitTranscriptEventsByCutoff puts timestamp <= cutoffMs in
|
|
533
|
+
// history (absorbed) and > cutoffMs in live (ingested).
|
|
534
|
+
//
|
|
535
|
+
// If we hand split the same value as the scanner's lower bound, an
|
|
536
|
+
// event AT exactly that timestamp (e.g. the user's just-arrived
|
|
537
|
+
// Lark user event) is matched-eligible by the scanner — driving
|
|
538
|
+
// the switch — but absorbed as history by split, leaving the
|
|
539
|
+
// pending turn unstarted and the message silent. Subtract 1ms to
|
|
540
|
+
// make split's history strictly older than the scanner's
|
|
541
|
+
// eligibility floor.
|
|
542
|
+
const historyCutoffMs = ((candidate.markTimeMs ?? Date.now()) - 5_000) - 1;
|
|
543
|
+
bridgeApplyFingerprintSwitch(decision.path, reason, historyCutoffMs);
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
if (decision.action === 'abstain') {
|
|
547
|
+
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.`);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
/** Last-resort rotation follower for the case where pid resolver returned
|
|
553
|
+
* `'unavailable'` (no /proc, missing/invalid pid file). Originally also
|
|
554
|
+
* ran on `'same'` to catch in-pane `/clear` with no pending Lark turn,
|
|
555
|
+
* but that path is now intentionally dropped — the directory-mtime
|
|
556
|
+
* heuristic in Path 2 below cannot tell our pane's rotation from a
|
|
557
|
+
* sibling Claude pane in the same cwd, and the sibling-pane hijack
|
|
558
|
+
* silently corrupts every multi-pane adopt setup (see
|
|
559
|
+
* `bridge-rotation-policy.ts`). The Lark-message-driven /clear recovery
|
|
560
|
+
* flow (fingerprint fallback) covers the dominant case.
|
|
561
|
+
*
|
|
562
|
+
* Detection priority:
|
|
563
|
+
* 1. Linux first-class: read `/proc/<pid>/fd` and pick the .jsonl the
|
|
564
|
+
* adopted Claude process actually has open. Bound to the real PID
|
|
565
|
+
* — a sibling Claude pane has a different PID and cannot hijack
|
|
566
|
+
* the result. Note: Claude Code opens-writes-closes per event, so
|
|
567
|
+
* this often returns 0 entries between writes; the gate above
|
|
568
|
+
* ensures we still skip Path 2 in that case when pid resolver
|
|
569
|
+
* confirmed our path.
|
|
570
|
+
* 2. Cross-platform fallback: directory-level mtime heuristic, gated
|
|
571
|
+
* on (a) our current jsonl quiet ≥ QUIET_ROTATION_MS, (b) candidate
|
|
572
|
+
* newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Only
|
|
573
|
+
* runs when Path 1 returns 0 entries AND pid resolver was
|
|
574
|
+
* unavailable.
|
|
575
|
+
*
|
|
576
|
+
* When a rotation is detected, the new jsonl is drained from offset 0
|
|
577
|
+
* and events are split by timestamp against `rotationCutoffMs` (the
|
|
578
|
+
* old jsonl's last-write time): events before the cutoff are *history*
|
|
579
|
+
* (absorbed into the seen-set, not emitted), events after are *live*
|
|
580
|
+
* (ingested → local-turn synthesis runs). This is what lets a rotation
|
|
581
|
+
* to a long-history jsonl NOT replay the entire past as one giant
|
|
582
|
+
* local turn.
|
|
583
|
+
*
|
|
584
|
+
* Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
|
|
585
|
+
* also fires `maybeEmitAdoptPreamble`, which on rotation would surface
|
|
586
|
+
* the *previous session's* last turn as if it were a fresh "/adopt 前最
|
|
587
|
+
* 后一轮" preamble. Preamble belongs only to initial attach. */
|
|
588
|
+
const QUIET_ROTATION_MS = 8_000;
|
|
589
|
+
function statSafe(path) {
|
|
590
|
+
try {
|
|
591
|
+
const st = statSync(path);
|
|
592
|
+
if (!st.isFile())
|
|
593
|
+
return null;
|
|
594
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function isPidAlive(pid) {
|
|
601
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
602
|
+
return false;
|
|
603
|
+
try {
|
|
604
|
+
process.kill(pid, 0);
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/** List `.jsonl` files inside `dir` that are currently held open by `pid`.
|
|
612
|
+
* Returns [] on non-Linux platforms or if /proc lookup fails — the caller
|
|
613
|
+
* treats an empty result as "fd info unavailable, fall back to mtime". */
|
|
614
|
+
function findOpenJsonlsForPid(pid, dir) {
|
|
615
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
616
|
+
return [];
|
|
617
|
+
if (process.platform !== 'linux')
|
|
618
|
+
return [];
|
|
619
|
+
let entries;
|
|
620
|
+
try {
|
|
621
|
+
entries = readdirSync(`/proc/${pid}/fd`);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
const out = [];
|
|
627
|
+
for (const name of entries) {
|
|
628
|
+
let target;
|
|
629
|
+
try {
|
|
630
|
+
target = readlinkSync(`/proc/${pid}/fd/${name}`);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (!target.endsWith('.jsonl'))
|
|
636
|
+
continue;
|
|
637
|
+
if (dirname(target) !== dir)
|
|
638
|
+
continue;
|
|
639
|
+
out.push(target);
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
/** Pick the most recently modified path among `paths`. Returns null if
|
|
644
|
+
* none of them stat. */
|
|
645
|
+
function newestPath(paths) {
|
|
646
|
+
let best = null;
|
|
647
|
+
for (const p of paths) {
|
|
648
|
+
const st = statSafe(p);
|
|
649
|
+
if (!st)
|
|
650
|
+
continue;
|
|
651
|
+
if (!best || st.mtimeMs > best.mtimeMs)
|
|
652
|
+
best = { path: p, mtimeMs: st.mtimeMs };
|
|
653
|
+
}
|
|
654
|
+
return best?.path ?? null;
|
|
655
|
+
}
|
|
656
|
+
/** Switch bridgeJsonlPath to `newPath` and split-baseline its existing
|
|
657
|
+
* content: events with timestamp ≤ `cutoffMs` are absorbed as history
|
|
658
|
+
* (seen-set only, no emission), events strictly after are ingested so
|
|
659
|
+
* local turn synthesis runs against them. The old path is retained in
|
|
660
|
+
* the secondary polling rotation if any started turn still references
|
|
661
|
+
* it. Does NOT emit `adopt_preamble` — that's an initial-attach signal,
|
|
662
|
+
* not a rotation signal. */
|
|
663
|
+
function performRotationSwitch(newPath, cutoffMs, reason) {
|
|
664
|
+
// Drain-before-switch: pull any unread bytes from the old path so a
|
|
665
|
+
// late assistant append doesn't vanish. Mirrors the other rotation
|
|
666
|
+
// helpers.
|
|
667
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
668
|
+
let postDrainOffset = bridgeOffset;
|
|
669
|
+
try {
|
|
670
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
671
|
+
postDrainOffset = drained.offset;
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
log(`Bridge final-drain on rotation (${reason}) failed (${err.message}); continuing`);
|
|
675
|
+
}
|
|
676
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
677
|
+
}
|
|
678
|
+
log(`Bridge transcript switched (${reason}): ${bridgeJsonlPath ?? '(none)'} → ${newPath}`);
|
|
679
|
+
if (bridgeWatcher) {
|
|
680
|
+
try {
|
|
681
|
+
bridgeWatcher.close();
|
|
682
|
+
}
|
|
683
|
+
catch { /* ignore */ }
|
|
684
|
+
bridgeWatcher = null;
|
|
685
|
+
}
|
|
686
|
+
bridgeJsonlPath = newPath;
|
|
687
|
+
bridgeJsonlDir = dirname(newPath);
|
|
688
|
+
bridgePendingTail = '';
|
|
689
|
+
// Drain the new path from 0 ourselves (do NOT call bridgeAbsorbBaseline
|
|
690
|
+
// — that would emit the preamble we want to suppress on rotation).
|
|
691
|
+
const result = drainTranscript(newPath, 0);
|
|
692
|
+
bridgeOffset = result.newOffset;
|
|
693
|
+
bridgePendingTail = result.pendingTail;
|
|
694
|
+
const { history, live } = splitTranscriptEventsByCutoff(result.events, cutoffMs);
|
|
695
|
+
bridgeQueue.absorb(history);
|
|
696
|
+
if (live.length > 0)
|
|
697
|
+
bridgeQueue.ingest(live, newPath);
|
|
698
|
+
bridgeBaselineDone = true;
|
|
699
|
+
log(`Bridge rotation split: ${history.length} historical events absorbed, ${live.length} live events ingested`);
|
|
700
|
+
try {
|
|
701
|
+
bridgeWatcher = fsWatch(newPath, { persistent: false }, () => {
|
|
702
|
+
try {
|
|
703
|
+
bridgeIngest();
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function maybeFollowQuietRotation() {
|
|
715
|
+
if (!bridgeJsonlDir || !bridgeJsonlPath)
|
|
716
|
+
return;
|
|
717
|
+
// Need a known pid to do safe rotation tracking; if we don't have one,
|
|
718
|
+
// we can't bind to the adopted Claude process and a directory-mtime
|
|
719
|
+
// switch would risk sibling-pane hijack.
|
|
720
|
+
if (bridgeCliPid === undefined)
|
|
721
|
+
return;
|
|
722
|
+
if (!isPidAlive(bridgeCliPid))
|
|
723
|
+
return;
|
|
724
|
+
const currentStat = statSafe(bridgeJsonlPath);
|
|
725
|
+
if (!currentStat)
|
|
726
|
+
return;
|
|
727
|
+
// Path 1: Linux fd-based detection — definitive, can't be hijacked.
|
|
728
|
+
// Read /proc/<pid>/fd, find every .jsonl Claude has open in our cwd's
|
|
729
|
+
// project dir, pick the one with the most recent mtime. Differs from
|
|
730
|
+
// bridgeJsonlPath ⇒ rotation.
|
|
731
|
+
const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
|
|
732
|
+
if (opened.length > 0) {
|
|
733
|
+
// Every fd-observed jsonl belongs to our pid — feed all of them
|
|
734
|
+
// into the sibling-pane hijack guard's trust list, not just the
|
|
735
|
+
// newest. This is how a post-/clear sessionId enters the trust
|
|
736
|
+
// set: Claude opens the new jsonl briefly during the /clear
|
|
737
|
+
// handshake; if a fd probe lands in that window, fingerprint
|
|
738
|
+
// fallback can later accept the new sessionId on the user's next
|
|
739
|
+
// Lark message.
|
|
740
|
+
for (const path of opened)
|
|
741
|
+
bridgeRememberSessionIdForPath(path);
|
|
742
|
+
const newest = newestPath(opened);
|
|
743
|
+
if (newest && newest !== bridgeJsonlPath) {
|
|
744
|
+
performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
|
|
745
|
+
}
|
|
746
|
+
// fd lookup succeeded — even if it confirmed the current path, the
|
|
747
|
+
// mtime fallback below would only add risk. Stop here.
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
// Path 2: non-Linux fallback (or /proc unavailable). Directory-mtime
|
|
751
|
+
// heuristic with three guards plus a trust-set filter on candidates.
|
|
752
|
+
//
|
|
753
|
+
// Without the trust-set filter, an actively-written sibling Claude pane
|
|
754
|
+
// in the same project dir always wins the mtime race; pid resolver then
|
|
755
|
+
// pulls the watcher back to our own (idle) jsonl on the next tick,
|
|
756
|
+
// re-arming the same condition. Result: 1 Hz path-flap that pegs CPU
|
|
757
|
+
// for as long as the sibling keeps writing (observed: 8 days, 6896
|
|
758
|
+
// switches on a single worker). Only candidates whose sid lives in
|
|
759
|
+
// `bridgeKnownSessionIds` (populated from initial attach, pid resolver
|
|
760
|
+
// hits, fd probes) are eligible — sibling sids are rejected.
|
|
761
|
+
const now = Date.now();
|
|
762
|
+
if (now - currentStat.mtimeMs < QUIET_ROTATION_MS)
|
|
763
|
+
return;
|
|
764
|
+
const latest = findLatestJsonl(bridgeJsonlDir, {
|
|
765
|
+
acceptCandidate: (path) => {
|
|
766
|
+
const sid = sessionIdFromJsonlPath(path);
|
|
767
|
+
return SESSION_ID_FILENAME_RE.test(sid) && bridgeKnownSessionIds.has(sid);
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
if (!latest || latest === bridgeJsonlPath)
|
|
771
|
+
return;
|
|
772
|
+
const latestStat = statSafe(latest);
|
|
773
|
+
if (!latestStat)
|
|
774
|
+
return;
|
|
775
|
+
if (latestStat.mtimeMs - currentStat.mtimeMs < QUIET_ROTATION_MS)
|
|
776
|
+
return;
|
|
777
|
+
performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
|
|
778
|
+
}
|
|
779
|
+
/** Pid-state rotation follow: re-read ~/.claude/sessions/<cliPid>.json
|
|
780
|
+
* and switch bridgeJsonlPath whenever the recorded sessionId differs
|
|
781
|
+
* from what we're watching. Same source as the writeInput pid resolver,
|
|
782
|
+
* with the same cwd + procStart validation.
|
|
783
|
+
*
|
|
784
|
+
* Empirical scope (Claude Code 2.1.123): the pid file's `sessionId` is
|
|
785
|
+
* written ONCE at process start. `--resume` rewrites it (it's a fresh
|
|
786
|
+
* spawn → fresh pid file). In-pane `/clear` does NOT rewrite it —
|
|
787
|
+
* `updatedAt` and `status` change but `sessionId` stays. So this probe
|
|
788
|
+
* catches spawn-time / `--resume` rotations; `/clear` (and in-pane
|
|
789
|
+
* `/resume` if Claude treats it the same) is left to the fingerprint
|
|
790
|
+
* fallback that anchors on a pending Lark turn. Returns a tri-state
|
|
791
|
+
* result rather than a bool so the caller can distinguish 'switched'
|
|
792
|
+
* (we moved) from 'same' (path confirmed) from 'unavailable' (no
|
|
793
|
+
* reliable answer) — the downstream gates use that distinction. */
|
|
794
|
+
/** Tri-state result so callers can distinguish "pid file unreadable, fall
|
|
795
|
+
* back to fingerprint heuristic" from "pid file confirmed current path"
|
|
796
|
+
* vs "pid file said rotate to a new path".
|
|
797
|
+
*
|
|
798
|
+
* Used by two downstream gates:
|
|
799
|
+
* - Fingerprint fallback (`maybeSwitchBridgeJsonl`): runs whenever the
|
|
800
|
+
* pid resolver did not actively switch (`!= 'switched'`). Safe even
|
|
801
|
+
* on `'same'` because the fingerprint scan requires a pending Lark
|
|
802
|
+
* turn — no risk of hijacking to a sibling pane.
|
|
803
|
+
* - Quiet-mtime fallback (`maybeFollowQuietRotation`): runs only on
|
|
804
|
+
* `'unavailable'`. The mtime heuristic can't distinguish our pane's
|
|
805
|
+
* rotation from a sibling pane in the same cwd, so even when pid
|
|
806
|
+
* resolver's `'same'` is not proof against in-process /clear (it
|
|
807
|
+
* isn't — Claude doesn't refresh `sessionId` on /clear), we still
|
|
808
|
+
* skip the heuristic. The cost is that a pure-local /clear with no
|
|
809
|
+
* pending Lark turn won't auto-follow until the user sends a Lark
|
|
810
|
+
* message; the alternative (running mtime fallback on 'same') would
|
|
811
|
+
* silently corrupt every multi-pane adopt setup.
|
|
812
|
+
*
|
|
813
|
+
* Type imported from `./services/bridge-rotation-policy` — the gate
|
|
814
|
+
* function lives there so it's testable without dragging worker fs/IPC
|
|
815
|
+
* side-effects into the unit suite. */
|
|
816
|
+
function maybeFollowSessionRotationViaPid() {
|
|
817
|
+
if (!bridgeCliPid || !bridgeCliCwd)
|
|
818
|
+
return 'unavailable';
|
|
819
|
+
const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
|
|
820
|
+
if (!resolved)
|
|
821
|
+
return 'unavailable';
|
|
822
|
+
if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
|
|
823
|
+
bridgeObservedCliSessionId = resolved.cliSessionId;
|
|
824
|
+
}
|
|
825
|
+
// Pid resolver always reports the spawn-time sessionId — this is a sid
|
|
826
|
+
// that genuinely belongs to our adopted Claude pid, so remember it for
|
|
827
|
+
// the sibling-pane hijack guard.
|
|
828
|
+
bridgeRememberSessionIdForPath(resolved.path);
|
|
829
|
+
if (resolved.path === bridgeJsonlPath)
|
|
830
|
+
return 'same';
|
|
831
|
+
// Stale-pid suppression: when the fingerprint fallback accepted a
|
|
832
|
+
// post-/clear jsonl (Claude's pid file isn't refreshed by in-pane
|
|
833
|
+
// /clear, so it keeps reporting the spawn-time sid), pid resolver
|
|
834
|
+
// would otherwise pull the watcher back to that spawn-time sid every
|
|
835
|
+
// tick — re-creating the flap loop the user reported. The decision
|
|
836
|
+
// lives in `bridge-rotation-policy.evaluatePidResolverPullback` so
|
|
837
|
+
// the four-cell matrix can be unit-tested in isolation.
|
|
838
|
+
const pullback = evaluatePidResolverPullback({
|
|
839
|
+
resolvedCliSessionId: resolved.cliSessionId,
|
|
840
|
+
resolvedPath: resolved.path,
|
|
841
|
+
currentBridgeJsonlPath: bridgeJsonlPath,
|
|
842
|
+
stalePidStateSessionId: bridgeStalePidStateSessionId,
|
|
843
|
+
});
|
|
844
|
+
if (pullback.clearStale)
|
|
845
|
+
bridgeStalePidStateSessionId = undefined;
|
|
846
|
+
if (pullback.suppress)
|
|
847
|
+
return 'same';
|
|
848
|
+
// Drain-before-switch: pull in any unread bytes from the OLD path so a
|
|
849
|
+
// trailing assistant append doesn't vanish. We do NOT emit here — emit
|
|
850
|
+
// is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
|
|
851
|
+
// publish a half-finished assistant during fs.watch / poll-driven
|
|
852
|
+
// bridgeIngest calls. If a started turn still references the old path
|
|
853
|
+
// and its assistant text might still be on the way, the old path stays
|
|
854
|
+
// in the polling rotation via bridgeSecondaryPaths.
|
|
855
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
856
|
+
let postDrainOffset = bridgeOffset;
|
|
857
|
+
try {
|
|
858
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
859
|
+
postDrainOffset = drained.offset;
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
log(`Bridge final-drain on rotation failed (${err.message}); continuing`);
|
|
863
|
+
}
|
|
864
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
865
|
+
}
|
|
866
|
+
log(`Bridge transcript switched (pid resolver): ${bridgeJsonlPath ?? '(none)'} → ${resolved.path}`);
|
|
867
|
+
if (bridgeWatcher) {
|
|
868
|
+
try {
|
|
869
|
+
bridgeWatcher.close();
|
|
870
|
+
}
|
|
871
|
+
catch { /* ignore */ }
|
|
872
|
+
bridgeWatcher = null;
|
|
873
|
+
}
|
|
874
|
+
// Preserve any pending Lark turn so the next ingest can attribute it
|
|
875
|
+
// when Claude appends our user event to the new jsonl. Skip baseline:
|
|
876
|
+
// we want to read from offset 0 so the pending turn's user event is
|
|
877
|
+
// visible to BridgeTurnQueue.ingest(). Turns already started on the
|
|
878
|
+
// old path keep their stamped sourceJsonlPath, so when their assistant
|
|
879
|
+
// text eventually arrives there too it still resolves correctly.
|
|
880
|
+
bridgeJsonlPath = resolved.path;
|
|
881
|
+
bridgeJsonlDir = dirname(resolved.path);
|
|
882
|
+
bridgeOffset = 0;
|
|
883
|
+
bridgePendingTail = '';
|
|
884
|
+
bridgeBaselineDone = true;
|
|
885
|
+
try {
|
|
886
|
+
bridgeWatcher = fsWatch(resolved.path, { persistent: false }, () => {
|
|
887
|
+
try {
|
|
888
|
+
bridgeIngest();
|
|
889
|
+
}
|
|
890
|
+
catch (err) {
|
|
891
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
|
|
897
|
+
}
|
|
898
|
+
return 'switched';
|
|
899
|
+
}
|
|
900
|
+
function bridgeIngest() {
|
|
901
|
+
// Defensive TTL: sweep any pending+unstarted mark whose Lark message
|
|
902
|
+
// never matched a user line in the transcript (writeInput failure
|
|
903
|
+
// surface that didn't get caught, future paths that forget to call
|
|
904
|
+
// dropPendingTurn). Without this, a stranded mark drives
|
|
905
|
+
// `maybeSwitchBridgeJsonl` to do full-directory jsonl scans every tick
|
|
906
|
+
// until daemon restart — the 99% CPU bug. The explicit dropPendingTurn
|
|
907
|
+
// path in scheduleSubmitFailureNotify handles the known offender;
|
|
908
|
+
// this catches everything else.
|
|
909
|
+
const expired = bridgeQueue.pruneExpired(BRIDGE_PENDING_TURN_TTL_MS);
|
|
910
|
+
for (const t of expired) {
|
|
911
|
+
if (t.contentFingerprint)
|
|
912
|
+
bridgeFingerprintScanLastMs.delete(t.contentFingerprint);
|
|
913
|
+
log(`Bridge mark expired after ${Math.round(BRIDGE_PENDING_TURN_TTL_MS / 1000)}s without matching a jsonl user line (turnId=${t.turnId}) — dropped to prevent rotation-fallback scan loop.`);
|
|
914
|
+
}
|
|
915
|
+
// Drain secondary paths first so any trailing assistant text on an old
|
|
916
|
+
// jsonl reaches the queue before the rotation check considers retiring
|
|
917
|
+
// the path. Strictly read-only on the polling rotation; never triggers
|
|
918
|
+
// a rotate or shifts the primary path.
|
|
919
|
+
drainSecondaryPaths();
|
|
920
|
+
// Cheap probe: catch any jsonls our adopted pid currently has open
|
|
921
|
+
// and add their sessionIds to the sibling-pane hijack guard's trust
|
|
922
|
+
// list. Runs every tick (independent of rotation gates) because
|
|
923
|
+
// Claude opens-writes-closes the jsonl per event — fd observation
|
|
924
|
+
// is therefore intermittent, and more ticks = more chances to
|
|
925
|
+
// catch a post-/clear sessionId. This is the only hook by which
|
|
926
|
+
// an in-pane /clear becomes followable: without an fd-probe hit
|
|
927
|
+
// the fingerprint fallback will reject the new (unknown) sessionId
|
|
928
|
+
// and the user must re-adopt to recover.
|
|
929
|
+
bridgeProbeOpenSessionIds();
|
|
930
|
+
// Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
|
|
931
|
+
// pid file → new sessionId), e.g. daemon restart that re-issues
|
|
932
|
+
// `--resume <id>` and Claude rotates the internal id.
|
|
933
|
+
const pidFollow = maybeFollowSessionRotationViaPid();
|
|
934
|
+
// Fingerprint fallback: catches *in-process* rotations Claude makes
|
|
935
|
+
// via /clear or /resume from the user's pane. Empirically (verified
|
|
936
|
+
// on Claude Code 2.1.123) the pid file's `sessionId` field is set
|
|
937
|
+
// ONCE at process start; /clear refreshes `updatedAt` but does NOT
|
|
938
|
+
// rewrite `sessionId`, so pid resolver returning 'same' is NOT proof
|
|
939
|
+
// that no rotation happened. We skip the fingerprint scan only when
|
|
940
|
+
// pid resolver actively switched the path — in that case the
|
|
941
|
+
// authoritative source already moved us, and running fingerprint on
|
|
942
|
+
// top would risk a redundant flip. Sibling-pane hijack protection is
|
|
943
|
+
// NOT delegated to the markTimeMs-5s event filter (short fingerprints
|
|
944
|
+
// substring-match unrelated content like "test" → "run tests"); the
|
|
945
|
+
// real gate is the sibling guard inside `maybeSwitchBridgeJsonl` that
|
|
946
|
+
// rejects every candidate whose sessionId isn't in the pid-derived
|
|
947
|
+
// trust set.
|
|
948
|
+
let switched = pidFollow === 'switched';
|
|
949
|
+
if (!switched) {
|
|
950
|
+
switched = maybeSwitchBridgeJsonl();
|
|
951
|
+
}
|
|
952
|
+
// Quiet-rotation fallback: directory-mtime heuristic that picks the
|
|
953
|
+
// newest jsonl in the same project dir when our current path goes
|
|
954
|
+
// quiet. Originally the safety net for "user runs /clear purely in
|
|
955
|
+
// iTerm with no pending Lark turn, so fingerprint fallback can't
|
|
956
|
+
// anchor on anything". Trade-off: when the user has a SIBLING Claude
|
|
957
|
+
// pane in the same cwd, that pane's busier jsonl always wins this
|
|
958
|
+
// race and the bridge gets hijacked, ingesting the sibling pane's
|
|
959
|
+
// user/assistant events as `isLocal: true` local turns and forwarding
|
|
960
|
+
// them to the adopted Lark thread (the user-reported "/adopt 一对话
|
|
961
|
+
// 出来一堆历史会话" symptom).
|
|
962
|
+
//
|
|
963
|
+
// We accept the asymmetry: sibling-pane hijack is silent, persistent
|
|
964
|
+
// and corrupts every adopted multi-pane setup; pure-local /clear
|
|
965
|
+
// without a pending Lark turn is a narrow corner case the user can
|
|
966
|
+
// unstick by sending one Lark message (which arms fingerprint
|
|
967
|
+
// fallback). So we ONLY consult the mtime heuristic when the pid
|
|
968
|
+
// probe was unavailable (non-Linux, missing/invalid pid file).
|
|
969
|
+
if (shouldRunQuietRotation(pidFollow, switched)) {
|
|
970
|
+
maybeFollowQuietRotation();
|
|
971
|
+
}
|
|
972
|
+
if (!bridgeJsonlPath)
|
|
973
|
+
return;
|
|
974
|
+
if (!bridgeBaselineDone) {
|
|
975
|
+
// Lazy baseline: file didn't exist at attach, baseline the moment it does.
|
|
976
|
+
if (!existsSyncSafe(bridgeJsonlPath))
|
|
977
|
+
return;
|
|
978
|
+
bridgeAbsorbBaseline();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const result = drainTranscript(bridgeJsonlPath, bridgeOffset);
|
|
982
|
+
bridgeOffset = result.newOffset;
|
|
983
|
+
bridgePendingTail = result.pendingTail;
|
|
984
|
+
bridgeQueue.ingest(result.events, bridgeJsonlPath);
|
|
985
|
+
}
|
|
986
|
+
function startBridgeWatcher(jsonlPath, opts) {
|
|
987
|
+
bridgeJsonlPath = jsonlPath;
|
|
988
|
+
bridgeJsonlDir = dirname(jsonlPath);
|
|
989
|
+
bridgeCliPid = opts?.cliPid;
|
|
990
|
+
bridgeCliCwd = opts?.cliCwd;
|
|
991
|
+
const mode = opts?.mode ?? 'baseline-existing';
|
|
992
|
+
// Pid-state record ranks above the path the adopt scan computed. If
|
|
993
|
+
// Claude was launched with `--resume` (or the adopt scan picked a
|
|
994
|
+
// stale jsonl), the pid file points at the actual current sessionId
|
|
995
|
+
// and we swap to it before baseline so we don't waste a baseline on
|
|
996
|
+
// a frozen file.
|
|
997
|
+
if (bridgeCliPid && bridgeCliCwd) {
|
|
998
|
+
const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
|
|
999
|
+
if (resolved) {
|
|
1000
|
+
bridgeObservedCliSessionId = resolved.cliSessionId;
|
|
1001
|
+
bridgeRememberSessionIdForPath(resolved.path);
|
|
1002
|
+
if (resolved.path !== bridgeJsonlPath) {
|
|
1003
|
+
log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
|
|
1004
|
+
bridgeJsonlPath = resolved.path;
|
|
1005
|
+
bridgeJsonlDir = dirname(resolved.path);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
// fd probe at start: the pid file's `sessionId` is set ONCE at Claude's
|
|
1010
|
+
// process start and is NOT refreshed by in-pane `/clear`. So if the user
|
|
1011
|
+
// /clear'd between the original /adopt and this worker spawn (most
|
|
1012
|
+
// commonly: daemon restart that restored a long-lived adopt session),
|
|
1013
|
+
// pid resolver still points at the spawn-time jsonl while Claude has
|
|
1014
|
+
// rotated to a new one. `/proc/<pid>/fd` shows what Claude *currently*
|
|
1015
|
+
// has open — bound to our pid, so no sibling-pane hijack risk.
|
|
1016
|
+
//
|
|
1017
|
+
// Two signals matter: direct `.jsonl` fd (only present during a write
|
|
1018
|
+
// window — Claude opens-writes-closes per event) and `~/.claude/tasks/
|
|
1019
|
+
// <sid>` symlinks (Claude holds the tasks dir + its .lock file open
|
|
1020
|
+
// continuously for the active session, so this catches the rotation
|
|
1021
|
+
// even between writes). `findOpenClaudeSessionIds` unions both.
|
|
1022
|
+
if (bridgeCliPid !== undefined && bridgeJsonlDir && bridgeCliCwd) {
|
|
1023
|
+
const sids = findOpenClaudeSessionIds(bridgeCliPid);
|
|
1024
|
+
const candidates = [];
|
|
1025
|
+
for (const sid of sids) {
|
|
1026
|
+
const path = claudeJsonlPathForSession(sid, bridgeCliCwd);
|
|
1027
|
+
bridgeRememberSessionIdForPath(path);
|
|
1028
|
+
if (existsSyncSafe(path))
|
|
1029
|
+
candidates.push(path);
|
|
1030
|
+
}
|
|
1031
|
+
if (candidates.length > 0) {
|
|
1032
|
+
const newest = newestPath(candidates);
|
|
1033
|
+
if (newest && newest !== bridgeJsonlPath) {
|
|
1034
|
+
log(`Bridge transcript adjusted at start (pid fd probe — Claude rotated since worker spawn): ${bridgeJsonlPath} → ${newest}`);
|
|
1035
|
+
bridgeJsonlPath = newest;
|
|
1036
|
+
bridgeJsonlDir = dirname(newest);
|
|
1037
|
+
// Pid file's sessionId disagrees with the path Claude actually has
|
|
1038
|
+
// open — record it as stale so the per-tick pid resolver doesn't
|
|
1039
|
+
// pull us back to the spawn-time jsonl on every poll.
|
|
1040
|
+
bridgeMarkStalePidStateForAcceptedSid(sessionIdFromJsonlPath(newest));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// Remember the initial path's sessionId — this is the ground-truth
|
|
1045
|
+
// anchor for the sibling-pane hijack guard. Subsequent fingerprint
|
|
1046
|
+
// candidates are accepted only if their sessionId is in this set
|
|
1047
|
+
// (populated here, by pid resolver hits, and by per-tick fd probes).
|
|
1048
|
+
bridgeRememberSessionIdForPath(bridgeJsonlPath);
|
|
1049
|
+
if (mode === 'fresh-empty') {
|
|
1050
|
+
// Non-adopt fallback: brand-new session, jsonl gets created on the first
|
|
1051
|
+
// user submit. We must NOT lazy-absorb the file when it appears — that
|
|
1052
|
+
// would treat the first turn's user/assistant events as history and the
|
|
1053
|
+
// worker would never emit a final_output for them. Instead declare
|
|
1054
|
+
// baseline=done with offset=0 up front: the very first events drained
|
|
1055
|
+
// from the file are eligible for attribution against pending Lark turns.
|
|
1056
|
+
bridgeOffset = 0;
|
|
1057
|
+
bridgePendingTail = '';
|
|
1058
|
+
bridgeBaselineDone = true;
|
|
1059
|
+
log(`Bridge fresh-empty mode: ${bridgeJsonlPath} (waiting for file to appear; no baseline absorb)`);
|
|
1060
|
+
}
|
|
1061
|
+
else if (existsSyncSafe(bridgeJsonlPath)) {
|
|
1062
|
+
bridgeAbsorbBaseline();
|
|
1063
|
+
log(`Bridge baselined: ${bridgeJsonlPath} (offset=${bridgeOffset})`);
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
log(`Bridge transcript not yet present at ${bridgeJsonlPath}; will baseline on first appearance`);
|
|
1067
|
+
}
|
|
1068
|
+
// fs.watch is best-effort wakeup — actual data source is the byte offset.
|
|
1069
|
+
// The fallback poller covers fs.watch's gaps (NFS, rename-rotation, etc.)
|
|
1070
|
+
// and also drives lazy baseline when the file shows up after attach.
|
|
1071
|
+
try {
|
|
1072
|
+
bridgeWatcher = fsWatch(bridgeJsonlPath, { persistent: false }, () => {
|
|
1073
|
+
try {
|
|
1074
|
+
bridgeIngest();
|
|
1075
|
+
}
|
|
1076
|
+
catch (err) {
|
|
1077
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
catch (err) {
|
|
1082
|
+
log(`Bridge fs.watch unavailable (${err.message}); relying on fallback poller`);
|
|
1083
|
+
}
|
|
1084
|
+
bridgeFallbackTimer = setInterval(() => {
|
|
1085
|
+
try {
|
|
1086
|
+
bridgeIngest();
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
1090
|
+
}
|
|
1091
|
+
}, 1000);
|
|
1092
|
+
}
|
|
1093
|
+
function stopBridgeWatcher() {
|
|
1094
|
+
if (bridgeWatcher) {
|
|
1095
|
+
try {
|
|
1096
|
+
bridgeWatcher.close();
|
|
1097
|
+
}
|
|
1098
|
+
catch { /* ignore */ }
|
|
1099
|
+
bridgeWatcher = null;
|
|
1100
|
+
}
|
|
1101
|
+
if (bridgeFallbackTimer) {
|
|
1102
|
+
clearInterval(bridgeFallbackTimer);
|
|
1103
|
+
bridgeFallbackTimer = null;
|
|
1104
|
+
}
|
|
1105
|
+
bridgeCliPid = undefined;
|
|
1106
|
+
bridgeCliCwd = undefined;
|
|
1107
|
+
bridgeObservedCliSessionId = undefined;
|
|
1108
|
+
bridgeKnownSessionIds.clear();
|
|
1109
|
+
bridgeStalePidStateSessionId = undefined;
|
|
1110
|
+
bridgeSecondaryPaths.clear();
|
|
1111
|
+
bridgeFingerprintScanLastMs.clear();
|
|
1112
|
+
bridgePreambleSent = false;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Push a pending turn for the next Lark message.
|
|
1116
|
+
*
|
|
1117
|
+
* Returns the turnId on success, undefined if bridge-final-output isn't
|
|
1118
|
+
* available for this message (transcript not yet baselined). On undefined
|
|
1119
|
+
* the worker still raw-writes the message into the pane — the user just
|
|
1120
|
+
* won't get a transcript-driven final_output reply for it. This keeps the
|
|
1121
|
+
* v3 promise: if we can't attribute correctly, we don't attribute at all.
|
|
1122
|
+
*
|
|
1123
|
+
* `messageText` is the raw Lark message body — we derive a short content
|
|
1124
|
+
* fingerprint from it so the next *matching* user event in the transcript
|
|
1125
|
+
* (and only that one) starts this turn. Local-terminal input that races
|
|
1126
|
+
* with the pane-write will not match the fingerprint and won't hijack the
|
|
1127
|
+
* Lark turn.
|
|
1128
|
+
*
|
|
1129
|
+
* The turnId is returned so the writeInput failure path can call
|
|
1130
|
+
* `bridgeQueue.dropPendingTurn(turnId)` after deferred recheck conclusively
|
|
1131
|
+
* fails — otherwise an Enter-eaten-by-TUI submit leaves a fingerprint that
|
|
1132
|
+
* no jsonl line will ever match, and `maybeSwitchBridgeJsonl` burns 99%
|
|
1133
|
+
* CPU scanning all sibling jsonls for it on every poll tick.
|
|
1134
|
+
*/
|
|
1135
|
+
function bridgeMarkPendingTurn(messageText) {
|
|
1136
|
+
if (!bridgeJsonlPath)
|
|
1137
|
+
return undefined;
|
|
1138
|
+
if (!bridgeBaselineDone) {
|
|
1139
|
+
log('Bridge baseline not ready — this turn will not have transcript-driven final_output');
|
|
1140
|
+
return undefined;
|
|
1141
|
+
}
|
|
1142
|
+
const fingerprint = makeFingerprint(messageText);
|
|
1143
|
+
// Full normalised content powers the unknown-sid recovery path. When a
|
|
1144
|
+
// user runs `/clear` and the bridge can't see the new sessionId yet
|
|
1145
|
+
// (pid file lags, fd probe missed the brief open window), we fall back
|
|
1146
|
+
// to scanning every untrusted candidate jsonl for an EXACT equality
|
|
1147
|
+
// with this normalised string — substantially harder for a sibling
|
|
1148
|
+
// pane to false-match than the 30-char substring fingerprint.
|
|
1149
|
+
const normalised = normaliseForFingerprint(messageText);
|
|
1150
|
+
const contentNormalized = normalised.length > 0 ? normalised : undefined;
|
|
1151
|
+
const turnId = randomBytes(8).toString('hex');
|
|
1152
|
+
bridgeQueue.mark(turnId, fingerprint, Date.now(), contentNormalized);
|
|
1153
|
+
return turnId;
|
|
1154
|
+
}
|
|
1155
|
+
function bridgeDrainAndMaybeEmit() {
|
|
1156
|
+
if (!bridgeJsonlPath)
|
|
1157
|
+
return;
|
|
1158
|
+
bridgeIngest();
|
|
1159
|
+
emitReadyTurns();
|
|
1160
|
+
// Prune AFTER emit so a path is only retired once its turn has actually
|
|
1161
|
+
// been published. During non-idle ticks (fs.watch / 1s poll) we never
|
|
1162
|
+
// emit, so we never prune — the path stays put until idle resolves it.
|
|
1163
|
+
pruneSecondaryPaths();
|
|
1164
|
+
}
|
|
1165
|
+
/** Pop ready turns and emit their final_output. Resolves uuid → text via
|
|
1166
|
+
* each turn's own `sourceJsonlPath` (stamped at turn-start) so an in-flight
|
|
1167
|
+
* reply that started in an old jsonl still gets picked up after a sessionId
|
|
1168
|
+
* rotation has switched the global `bridgeJsonlPath` to a different file.
|
|
1169
|
+
* Falls back to `bridgeJsonlPath` for legacy turns without a stamped source.
|
|
1170
|
+
*
|
|
1171
|
+
* Caches per-path drains so a batch of turns from the same file only reads
|
|
1172
|
+
* the transcript once (O(jsonl size) per distinct path). */
|
|
1173
|
+
function emitReadyTurns() {
|
|
1174
|
+
const ready = bridgeQueue.drainEmittable();
|
|
1175
|
+
if (ready.length === 0)
|
|
1176
|
+
return;
|
|
1177
|
+
const adoptMode = lastInitConfig?.adoptMode === true;
|
|
1178
|
+
// Send markers (`botmux send` landed in own thread) + the queue's first
|
|
1179
|
+
// still-unready turn. The latter caps the LAST ready turn's window —
|
|
1180
|
+
// without it, a model that's still mid-tool-use for turn N+1 could leak
|
|
1181
|
+
// a send credit into turn N's window via shouldSuppressBridgeEmit.
|
|
1182
|
+
const markers = adoptMode ? [] : readSendMarkers();
|
|
1183
|
+
const remainingPending = bridgeQueue.peek();
|
|
1184
|
+
const nextPendingMarkTimeMs = remainingPending.length > 0 ? remainingPending[0].markTimeMs : undefined;
|
|
1185
|
+
const cache = new Map();
|
|
1186
|
+
for (let i = 0; i < ready.length; i++) {
|
|
1187
|
+
const turn = ready[i];
|
|
1188
|
+
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
|
|
1189
|
+
if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
|
|
1190
|
+
const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
|
|
1191
|
+
log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const path = turn.sourceJsonlPath ?? bridgeJsonlPath;
|
|
1195
|
+
if (!path)
|
|
1196
|
+
continue;
|
|
1197
|
+
let drained = cache.get(path);
|
|
1198
|
+
if (!drained) {
|
|
1199
|
+
drained = drainTranscript(path, 0);
|
|
1200
|
+
cache.set(path, drained);
|
|
1201
|
+
}
|
|
1202
|
+
const set = new Set(turn.assistantUuids);
|
|
1203
|
+
const matched = drained.events.filter(e => e.uuid && set.has(e.uuid));
|
|
1204
|
+
const assistantText = joinAssistantText(matched);
|
|
1205
|
+
if (assistantText.length === 0)
|
|
1206
|
+
continue;
|
|
1207
|
+
const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
|
|
1208
|
+
if (turn.isLocal) {
|
|
1209
|
+
if (turn.userUuid) {
|
|
1210
|
+
// Local turn (adopt mode only): also surface the user prompt so the
|
|
1211
|
+
// Lark thread shows both sides of the exchange. User text comes from
|
|
1212
|
+
// the same drained transcript via the userUuid stamped at start time.
|
|
1213
|
+
// extractTurnStartText handles both `role:user` events (text in
|
|
1214
|
+
// message.content) AND `attachment(queued_command)` events (text in
|
|
1215
|
+
// attachment.prompt) so type-ahead'd local input renders the same as
|
|
1216
|
+
// a normally-typed pane prompt.
|
|
1217
|
+
const userEv = drained.events.find(e => e.uuid === turn.userUuid);
|
|
1218
|
+
const rawUserText = userEv ? extractTurnStartText(userEv) : '';
|
|
1219
|
+
const fields = formatLocalTurnFields(rawUserText, assistantText);
|
|
1220
|
+
if (!fields)
|
|
1221
|
+
continue;
|
|
1222
|
+
send({
|
|
1223
|
+
type: 'final_output',
|
|
1224
|
+
content: fields.content,
|
|
1225
|
+
lastUuid,
|
|
1226
|
+
turnId: turn.turnId,
|
|
1227
|
+
kind: 'local-turn',
|
|
1228
|
+
userText: fields.userText,
|
|
1229
|
+
});
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
// Headless local turn — see formatHeadlessLocalTurnContent for context.
|
|
1233
|
+
const headlessContent = formatHeadlessLocalTurnContent(assistantText);
|
|
1234
|
+
if (!headlessContent)
|
|
1235
|
+
continue;
|
|
1236
|
+
send({
|
|
1237
|
+
type: 'final_output',
|
|
1238
|
+
content: headlessContent,
|
|
1239
|
+
lastUuid,
|
|
1240
|
+
turnId: turn.turnId,
|
|
1241
|
+
kind: 'local-turn-headless',
|
|
1242
|
+
});
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
send({ type: 'final_output', content: assistantText, lastUuid, turnId: turn.turnId });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/** Drain `path` from `fromOffset` and feed the events to the bridge queue
|
|
1249
|
+
* with that path as the source stamp. Pure side-effects on bridgeQueue +
|
|
1250
|
+
* the returned cursor; does NOT touch bridgeJsonlPath / bridgeOffset, so
|
|
1251
|
+
* callers can use it to flush the old path during a rotation without
|
|
1252
|
+
* disturbing the watcher's normal cursor. Returns the new offset for the
|
|
1253
|
+
* caller to commit (or discard, if it's about to switch paths). */
|
|
1254
|
+
function drainPathInto(path, fromOffset) {
|
|
1255
|
+
const result = drainTranscript(path, fromOffset);
|
|
1256
|
+
bridgeQueue.ingest(result.events, path);
|
|
1257
|
+
return { offset: result.newOffset, tail: result.pendingTail };
|
|
1258
|
+
}
|
|
1259
|
+
// ─── Codex bridge wiring ─────────────────────────────────────────────────
|
|
1260
|
+
//
|
|
1261
|
+
// Codex's bridge fallback is intentionally simpler than Claude's: no /adopt
|
|
1262
|
+
// surface, no pid-resolver / quiet-rotation / fingerprint-jsonl-switch
|
|
1263
|
+
// machinery. The reader watches one rollout file (located by cliSessionId)
|
|
1264
|
+
// and the queue's only responsibility is "user fingerprint match → start;
|
|
1265
|
+
// assistant_final → close". Everything else (mark / emit gate / send
|
|
1266
|
+
// marker IO / type-ahead serialisation / one-write-per-idle break) is
|
|
1267
|
+
// shared with the Claude path.
|
|
1268
|
+
function codexBridgeFallbackActive() {
|
|
1269
|
+
// True for transcript-backed CLIs whose final output can be harvested
|
|
1270
|
+
// from append-only JSONL when the model forgets to call `botmux send`.
|
|
1271
|
+
// Codex uses ~/.codex rollouts; CoCo uses ~/.cache/coco events. Both
|
|
1272
|
+
// work in adopt mode now that CoCo's PID→sessionId discovery is wired.
|
|
1273
|
+
return lastInitConfig?.cliId === 'codex' || lastInitConfig?.cliId === 'coco';
|
|
1274
|
+
}
|
|
1275
|
+
function structuredBridgeIsCodex() {
|
|
1276
|
+
return lastInitConfig?.cliId === 'codex';
|
|
1277
|
+
}
|
|
1278
|
+
function structuredBridgeIngestPath(path, offset) {
|
|
1279
|
+
return structuredBridgeIsCodex()
|
|
1280
|
+
? drainCodexRollout(path, offset)
|
|
1281
|
+
: drainCocoEvents(path, offset);
|
|
1282
|
+
}
|
|
1283
|
+
function codexBridgeStartTimer() {
|
|
1284
|
+
if (codexBridgeTimer)
|
|
1285
|
+
return;
|
|
1286
|
+
// Single 1s ticker that handles three jobs: late-attach (poll for the
|
|
1287
|
+
// rollout file once we know cliSessionId), ingest (fs.watch backup),
|
|
1288
|
+
// and idle-window emit. The last is critical for the late-attach race:
|
|
1289
|
+
// if the rollout path appears AFTER the CLI's idle event has fired,
|
|
1290
|
+
// the idle callback's emit already ran (and saw an empty queue), so
|
|
1291
|
+
// the next emit chance would be at the next idle — i.e. the user has
|
|
1292
|
+
// to send another message before the previous turn's fallback shows
|
|
1293
|
+
// up. Emitting here when isPromptReady=true closes that window.
|
|
1294
|
+
// Codex's queue only releases turns on `assistant_final` (the model's
|
|
1295
|
+
// declared end-of-turn), so a tick-driven emit can't accidentally
|
|
1296
|
+
// publish a half-streamed response.
|
|
1297
|
+
codexBridgeTimer = setInterval(() => {
|
|
1298
|
+
try {
|
|
1299
|
+
if (!codexBridgeRolloutPath) {
|
|
1300
|
+
// Two discovery paths, in order: cliSessionId (known via writeInput
|
|
1301
|
+
// result for non-adopt or daemon-side probe for adopt) → exact
|
|
1302
|
+
// file by name; PID (adopt only) → walk /proc/<pid>/fd. Adopt
|
|
1303
|
+
// attaches via split-live (history absorbed, live ingested);
|
|
1304
|
+
// non-adopt uses fresh-empty (queue's markTimeMs - 5s lower bound
|
|
1305
|
+
// gates historical fingerprint matches without needing a split).
|
|
1306
|
+
// Discovery primitives differ per CLI: codex walks ~/.codex/sessions
|
|
1307
|
+
// by sid suffix; CoCo's events.jsonl path is deterministic from
|
|
1308
|
+
// sid, so the lookup is just a path computation + existence check.
|
|
1309
|
+
const isCoco = lastInitConfig?.cliId === 'coco';
|
|
1310
|
+
let path;
|
|
1311
|
+
if (codexBridgePendingSessionId) {
|
|
1312
|
+
path = isCoco
|
|
1313
|
+
? cocoEventsPathForSession(codexBridgePendingSessionId)
|
|
1314
|
+
: findCodexRolloutBySessionId(codexBridgePendingSessionId);
|
|
1315
|
+
if (path && isCoco && !existsSync(path))
|
|
1316
|
+
path = undefined;
|
|
1317
|
+
}
|
|
1318
|
+
if (!path && codexAdoptPendingPid) {
|
|
1319
|
+
if (isCoco) {
|
|
1320
|
+
const probed = findCocoSessionByPid(codexAdoptPendingPid);
|
|
1321
|
+
if (probed && existsSync(probed.eventsPath))
|
|
1322
|
+
path = probed.eventsPath;
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
const probed = findCodexRolloutByPid(codexAdoptPendingPid);
|
|
1326
|
+
if (probed)
|
|
1327
|
+
path = probed.path;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (path) {
|
|
1331
|
+
codexBridgePendingSessionId = undefined;
|
|
1332
|
+
codexAdoptPendingPid = undefined;
|
|
1333
|
+
// Adopt mode: split-live partitions drained events by
|
|
1334
|
+
// codexAdoptStartMs so anything the user did AFTER adopt but
|
|
1335
|
+
// BEFORE we found the rollout still emits (history is absorbed,
|
|
1336
|
+
// live is ingested). Non-adopt: fresh-empty as before — queue's
|
|
1337
|
+
// markTimeMs - 5s lower bound is enough since there's no
|
|
1338
|
+
// local-turn synthesis on that path.
|
|
1339
|
+
const mode = lastInitConfig?.adoptMode ? 'split-live' : 'fresh-empty';
|
|
1340
|
+
codexBridgeAttach(path, mode);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
codexBridgeIngest();
|
|
1344
|
+
if (isPromptReady)
|
|
1345
|
+
emitReadyCodexTurns();
|
|
1346
|
+
}
|
|
1347
|
+
catch (err) {
|
|
1348
|
+
log(`Codex bridge tick error: ${err.message}`);
|
|
1349
|
+
}
|
|
1350
|
+
}, 1000);
|
|
1351
|
+
}
|
|
1352
|
+
function codexBridgeAttach(rolloutPath, mode) {
|
|
1353
|
+
codexBridgeRolloutPath = rolloutPath;
|
|
1354
|
+
if (mode === 'fresh-empty') {
|
|
1355
|
+
// Brand-new session OR late-attach right after first submit. Either
|
|
1356
|
+
// way we want to ingest from offset 0 — pending turns marked before
|
|
1357
|
+
// attach are still in the queue, so the user_message that just landed
|
|
1358
|
+
// (or is about to land) will fingerprint-match them.
|
|
1359
|
+
codexBridgeOffset = 0;
|
|
1360
|
+
codexBridgePendingTail = '';
|
|
1361
|
+
codexBridgeBaselineDone = true;
|
|
1362
|
+
log(`Codex bridge fresh-empty: ${rolloutPath}`);
|
|
1363
|
+
}
|
|
1364
|
+
else if (mode === 'split-live' && existsSync(rolloutPath)) {
|
|
1365
|
+
// Adopt mode: drain everything, then split by adoptStartMs. History
|
|
1366
|
+
// (pre-adopt) is `absorb()`-ed so it can't replay; live (post-adopt)
|
|
1367
|
+
// is `ingest()`-ed so a Lark turn already marked or an iTerm-typed
|
|
1368
|
+
// local turn that landed before we found the rollout still gets
|
|
1369
|
+
// attributed. Without this split, baseline-existing would absorb()
|
|
1370
|
+
// the live events too, silently dropping anything the user did
|
|
1371
|
+
// between adopt and rollout-discovery — that's the user-reported
|
|
1372
|
+
// "iTerm 手动输入飞书没收到" symptom under late-attach.
|
|
1373
|
+
const result = structuredBridgeIngestPath(rolloutPath, 0);
|
|
1374
|
+
const cutoff = (codexAdoptStartMs ?? Date.now()) - 5_000;
|
|
1375
|
+
const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
|
|
1376
|
+
codexBridgeQueue.absorb(history);
|
|
1377
|
+
codexBridgeQueue.ingest(live);
|
|
1378
|
+
codexBridgeOffset = result.newOffset;
|
|
1379
|
+
codexBridgePendingTail = result.pendingTail;
|
|
1380
|
+
codexBridgeBaselineDone = true;
|
|
1381
|
+
log(`Codex bridge split-live: ${rolloutPath} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${codexBridgeOffset})`);
|
|
1382
|
+
maybeEmitCodexAdoptPreamble(history);
|
|
1383
|
+
}
|
|
1384
|
+
else if (mode === 'split-live') {
|
|
1385
|
+
// split-live requested but file missing — degrade to fresh: the file
|
|
1386
|
+
// will appear later via fs.watch / poller, and ingest from offset 0
|
|
1387
|
+
// will pick up everything as live (consistent with split semantics
|
|
1388
|
+
// when there's no history to absorb).
|
|
1389
|
+
codexBridgeOffset = 0;
|
|
1390
|
+
codexBridgePendingTail = '';
|
|
1391
|
+
codexBridgeBaselineDone = true;
|
|
1392
|
+
log(`Codex bridge split-live degraded to fresh (file missing): ${rolloutPath}`);
|
|
1393
|
+
}
|
|
1394
|
+
else if (existsSync(rolloutPath)) {
|
|
1395
|
+
const result = structuredBridgeIngestPath(rolloutPath, 0);
|
|
1396
|
+
codexBridgeOffset = result.newOffset;
|
|
1397
|
+
codexBridgePendingTail = result.pendingTail;
|
|
1398
|
+
codexBridgeQueue.absorb(result.events);
|
|
1399
|
+
codexBridgeBaselineDone = true;
|
|
1400
|
+
log(`Codex bridge baselined: ${rolloutPath} (offset=${codexBridgeOffset})`);
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
// baseline-existing requested but file missing — degrade to fresh
|
|
1404
|
+
// semantics so the lazy-appearing file isn't accidentally absorbed.
|
|
1405
|
+
codexBridgeOffset = 0;
|
|
1406
|
+
codexBridgePendingTail = '';
|
|
1407
|
+
codexBridgeBaselineDone = true;
|
|
1408
|
+
log(`Codex bridge transcript not yet present at ${rolloutPath}; treating as fresh`);
|
|
1409
|
+
}
|
|
1410
|
+
try {
|
|
1411
|
+
codexBridgeWatcher = fsWatch(rolloutPath, { persistent: false }, () => {
|
|
1412
|
+
try {
|
|
1413
|
+
codexBridgeIngest();
|
|
1414
|
+
}
|
|
1415
|
+
catch (err) {
|
|
1416
|
+
log(`Codex bridge ingest error: ${err.message}`);
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
catch (err) {
|
|
1421
|
+
log(`Codex bridge fs.watch unavailable (${err.message}); relying on poller`);
|
|
1422
|
+
}
|
|
1423
|
+
// macOS 上 fs.watch 对 codex/coco 的外部进程追加 rollout / events.jsonl
|
|
1424
|
+
// 经常静默丢事件(FSEvents 跨进程不可靠),所以无论 watcher 是否 attach
|
|
1425
|
+
// 成功,都必须起 1s poller 兜底 —— 不然 split-live 成功的 adopt session
|
|
1426
|
+
// 在 macOS 上会卡死,永远收不到模型回复。Linux 上 poller 多 tick 也无害
|
|
1427
|
+
// (codexBridgeIngest 在 offset 未推进时是 no-op)。
|
|
1428
|
+
codexBridgeStartTimer();
|
|
1429
|
+
}
|
|
1430
|
+
/** Called from flushPending after writeInput first returns a cliSessionId.
|
|
1431
|
+
* Tries to locate the rollout file immediately; if it's not on disk yet,
|
|
1432
|
+
* remembers the sid so the 1s poller can keep retrying. */
|
|
1433
|
+
function codexBridgeNotifyCliSessionId(cliSessionId) {
|
|
1434
|
+
if (!codexBridgeFallbackActive() || codexBridgeRolloutPath)
|
|
1435
|
+
return;
|
|
1436
|
+
const path = findCodexRolloutBySessionId(cliSessionId);
|
|
1437
|
+
if (path) {
|
|
1438
|
+
codexBridgePendingSessionId = undefined;
|
|
1439
|
+
codexBridgeAttach(path, 'fresh-empty');
|
|
1440
|
+
}
|
|
1441
|
+
else {
|
|
1442
|
+
codexBridgePendingSessionId = cliSessionId;
|
|
1443
|
+
codexBridgeStartTimer();
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
function codexBridgeIngest() {
|
|
1447
|
+
if (!codexBridgeRolloutPath || !codexBridgeBaselineDone)
|
|
1448
|
+
return;
|
|
1449
|
+
const result = structuredBridgeIngestPath(codexBridgeRolloutPath, codexBridgeOffset);
|
|
1450
|
+
codexBridgeOffset = result.newOffset;
|
|
1451
|
+
codexBridgePendingTail = result.pendingTail;
|
|
1452
|
+
codexBridgeQueue.ingest(result.events);
|
|
1453
|
+
// Transcript-driven idle: an `assistant_final` event is the CLI declaring
|
|
1454
|
+
// end-of-turn, far more reliable than the screen-pattern heuristic
|
|
1455
|
+
// (CoCo's status bar varies by --yolo flag, version, theme; codex has
|
|
1456
|
+
// its own moving targets). Pushing idle here lets the bridge emit
|
|
1457
|
+
// immediately instead of waiting for readyPattern + quiescence to
|
|
1458
|
+
// converge. Idempotent — IdleDetector.fireIdle no-ops while already idle.
|
|
1459
|
+
if (result.events.some(e => e.kind === 'assistant_final')) {
|
|
1460
|
+
idleDetector?.fireIdle();
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
/** Mark a pending Lark turn for Codex. Crucially this works even before a
|
|
1464
|
+
* rollout path is known — the queue is path-agnostic, and ingest after
|
|
1465
|
+
* late-attach picks up the user_message and matches the fingerprint. */
|
|
1466
|
+
function codexBridgeMarkPendingTurn(messageText) {
|
|
1467
|
+
if (!codexBridgeFallbackActive())
|
|
1468
|
+
return false;
|
|
1469
|
+
const turnId = `codex-${randomBytes(8).toString('hex')}`;
|
|
1470
|
+
codexBridgeQueue.mark(turnId, messageText);
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
function codexBridgeDrainAndMaybeEmit() {
|
|
1474
|
+
if (!codexBridgeFallbackActive())
|
|
1475
|
+
return;
|
|
1476
|
+
if (codexBridgeRolloutPath && codexBridgeBaselineDone) {
|
|
1477
|
+
try {
|
|
1478
|
+
codexBridgeIngest();
|
|
1479
|
+
}
|
|
1480
|
+
catch (err) {
|
|
1481
|
+
log(`Codex bridge ingest error: ${err.message}`);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
emitReadyCodexTurns();
|
|
1485
|
+
}
|
|
1486
|
+
function emitReadyCodexTurns() {
|
|
1487
|
+
const ready = codexBridgeQueue.drainEmittable();
|
|
1488
|
+
if (ready.length === 0)
|
|
1489
|
+
return;
|
|
1490
|
+
const adoptMode = lastInitConfig?.adoptMode === true;
|
|
1491
|
+
// Adopt mode: model is the user's external Codex, no botmux send to
|
|
1492
|
+
// gate against — every assistant turn (Lark-driven OR locally typed)
|
|
1493
|
+
// should reach the thread. Skip marker IO entirely.
|
|
1494
|
+
const markers = adoptMode ? [] : readSendMarkers();
|
|
1495
|
+
const remaining = codexBridgeQueue.peek();
|
|
1496
|
+
const nextPendingMarkTimeMs = remaining.length > 0 ? remaining[0].markTimeMs : undefined;
|
|
1497
|
+
for (let i = 0; i < ready.length; i++) {
|
|
1498
|
+
const turn = ready[i];
|
|
1499
|
+
if (!turn.finalText)
|
|
1500
|
+
continue;
|
|
1501
|
+
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
|
|
1502
|
+
if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
|
|
1503
|
+
log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
if (turn.isLocal) {
|
|
1507
|
+
// Local turn (adopt only): user typed in iTerm. Surface both sides
|
|
1508
|
+
// so the Lark thread sees a complete exchange instead of an orphan
|
|
1509
|
+
// reply. formatLocalTurnFields caps both texts to keep within
|
|
1510
|
+
// Lark's per-message limit; daemon owns the card chrome.
|
|
1511
|
+
const fields = formatLocalTurnFields(turn.userText ?? '', turn.finalText);
|
|
1512
|
+
if (!fields)
|
|
1513
|
+
continue;
|
|
1514
|
+
send({
|
|
1515
|
+
type: 'final_output',
|
|
1516
|
+
content: fields.content,
|
|
1517
|
+
lastUuid: turn.turnId,
|
|
1518
|
+
turnId: turn.turnId,
|
|
1519
|
+
kind: 'local-turn',
|
|
1520
|
+
userText: fields.userText,
|
|
1521
|
+
});
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
send({ type: 'final_output', content: turn.finalText, lastUuid: turn.turnId, turnId: turn.turnId });
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function stopCodexBridge() {
|
|
1528
|
+
if (codexBridgeWatcher) {
|
|
1529
|
+
try {
|
|
1530
|
+
codexBridgeWatcher.close();
|
|
1531
|
+
}
|
|
1532
|
+
catch { /* ignore */ }
|
|
1533
|
+
codexBridgeWatcher = null;
|
|
1534
|
+
}
|
|
1535
|
+
if (codexBridgeTimer) {
|
|
1536
|
+
clearInterval(codexBridgeTimer);
|
|
1537
|
+
codexBridgeTimer = null;
|
|
1538
|
+
}
|
|
1539
|
+
codexBridgeRolloutPath = undefined;
|
|
1540
|
+
codexBridgeOffset = 0;
|
|
1541
|
+
codexBridgePendingTail = '';
|
|
1542
|
+
codexBridgeBaselineDone = false;
|
|
1543
|
+
codexBridgeQueue.clearPending();
|
|
1544
|
+
codexBridgeQueue.setLocalTurns(false);
|
|
1545
|
+
codexBridgePendingSessionId = undefined;
|
|
1546
|
+
codexAdoptPendingPid = undefined;
|
|
1547
|
+
codexAdoptStartMs = undefined;
|
|
1548
|
+
}
|
|
1549
|
+
/** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
|
|
1550
|
+
* whose sourceJsonlPath equals oldPath may still be waiting on assistant
|
|
1551
|
+
* text that hasn't landed yet. Add oldPath to the secondary polling set
|
|
1552
|
+
* so subsequent ingests continue to drain it; the offset is whatever was
|
|
1553
|
+
* reached by the final pre-switch drain so we don't re-scan history. The
|
|
1554
|
+
* entry is later pruned after each idle emit when no started turn
|
|
1555
|
+
* references it anymore. */
|
|
1556
|
+
function retainSecondaryPathIfStillReferenced(oldPath, postDrainOffset) {
|
|
1557
|
+
const stillReferenced = bridgeQueue.peek().some(t => t.sourceJsonlPath === oldPath);
|
|
1558
|
+
if (!stillReferenced)
|
|
1559
|
+
return;
|
|
1560
|
+
const existing = bridgeSecondaryPaths.get(oldPath);
|
|
1561
|
+
// Don't rewind a higher existing offset — multiple rotations through
|
|
1562
|
+
// the same file shouldn't replay drained bytes.
|
|
1563
|
+
if (existing === undefined || postDrainOffset > existing) {
|
|
1564
|
+
bridgeSecondaryPaths.set(oldPath, postDrainOffset);
|
|
1565
|
+
}
|
|
1566
|
+
log(`Bridge retaining secondary path ${oldPath} (offset=${postDrainOffset}) for in-flight turn`);
|
|
1567
|
+
}
|
|
1568
|
+
/** Drain every secondary path once. Mirrors bridgeIngest's primary-path
|
|
1569
|
+
* drain but never touches bridgeJsonlPath / bridgeOffset and never
|
|
1570
|
+
* triggers further rotation checks — it's strictly a "catch up trailing
|
|
1571
|
+
* events on an old file" pass. */
|
|
1572
|
+
function drainSecondaryPaths() {
|
|
1573
|
+
for (const [path, offset] of bridgeSecondaryPaths) {
|
|
1574
|
+
try {
|
|
1575
|
+
const result = drainTranscript(path, offset);
|
|
1576
|
+
if (result.events.length > 0)
|
|
1577
|
+
bridgeQueue.ingest(result.events, path);
|
|
1578
|
+
bridgeSecondaryPaths.set(path, result.newOffset);
|
|
1579
|
+
}
|
|
1580
|
+
catch (err) {
|
|
1581
|
+
log(`Bridge secondary-path drain failed (${path}): ${err.message}`);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
/** Drop secondary paths whose started turns are no longer in the queue —
|
|
1586
|
+
* i.e. they've been emitted (or discarded). Called after each idle emit so
|
|
1587
|
+
* pruning never races with an in-flight turn. */
|
|
1588
|
+
function pruneSecondaryPaths() {
|
|
1589
|
+
if (bridgeSecondaryPaths.size === 0)
|
|
1590
|
+
return;
|
|
1591
|
+
const referenced = new Set();
|
|
1592
|
+
for (const t of bridgeQueue.peek()) {
|
|
1593
|
+
if (t.sourceJsonlPath)
|
|
1594
|
+
referenced.add(t.sourceJsonlPath);
|
|
1595
|
+
}
|
|
1596
|
+
for (const path of [...bridgeSecondaryPaths.keys()]) {
|
|
1597
|
+
if (!referenced.has(path)) {
|
|
1598
|
+
bridgeSecondaryPaths.delete(path);
|
|
1599
|
+
log(`Bridge dropped secondary path ${path} (no remaining turns)`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
/** Tiny safe-existence check that doesn't throw. */
|
|
1604
|
+
function existsSyncSafe(p) {
|
|
1605
|
+
try {
|
|
1606
|
+
return existsSync(p);
|
|
1607
|
+
}
|
|
1608
|
+
catch {
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
54
1612
|
/** Suppress screen updates until first prompt detected (avoids history replay in card on --resume) */
|
|
55
1613
|
let awaitingFirstPrompt = true;
|
|
56
1614
|
// ─── PTY Dimensions ──────────────────────────────────────────────────────────
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
1615
|
+
// Default for botmux-spawned CLIs: narrow enough for the web terminal to
|
|
1616
|
+
// render comfortably and for the card PNG to fit Lark's typical card width.
|
|
1617
|
+
// Adopt mode overrides this via resolveRenderDimensions() to match the
|
|
1618
|
+
// user's actual pane (often 200-270 cols) so the renderer doesn't wrap
|
|
1619
|
+
// wide ANSI into a stair-stepped / duplicated mess.
|
|
1620
|
+
const PTY_COLS = DEFAULT_RENDER_COLS;
|
|
1621
|
+
const PTY_ROWS = DEFAULT_RENDER_ROWS;
|
|
1622
|
+
/** Set in the `init` handler BEFORE startScreenUpdates() so the headless
|
|
1623
|
+
* xterm + screenshot canvas are sized to the source pane from the start.
|
|
1624
|
+
* Setting them later (after the renderer was built at the default size)
|
|
1625
|
+
* wouldn't retroactively re-size what xterm has already buffered,
|
|
1626
|
+
* leaving the wrap artefacts in place. */
|
|
1627
|
+
let renderCols = PTY_COLS;
|
|
1628
|
+
let renderRows = PTY_ROWS;
|
|
61
1629
|
// ─── Headless Terminal for Screen Capture ────────────────────────────────────
|
|
62
1630
|
let renderer = null;
|
|
1631
|
+
/** Most recent unfiltered viewport text — kept in sync by the screen_update
|
|
1632
|
+
* timer for pipe-pane backends so ScreenAnalyzer (which is synchronous) has
|
|
1633
|
+
* a fresh snapshot to read without needing its own tmux capture-pane call. */
|
|
1634
|
+
let lastAnalyzerSnapshot = '';
|
|
63
1635
|
let screenUpdateTimer = null;
|
|
64
1636
|
const SCREEN_UPDATE_INTERVAL_MS = 2_000;
|
|
65
1637
|
// ─── Scrollback Buffer (replay to late-connecting WS clients) ───────────────
|
|
@@ -93,7 +1665,14 @@ function startScreenAnalyzer() {
|
|
|
93
1665
|
extraHeaders: sa.extraHeaders,
|
|
94
1666
|
extraBody: sa.extraBody,
|
|
95
1667
|
}, {
|
|
96
|
-
getSnapshot: () =>
|
|
1668
|
+
getSnapshot: () => {
|
|
1669
|
+
// ScreenAnalyzer is called every ~5s for TUI-prompt detection. We
|
|
1670
|
+
// can't make this async without overhauling the analyzer, so cache
|
|
1671
|
+
// the last pipe-pane text snapshot here and refresh it eagerly.
|
|
1672
|
+
// For pipe-pane backends, the cache is repopulated by the screen
|
|
1673
|
+
// update timer; for others, fall through to the long-lived renderer.
|
|
1674
|
+
return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
|
|
1675
|
+
},
|
|
97
1676
|
onAnalyzing: () => { },
|
|
98
1677
|
onTuiPrompt: (description, options, multiSelect) => {
|
|
99
1678
|
tuiPromptBlocking = true;
|
|
@@ -117,8 +1696,10 @@ function stopScreenAnalyzer() {
|
|
|
117
1696
|
// ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
|
|
118
1697
|
const SCREENSHOT_INTERVAL_MS = 10_000;
|
|
119
1698
|
const POST_ACTION_DELAY_MS = 1_000;
|
|
120
|
-
|
|
121
|
-
|
|
1699
|
+
// PNG dimensions key off the renderer's actual size (renderCols / renderRows),
|
|
1700
|
+
// which adopt-mode peg to the source pane so wrap artefacts don't appear.
|
|
1701
|
+
// Re-clamping at MAX_RENDER_COLS/ROWS guards against a malformed init
|
|
1702
|
+
// payload sneaking past the resolver into a runaway canvas.
|
|
122
1703
|
let displayMode = 'hidden';
|
|
123
1704
|
let screenshotTimer = null;
|
|
124
1705
|
let pendingShotTimer = null;
|
|
@@ -128,10 +1709,12 @@ let larkAppSecretForUpload = '';
|
|
|
128
1709
|
function startScreenshotLoop() {
|
|
129
1710
|
stopScreenshotLoop();
|
|
130
1711
|
screenshotTimer = setInterval(() => { void captureAndUpload(); }, SCREENSHOT_INTERVAL_MS);
|
|
1712
|
+
log(`Screenshot loop started (interval=${SCREENSHOT_INTERVAL_MS}ms)`);
|
|
131
1713
|
// Capture immediately so the user gets a first frame fast
|
|
132
1714
|
void captureAndUpload();
|
|
133
1715
|
}
|
|
134
1716
|
function stopScreenshotLoop() {
|
|
1717
|
+
const wasRunning = !!screenshotTimer || !!pendingShotTimer;
|
|
135
1718
|
if (screenshotTimer) {
|
|
136
1719
|
clearInterval(screenshotTimer);
|
|
137
1720
|
screenshotTimer = null;
|
|
@@ -140,6 +1723,26 @@ function stopScreenshotLoop() {
|
|
|
140
1723
|
clearTimeout(pendingShotTimer);
|
|
141
1724
|
pendingShotTimer = null;
|
|
142
1725
|
}
|
|
1726
|
+
if (wasRunning)
|
|
1727
|
+
log('Screenshot loop stopped');
|
|
1728
|
+
}
|
|
1729
|
+
// Throttle silent-skip reasons so a wedged worker prints why once every 30s
|
|
1730
|
+
// without spamming. Each distinct reason has its own throttle clock.
|
|
1731
|
+
const screenshotSkipLogState = {};
|
|
1732
|
+
function logScreenshotSkip(reason) {
|
|
1733
|
+
const now = Date.now();
|
|
1734
|
+
if (now - (screenshotSkipLogState[reason] ?? 0) < 30_000)
|
|
1735
|
+
return;
|
|
1736
|
+
screenshotSkipLogState[reason] = now;
|
|
1737
|
+
log(`Screenshot skipped: ${reason}`);
|
|
1738
|
+
}
|
|
1739
|
+
// Worker stderr is piped through worker-pool, where most CLI stderr stays at
|
|
1740
|
+
// info level to avoid polluting error.log. Mark true worker faults so the
|
|
1741
|
+
// parent can selectively promote only these lines to logger.error.
|
|
1742
|
+
const WORKER_ERROR_MARKER = '[botmux-worker-error]';
|
|
1743
|
+
function logError(msg) {
|
|
1744
|
+
const ts = new Date().toISOString();
|
|
1745
|
+
process.stderr.write(`[${ts}] [worker:${sessionId.substring(0, 8) || '??'}] ${WORKER_ERROR_MARKER} ${msg}\n`);
|
|
143
1746
|
}
|
|
144
1747
|
/** Schedule a single capture +1s, then resume the regular 10s cadence. */
|
|
145
1748
|
function scheduleOneShotAfterAction() {
|
|
@@ -160,28 +1763,55 @@ function scheduleOneShotAfterAction() {
|
|
|
160
1763
|
}, POST_ACTION_DELAY_MS);
|
|
161
1764
|
}
|
|
162
1765
|
async function captureAndUpload() {
|
|
163
|
-
|
|
1766
|
+
// displayMode mismatch should be impossible during a running loop (start/stop
|
|
1767
|
+
// gate on it). Logging here exists to surface the unexpected case — e.g. a
|
|
1768
|
+
// stray scheduleOneShotAfterAction firing after user toggled back to hidden.
|
|
1769
|
+
if (displayMode !== 'screenshot') {
|
|
1770
|
+
logScreenshotSkip(`displayMode=${displayMode}`);
|
|
164
1771
|
return;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return;
|
|
169
|
-
if (!larkAppIdForUpload || !larkAppSecretForUpload)
|
|
1772
|
+
}
|
|
1773
|
+
if (awaitingFirstPrompt) {
|
|
1774
|
+
logScreenshotSkip('awaitingFirstPrompt');
|
|
170
1775
|
return;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const snap = renderer.rawSnapshot();
|
|
175
|
-
const hash = createHash('md5').update(snap).digest('hex');
|
|
176
|
-
if (hash === lastShotHash)
|
|
1776
|
+
}
|
|
1777
|
+
if (!larkAppIdForUpload || !larkAppSecretForUpload) {
|
|
1778
|
+
logScreenshotSkip('lark credentials missing');
|
|
177
1779
|
return;
|
|
178
|
-
|
|
1780
|
+
}
|
|
179
1781
|
let png;
|
|
180
1782
|
try {
|
|
181
|
-
|
|
1783
|
+
// Preferred path: pipe-pane backends ask tmux for a fresh viewport
|
|
1784
|
+
// snapshot and render it through a transient xterm-headless. This
|
|
1785
|
+
// avoids the accumulated-buffer drift that produced duplicated /
|
|
1786
|
+
// staircase content under the legacy long-lived renderer.
|
|
1787
|
+
const pipeResult = await snapshotToPng(backend, renderCols, renderRows);
|
|
1788
|
+
if (pipeResult) {
|
|
1789
|
+
if (pipeResult.ansi === lastShotHash)
|
|
1790
|
+
return;
|
|
1791
|
+
lastShotHash = pipeResult.ansi;
|
|
1792
|
+
png = pipeResult.png;
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
// Fallback path: non-pipe backends (PtyBackend, legacy TmuxBackend)
|
|
1796
|
+
// still drive the long-lived renderer.
|
|
1797
|
+
if (!renderer) {
|
|
1798
|
+
logScreenshotSkip('renderer=null');
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const term = renderer.xterm;
|
|
1802
|
+
const startY = term.buffer.active.baseY;
|
|
1803
|
+
const snap = renderer.rawSnapshot();
|
|
1804
|
+
const hash = createHash('md5').update(snap).digest('hex');
|
|
1805
|
+
if (hash === lastShotHash)
|
|
1806
|
+
return;
|
|
1807
|
+
lastShotHash = hash;
|
|
1808
|
+
const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
|
|
1809
|
+
const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
|
|
1810
|
+
png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
|
|
1811
|
+
}
|
|
182
1812
|
}
|
|
183
1813
|
catch (err) {
|
|
184
|
-
|
|
1814
|
+
logError(`Screenshot render failed: ${err?.message ?? err}`);
|
|
185
1815
|
return;
|
|
186
1816
|
}
|
|
187
1817
|
let imageKey;
|
|
@@ -189,7 +1819,7 @@ async function captureAndUpload() {
|
|
|
189
1819
|
imageKey = await uploadImageBuffer(larkAppIdForUpload, larkAppSecretForUpload, png);
|
|
190
1820
|
}
|
|
191
1821
|
catch (err) {
|
|
192
|
-
|
|
1822
|
+
logError(`Screenshot upload failed: ${err?.message ?? err}`);
|
|
193
1823
|
return;
|
|
194
1824
|
}
|
|
195
1825
|
let status = isPromptReady ? 'idle' : 'working';
|
|
@@ -377,9 +2007,11 @@ let trustHandled = false;
|
|
|
377
2007
|
// ─── Prompt Detection ────────────────────────────────────────────────────────
|
|
378
2008
|
function onPtyData(data) {
|
|
379
2009
|
renderer?.write(data);
|
|
380
|
-
// In tmux mode, web
|
|
381
|
-
// In non-tmux mode
|
|
382
|
-
|
|
2010
|
+
// In tmux-attach mode, each web client has its own tmux attach PTY —
|
|
2011
|
+
// no relay needed. In non-tmux mode AND in pipe mode (adopt-bridge),
|
|
2012
|
+
// broadcast through the shared scrollback so all connected web clients
|
|
2013
|
+
// render the same byte stream.
|
|
2014
|
+
if (!isTmuxMode || isPipeMode) {
|
|
383
2015
|
// Track alt-buffer state so we can restore it in the scrollback prefix.
|
|
384
2016
|
// Scan for the *last* toggle in this chunk — that's the current state.
|
|
385
2017
|
let lastToggleIdx = -1;
|
|
@@ -453,6 +2085,71 @@ function markPromptReady() {
|
|
|
453
2085
|
}
|
|
454
2086
|
flushPending();
|
|
455
2087
|
}
|
|
2088
|
+
function persistCliSessionId(cliSessionId) {
|
|
2089
|
+
if (!cliSessionId || !sessionId)
|
|
2090
|
+
return;
|
|
2091
|
+
if (lastInitConfig)
|
|
2092
|
+
lastInitConfig.cliSessionId = cliSessionId;
|
|
2093
|
+
try {
|
|
2094
|
+
const session = sessionStore.getSession(sessionId);
|
|
2095
|
+
if (!session || session.cliSessionId === cliSessionId)
|
|
2096
|
+
return;
|
|
2097
|
+
session.cliSessionId = cliSessionId;
|
|
2098
|
+
sessionStore.updateSession(session);
|
|
2099
|
+
log(`Persisted CLI session id: ${cliSessionId}`);
|
|
2100
|
+
}
|
|
2101
|
+
catch (err) {
|
|
2102
|
+
log(`Failed to persist CLI session id: ${err.message}`);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
/** How long to wait before re-checking whether a submit-not-confirmed message
|
|
2106
|
+
* eventually landed. Cold-start sessions and slow third-party hooks
|
|
2107
|
+
* (UserPromptSubmit, SessionStart — e.g. superpowers' large skill injection)
|
|
2108
|
+
* can defer Claude's jsonl append by 5–15s; a 20s deferred recheck covers
|
|
2109
|
+
* both without being so long that a true failure goes unsurfaced. */
|
|
2110
|
+
const SUBMIT_DEFERRED_RECHECK_MS = 20_000;
|
|
2111
|
+
/** Worker-side handler for `submitted: false`. Defers the user-facing
|
|
2112
|
+
* warning and runs the adapter-supplied `recheck` closure first; if the
|
|
2113
|
+
* message has shown up in the transcript by then (slow path, hook delay),
|
|
2114
|
+
* suppresses the warning entirely. Adapters without a recheck still fall
|
|
2115
|
+
* through to the warning after the same delay so the UX is uniform.
|
|
2116
|
+
*
|
|
2117
|
+
* `bridgeTurnId` is the BridgeTurnQueue mark created right before the
|
|
2118
|
+
* failing writeInput. When the deferred recheck conclusively fails (= no
|
|
2119
|
+
* jsonl line will ever match this fingerprint), we drop the mark — leaving
|
|
2120
|
+
* it would keep `maybeSwitchBridgeJsonl` doing full-directory scans every
|
|
2121
|
+
* poll tick for a fingerprint that's permanently dead, the 99% CPU bug
|
|
2122
|
+
* this whole patch series is fixing. */
|
|
2123
|
+
function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId) {
|
|
2124
|
+
const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
|
|
2125
|
+
log(`writeInput: submit not confirmed after retries — deferred ${SUBMIT_DEFERRED_RECHECK_MS}ms recheck queued. preview="${preview}"`);
|
|
2126
|
+
setTimeout(async () => {
|
|
2127
|
+
if (recheck) {
|
|
2128
|
+
try {
|
|
2129
|
+
if (await recheck()) {
|
|
2130
|
+
log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
catch (err) {
|
|
2135
|
+
log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
if (bridgeTurnId) {
|
|
2139
|
+
const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId);
|
|
2140
|
+
if (dropped) {
|
|
2141
|
+
if (dropped.contentFingerprint)
|
|
2142
|
+
bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
|
|
2143
|
+
log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
|
|
2147
|
+
send({
|
|
2148
|
+
type: 'user_notify',
|
|
2149
|
+
message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 后等了 ${Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000)}s 仍未在${transcriptLabel}里看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
|
|
2150
|
+
});
|
|
2151
|
+
}, SUBMIT_DEFERRED_RECHECK_MS);
|
|
2152
|
+
}
|
|
456
2153
|
/**
|
|
457
2154
|
* Drain the pending message queue sequentially.
|
|
458
2155
|
* Async with isFlushing mutex: awaits each writeInput, then immediately
|
|
@@ -466,8 +2163,19 @@ async function flushPending() {
|
|
|
466
2163
|
return;
|
|
467
2164
|
if (pendingMessages.length === 0)
|
|
468
2165
|
return; // nothing to flush — keep isPromptReady
|
|
469
|
-
// Type-ahead adapters flush even while the CLI is busy; others wait for
|
|
470
|
-
|
|
2166
|
+
// Type-ahead adapters flush even while the CLI is busy; others wait for
|
|
2167
|
+
// idle. Claude bridge fallback used to also disable type-ahead because
|
|
2168
|
+
// BridgeTurnQueue.ingest didn't recognise the `attachment(queued_command)`
|
|
2169
|
+
// events Claude writes when it dequeues a queued submit — assistant text
|
|
2170
|
+
// for the type-ahead'd turn was either dropped or attributed to the wrong
|
|
2171
|
+
// Lark message. Now that the queue handles queued_command identically to
|
|
2172
|
+
// role:user (and overrides markTimeMs to the dequeue-time event timestamp
|
|
2173
|
+
// so the gate window is correct), Claude bridge can run with type-ahead
|
|
2174
|
+
// again. Codex bridge stays serial because its queue hasn't been upgraded.
|
|
2175
|
+
const claudeBridgeActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
|
|
2176
|
+
const codexBridgeActive = codexBridgeFallbackActive();
|
|
2177
|
+
const typeAheadAllowed = cliAdapter.supportsTypeAhead && !codexBridgeActive;
|
|
2178
|
+
if (!isPromptReady && !typeAheadAllowed)
|
|
471
2179
|
return;
|
|
472
2180
|
isFlushing = true;
|
|
473
2181
|
if (isPromptReady) {
|
|
@@ -477,16 +2185,56 @@ async function flushPending() {
|
|
|
477
2185
|
try {
|
|
478
2186
|
while (pendingMessages.length > 0 && backend && cliAdapter) {
|
|
479
2187
|
const msg = pendingMessages.shift();
|
|
2188
|
+
// Bridge fallback: mark immediately before writeInput. Doing it here
|
|
2189
|
+
// (instead of at enqueue time) means markTimeMs anchors to the
|
|
2190
|
+
// moment the message actually starts hitting the PTY — so any
|
|
2191
|
+
// `botmux send` whose sentAtMs lands during turn N's processing
|
|
2192
|
+
// falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
|
|
2193
|
+
// (at IPC arrival) would let a slow-finishing turn N's send leak
|
|
2194
|
+
// into turn N+1's window and falsely suppress its emit.
|
|
2195
|
+
let bridgeTurnId;
|
|
2196
|
+
if (claudeBridgeActive) {
|
|
2197
|
+
try {
|
|
2198
|
+
bridgeIngest();
|
|
2199
|
+
}
|
|
2200
|
+
catch { /* best-effort */ }
|
|
2201
|
+
bridgeTurnId = bridgeMarkPendingTurn(msg);
|
|
2202
|
+
}
|
|
2203
|
+
else if (codexBridgeActive) {
|
|
2204
|
+
// Codex mark works even before the rollout path is known: the
|
|
2205
|
+
// queue is path-agnostic, and the late-attach below will start
|
|
2206
|
+
// ingest from offset 0 so the user_message that lands shortly
|
|
2207
|
+
// after still fingerprint-matches this turn.
|
|
2208
|
+
codexBridgeMarkPendingTurn(msg);
|
|
2209
|
+
}
|
|
480
2210
|
log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
|
|
481
2211
|
const result = await cliAdapter.writeInput(backend, msg);
|
|
2212
|
+
// Persist any sessionId the adapter observed via authoritative sources
|
|
2213
|
+
// (Claude's pid file, Codex's history). Done independently of submit
|
|
2214
|
+
// outcome — the rotation is real even when the current Enter didn't
|
|
2215
|
+
// land, and we want next-resume to use the right id.
|
|
2216
|
+
if (result?.cliSessionId) {
|
|
2217
|
+
persistCliSessionId(result.cliSessionId);
|
|
2218
|
+
// First successful Codex submit also reveals the rollout path.
|
|
2219
|
+
// Late-attach now so subsequent assistant_final events get
|
|
2220
|
+
// attributed to this turn.
|
|
2221
|
+
if (codexBridgeActive)
|
|
2222
|
+
codexBridgeNotifyCliSessionId(result.cliSessionId);
|
|
2223
|
+
}
|
|
482
2224
|
if (result && result.submitted === false) {
|
|
483
|
-
|
|
484
|
-
log(`writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
|
|
485
|
-
send({
|
|
486
|
-
type: 'user_notify',
|
|
487
|
-
message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 3 次仍未在会话 JSONL 中看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
|
|
488
|
-
});
|
|
2225
|
+
scheduleSubmitFailureNotify(msg, result.recheck, '会话 JSONL', bridgeTurnId);
|
|
489
2226
|
}
|
|
2227
|
+
// Codex bridge: stop after one writeInput per idle cycle. Codex's
|
|
2228
|
+
// bridge queue doesn't yet attribute queued_command-equivalents, so
|
|
2229
|
+
// type-ahead'd submits would have their assistant text dropped or
|
|
2230
|
+
// mis-attributed. We resume on the next idle, by which point Codex
|
|
2231
|
+
// has finished and the next message can be a normal user_message
|
|
2232
|
+
// submit. Claude bridge no longer takes this break — its
|
|
2233
|
+
// BridgeTurnQueue handles `attachment(queued_command)` events
|
|
2234
|
+
// identically to `role:user`, so type-ahead'd turns are correctly
|
|
2235
|
+
// attributed and no longer need the serial-per-idle guard.
|
|
2236
|
+
if (codexBridgeActive && pendingMessages.length > 0)
|
|
2237
|
+
break;
|
|
490
2238
|
}
|
|
491
2239
|
}
|
|
492
2240
|
finally {
|
|
@@ -497,11 +2245,26 @@ function sendToPty(content) {
|
|
|
497
2245
|
if (!backend || !cliAdapter)
|
|
498
2246
|
return;
|
|
499
2247
|
pendingMessages.push(content);
|
|
2248
|
+
// User-override semantics: a fresh Lark message while a TUI prompt is "active"
|
|
2249
|
+
// takes precedence over the AI-detected prompt. The screen analyzer can be
|
|
2250
|
+
// wrong (false positive on a question that has no rendered options) and a
|
|
2251
|
+
// wedged blocking flag silently swallows every subsequent message — without
|
|
2252
|
+
// this override the user has no way to recover from Lark. Mirrors the
|
|
2253
|
+
// web-terminal text-input path (handleTuiTextInput).
|
|
500
2254
|
if (tuiPromptBlocking) {
|
|
501
|
-
log(`
|
|
502
|
-
|
|
2255
|
+
log(`User override: incoming Lark message clears tuiPromptBlocking — "${content.substring(0, 80)}"`);
|
|
2256
|
+
tuiPromptBlocking = false;
|
|
2257
|
+
screenAnalyzer?.notifySelection('lark-input');
|
|
2258
|
+
// Tear down the prompt card so the user doesn't see stale options.
|
|
2259
|
+
send({ type: 'tui_prompt_resolved', selectedText: 'user-override' });
|
|
503
2260
|
}
|
|
504
|
-
|
|
2261
|
+
// See flushPending: only Codex bridge still serialises type-ahead.
|
|
2262
|
+
// Claude bridge now attributes `attachment(queued_command)` events
|
|
2263
|
+
// identically to `role:user`, so type-ahead'd submits land in the right
|
|
2264
|
+
// turn and we no longer need to gate the entry path on claudeBridgeActive.
|
|
2265
|
+
const codexBridgeActive = codexBridgeFallbackActive();
|
|
2266
|
+
const typeAheadAllowed = cliAdapter.supportsTypeAhead && !codexBridgeActive;
|
|
2267
|
+
if (isPromptReady || isFlushing || typeAheadAllowed) {
|
|
505
2268
|
log(`Writing to PTY: "${content.substring(0, 80)}"`);
|
|
506
2269
|
flushPending(); // fire-and-forget async; no-op if already flushing
|
|
507
2270
|
}
|
|
@@ -511,20 +2274,53 @@ function sendToPty(content) {
|
|
|
511
2274
|
}
|
|
512
2275
|
// ─── Screen Update Timer ─────────────────────────────────────────────────────
|
|
513
2276
|
function startScreenUpdates() {
|
|
514
|
-
|
|
2277
|
+
// renderCols / renderRows were set by the init handler from cfg, so
|
|
2278
|
+
// adopt-mode panes (e.g. 270x57) get an xterm-headless of matching
|
|
2279
|
+
// width. With a too-narrow renderer, ANSI meant for the source pane
|
|
2280
|
+
// would wrap and the screenshot would show duplicated / stair-stepped
|
|
2281
|
+
// content (the live failure that prompted this fix).
|
|
2282
|
+
renderer = new TerminalRenderer(renderCols, renderRows);
|
|
515
2283
|
let lastSentStatus;
|
|
2284
|
+
let lastTextSnapshotHash = '';
|
|
516
2285
|
screenUpdateTimer = setInterval(() => {
|
|
517
|
-
if (
|
|
2286
|
+
if (awaitingFirstPrompt)
|
|
518
2287
|
return;
|
|
519
|
-
const { content, changed } = renderer.snapshot();
|
|
520
2288
|
let status = isPromptReady ? 'idle' : 'working';
|
|
521
2289
|
if (screenAnalyzer?.isAnalyzing)
|
|
522
2290
|
status = 'analyzing';
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
2291
|
+
void (async () => {
|
|
2292
|
+
let content;
|
|
2293
|
+
let changed;
|
|
2294
|
+
// Preferred path: pipe-pane backends pull a fresh viewport snapshot
|
|
2295
|
+
// from tmux every tick. This eliminates the accumulated-buffer drift
|
|
2296
|
+
// that produced duplicated/staircase text in 'text' display mode.
|
|
2297
|
+
const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
|
|
2298
|
+
if (pipeText) {
|
|
2299
|
+
content = pipeText.content;
|
|
2300
|
+
const hash = pipeText.ansi;
|
|
2301
|
+
changed = hash !== lastTextSnapshotHash;
|
|
2302
|
+
lastTextSnapshotHash = hash;
|
|
2303
|
+
// Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
|
|
2304
|
+
// tmux call would otherwise need to fire twice per tick.
|
|
2305
|
+
if (changed) {
|
|
2306
|
+
const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
|
|
2307
|
+
if (rawSnap)
|
|
2308
|
+
lastAnalyzerSnapshot = rawSnap.content;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
else if (renderer) {
|
|
2312
|
+
const snap = renderer.snapshot();
|
|
2313
|
+
content = snap.content;
|
|
2314
|
+
changed = snap.changed;
|
|
2315
|
+
}
|
|
2316
|
+
else {
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
if (changed || status !== lastSentStatus) {
|
|
2320
|
+
lastSentStatus = status;
|
|
2321
|
+
send({ type: 'screen_update', content, status });
|
|
2322
|
+
}
|
|
2323
|
+
})();
|
|
528
2324
|
}, SCREEN_UPDATE_INTERVAL_MS);
|
|
529
2325
|
}
|
|
530
2326
|
function stopScreenUpdates() {
|
|
@@ -536,46 +2332,196 @@ function stopScreenUpdates() {
|
|
|
536
2332
|
renderer.dispose();
|
|
537
2333
|
renderer = null;
|
|
538
2334
|
}
|
|
2335
|
+
lastAnalyzerSnapshot = '';
|
|
539
2336
|
}
|
|
540
2337
|
// ─── PTY Management ──────────────────────────────────────────────────────────
|
|
541
2338
|
function spawnCli(cfg) {
|
|
542
|
-
// ── Adopt mode:
|
|
2339
|
+
// ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
|
|
543
2340
|
if (cfg.adoptMode && cfg.adoptTmuxTarget) {
|
|
2341
|
+
// We mark BOTH isTmuxMode and isPipeMode: the former keeps idle/spawn
|
|
2342
|
+
// logic on the tmux track; the latter tells the WS handler to route
|
|
2343
|
+
// updates through the shared scrollback fan-out (because there is no
|
|
2344
|
+
// PTY-per-WS — we don't attach to anything).
|
|
544
2345
|
isTmuxMode = true;
|
|
2346
|
+
isPipeMode = true;
|
|
545
2347
|
const cols = cfg.adoptPaneCols ?? PTY_COLS;
|
|
546
2348
|
const rows = cfg.adoptPaneRows ?? PTY_ROWS;
|
|
547
|
-
const
|
|
548
|
-
backend =
|
|
549
|
-
|
|
2349
|
+
const pipeBe = new TmuxPipeBackend(cfg.adoptTmuxTarget);
|
|
2350
|
+
backend = pipeBe;
|
|
2351
|
+
pipeBe.spawn('', [], {
|
|
550
2352
|
cwd: cfg.workingDir,
|
|
551
2353
|
cols,
|
|
552
2354
|
rows,
|
|
553
2355
|
env: process.env,
|
|
554
2356
|
});
|
|
555
|
-
//
|
|
556
|
-
|
|
2357
|
+
// Seed the shared scrollback with the pane's current screen so any
|
|
2358
|
+
// already-connected (or future) WS clients render meaningful content
|
|
2359
|
+
// immediately, instead of waiting for the next byte tmux pipes through.
|
|
2360
|
+
try {
|
|
2361
|
+
const initial = pipeBe.captureCurrentScreen();
|
|
2362
|
+
if (initial.length > 0)
|
|
2363
|
+
onPtyData(initial);
|
|
2364
|
+
}
|
|
2365
|
+
catch (err) {
|
|
2366
|
+
log(`captureCurrentScreen failed: ${err.message}`);
|
|
2367
|
+
}
|
|
2368
|
+
// Bridge mode: tail the adopted CLI's transcript to harvest assistant
|
|
2369
|
+
// turns out-of-band. Two paths:
|
|
2370
|
+
// - claude-code: cfg.bridgeJsonlPath is set when adopt knew the sid.
|
|
2371
|
+
// - codex: locate rollout via cliSessionId (daemon's discovery probe)
|
|
2372
|
+
// or by reading /proc/<pid>/fd. Both modes enable adopt-only local
|
|
2373
|
+
// turn synthesis so iTerm-typed conversation also reaches Lark.
|
|
2374
|
+
if (cfg.bridgeJsonlPath) {
|
|
2375
|
+
startBridgeWatcher(cfg.bridgeJsonlPath, {
|
|
2376
|
+
cliPid: cfg.adoptCliPid,
|
|
2377
|
+
cliCwd: cfg.adoptCwd,
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
else if (cfg.cliId === 'codex') {
|
|
2381
|
+
const adoptStartMs = Date.now();
|
|
2382
|
+
codexAdoptStartMs = adoptStartMs;
|
|
2383
|
+
codexBridgeQueue.setLocalTurns(true, adoptStartMs);
|
|
2384
|
+
let rolloutPath;
|
|
2385
|
+
if (cfg.cliSessionId)
|
|
2386
|
+
rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
|
|
2387
|
+
if (!rolloutPath && cfg.adoptCliPid) {
|
|
2388
|
+
const probed = findCodexRolloutByPid(cfg.adoptCliPid);
|
|
2389
|
+
if (probed)
|
|
2390
|
+
rolloutPath = probed.path;
|
|
2391
|
+
}
|
|
2392
|
+
if (rolloutPath) {
|
|
2393
|
+
// Adopt-time attach: split-live so any iTerm activity that
|
|
2394
|
+
// happened in the brief window between adopt detection and worker
|
|
2395
|
+
// spawn (or between codex's own startup writes and now) lands as
|
|
2396
|
+
// live, not absorbed history.
|
|
2397
|
+
codexBridgeAttach(rolloutPath, 'split-live');
|
|
2398
|
+
}
|
|
2399
|
+
else {
|
|
2400
|
+
// Couldn't locate yet — start poller. The 1s timer keeps trying
|
|
2401
|
+
// both findCodexRolloutBySessionId (if cliSessionId is set) and
|
|
2402
|
+
// findCodexRolloutByPid (passed via the discovery hooks below).
|
|
2403
|
+
if (cfg.cliSessionId)
|
|
2404
|
+
codexBridgePendingSessionId = cfg.cliSessionId;
|
|
2405
|
+
codexAdoptPendingPid = cfg.adoptCliPid;
|
|
2406
|
+
codexBridgeStartTimer();
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
else if (cfg.cliId === 'coco') {
|
|
2410
|
+
// CoCo adopt: parallel to codex, but the events.jsonl path is
|
|
2411
|
+
// deterministic from cliSessionId, so once the daemon-side discovery
|
|
2412
|
+
// surfaced an sid we know the path immediately. The file may not
|
|
2413
|
+
// exist yet (CoCo creates it on first event); codexBridgeAttach's
|
|
2414
|
+
// split-live-with-missing-file branch degrades to fresh, and the
|
|
2415
|
+
// late-attach poller catches re-creation.
|
|
2416
|
+
const adoptStartMs = Date.now();
|
|
2417
|
+
codexAdoptStartMs = adoptStartMs;
|
|
2418
|
+
codexBridgeQueue.setLocalTurns(true, adoptStartMs);
|
|
2419
|
+
let eventsPath;
|
|
2420
|
+
if (cfg.cliSessionId)
|
|
2421
|
+
eventsPath = cocoEventsPathForSession(cfg.cliSessionId);
|
|
2422
|
+
if (!eventsPath && cfg.adoptCliPid) {
|
|
2423
|
+
const probed = findCocoSessionByPid(cfg.adoptCliPid);
|
|
2424
|
+
if (probed)
|
|
2425
|
+
eventsPath = probed.eventsPath;
|
|
2426
|
+
}
|
|
2427
|
+
if (eventsPath) {
|
|
2428
|
+
// If the session DIRECTORY is missing (not just events.jsonl), CoCo
|
|
2429
|
+
// is operating on an unlinked inode — common after an e2e test or
|
|
2430
|
+
// manual cleanup wiped the dir while CoCo kept its fds open. The
|
|
2431
|
+
// bridge file will never appear, so warn the user once via Lark
|
|
2432
|
+
// instead of polling forever in silence.
|
|
2433
|
+
const sessionDir = dirname(eventsPath);
|
|
2434
|
+
if (!existsSync(sessionDir)) {
|
|
2435
|
+
send({
|
|
2436
|
+
type: 'final_output',
|
|
2437
|
+
content: '⚠️ 当前 CoCo 进程的会话目录已被删除(可能是 e2e 测试清理或手动 rm),写到 events.jsonl 的内容会落到一个失效 inode 上,桥接读不到。请重启 CoCo 后重新 /adopt。',
|
|
2438
|
+
lastUuid: `coco-adopt-stale-${randomBytes(4).toString('hex')}`,
|
|
2439
|
+
turnId: 'coco-adopt-stale',
|
|
2440
|
+
});
|
|
2441
|
+
log(`CoCo adopt: session dir missing, bridge disabled (${sessionDir})`);
|
|
2442
|
+
}
|
|
2443
|
+
else {
|
|
2444
|
+
codexBridgeAttach(eventsPath, 'split-live');
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
else {
|
|
2448
|
+
// No sid known yet — fall back to PID-walk in the late-attach
|
|
2449
|
+
// poller. Reuses codexAdoptPendingPid since the timer dispatches
|
|
2450
|
+
// by cliId at probe time (see codexBridgeStartTimer).
|
|
2451
|
+
codexAdoptPendingPid = cfg.adoptCliPid;
|
|
2452
|
+
}
|
|
2453
|
+
// Always run the bridge poller for CoCo adopt — events.jsonl is created
|
|
2454
|
+
// lazily on first event, so fs.watch typically ENOENTs at attach time.
|
|
2455
|
+
// The 1s timer covers ingest + emit even when the watcher never armed,
|
|
2456
|
+
// and is idempotent (no-op if already started).
|
|
2457
|
+
codexBridgeStartTimer();
|
|
2458
|
+
}
|
|
2459
|
+
// Idle detection. In bridge mode we use the adopted CLI's real
|
|
2460
|
+
// completion/ready patterns (e.g. "Worked for Xs") so tool-execution
|
|
2461
|
+
// pauses don't trigger a premature emit. Other adopt cases keep the
|
|
2462
|
+
// minimal output-quiescence-only detector.
|
|
2463
|
+
const idleAdapter = cfg.bridgeJsonlPath
|
|
2464
|
+
? createCliAdapterSync('claude-code', undefined)
|
|
2465
|
+
: cfg.cliId === 'codex' || cfg.cliId === 'coco'
|
|
2466
|
+
? createCliAdapterSync(cfg.cliId, undefined)
|
|
2467
|
+
: { completionPattern: undefined, readyPattern: undefined };
|
|
2468
|
+
idleDetector = new IdleDetector(idleAdapter);
|
|
2469
|
+
// Codex adopt write path: route Lark messages through the codex
|
|
2470
|
+
// adapter's writeInput so they pick up the 200 ms paste-detection
|
|
2471
|
+
// delay + Enter-retry + ~/.codex/history.jsonl verification loop
|
|
2472
|
+
// (see src/adapters/cli/codex.ts:125-178). Without it, Codex TUI's
|
|
2473
|
+
// "\n treated as Enter" handling leaves multi-line submits stuck
|
|
2474
|
+
// in the input box. Other adopt CLIs keep the simpler raw
|
|
2475
|
+
// sendText+Enter path — claude-code adopt has its own bridge
|
|
2476
|
+
// verify path; gemini / coco / opencode / aiden haven't surfaced
|
|
2477
|
+
// this failure mode and we don't want to risk regressing them.
|
|
2478
|
+
if (cfg.cliId === 'codex') {
|
|
2479
|
+
cliAdapter = createCliAdapterSync('codex', cfg.cliPathOverride);
|
|
2480
|
+
}
|
|
557
2481
|
idleDetector.onIdle(() => {
|
|
558
2482
|
log('Prompt detected (idle) — adopt mode');
|
|
2483
|
+
try {
|
|
2484
|
+
bridgeDrainAndMaybeEmit();
|
|
2485
|
+
}
|
|
2486
|
+
catch (err) {
|
|
2487
|
+
log(`Bridge emit error: ${err.message}`);
|
|
2488
|
+
}
|
|
2489
|
+
try {
|
|
2490
|
+
codexBridgeDrainAndMaybeEmit();
|
|
2491
|
+
}
|
|
2492
|
+
catch (err) {
|
|
2493
|
+
log(`Codex bridge emit error: ${err.message}`);
|
|
2494
|
+
}
|
|
559
2495
|
markPromptReady();
|
|
560
2496
|
});
|
|
561
2497
|
backend.onData(onPtyData);
|
|
562
2498
|
backend.onExit((code, signal) => {
|
|
563
|
-
log(`Adopted
|
|
2499
|
+
log(`Adopted pipe-pane stream ended (code: ${code}, signal: ${signal})`);
|
|
564
2500
|
backend = null;
|
|
565
2501
|
isPromptReady = false;
|
|
2502
|
+
stopBridgeWatcher();
|
|
566
2503
|
send({ type: 'claude_exit', code, signal });
|
|
567
2504
|
});
|
|
568
|
-
// CLI is already running — unblock screen updates immediately
|
|
569
2505
|
awaitingFirstPrompt = false;
|
|
570
2506
|
renderer?.markNewTurn();
|
|
571
|
-
log(`Adopt mode:
|
|
2507
|
+
log(`Adopt mode (pipe): observing ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
|
|
572
2508
|
return;
|
|
573
2509
|
}
|
|
574
2510
|
cliAdapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride);
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
2511
|
+
// backendType=tmux trust-but-verify: an explicit per-bot config (or
|
|
2512
|
+
// BACKEND_TYPE=tmux env override) bypasses config.ts's auto-detect, so
|
|
2513
|
+
// the worker re-probes here. If tmux can't start a server we silently
|
|
2514
|
+
// fall back to PTY rather than letting attach-session / new-session spam
|
|
2515
|
+
// the daemon error log every poll cycle.
|
|
2516
|
+
let useTmux = cfg.backendType === 'tmux';
|
|
2517
|
+
if (useTmux && !TmuxBackend.isAvailable()) {
|
|
2518
|
+
log('tmux backend requested but functional probe failed — falling back to PTY backend');
|
|
2519
|
+
useTmux = false;
|
|
2520
|
+
}
|
|
2521
|
+
const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, useTmux });
|
|
2522
|
+
isTmuxMode = selectedBackend.isTmuxMode;
|
|
2523
|
+
isPipeMode = selectedBackend.isPipeMode;
|
|
2524
|
+
backend = selectedBackend.backend;
|
|
579
2525
|
// Claude Code appends a line to ~/.claude/projects/<cwd-hash>/<sid>.jsonl each
|
|
580
2526
|
// time the user submits. The adapter uses this file to verify paste+Enter
|
|
581
2527
|
// actually committed (rather than trusting a fixed sleep), so wire it up now.
|
|
@@ -588,6 +2534,7 @@ function spawnCli(cfg) {
|
|
|
588
2534
|
const args = cliAdapter.buildArgs({
|
|
589
2535
|
sessionId: cfg.sessionId,
|
|
590
2536
|
resume: cfg.resume ?? false,
|
|
2537
|
+
resumeSessionId: cfg.cliSessionId,
|
|
591
2538
|
initialPrompt: cfg.prompt || undefined,
|
|
592
2539
|
botName: cfg.botName,
|
|
593
2540
|
botOpenId: cfg.botOpenId,
|
|
@@ -596,15 +2543,29 @@ function spawnCli(cfg) {
|
|
|
596
2543
|
const extra = (process.env.CLI_EXTRA_ARGS ?? '').trim();
|
|
597
2544
|
if (extra)
|
|
598
2545
|
args.push(...extra.split(/\s+/).filter(Boolean));
|
|
2546
|
+
// Claude Code 在 root/sudo 下会拒绝 --dangerously-skip-permissions 并立即 exit。
|
|
2547
|
+
// botmux 必须带这个 flag(话题里没法弹交互式审批),所以为 root 自动注入
|
|
2548
|
+
// IS_SANDBOX=1 走 Claude Code 的受控环境逃生舱。用户显式设了就尊重不覆盖。
|
|
2549
|
+
const injectClaudeSandbox = cfg.cliId === 'claude-code' &&
|
|
2550
|
+
process.getuid?.() === 0 &&
|
|
2551
|
+
!process.env.IS_SANDBOX;
|
|
2552
|
+
if (injectClaudeSandbox) {
|
|
2553
|
+
log('Detected root user — injecting IS_SANDBOX=1 for Claude Code');
|
|
2554
|
+
}
|
|
599
2555
|
log(`Spawning: ${cliAdapter.resolvedBin} ${args.join(' ')} (cwd: ${cfg.workingDir})`);
|
|
600
2556
|
backend.spawn(cliAdapter.resolvedBin, args, {
|
|
601
2557
|
cwd: cfg.workingDir,
|
|
602
2558
|
cols: PTY_COLS,
|
|
603
2559
|
rows: PTY_ROWS,
|
|
604
|
-
env: {
|
|
2560
|
+
env: {
|
|
2561
|
+
...process.env,
|
|
2562
|
+
CLAUDECODE: undefined,
|
|
2563
|
+
...(injectClaudeSandbox ? { IS_SANDBOX: '1' } : {}),
|
|
2564
|
+
},
|
|
605
2565
|
});
|
|
606
|
-
// Write CLI PID marker so
|
|
607
|
-
//
|
|
2566
|
+
// Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
|
|
2567
|
+
// can verify they were spawned inside a botmux session by walking the
|
|
2568
|
+
// process tree and looking for a matching pid file in this directory.
|
|
608
2569
|
const cliPid = backend.getChildPid?.();
|
|
609
2570
|
if (cliPid && process.env.SESSION_DATA_DIR) {
|
|
610
2571
|
const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
|
|
@@ -618,16 +2579,86 @@ function spawnCli(cfg) {
|
|
|
618
2579
|
log(`Failed to write CLI PID marker: ${err.message}`);
|
|
619
2580
|
}
|
|
620
2581
|
}
|
|
2582
|
+
// Wire pid + cwd so the claude-code adapter's writeInput can read
|
|
2583
|
+
// ~/.claude/sessions/<pid>.json — the spawn-time pid-state record. Its
|
|
2584
|
+
// `sessionId` is set ONCE at process start (Claude Code 2.1.123); a
|
|
2585
|
+
// `--resume` lookup will surface here, but in-pane `/clear` won't, so a
|
|
2586
|
+
// 'matching sessionId' answer is "no spawn-time rotation observed", not
|
|
2587
|
+
// "no rotation at all". The pinned claudeJsonlPath above is still the
|
|
2588
|
+
// initial guess; the resolver corrects it on first write when Claude was
|
|
2589
|
+
// started with `--resume`.
|
|
2590
|
+
if (cfg.cliId === 'claude-code' && cliPid) {
|
|
2591
|
+
backend.cliPid = cliPid;
|
|
2592
|
+
backend.cliCwd = cfg.workingDir;
|
|
2593
|
+
}
|
|
621
2594
|
// On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
|
|
622
2595
|
// suppressed until the idle detector fires markNewTurn() — this prevents the
|
|
623
2596
|
// full tmux scrollback history from leaking into the streaming card.
|
|
624
|
-
|
|
625
|
-
|
|
2597
|
+
// Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
|
|
2598
|
+
// turn the model finishes WITHOUT calling `botmux send` still gets its
|
|
2599
|
+
// assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
|
|
2600
|
+
// the emit when a send did happen). Adopt mode wires this up separately
|
|
2601
|
+
// (with baseline-existing); here we use fresh-empty for new sessions so
|
|
2602
|
+
// the file Claude creates on first submit isn't absorbed as history,
|
|
2603
|
+
// and baseline-existing on resume so prior-run turns ARE absorbed (we
|
|
2604
|
+
// don't want to re-emit yesterday's conversation as fresh turns).
|
|
2605
|
+
if (cfg.cliId === 'claude-code' && cfg.sessionId) {
|
|
2606
|
+
const claudeJsonl = claudeJsonlPathForSession(cfg.sessionId, cfg.workingDir);
|
|
2607
|
+
startBridgeWatcher(claudeJsonl, {
|
|
2608
|
+
cliPid: cliPid ?? undefined,
|
|
2609
|
+
cliCwd: cfg.workingDir,
|
|
2610
|
+
mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
// Structured transcript bridge fallback: if the model finishes without
|
|
2614
|
+
// calling `botmux send`, harvest the final answer from the CLI transcript
|
|
2615
|
+
// and post it to Lark. Codex needs late attach because its rollout id is
|
|
2616
|
+
// discovered after the first submit; CoCo's events path is deterministic
|
|
2617
|
+
// from botmux sessionId.
|
|
2618
|
+
if (cfg.cliId === 'codex') {
|
|
2619
|
+
if (cfg.cliSessionId) {
|
|
2620
|
+
const rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
|
|
2621
|
+
if (rolloutPath) {
|
|
2622
|
+
codexBridgeAttach(rolloutPath, 'baseline-existing');
|
|
2623
|
+
}
|
|
2624
|
+
else {
|
|
2625
|
+
codexBridgePendingSessionId = cfg.cliSessionId;
|
|
2626
|
+
codexBridgeStartTimer();
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
else {
|
|
2630
|
+
codexBridgeStartTimer();
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
else if (cfg.cliId === 'coco') {
|
|
2634
|
+
const eventsPath = cocoEventsPathForSession(cfg.sessionId);
|
|
2635
|
+
codexBridgeAttach(eventsPath, cfg.resume ? 'baseline-existing' : 'fresh-empty');
|
|
2636
|
+
codexBridgeStartTimer();
|
|
626
2637
|
}
|
|
627
2638
|
// Set up idle detection
|
|
628
2639
|
idleDetector = new IdleDetector(cliAdapter);
|
|
629
2640
|
idleDetector.onIdle(() => {
|
|
630
2641
|
log('Prompt detected (idle)');
|
|
2642
|
+
// Bridge drain MUST run before markPromptReady() — the latter calls
|
|
2643
|
+
// flushPending() which can immediately fire the next queued message
|
|
2644
|
+
// (type-ahead adapters), shifting bridgeQueue's notion of "current
|
|
2645
|
+
// turn" before we've had a chance to emit the previous one.
|
|
2646
|
+
if (bridgeJsonlPath) {
|
|
2647
|
+
try {
|
|
2648
|
+
bridgeDrainAndMaybeEmit();
|
|
2649
|
+
}
|
|
2650
|
+
catch (err) {
|
|
2651
|
+
log(`Bridge emit error: ${err.message}`);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
if (codexBridgeFallbackActive()) {
|
|
2655
|
+
try {
|
|
2656
|
+
codexBridgeDrainAndMaybeEmit();
|
|
2657
|
+
}
|
|
2658
|
+
catch (err) {
|
|
2659
|
+
log(`Codex bridge emit error: ${err.message}`);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
631
2662
|
markPromptReady();
|
|
632
2663
|
});
|
|
633
2664
|
backend.onData(onPtyData);
|
|
@@ -637,10 +2668,22 @@ function spawnCli(cfg) {
|
|
|
637
2668
|
isPromptReady = false;
|
|
638
2669
|
send({ type: 'claude_exit', code, signal });
|
|
639
2670
|
});
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
2671
|
+
if (isPipeMode && backend instanceof TmuxPipeBackend && backend.isReattach) {
|
|
2672
|
+
log(`Re-attached to existing tmux session via pipe-pane: ${TmuxBackend.sessionName(cfg.sessionId)}`);
|
|
2673
|
+
try {
|
|
2674
|
+
const initial = backend.captureCurrentScreen();
|
|
2675
|
+
if (initial.length > 0)
|
|
2676
|
+
onPtyData(initial);
|
|
2677
|
+
}
|
|
2678
|
+
catch (err) {
|
|
2679
|
+
log(`captureCurrentScreen failed: ${err.message}`);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
// Fallback: if the CLI takes too long to show its prompt (e.g. slow
|
|
2683
|
+
// plugin init), unblock screen updates so the card doesn't stay at
|
|
2684
|
+
// "启动中" forever. markNewTurn() sets a clean baseline at the current
|
|
2685
|
+
// cursor position so only content written *after* this point appears in
|
|
2686
|
+
// the card.
|
|
644
2687
|
setTimeout(() => {
|
|
645
2688
|
if (awaitingFirstPrompt) {
|
|
646
2689
|
awaitingFirstPrompt = false;
|
|
@@ -656,6 +2699,11 @@ function killCli() {
|
|
|
656
2699
|
stopScreenUpdates();
|
|
657
2700
|
backend?.kill();
|
|
658
2701
|
backend = null;
|
|
2702
|
+
// Tear down the bridge watcher (if any). spawnCli will rebuild it on
|
|
2703
|
+
// restart with the proper mode based on the new cfg. Leaving it running
|
|
2704
|
+
// would dangle a watcher pinned to a stale jsonl path.
|
|
2705
|
+
stopBridgeWatcher();
|
|
2706
|
+
stopCodexBridge();
|
|
659
2707
|
// Clean up CLI PID marker
|
|
660
2708
|
if (cliPidMarker) {
|
|
661
2709
|
try {
|
|
@@ -688,8 +2736,8 @@ function startWebServer(host, preferredPort) {
|
|
|
688
2736
|
if (hasWrite)
|
|
689
2737
|
authedClients.add(ws);
|
|
690
2738
|
log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
|
|
691
|
-
if (isTmuxMode && sessionId) {
|
|
692
|
-
// ── Tmux mode: per-client attach ──
|
|
2739
|
+
if (isTmuxMode && !isPipeMode && sessionId) {
|
|
2740
|
+
// ── Tmux-attach mode: per-client attach ──
|
|
693
2741
|
// Each WS client gets its own `tmux attach-session` PTY.
|
|
694
2742
|
// Scrollback is handled natively by tmux (history-limit).
|
|
695
2743
|
// In adopt mode, attach to the user's original pane; otherwise use bmx-* session.
|
|
@@ -711,6 +2759,7 @@ function startWebServer(host, preferredPort) {
|
|
|
711
2759
|
name: 'xterm-256color',
|
|
712
2760
|
cols,
|
|
713
2761
|
rows,
|
|
2762
|
+
env: tmuxEnv(),
|
|
714
2763
|
});
|
|
715
2764
|
clientPtys.set(ws, cp);
|
|
716
2765
|
cp.onData((d) => {
|
|
@@ -849,11 +2898,11 @@ body{display:flex;flex-direction:column}
|
|
|
849
2898
|
color:#565f89;background:#1a1b26cc;padding:2px 8px;border-radius:4px}
|
|
850
2899
|
#status.ok{color:#9ece6a}
|
|
851
2900
|
#status.err{color:#f7768e}
|
|
852
|
-
#readonly-banner{display:none;position:fixed;top:
|
|
853
|
-
padding:
|
|
854
|
-
background:rgba(247,118,142,0.12);border
|
|
855
|
-
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)
|
|
856
|
-
#readonly-banner.show{display:block}
|
|
2901
|
+
#readonly-banner{display:none;position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:50;
|
|
2902
|
+
padding:4px 10px;font:12px monospace;color:#f7768e;white-space:nowrap;cursor:pointer;
|
|
2903
|
+
background:rgba(247,118,142,0.12);border:1px solid rgba(247,118,142,0.35);border-radius:4px;
|
|
2904
|
+
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
|
2905
|
+
#readonly-banner.show{display:inline-block}
|
|
857
2906
|
</style>
|
|
858
2907
|
</head>
|
|
859
2908
|
<body>
|
|
@@ -879,7 +2928,7 @@ body{display:flex;flex-direction:column}
|
|
|
879
2928
|
var isTouch='ontouchstart'in window||navigator.maxTouchPoints>0;
|
|
880
2929
|
if(isTouch)document.getElementById('vp').content='width=1100,viewport-fit=cover';
|
|
881
2930
|
var hasToken=${hasWrite};
|
|
882
|
-
if(!hasToken)document.getElementById('readonly-banner').classList.add('show');
|
|
2931
|
+
if(!hasToken){var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
|
|
883
2932
|
|
|
884
2933
|
var term=new Terminal({
|
|
885
2934
|
theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
|
|
@@ -956,7 +3005,7 @@ window.addEventListener('resize',function(){fit.fit();sendResize()});
|
|
|
956
3005
|
})();
|
|
957
3006
|
|
|
958
3007
|
// ── Read-only scroll handling ──
|
|
959
|
-
if(!hasToken&&!${isTmuxMode}){
|
|
3008
|
+
if(!hasToken&&!${isTmuxMode && !isPipeMode}){
|
|
960
3009
|
// Non-tmux read-only: CLI mouse mode blocks local scroll, override with scrollLines
|
|
961
3010
|
document.getElementById('terminal').addEventListener('wheel',function(e){
|
|
962
3011
|
e.preventDefault();term.scrollLines(e.deltaY>0?3:-3);
|
|
@@ -966,7 +3015,7 @@ if(!hasToken&&!${isTmuxMode}){
|
|
|
966
3015
|
// ── Scroll helper (shared by toolbar buttons & two-finger touch) ──
|
|
967
3016
|
function _sendScroll(up,n){
|
|
968
3017
|
n=n||3;
|
|
969
|
-
if(${isTmuxMode}){
|
|
3018
|
+
if(${isTmuxMode && !isPipeMode}){
|
|
970
3019
|
// SGR mouse wheel: 64=up 65=down — tmux enters copy-mode and scrolls
|
|
971
3020
|
var seq='\\x1b[<'+(up?64:65)+';1;1M';
|
|
972
3021
|
for(var i=0;i<n;i++){if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'input',data:seq}))}
|
|
@@ -1055,7 +3104,15 @@ process.on('message', async (raw) => {
|
|
|
1055
3104
|
// Capture credentials for direct image upload from worker
|
|
1056
3105
|
larkAppIdForUpload = msg.larkAppId;
|
|
1057
3106
|
larkAppSecretForUpload = msg.larkAppSecret;
|
|
1058
|
-
|
|
3107
|
+
// Resolve render dimensions BEFORE startScreenUpdates() — the
|
|
3108
|
+
// headless xterm and PNG canvas need to know the source pane size
|
|
3109
|
+
// up-front. Setting them later (after the renderer was built at
|
|
3110
|
+
// 160x50) wouldn't unwrap content xterm has already buffered, so
|
|
3111
|
+
// adopt-mode wide-pane content would still come out stair-stepped.
|
|
3112
|
+
const dims = resolveRenderDimensions(msg);
|
|
3113
|
+
renderCols = dims.cols;
|
|
3114
|
+
renderRows = dims.rows;
|
|
3115
|
+
log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
|
|
1059
3116
|
try {
|
|
1060
3117
|
const port = await startWebServer('0.0.0.0', msg.webPort);
|
|
1061
3118
|
startScreenUpdates();
|
|
@@ -1064,6 +3121,8 @@ process.on('message', async (raw) => {
|
|
|
1064
3121
|
// Queue the initial prompt — flushed when CLI shows idle.
|
|
1065
3122
|
// Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
|
|
1066
3123
|
// prompt into CLI args, so we skip queuing to avoid double-send.
|
|
3124
|
+
// Bridge mark is deferred to flushPending — see flushPending
|
|
3125
|
+
// comment for why marking at enqueue is wrong.
|
|
1067
3126
|
if (msg.prompt && !cliAdapter?.passesInitialPromptViaArgs) {
|
|
1068
3127
|
pendingMessages.push(msg.prompt);
|
|
1069
3128
|
}
|
|
@@ -1083,10 +3142,70 @@ process.on('message', async (raw) => {
|
|
|
1083
3142
|
exitTmuxScrollMode();
|
|
1084
3143
|
const content = msg.content;
|
|
1085
3144
|
if (lastInitConfig?.adoptMode) {
|
|
1086
|
-
//
|
|
3145
|
+
// Bridge mode: capture transcript baseline BEFORE writing to the pane,
|
|
3146
|
+
// so any assistant uuids appended after this point are attributed to
|
|
3147
|
+
// *this* Lark turn (not local user activity in the pane). Mark may
|
|
3148
|
+
// return false (baseline not ready) — we still write to the pane;
|
|
3149
|
+
// user just won't get a final_output for this message.
|
|
3150
|
+
if (bridgeJsonlPath) {
|
|
3151
|
+
try {
|
|
3152
|
+
bridgeIngest();
|
|
3153
|
+
}
|
|
3154
|
+
catch { /* best effort */ }
|
|
3155
|
+
bridgeMarkPendingTurn(content);
|
|
3156
|
+
}
|
|
3157
|
+
else if (codexBridgeFallbackActive()) {
|
|
3158
|
+
// Codex adopt: same idea, different bridge. ingest first so any
|
|
3159
|
+
// in-flight events from a local-typed prior turn close before
|
|
3160
|
+
// this Lark turn's fingerprint window opens. Mark works even
|
|
3161
|
+
// pre-attach (queue is path-agnostic).
|
|
3162
|
+
try {
|
|
3163
|
+
codexBridgeIngest();
|
|
3164
|
+
}
|
|
3165
|
+
catch { /* best effort */ }
|
|
3166
|
+
codexBridgeMarkPendingTurn(content);
|
|
3167
|
+
}
|
|
3168
|
+
// Adopt mode write:
|
|
3169
|
+
// - codex routes through cliAdapter.writeInput so the adapter's
|
|
3170
|
+
// paste-detection delay + Enter-retry + history.jsonl verify
|
|
3171
|
+
// loop handles Codex TUI's "\n treated as Enter" submit
|
|
3172
|
+
// behaviour. Without it, Lark messages get stranded in the
|
|
3173
|
+
// input box (user-reported "卡在输入框中").
|
|
3174
|
+
// - everything else keeps the simple raw sendText+Enter — the
|
|
3175
|
+
// claude-code adopt bridge has its own dual-write recovery
|
|
3176
|
+
// path, and the other CLIs' adopt flows haven't surfaced
|
|
3177
|
+
// this submit-detection issue.
|
|
1087
3178
|
if (backend) {
|
|
1088
|
-
if (
|
|
3179
|
+
if (lastInitConfig?.cliId === 'codex' && cliAdapter) {
|
|
3180
|
+
// writeInput is async but we're already inside an async
|
|
3181
|
+
// message handler. Errors are best-effort logged; the bridge
|
|
3182
|
+
// ingest path is unaffected because mark already happened
|
|
3183
|
+
// above (codexBridgeMarkPendingTurn / bridgeMarkPendingTurn).
|
|
3184
|
+
try {
|
|
3185
|
+
const result = await cliAdapter.writeInput(backend, content);
|
|
3186
|
+
if (result?.cliSessionId) {
|
|
3187
|
+
persistCliSessionId(result.cliSessionId);
|
|
3188
|
+
codexBridgeNotifyCliSessionId(result.cliSessionId);
|
|
3189
|
+
}
|
|
3190
|
+
if (result && result.submitted === false) {
|
|
3191
|
+
scheduleSubmitFailureNotify(content, result.recheck, 'Codex history');
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
catch (err) {
|
|
3195
|
+
log(`Codex adopt writeInput error: ${err.message}`);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
|
|
1089
3199
|
backend.sendText(content);
|
|
3200
|
+
// Beat between text and Enter so the adopted CLI's input layer
|
|
3201
|
+
// has time to register the typed chars before submit. Without
|
|
3202
|
+
// this, Ink-based TUIs (CoCo, Claude Code) flag the rapid
|
|
3203
|
+
// input+Enter as paste continuation and treat the trailing
|
|
3204
|
+
// Enter as a soft-newline, leaving the message stranded in the
|
|
3205
|
+
// input box. 200ms mirrors the per-adapter writeInput delay
|
|
3206
|
+
// that fresh-spawn mode goes through and matches the slash-
|
|
3207
|
+
// command (raw_input) fix.
|
|
3208
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1090
3209
|
backend.sendSpecialKeys('Enter');
|
|
1091
3210
|
}
|
|
1092
3211
|
else {
|
|
@@ -1097,6 +3216,11 @@ process.on('message', async (raw) => {
|
|
|
1097
3216
|
}
|
|
1098
3217
|
}
|
|
1099
3218
|
else {
|
|
3219
|
+
// Non-adopt: enqueue only. Bridge mark is deferred to flushPending
|
|
3220
|
+
// so markTimeMs anchors to the actual PTY-write moment, not IPC
|
|
3221
|
+
// arrival. Marking now would race with a still-running previous
|
|
3222
|
+
// turn whose `botmux send` could sneak its sentAtMs past this
|
|
3223
|
+
// turn's markTimeMs and falsely suppress its fallback.
|
|
1100
3224
|
sendToPty(content);
|
|
1101
3225
|
}
|
|
1102
3226
|
break;
|
|
@@ -1113,6 +3237,14 @@ process.on('message', async (raw) => {
|
|
|
1113
3237
|
if (backend) {
|
|
1114
3238
|
if ('sendText' in backend && 'sendSpecialKeys' in backend) {
|
|
1115
3239
|
backend.sendText(msg.content);
|
|
3240
|
+
// Beat between text and Enter so the CLI's slash-command picker has
|
|
3241
|
+
// time to register the match before submit. Without this, Codex
|
|
3242
|
+
// (and likely other Ink-based TUIs) fires Enter while the picker
|
|
3243
|
+
// is still building, dismisses the match, and submits the literal
|
|
3244
|
+
// `/clear` as a regular user prompt — visible to the user as
|
|
3245
|
+
// "/clear + 换行" stuck in conversation history. 200ms mirrors the
|
|
3246
|
+
// codex adapter's own writeInput paste-detection delay.
|
|
3247
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1116
3248
|
backend.sendSpecialKeys('Enter');
|
|
1117
3249
|
}
|
|
1118
3250
|
else {
|
|
@@ -1177,6 +3309,11 @@ process.on('message', async (raw) => {
|
|
|
1177
3309
|
// destroySession kills tmux session permanently; kill() only detaches
|
|
1178
3310
|
backend?.destroySession?.();
|
|
1179
3311
|
killCli();
|
|
3312
|
+
// Bridge marker file outlives a single CLI process (we keep it across
|
|
3313
|
+
// restarts so a mid-flight send is still credited), but a real close
|
|
3314
|
+
// tears down the session — purge the file so a future re-use of the
|
|
3315
|
+
// same sessionId starts clean.
|
|
3316
|
+
clearSendMarkers();
|
|
1180
3317
|
cleanup();
|
|
1181
3318
|
process.exit(0);
|
|
1182
3319
|
}
|