@cortexkit/aft-bridge 0.18.5 → 0.19.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/bridge.js ADDED
@@ -0,0 +1,653 @@
1
+ import { spawn } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { error, getLogFilePath, log, sessionWarn, warn } from "./active-logger.js";
5
+ const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
6
+ const SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5_000;
7
+ const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
8
+ // ## Note on TypeScript `as` type assertions
9
+ //
10
+ // Bridge responses use `as string`, `as string[]` etc. in several places.
11
+ // This is intentional: all 16 tool handlers already guard against error
12
+ // responses with `if (response.success === false) throw ...` before accessing
13
+ // typed fields. The remaining `as` casts are on fields from known-success
14
+ // Rust responses where the shape is guaranteed by the protocol contract.
15
+ // Adding Zod runtime validation for every bridge response would add ~2ms
16
+ // per call with no practical safety benefit given the error guards.
17
+ /**
18
+ * Compare two semver version strings (major.minor.patch plus pre-release).
19
+ * Returns: negative if a < b, 0 if equal, positive if a > b.
20
+ */
21
+ export function compareSemver(a, b) {
22
+ const [aMain, aPre] = a.split("-", 2);
23
+ const [bMain, bPre] = b.split("-", 2);
24
+ const aParts = aMain.split(".").map(Number);
25
+ const bParts = bMain.split(".").map(Number);
26
+ for (let i = 0; i < 3; i++) {
27
+ if (aParts[i] !== bParts[i])
28
+ return (aParts[i] ?? 0) - (bParts[i] ?? 0);
29
+ }
30
+ if (!aPre && !bPre)
31
+ return 0;
32
+ if (!aPre)
33
+ return 1;
34
+ if (!bPre)
35
+ return -1;
36
+ const aIds = aPre.split(".");
37
+ const bIds = bPre.split(".");
38
+ for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
39
+ const ai = aIds[i];
40
+ const bi = bIds[i];
41
+ if (ai === undefined)
42
+ return -1;
43
+ if (bi === undefined)
44
+ return 1;
45
+ const aNum = /^\d+$/.test(ai);
46
+ const bNum = /^\d+$/.test(bi);
47
+ if (aNum && bNum) {
48
+ const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
49
+ if (diff !== 0)
50
+ return diff;
51
+ }
52
+ else if (aNum) {
53
+ return -1;
54
+ }
55
+ else if (bNum) {
56
+ return 1;
57
+ }
58
+ else {
59
+ const cmp = ai.localeCompare(bi);
60
+ if (cmp !== 0)
61
+ return cmp;
62
+ }
63
+ }
64
+ return 0;
65
+ }
66
+ function clampSemanticTimeout(configOverrides, bridgeTimeoutMs) {
67
+ const semantic = configOverrides.semantic;
68
+ if (!semantic || typeof semantic !== "object" || Array.isArray(semantic)) {
69
+ return configOverrides;
70
+ }
71
+ const timeoutMs = semantic.timeout_ms;
72
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
73
+ return configOverrides;
74
+ }
75
+ const maxSemanticTimeoutMs = bridgeTimeoutMs > SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS
76
+ ? bridgeTimeoutMs - SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS
77
+ : Math.max(1, bridgeTimeoutMs - 1);
78
+ if (timeoutMs <= maxSemanticTimeoutMs) {
79
+ return configOverrides;
80
+ }
81
+ warn(`semantic.timeout_ms=${timeoutMs} exceeds bridge timeout budget; clamping to ${maxSemanticTimeoutMs}ms (bridge timeout: ${bridgeTimeoutMs}ms)`);
82
+ return {
83
+ ...configOverrides,
84
+ semantic: {
85
+ ...semantic,
86
+ timeout_ms: maxSemanticTimeoutMs,
87
+ },
88
+ };
89
+ }
90
+ /**
91
+ * Manages a persistent `aft` child process, communicating via NDJSON over
92
+ * stdin/stdout. Lazy-spawns on first `send()` call. Handles crash detection
93
+ * with exponential backoff auto-restart.
94
+ */
95
+ export class BinaryBridge {
96
+ static RESTART_RESET_MS = 5 * 60 * 1000;
97
+ /** How many recent stderr lines to keep for crash diagnostics. */
98
+ static STDERR_TAIL_MAX = 20;
99
+ binaryPath;
100
+ cwd;
101
+ process = null;
102
+ pending = new Map();
103
+ nextId = 1;
104
+ stdoutBuffer = "";
105
+ /** Ring buffer of the last N stderr lines, cleared on every spawn. */
106
+ stderrTail = [];
107
+ _restartCount = 0;
108
+ _shuttingDown = false;
109
+ timeoutMs;
110
+ maxRestarts;
111
+ configured = false;
112
+ _configurePromise = null;
113
+ configOverrides;
114
+ minVersion;
115
+ onVersionMismatch;
116
+ onConfigureWarnings;
117
+ onBashCompletion;
118
+ /** Notification clients keyed by session_id for async configure warning pushes. */
119
+ configureWarningClients = new Map();
120
+ restartResetTimer = null;
121
+ errorPrefix;
122
+ constructor(binaryPath, cwd, options, configOverrides) {
123
+ this.binaryPath = binaryPath;
124
+ this.cwd = cwd;
125
+ this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
126
+ this.maxRestarts = options?.maxRestarts ?? 3;
127
+ this.configOverrides = clampSemanticTimeout(configOverrides ?? {}, this.timeoutMs);
128
+ this.minVersion = options?.minVersion;
129
+ this.onVersionMismatch = options?.onVersionMismatch;
130
+ this.onConfigureWarnings = options?.onConfigureWarnings;
131
+ this.onBashCompletion = options?.onBashCompletion;
132
+ this.errorPrefix = options?.errorPrefix ?? "[aft-bridge]";
133
+ }
134
+ /** Number of times the binary has been restarted after a crash. */
135
+ get restartCount() {
136
+ return this._restartCount;
137
+ }
138
+ /** Whether the child process is currently alive. */
139
+ isAlive() {
140
+ return this.process !== null && this.process.exitCode === null && !this.process.killed;
141
+ }
142
+ hasPendingRequests() {
143
+ return this.pending.size > 0;
144
+ }
145
+ /**
146
+ * Send a command to the binary and return the parsed response.
147
+ * Lazy-spawns the binary on first call.
148
+ */
149
+ async send(command, params = {}, options) {
150
+ if (this._shuttingDown) {
151
+ throw new Error(`${this.errorPrefix} Bridge is shutting down, cannot send "${command}"`);
152
+ }
153
+ if (Object.hasOwn(params, "id")) {
154
+ throw new Error("params cannot contain reserved key 'id'");
155
+ }
156
+ this.ensureSpawned();
157
+ // Capture session_id early so auto-configure can reuse the initiating
158
+ // session's notification client when the deferred configure warning frame
159
+ // arrives later. One project bridge can serve many sessions, so keep this
160
+ // per-session instead of one bridge-wide "last client".
161
+ const requestSessionId = typeof params.session_id === "string" && params.session_id.length > 0
162
+ ? params.session_id
163
+ : undefined;
164
+ if (requestSessionId && options?.configureWarningClient !== undefined) {
165
+ this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
166
+ }
167
+ // Auto-configure project root + plugin config on first command, then check version.
168
+ // configured is set AFTER success to prevent skipping configuration on failure (#18).
169
+ // When multiple parallel calls arrive before configure completes, they all await
170
+ // the same promise instead of each independently trying to configure.
171
+ if (!this.configured) {
172
+ if (command !== "configure" && command !== "version") {
173
+ if (!this._configurePromise) {
174
+ // First caller — create the configure promise.
175
+ // All parallel callers await this same promise.
176
+ //
177
+ // Forward the triggering call's session_id into configure so
178
+ // Rust's thread-local session context propagates through to
179
+ // background tasks spawned by configure (search-index pre-warm,
180
+ // semantic-index build). Without this, background log lines
181
+ // emitted by configure threads appear with no session prefix.
182
+ const sessionIdForConfigure = typeof params.session_id === "string" ? params.session_id : undefined;
183
+ this._configurePromise = (async () => {
184
+ try {
185
+ const configResult = await this.send("configure", {
186
+ project_root: this.cwd,
187
+ ...this.configOverrides,
188
+ ...(sessionIdForConfigure ? { session_id: sessionIdForConfigure } : {}),
189
+ });
190
+ if (configResult.success === false) {
191
+ throw new Error(`${this.errorPrefix} Configure failed: ${configResult.message ?? "unknown error"}`);
192
+ }
193
+ // Large-repo warning is emitted by the Rust side via log::warn!
194
+ // and relayed through stderr → plugin log. No need to re-log here
195
+ // (doing so would just duplicate the same line in aft-plugin.log).
196
+ await this.deliverConfigureWarnings(configResult, params, options);
197
+ await this.checkVersion();
198
+ // Re-check liveness after version check — checkVersion() swallows
199
+ // errors as best-effort, so the bridge may have died without throwing.
200
+ if (!this.isAlive()) {
201
+ throw new Error(`${this.errorPrefix} Bridge died during version check. Check logs: ${getLogFilePath()}`);
202
+ }
203
+ this.configured = true;
204
+ }
205
+ finally {
206
+ this._configurePromise = null;
207
+ }
208
+ })();
209
+ }
210
+ // All callers (including the first) await the shared promise
211
+ await this._configurePromise;
212
+ }
213
+ }
214
+ const id = String(this.nextId++);
215
+ // Wire format: when params contains a key that collides with the protocol
216
+ // envelope (`command`/`method`), nest params under a `params` key so the
217
+ // outer dispatch dispatches on `command: "<bridge command>"` rather than
218
+ // the user's payload key. Reserved envelope fields (`session_id`,
219
+ // `lsp_hints`) must STILL be promoted to the top level so RawRequest's
220
+ // dedicated fields deserialize correctly. Without this promotion, e.g.
221
+ // `bash` (whose params include `command: "<shell command>"`) silently
222
+ // loses `session_id` because it stays nested inside `params`.
223
+ let request;
224
+ if (Object.hasOwn(params, "command") || Object.hasOwn(params, "method")) {
225
+ const nested = { ...params };
226
+ const reserved = {};
227
+ for (const key of ["session_id", "lsp_hints"]) {
228
+ if (Object.hasOwn(nested, key)) {
229
+ reserved[key] = nested[key];
230
+ delete nested[key];
231
+ }
232
+ }
233
+ request = { id, command, ...reserved, params: nested };
234
+ }
235
+ else {
236
+ request = { id, command, ...params };
237
+ }
238
+ const line = `${JSON.stringify(request)}\n`;
239
+ // Per-op timeout override: tool wrappers can pass longer budgets for
240
+ // commands that legitimately need them (callers, trace_to, grep on big
241
+ // repos). Defaults to the bridge-wide timeout otherwise.
242
+ const effectiveTimeoutMs = options?.transportTimeoutMs ?? options?.timeoutMs ?? this.timeoutMs;
243
+ const keepBridgeOnTimeout = options?.keepBridgeOnTimeout === true;
244
+ return new Promise((resolve, reject) => {
245
+ const timer = setTimeout(() => {
246
+ this.pending.delete(id);
247
+ const restartSuffix = keepBridgeOnTimeout ? "" : " — restarting bridge";
248
+ const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
249
+ if (requestSessionId) {
250
+ sessionWarn(requestSessionId, timeoutMsg);
251
+ }
252
+ else {
253
+ warn(timeoutMsg);
254
+ }
255
+ reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
256
+ // Kill the hung process so the next request gets a fresh bridge —
257
+ // unless the caller explicitly opted out (e.g. bash, which enforces
258
+ // its own timeout on the Rust side and shouldn't lose warm bridge
259
+ // state when its response is merely late).
260
+ if (!keepBridgeOnTimeout) {
261
+ this.handleTimeout();
262
+ }
263
+ }, effectiveTimeoutMs);
264
+ this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
265
+ if (!this.process?.stdin?.writable) {
266
+ this.pending.delete(id);
267
+ clearTimeout(timer);
268
+ reject(new Error(`${this.errorPrefix} stdin not writable for command "${command}"`));
269
+ return;
270
+ }
271
+ this.process.stdin.write(line, (err) => {
272
+ if (err) {
273
+ const entry = this.pending.get(id);
274
+ if (entry) {
275
+ this.pending.delete(id);
276
+ clearTimeout(entry.timer);
277
+ entry.reject(new Error(`${this.errorPrefix} Failed to write to stdin: ${err.message}`));
278
+ }
279
+ }
280
+ });
281
+ });
282
+ }
283
+ async deliverConfigureWarnings(configResult, params, options) {
284
+ if (!this.onConfigureWarnings || !Array.isArray(configResult.warnings))
285
+ return;
286
+ if (configResult.warnings.length === 0)
287
+ return;
288
+ try {
289
+ const sessionId = typeof params.session_id === "string" ? params.session_id : undefined;
290
+ await this.onConfigureWarnings({
291
+ projectRoot: this.cwd,
292
+ sessionId,
293
+ client: options?.configureWarningClient ??
294
+ (sessionId ? this.configureWarningClients.get(sessionId) : undefined),
295
+ warnings: configResult.warnings,
296
+ });
297
+ }
298
+ catch (err) {
299
+ warn(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
300
+ }
301
+ }
302
+ /**
303
+ * Handle the `configure_warnings` push frame the Rust binary emits after
304
+ * configure has returned. The frame carries the warnings produced by the
305
+ * deferred file walk + missing-binary detection. Forwards to the same
306
+ * `onConfigureWarnings` handler used for synchronous warnings so plugins
307
+ * don't need to know about the async path.
308
+ */
309
+ async handleConfigureWarningsFrame(frame) {
310
+ if (!this.onConfigureWarnings)
311
+ return;
312
+ const warnings = frame.warnings;
313
+ if (!Array.isArray(warnings) || warnings.length === 0)
314
+ return;
315
+ const projectRoot = typeof frame.project_root === "string" ? frame.project_root : this.cwd;
316
+ const rawSessionId = frame.session_id;
317
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : null;
318
+ await this.onConfigureWarnings({
319
+ projectRoot,
320
+ sessionId,
321
+ client: sessionId ? this.configureWarningClients.get(sessionId) : undefined,
322
+ warnings: warnings,
323
+ });
324
+ }
325
+ /** Kill the child process and reject all pending requests. */
326
+ async shutdown() {
327
+ this._shuttingDown = true;
328
+ this.clearRestartResetTimer();
329
+ this.rejectAllPending(new Error(`${this.errorPrefix} Bridge shutting down`));
330
+ if (this.process) {
331
+ const proc = this.process;
332
+ this.process = null;
333
+ return new Promise((resolve) => {
334
+ const forceKillTimer = setTimeout(() => {
335
+ proc.kill("SIGKILL");
336
+ resolve();
337
+ }, 5_000);
338
+ proc.once("exit", () => {
339
+ clearTimeout(forceKillTimer);
340
+ log("Process exited during shutdown");
341
+ resolve();
342
+ });
343
+ proc.kill("SIGTERM");
344
+ });
345
+ }
346
+ }
347
+ // ---- Internal ----
348
+ /** Query binary version and compare against minVersion. Calls onVersionMismatch if outdated. */
349
+ async checkVersion() {
350
+ if (!this.minVersion)
351
+ return;
352
+ try {
353
+ const resp = await this.send("version");
354
+ const binaryVersion = resp.version;
355
+ if (!binaryVersion) {
356
+ log("Binary did not report a version — skipping version check");
357
+ return;
358
+ }
359
+ log(`Binary version: ${binaryVersion}`);
360
+ if (compareSemver(binaryVersion, this.minVersion) < 0) {
361
+ warn(`Binary version ${binaryVersion} is older than required ${this.minVersion}`);
362
+ this.onVersionMismatch?.(binaryVersion, this.minVersion);
363
+ }
364
+ }
365
+ catch (err) {
366
+ // Version check is best-effort — don't block tool usage if it fails
367
+ warn(`Version check failed: ${err.message}`);
368
+ }
369
+ }
370
+ ensureSpawned() {
371
+ if (this.isAlive())
372
+ return;
373
+ this.spawnProcess();
374
+ }
375
+ spawnProcess() {
376
+ log(`Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
377
+ const semantic = this.configOverrides.semantic;
378
+ const semanticBackend = (() => {
379
+ if (semantic && typeof semantic === "object" && !Array.isArray(semantic)) {
380
+ const candidate = semantic.backend;
381
+ return typeof candidate === "string" ? candidate : undefined;
382
+ }
383
+ return undefined;
384
+ })();
385
+ const useFastembedBackend = semanticBackend === undefined || semanticBackend === "fastembed" || semanticBackend === "";
386
+ const ortDir = typeof this.configOverrides._ort_dylib_dir === "string" && useFastembedBackend
387
+ ? this.configOverrides._ort_dylib_dir
388
+ : null;
389
+ const ortLibraryPath = ortDir == null
390
+ ? null
391
+ : join(ortDir, process.platform === "win32"
392
+ ? "onnxruntime.dll"
393
+ : process.platform === "darwin"
394
+ ? "libonnxruntime.dylib"
395
+ : "libonnxruntime.so");
396
+ const envPath = process.platform === "win32" && ortDir
397
+ ? `${ortDir};${process.env.PATH ?? ""}`
398
+ : process.env.PATH;
399
+ const env = {
400
+ ...process.env,
401
+ ...(envPath ? { PATH: envPath } : {}),
402
+ };
403
+ if (useFastembedBackend) {
404
+ // Store fastembed model files alongside the semantic index, not the project cwd.
405
+ // This is only relevant when the fastembed backend is selected.
406
+ env.FASTEMBED_CACHE_DIR =
407
+ process.env.FASTEMBED_CACHE_DIR ||
408
+ (typeof this.configOverrides.storage_dir === "string"
409
+ ? join(this.configOverrides.storage_dir, "semantic", "models")
410
+ : join(homedir() || "", ".cache", "fastembed"));
411
+ // Point ort to the auto-downloaded or system ONNX Runtime library.
412
+ if (ortLibraryPath) {
413
+ env.ORT_DYLIB_PATH = ortLibraryPath;
414
+ }
415
+ }
416
+ const child = spawn(this.binaryPath, [], {
417
+ cwd: this.cwd,
418
+ stdio: ["pipe", "pipe", "pipe"],
419
+ env,
420
+ });
421
+ const currentChild = child;
422
+ child.stdout?.on("data", (chunk) => {
423
+ this.onStdoutData(chunk.toString("utf-8"));
424
+ });
425
+ child.stderr?.on("data", (chunk) => {
426
+ const lines = chunk.toString("utf-8").trimEnd().split("\n");
427
+ for (const line of lines) {
428
+ if (!line)
429
+ continue;
430
+ // Strip Rust env_logger prefix and re-tag under [aft]
431
+ const stripped = line.replace(/^\[aft\]\s*/, "");
432
+ log(`[aft] ${stripped}`);
433
+ this.pushStderrLine(stripped);
434
+ }
435
+ });
436
+ child.on("error", (err) => {
437
+ if (this.process !== currentChild)
438
+ return;
439
+ error(`Process error: ${err.message}${this.formatStderrTail()}`);
440
+ this.handleCrash();
441
+ });
442
+ child.on("exit", (code, signal) => {
443
+ if (this.process !== currentChild)
444
+ return;
445
+ if (this._shuttingDown)
446
+ return;
447
+ log(`Process exited: code=${code}, signal=${signal}`);
448
+ // External termination signals (SIGTERM/SIGKILL/SIGHUP/SIGINT) are almost
449
+ // always intentional kills — from our own shutdown path, OpenCode tearing
450
+ // down, OS shutdown, or the user killing the host. Auto-restarting here
451
+ // produces process avalanches (issue #14): N bridges all receive SIGTERM
452
+ // simultaneously, each "auto-restarts", spawning N fresh processes that
453
+ // reload ONNX + semantic + trigram indexes. Real Rust panics/crashes exit
454
+ // with a non-null `code` and `signal === null`; those still restart.
455
+ if (signal === "SIGTERM" ||
456
+ signal === "SIGKILL" ||
457
+ signal === "SIGHUP" ||
458
+ signal === "SIGINT") {
459
+ this.process = null;
460
+ this.configured = false;
461
+ this.clearRestartResetTimer();
462
+ this.rejectAllPending(new Error(`${this.errorPrefix} Binary killed by ${signal}`));
463
+ return;
464
+ }
465
+ this.handleCrash();
466
+ });
467
+ this.process = child;
468
+ this.stdoutBuffer = "";
469
+ // Fresh spawn — clear the stderr ring so crash diagnostics only reflect
470
+ // the current child's output, not output from prior restart cycles.
471
+ this.stderrTail = [];
472
+ }
473
+ pushStderrLine(line) {
474
+ this.stderrTail.push(line);
475
+ if (this.stderrTail.length > BinaryBridge.STDERR_TAIL_MAX) {
476
+ this.stderrTail.shift();
477
+ }
478
+ }
479
+ /**
480
+ * Format the current stderr tail for inclusion in error messages. Returns
481
+ * empty string when nothing has been captured (e.g., silent SIGKILL from
482
+ * macOS amfid) so the caller can safely concatenate unconditionally.
483
+ */
484
+ formatStderrTail() {
485
+ if (this.stderrTail.length === 0)
486
+ return "";
487
+ const tail = this.stderrTail.join("\n ");
488
+ return `\n --- last ${this.stderrTail.length} stderr lines ---\n ${tail}`;
489
+ }
490
+ onStdoutData(data) {
491
+ this.stdoutBuffer += data;
492
+ if (this.stdoutBuffer.length > MAX_STDOUT_BUFFER) {
493
+ this.handleCrash(new Error(`aft bridge stdout buffer exceeded ${MAX_STDOUT_BUFFER} bytes — killing bridge`));
494
+ return;
495
+ }
496
+ // Process complete lines
497
+ let newlineIdx;
498
+ while ((newlineIdx = this.stdoutBuffer.indexOf("\n")) !== -1) {
499
+ const line = this.stdoutBuffer.slice(0, newlineIdx).trim();
500
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIdx + 1);
501
+ if (!line)
502
+ continue;
503
+ try {
504
+ const response = JSON.parse(line);
505
+ if (response.type === "progress") {
506
+ const requestId = response.request_id;
507
+ const entry = requestId ? this.pending.get(requestId) : undefined;
508
+ const kind = response.kind === "stderr" ? "stderr" : "stdout";
509
+ const text = typeof response.chunk === "string" ? response.chunk : "";
510
+ entry?.onProgress?.({ kind, text });
511
+ continue;
512
+ }
513
+ if (response.type === "permission_ask") {
514
+ const requestId = response.request_id;
515
+ const entry = requestId ? this.pending.get(requestId) : undefined;
516
+ if (requestId && entry) {
517
+ this.pending.delete(requestId);
518
+ clearTimeout(entry.timer);
519
+ entry.resolve({
520
+ success: false,
521
+ code: "permission_required",
522
+ message: "bash command requires permission",
523
+ asks: response.asks,
524
+ });
525
+ }
526
+ continue;
527
+ }
528
+ if (response.type === "bash_completed") {
529
+ this.onBashCompletion?.(response, this);
530
+ continue;
531
+ }
532
+ if (response.type === "configure_warnings") {
533
+ this.handleConfigureWarningsFrame(response).catch((err) => {
534
+ warn(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
535
+ });
536
+ continue;
537
+ }
538
+ const id = response.id;
539
+ if (id && this.pending.has(id)) {
540
+ const entry = this.pending.get(id);
541
+ if (!entry)
542
+ continue;
543
+ this.pending.delete(id);
544
+ clearTimeout(entry.timer);
545
+ this.scheduleRestartCountReset();
546
+ entry.resolve(response);
547
+ }
548
+ else if (typeof response.type === "string") {
549
+ log(`Ignoring unknown stdout push frame type: ${response.type}`);
550
+ }
551
+ }
552
+ catch (_err) {
553
+ warn(`Failed to parse stdout line: ${line}`);
554
+ }
555
+ }
556
+ }
557
+ handleTimeout() {
558
+ // A single request timed out. Kill the hung process so the bridge can
559
+ // respawn on the next call — but do NOT reject other pending requests
560
+ // here (#21). Each pending request has its own timer and will reject
561
+ // itself if it also times out. Proactively rejecting peers destroys work
562
+ // that may have been perfectly healthy (e.g. a `read` call waiting behind
563
+ // a slow `bash` command).
564
+ //
565
+ // When the process dies, its stdout closes and the crash handler fires,
566
+ // which will reject any remaining pending requests through the normal path.
567
+ if (this.process) {
568
+ this.process.kill("SIGKILL");
569
+ this.process = null;
570
+ }
571
+ this.clearRestartResetTimer();
572
+ this.configured = false;
573
+ // Capture the stderr tail for diagnostics. The tail goes to the plugin
574
+ // log only — it's operator-facing noise (loaded N backups, invalidated K
575
+ // files, etc.) that the agent can't act on, so we don't put it in the
576
+ // rejection error. Clear the ring so the next spawn doesn't inherit it.
577
+ const tail = this.formatStderrTail();
578
+ this.stderrTail = [];
579
+ if (tail) {
580
+ error(`Bridge killed after timeout.${tail}`);
581
+ }
582
+ else {
583
+ warn(`Bridge killed after timeout (see ${getLogFilePath()})`);
584
+ }
585
+ // Peer requests are NOT rejected here. They will either:
586
+ // 1. Resolve if the binary somehow still delivers their response (unlikely
587
+ // after SIGKILL, but harmless to leave pending briefly), or
588
+ // 2. Reject through their own timers when they individually expire, or
589
+ // 3. Reject immediately through handleCrash() when the stdout pipe closes.
590
+ }
591
+ handleCrash(cause) {
592
+ const proc = this.process;
593
+ this.process = null;
594
+ if (proc && proc.exitCode === null && !proc.killed) {
595
+ proc.kill("SIGKILL");
596
+ }
597
+ this.clearRestartResetTimer();
598
+ this.configured = false; // Force reconfigure on next command after restart
599
+ // Capture the tail BEFORE spawning the replacement, because the next spawn
600
+ // clears the ring. The tail goes to the plugin log only — it's operator
601
+ // diagnostic output that the agent can't act on. The pending-request
602
+ // rejection only carries a pointer to the log.
603
+ const tail = this.formatStderrTail();
604
+ if (tail) {
605
+ error(`Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""}.${tail}`);
606
+ }
607
+ this.rejectAllPending(new Error(`${this.errorPrefix} Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""} (see ${getLogFilePath()})`));
608
+ // Auto-restart with exponential backoff
609
+ if (this._restartCount < this.maxRestarts) {
610
+ const delay = 100 * 2 ** this._restartCount; // 100ms, 200ms, 400ms
611
+ this._restartCount++;
612
+ log(`Auto-restart #${this._restartCount} in ${delay}ms`);
613
+ setTimeout(() => {
614
+ if (!this._shuttingDown && !this.isAlive()) {
615
+ try {
616
+ this.spawnProcess();
617
+ }
618
+ catch (err) {
619
+ error(`Failed to restart: ${err.message}`);
620
+ }
621
+ }
622
+ }, delay);
623
+ // Also decay the counter over time so repeated crashes without any
624
+ // successful response don't permanently wedge the bridge.
625
+ this.scheduleRestartCountReset();
626
+ }
627
+ else {
628
+ error(`Max restarts (${this.maxRestarts}) reached, giving up. Logs: ${getLogFilePath()}${tail}`);
629
+ this.scheduleRestartCountReset();
630
+ }
631
+ }
632
+ rejectAllPending(error) {
633
+ for (const [_id, entry] of this.pending) {
634
+ clearTimeout(entry.timer);
635
+ entry.reject(error);
636
+ }
637
+ this.pending.clear();
638
+ }
639
+ scheduleRestartCountReset() {
640
+ this.clearRestartResetTimer();
641
+ this.restartResetTimer = setTimeout(() => {
642
+ this._restartCount = 0;
643
+ this.restartResetTimer = null;
644
+ }, BinaryBridge.RESTART_RESET_MS);
645
+ }
646
+ clearRestartResetTimer() {
647
+ if (this.restartResetTimer) {
648
+ clearTimeout(this.restartResetTimer);
649
+ this.restartResetTimer = null;
650
+ }
651
+ }
652
+ }
653
+ //# sourceMappingURL=bridge.js.map