@cortexkit/aft-bridge 0.25.2 → 0.26.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 CHANGED
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
- import { error, getActiveLogger, getLogFilePath, log, sessionError, sessionLog, sessionWarn, warn, } from "./active-logger.js";
5
+ import { error, getActiveLogger, getLogFilePath, log, warn } from "./active-logger.js";
6
6
  const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
7
7
  const SEMANTIC_TIMEOUT_SAFETY_MARGIN_MS = 5_000;
8
8
  const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
@@ -107,6 +107,14 @@ function clampSemanticTimeout(configOverrides, bridgeTimeoutMs) {
107
107
  },
108
108
  };
109
109
  }
110
+ class BridgeReplacedDuringVersionCheck extends Error {
111
+ newBinaryPath;
112
+ constructor(newBinaryPath) {
113
+ super(`Bridge binary replaced during version check: ${newBinaryPath}`);
114
+ this.newBinaryPath = newBinaryPath;
115
+ this.name = "BridgeReplacedDuringVersionCheck";
116
+ }
117
+ }
110
118
  /**
111
119
  * Manages a persistent `aft` child process, communicating via NDJSON over
112
120
  * stdin/stdout. Lazy-spawns on first `send()` call. Handles crash detection
@@ -122,6 +130,7 @@ export class BinaryBridge {
122
130
  pending = new Map();
123
131
  nextId = 1;
124
132
  stdoutBuffer = "";
133
+ stderrBuffer = "";
125
134
  /** Ring buffer of the last N stderr lines, cleared on every spawn. */
126
135
  stderrTail = [];
127
136
  _restartCount = 0;
@@ -157,44 +166,71 @@ export class BinaryBridge {
157
166
  this.errorPrefix = options?.errorPrefix ?? "[aft-bridge]";
158
167
  this.logger = options?.logger;
159
168
  }
160
- /**
161
- * Internal log helpers that prefer the constructor-provided logger over the
162
- * active-logger singleton. Mirrors the same pattern used by `BridgePool`
163
- * (Oracle F9 — D2 deferral for BinaryBridge). These are intentionally
164
- * unused at the call sites today: migrating every existing `log()`/`warn()`/
165
- * `error()`/`sessionLog()` call site in this file to `this.logVia()` etc. is
166
- * a separate mechanical refactor we defer until we have a concrete use case
167
- * for per-bridge log routing. Until then, the existing module-level helpers
168
- * fall through to the singleton, which is the same default behavior the
169
- * `this.logVia` helpers provide when `this.logger` is `undefined`.
170
- *
171
- * The constructor `logger` option + the test in `bridge-transport.test.ts`
172
- * are what verify the F9 wiring. Once a future change starts using these
173
- * helpers for any new logging, the biome unused-private warnings disappear.
174
- */
175
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: F9 plumbing kept for future call-site migration
176
169
  logVia(message, meta) {
177
170
  const logger = this.logger ?? getActiveLogger();
178
- if (logger)
179
- logger.log(message, meta);
180
- else
171
+ if (logger) {
172
+ try {
173
+ logger.log(message, meta);
174
+ }
175
+ catch (err) {
176
+ console.error(`[aft-bridge] ERROR: logger log threw: ${err instanceof Error ? err.message : String(err)}`);
177
+ console.error(`[aft-bridge] ${message}`);
178
+ }
179
+ }
180
+ else {
181
181
  log(message, meta);
182
+ }
182
183
  }
183
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: F9 plumbing kept for future call-site migration
184
184
  warnVia(message, meta) {
185
185
  const logger = this.logger ?? getActiveLogger();
186
- if (logger)
187
- logger.warn(message, meta);
188
- else
186
+ if (logger) {
187
+ try {
188
+ logger.warn(message, meta);
189
+ }
190
+ catch (err) {
191
+ console.error(`[aft-bridge] ERROR: logger warn threw: ${err instanceof Error ? err.message : String(err)}`);
192
+ console.error(`[aft-bridge] WARN: ${message}`);
193
+ }
194
+ }
195
+ else {
189
196
  warn(message, meta);
197
+ }
190
198
  }
