botmux 2.11.1 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/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 +6 -0
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts +16 -2
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +40 -10
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +59 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
- package/dist/adapters/backend/tmux-pipe-backend.js +288 -0
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
- package/dist/adapters/cli/claude-code.d.ts +15 -0
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +205 -24
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +78 -16
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +10 -0
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/cli.js +63 -8
- package/dist/cli.js.map +1 -1
- package/dist/core/command-handler.d.ts +10 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +29 -1
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/scheduler.d.ts +3 -0
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +3 -0
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/session-manager.d.ts +17 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +51 -3
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/types.d.ts +4 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +13 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +115 -4
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +59 -120
- package/dist/daemon.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +2 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +23 -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 +26 -93
- package/dist/im/lark/card-handler.js.map +1 -1
- 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 +99 -0
- package/dist/im/lark/merge-forward.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-turn-queue.d.ts +111 -0
- package/dist/services/bridge-turn-queue.d.ts.map +1 -0
- package/dist/services/bridge-turn-queue.js +213 -0
- package/dist/services/bridge-turn-queue.js.map +1 -0
- package/dist/services/claude-transcript.d.ts +168 -0
- package/dist/services/claude-transcript.d.ts.map +1 -0
- package/dist/services/claude-transcript.js +524 -0
- package/dist/services/claude-transcript.js.map +1 -0
- package/dist/services/codex-bridge-queue.d.ts +39 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -0
- package/dist/services/codex-bridge-queue.js +116 -0
- package/dist/services/codex-bridge-queue.js.map +1 -0
- package/dist/services/codex-transcript.d.ts +35 -0
- package/dist/services/codex-transcript.d.ts.map +1 -0
- package/dist/services/codex-transcript.js +163 -0
- package/dist/services/codex-transcript.js.map +1 -0
- package/dist/services/schedule-store.d.ts +3 -0
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +6 -0
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/services/session-store.d.ts +10 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +40 -0
- package/dist/services/session-store.js.map +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +2 -1
- package/dist/skills/definitions.js.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.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/terminal-renderer.d.ts.map +1 -1
- package/dist/utils/terminal-renderer.js +5 -2
- package/dist/utils/terminal-renderer.js.map +1 -1
- package/dist/worker.js +1317 -37
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
package/dist/worker.js
CHANGED
|
@@ -13,15 +13,23 @@
|
|
|
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, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent } from './services/claude-transcript.js';
|
|
19
|
+
import { BridgeTurnQueue, makeFingerprint } from './services/bridge-turn-queue.js';
|
|
20
|
+
import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
|
|
21
|
+
import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
|
|
22
|
+
import { drainCodexRollout, findCodexRolloutBySessionId } from './services/codex-transcript.js';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
18
24
|
import { createServer as createHttpServer } from 'node:http';
|
|
19
25
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
20
26
|
import { TerminalRenderer } from './utils/terminal-renderer.js';
|
|
27
|
+
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
28
|
import { createCliAdapterSync } from './adapters/cli/registry.js';
|
|
22
|
-
import { claudeJsonlPathForSession } from './adapters/cli/claude-code.js';
|
|
29
|
+
import { claudeJsonlPathForSession, resolveJsonlFromPid } from './adapters/cli/claude-code.js';
|
|
23
30
|
import { PtyBackend } from './adapters/backend/pty-backend.js';
|
|
24
31
|
import { TmuxBackend } from './adapters/backend/tmux-backend.js';
|
|
32
|
+
import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
|
|
25
33
|
import { IdleDetector } from './utils/idle-detector.js';
|
|
26
34
|
import { ScreenAnalyzer } from './utils/screen-analyzer.js';
|
|
27
35
|
import { captureToPng } from './utils/screenshot-renderer.js';
|
|
@@ -36,6 +44,10 @@ let backend = null;
|
|
|
36
44
|
let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
|
|
37
45
|
let idleDetector = null;
|
|
38
46
|
let isTmuxMode = false;
|
|
47
|
+
/** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
|
|
48
|
+
* web-terminal updates flow through the shared scrollback fan-out instead
|
|
49
|
+
* of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
|
|
50
|
+
let isPipeMode = false;
|
|
39
51
|
let httpServer = null;
|
|
40
52
|
let wss = null;
|
|
41
53
|
const wsClients = new Set();
|
|
@@ -51,13 +63,1040 @@ let isPromptReady = false;
|
|
|
51
63
|
/** Mutex for async flushPending — prevents concurrent flush loops. */
|
|
52
64
|
let isFlushing = false;
|
|
53
65
|
const pendingMessages = [];
|
|
66
|
+
// ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
|
|
67
|
+
//
|
|
68
|
+
// In bridge mode the daemon adopted an existing CLI session that we do NOT
|
|
69
|
+
// own; the model never sees botmux. We harvest assistant turns by tailing
|
|
70
|
+
// Claude Code's transcript JSONL and forward only the bytes appended after
|
|
71
|
+
// each Lark-driven user turn — never the historical content present at
|
|
72
|
+
// attach time, never local-terminal-driven turns.
|
|
73
|
+
//
|
|
74
|
+
// Attribution lives in BridgeTurnQueue; this file only manages the
|
|
75
|
+
// fs.watch wakeup, byte-offset bookkeeping, lazy baseline, and IPC emit.
|
|
76
|
+
let bridgeJsonlPath;
|
|
77
|
+
/** Directory enclosing bridgeJsonlPath. We poll this dir for newer jsonl
|
|
78
|
+
* files so the bridge follows `/clear` / `/resume` in the user's CLI —
|
|
79
|
+
* those create a brand-new sessionId.jsonl, and a watcher pinned to the
|
|
80
|
+
* original path would silently stop receiving events. */
|
|
81
|
+
let bridgeJsonlDir;
|
|
82
|
+
/** PID + cwd of the adopted Claude Code process. Lets every poll re-read
|
|
83
|
+
* ~/.claude/sessions/<pid>.json — Claude's own authoritative record of the
|
|
84
|
+
* current sessionId — and switch the watched jsonl when Claude rotates
|
|
85
|
+
* (via /clear, /resume, --resume etc.) without waiting for a Lark message
|
|
86
|
+
* to land in the new file. */
|
|
87
|
+
let bridgeCliPid;
|
|
88
|
+
let bridgeCliCwd;
|
|
89
|
+
/** Last sessionId we observed via the pid resolver — used to detect
|
|
90
|
+
* rotations cheaply (string compare instead of stat()ing every jsonl). */
|
|
91
|
+
let bridgeObservedCliSessionId;
|
|
92
|
+
/** Old jsonl paths we keep polling AFTER a rotation switched
|
|
93
|
+
* bridgeJsonlPath away — needed when a started turn was stamped with the
|
|
94
|
+
* old path but its assistant text hasn't been written yet. We continue to
|
|
95
|
+
* drain each entry on every tick so trailing appends to that file land in
|
|
96
|
+
* the queue against the right turn, and prune the entry once no pending
|
|
97
|
+
* turn references the path anymore. */
|
|
98
|
+
const bridgeSecondaryPaths = new Map(); // path → offset
|
|
99
|
+
let bridgeOffset = 0;
|
|
100
|
+
let bridgePendingTail = '';
|
|
101
|
+
const bridgeQueue = new BridgeTurnQueue();
|
|
102
|
+
let bridgeWatcher = null;
|
|
103
|
+
let bridgeFallbackTimer = null;
|
|
104
|
+
/** True once we successfully baselined the transcript file. Until then,
|
|
105
|
+
* any data we see is treated as history — absorbed into the queue's seen
|
|
106
|
+
* set without being attributed to a pending Lark turn. This protects the
|
|
107
|
+
* first Lark turn from inheriting historical lines if Claude Code creates
|
|
108
|
+
* the JSONL file *after* attach. */
|
|
109
|
+
let bridgeBaselineDone = false;
|
|
110
|
+
/** Once-per-attach flag so a re-baseline after fs.watch lazy-fire doesn't
|
|
111
|
+
* re-send the preamble. Reset only when the bridge teardown happens. */
|
|
112
|
+
let bridgePreambleSent = false;
|
|
113
|
+
// ─── Codex bridge state ──────────────────────────────────────────────────
|
|
114
|
+
//
|
|
115
|
+
// Parallel to the Claude bridge above. Codex's transcript layout is
|
|
116
|
+
// different enough (separate file location, different event schema) that
|
|
117
|
+
// trying to share storage / readers would obscure both — so we keep state
|
|
118
|
+
// independent. Marker file (`<DATA_DIR>/turn-sends/<sid>.jsonl`) and the
|
|
119
|
+
// gate function are CLI-agnostic and shared.
|
|
120
|
+
let codexBridgeRolloutPath;
|
|
121
|
+
let codexBridgeOffset = 0;
|
|
122
|
+
let codexBridgePendingTail = '';
|
|
123
|
+
let codexBridgeBaselineDone = false;
|
|
124
|
+
const codexBridgeQueue = new CodexBridgeQueue();
|
|
125
|
+
let codexBridgeWatcher = null;
|
|
126
|
+
let codexBridgeTimer = null;
|
|
127
|
+
/** Codex sessionId we received via writeInput but haven't yet resolved a
|
|
128
|
+
* rollout file for. The poller keeps retrying — the file appears on
|
|
129
|
+
* Codex's first user submit, but with some race delay after our submit
|
|
130
|
+
* returns. Cleared once attached. */
|
|
131
|
+
let codexBridgePendingSessionId;
|
|
132
|
+
/** Cap the preamble text so an extremely long previous turn doesn't blow
|
|
133
|
+
* past Lark's per-message limit. The user only needs enough to recall
|
|
134
|
+
* context, not the entire transcript. */
|
|
135
|
+
const PREAMBLE_USER_MAX = 500;
|
|
136
|
+
const PREAMBLE_ASSISTANT_MAX = 4000;
|
|
137
|
+
/** Same intent as the preamble caps, but for live local-terminal turns
|
|
138
|
+
* forwarded to Lark. A long paste typed locally shouldn't be allowed to
|
|
139
|
+
* blow past Lark's per-message limit. */
|
|
140
|
+
const LOCAL_TURN_USER_MAX = 1000;
|
|
141
|
+
const LOCAL_TURN_ASSISTANT_MAX = 8000;
|
|
142
|
+
function truncatePreambleText(text, max) {
|
|
143
|
+
if (text.length <= max)
|
|
144
|
+
return text;
|
|
145
|
+
return text.slice(0, max) + '…';
|
|
146
|
+
}
|
|
147
|
+
/** Compose a `final_output` payload for a turn synthesised from a user
|
|
148
|
+
* prompt the human typed directly into the adopted pane. Shows both the
|
|
149
|
+
* user text and assistant text so the Lark thread doesn't see an orphan
|
|
150
|
+
* reply with no context. Returns `null` when neither side has anything
|
|
151
|
+
* visible — the worker should suppress the emit in that case. */
|
|
152
|
+
function formatLocalTurnContent(userText, assistantText) {
|
|
153
|
+
const u = truncatePreambleText(userText.trim(), LOCAL_TURN_USER_MAX);
|
|
154
|
+
const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
|
|
155
|
+
if (!u && !a)
|
|
156
|
+
return null;
|
|
157
|
+
return [
|
|
158
|
+
'🖥️ 终端本地对话(在 adopted pane 中直接输入,已同步至飞书)',
|
|
159
|
+
'',
|
|
160
|
+
'👤 你:',
|
|
161
|
+
u || '(空)',
|
|
162
|
+
'',
|
|
163
|
+
`🤖 ${cliName()}:`,
|
|
164
|
+
a || '(空)',
|
|
165
|
+
].join('\n');
|
|
166
|
+
}
|
|
167
|
+
// ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
|
|
168
|
+
//
|
|
169
|
+
// `botmux send` (cli.ts cmdSend) appends a line `{sentAtMs, messageId}\n` to
|
|
170
|
+
// `<DATA_DIR>/turn-sends/<sid>.jsonl` every time the model successfully posts
|
|
171
|
+
// a reply to its OWN session thread. The worker reads these markers at idle
|
|
172
|
+
// and suppresses transcript-driven final_output for any turn whose time
|
|
173
|
+
// window already contains a send — i.e. the model didn't forget, no fallback
|
|
174
|
+
// needed. Append-only over a shared file (instead of a per-turn marker) is
|
|
175
|
+
// type-ahead safe: type-ahead'd turns each have their own [markTimeMs,
|
|
176
|
+
// nextTurn.markTimeMs) window, and a stray send only fills its own bucket.
|
|
177
|
+
function bridgeMarkerPath() {
|
|
178
|
+
if (!process.env.SESSION_DATA_DIR || !sessionId)
|
|
179
|
+
return undefined;
|
|
180
|
+
return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
|
|
181
|
+
}
|
|
182
|
+
function readSendMarkers() {
|
|
183
|
+
const path = bridgeMarkerPath();
|
|
184
|
+
if (!path || !existsSync(path))
|
|
185
|
+
return [];
|
|
186
|
+
try {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
189
|
+
if (!line.trim())
|
|
190
|
+
continue;
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(line);
|
|
193
|
+
if (typeof parsed?.sentAtMs === 'number')
|
|
194
|
+
out.push(parsed);
|
|
195
|
+
}
|
|
196
|
+
catch { /* skip malformed line */ }
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
log(`Bridge marker read failed: ${err.message}`);
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function clearSendMarkers() {
|
|
206
|
+
const path = bridgeMarkerPath();
|
|
207
|
+
if (!path)
|
|
208
|
+
return;
|
|
209
|
+
try {
|
|
210
|
+
unlinkSync(path);
|
|
211
|
+
}
|
|
212
|
+
catch { /* already gone or fs.unavailable; not fatal */ }
|
|
213
|
+
}
|
|
214
|
+
function maybeEmitAdoptPreamble(events) {
|
|
215
|
+
// Preamble is an /adopt-only signal: it tells the user "here's the last
|
|
216
|
+
// turn from the Claude session you just attached to, so the Lark thread
|
|
217
|
+
// has context to continue from". In non-adopt sessions the user IS the
|
|
218
|
+
// Lark thread (every turn was already pushed there as a card), so
|
|
219
|
+
// surfacing the last turn again on daemon restart is just noise.
|
|
220
|
+
if (!lastInitConfig?.adoptMode)
|
|
221
|
+
return;
|
|
222
|
+
if (bridgePreambleSent)
|
|
223
|
+
return;
|
|
224
|
+
const turn = extractLastAssistantTurn(events);
|
|
225
|
+
if (!turn)
|
|
226
|
+
return;
|
|
227
|
+
bridgePreambleSent = true;
|
|
228
|
+
send({
|
|
229
|
+
type: 'adopt_preamble',
|
|
230
|
+
userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
|
|
231
|
+
assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
|
|
232
|
+
});
|
|
233
|
+
log('Bridge adopt preamble emitted (last completed turn from baseline)');
|
|
234
|
+
}
|
|
235
|
+
function bridgeAbsorbBaseline() {
|
|
236
|
+
if (!bridgeJsonlPath)
|
|
237
|
+
return;
|
|
238
|
+
const result = drainTranscript(bridgeJsonlPath, 0);
|
|
239
|
+
bridgeOffset = result.newOffset;
|
|
240
|
+
bridgePendingTail = result.pendingTail;
|
|
241
|
+
bridgeQueue.absorb(result.events);
|
|
242
|
+
bridgeBaselineDone = true;
|
|
243
|
+
// After absorb (uuids registered as seen so they won't re-emit as a Lark
|
|
244
|
+
// turn), surface the last completed user/assistant exchange to Lark as a
|
|
245
|
+
// one-shot preamble — but only for real /adopt sessions. Non-adopt
|
|
246
|
+
// claude-code fallback bridge also uses baseline-existing on daemon
|
|
247
|
+
// restart/resume; it must not emit the "/adopt 前最后一轮" message.
|
|
248
|
+
if (lastInitConfig?.adoptMode)
|
|
249
|
+
maybeEmitAdoptPreamble(result.events);
|
|
250
|
+
}
|
|
251
|
+
/** Detect /clear / /resume: when Claude Code starts a new session in the
|
|
252
|
+
* user's pane it writes to a brand-new sessionId.jsonl. We *cannot* use
|
|
253
|
+
* "latest-mtime jsonl in the project dir" as the switch trigger — that
|
|
254
|
+
* hijacks our watcher whenever a sibling Claude pane in the same cwd
|
|
255
|
+
* writes anything. Instead, switch only when:
|
|
256
|
+
*
|
|
257
|
+
* 1. We have an unstarted pending Lark turn (otherwise no signal to
|
|
258
|
+
* chase, and switching would risk grabbing another pane's reply).
|
|
259
|
+
* 2. The pending turn's content fingerprint shows up in a candidate
|
|
260
|
+
* jsonl other than our current one — that's the user's current
|
|
261
|
+
* session because they JUST typed our pane-write into it.
|
|
262
|
+
*
|
|
263
|
+
* Pending turns are preserved across the switch so the next ingest can
|
|
264
|
+
* match the fingerprint and start the turn in the new file. */
|
|
265
|
+
function maybeSwitchBridgeJsonl() {
|
|
266
|
+
if (!bridgeJsonlDir)
|
|
267
|
+
return false;
|
|
268
|
+
const pending = bridgeQueue.peek();
|
|
269
|
+
const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
|
|
270
|
+
if (!candidate || !candidate.contentFingerprint)
|
|
271
|
+
return false;
|
|
272
|
+
// Bound the search to events written after the turn was marked. Short
|
|
273
|
+
// fingerprints ("hello", "test") would otherwise match old user lines
|
|
274
|
+
// in unrelated sibling jsonls. 5s skew absorbs clock drift between the
|
|
275
|
+
// mark and Claude's transcript write.
|
|
276
|
+
const minEventTimestampMs = candidate.markTimeMs !== undefined
|
|
277
|
+
? candidate.markTimeMs - 5_000
|
|
278
|
+
: undefined;
|
|
279
|
+
const matched = findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
|
|
280
|
+
excludePath: bridgeJsonlPath,
|
|
281
|
+
includeQueueOperations: true,
|
|
282
|
+
minEventTimestampMs,
|
|
283
|
+
});
|
|
284
|
+
if (!matched)
|
|
285
|
+
return false;
|
|
286
|
+
// Drain-before-switch: pull in any unread bytes from the old path so a
|
|
287
|
+
// late assistant append doesn't vanish. We do NOT emit here — emission
|
|
288
|
+
// only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
|
|
289
|
+
// would publish a half-finished assistant turn during fs.watch / poll
|
|
290
|
+
// ticks (drainEmittable's contract is "has visible text", not "model
|
|
291
|
+
// finished"). If the drained user/assistant events still need follow-up
|
|
292
|
+
// appends on the old path, retainSecondaryPathIfStillReferenced() keeps
|
|
293
|
+
// the old path in the polling rotation.
|
|
294
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
295
|
+
let postDrainOffset = bridgeOffset;
|
|
296
|
+
try {
|
|
297
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
298
|
+
postDrainOffset = drained.offset;
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
log(`Bridge final-drain on fingerprint switch failed (${err.message}); continuing`);
|
|
302
|
+
}
|
|
303
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
304
|
+
}
|
|
305
|
+
log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (Lark fingerprint observed in new jsonl — user likely ran /clear or /resume)`);
|
|
306
|
+
if (bridgeWatcher) {
|
|
307
|
+
try {
|
|
308
|
+
bridgeWatcher.close();
|
|
309
|
+
}
|
|
310
|
+
catch { /* ignore */ }
|
|
311
|
+
bridgeWatcher = null;
|
|
312
|
+
}
|
|
313
|
+
// Critically: do NOT clear pending turns. The switch was triggered by
|
|
314
|
+
// the fingerprint of the FIRST pending turn already living in `matched`,
|
|
315
|
+
// so the immediate next ingest from offset 0 will find that user event
|
|
316
|
+
// and start the turn. Clearing here would race-drop exactly the message
|
|
317
|
+
// we're trying to deliver.
|
|
318
|
+
bridgeJsonlPath = matched;
|
|
319
|
+
bridgeOffset = 0;
|
|
320
|
+
bridgePendingTail = '';
|
|
321
|
+
// baselineDone=false would absorb the new file's existing content
|
|
322
|
+
// (including the pending turn's user event) as history — defeating the
|
|
323
|
+
// switch. Skip baseline; fall straight into ingest from offset 0 so
|
|
324
|
+
// BridgeTurnQueue.ingest() can attribute the matching user/assistant.
|
|
325
|
+
bridgeBaselineDone = true;
|
|
326
|
+
try {
|
|
327
|
+
bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
|
|
328
|
+
try {
|
|
329
|
+
bridgeIngest();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
/** /clear or /resume in the user's adopted pane creates (or touches) a new
|
|
342
|
+
* jsonl in the same Claude project directory. Neither pid-resolver nor
|
|
343
|
+
* fingerprint switch will fire when the rotation happened mid-process AND
|
|
344
|
+
* there's no pending Lark turn to anchor on (pure local-terminal use), so
|
|
345
|
+
* this fallback owns that case.
|
|
346
|
+
*
|
|
347
|
+
* Detection priority:
|
|
348
|
+
* 1. Linux first-class: read `/proc/<pid>/fd` and pick the .jsonl the
|
|
349
|
+
* adopted Claude process actually has open. This is bound to the real
|
|
350
|
+
* PID — a sibling Claude pane in the same cwd has a different PID and
|
|
351
|
+
* therefore cannot hijack the result.
|
|
352
|
+
* 2. Cross-platform fallback: directory-level mtime heuristic, gated on
|
|
353
|
+
* (a) our current jsonl quiet ≥ QUIET_ROTATION_MS, (b) candidate
|
|
354
|
+
* newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Less
|
|
355
|
+
* robust than fd lookup but the best available without /proc.
|
|
356
|
+
*
|
|
357
|
+
* When a rotation is detected, the new jsonl is drained from offset 0 and
|
|
358
|
+
* events are split by timestamp against `rotationCutoffMs` (the old
|
|
359
|
+
* jsonl's last-write time): events before the cutoff are *history*
|
|
360
|
+
* (absorbed into the seen-set, not emitted), events after are *live*
|
|
361
|
+
* (ingested → local-turn synthesis runs). This is what lets /resume to a
|
|
362
|
+
* long-history jsonl NOT replay the entire past as one giant local turn,
|
|
363
|
+
* while /clear's first new turn still gets forwarded.
|
|
364
|
+
*
|
|
365
|
+
* Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
|
|
366
|
+
* also fires `maybeEmitAdoptPreamble`, which on rotation would surface
|
|
367
|
+
* the *previous session's* last turn as if it were a fresh "/adopt 前最
|
|
368
|
+
* 后一轮" preamble. Preamble belongs only to initial attach. */
|
|
369
|
+
const QUIET_ROTATION_MS = 8_000;
|
|
370
|
+
function statSafe(path) {
|
|
371
|
+
try {
|
|
372
|
+
const st = statSync(path);
|
|
373
|
+
if (!st.isFile())
|
|
374
|
+
return null;
|
|
375
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function isPidAlive(pid) {
|
|
382
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
383
|
+
return false;
|
|
384
|
+
try {
|
|
385
|
+
process.kill(pid, 0);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** List `.jsonl` files inside `dir` that are currently held open by `pid`.
|
|
393
|
+
* Returns [] on non-Linux platforms or if /proc lookup fails — the caller
|
|
394
|
+
* treats an empty result as "fd info unavailable, fall back to mtime". */
|
|
395
|
+
function findOpenJsonlsForPid(pid, dir) {
|
|
396
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
397
|
+
return [];
|
|
398
|
+
if (process.platform !== 'linux')
|
|
399
|
+
return [];
|
|
400
|
+
let entries;
|
|
401
|
+
try {
|
|
402
|
+
entries = readdirSync(`/proc/${pid}/fd`);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const name of entries) {
|
|
409
|
+
let target;
|
|
410
|
+
try {
|
|
411
|
+
target = readlinkSync(`/proc/${pid}/fd/${name}`);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (!target.endsWith('.jsonl'))
|
|
417
|
+
continue;
|
|
418
|
+
if (dirname(target) !== dir)
|
|
419
|
+
continue;
|
|
420
|
+
out.push(target);
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
/** Pick the most recently modified path among `paths`. Returns null if
|
|
425
|
+
* none of them stat. */
|
|
426
|
+
function newestPath(paths) {
|
|
427
|
+
let best = null;
|
|
428
|
+
for (const p of paths) {
|
|
429
|
+
const st = statSafe(p);
|
|
430
|
+
if (!st)
|
|
431
|
+
continue;
|
|
432
|
+
if (!best || st.mtimeMs > best.mtimeMs)
|
|
433
|
+
best = { path: p, mtimeMs: st.mtimeMs };
|
|
434
|
+
}
|
|
435
|
+
return best?.path ?? null;
|
|
436
|
+
}
|
|
437
|
+
/** Switch bridgeJsonlPath to `newPath` and split-baseline its existing
|
|
438
|
+
* content: events with timestamp ≤ `cutoffMs` are absorbed as history
|
|
439
|
+
* (seen-set only, no emission), events strictly after are ingested so
|
|
440
|
+
* local turn synthesis runs against them. The old path is retained in
|
|
441
|
+
* the secondary polling rotation if any started turn still references
|
|
442
|
+
* it. Does NOT emit `adopt_preamble` — that's an initial-attach signal,
|
|
443
|
+
* not a rotation signal. */
|
|
444
|
+
function performRotationSwitch(newPath, cutoffMs, reason) {
|
|
445
|
+
// Drain-before-switch: pull any unread bytes from the old path so a
|
|
446
|
+
// late assistant append doesn't vanish. Mirrors the other rotation
|
|
447
|
+
// helpers.
|
|
448
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
449
|
+
let postDrainOffset = bridgeOffset;
|
|
450
|
+
try {
|
|
451
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
452
|
+
postDrainOffset = drained.offset;
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
log(`Bridge final-drain on rotation (${reason}) failed (${err.message}); continuing`);
|
|
456
|
+
}
|
|
457
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
458
|
+
}
|
|
459
|
+
log(`Bridge transcript switched (${reason}): ${bridgeJsonlPath ?? '(none)'} → ${newPath}`);
|
|
460
|
+
if (bridgeWatcher) {
|
|
461
|
+
try {
|
|
462
|
+
bridgeWatcher.close();
|
|
463
|
+
}
|
|
464
|
+
catch { /* ignore */ }
|
|
465
|
+
bridgeWatcher = null;
|
|
466
|
+
}
|
|
467
|
+
bridgeJsonlPath = newPath;
|
|
468
|
+
bridgeJsonlDir = dirname(newPath);
|
|
469
|
+
bridgePendingTail = '';
|
|
470
|
+
// Drain the new path from 0 ourselves (do NOT call bridgeAbsorbBaseline
|
|
471
|
+
// — that would emit the preamble we want to suppress on rotation).
|
|
472
|
+
const result = drainTranscript(newPath, 0);
|
|
473
|
+
bridgeOffset = result.newOffset;
|
|
474
|
+
bridgePendingTail = result.pendingTail;
|
|
475
|
+
const history = [];
|
|
476
|
+
const live = [];
|
|
477
|
+
for (const ev of result.events) {
|
|
478
|
+
let evMs = Number.NaN;
|
|
479
|
+
if (typeof ev.timestamp === 'string')
|
|
480
|
+
evMs = Date.parse(ev.timestamp);
|
|
481
|
+
if (Number.isFinite(evMs) && evMs <= cutoffMs)
|
|
482
|
+
history.push(ev);
|
|
483
|
+
else
|
|
484
|
+
live.push(ev);
|
|
485
|
+
}
|
|
486
|
+
bridgeQueue.absorb(history);
|
|
487
|
+
if (live.length > 0)
|
|
488
|
+
bridgeQueue.ingest(live, newPath);
|
|
489
|
+
bridgeBaselineDone = true;
|
|
490
|
+
log(`Bridge rotation split: ${history.length} historical events absorbed, ${live.length} live events ingested`);
|
|
491
|
+
try {
|
|
492
|
+
bridgeWatcher = fsWatch(newPath, { persistent: false }, () => {
|
|
493
|
+
try {
|
|
494
|
+
bridgeIngest();
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function maybeFollowQuietRotation() {
|
|
506
|
+
if (!bridgeJsonlDir || !bridgeJsonlPath)
|
|
507
|
+
return;
|
|
508
|
+
// Need a known pid to do safe rotation tracking; if we don't have one,
|
|
509
|
+
// we can't bind to the adopted Claude process and a directory-mtime
|
|
510
|
+
// switch would risk sibling-pane hijack.
|
|
511
|
+
if (bridgeCliPid === undefined)
|
|
512
|
+
return;
|
|
513
|
+
if (!isPidAlive(bridgeCliPid))
|
|
514
|
+
return;
|
|
515
|
+
const currentStat = statSafe(bridgeJsonlPath);
|
|
516
|
+
if (!currentStat)
|
|
517
|
+
return;
|
|
518
|
+
// Path 1: Linux fd-based detection — definitive, can't be hijacked.
|
|
519
|
+
// Read /proc/<pid>/fd, find every .jsonl Claude has open in our cwd's
|
|
520
|
+
// project dir, pick the one with the most recent mtime. Differs from
|
|
521
|
+
// bridgeJsonlPath ⇒ rotation.
|
|
522
|
+
const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
|
|
523
|
+
if (opened.length > 0) {
|
|
524
|
+
const newest = newestPath(opened);
|
|
525
|
+
if (newest && newest !== bridgeJsonlPath) {
|
|
526
|
+
performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
|
|
527
|
+
}
|
|
528
|
+
// fd lookup succeeded — even if it confirmed the current path, the
|
|
529
|
+
// mtime fallback below would only add risk. Stop here.
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Path 2: non-Linux fallback (or /proc unavailable). Directory-mtime
|
|
533
|
+
// heuristic with three guards. Less robust than fd lookup; sibling
|
|
534
|
+
// panes could in principle race the conditions, but the QUIET windows
|
|
535
|
+
// make it unlikely in practice.
|
|
536
|
+
const now = Date.now();
|
|
537
|
+
if (now - currentStat.mtimeMs < QUIET_ROTATION_MS)
|
|
538
|
+
return;
|
|
539
|
+
const latest = findLatestJsonl(bridgeJsonlDir);
|
|
540
|
+
if (!latest || latest === bridgeJsonlPath)
|
|
541
|
+
return;
|
|
542
|
+
const latestStat = statSafe(latest);
|
|
543
|
+
if (!latestStat)
|
|
544
|
+
return;
|
|
545
|
+
if (latestStat.mtimeMs - currentStat.mtimeMs < QUIET_ROTATION_MS)
|
|
546
|
+
return;
|
|
547
|
+
performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
|
|
548
|
+
}
|
|
549
|
+
function maybeFollowSessionRotationViaPid() {
|
|
550
|
+
if (!bridgeCliPid || !bridgeCliCwd)
|
|
551
|
+
return 'unavailable';
|
|
552
|
+
const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
|
|
553
|
+
if (!resolved)
|
|
554
|
+
return 'unavailable';
|
|
555
|
+
if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
|
|
556
|
+
bridgeObservedCliSessionId = resolved.cliSessionId;
|
|
557
|
+
}
|
|
558
|
+
if (resolved.path === bridgeJsonlPath)
|
|
559
|
+
return 'same';
|
|
560
|
+
// Drain-before-switch: pull in any unread bytes from the OLD path so a
|
|
561
|
+
// trailing assistant append doesn't vanish. We do NOT emit here — emit
|
|
562
|
+
// is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
|
|
563
|
+
// publish a half-finished assistant during fs.watch / poll-driven
|
|
564
|
+
// bridgeIngest calls. If a started turn still references the old path
|
|
565
|
+
// and its assistant text might still be on the way, the old path stays
|
|
566
|
+
// in the polling rotation via bridgeSecondaryPaths.
|
|
567
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
568
|
+
let postDrainOffset = bridgeOffset;
|
|
569
|
+
try {
|
|
570
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
571
|
+
postDrainOffset = drained.offset;
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
log(`Bridge final-drain on rotation failed (${err.message}); continuing`);
|
|
575
|
+
}
|
|
576
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
577
|
+
}
|
|
578
|
+
log(`Bridge transcript switched (pid resolver): ${bridgeJsonlPath ?? '(none)'} → ${resolved.path}`);
|
|
579
|
+
if (bridgeWatcher) {
|
|
580
|
+
try {
|
|
581
|
+
bridgeWatcher.close();
|
|
582
|
+
}
|
|
583
|
+
catch { /* ignore */ }
|
|
584
|
+
bridgeWatcher = null;
|
|
585
|
+
}
|
|
586
|
+
// Preserve any pending Lark turn so the next ingest can attribute it
|
|
587
|
+
// when Claude appends our user event to the new jsonl. Skip baseline:
|
|
588
|
+
// we want to read from offset 0 so the pending turn's user event is
|
|
589
|
+
// visible to BridgeTurnQueue.ingest(). Turns already started on the
|
|
590
|
+
// old path keep their stamped sourceJsonlPath, so when their assistant
|
|
591
|
+
// text eventually arrives there too it still resolves correctly.
|
|
592
|
+
bridgeJsonlPath = resolved.path;
|
|
593
|
+
bridgeJsonlDir = dirname(resolved.path);
|
|
594
|
+
bridgeOffset = 0;
|
|
595
|
+
bridgePendingTail = '';
|
|
596
|
+
bridgeBaselineDone = true;
|
|
597
|
+
try {
|
|
598
|
+
bridgeWatcher = fsWatch(resolved.path, { persistent: false }, () => {
|
|
599
|
+
try {
|
|
600
|
+
bridgeIngest();
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
|
|
609
|
+
}
|
|
610
|
+
return 'switched';
|
|
611
|
+
}
|
|
612
|
+
function bridgeIngest() {
|
|
613
|
+
// Drain secondary paths first so any trailing assistant text on an old
|
|
614
|
+
// jsonl reaches the queue before the rotation check considers retiring
|
|
615
|
+
// the path. Strictly read-only on the polling rotation; never triggers
|
|
616
|
+
// a rotate or shifts the primary path.
|
|
617
|
+
drainSecondaryPaths();
|
|
618
|
+
// Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
|
|
619
|
+
// pid file → new sessionId), e.g. daemon restart that re-issues
|
|
620
|
+
// `--resume <id>` and Claude rotates the internal id.
|
|
621
|
+
const pidFollow = maybeFollowSessionRotationViaPid();
|
|
622
|
+
// Fingerprint fallback: catches *in-process* rotations Claude makes
|
|
623
|
+
// via /clear or /resume from the user's pane. Claude's pid file has
|
|
624
|
+
// its sessionId field set ONCE at process start (see binary persistence
|
|
625
|
+
// schema) and is NOT rewritten on /clear, so pid resolver returning
|
|
626
|
+
// 'same' is NOT proof that no rotation happened. We skip the
|
|
627
|
+
// fingerprint scan only when pid resolver actively switched the path
|
|
628
|
+
// — in that case the authoritative source already moved us, and
|
|
629
|
+
// running fingerprint on top would risk a redundant flip.
|
|
630
|
+
let switched = pidFollow === 'switched';
|
|
631
|
+
if (!switched) {
|
|
632
|
+
switched = maybeSwitchBridgeJsonl();
|
|
633
|
+
}
|
|
634
|
+
// Quiet-rotation fallback: catches /clear or /resume in pure-local
|
|
635
|
+
// sessions (no pending Lark turn → no fingerprint to match against).
|
|
636
|
+
// Without this, a user who hits /clear in the adopted pane and then
|
|
637
|
+
// continues in the terminal would never get those replies forwarded
|
|
638
|
+
// to Lark — the watcher stays stuck on the old, frozen jsonl.
|
|
639
|
+
if (!switched) {
|
|
640
|
+
maybeFollowQuietRotation();
|
|
641
|
+
}
|
|
642
|
+
if (!bridgeJsonlPath)
|
|
643
|
+
return;
|
|
644
|
+
if (!bridgeBaselineDone) {
|
|
645
|
+
// Lazy baseline: file didn't exist at attach, baseline the moment it does.
|
|
646
|
+
if (!existsSyncSafe(bridgeJsonlPath))
|
|
647
|
+
return;
|
|
648
|
+
bridgeAbsorbBaseline();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const result = drainTranscript(bridgeJsonlPath, bridgeOffset);
|
|
652
|
+
bridgeOffset = result.newOffset;
|
|
653
|
+
bridgePendingTail = result.pendingTail;
|
|
654
|
+
bridgeQueue.ingest(result.events, bridgeJsonlPath);
|
|
655
|
+
}
|
|
656
|
+
function startBridgeWatcher(jsonlPath, opts) {
|
|
657
|
+
bridgeJsonlPath = jsonlPath;
|
|
658
|
+
bridgeJsonlDir = dirname(jsonlPath);
|
|
659
|
+
bridgeCliPid = opts?.cliPid;
|
|
660
|
+
bridgeCliCwd = opts?.cliCwd;
|
|
661
|
+
const mode = opts?.mode ?? 'baseline-existing';
|
|
662
|
+
// Authoritative: prefer Claude's own pid-state record over the path the
|
|
663
|
+
// adopt scan computed. If Claude has already rotated since adopt fired
|
|
664
|
+
// (e.g. user ran /clear before any Lark message arrived), this swaps the
|
|
665
|
+
// initial path before baseline so we don't waste a baseline on a frozen
|
|
666
|
+
// file.
|
|
667
|
+
if (bridgeCliPid && bridgeCliCwd) {
|
|
668
|
+
const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
|
|
669
|
+
if (resolved) {
|
|
670
|
+
bridgeObservedCliSessionId = resolved.cliSessionId;
|
|
671
|
+
if (resolved.path !== bridgeJsonlPath) {
|
|
672
|
+
log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
|
|
673
|
+
bridgeJsonlPath = resolved.path;
|
|
674
|
+
bridgeJsonlDir = dirname(resolved.path);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (mode === 'fresh-empty') {
|
|
679
|
+
// Non-adopt fallback: brand-new session, jsonl gets created on the first
|
|
680
|
+
// user submit. We must NOT lazy-absorb the file when it appears — that
|
|
681
|
+
// would treat the first turn's user/assistant events as history and the
|
|
682
|
+
// worker would never emit a final_output for them. Instead declare
|
|
683
|
+
// baseline=done with offset=0 up front: the very first events drained
|
|
684
|
+
// from the file are eligible for attribution against pending Lark turns.
|
|
685
|
+
bridgeOffset = 0;
|
|
686
|
+
bridgePendingTail = '';
|
|
687
|
+
bridgeBaselineDone = true;
|
|
688
|
+
log(`Bridge fresh-empty mode: ${bridgeJsonlPath} (waiting for file to appear; no baseline absorb)`);
|
|
689
|
+
}
|
|
690
|
+
else if (existsSyncSafe(bridgeJsonlPath)) {
|
|
691
|
+
bridgeAbsorbBaseline();
|
|
692
|
+
log(`Bridge baselined: ${bridgeJsonlPath} (offset=${bridgeOffset})`);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
log(`Bridge transcript not yet present at ${bridgeJsonlPath}; will baseline on first appearance`);
|
|
696
|
+
}
|
|
697
|
+
// fs.watch is best-effort wakeup — actual data source is the byte offset.
|
|
698
|
+
// The fallback poller covers fs.watch's gaps (NFS, rename-rotation, etc.)
|
|
699
|
+
// and also drives lazy baseline when the file shows up after attach.
|
|
700
|
+
try {
|
|
701
|
+
bridgeWatcher = fsWatch(bridgeJsonlPath, { 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 (${err.message}); relying on fallback poller`);
|
|
712
|
+
}
|
|
713
|
+
bridgeFallbackTimer = setInterval(() => {
|
|
714
|
+
try {
|
|
715
|
+
bridgeIngest();
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
719
|
+
}
|
|
720
|
+
}, 1000);
|
|
721
|
+
}
|
|
722
|
+
function stopBridgeWatcher() {
|
|
723
|
+
if (bridgeWatcher) {
|
|
724
|
+
try {
|
|
725
|
+
bridgeWatcher.close();
|
|
726
|
+
}
|
|
727
|
+
catch { /* ignore */ }
|
|
728
|
+
bridgeWatcher = null;
|
|
729
|
+
}
|
|
730
|
+
if (bridgeFallbackTimer) {
|
|
731
|
+
clearInterval(bridgeFallbackTimer);
|
|
732
|
+
bridgeFallbackTimer = null;
|
|
733
|
+
}
|
|
734
|
+
bridgeCliPid = undefined;
|
|
735
|
+
bridgeCliCwd = undefined;
|
|
736
|
+
bridgeObservedCliSessionId = undefined;
|
|
737
|
+
bridgeSecondaryPaths.clear();
|
|
738
|
+
bridgePreambleSent = false;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Push a pending turn for the next Lark message.
|
|
742
|
+
*
|
|
743
|
+
* Returns true on success, false if bridge-final-output isn't available for
|
|
744
|
+
* this message (transcript not yet baselined). On false, the worker still
|
|
745
|
+
* raw-writes the message into the pane — the user just won't get a
|
|
746
|
+
* transcript-driven final_output reply for it. This keeps the v3 promise:
|
|
747
|
+
* if we can't attribute correctly, we don't attribute at all.
|
|
748
|
+
*
|
|
749
|
+
* `messageText` is the raw Lark message body — we derive a short content
|
|
750
|
+
* fingerprint from it so the next *matching* user event in the transcript
|
|
751
|
+
* (and only that one) starts this turn. Local-terminal input that races
|
|
752
|
+
* with the pane-write will not match the fingerprint and won't hijack the
|
|
753
|
+
* Lark turn.
|
|
754
|
+
*/
|
|
755
|
+
function bridgeMarkPendingTurn(messageText) {
|
|
756
|
+
if (!bridgeJsonlPath)
|
|
757
|
+
return false;
|
|
758
|
+
if (!bridgeBaselineDone) {
|
|
759
|
+
log('Bridge baseline not ready — this turn will not have transcript-driven final_output');
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
const fingerprint = makeFingerprint(messageText);
|
|
763
|
+
bridgeQueue.mark(randomBytes(8).toString('hex'), fingerprint);
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
function bridgeDrainAndMaybeEmit() {
|
|
767
|
+
if (!bridgeJsonlPath)
|
|
768
|
+
return;
|
|
769
|
+
bridgeIngest();
|
|
770
|
+
emitReadyTurns();
|
|
771
|
+
// Prune AFTER emit so a path is only retired once its turn has actually
|
|
772
|
+
// been published. During non-idle ticks (fs.watch / 1s poll) we never
|
|
773
|
+
// emit, so we never prune — the path stays put until idle resolves it.
|
|
774
|
+
pruneSecondaryPaths();
|
|
775
|
+
}
|
|
776
|
+
/** Pop ready turns and emit their final_output. Resolves uuid → text via
|
|
777
|
+
* each turn's own `sourceJsonlPath` (stamped at turn-start) so an in-flight
|
|
778
|
+
* reply that started in an old jsonl still gets picked up after a sessionId
|
|
779
|
+
* rotation has switched the global `bridgeJsonlPath` to a different file.
|
|
780
|
+
* Falls back to `bridgeJsonlPath` for legacy turns without a stamped source.
|
|
781
|
+
*
|
|
782
|
+
* Caches per-path drains so a batch of turns from the same file only reads
|
|
783
|
+
* the transcript once (O(jsonl size) per distinct path). */
|
|
784
|
+
function emitReadyTurns() {
|
|
785
|
+
const ready = bridgeQueue.drainEmittable();
|
|
786
|
+
if (ready.length === 0)
|
|
787
|
+
return;
|
|
788
|
+
const adoptMode = lastInitConfig?.adoptMode === true;
|
|
789
|
+
// Send markers (`botmux send` landed in own thread) + the queue's first
|
|
790
|
+
// still-unready turn. The latter caps the LAST ready turn's window —
|
|
791
|
+
// without it, a model that's still mid-tool-use for turn N+1 could leak
|
|
792
|
+
// a send credit into turn N's window via shouldSuppressBridgeEmit.
|
|
793
|
+
const markers = adoptMode ? [] : readSendMarkers();
|
|
794
|
+
const remainingPending = bridgeQueue.peek();
|
|
795
|
+
const nextPendingMarkTimeMs = remainingPending.length > 0 ? remainingPending[0].markTimeMs : undefined;
|
|
796
|
+
const cache = new Map();
|
|
797
|
+
for (let i = 0; i < ready.length; i++) {
|
|
798
|
+
const turn = ready[i];
|
|
799
|
+
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
|
|
800
|
+
if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
|
|
801
|
+
const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
|
|
802
|
+
log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const path = turn.sourceJsonlPath ?? bridgeJsonlPath;
|
|
806
|
+
if (!path)
|
|
807
|
+
continue;
|
|
808
|
+
let drained = cache.get(path);
|
|
809
|
+
if (!drained) {
|
|
810
|
+
drained = drainTranscript(path, 0);
|
|
811
|
+
cache.set(path, drained);
|
|
812
|
+
}
|
|
813
|
+
const set = new Set(turn.assistantUuids);
|
|
814
|
+
const matched = drained.events.filter(e => e.uuid && set.has(e.uuid));
|
|
815
|
+
const assistantText = joinAssistantText(matched);
|
|
816
|
+
if (assistantText.length === 0)
|
|
817
|
+
continue;
|
|
818
|
+
const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
|
|
819
|
+
if (turn.isLocal) {
|
|
820
|
+
// Local turn (adopt mode only): also surface the user prompt so the
|
|
821
|
+
// Lark thread shows both sides of the exchange. User text comes from
|
|
822
|
+
// the same drained transcript via the userUuid stamped at start time.
|
|
823
|
+
const userEv = turn.userUuid
|
|
824
|
+
? drained.events.find(e => e.uuid === turn.userUuid)
|
|
825
|
+
: undefined;
|
|
826
|
+
const userText = userEv ? stringifyUserContent(userEv.message?.content) : '';
|
|
827
|
+
const content = formatLocalTurnContent(userText, assistantText);
|
|
828
|
+
if (!content)
|
|
829
|
+
continue;
|
|
830
|
+
send({ type: 'final_output', content, lastUuid, turnId: turn.turnId });
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
send({ type: 'final_output', content: assistantText, lastUuid, turnId: turn.turnId });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/** Drain `path` from `fromOffset` and feed the events to the bridge queue
|
|
837
|
+
* with that path as the source stamp. Pure side-effects on bridgeQueue +
|
|
838
|
+
* the returned cursor; does NOT touch bridgeJsonlPath / bridgeOffset, so
|
|
839
|
+
* callers can use it to flush the old path during a rotation without
|
|
840
|
+
* disturbing the watcher's normal cursor. Returns the new offset for the
|
|
841
|
+
* caller to commit (or discard, if it's about to switch paths). */
|
|
842
|
+
function drainPathInto(path, fromOffset) {
|
|
843
|
+
const result = drainTranscript(path, fromOffset);
|
|
844
|
+
bridgeQueue.ingest(result.events, path);
|
|
845
|
+
return { offset: result.newOffset, tail: result.pendingTail };
|
|
846
|
+
}
|
|
847
|
+
// ─── Codex bridge wiring ─────────────────────────────────────────────────
|
|
848
|
+
//
|
|
849
|
+
// Codex's bridge fallback is intentionally simpler than Claude's: no /adopt
|
|
850
|
+
// surface, no pid-resolver / quiet-rotation / fingerprint-jsonl-switch
|
|
851
|
+
// machinery. The reader watches one rollout file (located by cliSessionId)
|
|
852
|
+
// and the queue's only responsibility is "user fingerprint match → start;
|
|
853
|
+
// assistant_final → close". Everything else (mark / emit gate / send
|
|
854
|
+
// marker IO / type-ahead serialisation / one-write-per-idle break) is
|
|
855
|
+
// shared with the Claude path.
|
|
856
|
+
function codexBridgeFallbackActive() {
|
|
857
|
+
if (lastInitConfig?.adoptMode)
|
|
858
|
+
return false;
|
|
859
|
+
return lastInitConfig?.cliId === 'codex';
|
|
860
|
+
}
|
|
861
|
+
function codexBridgeStartTimer() {
|
|
862
|
+
if (codexBridgeTimer)
|
|
863
|
+
return;
|
|
864
|
+
// Single 1s ticker that handles three jobs: late-attach (poll for the
|
|
865
|
+
// rollout file once we know cliSessionId), ingest (fs.watch backup),
|
|
866
|
+
// and idle-window emit. The last is critical for the late-attach race:
|
|
867
|
+
// if the rollout path appears AFTER the CLI's idle event has fired,
|
|
868
|
+
// the idle callback's emit already ran (and saw an empty queue), so
|
|
869
|
+
// the next emit chance would be at the next idle — i.e. the user has
|
|
870
|
+
// to send another message before the previous turn's fallback shows
|
|
871
|
+
// up. Emitting here when isPromptReady=true closes that window.
|
|
872
|
+
// Codex's queue only releases turns on `assistant_final` (the model's
|
|
873
|
+
// declared end-of-turn), so a tick-driven emit can't accidentally
|
|
874
|
+
// publish a half-streamed response.
|
|
875
|
+
codexBridgeTimer = setInterval(() => {
|
|
876
|
+
try {
|
|
877
|
+
if (!codexBridgeRolloutPath && codexBridgePendingSessionId) {
|
|
878
|
+
const path = findCodexRolloutBySessionId(codexBridgePendingSessionId);
|
|
879
|
+
if (path) {
|
|
880
|
+
codexBridgePendingSessionId = undefined;
|
|
881
|
+
codexBridgeAttach(path, 'fresh-empty');
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
codexBridgeIngest();
|
|
885
|
+
if (isPromptReady)
|
|
886
|
+
emitReadyCodexTurns();
|
|
887
|
+
}
|
|
888
|
+
catch (err) {
|
|
889
|
+
log(`Codex bridge tick error: ${err.message}`);
|
|
890
|
+
}
|
|
891
|
+
}, 1000);
|
|
892
|
+
}
|
|
893
|
+
function codexBridgeAttach(rolloutPath, mode) {
|
|
894
|
+
codexBridgeRolloutPath = rolloutPath;
|
|
895
|
+
if (mode === 'fresh-empty') {
|
|
896
|
+
// Brand-new session OR late-attach right after first submit. Either
|
|
897
|
+
// way we want to ingest from offset 0 — pending turns marked before
|
|
898
|
+
// attach are still in the queue, so the user_message that just landed
|
|
899
|
+
// (or is about to land) will fingerprint-match them.
|
|
900
|
+
codexBridgeOffset = 0;
|
|
901
|
+
codexBridgePendingTail = '';
|
|
902
|
+
codexBridgeBaselineDone = true;
|
|
903
|
+
log(`Codex bridge fresh-empty: ${rolloutPath}`);
|
|
904
|
+
}
|
|
905
|
+
else if (existsSync(rolloutPath)) {
|
|
906
|
+
const result = drainCodexRollout(rolloutPath, 0);
|
|
907
|
+
codexBridgeOffset = result.newOffset;
|
|
908
|
+
codexBridgePendingTail = result.pendingTail;
|
|
909
|
+
codexBridgeQueue.absorb(result.events);
|
|
910
|
+
codexBridgeBaselineDone = true;
|
|
911
|
+
log(`Codex bridge baselined: ${rolloutPath} (offset=${codexBridgeOffset})`);
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
// baseline-existing requested but file missing — degrade to fresh
|
|
915
|
+
// semantics so the lazy-appearing file isn't accidentally absorbed.
|
|
916
|
+
codexBridgeOffset = 0;
|
|
917
|
+
codexBridgePendingTail = '';
|
|
918
|
+
codexBridgeBaselineDone = true;
|
|
919
|
+
log(`Codex bridge transcript not yet present at ${rolloutPath}; treating as fresh`);
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
codexBridgeWatcher = fsWatch(rolloutPath, { persistent: false }, () => {
|
|
923
|
+
try {
|
|
924
|
+
codexBridgeIngest();
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
log(`Codex bridge ingest error: ${err.message}`);
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
catch (err) {
|
|
932
|
+
log(`Codex bridge fs.watch unavailable (${err.message}); relying on poller`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/** Called from flushPending after writeInput first returns a cliSessionId.
|
|
936
|
+
* Tries to locate the rollout file immediately; if it's not on disk yet,
|
|
937
|
+
* remembers the sid so the 1s poller can keep retrying. */
|
|
938
|
+
function codexBridgeNotifyCliSessionId(cliSessionId) {
|
|
939
|
+
if (!codexBridgeFallbackActive() || codexBridgeRolloutPath)
|
|
940
|
+
return;
|
|
941
|
+
const path = findCodexRolloutBySessionId(cliSessionId);
|
|
942
|
+
if (path) {
|
|
943
|
+
codexBridgePendingSessionId = undefined;
|
|
944
|
+
codexBridgeAttach(path, 'fresh-empty');
|
|
945
|
+
}
|
|
946
|
+
else {
|
|
947
|
+
codexBridgePendingSessionId = cliSessionId;
|
|
948
|
+
codexBridgeStartTimer();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function codexBridgeIngest() {
|
|
952
|
+
if (!codexBridgeRolloutPath || !codexBridgeBaselineDone)
|
|
953
|
+
return;
|
|
954
|
+
const result = drainCodexRollout(codexBridgeRolloutPath, codexBridgeOffset);
|
|
955
|
+
codexBridgeOffset = result.newOffset;
|
|
956
|
+
codexBridgePendingTail = result.pendingTail;
|
|
957
|
+
codexBridgeQueue.ingest(result.events);
|
|
958
|
+
}
|
|
959
|
+
/** Mark a pending Lark turn for Codex. Crucially this works even before a
|
|
960
|
+
* rollout path is known — the queue is path-agnostic, and ingest after
|
|
961
|
+
* late-attach picks up the user_message and matches the fingerprint. */
|
|
962
|
+
function codexBridgeMarkPendingTurn(messageText) {
|
|
963
|
+
if (!codexBridgeFallbackActive())
|
|
964
|
+
return false;
|
|
965
|
+
const turnId = `codex-${randomBytes(8).toString('hex')}`;
|
|
966
|
+
codexBridgeQueue.mark(turnId, messageText);
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
function codexBridgeDrainAndMaybeEmit() {
|
|
970
|
+
if (!codexBridgeFallbackActive())
|
|
971
|
+
return;
|
|
972
|
+
if (codexBridgeRolloutPath && codexBridgeBaselineDone) {
|
|
973
|
+
try {
|
|
974
|
+
codexBridgeIngest();
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
log(`Codex bridge ingest error: ${err.message}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
emitReadyCodexTurns();
|
|
981
|
+
}
|
|
982
|
+
function emitReadyCodexTurns() {
|
|
983
|
+
const ready = codexBridgeQueue.drainEmittable();
|
|
984
|
+
if (ready.length === 0)
|
|
985
|
+
return;
|
|
986
|
+
const markers = readSendMarkers();
|
|
987
|
+
const remaining = codexBridgeQueue.peek();
|
|
988
|
+
const nextPendingMarkTimeMs = remaining.length > 0 ? remaining[0].markTimeMs : undefined;
|
|
989
|
+
for (let i = 0; i < ready.length; i++) {
|
|
990
|
+
const turn = ready[i];
|
|
991
|
+
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
|
|
992
|
+
if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: false }, nextBoundaryMs, markers, false)) {
|
|
993
|
+
log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (model called botmux send within window)`);
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (!turn.finalText)
|
|
997
|
+
continue;
|
|
998
|
+
send({ type: 'final_output', content: turn.finalText, lastUuid: turn.turnId, turnId: turn.turnId });
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function stopCodexBridge() {
|
|
1002
|
+
if (codexBridgeWatcher) {
|
|
1003
|
+
try {
|
|
1004
|
+
codexBridgeWatcher.close();
|
|
1005
|
+
}
|
|
1006
|
+
catch { /* ignore */ }
|
|
1007
|
+
codexBridgeWatcher = null;
|
|
1008
|
+
}
|
|
1009
|
+
if (codexBridgeTimer) {
|
|
1010
|
+
clearInterval(codexBridgeTimer);
|
|
1011
|
+
codexBridgeTimer = null;
|
|
1012
|
+
}
|
|
1013
|
+
codexBridgeRolloutPath = undefined;
|
|
1014
|
+
codexBridgeOffset = 0;
|
|
1015
|
+
codexBridgePendingTail = '';
|
|
1016
|
+
codexBridgeBaselineDone = false;
|
|
1017
|
+
codexBridgeQueue.clearPending();
|
|
1018
|
+
codexBridgePendingSessionId = undefined;
|
|
1019
|
+
}
|
|
1020
|
+
/** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
|
|
1021
|
+
* whose sourceJsonlPath equals oldPath may still be waiting on assistant
|
|
1022
|
+
* text that hasn't landed yet. Add oldPath to the secondary polling set
|
|
1023
|
+
* so subsequent ingests continue to drain it; the offset is whatever was
|
|
1024
|
+
* reached by the final pre-switch drain so we don't re-scan history. The
|
|
1025
|
+
* entry is later pruned after each idle emit when no started turn
|
|
1026
|
+
* references it anymore. */
|
|
1027
|
+
function retainSecondaryPathIfStillReferenced(oldPath, postDrainOffset) {
|
|
1028
|
+
const stillReferenced = bridgeQueue.peek().some(t => t.sourceJsonlPath === oldPath);
|
|
1029
|
+
if (!stillReferenced)
|
|
1030
|
+
return;
|
|
1031
|
+
const existing = bridgeSecondaryPaths.get(oldPath);
|
|
1032
|
+
// Don't rewind a higher existing offset — multiple rotations through
|
|
1033
|
+
// the same file shouldn't replay drained bytes.
|
|
1034
|
+
if (existing === undefined || postDrainOffset > existing) {
|
|
1035
|
+
bridgeSecondaryPaths.set(oldPath, postDrainOffset);
|
|
1036
|
+
}
|
|
1037
|
+
log(`Bridge retaining secondary path ${oldPath} (offset=${postDrainOffset}) for in-flight turn`);
|
|
1038
|
+
}
|
|
1039
|
+
/** Drain every secondary path once. Mirrors bridgeIngest's primary-path
|
|
1040
|
+
* drain but never touches bridgeJsonlPath / bridgeOffset and never
|
|
1041
|
+
* triggers further rotation checks — it's strictly a "catch up trailing
|
|
1042
|
+
* events on an old file" pass. */
|
|
1043
|
+
function drainSecondaryPaths() {
|
|
1044
|
+
for (const [path, offset] of bridgeSecondaryPaths) {
|
|
1045
|
+
try {
|
|
1046
|
+
const result = drainTranscript(path, offset);
|
|
1047
|
+
if (result.events.length > 0)
|
|
1048
|
+
bridgeQueue.ingest(result.events, path);
|
|
1049
|
+
bridgeSecondaryPaths.set(path, result.newOffset);
|
|
1050
|
+
}
|
|
1051
|
+
catch (err) {
|
|
1052
|
+
log(`Bridge secondary-path drain failed (${path}): ${err.message}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/** Drop secondary paths whose started turns are no longer in the queue —
|
|
1057
|
+
* i.e. they've been emitted (or discarded). Called after each idle emit so
|
|
1058
|
+
* pruning never races with an in-flight turn. */
|
|
1059
|
+
function pruneSecondaryPaths() {
|
|
1060
|
+
if (bridgeSecondaryPaths.size === 0)
|
|
1061
|
+
return;
|
|
1062
|
+
const referenced = new Set();
|
|
1063
|
+
for (const t of bridgeQueue.peek()) {
|
|
1064
|
+
if (t.sourceJsonlPath)
|
|
1065
|
+
referenced.add(t.sourceJsonlPath);
|
|
1066
|
+
}
|
|
1067
|
+
for (const path of [...bridgeSecondaryPaths.keys()]) {
|
|
1068
|
+
if (!referenced.has(path)) {
|
|
1069
|
+
bridgeSecondaryPaths.delete(path);
|
|
1070
|
+
log(`Bridge dropped secondary path ${path} (no remaining turns)`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/** Tiny safe-existence check that doesn't throw. */
|
|
1075
|
+
function existsSyncSafe(p) {
|
|
1076
|
+
try {
|
|
1077
|
+
return existsSync(p);
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
54
1083
|
/** Suppress screen updates until first prompt detected (avoids history replay in card on --resume) */
|
|
55
1084
|
let awaitingFirstPrompt = true;
|
|
56
1085
|
// ─── PTY Dimensions ──────────────────────────────────────────────────────────
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
1086
|
+
// Default for botmux-spawned CLIs: narrow enough for the web terminal to
|
|
1087
|
+
// render comfortably and for the card PNG to fit Lark's typical card width.
|
|
1088
|
+
// Adopt mode overrides this via resolveRenderDimensions() to match the
|
|
1089
|
+
// user's actual pane (often 200-270 cols) so the renderer doesn't wrap
|
|
1090
|
+
// wide ANSI into a stair-stepped / duplicated mess.
|
|
1091
|
+
const PTY_COLS = DEFAULT_RENDER_COLS;
|
|
1092
|
+
const PTY_ROWS = DEFAULT_RENDER_ROWS;
|
|
1093
|
+
/** Set in the `init` handler BEFORE startScreenUpdates() so the headless
|
|
1094
|
+
* xterm + screenshot canvas are sized to the source pane from the start.
|
|
1095
|
+
* Setting them later (after the renderer was built at the default size)
|
|
1096
|
+
* wouldn't retroactively re-size what xterm has already buffered,
|
|
1097
|
+
* leaving the wrap artefacts in place. */
|
|
1098
|
+
let renderCols = PTY_COLS;
|
|
1099
|
+
let renderRows = PTY_ROWS;
|
|
61
1100
|
// ─── Headless Terminal for Screen Capture ────────────────────────────────────
|
|
62
1101
|
let renderer = null;
|
|
63
1102
|
let screenUpdateTimer = null;
|
|
@@ -117,8 +1156,10 @@ function stopScreenAnalyzer() {
|
|
|
117
1156
|
// ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
|
|
118
1157
|
const SCREENSHOT_INTERVAL_MS = 10_000;
|
|
119
1158
|
const POST_ACTION_DELAY_MS = 1_000;
|
|
120
|
-
|
|
121
|
-
|
|
1159
|
+
// PNG dimensions key off the renderer's actual size (renderCols / renderRows),
|
|
1160
|
+
// which adopt-mode peg to the source pane so wrap artefacts don't appear.
|
|
1161
|
+
// Re-clamping at MAX_RENDER_COLS/ROWS guards against a malformed init
|
|
1162
|
+
// payload sneaking past the resolver into a runaway canvas.
|
|
122
1163
|
let displayMode = 'hidden';
|
|
123
1164
|
let screenshotTimer = null;
|
|
124
1165
|
let pendingShotTimer = null;
|
|
@@ -178,7 +1219,9 @@ async function captureAndUpload() {
|
|
|
178
1219
|
lastShotHash = hash;
|
|
179
1220
|
let png;
|
|
180
1221
|
try {
|
|
181
|
-
|
|
1222
|
+
const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
|
|
1223
|
+
const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
|
|
1224
|
+
png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
|
|
182
1225
|
}
|
|
183
1226
|
catch (err) {
|
|
184
1227
|
log(`Screenshot render failed: ${err.message}`);
|
|
@@ -377,9 +1420,11 @@ let trustHandled = false;
|
|
|
377
1420
|
// ─── Prompt Detection ────────────────────────────────────────────────────────
|
|
378
1421
|
function onPtyData(data) {
|
|
379
1422
|
renderer?.write(data);
|
|
380
|
-
// In tmux mode, web
|
|
381
|
-
// In non-tmux mode
|
|
382
|
-
|
|
1423
|
+
// In tmux-attach mode, each web client has its own tmux attach PTY —
|
|
1424
|
+
// no relay needed. In non-tmux mode AND in pipe mode (adopt-bridge),
|
|
1425
|
+
// broadcast through the shared scrollback so all connected web clients
|
|
1426
|
+
// render the same byte stream.
|
|
1427
|
+
if (!isTmuxMode || isPipeMode) {
|
|
383
1428
|
// Track alt-buffer state so we can restore it in the scrollback prefix.
|
|
384
1429
|
// Scan for the *last* toggle in this chunk — that's the current state.
|
|
385
1430
|
let lastToggleIdx = -1;
|
|
@@ -453,6 +1498,23 @@ function markPromptReady() {
|
|
|
453
1498
|
}
|
|
454
1499
|
flushPending();
|
|
455
1500
|
}
|
|
1501
|
+
function persistCliSessionId(cliSessionId) {
|
|
1502
|
+
if (!cliSessionId || !sessionId)
|
|
1503
|
+
return;
|
|
1504
|
+
if (lastInitConfig)
|
|
1505
|
+
lastInitConfig.cliSessionId = cliSessionId;
|
|
1506
|
+
try {
|
|
1507
|
+
const session = sessionStore.getSession(sessionId);
|
|
1508
|
+
if (!session || session.cliSessionId === cliSessionId)
|
|
1509
|
+
return;
|
|
1510
|
+
session.cliSessionId = cliSessionId;
|
|
1511
|
+
sessionStore.updateSession(session);
|
|
1512
|
+
log(`Persisted CLI session id: ${cliSessionId}`);
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
log(`Failed to persist CLI session id: ${err.message}`);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
456
1518
|
/**
|
|
457
1519
|
* Drain the pending message queue sequentially.
|
|
458
1520
|
* Async with isFlushing mutex: awaits each writeInput, then immediately
|
|
@@ -467,7 +1529,21 @@ async function flushPending() {
|
|
|
467
1529
|
if (pendingMessages.length === 0)
|
|
468
1530
|
return; // nothing to flush — keep isPromptReady
|
|
469
1531
|
// Type-ahead adapters flush even while the CLI is busy; others wait for idle.
|
|
470
|
-
|
|
1532
|
+
// Bridge fallback (non-adopt) disables type-ahead: queued submits land
|
|
1533
|
+
// in jsonl as `attachment(queued_command)` events, NOT `role:user` lines,
|
|
1534
|
+
// so BridgeTurnQueue.ingest never starts the pending turn for them and
|
|
1535
|
+
// the assistant text would be dropped on the floor. Serialise instead —
|
|
1536
|
+
// worker holds messages in pendingMessages until the CLI reaches idle.
|
|
1537
|
+
const claudeBridgeActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
|
|
1538
|
+
const codexBridgeActive = codexBridgeFallbackActive();
|
|
1539
|
+
const bridgeFallbackActive = claudeBridgeActive || codexBridgeActive;
|
|
1540
|
+
// Type-ahead must be disabled for any active bridge fallback (claude or
|
|
1541
|
+
// codex). Claude type-ahead's queued submits never become role:user
|
|
1542
|
+
// events; Codex doesn't declare supportsTypeAhead so this is mostly a
|
|
1543
|
+
// belt-and-braces gate, but keep symmetry so future adapters with
|
|
1544
|
+
// type-ahead get the same protection automatically.
|
|
1545
|
+
const typeAheadAllowed = cliAdapter.supportsTypeAhead && !bridgeFallbackActive;
|
|
1546
|
+
if (!isPromptReady && !typeAheadAllowed)
|
|
471
1547
|
return;
|
|
472
1548
|
isFlushing = true;
|
|
473
1549
|
if (isPromptReady) {
|
|
@@ -477,8 +1553,41 @@ async function flushPending() {
|
|
|
477
1553
|
try {
|
|
478
1554
|
while (pendingMessages.length > 0 && backend && cliAdapter) {
|
|
479
1555
|
const msg = pendingMessages.shift();
|
|
1556
|
+
// Bridge fallback: mark immediately before writeInput. Doing it here
|
|
1557
|
+
// (instead of at enqueue time) means markTimeMs anchors to the
|
|
1558
|
+
// moment the message actually starts hitting the PTY — so any
|
|
1559
|
+
// `botmux send` whose sentAtMs lands during turn N's processing
|
|
1560
|
+
// falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
|
|
1561
|
+
// (at IPC arrival) would let a slow-finishing turn N's send leak
|
|
1562
|
+
// into turn N+1's window and falsely suppress its emit.
|
|
1563
|
+
if (claudeBridgeActive) {
|
|
1564
|
+
try {
|
|
1565
|
+
bridgeIngest();
|
|
1566
|
+
}
|
|
1567
|
+
catch { /* best-effort */ }
|
|
1568
|
+
bridgeMarkPendingTurn(msg);
|
|
1569
|
+
}
|
|
1570
|
+
else if (codexBridgeActive) {
|
|
1571
|
+
// Codex mark works even before the rollout path is known: the
|
|
1572
|
+
// queue is path-agnostic, and the late-attach below will start
|
|
1573
|
+
// ingest from offset 0 so the user_message that lands shortly
|
|
1574
|
+
// after still fingerprint-matches this turn.
|
|
1575
|
+
codexBridgeMarkPendingTurn(msg);
|
|
1576
|
+
}
|
|
480
1577
|
log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
|
|
481
1578
|
const result = await cliAdapter.writeInput(backend, msg);
|
|
1579
|
+
// Persist any sessionId the adapter observed via authoritative sources
|
|
1580
|
+
// (Claude's pid file, Codex's history). Done independently of submit
|
|
1581
|
+
// outcome — the rotation is real even when the current Enter didn't
|
|
1582
|
+
// land, and we want next-resume to use the right id.
|
|
1583
|
+
if (result?.cliSessionId) {
|
|
1584
|
+
persistCliSessionId(result.cliSessionId);
|
|
1585
|
+
// First successful Codex submit also reveals the rollout path.
|
|
1586
|
+
// Late-attach now so subsequent assistant_final events get
|
|
1587
|
+
// attributed to this turn.
|
|
1588
|
+
if (codexBridgeActive)
|
|
1589
|
+
codexBridgeNotifyCliSessionId(result.cliSessionId);
|
|
1590
|
+
}
|
|
482
1591
|
if (result && result.submitted === false) {
|
|
483
1592
|
const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
|
|
484
1593
|
log(`writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
|
|
@@ -487,6 +1596,16 @@ async function flushPending() {
|
|
|
487
1596
|
message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 3 次仍未在会话 JSONL 中看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
|
|
488
1597
|
});
|
|
489
1598
|
}
|
|
1599
|
+
// Bridge fallback: stop after one writeInput. Subsequent submits
|
|
1600
|
+
// would be type-ahead'd into Claude's queue, which jsonl records as
|
|
1601
|
+
// queued_command attachments (not role:user lines) — BridgeTurnQueue
|
|
1602
|
+
// can't attribute those, so the fallback would silently drop them.
|
|
1603
|
+
// We resume on the next idle, by which point Claude has finished
|
|
1604
|
+
// and the next message can be a normal role:user submit. Scoped to
|
|
1605
|
+
// bridgeFallbackActive so non-bridge CLIs (codex/gemini/...) keep
|
|
1606
|
+
// the original "one idle drains all pending" behaviour.
|
|
1607
|
+
if (bridgeFallbackActive && pendingMessages.length > 0)
|
|
1608
|
+
break;
|
|
490
1609
|
}
|
|
491
1610
|
}
|
|
492
1611
|
finally {
|
|
@@ -497,11 +1616,23 @@ function sendToPty(content) {
|
|
|
497
1616
|
if (!backend || !cliAdapter)
|
|
498
1617
|
return;
|
|
499
1618
|
pendingMessages.push(content);
|
|
1619
|
+
// User-override semantics: a fresh Lark message while a TUI prompt is "active"
|
|
1620
|
+
// takes precedence over the AI-detected prompt. The screen analyzer can be
|
|
1621
|
+
// wrong (false positive on a question that has no rendered options) and a
|
|
1622
|
+
// wedged blocking flag silently swallows every subsequent message — without
|
|
1623
|
+
// this override the user has no way to recover from Lark. Mirrors the
|
|
1624
|
+
// web-terminal text-input path (handleTuiTextInput).
|
|
500
1625
|
if (tuiPromptBlocking) {
|
|
501
|
-
log(`
|
|
502
|
-
|
|
1626
|
+
log(`User override: incoming Lark message clears tuiPromptBlocking — "${content.substring(0, 80)}"`);
|
|
1627
|
+
tuiPromptBlocking = false;
|
|
1628
|
+
screenAnalyzer?.notifySelection('lark-input');
|
|
1629
|
+
// Tear down the prompt card so the user doesn't see stale options.
|
|
1630
|
+
send({ type: 'tui_prompt_resolved', selectedText: 'user-override' });
|
|
503
1631
|
}
|
|
504
|
-
|
|
1632
|
+
// See flushPending: bridge fallback gates type-ahead off (claude OR codex).
|
|
1633
|
+
const bridgeFallbackActive = (!!bridgeJsonlPath && !lastInitConfig?.adoptMode) || codexBridgeFallbackActive();
|
|
1634
|
+
const typeAheadAllowed = cliAdapter.supportsTypeAhead && !bridgeFallbackActive;
|
|
1635
|
+
if (isPromptReady || isFlushing || typeAheadAllowed) {
|
|
505
1636
|
log(`Writing to PTY: "${content.substring(0, 80)}"`);
|
|
506
1637
|
flushPending(); // fire-and-forget async; no-op if already flushing
|
|
507
1638
|
}
|
|
@@ -511,7 +1642,12 @@ function sendToPty(content) {
|
|
|
511
1642
|
}
|
|
512
1643
|
// ─── Screen Update Timer ─────────────────────────────────────────────────────
|
|
513
1644
|
function startScreenUpdates() {
|
|
514
|
-
|
|
1645
|
+
// renderCols / renderRows were set by the init handler from cfg, so
|
|
1646
|
+
// adopt-mode panes (e.g. 270x57) get an xterm-headless of matching
|
|
1647
|
+
// width. With a too-narrow renderer, ANSI meant for the source pane
|
|
1648
|
+
// would wrap and the screenshot would show duplicated / stair-stepped
|
|
1649
|
+
// content (the live failure that prompted this fix).
|
|
1650
|
+
renderer = new TerminalRenderer(renderCols, renderRows);
|
|
515
1651
|
let lastSentStatus;
|
|
516
1652
|
screenUpdateTimer = setInterval(() => {
|
|
517
1653
|
if (!renderer || awaitingFirstPrompt)
|
|
@@ -539,36 +1675,73 @@ function stopScreenUpdates() {
|
|
|
539
1675
|
}
|
|
540
1676
|
// ─── PTY Management ──────────────────────────────────────────────────────────
|
|
541
1677
|
function spawnCli(cfg) {
|
|
542
|
-
// ── Adopt mode:
|
|
1678
|
+
// ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
|
|
543
1679
|
if (cfg.adoptMode && cfg.adoptTmuxTarget) {
|
|
1680
|
+
// We mark BOTH isTmuxMode and isPipeMode: the former keeps idle/spawn
|
|
1681
|
+
// logic on the tmux track; the latter tells the WS handler to route
|
|
1682
|
+
// updates through the shared scrollback fan-out (because there is no
|
|
1683
|
+
// PTY-per-WS — we don't attach to anything).
|
|
544
1684
|
isTmuxMode = true;
|
|
1685
|
+
isPipeMode = true;
|
|
545
1686
|
const cols = cfg.adoptPaneCols ?? PTY_COLS;
|
|
546
1687
|
const rows = cfg.adoptPaneRows ?? PTY_ROWS;
|
|
547
|
-
const
|
|
548
|
-
backend =
|
|
549
|
-
|
|
1688
|
+
const pipeBe = new TmuxPipeBackend(cfg.adoptTmuxTarget);
|
|
1689
|
+
backend = pipeBe;
|
|
1690
|
+
pipeBe.spawn('', [], {
|
|
550
1691
|
cwd: cfg.workingDir,
|
|
551
1692
|
cols,
|
|
552
1693
|
rows,
|
|
553
1694
|
env: process.env,
|
|
554
1695
|
});
|
|
555
|
-
//
|
|
556
|
-
|
|
1696
|
+
// Seed the shared scrollback with the pane's current screen so any
|
|
1697
|
+
// already-connected (or future) WS clients render meaningful content
|
|
1698
|
+
// immediately, instead of waiting for the next byte tmux pipes through.
|
|
1699
|
+
try {
|
|
1700
|
+
const initial = pipeBe.captureCurrentScreen();
|
|
1701
|
+
if (initial.length > 0)
|
|
1702
|
+
onPtyData(initial);
|
|
1703
|
+
}
|
|
1704
|
+
catch (err) {
|
|
1705
|
+
log(`captureCurrentScreen failed: ${err.message}`);
|
|
1706
|
+
}
|
|
1707
|
+
// Bridge mode: tail Claude Code's transcript JSONL to harvest assistant
|
|
1708
|
+
// turns out-of-band. Only enabled when the daemon supplied a path
|
|
1709
|
+
// (claude-code adopt with a known sessionId).
|
|
1710
|
+
if (cfg.bridgeJsonlPath) {
|
|
1711
|
+
startBridgeWatcher(cfg.bridgeJsonlPath, {
|
|
1712
|
+
cliPid: cfg.adoptCliPid,
|
|
1713
|
+
cliCwd: cfg.adoptCwd,
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
// Idle detection. In bridge mode we use Claude Code's real
|
|
1717
|
+
// completion/ready patterns (e.g. "Worked for Xs") so tool-execution
|
|
1718
|
+
// pauses don't trigger a premature emit. Other adopt cases keep the
|
|
1719
|
+
// minimal output-quiescence-only detector.
|
|
1720
|
+
const idleAdapter = cfg.bridgeJsonlPath
|
|
1721
|
+
? createCliAdapterSync('claude-code', undefined)
|
|
1722
|
+
: { completionPattern: undefined, readyPattern: undefined };
|
|
1723
|
+
idleDetector = new IdleDetector(idleAdapter);
|
|
557
1724
|
idleDetector.onIdle(() => {
|
|
558
1725
|
log('Prompt detected (idle) — adopt mode');
|
|
1726
|
+
try {
|
|
1727
|
+
bridgeDrainAndMaybeEmit();
|
|
1728
|
+
}
|
|
1729
|
+
catch (err) {
|
|
1730
|
+
log(`Bridge emit error: ${err.message}`);
|
|
1731
|
+
}
|
|
559
1732
|
markPromptReady();
|
|
560
1733
|
});
|
|
561
1734
|
backend.onData(onPtyData);
|
|
562
1735
|
backend.onExit((code, signal) => {
|
|
563
|
-
log(`Adopted
|
|
1736
|
+
log(`Adopted pipe-pane stream ended (code: ${code}, signal: ${signal})`);
|
|
564
1737
|
backend = null;
|
|
565
1738
|
isPromptReady = false;
|
|
1739
|
+
stopBridgeWatcher();
|
|
566
1740
|
send({ type: 'claude_exit', code, signal });
|
|
567
1741
|
});
|
|
568
|
-
// CLI is already running — unblock screen updates immediately
|
|
569
1742
|
awaitingFirstPrompt = false;
|
|
570
1743
|
renderer?.markNewTurn();
|
|
571
|
-
log(`Adopt mode:
|
|
1744
|
+
log(`Adopt mode (pipe): observing ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
|
|
572
1745
|
return;
|
|
573
1746
|
}
|
|
574
1747
|
cliAdapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride);
|
|
@@ -588,6 +1761,7 @@ function spawnCli(cfg) {
|
|
|
588
1761
|
const args = cliAdapter.buildArgs({
|
|
589
1762
|
sessionId: cfg.sessionId,
|
|
590
1763
|
resume: cfg.resume ?? false,
|
|
1764
|
+
resumeSessionId: cfg.cliSessionId,
|
|
591
1765
|
initialPrompt: cfg.prompt || undefined,
|
|
592
1766
|
botName: cfg.botName,
|
|
593
1767
|
botOpenId: cfg.botOpenId,
|
|
@@ -632,16 +1806,85 @@ function spawnCli(cfg) {
|
|
|
632
1806
|
log(`Failed to write CLI PID marker: ${err.message}`);
|
|
633
1807
|
}
|
|
634
1808
|
}
|
|
1809
|
+
// Wire pid + cwd so the claude-code adapter's writeInput can read
|
|
1810
|
+
// ~/.claude/sessions/<pid>.json — Claude's authoritative current sessionId.
|
|
1811
|
+
// The pinned claudeJsonlPath above is still used as the initial guess; the
|
|
1812
|
+
// resolver corrects it on first write when Claude has rotated under us.
|
|
1813
|
+
if (cfg.cliId === 'claude-code' && cliPid) {
|
|
1814
|
+
backend.cliPid = cliPid;
|
|
1815
|
+
backend.cliCwd = cfg.workingDir;
|
|
1816
|
+
}
|
|
635
1817
|
// On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
|
|
636
1818
|
// suppressed until the idle detector fires markNewTurn() — this prevents the
|
|
637
1819
|
// full tmux scrollback history from leaking into the streaming card.
|
|
638
1820
|
if (tmuxBe?.isReattach) {
|
|
639
1821
|
log('Re-attached to existing tmux session');
|
|
640
1822
|
}
|
|
1823
|
+
// Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
|
|
1824
|
+
// turn the model finishes WITHOUT calling `botmux send` still gets its
|
|
1825
|
+
// assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
|
|
1826
|
+
// the emit when a send did happen). Adopt mode wires this up separately
|
|
1827
|
+
// (with baseline-existing); here we use fresh-empty for new sessions so
|
|
1828
|
+
// the file Claude creates on first submit isn't absorbed as history,
|
|
1829
|
+
// and baseline-existing on resume so prior-run turns ARE absorbed (we
|
|
1830
|
+
// don't want to re-emit yesterday's conversation as fresh turns).
|
|
1831
|
+
if (cfg.cliId === 'claude-code' && cfg.sessionId) {
|
|
1832
|
+
const claudeJsonl = claudeJsonlPathForSession(cfg.sessionId, cfg.workingDir);
|
|
1833
|
+
startBridgeWatcher(claudeJsonl, {
|
|
1834
|
+
cliPid: cliPid ?? undefined,
|
|
1835
|
+
cliCwd: cfg.workingDir,
|
|
1836
|
+
mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
// Codex bridge fallback: same intent as the Claude block above but a
|
|
1840
|
+
// different transcript layout. Resume-with-known-cliSessionId can attach
|
|
1841
|
+
// immediately; new sessions / resume-without-id rely on flushPending to
|
|
1842
|
+
// late-attach once writeInput returns the cliSessionId.
|
|
1843
|
+
if (cfg.cliId === 'codex') {
|
|
1844
|
+
if (cfg.cliSessionId) {
|
|
1845
|
+
const rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
|
|
1846
|
+
if (rolloutPath) {
|
|
1847
|
+
codexBridgeAttach(rolloutPath, 'baseline-existing');
|
|
1848
|
+
}
|
|
1849
|
+
else {
|
|
1850
|
+
// Resume but the rollout file isn't where we expected — start the
|
|
1851
|
+
// poller so we keep looking; if the user submits and a new file
|
|
1852
|
+
// appears, late-attach kicks in via writeInput's cliSessionId.
|
|
1853
|
+
codexBridgePendingSessionId = cfg.cliSessionId;
|
|
1854
|
+
codexBridgeStartTimer();
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
else {
|
|
1858
|
+
// Brand-new Codex session: no path until first submit. Start the
|
|
1859
|
+
// poller anyway so the CLI is ready to attach the moment we have
|
|
1860
|
+
// a cliSessionId.
|
|
1861
|
+
codexBridgeStartTimer();
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
641
1864
|
// Set up idle detection
|
|
642
1865
|
idleDetector = new IdleDetector(cliAdapter);
|
|
643
1866
|
idleDetector.onIdle(() => {
|
|
644
1867
|
log('Prompt detected (idle)');
|
|
1868
|
+
// Bridge drain MUST run before markPromptReady() — the latter calls
|
|
1869
|
+
// flushPending() which can immediately fire the next queued message
|
|
1870
|
+
// (type-ahead adapters), shifting bridgeQueue's notion of "current
|
|
1871
|
+
// turn" before we've had a chance to emit the previous one.
|
|
1872
|
+
if (bridgeJsonlPath) {
|
|
1873
|
+
try {
|
|
1874
|
+
bridgeDrainAndMaybeEmit();
|
|
1875
|
+
}
|
|
1876
|
+
catch (err) {
|
|
1877
|
+
log(`Bridge emit error: ${err.message}`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (codexBridgeFallbackActive()) {
|
|
1881
|
+
try {
|
|
1882
|
+
codexBridgeDrainAndMaybeEmit();
|
|
1883
|
+
}
|
|
1884
|
+
catch (err) {
|
|
1885
|
+
log(`Codex bridge emit error: ${err.message}`);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
645
1888
|
markPromptReady();
|
|
646
1889
|
});
|
|
647
1890
|
backend.onData(onPtyData);
|
|
@@ -671,6 +1914,11 @@ function killCli() {
|
|
|
671
1914
|
stopScreenUpdates();
|
|
672
1915
|
backend?.kill();
|
|
673
1916
|
backend = null;
|
|
1917
|
+
// Tear down the bridge watcher (if any). spawnCli will rebuild it on
|
|
1918
|
+
// restart with the proper mode based on the new cfg. Leaving it running
|
|
1919
|
+
// would dangle a watcher pinned to a stale jsonl path.
|
|
1920
|
+
stopBridgeWatcher();
|
|
1921
|
+
stopCodexBridge();
|
|
674
1922
|
// Clean up CLI PID marker
|
|
675
1923
|
if (cliPidMarker) {
|
|
676
1924
|
try {
|
|
@@ -703,8 +1951,8 @@ function startWebServer(host, preferredPort) {
|
|
|
703
1951
|
if (hasWrite)
|
|
704
1952
|
authedClients.add(ws);
|
|
705
1953
|
log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
|
|
706
|
-
if (isTmuxMode && sessionId) {
|
|
707
|
-
// ── Tmux mode: per-client attach ──
|
|
1954
|
+
if (isTmuxMode && !isPipeMode && sessionId) {
|
|
1955
|
+
// ── Tmux-attach mode: per-client attach ──
|
|
708
1956
|
// Each WS client gets its own `tmux attach-session` PTY.
|
|
709
1957
|
// Scrollback is handled natively by tmux (history-limit).
|
|
710
1958
|
// In adopt mode, attach to the user's original pane; otherwise use bmx-* session.
|
|
@@ -864,11 +2112,11 @@ body{display:flex;flex-direction:column}
|
|
|
864
2112
|
color:#565f89;background:#1a1b26cc;padding:2px 8px;border-radius:4px}
|
|
865
2113
|
#status.ok{color:#9ece6a}
|
|
866
2114
|
#status.err{color:#f7768e}
|
|
867
|
-
#readonly-banner{display:none;position:fixed;top:
|
|
868
|
-
padding:
|
|
869
|
-
background:rgba(247,118,142,0.12);border
|
|
870
|
-
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)
|
|
871
|
-
#readonly-banner.show{display:block}
|
|
2115
|
+
#readonly-banner{display:none;position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:50;
|
|
2116
|
+
padding:4px 10px;font:12px monospace;color:#f7768e;white-space:nowrap;cursor:pointer;
|
|
2117
|
+
background:rgba(247,118,142,0.12);border:1px solid rgba(247,118,142,0.35);border-radius:4px;
|
|
2118
|
+
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
|
2119
|
+
#readonly-banner.show{display:inline-block}
|
|
872
2120
|
</style>
|
|
873
2121
|
</head>
|
|
874
2122
|
<body>
|
|
@@ -894,7 +2142,7 @@ body{display:flex;flex-direction:column}
|
|
|
894
2142
|
var isTouch='ontouchstart'in window||navigator.maxTouchPoints>0;
|
|
895
2143
|
if(isTouch)document.getElementById('vp').content='width=1100,viewport-fit=cover';
|
|
896
2144
|
var hasToken=${hasWrite};
|
|
897
|
-
if(!hasToken)document.getElementById('readonly-banner').classList.add('show');
|
|
2145
|
+
if(!hasToken){var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
|
|
898
2146
|
|
|
899
2147
|
var term=new Terminal({
|
|
900
2148
|
theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
|
|
@@ -971,7 +2219,7 @@ window.addEventListener('resize',function(){fit.fit();sendResize()});
|
|
|
971
2219
|
})();
|
|
972
2220
|
|
|
973
2221
|
// ── Read-only scroll handling ──
|
|
974
|
-
if(!hasToken&&!${isTmuxMode}){
|
|
2222
|
+
if(!hasToken&&!${isTmuxMode && !isPipeMode}){
|
|
975
2223
|
// Non-tmux read-only: CLI mouse mode blocks local scroll, override with scrollLines
|
|
976
2224
|
document.getElementById('terminal').addEventListener('wheel',function(e){
|
|
977
2225
|
e.preventDefault();term.scrollLines(e.deltaY>0?3:-3);
|
|
@@ -981,7 +2229,7 @@ if(!hasToken&&!${isTmuxMode}){
|
|
|
981
2229
|
// ── Scroll helper (shared by toolbar buttons & two-finger touch) ──
|
|
982
2230
|
function _sendScroll(up,n){
|
|
983
2231
|
n=n||3;
|
|
984
|
-
if(${isTmuxMode}){
|
|
2232
|
+
if(${isTmuxMode && !isPipeMode}){
|
|
985
2233
|
// SGR mouse wheel: 64=up 65=down — tmux enters copy-mode and scrolls
|
|
986
2234
|
var seq='\\x1b[<'+(up?64:65)+';1;1M';
|
|
987
2235
|
for(var i=0;i<n;i++){if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'input',data:seq}))}
|
|
@@ -1070,7 +2318,15 @@ process.on('message', async (raw) => {
|
|
|
1070
2318
|
// Capture credentials for direct image upload from worker
|
|
1071
2319
|
larkAppIdForUpload = msg.larkAppId;
|
|
1072
2320
|
larkAppSecretForUpload = msg.larkAppSecret;
|
|
1073
|
-
|
|
2321
|
+
// Resolve render dimensions BEFORE startScreenUpdates() — the
|
|
2322
|
+
// headless xterm and PNG canvas need to know the source pane size
|
|
2323
|
+
// up-front. Setting them later (after the renderer was built at
|
|
2324
|
+
// 160x50) wouldn't unwrap content xterm has already buffered, so
|
|
2325
|
+
// adopt-mode wide-pane content would still come out stair-stepped.
|
|
2326
|
+
const dims = resolveRenderDimensions(msg);
|
|
2327
|
+
renderCols = dims.cols;
|
|
2328
|
+
renderRows = dims.rows;
|
|
2329
|
+
log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
|
|
1074
2330
|
try {
|
|
1075
2331
|
const port = await startWebServer('0.0.0.0', msg.webPort);
|
|
1076
2332
|
startScreenUpdates();
|
|
@@ -1079,6 +2335,8 @@ process.on('message', async (raw) => {
|
|
|
1079
2335
|
// Queue the initial prompt — flushed when CLI shows idle.
|
|
1080
2336
|
// Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
|
|
1081
2337
|
// prompt into CLI args, so we skip queuing to avoid double-send.
|
|
2338
|
+
// Bridge mark is deferred to flushPending — see flushPending
|
|
2339
|
+
// comment for why marking at enqueue is wrong.
|
|
1082
2340
|
if (msg.prompt && !cliAdapter?.passesInitialPromptViaArgs) {
|
|
1083
2341
|
pendingMessages.push(msg.prompt);
|
|
1084
2342
|
}
|
|
@@ -1098,6 +2356,18 @@ process.on('message', async (raw) => {
|
|
|
1098
2356
|
exitTmuxScrollMode();
|
|
1099
2357
|
const content = msg.content;
|
|
1100
2358
|
if (lastInitConfig?.adoptMode) {
|
|
2359
|
+
// Bridge mode: capture transcript baseline BEFORE writing to the pane,
|
|
2360
|
+
// so any assistant uuids appended after this point are attributed to
|
|
2361
|
+
// *this* Lark turn (not local user activity in the pane). Mark may
|
|
2362
|
+
// return false (baseline not ready) — we still write to the pane;
|
|
2363
|
+
// user just won't get a final_output for this message.
|
|
2364
|
+
if (bridgeJsonlPath) {
|
|
2365
|
+
try {
|
|
2366
|
+
bridgeIngest();
|
|
2367
|
+
}
|
|
2368
|
+
catch { /* best effort */ }
|
|
2369
|
+
bridgeMarkPendingTurn(content);
|
|
2370
|
+
}
|
|
1101
2371
|
// Adopt mode: raw write to PTY (no adapter writeInput)
|
|
1102
2372
|
if (backend) {
|
|
1103
2373
|
if ('sendText' in backend && 'sendSpecialKeys' in backend) {
|
|
@@ -1112,6 +2382,11 @@ process.on('message', async (raw) => {
|
|
|
1112
2382
|
}
|
|
1113
2383
|
}
|
|
1114
2384
|
else {
|
|
2385
|
+
// Non-adopt: enqueue only. Bridge mark is deferred to flushPending
|
|
2386
|
+
// so markTimeMs anchors to the actual PTY-write moment, not IPC
|
|
2387
|
+
// arrival. Marking now would race with a still-running previous
|
|
2388
|
+
// turn whose `botmux send` could sneak its sentAtMs past this
|
|
2389
|
+
// turn's markTimeMs and falsely suppress its fallback.
|
|
1115
2390
|
sendToPty(content);
|
|
1116
2391
|
}
|
|
1117
2392
|
break;
|
|
@@ -1192,6 +2467,11 @@ process.on('message', async (raw) => {
|
|
|
1192
2467
|
// destroySession kills tmux session permanently; kill() only detaches
|
|
1193
2468
|
backend?.destroySession?.();
|
|
1194
2469
|
killCli();
|
|
2470
|
+
// Bridge marker file outlives a single CLI process (we keep it across
|
|
2471
|
+
// restarts so a mid-flight send is still credited), but a real close
|
|
2472
|
+
// tears down the session — purge the file so a future re-use of the
|
|
2473
|
+
// same sessionId starts clean.
|
|
2474
|
+
clearSendMarkers();
|
|
1195
2475
|
cleanup();
|
|
1196
2476
|
process.exit(0);
|
|
1197
2477
|
}
|