@cortexkit/aft-bridge 0.18.5 → 0.19.1

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