191
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: F9 plumbing kept for future call-site migration
192
199
  errorVia(message, meta) {
193
200
  const logger = this.logger ?? getActiveLogger();
194
- if (logger)
195
- logger.error(message, meta);
196
- else
201
+ if (logger) {
202
+ try {
203
+ logger.error(message, meta);
204
+ }
205
+ catch (err) {
206
+ console.error(`[aft-bridge] ERROR: logger error threw: ${err instanceof Error ? err.message : String(err)}`);
207
+ console.error(`[aft-bridge] ERROR: ${message}`);
208
+ }
209
+ }
210
+ else {
197
211
  error(message, meta);
212
+ }
213
+ }
214
+ getLogFilePathVia() {
215
+ if (this.logger?.getLogFilePath) {
216
+ try {
217
+ return this.logger.getLogFilePath();
218
+ }
219
+ catch (err) {
220
+ console.error(`[aft-bridge] ERROR: logger getLogFilePath threw: ${err instanceof Error ? err.message : String(err)}`);
221
+ return undefined;
222
+ }
223
+ }
224
+ return getLogFilePath();
225
+ }
226
+ sessionLogVia(sessionId, message) {
227
+ this.logVia(message, sessionId ? { sessionId } : undefined);
228
+ }
229
+ sessionWarnVia(sessionId, message) {
230
+ this.warnVia(message, sessionId ? { sessionId } : undefined);
231
+ }
232
+ sessionErrorVia(sessionId, message) {
233
+ this.errorVia(message, sessionId ? { sessionId } : undefined);
198
234
  }
199
235
  /** Number of times the binary has been restarted after a crash. */
