@cortexkit/aft-bridge 0.59.0 → 0.61.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.
Files changed (93) hide show
  1. package/dist/bash-format.d.ts +5 -0
  2. package/dist/bash-format.d.ts.map +1 -1
  3. package/dist/bash-format.js +12 -5
  4. package/dist/bash-format.js.map +1 -1
  5. package/dist/bash-hints.d.ts +3 -3
  6. package/dist/bash-hints.d.ts.map +1 -1
  7. package/dist/bash-hints.js +14 -13
  8. package/dist/bash-hints.js.map +1 -1
  9. package/dist/bash-host-fallback.d.ts +2 -2
  10. package/dist/bash-host-fallback.d.ts.map +1 -1
  11. package/dist/bash-host-fallback.js +2 -2
  12. package/dist/bash-host-fallback.js.map +1 -1
  13. package/dist/bridge.d.ts +87 -9
  14. package/dist/bridge.d.ts.map +1 -1
  15. package/dist/bridge.js +289 -108
  16. package/dist/bridge.js.map +1 -1
  17. package/dist/config-watch.d.ts +8 -6
  18. package/dist/config-watch.d.ts.map +1 -1
  19. package/dist/config-watch.js +17 -12
  20. package/dist/config-watch.js.map +1 -1
  21. package/dist/downloader.js +4 -3
  22. package/dist/downloader.js.map +1 -1
  23. package/dist/error-contract.d.ts +30 -2
  24. package/dist/error-contract.d.ts.map +1 -1
  25. package/dist/error-contract.js +69 -7
  26. package/dist/error-contract.js.map +1 -1
  27. package/dist/feature-config.d.ts +3 -0
  28. package/dist/feature-config.d.ts.map +1 -1
  29. package/dist/feature-config.js +124 -0
  30. package/dist/feature-config.js.map +1 -1
  31. package/dist/index.d.ts +11 -7
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +10 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/lsp-github-install.d.ts +104 -0
  36. package/dist/lsp-github-install.d.ts.map +1 -0
  37. package/dist/lsp-github-install.js +1455 -0
  38. package/dist/lsp-github-install.js.map +1 -0
  39. package/dist/lsp-github-probe.d.ts +81 -0
  40. package/dist/lsp-github-probe.d.ts.map +1 -0
  41. package/dist/lsp-github-probe.js +131 -0
  42. package/dist/lsp-github-probe.js.map +1 -0
  43. package/dist/lsp-github-table.d.ts +64 -0
  44. package/dist/lsp-github-table.d.ts.map +1 -0
  45. package/dist/lsp-github-table.js +174 -0
  46. package/dist/lsp-github-table.js.map +1 -0
  47. package/dist/migration.d.ts +10 -3
  48. package/dist/migration.d.ts.map +1 -1
  49. package/dist/migration.js +19 -14
  50. package/dist/migration.js.map +1 -1
  51. package/dist/onnx-runtime.d.ts +2 -0
  52. package/dist/onnx-runtime.d.ts.map +1 -1
  53. package/dist/onnx-runtime.js +12 -7
  54. package/dist/onnx-runtime.js.map +1 -1
  55. package/dist/paths.d.ts +3 -0
  56. package/dist/paths.d.ts.map +1 -1
  57. package/dist/paths.js +41 -6
  58. package/dist/paths.js.map +1 -1
  59. package/dist/pool.d.ts +8 -0
  60. package/dist/pool.d.ts.map +1 -1
  61. package/dist/pool.js +31 -8
  62. package/dist/pool.js.map +1 -1
  63. package/dist/project-identity.d.ts +2 -0
  64. package/dist/project-identity.d.ts.map +1 -1
  65. package/dist/project-identity.js +8 -2
  66. package/dist/project-identity.js.map +1 -1
  67. package/dist/protocol.d.ts +3 -0
  68. package/dist/protocol.d.ts.map +1 -1
  69. package/dist/protocol.js +14 -1
  70. package/dist/protocol.js.map +1 -1
  71. package/dist/resolver.d.ts.map +1 -1
  72. package/dist/resolver.js +5 -2
  73. package/dist/resolver.js.map +1 -1
  74. package/dist/revivable-transport.d.ts +16 -1
  75. package/dist/revivable-transport.d.ts.map +1 -1
  76. package/dist/revivable-transport.js +83 -11
  77. package/dist/revivable-transport.js.map +1 -1
  78. package/dist/subc-transport.d.ts +45 -4
  79. package/dist/subc-transport.d.ts.map +1 -1
  80. package/dist/subc-transport.js +659 -49
  81. package/dist/subc-transport.js.map +1 -1
  82. package/dist/tar-executable.d.ts +3 -1
  83. package/dist/tar-executable.d.ts.map +1 -1
  84. package/dist/tar-executable.js +35 -15
  85. package/dist/tar-executable.js.map +1 -1
  86. package/dist/transport-factory.d.ts.map +1 -1
  87. package/dist/transport-factory.js +8 -32
  88. package/dist/transport-factory.js.map +1 -1
  89. package/dist/transport.d.ts +27 -0
  90. package/dist/transport.d.ts.map +1 -1
  91. package/dist/transport.js +9 -0
  92. package/dist/transport.js.map +1 -1
  93. package/package.json +2 -2
