botmux 2.11.0 → 2.12.0
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 +43 -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 +17 -1
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +208 -26
- 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/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 +1082 -38
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
package/dist/worker.js
CHANGED
|
@@ -13,15 +13,21 @@
|
|
|
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 { dirname } from 'node:path';
|
|
18
22
|
import { createServer as createHttpServer } from 'node:http';
|
|
19
23
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
20
24
|
import { TerminalRenderer } from './utils/terminal-renderer.js';
|
|
25
|
+
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
26
|
import { createCliAdapterSync } from './adapters/cli/registry.js';
|
|
22
|
-
import { claudeJsonlPathForSession } from './adapters/cli/claude-code.js';
|
|
27
|
+
import { claudeJsonlPathForSession, resolveJsonlFromPid } from './adapters/cli/claude-code.js';
|
|
23
28
|
import { PtyBackend } from './adapters/backend/pty-backend.js';
|
|
24
29
|
import { TmuxBackend } from './adapters/backend/tmux-backend.js';
|
|
30
|
+
import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
|
|
25
31
|
import { IdleDetector } from './utils/idle-detector.js';
|
|
26
32
|
import { ScreenAnalyzer } from './utils/screen-analyzer.js';
|
|
27
33
|
import { captureToPng } from './utils/screenshot-renderer.js';
|
|
@@ -36,6 +42,10 @@ let backend = null;
|
|
|
36
42
|
let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
|
|
37
43
|
let idleDetector = null;
|
|
38
44
|
let isTmuxMode = false;
|
|
45
|
+
/** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
|
|
46
|
+
* web-terminal updates flow through the shared scrollback fan-out instead
|
|
47
|
+
* of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
|
|
48
|
+
let isPipeMode = false;
|
|
39
49
|
let httpServer = null;
|
|
40
50
|
let wss = null;
|
|
41
51
|
const wsClients = new Set();
|
|
@@ -51,13 +61,848 @@ let isPromptReady = false;
|
|
|
51
61
|
/** Mutex for async flushPending — prevents concurrent flush loops. */
|
|
52
62
|
let isFlushing = false;
|
|
53
63
|
const pendingMessages = [];
|
|
64
|
+
// ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// In bridge mode the daemon adopted an existing CLI session that we do NOT
|
|
67
|
+
// own; the model never sees botmux. We harvest assistant turns by tailing
|
|
68
|
+
// Claude Code's transcript JSONL and forward only the bytes appended after
|
|
69
|
+
// each Lark-driven user turn — never the historical content present at
|
|
70
|
+
// attach time, never local-terminal-driven turns.
|
|
71
|
+
//
|
|
72
|
+
// Attribution lives in BridgeTurnQueue; this file only manages the
|
|
73
|
+
// fs.watch wakeup, byte-offset bookkeeping, lazy baseline, and IPC emit.
|
|
74
|
+
let bridgeJsonlPath;
|
|
75
|
+
/** Directory enclosing bridgeJsonlPath. We poll this dir for newer jsonl
|
|
76
|
+
* files so the bridge follows `/clear` / `/resume` in the user's CLI —
|
|
77
|
+
* those create a brand-new sessionId.jsonl, and a watcher pinned to the
|
|
78
|
+
* original path would silently stop receiving events. */
|
|
79
|
+
let bridgeJsonlDir;
|
|
80
|
+
/** PID + cwd of the adopted Claude Code process. Lets every poll re-read
|
|
81
|
+
* ~/.claude/sessions/<pid>.json — Claude's own authoritative record of the
|
|
82
|
+
* current sessionId — and switch the watched jsonl when Claude rotates
|
|
83
|
+
* (via /clear, /resume, --resume etc.) without waiting for a Lark message
|
|
84
|
+
* to land in the new file. */
|
|
85
|
+
let bridgeCliPid;
|
|
86
|
+
let bridgeCliCwd;
|
|
87
|
+
/** Last sessionId we observed via the pid resolver — used to detect
|
|
88
|
+
* rotations cheaply (string compare instead of stat()ing every jsonl). */
|
|
89
|
+
let bridgeObservedCliSessionId;
|
|
90
|
+
/** Old jsonl paths we keep polling AFTER a rotation switched
|
|
91
|
+
* bridgeJsonlPath away — needed when a started turn was stamped with the
|
|
92
|
+
* old path but its assistant text hasn't been written yet. We continue to
|
|
93
|
+
* drain each entry on every tick so trailing appends to that file land in
|
|
94
|
+
* the queue against the right turn, and prune the entry once no pending
|
|
95
|
+
* turn references the path anymore. */
|
|
96
|
+
const bridgeSecondaryPaths = new Map(); // path → offset
|
|
97
|
+
let bridgeOffset = 0;
|
|
98
|
+
let bridgePendingTail = '';
|
|
99
|
+
const bridgeQueue = new BridgeTurnQueue();
|
|
100
|
+
let bridgeWatcher = null;
|
|
101
|
+
let bridgeFallbackTimer = null;
|
|
102
|
+
/** True once we successfully baselined the transcript file. Until then,
|
|
103
|
+
* any data we see is treated as history — absorbed into the queue's seen
|
|
104
|
+
* set without being attributed to a pending Lark turn. This protects the
|
|
105
|
+
* first Lark turn from inheriting historical lines if Claude Code creates
|
|
106
|
+
* the JSONL file *after* attach. */
|
|
107
|
+
let bridgeBaselineDone = false;
|
|
108
|
+
/** Once-per-attach flag so a re-baseline after fs.watch lazy-fire doesn't
|
|
109
|
+
* re-send the preamble. Reset only when the bridge teardown happens. */
|
|
110
|
+
let bridgePreambleSent = false;
|
|
111
|
+
/** Cap the preamble text so an extremely long previous turn doesn't blow
|
|
112
|
+
* past Lark's per-message limit. The user only needs enough to recall
|
|
113
|
+
* context, not the entire transcript. */
|
|
114
|
+
const PREAMBLE_USER_MAX = 500;
|
|
115
|
+
const PREAMBLE_ASSISTANT_MAX = 4000;
|
|
116
|
+
/** Same intent as the preamble caps, but for live local-terminal turns
|
|
117
|
+
* forwarded to Lark. A long paste typed locally shouldn't be allowed to
|
|
118
|
+
* blow past Lark's per-message limit. */
|
|
119
|
+
const LOCAL_TURN_USER_MAX = 1000;
|
|
120
|
+
const LOCAL_TURN_ASSISTANT_MAX = 8000;
|
|
121
|
+
function truncatePreambleText(text, max) {
|
|
122
|
+
if (text.length <= max)
|
|
123
|
+
return text;
|
|
124
|
+
return text.slice(0, max) + '…';
|
|
125
|
+
}
|
|
126
|
+
/** Compose a `final_output` payload for a turn synthesised from a user
|
|
127
|
+
* prompt the human typed directly into the adopted pane. Shows both the
|
|
128
|
+
* user text and assistant text so the Lark thread doesn't see an orphan
|
|
129
|
+
* reply with no context. Returns `null` when neither side has anything
|
|
130
|
+
* visible — the worker should suppress the emit in that case. */
|
|
131
|
+
function formatLocalTurnContent(userText, assistantText) {
|
|
132
|
+
const u = truncatePreambleText(userText.trim(), LOCAL_TURN_USER_MAX);
|
|
133
|
+
const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
|
|
134
|
+
if (!u && !a)
|
|
135
|
+
return null;
|
|
136
|
+
return [
|
|
137
|
+
'🖥️ 终端本地对话(在 adopted pane 中直接输入,已同步至飞书)',
|
|
138
|
+
'',
|
|
139
|
+
'👤 你:',
|
|
140
|
+
u || '(空)',
|
|
141
|
+
'',
|
|
142
|
+
`🤖 ${cliName()}:`,
|
|
143
|
+
a || '(空)',
|
|
144
|
+
].join('\n');
|
|
145
|
+
}
|
|
146
|
+
// ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
|
|
147
|
+
//
|
|
148
|
+
// `botmux send` (cli.ts cmdSend) appends a line `{sentAtMs, messageId}\n` to
|
|
149
|
+
// `<DATA_DIR>/turn-sends/<sid>.jsonl` every time the model successfully posts
|
|
150
|
+
// a reply to its OWN session thread. The worker reads these markers at idle
|
|
151
|
+
// and suppresses transcript-driven final_output for any turn whose time
|
|
152
|
+
// window already contains a send — i.e. the model didn't forget, no fallback
|
|
153
|
+
// needed. Append-only over a shared file (instead of a per-turn marker) is
|
|
154
|
+
// type-ahead safe: type-ahead'd turns each have their own [markTimeMs,
|
|
155
|
+
// nextTurn.markTimeMs) window, and a stray send only fills its own bucket.
|
|
156
|
+
function bridgeMarkerPath() {
|
|
157
|
+
if (!process.env.SESSION_DATA_DIR || !sessionId)
|
|
158
|
+
return undefined;
|
|
159
|
+
return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
|
|
160
|
+
}
|
|
161
|
+
function readSendMarkers() {
|
|
162
|
+
const path = bridgeMarkerPath();
|
|
163
|
+
if (!path || !existsSync(path))
|
|
164
|
+
return [];
|
|
165
|
+
try {
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
168
|
+
if (!line.trim())
|
|
169
|
+
continue;
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(line);
|
|
172
|
+
if (typeof parsed?.sentAtMs === 'number')
|
|
173
|
+
out.push(parsed);
|
|
174
|
+
}
|
|
175
|
+
catch { /* skip malformed line */ }
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
log(`Bridge marker read failed: ${err.message}`);
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function clearSendMarkers() {
|
|
185
|
+
const path = bridgeMarkerPath();
|
|
186
|
+
if (!path)
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
unlinkSync(path);
|
|
190
|
+
}
|
|
191
|
+
catch { /* already gone or fs.unavailable; not fatal */ }
|
|
192
|
+
}
|
|
193
|
+
function maybeEmitAdoptPreamble(events) {
|
|
194
|
+
// Preamble is an /adopt-only signal: it tells the user "here's the last
|
|
195
|
+
// turn from the Claude session you just attached to, so the Lark thread
|
|
196
|
+
// has context to continue from". In non-adopt sessions the user IS the
|
|
197
|
+
// Lark thread (every turn was already pushed there as a card), so
|
|
198
|
+
// surfacing the last turn again on daemon restart is just noise.
|
|
199
|
+
if (!lastInitConfig?.adoptMode)
|
|
200
|
+
return;
|
|
201
|
+
if (bridgePreambleSent)
|
|
202
|
+
return;
|
|
203
|
+
const turn = extractLastAssistantTurn(events);
|
|
204
|
+
if (!turn)
|
|
205
|
+
return;
|
|
206
|
+
bridgePreambleSent = true;
|
|
207
|
+
send({
|
|
208
|
+
type: 'adopt_preamble',
|
|
209
|
+
userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
|
|
210
|
+
assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
|
|
211
|
+
});
|
|
212
|
+
log('Bridge adopt preamble emitted (last completed turn from baseline)');
|
|
213
|
+
}
|
|
214
|
+
function bridgeAbsorbBaseline() {
|
|
215
|
+
if (!bridgeJsonlPath)
|
|
216
|
+
return;
|
|
217
|
+
const result = drainTranscript(bridgeJsonlPath, 0);
|
|
218
|
+
bridgeOffset = result.newOffset;
|
|
219
|
+
bridgePendingTail = result.pendingTail;
|
|
220
|
+
bridgeQueue.absorb(result.events);
|
|
221
|
+
bridgeBaselineDone = true;
|
|
222
|
+
// After absorb (uuids registered as seen so they won't re-emit as a Lark
|
|
223
|
+
// turn), surface the last completed user/assistant exchange to Lark as a
|
|
224
|
+
// one-shot preamble — but only for real /adopt sessions. Non-adopt
|
|
225
|
+
// claude-code fallback bridge also uses baseline-existing on daemon
|
|
226
|
+
// restart/resume; it must not emit the "/adopt 前最后一轮" message.
|
|
227
|
+
if (lastInitConfig?.adoptMode)
|
|
228
|
+
maybeEmitAdoptPreamble(result.events);
|
|
229
|
+
}
|
|
230
|
+
/** Detect /clear / /resume: when Claude Code starts a new session in the
|
|
231
|
+
* user's pane it writes to a brand-new sessionId.jsonl. We *cannot* use
|
|
232
|
+
* "latest-mtime jsonl in the project dir" as the switch trigger — that
|
|
233
|
+
* hijacks our watcher whenever a sibling Claude pane in the same cwd
|
|
234
|
+
* writes anything. Instead, switch only when:
|
|
235
|
+
*
|
|
236
|
+
* 1. We have an unstarted pending Lark turn (otherwise no signal to
|
|
237
|
+
* chase, and switching would risk grabbing another pane's reply).
|
|
238
|
+
* 2. The pending turn's content fingerprint shows up in a candidate
|
|
239
|
+
* jsonl other than our current one — that's the user's current
|
|
240
|
+
* session because they JUST typed our pane-write into it.
|
|
241
|
+
*
|
|
242
|
+
* Pending turns are preserved across the switch so the next ingest can
|
|
243
|
+
* match the fingerprint and start the turn in the new file. */
|
|
244
|
+
function maybeSwitchBridgeJsonl() {
|
|
245
|
+
if (!bridgeJsonlDir)
|
|
246
|
+
return false;
|
|
247
|
+
const pending = bridgeQueue.peek();
|
|
248
|
+
const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
|
|
249
|
+
if (!candidate || !candidate.contentFingerprint)
|
|
250
|
+
return false;
|
|
251
|
+
// Bound the search to events written after the turn was marked. Short
|
|
252
|
+
// fingerprints ("hello", "test") would otherwise match old user lines
|
|
253
|
+
// in unrelated sibling jsonls. 5s skew absorbs clock drift between the
|
|
254
|
+
// mark and Claude's transcript write.
|
|
255
|
+
const minEventTimestampMs = candidate.markTimeMs !== undefined
|
|
256
|
+
? candidate.markTimeMs - 5_000
|
|
257
|
+
: undefined;
|
|
258
|
+
const matched = findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
|
|
259
|
+
excludePath: bridgeJsonlPath,
|
|
260
|
+
includeQueueOperations: true,
|
|
261
|
+
minEventTimestampMs,
|
|
262
|
+
});
|
|
263
|
+
if (!matched)
|
|
264
|
+
return false;
|
|
265
|
+
// Drain-before-switch: pull in any unread bytes from the old path so a
|
|
266
|
+
// late assistant append doesn't vanish. We do NOT emit here — emission
|
|
267
|
+
// only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
|
|
268
|
+
// would publish a half-finished assistant turn during fs.watch / poll
|
|
269
|
+
// ticks (drainEmittable's contract is "has visible text", not "model
|
|
270
|
+
// finished"). If the drained user/assistant events still need follow-up
|
|
271
|
+
// appends on the old path, retainSecondaryPathIfStillReferenced() keeps
|
|
272
|
+
// the old path in the polling rotation.
|
|
273
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
274
|
+
let postDrainOffset = bridgeOffset;
|
|
275
|
+
try {
|
|
276
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
277
|
+
postDrainOffset = drained.offset;
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
log(`Bridge final-drain on fingerprint switch failed (${err.message}); continuing`);
|
|
281
|
+
}
|
|
282
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
283
|
+
}
|
|
284
|
+
log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (Lark fingerprint observed in new jsonl — user likely ran /clear or /resume)`);
|
|
285
|
+
if (bridgeWatcher) {
|
|
286
|
+
try {
|
|
287
|
+
bridgeWatcher.close();
|
|
288
|
+
}
|
|
289
|
+
catch { /* ignore */ }
|
|
290
|
+
bridgeWatcher = null;
|
|
291
|
+
}
|
|
292
|
+
// Critically: do NOT clear pending turns. The switch was triggered by
|
|
293
|
+
// the fingerprint of the FIRST pending turn already living in `matched`,
|
|
294
|
+
// so the immediate next ingest from offset 0 will find that user event
|
|
295
|
+
// and start the turn. Clearing here would race-drop exactly the message
|
|
296
|
+
// we're trying to deliver.
|
|
297
|
+
bridgeJsonlPath = matched;
|
|
298
|
+
bridgeOffset = 0;
|
|
299
|
+
bridgePendingTail = '';
|
|
300
|
+
// baselineDone=false would absorb the new file's existing content
|
|
301
|
+
// (including the pending turn's user event) as history — defeating the
|
|
302
|
+
// switch. Skip baseline; fall straight into ingest from offset 0 so
|
|
303
|
+
// BridgeTurnQueue.ingest() can attribute the matching user/assistant.
|
|
304
|
+
bridgeBaselineDone = true;
|
|
305
|
+
try {
|
|
306
|
+
bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
|
|
307
|
+
try {
|
|
308
|
+
bridgeIngest();
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
|
|
317
|
+
}
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
/** /clear or /resume in the user's adopted pane creates (or touches) a new
|
|
321
|
+
* jsonl in the same Claude project directory. Neither pid-resolver nor
|
|
322
|
+
* fingerprint switch will fire when the rotation happened mid-process AND
|
|
323
|
+
* there's no pending Lark turn to anchor on (pure local-terminal use), so
|
|
324
|
+
* this fallback owns that case.
|
|
325
|
+
*
|
|
326
|
+
* Detection priority:
|
|
327
|
+
* 1. Linux first-class: read `/proc/<pid>/fd` and pick the .jsonl the
|
|
328
|
+
* adopted Claude process actually has open. This is bound to the real
|
|
329
|
+
* PID — a sibling Claude pane in the same cwd has a different PID and
|
|
330
|
+
* therefore cannot hijack the result.
|
|
331
|
+
* 2. Cross-platform fallback: directory-level mtime heuristic, gated on
|
|
332
|
+
* (a) our current jsonl quiet ≥ QUIET_ROTATION_MS, (b) candidate
|
|
333
|
+
* newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Less
|
|
334
|
+
* robust than fd lookup but the best available without /proc.
|
|
335
|
+
*
|
|
336
|
+
* When a rotation is detected, the new jsonl is drained from offset 0 and
|
|
337
|
+
* events are split by timestamp against `rotationCutoffMs` (the old
|
|
338
|
+
* jsonl's last-write time): events before the cutoff are *history*
|
|
339
|
+
* (absorbed into the seen-set, not emitted), events after are *live*
|
|
340
|
+
* (ingested → local-turn synthesis runs). This is what lets /resume to a
|
|
341
|
+
* long-history jsonl NOT replay the entire past as one giant local turn,
|
|
342
|
+
* while /clear's first new turn still gets forwarded.
|
|
343
|
+
*
|
|
344
|
+
* Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
|
|
345
|
+
* also fires `maybeEmitAdoptPreamble`, which on rotation would surface
|
|
346
|
+
* the *previous session's* last turn as if it were a fresh "/adopt 前最
|
|
347
|
+
* 后一轮" preamble. Preamble belongs only to initial attach. */
|
|
348
|
+
const QUIET_ROTATION_MS = 8_000;
|
|
349
|
+
function statSafe(path) {
|
|
350
|
+
try {
|
|
351
|
+
const st = statSync(path);
|
|
352
|
+
if (!st.isFile())
|
|
353
|
+
return null;
|
|
354
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function isPidAlive(pid) {
|
|
361
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
362
|
+
return false;
|
|
363
|
+
try {
|
|
364
|
+
process.kill(pid, 0);
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** List `.jsonl` files inside `dir` that are currently held open by `pid`.
|
|
372
|
+
* Returns [] on non-Linux platforms or if /proc lookup fails — the caller
|
|
373
|
+
* treats an empty result as "fd info unavailable, fall back to mtime". */
|
|
374
|
+
function findOpenJsonlsForPid(pid, dir) {
|
|
375
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
376
|
+
return [];
|
|
377
|
+
if (process.platform !== 'linux')
|
|
378
|
+
return [];
|
|
379
|
+
let entries;
|
|
380
|
+
try {
|
|
381
|
+
entries = readdirSync(`/proc/${pid}/fd`);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
const out = [];
|
|
387
|
+
for (const name of entries) {
|
|
388
|
+
let target;
|
|
389
|
+
try {
|
|
390
|
+
target = readlinkSync(`/proc/${pid}/fd/${name}`);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (!target.endsWith('.jsonl'))
|
|
396
|
+
continue;
|
|
397
|
+
if (dirname(target) !== dir)
|
|
398
|
+
continue;
|
|
399
|
+
out.push(target);
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
/** Pick the most recently modified path among `paths`. Returns null if
|
|
404
|
+
* none of them stat. */
|
|
405
|
+
function newestPath(paths) {
|
|
406
|
+
let best = null;
|
|
407
|
+
for (const p of paths) {
|
|
408
|
+
const st = statSafe(p);
|
|
409
|
+
if (!st)
|
|
410
|
+
continue;
|
|
411
|
+
if (!best || st.mtimeMs > best.mtimeMs)
|
|
412
|
+
best = { path: p, mtimeMs: st.mtimeMs };
|
|
413
|
+
}
|
|
414
|
+
return best?.path ?? null;
|
|
415
|
+
}
|
|
416
|
+
/** Switch bridgeJsonlPath to `newPath` and split-baseline its existing
|
|
417
|
+
* content: events with timestamp ≤ `cutoffMs` are absorbed as history
|
|
418
|
+
* (seen-set only, no emission), events strictly after are ingested so
|
|
419
|
+
* local turn synthesis runs against them. The old path is retained in
|
|
420
|
+
* the secondary polling rotation if any started turn still references
|
|
421
|
+
* it. Does NOT emit `adopt_preamble` — that's an initial-attach signal,
|
|
422
|
+
* not a rotation signal. */
|
|
423
|
+
function performRotationSwitch(newPath, cutoffMs, reason) {
|
|
424
|
+
// Drain-before-switch: pull any unread bytes from the old path so a
|
|
425
|
+
// late assistant append doesn't vanish. Mirrors the other rotation
|
|
426
|
+
// helpers.
|
|
427
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
428
|
+
let postDrainOffset = bridgeOffset;
|
|
429
|
+
try {
|
|
430
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
431
|
+
postDrainOffset = drained.offset;
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
log(`Bridge final-drain on rotation (${reason}) failed (${err.message}); continuing`);
|
|
435
|
+
}
|
|
436
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
437
|
+
}
|
|
438
|
+
log(`Bridge transcript switched (${reason}): ${bridgeJsonlPath ?? '(none)'} → ${newPath}`);
|
|
439
|
+
if (bridgeWatcher) {
|
|
440
|
+
try {
|
|
441
|
+
bridgeWatcher.close();
|
|
442
|
+
}
|
|
443
|
+
catch { /* ignore */ }
|
|
444
|
+
bridgeWatcher = null;
|
|
445
|
+
}
|
|
446
|
+
bridgeJsonlPath = newPath;
|
|
447
|
+
bridgeJsonlDir = dirname(newPath);
|
|
448
|
+
bridgePendingTail = '';
|
|
449
|
+
// Drain the new path from 0 ourselves (do NOT call bridgeAbsorbBaseline
|
|
450
|
+
// — that would emit the preamble we want to suppress on rotation).
|
|
451
|
+
const result = drainTranscript(newPath, 0);
|
|
452
|
+
bridgeOffset = result.newOffset;
|
|
453
|
+
bridgePendingTail = result.pendingTail;
|
|
454
|
+
const history = [];
|
|
455
|
+
const live = [];
|
|
456
|
+
for (const ev of result.events) {
|
|
457
|
+
let evMs = Number.NaN;
|
|
458
|
+
if (typeof ev.timestamp === 'string')
|
|
459
|
+
evMs = Date.parse(ev.timestamp);
|
|
460
|
+
if (Number.isFinite(evMs) && evMs <= cutoffMs)
|
|
461
|
+
history.push(ev);
|
|
462
|
+
else
|
|
463
|
+
live.push(ev);
|
|
464
|
+
}
|
|
465
|
+
bridgeQueue.absorb(history);
|
|
466
|
+
if (live.length > 0)
|
|
467
|
+
bridgeQueue.ingest(live, newPath);
|
|
468
|
+
bridgeBaselineDone = true;
|
|
469
|
+
log(`Bridge rotation split: ${history.length} historical events absorbed, ${live.length} live events ingested`);
|
|
470
|
+
try {
|
|
471
|
+
bridgeWatcher = fsWatch(newPath, { persistent: false }, () => {
|
|
472
|
+
try {
|
|
473
|
+
bridgeIngest();
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function maybeFollowQuietRotation() {
|
|
485
|
+
if (!bridgeJsonlDir || !bridgeJsonlPath)
|
|
486
|
+
return;
|
|
487
|
+
// Need a known pid to do safe rotation tracking; if we don't have one,
|
|
488
|
+
// we can't bind to the adopted Claude process and a directory-mtime
|
|
489
|
+
// switch would risk sibling-pane hijack.
|
|
490
|
+
if (bridgeCliPid === undefined)
|
|
491
|
+
return;
|
|
492
|
+
if (!isPidAlive(bridgeCliPid))
|
|
493
|
+
return;
|
|
494
|
+
const currentStat = statSafe(bridgeJsonlPath);
|
|
495
|
+
if (!currentStat)
|
|
496
|
+
return;
|
|
497
|
+
// Path 1: Linux fd-based detection — definitive, can't be hijacked.
|
|
498
|
+
// Read /proc/<pid>/fd, find every .jsonl Claude has open in our cwd's
|
|
499
|
+
// project dir, pick the one with the most recent mtime. Differs from
|
|
500
|
+
// bridgeJsonlPath ⇒ rotation.
|
|
501
|
+
const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
|
|
502
|
+
if (opened.length > 0) {
|
|
503
|
+
const newest = newestPath(opened);
|
|
504
|
+
if (newest && newest !== bridgeJsonlPath) {
|
|
505
|
+
performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
|
|
506
|
+
}
|
|
507
|
+
// fd lookup succeeded — even if it confirmed the current path, the
|
|
508
|
+
// mtime fallback below would only add risk. Stop here.
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Path 2: non-Linux fallback (or /proc unavailable). Directory-mtime
|
|
512
|
+
// heuristic with three guards. Less robust than fd lookup; sibling
|
|
513
|
+
// panes could in principle race the conditions, but the QUIET windows
|
|
514
|
+
// make it unlikely in practice.
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
if (now - currentStat.mtimeMs < QUIET_ROTATION_MS)
|
|
517
|
+
return;
|
|
518
|
+
const latest = findLatestJsonl(bridgeJsonlDir);
|
|
519
|
+
if (!latest || latest === bridgeJsonlPath)
|
|
520
|
+
return;
|
|
521
|
+
const latestStat = statSafe(latest);
|
|
522
|
+
if (!latestStat)
|
|
523
|
+
return;
|
|
524
|
+
if (latestStat.mtimeMs - currentStat.mtimeMs < QUIET_ROTATION_MS)
|
|
525
|
+
return;
|
|
526
|
+
performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
|
|
527
|
+
}
|
|
528
|
+
function maybeFollowSessionRotationViaPid() {
|
|
529
|
+
if (!bridgeCliPid || !bridgeCliCwd)
|
|
530
|
+
return 'unavailable';
|
|
531
|
+
const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
|
|
532
|
+
if (!resolved)
|
|
533
|
+
return 'unavailable';
|
|
534
|
+
if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
|
|
535
|
+
bridgeObservedCliSessionId = resolved.cliSessionId;
|
|
536
|
+
}
|
|
537
|
+
if (resolved.path === bridgeJsonlPath)
|
|
538
|
+
return 'same';
|
|
539
|
+
// Drain-before-switch: pull in any unread bytes from the OLD path so a
|
|
540
|
+
// trailing assistant append doesn't vanish. We do NOT emit here — emit
|
|
541
|
+
// is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
|
|
542
|
+
// publish a half-finished assistant during fs.watch / poll-driven
|
|
543
|
+
// bridgeIngest calls. If a started turn still references the old path
|
|
544
|
+
// and its assistant text might still be on the way, the old path stays
|
|
545
|
+
// in the polling rotation via bridgeSecondaryPaths.
|
|
546
|
+
if (bridgeJsonlPath && bridgeBaselineDone) {
|
|
547
|
+
let postDrainOffset = bridgeOffset;
|
|
548
|
+
try {
|
|
549
|
+
const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
|
|
550
|
+
postDrainOffset = drained.offset;
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
log(`Bridge final-drain on rotation failed (${err.message}); continuing`);
|
|
554
|
+
}
|
|
555
|
+
retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
|
|
556
|
+
}
|
|
557
|
+
log(`Bridge transcript switched (pid resolver): ${bridgeJsonlPath ?? '(none)'} → ${resolved.path}`);
|
|
558
|
+
if (bridgeWatcher) {
|
|
559
|
+
try {
|
|
560
|
+
bridgeWatcher.close();
|
|
561
|
+
}
|
|
562
|
+
catch { /* ignore */ }
|
|
563
|
+
bridgeWatcher = null;
|
|
564
|
+
}
|
|
565
|
+
// Preserve any pending Lark turn so the next ingest can attribute it
|
|
566
|
+
// when Claude appends our user event to the new jsonl. Skip baseline:
|
|
567
|
+
// we want to read from offset 0 so the pending turn's user event is
|
|
568
|
+
// visible to BridgeTurnQueue.ingest(). Turns already started on the
|
|
569
|
+
// old path keep their stamped sourceJsonlPath, so when their assistant
|
|
570
|
+
// text eventually arrives there too it still resolves correctly.
|
|
571
|
+
bridgeJsonlPath = resolved.path;
|
|
572
|
+
bridgeJsonlDir = dirname(resolved.path);
|
|
573
|
+
bridgeOffset = 0;
|
|
574
|
+
bridgePendingTail = '';
|
|
575
|
+
bridgeBaselineDone = true;
|
|
576
|
+
try {
|
|
577
|
+
bridgeWatcher = fsWatch(resolved.path, { persistent: false }, () => {
|
|
578
|
+
try {
|
|
579
|
+
bridgeIngest();
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
|
|
588
|
+
}
|
|
589
|
+
return 'switched';
|
|
590
|
+
}
|
|
591
|
+
function bridgeIngest() {
|
|
592
|
+
// Drain secondary paths first so any trailing assistant text on an old
|
|
593
|
+
// jsonl reaches the queue before the rotation check considers retiring
|
|
594
|
+
// the path. Strictly read-only on the polling rotation; never triggers
|
|
595
|
+
// a rotate or shifts the primary path.
|
|
596
|
+
drainSecondaryPaths();
|
|
597
|
+
// Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
|
|
598
|
+
// pid file → new sessionId), e.g. daemon restart that re-issues
|
|
599
|
+
// `--resume <id>` and Claude rotates the internal id.
|
|
600
|
+
const pidFollow = maybeFollowSessionRotationViaPid();
|
|
601
|
+
// Fingerprint fallback: catches *in-process* rotations Claude makes
|
|
602
|
+
// via /clear or /resume from the user's pane. Claude's pid file has
|
|
603
|
+
// its sessionId field set ONCE at process start (see binary persistence
|
|
604
|
+
// schema) and is NOT rewritten on /clear, so pid resolver returning
|
|
605
|
+
// 'same' is NOT proof that no rotation happened. We skip the
|
|
606
|
+
// fingerprint scan only when pid resolver actively switched the path
|
|
607
|
+
// — in that case the authoritative source already moved us, and
|
|
608
|
+
// running fingerprint on top would risk a redundant flip.
|
|
609
|
+
let switched = pidFollow === 'switched';
|
|
610
|
+
if (!switched) {
|
|
611
|
+
switched = maybeSwitchBridgeJsonl();
|
|
612
|
+
}
|
|
613
|
+
// Quiet-rotation fallback: catches /clear or /resume in pure-local
|
|
614
|
+
// sessions (no pending Lark turn → no fingerprint to match against).
|
|
615
|
+
// Without this, a user who hits /clear in the adopted pane and then
|
|
616
|
+
// continues in the terminal would never get those replies forwarded
|
|
617
|
+
// to Lark — the watcher stays stuck on the old, frozen jsonl.
|
|
618
|
+
if (!switched) {
|
|
619
|
+
maybeFollowQuietRotation();
|
|
620
|
+
}
|
|
621
|
+
if (!bridgeJsonlPath)
|
|
622
|
+
return;
|
|
623
|
+
if (!bridgeBaselineDone) {
|
|
624
|
+
// Lazy baseline: file didn't exist at attach, baseline the moment it does.
|
|
625
|
+
if (!existsSyncSafe(bridgeJsonlPath))
|
|
626
|
+
return;
|
|
627
|
+
bridgeAbsorbBaseline();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const result = drainTranscript(bridgeJsonlPath, bridgeOffset);
|
|
631
|
+
bridgeOffset = result.newOffset;
|
|
632
|
+
bridgePendingTail = result.pendingTail;
|
|
633
|
+
bridgeQueue.ingest(result.events, bridgeJsonlPath);
|
|
634
|
+
}
|
|
635
|
+
function startBridgeWatcher(jsonlPath, opts) {
|
|
636
|
+
bridgeJsonlPath = jsonlPath;
|
|
637
|
+
bridgeJsonlDir = dirname(jsonlPath);
|
|
638
|
+
bridgeCliPid = opts?.cliPid;
|
|
639
|
+
bridgeCliCwd = opts?.cliCwd;
|
|
640
|
+
const mode = opts?.mode ?? 'baseline-existing';
|
|
641
|
+
// Authoritative: prefer Claude's own pid-state record over the path the
|
|
642
|
+
// adopt scan computed. If Claude has already rotated since adopt fired
|
|
643
|
+
// (e.g. user ran /clear before any Lark message arrived), this swaps the
|
|
644
|
+
// initial path before baseline so we don't waste a baseline on a frozen
|
|
645
|
+
// file.
|
|
646
|
+
if (bridgeCliPid && bridgeCliCwd) {
|
|
647
|
+
const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
|
|
648
|
+
if (resolved) {
|
|
649
|
+
bridgeObservedCliSessionId = resolved.cliSessionId;
|
|
650
|
+
if (resolved.path !== bridgeJsonlPath) {
|
|
651
|
+
log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
|
|
652
|
+
bridgeJsonlPath = resolved.path;
|
|
653
|
+
bridgeJsonlDir = dirname(resolved.path);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (mode === 'fresh-empty') {
|
|
658
|
+
// Non-adopt fallback: brand-new session, jsonl gets created on the first
|
|
659
|
+
// user submit. We must NOT lazy-absorb the file when it appears — that
|
|
660
|
+
// would treat the first turn's user/assistant events as history and the
|
|
661
|
+
// worker would never emit a final_output for them. Instead declare
|
|
662
|
+
// baseline=done with offset=0 up front: the very first events drained
|
|
663
|
+
// from the file are eligible for attribution against pending Lark turns.
|
|
664
|
+
bridgeOffset = 0;
|
|
665
|
+
bridgePendingTail = '';
|
|
666
|
+
bridgeBaselineDone = true;
|
|
667
|
+
log(`Bridge fresh-empty mode: ${bridgeJsonlPath} (waiting for file to appear; no baseline absorb)`);
|
|
668
|
+
}
|
|
669
|
+
else if (existsSyncSafe(bridgeJsonlPath)) {
|
|
670
|
+
bridgeAbsorbBaseline();
|
|
671
|
+
log(`Bridge baselined: ${bridgeJsonlPath} (offset=${bridgeOffset})`);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
log(`Bridge transcript not yet present at ${bridgeJsonlPath}; will baseline on first appearance`);
|
|
675
|
+
}
|
|
676
|
+
// fs.watch is best-effort wakeup — actual data source is the byte offset.
|
|
677
|
+
// The fallback poller covers fs.watch's gaps (NFS, rename-rotation, etc.)
|
|
678
|
+
// and also drives lazy baseline when the file shows up after attach.
|
|
679
|
+
try {
|
|
680
|
+
bridgeWatcher = fsWatch(bridgeJsonlPath, { persistent: false }, () => {
|
|
681
|
+
try {
|
|
682
|
+
bridgeIngest();
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
log(`Bridge fs.watch unavailable (${err.message}); relying on fallback poller`);
|
|
691
|
+
}
|
|
692
|
+
bridgeFallbackTimer = setInterval(() => {
|
|
693
|
+
try {
|
|
694
|
+
bridgeIngest();
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
log(`Bridge ingest error: ${err.message}`);
|
|
698
|
+
}
|
|
699
|
+
}, 1000);
|
|
700
|
+
}
|
|
701
|
+
function stopBridgeWatcher() {
|
|
702
|
+
if (bridgeWatcher) {
|
|
703
|
+
try {
|
|
704
|
+
bridgeWatcher.close();
|
|
705
|
+
}
|
|
706
|
+
catch { /* ignore */ }
|
|
707
|
+
bridgeWatcher = null;
|
|
708
|
+
}
|
|
709
|
+
if (bridgeFallbackTimer) {
|
|
710
|
+
clearInterval(bridgeFallbackTimer);
|
|
711
|
+
bridgeFallbackTimer = null;
|
|
712
|
+
}
|
|
713
|
+
bridgeCliPid = undefined;
|
|
714
|
+
bridgeCliCwd = undefined;
|
|
715
|
+
bridgeObservedCliSessionId = undefined;
|
|
716
|
+
bridgeSecondaryPaths.clear();
|
|
717
|
+
bridgePreambleSent = false;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Push a pending turn for the next Lark message.
|
|
721
|
+
*
|
|
722
|
+
* Returns true on success, false if bridge-final-output isn't available for
|
|
723
|
+
* this message (transcript not yet baselined). On false, the worker still
|
|
724
|
+
* raw-writes the message into the pane — the user just won't get a
|
|
725
|
+
* transcript-driven final_output reply for it. This keeps the v3 promise:
|
|
726
|
+
* if we can't attribute correctly, we don't attribute at all.
|
|
727
|
+
*
|
|
728
|
+
* `messageText` is the raw Lark message body — we derive a short content
|
|
729
|
+
* fingerprint from it so the next *matching* user event in the transcript
|
|
730
|
+
* (and only that one) starts this turn. Local-terminal input that races
|
|
731
|
+
* with the pane-write will not match the fingerprint and won't hijack the
|
|
732
|
+
* Lark turn.
|
|
733
|
+
*/
|
|
734
|
+
function bridgeMarkPendingTurn(messageText) {
|
|
735
|
+
if (!bridgeJsonlPath)
|
|
736
|
+
return false;
|
|
737
|
+
if (!bridgeBaselineDone) {
|
|
738
|
+
log('Bridge baseline not ready — this turn will not have transcript-driven final_output');
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
const fingerprint = makeFingerprint(messageText);
|
|
742
|
+
bridgeQueue.mark(randomBytes(8).toString('hex'), fingerprint);
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
function bridgeDrainAndMaybeEmit() {
|
|
746
|
+
if (!bridgeJsonlPath)
|
|
747
|
+
return;
|
|
748
|
+
bridgeIngest();
|
|
749
|
+
emitReadyTurns();
|
|
750
|
+
// Prune AFTER emit so a path is only retired once its turn has actually
|
|
751
|
+
// been published. During non-idle ticks (fs.watch / 1s poll) we never
|
|
752
|
+
// emit, so we never prune — the path stays put until idle resolves it.
|
|
753
|
+
pruneSecondaryPaths();
|
|
754
|
+
}
|
|
755
|
+
/** Pop ready turns and emit their final_output. Resolves uuid → text via
|
|
756
|
+
* each turn's own `sourceJsonlPath` (stamped at turn-start) so an in-flight
|
|
757
|
+
* reply that started in an old jsonl still gets picked up after a sessionId
|
|
758
|
+
* rotation has switched the global `bridgeJsonlPath` to a different file.
|
|
759
|
+
* Falls back to `bridgeJsonlPath` for legacy turns without a stamped source.
|
|
760
|
+
*
|
|
761
|
+
* Caches per-path drains so a batch of turns from the same file only reads
|
|
762
|
+
* the transcript once (O(jsonl size) per distinct path). */
|
|
763
|
+
function emitReadyTurns() {
|
|
764
|
+
const ready = bridgeQueue.drainEmittable();
|
|
765
|
+
if (ready.length === 0)
|
|
766
|
+
return;
|
|
767
|
+
const adoptMode = lastInitConfig?.adoptMode === true;
|
|
768
|
+
// Send markers (`botmux send` landed in own thread) + the queue's first
|
|
769
|
+
// still-unready turn. The latter caps the LAST ready turn's window —
|
|
770
|
+
// without it, a model that's still mid-tool-use for turn N+1 could leak
|
|
771
|
+
// a send credit into turn N's window via shouldSuppressBridgeEmit.
|
|
772
|
+
const markers = adoptMode ? [] : readSendMarkers();
|
|
773
|
+
const remainingPending = bridgeQueue.peek();
|
|
774
|
+
const nextPendingMarkTimeMs = remainingPending.length > 0 ? remainingPending[0].markTimeMs : undefined;
|
|
775
|
+
const cache = new Map();
|
|
776
|
+
for (let i = 0; i < ready.length; i++) {
|
|
777
|
+
const turn = ready[i];
|
|
778
|
+
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
|
|
779
|
+
if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
|
|
780
|
+
const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
|
|
781
|
+
log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const path = turn.sourceJsonlPath ?? bridgeJsonlPath;
|
|
785
|
+
if (!path)
|
|
786
|
+
continue;
|
|
787
|
+
let drained = cache.get(path);
|
|
788
|
+
if (!drained) {
|
|
789
|
+
drained = drainTranscript(path, 0);
|
|
790
|
+
cache.set(path, drained);
|
|
791
|
+
}
|
|
792
|
+
const set = new Set(turn.assistantUuids);
|
|
793
|
+
const matched = drained.events.filter(e => e.uuid && set.has(e.uuid));
|
|
794
|
+
const assistantText = joinAssistantText(matched);
|
|
795
|
+
if (assistantText.length === 0)
|
|
796
|
+
continue;
|
|
797
|
+
const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
|
|
798
|
+
if (turn.isLocal) {
|
|
799
|
+
// Local turn (adopt mode only): also surface the user prompt so the
|
|
800
|
+
// Lark thread shows both sides of the exchange. User text comes from
|
|
801
|
+
// the same drained transcript via the userUuid stamped at start time.
|
|
802
|
+
const userEv = turn.userUuid
|
|
803
|
+
? drained.events.find(e => e.uuid === turn.userUuid)
|
|
804
|
+
: undefined;
|
|
805
|
+
const userText = userEv ? stringifyUserContent(userEv.message?.content) : '';
|
|
806
|
+
const content = formatLocalTurnContent(userText, assistantText);
|
|
807
|
+
if (!content)
|
|
808
|
+
continue;
|
|
809
|
+
send({ type: 'final_output', content, lastUuid, turnId: turn.turnId });
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
send({ type: 'final_output', content: assistantText, lastUuid, turnId: turn.turnId });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/** Drain `path` from `fromOffset` and feed the events to the bridge queue
|
|
816
|
+
* with that path as the source stamp. Pure side-effects on bridgeQueue +
|
|
817
|
+
* the returned cursor; does NOT touch bridgeJsonlPath / bridgeOffset, so
|
|
818
|
+
* callers can use it to flush the old path during a rotation without
|
|
819
|
+
* disturbing the watcher's normal cursor. Returns the new offset for the
|
|
820
|
+
* caller to commit (or discard, if it's about to switch paths). */
|
|
821
|
+
function drainPathInto(path, fromOffset) {
|
|
822
|
+
const result = drainTranscript(path, fromOffset);
|
|
823
|
+
bridgeQueue.ingest(result.events, path);
|
|
824
|
+
return { offset: result.newOffset, tail: result.pendingTail };
|
|
825
|
+
}
|
|
826
|
+
/** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
|
|
827
|
+
* whose sourceJsonlPath equals oldPath may still be waiting on assistant
|
|
828
|
+
* text that hasn't landed yet. Add oldPath to the secondary polling set
|
|
829
|
+
* so subsequent ingests continue to drain it; the offset is whatever was
|
|
830
|
+
* reached by the final pre-switch drain so we don't re-scan history. The
|
|
831
|
+
* entry is later pruned after each idle emit when no started turn
|
|
832
|
+
* references it anymore. */
|
|
833
|
+
function retainSecondaryPathIfStillReferenced(oldPath, postDrainOffset) {
|
|
834
|
+
const stillReferenced = bridgeQueue.peek().some(t => t.sourceJsonlPath === oldPath);
|
|
835
|
+
if (!stillReferenced)
|
|
836
|
+
return;
|
|
837
|
+
const existing = bridgeSecondaryPaths.get(oldPath);
|
|
838
|
+
// Don't rewind a higher existing offset — multiple rotations through
|
|
839
|
+
// the same file shouldn't replay drained bytes.
|
|
840
|
+
if (existing === undefined || postDrainOffset > existing) {
|
|
841
|
+
bridgeSecondaryPaths.set(oldPath, postDrainOffset);
|
|
842
|
+
}
|
|
843
|
+
log(`Bridge retaining secondary path ${oldPath} (offset=${postDrainOffset}) for in-flight turn`);
|
|
844
|
+
}
|
|
845
|
+
/** Drain every secondary path once. Mirrors bridgeIngest's primary-path
|
|
846
|
+
* drain but never touches bridgeJsonlPath / bridgeOffset and never
|
|
847
|
+
* triggers further rotation checks — it's strictly a "catch up trailing
|
|
848
|
+
* events on an old file" pass. */
|
|
849
|
+
function drainSecondaryPaths() {
|
|
850
|
+
for (const [path, offset] of bridgeSecondaryPaths) {
|
|
851
|
+
try {
|
|
852
|
+
const result = drainTranscript(path, offset);
|
|
853
|
+
if (result.events.length > 0)
|
|
854
|
+
bridgeQueue.ingest(result.events, path);
|
|
855
|
+
bridgeSecondaryPaths.set(path, result.newOffset);
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
log(`Bridge secondary-path drain failed (${path}): ${err.message}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/** Drop secondary paths whose started turns are no longer in the queue —
|
|
863
|
+
* i.e. they've been emitted (or discarded). Called after each idle emit so
|
|
864
|
+
* pruning never races with an in-flight turn. */
|
|
865
|
+
function pruneSecondaryPaths() {
|
|
866
|
+
if (bridgeSecondaryPaths.size === 0)
|
|
867
|
+
return;
|
|
868
|
+
const referenced = new Set();
|
|
869
|
+
for (const t of bridgeQueue.peek()) {
|
|
870
|
+
if (t.sourceJsonlPath)
|
|
871
|
+
referenced.add(t.sourceJsonlPath);
|
|
872
|
+
}
|
|
873
|
+
for (const path of [...bridgeSecondaryPaths.keys()]) {
|
|
874
|
+
if (!referenced.has(path)) {
|
|
875
|
+
bridgeSecondaryPaths.delete(path);
|
|
876
|
+
log(`Bridge dropped secondary path ${path} (no remaining turns)`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/** Tiny safe-existence check that doesn't throw. */
|
|
881
|
+
function existsSyncSafe(p) {
|
|
882
|
+
try {
|
|
883
|
+
return existsSync(p);
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
54
889
|
/** Suppress screen updates until first prompt detected (avoids history replay in card on --resume) */
|
|
55
890
|
let awaitingFirstPrompt = true;
|
|
56
891
|
// ─── PTY Dimensions ──────────────────────────────────────────────────────────
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
892
|
+
// Default for botmux-spawned CLIs: narrow enough for the web terminal to
|
|
893
|
+
// render comfortably and for the card PNG to fit Lark's typical card width.
|
|
894
|
+
// Adopt mode overrides this via resolveRenderDimensions() to match the
|
|
895
|
+
// user's actual pane (often 200-270 cols) so the renderer doesn't wrap
|
|
896
|
+
// wide ANSI into a stair-stepped / duplicated mess.
|
|
897
|
+
const PTY_COLS = DEFAULT_RENDER_COLS;
|
|
898
|
+
const PTY_ROWS = DEFAULT_RENDER_ROWS;
|
|
899
|
+
/** Set in the `init` handler BEFORE startScreenUpdates() so the headless
|
|
900
|
+
* xterm + screenshot canvas are sized to the source pane from the start.
|
|
901
|
+
* Setting them later (after the renderer was built at the default size)
|
|
902
|
+
* wouldn't retroactively re-size what xterm has already buffered,
|
|
903
|
+
* leaving the wrap artefacts in place. */
|
|
904
|
+
let renderCols = PTY_COLS;
|
|
905
|
+
let renderRows = PTY_ROWS;
|
|
61
906
|
// ─── Headless Terminal for Screen Capture ────────────────────────────────────
|
|
62
907
|
let renderer = null;
|
|
63
908
|
let screenUpdateTimer = null;
|
|
@@ -117,8 +962,10 @@ function stopScreenAnalyzer() {
|
|
|
117
962
|
// ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
|
|
118
963
|
const SCREENSHOT_INTERVAL_MS = 10_000;
|
|
119
964
|
const POST_ACTION_DELAY_MS = 1_000;
|
|
120
|
-
|
|
121
|
-
|
|
965
|
+
// PNG dimensions key off the renderer's actual size (renderCols / renderRows),
|
|
966
|
+
// which adopt-mode peg to the source pane so wrap artefacts don't appear.
|
|
967
|
+
// Re-clamping at MAX_RENDER_COLS/ROWS guards against a malformed init
|
|
968
|
+
// payload sneaking past the resolver into a runaway canvas.
|
|
122
969
|
let displayMode = 'hidden';
|
|
123
970
|
let screenshotTimer = null;
|
|
124
971
|
let pendingShotTimer = null;
|
|
@@ -178,7 +1025,9 @@ async function captureAndUpload() {
|
|
|
178
1025
|
lastShotHash = hash;
|
|
179
1026
|
let png;
|
|
180
1027
|
try {
|
|
181
|
-
|
|
1028
|
+
const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
|
|
1029
|
+
const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
|
|
1030
|
+
png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
|
|
182
1031
|
}
|
|
183
1032
|
catch (err) {
|
|
184
1033
|
log(`Screenshot render failed: ${err.message}`);
|
|
@@ -377,9 +1226,11 @@ let trustHandled = false;
|
|
|
377
1226
|
// ─── Prompt Detection ────────────────────────────────────────────────────────
|
|
378
1227
|
function onPtyData(data) {
|
|
379
1228
|
renderer?.write(data);
|
|
380
|
-
// In tmux mode, web
|
|
381
|
-
// In non-tmux mode
|
|
382
|
-
|
|
1229
|
+
// In tmux-attach mode, each web client has its own tmux attach PTY —
|
|
1230
|
+
// no relay needed. In non-tmux mode AND in pipe mode (adopt-bridge),
|
|
1231
|
+
// broadcast through the shared scrollback so all connected web clients
|
|
1232
|
+
// render the same byte stream.
|
|
1233
|
+
if (!isTmuxMode || isPipeMode) {
|
|
383
1234
|
// Track alt-buffer state so we can restore it in the scrollback prefix.
|
|
384
1235
|
// Scan for the *last* toggle in this chunk — that's the current state.
|
|
385
1236
|
let lastToggleIdx = -1;
|
|
@@ -453,6 +1304,23 @@ function markPromptReady() {
|
|
|
453
1304
|
}
|
|
454
1305
|
flushPending();
|
|
455
1306
|
}
|
|
1307
|
+
function persistCliSessionId(cliSessionId) {
|
|
1308
|
+
if (!cliSessionId || !sessionId)
|
|
1309
|
+
return;
|
|
1310
|
+
if (lastInitConfig)
|
|
1311
|
+
lastInitConfig.cliSessionId = cliSessionId;
|
|
1312
|
+
try {
|
|
1313
|
+
const session = sessionStore.getSession(sessionId);
|
|
1314
|
+
if (!session || session.cliSessionId === cliSessionId)
|
|
1315
|
+
return;
|
|
1316
|
+
session.cliSessionId = cliSessionId;
|
|
1317
|
+
sessionStore.updateSession(session);
|
|
1318
|
+
log(`Persisted CLI session id: ${cliSessionId}`);
|
|
1319
|
+
}
|
|
1320
|
+
catch (err) {
|
|
1321
|
+
log(`Failed to persist CLI session id: ${err.message}`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
456
1324
|
/**
|
|
457
1325
|
* Drain the pending message queue sequentially.
|
|
458
1326
|
* Async with isFlushing mutex: awaits each writeInput, then immediately
|
|
@@ -467,8 +1335,15 @@ async function flushPending() {
|
|
|
467
1335
|
if (pendingMessages.length === 0)
|
|
468
1336
|
return; // nothing to flush — keep isPromptReady
|
|
469
1337
|
// Type-ahead adapters flush even while the CLI is busy; others wait for idle.
|
|
470
|
-
|
|
1338
|
+
// Bridge fallback (non-adopt) disables type-ahead: queued submits land
|
|
1339
|
+
// in jsonl as `attachment(queued_command)` events, NOT `role:user` lines,
|
|
1340
|
+
// so BridgeTurnQueue.ingest never starts the pending turn for them and
|
|
1341
|
+
// the assistant text would be dropped on the floor. Serialise instead —
|
|
1342
|
+
// worker holds messages in pendingMessages until the CLI reaches idle.
|
|
1343
|
+
const typeAheadAllowed = cliAdapter.supportsTypeAhead && !(bridgeJsonlPath && !lastInitConfig?.adoptMode);
|
|
1344
|
+
if (!isPromptReady && !typeAheadAllowed)
|
|
471
1345
|
return;
|
|
1346
|
+
const bridgeFallbackActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
|
|
472
1347
|
isFlushing = true;
|
|
473
1348
|
if (isPromptReady) {
|
|
474
1349
|
isPromptReady = false;
|
|
@@ -477,8 +1352,28 @@ async function flushPending() {
|
|
|
477
1352
|
try {
|
|
478
1353
|
while (pendingMessages.length > 0 && backend && cliAdapter) {
|
|
479
1354
|
const msg = pendingMessages.shift();
|
|
1355
|
+
// Bridge fallback: mark immediately before writeInput. Doing it here
|
|
1356
|
+
// (instead of at enqueue time) means markTimeMs anchors to the
|
|
1357
|
+
// moment the message actually starts hitting the PTY — so any
|
|
1358
|
+
// `botmux send` whose sentAtMs lands during turn N's processing
|
|
1359
|
+
// falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
|
|
1360
|
+
// (at IPC arrival) would let a slow-finishing turn N's send leak
|
|
1361
|
+
// into turn N+1's window and falsely suppress its emit.
|
|
1362
|
+
if (bridgeFallbackActive) {
|
|
1363
|
+
try {
|
|
1364
|
+
bridgeIngest();
|
|
1365
|
+
}
|
|
1366
|
+
catch { /* best-effort */ }
|
|
1367
|
+
bridgeMarkPendingTurn(msg);
|
|
1368
|
+
}
|
|
480
1369
|
log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
|
|
481
1370
|
const result = await cliAdapter.writeInput(backend, msg);
|
|
1371
|
+
// Persist any sessionId the adapter observed via authoritative sources
|
|
1372
|
+
// (Claude's pid file, Codex's history). Done independently of submit
|
|
1373
|
+
// outcome — the rotation is real even when the current Enter didn't
|
|
1374
|
+
// land, and we want next-resume to use the right id.
|
|
1375
|
+
if (result?.cliSessionId)
|
|
1376
|
+
persistCliSessionId(result.cliSessionId);
|
|
482
1377
|
if (result && result.submitted === false) {
|
|
483
1378
|
const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
|
|
484
1379
|
log(`writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
|
|
@@ -487,6 +1382,16 @@ async function flushPending() {
|
|
|
487
1382
|
message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 3 次仍未在会话 JSONL 中看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
|
|
488
1383
|
});
|
|
489
1384
|
}
|
|
1385
|
+
// Bridge fallback: stop after one writeInput. Subsequent submits
|
|
1386
|
+
// would be type-ahead'd into Claude's queue, which jsonl records as
|
|
1387
|
+
// queued_command attachments (not role:user lines) — BridgeTurnQueue
|
|
1388
|
+
// can't attribute those, so the fallback would silently drop them.
|
|
1389
|
+
// We resume on the next idle, by which point Claude has finished
|
|
1390
|
+
// and the next message can be a normal role:user submit. Scoped to
|
|
1391
|
+
// bridgeFallbackActive so non-bridge CLIs (codex/gemini/...) keep
|
|
1392
|
+
// the original "one idle drains all pending" behaviour.
|
|
1393
|
+
if (bridgeFallbackActive && pendingMessages.length > 0)
|
|
1394
|
+
break;
|
|
490
1395
|
}
|
|
491
1396
|
}
|
|
492
1397
|
finally {
|
|
@@ -497,11 +1402,22 @@ function sendToPty(content) {
|
|
|
497
1402
|
if (!backend || !cliAdapter)
|
|
498
1403
|
return;
|
|
499
1404
|
pendingMessages.push(content);
|
|
1405
|
+
// User-override semantics: a fresh Lark message while a TUI prompt is "active"
|
|
1406
|
+
// takes precedence over the AI-detected prompt. The screen analyzer can be
|
|
1407
|
+
// wrong (false positive on a question that has no rendered options) and a
|
|
1408
|
+
// wedged blocking flag silently swallows every subsequent message — without
|
|
1409
|
+
// this override the user has no way to recover from Lark. Mirrors the
|
|
1410
|
+
// web-terminal text-input path (handleTuiTextInput).
|
|
500
1411
|
if (tuiPromptBlocking) {
|
|
501
|
-
log(`
|
|
502
|
-
|
|
1412
|
+
log(`User override: incoming Lark message clears tuiPromptBlocking — "${content.substring(0, 80)}"`);
|
|
1413
|
+
tuiPromptBlocking = false;
|
|
1414
|
+
screenAnalyzer?.notifySelection('lark-input');
|
|
1415
|
+
// Tear down the prompt card so the user doesn't see stale options.
|
|
1416
|
+
send({ type: 'tui_prompt_resolved', selectedText: 'user-override' });
|
|
503
1417
|
}
|
|
504
|
-
|
|
1418
|
+
// See flushPending: bridge fallback gates type-ahead off.
|
|
1419
|
+
const typeAheadAllowed = cliAdapter.supportsTypeAhead && !(bridgeJsonlPath && !lastInitConfig?.adoptMode);
|
|
1420
|
+
if (isPromptReady || isFlushing || typeAheadAllowed) {
|
|
505
1421
|
log(`Writing to PTY: "${content.substring(0, 80)}"`);
|
|
506
1422
|
flushPending(); // fire-and-forget async; no-op if already flushing
|
|
507
1423
|
}
|
|
@@ -511,7 +1427,12 @@ function sendToPty(content) {
|
|
|
511
1427
|
}
|
|
512
1428
|
// ─── Screen Update Timer ─────────────────────────────────────────────────────
|
|
513
1429
|
function startScreenUpdates() {
|
|
514
|
-
|
|
1430
|
+
// renderCols / renderRows were set by the init handler from cfg, so
|
|
1431
|
+
// adopt-mode panes (e.g. 270x57) get an xterm-headless of matching
|
|
1432
|
+
// width. With a too-narrow renderer, ANSI meant for the source pane
|
|
1433
|
+
// would wrap and the screenshot would show duplicated / stair-stepped
|
|
1434
|
+
// content (the live failure that prompted this fix).
|
|
1435
|
+
renderer = new TerminalRenderer(renderCols, renderRows);
|
|
515
1436
|
let lastSentStatus;
|
|
516
1437
|
screenUpdateTimer = setInterval(() => {
|
|
517
1438
|
if (!renderer || awaitingFirstPrompt)
|
|
@@ -539,36 +1460,73 @@ function stopScreenUpdates() {
|
|
|
539
1460
|
}
|
|
540
1461
|
// ─── PTY Management ──────────────────────────────────────────────────────────
|
|
541
1462
|
function spawnCli(cfg) {
|
|
542
|
-
// ── Adopt mode:
|
|
1463
|
+
// ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
|
|
543
1464
|
if (cfg.adoptMode && cfg.adoptTmuxTarget) {
|
|
1465
|
+
// We mark BOTH isTmuxMode and isPipeMode: the former keeps idle/spawn
|
|
1466
|
+
// logic on the tmux track; the latter tells the WS handler to route
|
|
1467
|
+
// updates through the shared scrollback fan-out (because there is no
|
|
1468
|
+
// PTY-per-WS — we don't attach to anything).
|
|
544
1469
|
isTmuxMode = true;
|
|
1470
|
+
isPipeMode = true;
|
|
545
1471
|
const cols = cfg.adoptPaneCols ?? PTY_COLS;
|
|
546
1472
|
const rows = cfg.adoptPaneRows ?? PTY_ROWS;
|
|
547
|
-
const
|
|
548
|
-
backend =
|
|
549
|
-
|
|
1473
|
+
const pipeBe = new TmuxPipeBackend(cfg.adoptTmuxTarget);
|
|
1474
|
+
backend = pipeBe;
|
|
1475
|
+
pipeBe.spawn('', [], {
|
|
550
1476
|
cwd: cfg.workingDir,
|
|
551
1477
|
cols,
|
|
552
1478
|
rows,
|
|
553
1479
|
env: process.env,
|
|
554
1480
|
});
|
|
555
|
-
//
|
|
556
|
-
|
|
1481
|
+
// Seed the shared scrollback with the pane's current screen so any
|
|
1482
|
+
// already-connected (or future) WS clients render meaningful content
|
|
1483
|
+
// immediately, instead of waiting for the next byte tmux pipes through.
|
|
1484
|
+
try {
|
|
1485
|
+
const initial = pipeBe.captureCurrentScreen();
|
|
1486
|
+
if (initial.length > 0)
|
|
1487
|
+
onPtyData(initial);
|
|
1488
|
+
}
|
|
1489
|
+
catch (err) {
|
|
1490
|
+
log(`captureCurrentScreen failed: ${err.message}`);
|
|
1491
|
+
}
|
|
1492
|
+
// Bridge mode: tail Claude Code's transcript JSONL to harvest assistant
|
|
1493
|
+
// turns out-of-band. Only enabled when the daemon supplied a path
|
|
1494
|
+
// (claude-code adopt with a known sessionId).
|
|
1495
|
+
if (cfg.bridgeJsonlPath) {
|
|
1496
|
+
startBridgeWatcher(cfg.bridgeJsonlPath, {
|
|
1497
|
+
cliPid: cfg.adoptCliPid,
|
|
1498
|
+
cliCwd: cfg.adoptCwd,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
// Idle detection. In bridge mode we use Claude Code's real
|
|
1502
|
+
// completion/ready patterns (e.g. "Worked for Xs") so tool-execution
|
|
1503
|
+
// pauses don't trigger a premature emit. Other adopt cases keep the
|
|
1504
|
+
// minimal output-quiescence-only detector.
|
|
1505
|
+
const idleAdapter = cfg.bridgeJsonlPath
|
|
1506
|
+
? createCliAdapterSync('claude-code', undefined)
|
|
1507
|
+
: { completionPattern: undefined, readyPattern: undefined };
|
|
1508
|
+
idleDetector = new IdleDetector(idleAdapter);
|
|
557
1509
|
idleDetector.onIdle(() => {
|
|
558
1510
|
log('Prompt detected (idle) — adopt mode');
|
|
1511
|
+
try {
|
|
1512
|
+
bridgeDrainAndMaybeEmit();
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
log(`Bridge emit error: ${err.message}`);
|
|
1516
|
+
}
|
|
559
1517
|
markPromptReady();
|
|
560
1518
|
});
|
|
561
1519
|
backend.onData(onPtyData);
|
|
562
1520
|
backend.onExit((code, signal) => {
|
|
563
|
-
log(`Adopted
|
|
1521
|
+
log(`Adopted pipe-pane stream ended (code: ${code}, signal: ${signal})`);
|
|
564
1522
|
backend = null;
|
|
565
1523
|
isPromptReady = false;
|
|
1524
|
+
stopBridgeWatcher();
|
|
566
1525
|
send({ type: 'claude_exit', code, signal });
|
|
567
1526
|
});
|
|
568
|
-
// CLI is already running — unblock screen updates immediately
|
|
569
1527
|
awaitingFirstPrompt = false;
|
|
570
1528
|
renderer?.markNewTurn();
|
|
571
|
-
log(`Adopt mode:
|
|
1529
|
+
log(`Adopt mode (pipe): observing ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
|
|
572
1530
|
return;
|
|
573
1531
|
}
|
|
574
1532
|
cliAdapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride);
|
|
@@ -588,6 +1546,7 @@ function spawnCli(cfg) {
|
|
|
588
1546
|
const args = cliAdapter.buildArgs({
|
|
589
1547
|
sessionId: cfg.sessionId,
|
|
590
1548
|
resume: cfg.resume ?? false,
|
|
1549
|
+
resumeSessionId: cfg.cliSessionId,
|
|
591
1550
|
initialPrompt: cfg.prompt || undefined,
|
|
592
1551
|
botName: cfg.botName,
|
|
593
1552
|
botOpenId: cfg.botOpenId,
|
|
@@ -596,12 +1555,25 @@ function spawnCli(cfg) {
|
|
|
596
1555
|
const extra = (process.env.CLI_EXTRA_ARGS ?? '').trim();
|
|
597
1556
|
if (extra)
|
|
598
1557
|
args.push(...extra.split(/\s+/).filter(Boolean));
|
|
1558
|
+
// Claude Code 在 root/sudo 下会拒绝 --dangerously-skip-permissions 并立即 exit。
|
|
1559
|
+
// botmux 必须带这个 flag(话题里没法弹交互式审批),所以为 root 自动注入
|
|
1560
|
+
// IS_SANDBOX=1 走 Claude Code 的受控环境逃生舱。用户显式设了就尊重不覆盖。
|
|
1561
|
+
const injectClaudeSandbox = cfg.cliId === 'claude-code' &&
|
|
1562
|
+
process.getuid?.() === 0 &&
|
|
1563
|
+
!process.env.IS_SANDBOX;
|
|
1564
|
+
if (injectClaudeSandbox) {
|
|
1565
|
+
log('Detected root user — injecting IS_SANDBOX=1 for Claude Code');
|
|
1566
|
+
}
|
|
599
1567
|
log(`Spawning: ${cliAdapter.resolvedBin} ${args.join(' ')} (cwd: ${cfg.workingDir})`);
|
|
600
1568
|
backend.spawn(cliAdapter.resolvedBin, args, {
|
|
601
1569
|
cwd: cfg.workingDir,
|
|
602
1570
|
cols: PTY_COLS,
|
|
603
1571
|
rows: PTY_ROWS,
|
|
604
|
-
env: {
|
|
1572
|
+
env: {
|
|
1573
|
+
...process.env,
|
|
1574
|
+
CLAUDECODE: undefined,
|
|
1575
|
+
...(injectClaudeSandbox ? { IS_SANDBOX: '1' } : {}),
|
|
1576
|
+
},
|
|
605
1577
|
});
|
|
606
1578
|
// Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
|
|
607
1579
|
// can verify they were spawned inside a botmux session by walking the
|
|
@@ -619,16 +1591,52 @@ function spawnCli(cfg) {
|
|
|
619
1591
|
log(`Failed to write CLI PID marker: ${err.message}`);
|
|
620
1592
|
}
|
|
621
1593
|
}
|
|
1594
|
+
// Wire pid + cwd so the claude-code adapter's writeInput can read
|
|
1595
|
+
// ~/.claude/sessions/<pid>.json — Claude's authoritative current sessionId.
|
|
1596
|
+
// The pinned claudeJsonlPath above is still used as the initial guess; the
|
|
1597
|
+
// resolver corrects it on first write when Claude has rotated under us.
|
|
1598
|
+
if (cfg.cliId === 'claude-code' && cliPid) {
|
|
1599
|
+
backend.cliPid = cliPid;
|
|
1600
|
+
backend.cliCwd = cfg.workingDir;
|
|
1601
|
+
}
|
|
622
1602
|
// On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
|
|
623
1603
|
// suppressed until the idle detector fires markNewTurn() — this prevents the
|
|
624
1604
|
// full tmux scrollback history from leaking into the streaming card.
|
|
625
1605
|
if (tmuxBe?.isReattach) {
|
|
626
1606
|
log('Re-attached to existing tmux session');
|
|
627
1607
|
}
|
|
1608
|
+
// Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
|
|
1609
|
+
// turn the model finishes WITHOUT calling `botmux send` still gets its
|
|
1610
|
+
// assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
|
|
1611
|
+
// the emit when a send did happen). Adopt mode wires this up separately
|
|
1612
|
+
// (with baseline-existing); here we use fresh-empty for new sessions so
|
|
1613
|
+
// the file Claude creates on first submit isn't absorbed as history,
|
|
1614
|
+
// and baseline-existing on resume so prior-run turns ARE absorbed (we
|
|
1615
|
+
// don't want to re-emit yesterday's conversation as fresh turns).
|
|
1616
|
+
if (cfg.cliId === 'claude-code' && cfg.sessionId) {
|
|
1617
|
+
const claudeJsonl = claudeJsonlPathForSession(cfg.sessionId, cfg.workingDir);
|
|
1618
|
+
startBridgeWatcher(claudeJsonl, {
|
|
1619
|
+
cliPid: cliPid ?? undefined,
|
|
1620
|
+
cliCwd: cfg.workingDir,
|
|
1621
|
+
mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
628
1624
|
// Set up idle detection
|
|
629
1625
|
idleDetector = new IdleDetector(cliAdapter);
|
|
630
1626
|
idleDetector.onIdle(() => {
|
|
631
1627
|
log('Prompt detected (idle)');
|
|
1628
|
+
// Bridge drain MUST run before markPromptReady() — the latter calls
|
|
1629
|
+
// flushPending() which can immediately fire the next queued message
|
|
1630
|
+
// (type-ahead adapters), shifting bridgeQueue's notion of "current
|
|
1631
|
+
// turn" before we've had a chance to emit the previous one.
|
|
1632
|
+
if (bridgeJsonlPath) {
|
|
1633
|
+
try {
|
|
1634
|
+
bridgeDrainAndMaybeEmit();
|
|
1635
|
+
}
|
|
1636
|
+
catch (err) {
|
|
1637
|
+
log(`Bridge emit error: ${err.message}`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
632
1640
|
markPromptReady();
|
|
633
1641
|
});
|
|
634
1642
|
backend.onData(onPtyData);
|
|
@@ -658,6 +1666,10 @@ function killCli() {
|
|
|
658
1666
|
stopScreenUpdates();
|
|
659
1667
|
backend?.kill();
|
|
660
1668
|
backend = null;
|
|
1669
|
+
// Tear down the bridge watcher (if any). spawnCli will rebuild it on
|
|
1670
|
+
// restart with the proper mode based on the new cfg. Leaving it running
|
|
1671
|
+
// would dangle a watcher pinned to a stale jsonl path.
|
|
1672
|
+
stopBridgeWatcher();
|
|
661
1673
|
// Clean up CLI PID marker
|
|
662
1674
|
if (cliPidMarker) {
|
|
663
1675
|
try {
|
|
@@ -690,8 +1702,8 @@ function startWebServer(host, preferredPort) {
|
|
|
690
1702
|
if (hasWrite)
|
|
691
1703
|
authedClients.add(ws);
|
|
692
1704
|
log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
|
|
693
|
-
if (isTmuxMode && sessionId) {
|
|
694
|
-
// ── Tmux mode: per-client attach ──
|
|
1705
|
+
if (isTmuxMode && !isPipeMode && sessionId) {
|
|
1706
|
+
// ── Tmux-attach mode: per-client attach ──
|
|
695
1707
|
// Each WS client gets its own `tmux attach-session` PTY.
|
|
696
1708
|
// Scrollback is handled natively by tmux (history-limit).
|
|
697
1709
|
// In adopt mode, attach to the user's original pane; otherwise use bmx-* session.
|
|
@@ -851,11 +1863,11 @@ body{display:flex;flex-direction:column}
|
|
|
851
1863
|
color:#565f89;background:#1a1b26cc;padding:2px 8px;border-radius:4px}
|
|
852
1864
|
#status.ok{color:#9ece6a}
|
|
853
1865
|
#status.err{color:#f7768e}
|
|
854
|
-
#readonly-banner{display:none;position:fixed;top:
|
|
855
|
-
padding:
|
|
856
|
-
background:rgba(247,118,142,0.12);border
|
|
857
|
-
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)
|
|
858
|
-
#readonly-banner.show{display:block}
|
|
1866
|
+
#readonly-banner{display:none;position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:50;
|
|
1867
|
+
padding:4px 10px;font:12px monospace;color:#f7768e;white-space:nowrap;cursor:pointer;
|
|
1868
|
+
background:rgba(247,118,142,0.12);border:1px solid rgba(247,118,142,0.35);border-radius:4px;
|
|
1869
|
+
backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
|
1870
|
+
#readonly-banner.show{display:inline-block}
|
|
859
1871
|
</style>
|
|
860
1872
|
</head>
|
|
861
1873
|
<body>
|
|
@@ -881,7 +1893,7 @@ body{display:flex;flex-direction:column}
|
|
|
881
1893
|
var isTouch='ontouchstart'in window||navigator.maxTouchPoints>0;
|
|
882
1894
|
if(isTouch)document.getElementById('vp').content='width=1100,viewport-fit=cover';
|
|
883
1895
|
var hasToken=${hasWrite};
|
|
884
|
-
if(!hasToken)document.getElementById('readonly-banner').classList.add('show');
|
|
1896
|
+
if(!hasToken){var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
|
|
885
1897
|
|
|
886
1898
|
var term=new Terminal({
|
|
887
1899
|
theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
|
|
@@ -958,7 +1970,7 @@ window.addEventListener('resize',function(){fit.fit();sendResize()});
|
|
|
958
1970
|
})();
|
|
959
1971
|
|
|
960
1972
|
// ── Read-only scroll handling ──
|
|
961
|
-
if(!hasToken&&!${isTmuxMode}){
|
|
1973
|
+
if(!hasToken&&!${isTmuxMode && !isPipeMode}){
|
|
962
1974
|
// Non-tmux read-only: CLI mouse mode blocks local scroll, override with scrollLines
|
|
963
1975
|
document.getElementById('terminal').addEventListener('wheel',function(e){
|
|
964
1976
|
e.preventDefault();term.scrollLines(e.deltaY>0?3:-3);
|
|
@@ -968,7 +1980,7 @@ if(!hasToken&&!${isTmuxMode}){
|
|
|
968
1980
|
// ── Scroll helper (shared by toolbar buttons & two-finger touch) ──
|
|
969
1981
|
function _sendScroll(up,n){
|
|
970
1982
|
n=n||3;
|
|
971
|
-
if(${isTmuxMode}){
|
|
1983
|
+
if(${isTmuxMode && !isPipeMode}){
|
|
972
1984
|
// SGR mouse wheel: 64=up 65=down — tmux enters copy-mode and scrolls
|
|
973
1985
|
var seq='\\x1b[<'+(up?64:65)+';1;1M';
|
|
974
1986
|
for(var i=0;i<n;i++){if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'input',data:seq}))}
|
|
@@ -1057,7 +2069,15 @@ process.on('message', async (raw) => {
|
|
|
1057
2069
|
// Capture credentials for direct image upload from worker
|
|
1058
2070
|
larkAppIdForUpload = msg.larkAppId;
|
|
1059
2071
|
larkAppSecretForUpload = msg.larkAppSecret;
|
|
1060
|
-
|
|
2072
|
+
// Resolve render dimensions BEFORE startScreenUpdates() — the
|
|
2073
|
+
// headless xterm and PNG canvas need to know the source pane size
|
|
2074
|
+
// up-front. Setting them later (after the renderer was built at
|
|
2075
|
+
// 160x50) wouldn't unwrap content xterm has already buffered, so
|
|
2076
|
+
// adopt-mode wide-pane content would still come out stair-stepped.
|
|
2077
|
+
const dims = resolveRenderDimensions(msg);
|
|
2078
|
+
renderCols = dims.cols;
|
|
2079
|
+
renderRows = dims.rows;
|
|
2080
|
+
log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
|
|
1061
2081
|
try {
|
|
1062
2082
|
const port = await startWebServer('0.0.0.0', msg.webPort);
|
|
1063
2083
|
startScreenUpdates();
|
|
@@ -1066,6 +2086,8 @@ process.on('message', async (raw) => {
|
|
|
1066
2086
|
// Queue the initial prompt — flushed when CLI shows idle.
|
|
1067
2087
|
// Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
|
|
1068
2088
|
// prompt into CLI args, so we skip queuing to avoid double-send.
|
|
2089
|
+
// Bridge mark is deferred to flushPending — see flushPending
|
|
2090
|
+
// comment for why marking at enqueue is wrong.
|
|
1069
2091
|
if (msg.prompt && !cliAdapter?.passesInitialPromptViaArgs) {
|
|
1070
2092
|
pendingMessages.push(msg.prompt);
|
|
1071
2093
|
}
|
|
@@ -1085,6 +2107,18 @@ process.on('message', async (raw) => {
|
|
|
1085
2107
|
exitTmuxScrollMode();
|
|
1086
2108
|
const content = msg.content;
|
|
1087
2109
|
if (lastInitConfig?.adoptMode) {
|
|
2110
|
+
// Bridge mode: capture transcript baseline BEFORE writing to the pane,
|
|
2111
|
+
// so any assistant uuids appended after this point are attributed to
|
|
2112
|
+
// *this* Lark turn (not local user activity in the pane). Mark may
|
|
2113
|
+
// return false (baseline not ready) — we still write to the pane;
|
|
2114
|
+
// user just won't get a final_output for this message.
|
|
2115
|
+
if (bridgeJsonlPath) {
|
|
2116
|
+
try {
|
|
2117
|
+
bridgeIngest();
|
|
2118
|
+
}
|
|
2119
|
+
catch { /* best effort */ }
|
|
2120
|
+
bridgeMarkPendingTurn(content);
|
|
2121
|
+
}
|
|
1088
2122
|
// Adopt mode: raw write to PTY (no adapter writeInput)
|
|
1089
2123
|
if (backend) {
|
|
1090
2124
|
if ('sendText' in backend && 'sendSpecialKeys' in backend) {
|
|
@@ -1099,6 +2133,11 @@ process.on('message', async (raw) => {
|
|
|
1099
2133
|
}
|
|
1100
2134
|
}
|
|
1101
2135
|
else {
|
|
2136
|
+
// Non-adopt: enqueue only. Bridge mark is deferred to flushPending
|
|
2137
|
+
// so markTimeMs anchors to the actual PTY-write moment, not IPC
|
|
2138
|
+
// arrival. Marking now would race with a still-running previous
|
|
2139
|
+
// turn whose `botmux send` could sneak its sentAtMs past this
|
|
2140
|
+
// turn's markTimeMs and falsely suppress its fallback.
|
|
1102
2141
|
sendToPty(content);
|
|
1103
2142
|
}
|
|
1104
2143
|
break;
|
|
@@ -1179,6 +2218,11 @@ process.on('message', async (raw) => {
|
|
|
1179
2218
|
// destroySession kills tmux session permanently; kill() only detaches
|
|
1180
2219
|
backend?.destroySession?.();
|
|
1181
2220
|
killCli();
|
|
2221
|
+
// Bridge marker file outlives a single CLI process (we keep it across
|
|
2222
|
+
// restarts so a mid-flight send is still credited), but a real close
|
|
2223
|
+
// tears down the session — purge the file so a future re-use of the
|
|
2224
|
+
// same sessionId starts clean.
|
|
2225
|
+
clearSendMarkers();
|
|
1182
2226
|
cleanup();
|
|
1183
2227
|
process.exit(0);
|
|
1184
2228
|
}
|