200
236
  get restartCount() {
@@ -234,142 +270,157 @@ export class BinaryBridge {
234
270
  * Lazy-spawns the binary on first call.
235
271
  */
236
272
  async send(command, params = {}, options) {
237
- if (this._shuttingDown) {
238
- throw new Error(`${this.errorPrefix} Bridge is shutting down, cannot send "${command}"`);
239
- }
240
- if (Object.hasOwn(params, "id")) {
241
- throw new Error("params cannot contain reserved key 'id'");
242
- }
243
- // Capture session_id BEFORE ensureSpawned so the spawn-time log line gets
244
- // tagged with the triggering session. Bridges are project-keyed and serve
245
- // many sessions over their lifetime, but the spawn itself is attributable
246
- // to whichever session's tool call triggered it.
247
- const requestSessionId = typeof params.session_id === "string" && params.session_id.length > 0
248
- ? params.session_id
249
- : undefined;
250
- this.ensureSpawned(requestSessionId);
251
- // Auto-configure can reuse the initiating session's notification client
252
- // when the deferred configure warning frame arrives later. One project
253
- // bridge can serve many sessions, so keep this per-session instead of one
254
- // bridge-wide "last client".
255
- if (requestSessionId && options?.configureWarningClient !== undefined) {
256
- this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
257
- }
258
- // Auto-configure project root + plugin config on first command, then check version.
259
- // configured is set AFTER success to prevent skipping configuration on failure (#18).
260
- // When multiple parallel calls arrive before configure completes, they all await
261
- // the same promise instead of each independently trying to configure.
262
- if (!this.configured) {
263
- if (command !== "configure" && command !== "version") {
264
- if (!this._configurePromise) {
265
- // First caller create the configure promise.
266
- // All parallel callers await this same promise.
267
- //
268
- // Forward the triggering call's session_id into configure so
269
- // Rust's thread-local session context propagates through to
270
- // background tasks spawned by configure (search-index pre-warm,
271
- // semantic-index build). Without this, background log lines
272
- // emitted by configure threads appear with no session prefix.
273
- const sessionIdForConfigure = typeof params.session_id === "string" ? params.session_id : undefined;
274
- this._configurePromise = (async () => {
275
- try {
276
- const configResult = await this.send("configure", {
277
- project_root: this.cwd,
278
- ...this.configOverrides,
279
- ...(sessionIdForConfigure ? { session_id: sessionIdForConfigure } : {}),
280
- });
281
- if (configResult.success === false) {
282
- throw new Error(`${this.errorPrefix} Configure failed: ${configResult.message ?? "unknown error"}`);
273
+ return this.sendWithVersionMismatchRetry(command, params, options, true);
274
+ }
275
+ async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap) {
276
+ try {
277
+ if (this._shuttingDown) {
278
+ throw new Error(`${this.errorPrefix} Bridge is shutting down, cannot send "${command}"`);
279
+ }
280
+ if (Object.hasOwn(params, "id")) {
281
+ throw new Error("params cannot contain reserved key 'id'");
282
+ }
283
+ // Capture session_id BEFORE ensureSpawned so the spawn-time log line gets
284
+ // tagged with the triggering session. Bridges are project-keyed and serve
285
+ // many sessions over their lifetime, but the spawn itself is attributable
286
+ // to whichever session's tool call triggered it.
287
+ const requestSessionId = typeof params.session_id === "string" && params.session_id.length > 0
288
+ ? params.session_id
289
+ : undefined;
290
+ this.ensureSpawned(requestSessionId);
291
+ // Auto-configure can reuse the initiating session's notification client
292
+ // when the deferred configure warning frame arrives later. One project
293
+ // bridge can serve many sessions, so keep this per-session instead of one
294
+ // bridge-wide "last client".
295
+ if (requestSessionId && options?.configureWarningClient !== undefined) {
296
+ this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
297
+ }
298
+ // Auto-configure project root + plugin config on first command, then check version.
299
+ // configured is set AFTER success to prevent skipping configuration on failure (#18).
300
+ // When multiple parallel calls arrive before configure completes, they all await
301
+ // the same promise instead of each independently trying to configure.
302
+ if (!this.configured) {
303
+ if (command !== "configure" && command !== "version") {
304
+ if (!this._configurePromise) {
305
+ // First caller create the configure promise.
306
+ // All parallel callers await this same promise.
307
+ //
308
+ // Forward the triggering call's session_id into configure so
309
+ // Rust's thread-local session context propagates through to
310
+ // background tasks spawned by configure (search-index pre-warm,
311
+ // semantic-index build). Without this, background log lines
312
+ // emitted by configure threads appear with no session prefix.
313
+ const sessionIdForConfigure = typeof params.session_id === "string" ? params.session_id : undefined;
314
+ this._configurePromise = (async () => {
315
+ try {
316
+ const configResult = await this.send("configure", {
317
+ project_root: this.cwd,
318
+ ...this.configOverrides,
319
+ ...(sessionIdForConfigure ? { session_id: sessionIdForConfigure } : {}),
320
+ });
321
+ if (configResult.success === false) {
322
+ throw new Error(`${this.errorPrefix} Configure failed: ${configResult.message ?? "unknown error"}`);
323
+ }
324
+ // Large-repo warning is emitted by the Rust side via log::warn!
325
+ // and relayed through stderr → plugin log. No need to re-log here
326
+ // (doing so would just duplicate the same line in aft-plugin.log).
327
+ await this.deliverConfigureWarnings(configResult, params, options);
328
+ await this.checkVersion();
329
+ // Re-check liveness after version check — checkVersion() swallows
330
+ // errors as best-effort, so the bridge may have died without throwing.
331
+ if (!this.isAlive()) {
332
+ throw new Error(`${this.errorPrefix} Bridge died during version check. Check logs: ${this.getLogFilePathVia()}`);
333
+ }
334
+ this.configured = true;
283
335
  }
284
- // Large-repo warning is emitted by the Rust side via log::warn!
285
- // and relayed through stderr → plugin log. No need to re-log here
286
- // (doing so would just duplicate the same line in aft-plugin.log).
287
- await this.deliverConfigureWarnings(configResult, params, options);
288
- await this.checkVersion();
289
- // Re-check liveness after version check — checkVersion() swallows
290
- // errors as best-effort, so the bridge may have died without throwing.
291
- if (!this.isAlive()) {
292
- throw new Error(`${this.errorPrefix} Bridge died during version check. Check logs: ${getLogFilePath()}`);
336
+ finally {
337
+ this._configurePromise = null;
293
338
  }
294
- this.configured = true;
295
- }
296
- finally {
297
- this._configurePromise = null;
298
- }
299
- })();
339
+ })();
340
+ }
341
+ // All callers (including the first) await the shared promise
342
+ await this._configurePromise;
300
343
  }
301
- // All callers (including the first) await the shared promise
302
- await this._configurePromise;
303
344
  }
304
- }
305
- const id = String(this.nextId++);
306
- // Wire format: when params contains a key that collides with the protocol
307
- // envelope (`command`/`method`), nest params under a `params` key so the
308
- // outer dispatch dispatches on `command: "<bridge command>"` rather than
309
- // the user's payload key. Reserved envelope fields (`session_id`,
310
- // `lsp_hints`) must STILL be promoted to the top level so RawRequest's
311
- // dedicated fields deserialize correctly. Without this promotion, e.g.
312
- // `bash` (whose params include `command: "<shell command>"`) silently
313
- // loses `session_id` because it stays nested inside `params`.
314
- let request;
315
- if (Object.hasOwn(params, "command") || Object.hasOwn(params, "method")) {
316
- const nested = { ...params };
317
- const reserved = {};
318
- for (const key of ["session_id", "lsp_hints"]) {
319
- if (Object.hasOwn(nested, key)) {
320
- reserved[key] = nested[key];
321
- delete nested[key];
345
+ const id = String(this.nextId++);
346
+ // Wire format: when params contains a key that collides with the protocol
347
+ // envelope (`command`/`method`), nest params under a `params` key so the
348
+ // outer dispatch dispatches on `command: "<bridge command>"` rather than
349
+ // the user's payload key. Reserved envelope fields (`session_id`,
350
+ // `lsp_hints`) must STILL be promoted to the top level so RawRequest's
351
+ // dedicated fields deserialize correctly. Without this promotion, e.g.
352
+ // `bash` (whose params include `command: "<shell command>"`) silently
353
+ // loses `session_id` because it stays nested inside `params`.
354
+ let request;
355
+ if (Object.hasOwn(params, "command") || Object.hasOwn(params, "method")) {
356
+ const nested = { ...params };
357
+ const reserved = {};
358
+ for (const key of ["session_id", "lsp_hints"]) {
359
+ if (Object.hasOwn(nested, key)) {
360
+ reserved[key] = nested[key];
361
+ delete nested[key];
362
+ }
322
363
  }
364
+ request = { id, command, ...reserved, params: nested };
323
365
  }
324
- request = { id, command, ...reserved, params: nested };
325
- }
326
- else {
327
- request = { id, command, ...params };
328
- }
329
- const line = `${JSON.stringify(request)}\n`;
330
- // Per-op timeout override: tool wrappers can pass longer budgets for
331
- // commands that legitimately need them (callers, trace_to, grep on big
332
- // repos). Defaults to the bridge-wide timeout otherwise.
333
- const effectiveTimeoutMs = options?.transportTimeoutMs ?? options?.timeoutMs ?? this.timeoutMs;
334
- const keepBridgeOnTimeout = options?.keepBridgeOnTimeout === true;
335
- return new Promise((resolve, reject) => {
336
- const timer = setTimeout(() => {
337
- this.pending.delete(id);
338
- const restartSuffix = keepBridgeOnTimeout ? "" : " — restarting bridge";
339
- const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
340
- if (requestSessionId) {
341
- sessionWarn(requestSessionId, timeoutMsg);
342
- }
343
- else {
344
- warn(timeoutMsg);
345
- }
346
- reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
347
- // Kill the hung process so the next request gets a fresh bridge —
348
- // unless the caller explicitly opted out (e.g. bash, which enforces
349
- // its own timeout on the Rust side and shouldn't lose warm bridge
350
- // state when its response is merely late).
351
- if (!keepBridgeOnTimeout) {
352
- this.handleTimeout(requestSessionId);
353
- }
354
- }, effectiveTimeoutMs);
355
- this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
356
- if (!this.process?.stdin?.writable) {
357
- this.pending.delete(id);
358
- clearTimeout(timer);
359
- reject(new Error(`${this.errorPrefix} stdin not writable for command "${command}"`));
360
- return;
366
+ else {
367
+ request = { id, command, ...params };
361
368
  }
362
- this.process.stdin.write(line, (err) => {
363
- if (err) {
364
- const entry = this.pending.get(id);
365
- if (entry) {
366
- this.pending.delete(id);
367
- clearTimeout(entry.timer);
368
- entry.reject(new Error(`${this.errorPrefix} Failed to write to stdin: ${err.message}`));
369
+ const line = `${JSON.stringify(request)}\n`;
370
+ // Per-op timeout override: tool wrappers can pass longer budgets for
371
+ // commands that legitimately need them (callers, trace_to, grep on big
372
+ // repos). Defaults to the bridge-wide timeout otherwise.
373
+ const effectiveTimeoutMs = options?.transportTimeoutMs ?? options?.timeoutMs ?? this.timeoutMs;
374
+ const keepBridgeOnTimeout = options?.keepBridgeOnTimeout === true;
375
+ return new Promise((resolve, reject) => {
376
+ const timer = setTimeout(() => {
377
+ this.pending.delete(id);
378
+ const restartSuffix = keepBridgeOnTimeout ? "" : " — restarting bridge";
379
+ const timeoutMsg = `Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms${restartSuffix}`;
380
+ if (requestSessionId) {
381
+ this.sessionWarnVia(requestSessionId, timeoutMsg);
382
+ }
383
+ else {
384
+ this.warnVia(timeoutMsg);
369
385
  }
386
+ reject(new Error(`${this.errorPrefix} Request "${command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`));
387
+ // Kill the hung process so the next request gets a fresh bridge —
388
+ // unless the caller explicitly opted out (e.g. bash, which enforces
389
+ // its own timeout on the Rust side and shouldn't lose warm bridge
390
+ // state when its response is merely late).
391
+ if (!keepBridgeOnTimeout) {
392
+ this.handleTimeout(requestSessionId);
393
+ }
394
+ }, effectiveTimeoutMs);
395
+ this.pending.set(id, { resolve, reject, timer, onProgress: options?.onProgress });
396
+ if (!this.process?.stdin?.writable) {
397
+ this.pending.delete(id);
398
+ clearTimeout(timer);
399
+ reject(new Error(`${this.errorPrefix} stdin not writable for command "${command}"`));
400
+ return;
370
401
  }
402
+ this.process.stdin.write(line, (err) => {
403
+ if (err) {
404
+ const entry = this.pending.get(id);
405
+ if (entry) {
406
+ this.pending.delete(id);
407
+ clearTimeout(entry.timer);
408
+ entry.reject(new Error(`${this.errorPrefix} Failed to write to stdin: ${err.message}`));
409
+ }
410
+ }
411
+ });
371
412
  });
372
- });
413
+ }
414
+ catch (err) {
415
+ if (err instanceof BridgeReplacedDuringVersionCheck &&
416
+ canRetryAfterVersionSwap &&
417
+ command !== "configure" &&
418
+ command !== "version") {
419
+ this.logVia(`Retrying request "${command}" once after coordinated binary replacement: ${err.newBinaryPath}`);
420
+ return this.sendWithVersionMismatchRetry(command, params, options, false);
421
+ }
422
+ throw err;
423
+ }
373
424
  }
374
425
  async deliverConfigureWarnings(configResult, params, options) {
375
426
  if (!this.onConfigureWarnings || !Array.isArray(configResult.warnings))
@@ -387,7 +438,7 @@ export class BinaryBridge {
387
438
  });
388
439
  }
389
440
  catch (err) {
390
- warn(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
441
+ this.warnVia(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
391
442
  }
392
443
  finally {
393
444
  if (sessionId) {
@@ -430,7 +481,7 @@ export class BinaryBridge {
430
481
  if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot))
431
482
  return;
432
483
  this.cachedStatus = snapshot;
433
- log("Received status_changed push frame; cached AFT status snapshot");
484
+ this.logVia("Received status_changed push frame; cached AFT status snapshot");
434
485
  for (const listener of this.statusListeners) {
435
486
  this.deliverStatusSnapshot(listener, this.cachedStatus);
436
487
  }
@@ -440,7 +491,7 @@ export class BinaryBridge {
440
491
  listener(snapshot);
441
492
  }
442
493
  catch (err) {
443
- warn(`status listener threw: ${err instanceof Error ? err.message : String(err)}`);
494
+ this.warnVia(`status listener threw: ${err instanceof Error ? err.message : String(err)}`);
444
495
  }
445
496
  }
446
497
  /** Kill the child process and reject all pending requests. */
@@ -459,7 +510,7 @@ export class BinaryBridge {
459
510
  }, 5_000);
460
511
  proc.once("exit", () => {
461
512
  clearTimeout(forceKillTimer);
462
- log("Process exited during shutdown");
513
+ this.logVia("Process exited during shutdown");
463
514
  resolve();
464
515
  });
465
516
  proc.kill("SIGTERM");
@@ -480,17 +531,49 @@ export class BinaryBridge {
480
531
  if (typeof binaryVersion !== "string") {
481
532
  throw new Error(`Binary did not report a version — likely too old (minVersion: ${this.minVersion})`);
482
533
  }
483
- log(`Binary version: ${binaryVersion}`);
534
+ this.logVia(`Binary version: ${binaryVersion}`);
484
535
  if (compareSemver(binaryVersion, this.minVersion) < 0) {
485
- warn(`Binary version ${binaryVersion} is older than required ${this.minVersion}`);
486
- this.onVersionMismatch?.(binaryVersion, this.minVersion);
536
+ this.warnVia(`Binary version ${binaryVersion} is older than required ${this.minVersion}`);
537
+ const replacementPath = await this.onVersionMismatch?.(binaryVersion, this.minVersion);
538
+ if (replacementPath === undefined) {
539
+ // Backwards compatibility: legacy callbacks returned void and usually kicked off a
540
+ // fire-and-forget download + pool swap. Preserve that behavior for existing callers.
541
+ return;
542
+ }
543
+ if (replacementPath === null || replacementPath.length === 0) {
544
+ throw new Error(`Binary version ${binaryVersion} is older than required ${this.minVersion}; no compatible replacement binary was provided`);
545
+ }
546
+ await this.replaceCurrentBinary(replacementPath);
547
+ throw new BridgeReplacedDuringVersionCheck(replacementPath);
487
548
  }
488
549
  }
489
550
  catch (err) {
490
- warn(`Version check failed: ${err.message}`);
551
+ this.warnVia(`Version check failed: ${err.message}`);
491
552
  throw err;
492
553
  }
493
554
  }
555
+ async replaceCurrentBinary(newBinaryPath) {
556
+ this.binaryPath = newBinaryPath;
557
+ this.configured = false;
558
+ this.clearRestartResetTimer();
559
+ this.rejectAllPending(new Error(`${this.errorPrefix} Bridge restarting with updated binary: ${newBinaryPath}`));
560
+ if (!this.process)
561
+ return;
562
+ const proc = this.process;
563
+ this.process = null;
564
+ await new Promise((resolve) => {
565
+ const forceKillTimer = setTimeout(() => {
566
+ proc.kill("SIGKILL");
567
+ resolve();
568
+ }, 5_000);
569
+ proc.once("exit", () => {
570
+ clearTimeout(forceKillTimer);
571
+ this.logVia("Process exited during coordinated binary replacement");
572
+ resolve();
573
+ });
574
+ proc.kill("SIGTERM");
575
+ });
576
+ }
494
577
  ensureSpawned(triggeringSessionId) {
495
578
  if (this.isAlive())
496
579
  return;
@@ -498,10 +581,10 @@ export class BinaryBridge {
498
581
  }
499
582
  spawnProcess(triggeringSessionId) {
500
583
  if (triggeringSessionId) {
501
- sessionLog(triggeringSessionId, `Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
584
+ this.sessionLogVia(triggeringSessionId, `Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
502
585
  }
503
586
  else {
504
- log(`Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
587
+ this.logVia(`Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
505
588
  }
506
589
  const semantic = this.configOverrides.semantic;
507
590
  const semanticBackend = (() => {
@@ -565,11 +648,12 @@ export class BinaryBridge {
565
648
  const remaining = stderrDecoder.end();
566
649
  if (remaining)
567
650
  this.onStderrData(remaining);
651
+ this.flushStderrBuffer();
568
652
  });
569
653
  child.on("error", (err) => {
570
654
  if (this.process !== currentChild)
571
655
  return;
572
- error(`Process error: ${err.message}${this.formatStderrTail()}`);
656
+ this.errorVia(`Process error: ${err.message}${this.formatStderrTail()}`);
573
657
  this.handleCrash();
574
658
  });
575
659
  child.on("exit", (code, signal) => {
@@ -577,7 +661,7 @@ export class BinaryBridge {
577
661
  return;
578
662
  if (this._shuttingDown)
579
663
  return;
580
- log(`Process exited: code=${code}, signal=${signal}`);
664
+ this.logVia(`Process exited: code=${code}, signal=${signal}`);
581
665
  // External termination signals (SIGTERM/SIGKILL/SIGHUP/SIGINT) are almost
582
666
  // always intentional kills — from our own shutdown path, OpenCode tearing
583
667
  // down, OS shutdown, or the user killing the host. Auto-restarting here
@@ -599,6 +683,7 @@ export class BinaryBridge {
599
683
  });
600
684
  this.process = child;
601
685
  this.stdoutBuffer = "";
686
+ this.stderrBuffer = "";
602
687
  // Fresh spawn — clear the stderr ring so crash diagnostics only reflect
603
688
  // the current child's output, not output from prior restart cycles.
604
689
  this.stderrTail = [];
@@ -610,15 +695,27 @@ export class BinaryBridge {
610
695
  }
611
696
  }
612
697
  onStderrData(data) {
613
- const lines = data.trimEnd().split("\n");
614
- for (const line of lines) {
698
+ this.stderrBuffer += data;
699
+ let newlineIdx;
700
+ while ((newlineIdx = this.stderrBuffer.indexOf("\n")) !== -1) {
701
+ const line = this.stderrBuffer.slice(0, newlineIdx).replace(/\r$/, "");
702
+ this.stderrBuffer = this.stderrBuffer.slice(newlineIdx + 1);
615
703
  if (!line)
616
704
  continue;
617
705
  const tagged = tagStderrLine(line);
618
- log(tagged);
706
+ this.logVia(tagged);
619
707
  this.pushStderrLine(tagged);
620
708
  }
621
709
  }
710
+ flushStderrBuffer() {
711
+ const line = this.stderrBuffer.replace(/\r$/, "");
712
+ this.stderrBuffer = "";
713
+ if (!line)
714
+ return;
715
+ const tagged = tagStderrLine(line);
716
+ this.logVia(tagged);
717
+ this.pushStderrLine(tagged);
718
+ }
622
719
  /**
623
720
  * Format the current stderr tail for inclusion in error messages. Returns
624
721
  * empty string when nothing has been captured (e.g., silent SIGKILL from
@@ -678,7 +775,7 @@ export class BinaryBridge {
678
775
  }
679
776
  if (response.type === "configure_warnings") {
680
777
  this.handleConfigureWarningsFrame(response).catch((err) => {
681
- warn(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
778
+ this.warnVia(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
682
779
  });
683
780
  continue;
684
781
  }
@@ -697,11 +794,11 @@ export class BinaryBridge {
697
794
  entry.resolve(response);
698
795
  }
699
796
  else if (typeof response.type === "string") {
700
- log(`Ignoring unknown stdout push frame type: ${response.type}`);
797
+ this.logVia(`Ignoring unknown stdout push frame type: ${response.type}`);
701
798
  }
702
799
  }
703
800
  catch (_err) {
704
- warn(`Failed to parse stdout line: ${line}`);
801
+ this.warnVia(`Failed to parse stdout line: ${line}`);
705
802
  }
706
803
  }
707
804
  }
@@ -724,20 +821,20 @@ export class BinaryBridge {
724
821
  this.stderrTail = [];
725
822
  const killedMsg = tail
726
823
  ? `Bridge killed after timeout.${tail}`
727
- : `Bridge killed after timeout (see ${getLogFilePath()})`;
824
+ : `Bridge killed after timeout (see ${this.getLogFilePathVia()})`;
728
825
  if (tail) {
729
826
  if (triggeringSessionId) {
730
- sessionError(triggeringSessionId, killedMsg);
827
+ this.sessionErrorVia(triggeringSessionId, killedMsg);
731
828
  }
732
829
  else {
733
- error(killedMsg);
830
+ this.errorVia(killedMsg);
734
831
  }
735
832
  }
736
833
  else if (triggeringSessionId) {
737
- sessionWarn(triggeringSessionId, killedMsg);
834
+ this.sessionWarnVia(triggeringSessionId, killedMsg);
738
835
  }
739
836
  else {
740
- warn(killedMsg);
837
+ this.warnVia(killedMsg);
741
838
  }
742
839
  }
743
840
  handleCrash(cause) {
@@ -754,21 +851,21 @@ export class BinaryBridge {
754
851
  // rejection only carries a pointer to the log.
755
852
  const tail = this.formatStderrTail();
756
853
  if (tail) {
757
- error(`Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""}.${tail}`);
854
+ this.errorVia(`Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""}.${tail}`);
758
855
  }
759
- this.rejectAllPending(new Error(`${this.errorPrefix} Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""} (see ${getLogFilePath()})`));
856
+ this.rejectAllPending(new Error(`${this.errorPrefix} Binary crashed (restarts: ${this._restartCount})${cause ? `: ${cause.message}` : ""} (see ${this.getLogFilePathVia()})`));
760
857
  // Auto-restart with exponential backoff
761
858
  if (this._restartCount < this.maxRestarts) {
762
859
  const delay = 100 * 2 ** this._restartCount; // 100ms, 200ms, 400ms
763
860
  this._restartCount++;
764
- log(`Auto-restart #${this._restartCount} in ${delay}ms`);
861
+ this.logVia(`Auto-restart #${this._restartCount} in ${delay}ms`);
765
862
  setTimeout(() => {
766
863
  if (!this._shuttingDown && !this.isAlive()) {
767
864
  try {
768
865
  this.spawnProcess();
769
866
  }
770
867
  catch (err) {
771
- error(`Failed to restart: ${err.message}`);
868
+ this.errorVia(`Failed to restart: ${err.message}`);
772
869
  }
773
870
  }
774
871
  }, delay);
@@ -777,7 +874,7 @@ export class BinaryBridge {
777
874
  this.scheduleRestartCountReset();
778
875
  }
779
876
  else {
780
- error(`Max restarts (${this.maxRestarts}) reached, giving up. Logs: ${getLogFilePath()}${tail}`);
877
+ this.errorVia(`Max restarts (${this.maxRestarts}) reached, giving up. Logs: ${this.getLogFilePathVia()}${tail}`);
781
878
  this.scheduleRestartCountReset();
782
879
  }
783
880
  }