package/dist/bridge.js CHANGED
@@ -6,7 +6,8 @@ import { binaryContentHash, binaryStampKey, peekBinaryContentHash } from "./bina
6
6
  import { spawn } from "./child-process.js";
7
7
  import { isPassiveCommand, PASSIVE_COMMAND_TIMEOUT_MS } from "./command-timeouts.js";
8
8
  import { withPathPrepended } from "./path-env.js";
9
- import { WORKER_SESSION_FIELD } from "./transport.js";
9
+ import { isTerminalBashStatus } from "./protocol.js";
10
+ import { HOST_TOOL_NAMES_FIELD, WORKER_SESSION_FIELD } from "./transport.js";
10
11
  const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
11
12
  const BRIDGE_HANG_TIMEOUT_THRESHOLD = 2;
12
13
  const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
@@ -14,19 +15,6 @@ const MAX_STDOUT_BUFFER = 64 * 1024 * 1024; // 64MB
14
15
  const MALFORMED_STDOUT_LOG_CHARS = 512;
15
16
  const HASHLINE_REGISTRATION_LOG_INTERVAL_MS = 60_000;
16
17
  const HASHLINE_REGISTRATION_LOG_STATE_LIMIT = 256;
17
- const TERMINAL_BASH_STATUSES = new Set([
18
- "completed",
19
- "failed",
20
- "killed",
21
- "timed_out",
22
- "fate_unknown",
23
- // Historical/defensive aliases seen in plugin-side compatibility code.
24
- "cancelled",
25
- "timeout",
26
- ]);
27
- function isTerminalBashStatus(status) {
28
- return typeof status === "string" && TERMINAL_BASH_STATUSES.has(status);
29
- }
30
18
  function bashTaskIdFrom(response) {
31
19
  const snakeCase = response.task_id;
32
20
  if (typeof snakeCase === "string" && snakeCase.length > 0)
@@ -115,47 +103,49 @@ export function shouldSurfaceStderrLine(line) {
115
103
  normalized === BENIGN_CPUINFO_PROC_CPUINFO_PARSE_FAILURE);
116
104
  }
117
105
  export function compareSemver(a, b) {
118
- const [aMain, aPre] = a.split("-", 2);
119
- const [bMain, bPre] = b.split("-", 2);
120
- const aParts = aMain.split(".").map(Number);
121
- const bParts = bMain.split(".").map(Number);
106
+ const parse = (version) => {
107
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
108
+ const pre = match?.[4]?.split(".") ?? [];
109
+ if (!match || pre.some((id) => /^\d+$/.test(id) && id.length > 1 && id[0] === "0")) {
110
+ throw new Error(`Invalid semantic version: ${version}`);
111
+ }
112
+ return { core: match.slice(1, 4), pre };
113
+ };
114
+ // Compare numeric identifiers as strings so even large valid identifiers
115
+ // retain their order without Number rounding or overflow.
116
+ const numeric = (a, b) => a.length !== b.length ? a.length - b.length : a === b ? 0 : a < b ? -1 : 1;
117
+ const left = parse(a);
118
+ const right = parse(b);
122
119
  for (let i = 0; i < 3; i++) {
123
- if (aParts[i] !== bParts[i])
124
- return (aParts[i] ?? 0) - (bParts[i] ?? 0);
125
- }
126
- if (!aPre && !bPre)
127
- return 0;
128
- if (!aPre)
129
- return 1;
130
- if (!bPre)
131
- return -1;
132
- const aIds = aPre.split(".");
133
- const bIds = bPre.split(".");
134
- for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
135
- const ai = aIds[i];
136
- const bi = bIds[i];
120
+ const diff = numeric(left.core[i], right.core[i]);
121
+ if (diff !== 0)
122
+ return diff;
123
+ }
124
+ if (left.pre.length === 0 || right.pre.length === 0) {
125
+ return left.pre.length === right.pre.length ? 0 : left.pre.length === 0 ? 1 : -1;
126
+ }
127
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
128
+ const ai = left.pre[i];
129
+ const bi = right.pre[i];
137
130
  if (ai === undefined)
138
131
  return -1;
139
132
  if (bi === undefined)
140
133
  return 1;
141
134
  const aNum = /^\d+$/.test(ai);
142
135
  const bNum = /^\d+$/.test(bi);
143
- if (aNum && bNum) {
144
- const diff = Number.parseInt(ai, 10) - Number.parseInt(bi, 10);
145
- if (diff !== 0)
146
- return diff;
147
- }
148
- else if (aNum) {
149
- return -1;
150
- }
151
- else if (bNum) {
152
- return 1;
153
- }
154
- else {
155
- const cmp = ai.localeCompare(bi);
156
- if (cmp !== 0)
157
- return cmp;
158
- }
136
+ const diff = aNum && bNum
137
+ ? numeric(ai, bi)
138
+ : aNum !== bNum
139
+ ? aNum
140
+ ? -1
141
+ : 1
142
+ : ai === bi
143
+ ? 0
144
+ : ai < bi
145
+ ? -1
146
+ : 1;
147
+ if (diff !== 0)
148
+ return diff;
159
149
  }
160
150
  return 0;
161
151
  }
@@ -195,12 +185,44 @@ export class BridgeTransportTimeoutError extends Error {
195
185
  command;
196
186
  timeoutMs;
197
187
  code = "transport_timeout";
198
- constructor(command, timeoutMs, message) {
188
+ /**
189
+ * For a `bash`/`powershell` call, what the bridge learned about the command
190
+ * while it waited a short grace period after the deadline. Undefined for
191
+ * other commands.
192
+ */
193
+ shellOutcome;
194
+ constructor(command, timeoutMs, message, shellOutcome) {
199
195
  super(message);
200
196
  this.command = command;
201
197
  this.timeoutMs = timeoutMs;
202
198
  this.name = "BridgeTransportTimeoutError";
199
+ this.shellOutcome = shellOutcome;
200
+ }
201
+ }
202
+ /** Commands that start a shell task in AFT and answer with its task id. */
203
+ const SHELL_TASK_COMMANDS = new Set(["bash", "powershell"]);
204
+ /**
205
+ * How long a timed-out shell call keeps listening for AFT's late reply. AFT
206
+ * can start the command and answer just after the bridge's deadline (for
207
+ * example while it is busy with other work); that reply carries the task id,
208
+ * which is the only way the agent can find the running command. Kept short
209
+ * because the agent waits this much longer for its error.
210
+ */
211
+ const DEFAULT_LATE_SHELL_REPLY_GRACE_MS = 10_000;
212
+ function shellOutcomeFromLateReply(response) {
213
+ const taskId = bashTaskIdFrom(response);
214
+ if (taskId)
215
+ return { kind: "task_started", taskId };
216
+ if (response.success === false) {
217
+ return {
218
+ kind: "not_started",
219
+ ...(typeof response.code === "string" ? { code: response.code } : {}),
220
+ ...(typeof response.message === "string" ? { message: response.message } : {}),
221
+ };
203
222
  }
223
+ // A successful reply without a task id is not a shape AFT sends for a shell
224
+ // command, so it proves nothing either way.
225
+ return { kind: "unknown" };
204
226
  }
205
227
  /** A standalone bridge cannot carry a request because its process transport died. */
206
228
  export class BridgeTransportUnavailableError extends Error {
@@ -240,6 +262,22 @@ export class BinaryBridge {
240
262
  /** Once true, this bridge must not accept new requests and should be drained. */
241
263
  _retiringDueToBinaryChange = false;
242
264
  pending = new Map();
265
+ /**
266
+ * Public `send()` calls that have not settled yet, including the configure
267
+ * and version requests a first call runs before its own request. While this
268
+ * is above zero (or a background bash task is outstanding) the child process
269
+ * and its pipes keep the host's event loop alive; otherwise they are
270
+ * unreferenced, so an idle bridge never stops a one-shot host command from
271
+ * exiting. See `syncEventLoopHold`.
272
+ */
273
+ activeSendCalls = 0;
274
+ /**
275
+ * Whether the current child's handles are referenced. A fresh spawn starts
276
+ * referenced, as Node and Bun create them. Tracked so `ref`/`unref` are
277
+ * only called on a change: Bun counts them on its child pipes, so a
278
+ * repeated `ref()` would need as many `unref()` calls to release the loop.
279
+ */
280
+ processLoopReferenced = true;
243
281
  outstandingBackgroundTaskIds = new Set();
244
282
  nextId = 1;
245
283
  processGeneration = 0;
@@ -263,6 +301,7 @@ export class BinaryBridge {
263
301
  _shuttingDown = false;
264
302
  timeoutMs;
265
303
  hangThreshold;
304
+ lateShellReplyGraceMs;
266
305
  maxRestarts;
267
306
  configured = false;
268
307
  _configurePromise = null;
@@ -280,6 +319,7 @@ export class BinaryBridge {
280
319
  statusListeners = new Set();
281
320
  /** Notification clients keyed by session_id for async configure warning pushes. */
282
321
  configureWarningClients = new Map();
322
+ restartTimer = null;
283
323
  restartResetTimer = null;
284
324
  /** Updated after every successfully parsed stdout frame from the child. */
285
325
  lastChildActivityAt = 0;
@@ -298,6 +338,8 @@ export class BinaryBridge {
298
338
  this.cwd = cwd;
299
339
  this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
300
340
  this.hangThreshold = options?.hangThreshold ?? BRIDGE_HANG_TIMEOUT_THRESHOLD;
341
+ this.lateShellReplyGraceMs =
342
+ options?.lateShellReplyGraceMs ?? DEFAULT_LATE_SHELL_REPLY_GRACE_MS;
301
343
  this.maxRestarts = options?.maxRestarts ?? 3;
302
344
  this.errorPrefix = options?.errorPrefix ?? "[aft-bridge]";
303
345
  // P1 config relocation: semantic config now arrives as raw `config` tiers and
@@ -532,6 +574,50 @@ export class BinaryBridge {
532
574
  * Lazy-spawns the binary on first call.
533
575
  */
534
576
  async send(command, params = {}, options) {
577
+ this.activeSendCalls += 1;
578
+ this.syncEventLoopHold();
579
+ try {
580
+ return await this.dispatchSend(command, params, options);
581
+ }
582
+ finally {
583
+ this.activeSendCalls -= 1;
584
+ this.syncEventLoopHold();
585
+ }
586
+ }
587
+ /**
588
+ * Reference the child process and its stdio pipes while the bridge has work
589
+ * in flight, and unreference them while it is idle.
590
+ *
591
+ * A host that loads the plugin and then has nothing left to do (a one-shot
592
+ * CLI command, or a headless run after its last turn) must be able to exit
593
+ * even though a warm `aft` child is still running; the child sees its stdin
594
+ * close and exits on its own. While a request is in flight the handles stay
595
+ * referenced, so the host cannot exit mid-call and lose the response. A
596
+ * background bash task the child is still running counts as work in flight
597
+ * too: its completion frame can wake the session, so the host stays up for
598
+ * it, as it always did before idle bridges were released.
599
+ * `ref`/`unref` are optional because not every runtime exposes them on every
600
+ * pipe (Bun's child stdin has neither and does not hold the loop).
601
+ */
602
+ syncEventLoopHold() {
603
+ const child = this.process;
604
+ if (!child)
605
+ return;
606
+ const hold = this.activeSendCalls > 0 || this.outstandingBackgroundTaskIds.size > 0;
607
+ if (hold === this.processLoopReferenced)
608
+ return;
609
+ this.processLoopReferenced = hold;
610
+ // The stdio pipes are typed as plain streams, but at runtime they are
611
+ // sockets (Node) or native readables (Bun) that carry ref/unref.
612
+ const handles = [child, child.stdin, child.stdout, child.stderr];
613
+ for (const handle of handles) {
614
+ if (hold)
615
+ handle?.ref?.();
616
+ else
617
+ handle?.unref?.();
618
+ }
619
+ }
620
+ async dispatchSend(command, params, options) {
535
621
  let dispatchParams = params;
536
622
  if (command === "configure") {
537
623
  dispatchParams = { ...params };
@@ -578,11 +664,13 @@ export class BinaryBridge {
578
664
  params.edit_slot_survives = editSlotSurvives;
579
665
  this.logHashlineRegistrationCarrier("tool_call", sessionId, editSlotSurvives);
580
666
  }
581
- const { preview, workerSession, ...sendOptions } = options ?? {};
667
+ const { preview, workerSession, hostToolNames, ...sendOptions } = options ?? {};
582
668
  if (preview === true)
583
669
  params.preview = true;
584
670
  if (workerSession === true)
585
671
  params[WORKER_SESSION_FIELD] = true;
672
+ if (hostToolNames)
673
+ params[HOST_TOOL_NAMES_FIELD] = hostToolNames;
586
674
  return (await this.send("tool_call", params, Object.keys(sendOptions).length > 0 ? sendOptions : undefined));
587
675
  }
588
676
  async sendWithVersionMismatchRetry(command, params, options, canRetryAfterVersionSwap) {
@@ -604,13 +692,6 @@ export class BinaryBridge {
604
692
  ? params.session_id
605
693
  : undefined;
606
694
  this.ensureSpawned(requestSessionId);
607
- // Auto-configure can reuse the initiating session's notification client
608
- // when the deferred configure warning frame arrives later. One project
609
- // bridge can serve many sessions, so keep this per-session instead of one
610
- // bridge-wide "last client".
611
- if (requestSessionId && options?.configureWarningClient !== undefined) {
612
- this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
613
- }
614
695
  // Per-op timeout override: tool wrappers can pass longer budgets for
615
696
  // commands that legitimately need them (callers, trace_to, grep on big
616
697
  // repos). Defaults to the bridge-wide timeout otherwise.
@@ -635,6 +716,13 @@ export class BinaryBridge {
635
716
  if (!this.configured) {
636
717
  if (command !== "configure" && command !== "version") {
637
718
  if (!this._configurePromise) {
719
+ // Only the initiating session owns configure's deferred delivery.
720
+ // Later requests must not retain unrelated notification clients.
721
+ if (this.onConfigureWarnings &&
722
+ requestSessionId &&
723
+ options?.configureWarningClient !== undefined) {
724
+ this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
725
+ }
638
726
  const configuringChild = this.process;
639
727
  const requireConfiguringChild = () => {
640
728
  if (this.process !== configuringChild) {
@@ -675,6 +763,11 @@ export class BinaryBridge {
675
763
  }
676
764
  this.configured = true;
677
765
  }
766
+ catch (error) {
767
+ if (sessionIdForConfigure)
768
+ this.configureWarningClients.delete(sessionIdForConfigure);
769
+ throw error;
770
+ }
678
771
  finally {
679
772
  this._configurePromise = null;
680
773
  }
@@ -700,7 +793,12 @@ export class BinaryBridge {
700
793
  Object.hasOwn(params, "method")) {
701
794
  const nested = { ...params };
702
795
  const reserved = {};
703
- for (const key of ["session_id", "lsp_hints", WORKER_SESSION_FIELD]) {
796
+ for (const key of [
797
+ "session_id",
798
+ "lsp_hints",
799
+ WORKER_SESSION_FIELD,
800
+ HOST_TOOL_NAMES_FIELD,
801
+ ]) {
704
802
  if (Object.hasOwn(nested, key)) {
705
803
  reserved[key] = nested[key];
706
804
  delete nested[key];
@@ -725,6 +823,10 @@ export class BinaryBridge {
725
823
  const entry = this.pending.get(id);
726
824
  if (!entry)
727
825
  return;
826
+ if (keepBridgeOnTimeout &&
827
+ this.holdForLateShellReply(id, entry, effectiveTimeoutMs, requestSessionId)) {
828
+ return;
829
+ }
728
830
  this.pending.delete(id);
729
831
  clearTimeout(entry.timer);
730
832
  entry.onSettled?.();
@@ -832,6 +934,13 @@ export class BinaryBridge {
832
934
  }
833
935
  }
834
936
  async deliverConfigureWarnings(configResult, params, options) {
937
+ const sessionId = typeof params.session_id === "string" ? params.session_id : undefined;
938
+ const client = options?.configureWarningClient ??
939
+ (sessionId ? this.configureWarningClients.get(sessionId) : undefined);
940
+ // Capture ownership before dispatch. A hung host callback must neither hold
941
+ // configure readiness nor delay releasing the terminal delivery's client.
942
+ if (sessionId && configResult.warnings_pending !== true)
943
+ this.configureWarningClients.delete(sessionId);
835
944
  if (!this.onConfigureWarnings)
836
945
  return;
837
946
  const warnings = Array.isArray(configResult.warnings)
@@ -840,57 +949,40 @@ export class BinaryBridge {
840
949
  const configDroppedKeys = coerceConfigureDroppedKeys(configResult.config_dropped_keys);
841
950
  if (warnings.length === 0 && configDroppedKeys.length === 0)
842
951
  return;
843
- const sessionId = typeof params.session_id === "string" ? params.session_id : undefined;
844
952
  const context = {
845
953
  projectRoot: this.cwd,
846
954
  sessionId,
847
- client: options?.configureWarningClient ??
848
- (sessionId ? this.configureWarningClients.get(sessionId) : undefined),
955
+ client,
849
956
  warnings,
850
957
  };
851
- if (configDroppedKeys.length > 0) {
958
+ if (configDroppedKeys.length > 0)
852
959
  context.configDroppedKeys = configDroppedKeys;
853
- }
854
- try {
855
- await this.onConfigureWarnings(context);
856
- }
857
- catch (err) {
858
- this.warnVia(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
859
- }
860
- finally {
861
- if (sessionId) {
862
- this.configureWarningClients.delete(sessionId);
863
- }
864
- }
960
+ this.dispatchHostCallback("configure warning", () => this.onConfigureWarnings?.(context));
865
961
  }
866
- /**
867
- * Handle the `configure_warnings` push frame the Rust binary emits after
868
- * configure has returned. The frame carries the warnings produced by the
869
- * deferred file walk + missing-binary detection. Forwards to the same
870
- * `onConfigureWarnings` handler used for synchronous warnings so plugins
871
- * don't need to know about the async path.
872
- */
962
+ /** Release deferred-warning ownership even when the terminal frame has no warnings. */
873
963
  async handleConfigureWarningsFrame(frame) {
874
- if (!this.onConfigureWarnings)
875
- return;
876
- const warnings = frame.warnings;
877
- if (!Array.isArray(warnings) || warnings.length === 0)
964
+ const sessionId = typeof frame.session_id === "string" && frame.session_id.length > 0 ? frame.session_id : null;
965
+ const client = sessionId ? this.configureWarningClients.get(sessionId) : undefined;
966
+ if (sessionId)
967
+ this.configureWarningClients.delete(sessionId);
968
+ if (!this.onConfigureWarnings || !Array.isArray(frame.warnings) || frame.warnings.length === 0)
878
969
  return;
879
- const projectRoot = typeof frame.project_root === "string" ? frame.project_root : this.cwd;
880
- const rawSessionId = frame.session_id;
881
- const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : null;
970
+ this.dispatchHostCallback("configure warning", () => this.onConfigureWarnings?.({
971
+ projectRoot: typeof frame.project_root === "string" ? frame.project_root : this.cwd,
972
+ sessionId,
973
+ client,
974
+ warnings: frame.warnings,
975
+ }));
976
+ }
977
+ dispatchHostCallback(label, callback) {
978
+ const failed = (error) => {
979
+ this.warnVia(`${label} delivery failed: ${error instanceof Error ? error.message : String(error)}`);
980
+ };
882
981
  try {
883
- await this.onConfigureWarnings({
884
- projectRoot,
885
- sessionId,
886
- client: sessionId ? this.configureWarningClients.get(sessionId) : undefined,
887
- warnings: warnings,
888
- });
982
+ void Promise.resolve(callback()).catch(failed);
889
983
  }
890
- finally {
891
- if (sessionId) {
892
- this.configureWarningClients.delete(sessionId);
893
- }
984
+ catch (error) {
985
+ failed(error);
894
986
  }
895
987
  }
896
988
  handleStatusChangedFrame(frame) {
@@ -918,6 +1010,7 @@ export class BinaryBridge {
918
1010
  this._shuttingDown = true;
919
1011
  this.spawnedBinaryFingerprint = null;
920
1012
  this.clearRestartResetTimer();
1013
+ this.clearRestartTimer();
921
1014
  this.configureWarningClients.clear();
922
1015
  this.rejectAllPending(new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} Bridge shutting down`));
923
1016
  if (this.process) {
@@ -1006,6 +1099,7 @@ export class BinaryBridge {
1006
1099
  this.spawnProcess(triggeringSessionId);
1007
1100
  }
1008
1101
  spawnProcess(triggeringSessionId) {
1102
+ this.clearRestartTimer();
1009
1103
  this._retiringDueToBinaryChange = false;
1010
1104
  if (triggeringSessionId) {
1011
1105
  this.sessionLogVia(triggeringSessionId, `Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
@@ -1090,6 +1184,13 @@ export class BinaryBridge {
1090
1184
  env: spawnEnv,
1091
1185
  });
1092
1186
  const currentChild = child;
1187
+ // Writable streams emit an error in addition to invoking write callbacks.
1188
+ // Keep retired children observed too, but only retire the current transport.
1189
+ child.stdin?.on("error", (cause) => {
1190
+ if (this.process !== currentChild)
1191
+ return;
1192
+ this.invalidateTransportProcess(new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} Failed to write to stdin: ${cause.message}`, { cause }));
1193
+ });
1093
1194
  const stdoutDecoder = new StringDecoder("utf8");
1094
1195
  child.stdout?.on("data", (chunk) => {
1095
1196
  if (this.process !== currentChild)
@@ -1162,6 +1263,10 @@ export class BinaryBridge {
1162
1263
  // Fresh spawn — clear the stderr ring so crash diagnostics only reflect
1163
1264
  // the current child's output, not output from prior restart cycles.
1164
1265
  this.stderrTail = [];
1266
+ // A child spawned by a call is held until that call settles; one spawned
1267
+ // by an auto-restart with nothing in flight is released right away.
1268
+ this.processLoopReferenced = true;
1269
+ this.syncEventLoopHold();
1165
1270
  }
1166
1271
  pushStderrLine(line) {
1167
1272
  this.stderrTail.push(line);
@@ -1217,11 +1322,12 @@ export class BinaryBridge {
1217
1322
  return `\n --- last ${this.stderrTail.length} stderr lines ---\n ${tail}`;
1218
1323
  }
1219
1324
  onStdoutData(data) {
1220
- // Unprocessed bytes are the carried partial line plus this chunk.
1221
- if (this.stdoutPendingLength + data.length > MAX_STDOUT_BUFFER) {
1325
+ const exceedsLimit = (fragment) => {
1326
+ if (this.stdoutPendingLength + Buffer.byteLength(fragment, "utf8") <= MAX_STDOUT_BUFFER)
1327
+ return false;
1222
1328
  this.handleCrash(new Error(`aft bridge stdout buffer exceeded ${MAX_STDOUT_BUFFER} bytes — killing bridge`));
1223
- return;
1224
- }
1329
+ return true;
1330
+ };
1225
1331
  // Only the new chunk is searched for newlines: the carried chunks are
1226
1332
  // already known to contain none.
1227
1333
  this.stdoutScannedChars += data.length;
@@ -1230,6 +1336,8 @@ export class BinaryBridge {
1230
1336
  let newlineIdx = data.indexOf("\n");
1231
1337
  while (newlineIdx !== -1) {
1232
1338
  let line = data.slice(start, newlineIdx);
1339
+ if (exceedsLimit(line))
1340
+ return;
1233
1341
  if (this.stdoutPending.length > 0) {
1234
1342
  this.stdoutPending.push(line);
1235
1343
  line = this.stdoutPending.join("");
@@ -1249,8 +1357,10 @@ export class BinaryBridge {
1249
1357
  }
1250
1358
  if (start < data.length) {
1251
1359
  const rest = start === 0 ? data : data.slice(start);
1360
+ if (exceedsLimit(rest))
1361
+ return;
1252
1362
  this.stdoutPending.push(rest);
1253
- this.stdoutPendingLength += rest.length;
1363
+ this.stdoutPendingLength += Buffer.byteLength(rest, "utf8");
1254
1364
  }
1255
1365
  }
1256
1366
  flushStdoutBuffer() {
@@ -1293,15 +1403,16 @@ export class BinaryBridge {
1293
1403
  const taskId = bashTaskIdFrom(response);
1294
1404
  if (taskId)
1295
1405
  this.outstandingBackgroundTaskIds.delete(taskId);
1296
- this.onBashCompletion?.(response, this);
1406
+ this.syncEventLoopHold();
1407
+ this.dispatchHostCallback("bash completion", () => this.onBashCompletion?.(response, this));
1297
1408
  return;
1298
1409
  }
1299
1410
  if (response.type === "bash_long_running") {
1300
- this.onBashLongRunning?.(response, this);
1411
+ this.dispatchHostCallback("bash long-running", () => this.onBashLongRunning?.(response, this));
1301
1412
  return;
1302
1413
  }
1303
1414
  if (response.type === "bash_pattern_match") {
1304
- this.onBashPatternMatch?.(response, this);
1415
+ this.dispatchHostCallback("bash pattern-match", () => this.onBashPatternMatch?.(response, this));
1305
1416
  return;
1306
1417
  }
1307
1418
  if (response.type === "configure_warnings") {
@@ -1339,6 +1450,18 @@ export class BinaryBridge {
1339
1450
  this.accountForBashTaskResponse(entry.command, response);
1340
1451
  entry.resolve(response);
1341
1452
  }
1453
+ else if (id && typeof response.type !== "string") {
1454
+ // A reply for a request that already settled (its deadline and any
1455
+ // grace period passed). One that names a task is a warning: the task
1456
+ // may still be running, and this log line is the only record of its id.
1457
+ const taskId = bashTaskIdFrom(response);
1458
+ if (taskId) {
1459
+ this.warnVia(`Dropped late reply for settled request id=${id} task_id=${taskId}`);
1460
+ }
1461
+ else {
1462
+ this.logVia(`Dropped late reply for settled request id=${id}`);
1463
+ }
1464
+ }
1342
1465
  else if (typeof response.type === "string") {
1343
1466
  this.logVia(`Ignoring unknown stdout push frame type: ${response.type}`);
1344
1467
  }
@@ -1351,11 +1474,54 @@ export class BinaryBridge {
1351
1474
  this.warnVia(`Failed to parse stdout line: ${quoted}`);
1352
1475
  }
1353
1476
  }
1477
+ /**
1478
+ * Keep a timed-out shell call listening for AFT's reply for a short grace
1479
+ * period instead of rejecting it at once. AFT may already have started the
1480
+ * command; its late reply carries the task id, and dropping it would leave the
1481
+ * agent with a running task it cannot find, which it may then start twice.
1482
+ *
1483
+ * The entry stays in `pending`, so the ordinary reply path still records the
1484
+ * task (see accountForBashTaskResponse) and clears the grace timer. The call
1485
+ * then rejects with a timeout error whose `shellOutcome` says what AFT
1486
+ * answered, or that nothing arrived. Returns false when the call is not a
1487
+ * shell command or the grace period is disabled; the caller then rejects as
1488
+ * before.
1489
+ */
1490
+ holdForLateShellReply(id, entry, effectiveTimeoutMs, requestSessionId) {
1491
+ const graceMs = this.lateShellReplyGraceMs;
1492
+ if (!SHELL_TASK_COMMANDS.has(entry.command) || graceMs <= 0)
1493
+ return false;
1494
+ const timeoutMsg = `Request "${entry.command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms; waiting up to ${graceMs}ms for a late reply naming its task`;
1495
+ if (requestSessionId) {
1496
+ this.sessionWarnVia(requestSessionId, timeoutMsg);
1497
+ }
1498
+ else {
1499
+ this.warnVia(timeoutMsg);
1500
+ }
1501
+ const message = `${this.errorPrefix} Request "${entry.command}" (id=${id}) timed out after ${effectiveTimeoutMs}ms`;
1502
+ const rejectWith = entry.reject;
1503
+ const settle = (outcome) => {
1504
+ rejectWith(new BridgeTransportTimeoutError(entry.command, effectiveTimeoutMs, message, outcome));
1505
+ };
1506
+ // A reply now arrives after the caller's deadline, so it settles the call
1507
+ // as a timeout that reports what AFT answered.
1508
+ entry.resolve = (response) => settle(shellOutcomeFromLateReply(response));
1509
+ clearTimeout(entry.timer);
1510
+ entry.timer = setTimeout(() => {
1511
+ if (this.pending.get(id) !== entry)
1512
+ return;
1513
+ this.pending.delete(id);
1514
+ entry.onSettled?.();
1515
+ this.warnVia(`Request "${entry.command}" (id=${id}) got no reply within ${graceMs}ms after its deadline`);
1516
+ settle({ kind: "unknown" });
1517
+ }, graceMs);
1518
+ return true;
1519
+ }
1354
1520
  accountForBashTaskResponse(command, response) {
1355
1521
  const taskId = bashTaskIdFrom(response);
1356
1522
  if (!taskId)
1357
1523
  return;
1358
- if (isTerminalBashStatus(response.status)) {
1524
+ if (isTerminalBashStatus(response.status, true)) {
1359
1525
  this.outstandingBackgroundTaskIds.delete(taskId);
1360
1526
  return;
1361
1527
  }
@@ -1414,6 +1580,7 @@ export class BinaryBridge {
1414
1580
  }
1415
1581
  }
1416
1582
  handleCrash(cause) {
1583
+ this.clearRestartTimer();
1417
1584
  const proc = this.process;
1418
1585
  this.process = null;
1419
1586
  this.spawnedBinaryFingerprint = null;
@@ -1445,7 +1612,8 @@ export class BinaryBridge {
1445
1612
  const delay = 100 * 2 ** this._restartCount; // 100ms, 200ms, 400ms
1446
1613
  this._restartCount++;
1447
1614
  this.logVia(`Auto-restart #${this._restartCount} in ${delay}ms`);
1448
- setTimeout(() => {
1615
+ this.restartTimer = setTimeout(() => {
1616
+ this.restartTimer = null;
1449
1617
  if (!this._shuttingDown && !this.isAlive()) {
1450
1618
  try {
1451
1619
  this.spawnProcess();
@@ -1455,6 +1623,9 @@ export class BinaryBridge {
1455
1623
  }
1456
1624
  }
1457
1625
  }, delay);
1626
+ // The crash already rejected every pending request, so nothing waits on
1627
+ // this restart; it must not keep a host that is otherwise done alive.
1628
+ this.restartTimer.unref?.();
1458
1629
  // Also decay the counter over time so repeated crashes without any
1459
1630
  // successful response don't permanently wedge the bridge.
1460
1631
  this.scheduleRestartCountReset();
@@ -1465,6 +1636,7 @@ export class BinaryBridge {
1465
1636
  }
1466
1637
  }
1467
1638
  rejectAllPending(error) {
1639
+ this.configureWarningClients.clear();
1468
1640
  for (const [_id, entry] of this.pending) {
1469
1641
  clearTimeout(entry.timer);
1470
1642
  entry.onSettled?.();
@@ -1473,11 +1645,20 @@ export class BinaryBridge {
1473
1645
  this.pending.clear();
1474
1646
  }
1475
1647
  scheduleRestartCountReset() {
1648
+ if (this._restartCount === 0)
1649
+ return;
1476
1650
  this.clearRestartResetTimer();
1477
1651
  this.restartResetTimer = setTimeout(() => {
1478
1652
  this._restartCount = 0;
1479
1653
  this.restartResetTimer = null;
1480
1654
  }, BinaryBridge.RESTART_RESET_MS);
1655
+ // Bookkeeping only: a five-minute counter reset must not hold the host open.
1656
+ this.restartResetTimer.unref?.();
1657
+ }
1658
+ clearRestartTimer() {
1659
+ if (this.restartTimer)
1660
+ clearTimeout(this.restartTimer);
1661
+ this.restartTimer = null;
1481
1662
  }
1482
1663
  clearRestartResetTimer() {
1483
1664
  if (this.restartResetTimer) {