@cortexkit/aft-bridge 0.60.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 (82) 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.map +1 -1
  6. package/dist/bash-hints.js +3 -9
  7. package/dist/bash-hints.js.map +1 -1
  8. package/dist/bridge.d.ts +54 -9
  9. package/dist/bridge.d.ts.map +1 -1
  10. package/dist/bridge.js +210 -106
  11. package/dist/bridge.js.map +1 -1
  12. package/dist/config-watch.d.ts.map +1 -1
  13. package/dist/config-watch.js +6 -0
  14. package/dist/config-watch.js.map +1 -1
  15. package/dist/downloader.js +4 -3
  16. package/dist/downloader.js.map +1 -1
  17. package/dist/error-contract.d.ts +15 -2
  18. package/dist/error-contract.d.ts.map +1 -1
  19. package/dist/error-contract.js +42 -6
  20. package/dist/error-contract.js.map +1 -1
  21. package/dist/feature-config.d.ts +3 -0
  22. package/dist/feature-config.d.ts.map +1 -1
  23. package/dist/feature-config.js +49 -0
  24. package/dist/feature-config.js.map +1 -1
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/lsp-github-install.d.ts +104 -0
  30. package/dist/lsp-github-install.d.ts.map +1 -0
  31. package/dist/lsp-github-install.js +1455 -0
  32. package/dist/lsp-github-install.js.map +1 -0
  33. package/dist/lsp-github-probe.d.ts +81 -0
  34. package/dist/lsp-github-probe.d.ts.map +1 -0
  35. package/dist/lsp-github-probe.js +131 -0
  36. package/dist/lsp-github-probe.js.map +1 -0
  37. package/dist/lsp-github-table.d.ts +64 -0
  38. package/dist/lsp-github-table.d.ts.map +1 -0
  39. package/dist/lsp-github-table.js +174 -0
  40. package/dist/lsp-github-table.js.map +1 -0
  41. package/dist/migration.d.ts +10 -3
  42. package/dist/migration.d.ts.map +1 -1
  43. package/dist/migration.js +19 -14
  44. package/dist/migration.js.map +1 -1
  45. package/dist/onnx-runtime.d.ts +2 -0
  46. package/dist/onnx-runtime.d.ts.map +1 -1
  47. package/dist/onnx-runtime.js +12 -7
  48. package/dist/onnx-runtime.js.map +1 -1
  49. package/dist/pool.d.ts +6 -0
  50. package/dist/pool.d.ts.map +1 -1
  51. package/dist/pool.js +27 -7
  52. package/dist/pool.js.map +1 -1
  53. package/dist/project-identity.d.ts +2 -0
  54. package/dist/project-identity.d.ts.map +1 -1
  55. package/dist/project-identity.js +8 -2
  56. package/dist/project-identity.js.map +1 -1
  57. package/dist/protocol.d.ts +3 -0
  58. package/dist/protocol.d.ts.map +1 -1
  59. package/dist/protocol.js +14 -1
  60. package/dist/protocol.js.map +1 -1
  61. package/dist/resolver.d.ts.map +1 -1
  62. package/dist/resolver.js +5 -2
  63. package/dist/resolver.js.map +1 -1
  64. package/dist/revivable-transport.d.ts +12 -1
  65. package/dist/revivable-transport.d.ts.map +1 -1
  66. package/dist/revivable-transport.js +68 -7
  67. package/dist/revivable-transport.js.map +1 -1
  68. package/dist/subc-transport.d.ts +2 -0
  69. package/dist/subc-transport.d.ts.map +1 -1
  70. package/dist/subc-transport.js +81 -25
  71. package/dist/subc-transport.js.map +1 -1
  72. package/dist/tar-executable.d.ts +3 -1
  73. package/dist/tar-executable.d.ts.map +1 -1
  74. package/dist/tar-executable.js +35 -15
  75. package/dist/tar-executable.js.map +1 -1
  76. package/dist/transport-factory.d.ts.map +1 -1
  77. package/dist/transport-factory.js +8 -33
  78. package/dist/transport-factory.js.map +1 -1
  79. package/dist/transport.d.ts +2 -0
  80. package/dist/transport.d.ts.map +1 -1
  81. package/dist/transport.js.map +1 -1
  82. package/package.json +1 -1
package/dist/bridge.js CHANGED
@@ -6,6 +6,7 @@ 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 { isTerminalBashStatus } from "./protocol.js";
9
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;
@@ -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,13 +185,45 @@ 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;
203
200
  }
204
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
+ };
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" };
226
+ }
205
227
  /** A standalone bridge cannot carry a request because its process transport died. */
206
228
  export class BridgeTransportUnavailableError extends Error {
207
229
  code = "bridge_transport_unavailable";
@@ -279,6 +301,7 @@ export class BinaryBridge {
279
301
  _shuttingDown = false;
280
302
  timeoutMs;
281
303
  hangThreshold;
304
+ lateShellReplyGraceMs;
282
305
  maxRestarts;
283
306
  configured = false;
284
307
  _configurePromise = null;
@@ -296,6 +319,7 @@ export class BinaryBridge {
296
319
  statusListeners = new Set();
297
320
  /** Notification clients keyed by session_id for async configure warning pushes. */
298
321
  configureWarningClients = new Map();
322
+ restartTimer = null;
299
323
  restartResetTimer = null;
300
324
  /** Updated after every successfully parsed stdout frame from the child. */
301
325
  lastChildActivityAt = 0;
@@ -314,6 +338,8 @@ export class BinaryBridge {
314
338
  this.cwd = cwd;
315
339
  this.timeoutMs = options?.timeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS;
316
340
  this.hangThreshold = options?.hangThreshold ?? BRIDGE_HANG_TIMEOUT_THRESHOLD;
341
+ this.lateShellReplyGraceMs =
342
+ options?.lateShellReplyGraceMs ?? DEFAULT_LATE_SHELL_REPLY_GRACE_MS;
317
343
  this.maxRestarts = options?.maxRestarts ?? 3;
318
344
  this.errorPrefix = options?.errorPrefix ?? "[aft-bridge]";
319
345
  // P1 config relocation: semantic config now arrives as raw `config` tiers and
@@ -666,13 +692,6 @@ export class BinaryBridge {
666
692
  ? params.session_id
667
693
  : undefined;
668
694
  this.ensureSpawned(requestSessionId);
669
- // Auto-configure can reuse the initiating session's notification client
670
- // when the deferred configure warning frame arrives later. One project
671
- // bridge can serve many sessions, so keep this per-session instead of one
672
- // bridge-wide "last client".
673
- if (requestSessionId && options?.configureWarningClient !== undefined) {
674
- this.configureWarningClients.set(requestSessionId, options.configureWarningClient);
675
- }
676
695
  // Per-op timeout override: tool wrappers can pass longer budgets for
677
696
  // commands that legitimately need them (callers, trace_to, grep on big
678
697
  // repos). Defaults to the bridge-wide timeout otherwise.
@@ -697,6 +716,13 @@ export class BinaryBridge {
697
716
  if (!this.configured) {
698
717
  if (command !== "configure" && command !== "version") {
699
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
+ }
700
726
  const configuringChild = this.process;
701
727
  const requireConfiguringChild = () => {
702
728
  if (this.process !== configuringChild) {
@@ -737,6 +763,11 @@ export class BinaryBridge {
737
763
  }
738
764
  this.configured = true;
739
765
  }
766
+ catch (error) {
767
+ if (sessionIdForConfigure)
768
+ this.configureWarningClients.delete(sessionIdForConfigure);
769
+ throw error;
770
+ }
740
771
  finally {
741
772
  this._configurePromise = null;
742
773
  }
@@ -792,6 +823,10 @@ export class BinaryBridge {
792
823
  const entry = this.pending.get(id);
793
824
  if (!entry)
794
825
  return;
826
+ if (keepBridgeOnTimeout &&
827
+ this.holdForLateShellReply(id, entry, effectiveTimeoutMs, requestSessionId)) {
828
+ return;
829
+ }
795
830
  this.pending.delete(id);
796
831
  clearTimeout(entry.timer);
797
832
  entry.onSettled?.();
@@ -899,6 +934,13 @@ export class BinaryBridge {
899
934
  }
900
935
  }
901
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);
902
944
  if (!this.onConfigureWarnings)
903
945
  return;
904
946
  const warnings = Array.isArray(configResult.warnings)
@@ -907,57 +949,40 @@ export class BinaryBridge {
907
949
  const configDroppedKeys = coerceConfigureDroppedKeys(configResult.config_dropped_keys);
908
950
  if (warnings.length === 0 && configDroppedKeys.length === 0)
909
951
  return;
910
- const sessionId = typeof params.session_id === "string" ? params.session_id : undefined;
911
952
  const context = {
912
953
  projectRoot: this.cwd,
913
954
  sessionId,
914
- client: options?.configureWarningClient ??
915
- (sessionId ? this.configureWarningClients.get(sessionId) : undefined),
955
+ client,
916
956
  warnings,
917
957
  };
918
- if (configDroppedKeys.length > 0) {
958
+ if (configDroppedKeys.length > 0)
919
959
  context.configDroppedKeys = configDroppedKeys;
920
- }
921
- try {
922
- await this.onConfigureWarnings(context);
923
- }
924
- catch (err) {
925
- this.warnVia(`configure warning delivery failed: ${err instanceof Error ? err.message : String(err)}`);
926
- }
927
- finally {
928
- if (sessionId) {
929
- this.configureWarningClients.delete(sessionId);
930
- }
931
- }
960
+ this.dispatchHostCallback("configure warning", () => this.onConfigureWarnings?.(context));
932
961
  }
933
- /**
934
- * Handle the `configure_warnings` push frame the Rust binary emits after
935
- * configure has returned. The frame carries the warnings produced by the
936
- * deferred file walk + missing-binary detection. Forwards to the same
937
- * `onConfigureWarnings` handler used for synchronous warnings so plugins
938
- * don't need to know about the async path.
939
- */
962
+ /** Release deferred-warning ownership even when the terminal frame has no warnings. */
940
963
  async handleConfigureWarningsFrame(frame) {
941
- if (!this.onConfigureWarnings)
942
- return;
943
- const warnings = frame.warnings;
944
- 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)
945
969
  return;
946
- const projectRoot = typeof frame.project_root === "string" ? frame.project_root : this.cwd;
947
- const rawSessionId = frame.session_id;
948
- 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
+ };
949
981
  try {
950
- await this.onConfigureWarnings({
951
- projectRoot,
952
- sessionId,
953
- client: sessionId ? this.configureWarningClients.get(sessionId) : undefined,
954
- warnings: warnings,
955
- });
982
+ void Promise.resolve(callback()).catch(failed);
956
983
  }
957
- finally {
958
- if (sessionId) {
959
- this.configureWarningClients.delete(sessionId);
960
- }
984
+ catch (error) {
985
+ failed(error);
961
986
  }
962
987
  }
963
988
  handleStatusChangedFrame(frame) {
@@ -985,6 +1010,7 @@ export class BinaryBridge {
985
1010
  this._shuttingDown = true;
986
1011
  this.spawnedBinaryFingerprint = null;
987
1012
  this.clearRestartResetTimer();
1013
+ this.clearRestartTimer();
988
1014
  this.configureWarningClients.clear();
989
1015
  this.rejectAllPending(new BridgeTransportUnknownOutcomeError(`${this.errorPrefix} Bridge shutting down`));
990
1016
  if (this.process) {
@@ -1073,6 +1099,7 @@ export class BinaryBridge {
1073
1099
  this.spawnProcess(triggeringSessionId);
1074
1100
  }
1075
1101
  spawnProcess(triggeringSessionId) {
1102
+ this.clearRestartTimer();
1076
1103
  this._retiringDueToBinaryChange = false;
1077
1104
  if (triggeringSessionId) {
1078
1105
  this.sessionLogVia(triggeringSessionId, `Spawning binary: ${this.binaryPath} (cwd: ${this.cwd})`);
@@ -1157,6 +1184,13 @@ export class BinaryBridge {
1157
1184
  env: spawnEnv,
1158
1185
  });
1159
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
+ });
1160
1194
  const stdoutDecoder = new StringDecoder("utf8");
1161
1195
  child.stdout?.on("data", (chunk) => {
1162
1196
  if (this.process !== currentChild)
@@ -1288,11 +1322,12 @@ export class BinaryBridge {
1288
1322
  return `\n --- last ${this.stderrTail.length} stderr lines ---\n ${tail}`;
1289
1323
  }
1290
1324
  onStdoutData(data) {
1291
- // Unprocessed bytes are the carried partial line plus this chunk.
1292
- 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;
1293
1328
  this.handleCrash(new Error(`aft bridge stdout buffer exceeded ${MAX_STDOUT_BUFFER} bytes — killing bridge`));
1294
- return;
1295
- }
1329
+ return true;
1330
+ };
1296
1331
  // Only the new chunk is searched for newlines: the carried chunks are
1297
1332
  // already known to contain none.
1298
1333
  this.stdoutScannedChars += data.length;
@@ -1301,6 +1336,8 @@ export class BinaryBridge {
1301
1336
  let newlineIdx = data.indexOf("\n");
1302
1337
  while (newlineIdx !== -1) {
1303
1338
  let line = data.slice(start, newlineIdx);
1339
+ if (exceedsLimit(line))
1340
+ return;
1304
1341
  if (this.stdoutPending.length > 0) {
1305
1342
  this.stdoutPending.push(line);
1306
1343
  line = this.stdoutPending.join("");
@@ -1320,8 +1357,10 @@ export class BinaryBridge {
1320
1357
  }
1321
1358
  if (start < data.length) {
1322
1359
  const rest = start === 0 ? data : data.slice(start);
1360
+ if (exceedsLimit(rest))
1361
+ return;
1323
1362
  this.stdoutPending.push(rest);
1324
- this.stdoutPendingLength += rest.length;
1363
+ this.stdoutPendingLength += Buffer.byteLength(rest, "utf8");
1325
1364
  }
1326
1365
  }
1327
1366
  flushStdoutBuffer() {
@@ -1365,15 +1404,15 @@ export class BinaryBridge {
1365
1404
  if (taskId)
1366
1405
  this.outstandingBackgroundTaskIds.delete(taskId);
1367
1406
  this.syncEventLoopHold();
1368
- this.onBashCompletion?.(response, this);
1407
+ this.dispatchHostCallback("bash completion", () => this.onBashCompletion?.(response, this));
1369
1408
  return;
1370
1409
  }
1371
1410
  if (response.type === "bash_long_running") {
1372
- this.onBashLongRunning?.(response, this);
1411
+ this.dispatchHostCallback("bash long-running", () => this.onBashLongRunning?.(response, this));
1373
1412
  return;
1374
1413
  }
1375
1414
  if (response.type === "bash_pattern_match") {
1376
- this.onBashPatternMatch?.(response, this);
1415
+ this.dispatchHostCallback("bash pattern-match", () => this.onBashPatternMatch?.(response, this));
1377
1416
  return;
1378
1417
  }
1379
1418
  if (response.type === "configure_warnings") {
@@ -1411,6 +1450,18 @@ export class BinaryBridge {
1411
1450
  this.accountForBashTaskResponse(entry.command, response);
1412
1451
  entry.resolve(response);
1413
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
+ }
1414
1465
  else if (typeof response.type === "string") {
1415
1466
  this.logVia(`Ignoring unknown stdout push frame type: ${response.type}`);
1416
1467
  }
@@ -1423,11 +1474,54 @@ export class BinaryBridge {
1423
1474
  this.warnVia(`Failed to parse stdout line: ${quoted}`);
1424
1475
  }
1425
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
+ }
1426
1520
  accountForBashTaskResponse(command, response) {
1427
1521
  const taskId = bashTaskIdFrom(response);
1428
1522
  if (!taskId)
1429
1523
  return;
1430
- if (isTerminalBashStatus(response.status)) {
1524
+ if (isTerminalBashStatus(response.status, true)) {
1431
1525
  this.outstandingBackgroundTaskIds.delete(taskId);
1432
1526
  return;
1433
1527
  }
@@ -1486,6 +1580,7 @@ export class BinaryBridge {
1486
1580
  }
1487
1581
  }
1488
1582
  handleCrash(cause) {
1583
+ this.clearRestartTimer();
1489
1584
  const proc = this.process;
1490
1585
  this.process = null;
1491
1586
  this.spawnedBinaryFingerprint = null;
@@ -1517,7 +1612,8 @@ export class BinaryBridge {
1517
1612
  const delay = 100 * 2 ** this._restartCount; // 100ms, 200ms, 400ms
1518
1613
  this._restartCount++;
1519
1614
  this.logVia(`Auto-restart #${this._restartCount} in ${delay}ms`);
1520
- const restartTimer = setTimeout(() => {
1615
+ this.restartTimer = setTimeout(() => {
1616
+ this.restartTimer = null;
1521
1617
  if (!this._shuttingDown && !this.isAlive()) {
1522
1618
  try {
1523
1619
  this.spawnProcess();
@@ -1529,7 +1625,7 @@ export class BinaryBridge {
1529
1625
  }, delay);
1530
1626
  // The crash already rejected every pending request, so nothing waits on
1531
1627
  // this restart; it must not keep a host that is otherwise done alive.
1532
- restartTimer.unref?.();
1628
+ this.restartTimer.unref?.();
1533
1629
  // Also decay the counter over time so repeated crashes without any
1534
1630
  // successful response don't permanently wedge the bridge.
1535
1631
  this.scheduleRestartCountReset();
@@ -1540,6 +1636,7 @@ export class BinaryBridge {
1540
1636
  }
1541
1637
  }
1542
1638
  rejectAllPending(error) {
1639
+ this.configureWarningClients.clear();
1543
1640
  for (const [_id, entry] of this.pending) {
1544
1641
  clearTimeout(entry.timer);
1545
1642
  entry.onSettled?.();
@@ -1548,6 +1645,8 @@ export class BinaryBridge {
1548
1645
  this.pending.clear();
1549
1646
  }
1550
1647
  scheduleRestartCountReset() {
1648
+ if (this._restartCount === 0)
1649
+ return;
1551
1650
  this.clearRestartResetTimer();
1552
1651
  this.restartResetTimer = setTimeout(() => {
1553
1652
  this._restartCount = 0;
@@ -1556,6 +1655,11 @@ export class BinaryBridge {
1556
1655
  // Bookkeeping only: a five-minute counter reset must not hold the host open.
1557
1656
  this.restartResetTimer.unref?.();
1558
1657
  }
1658
+ clearRestartTimer() {
1659
+ if (this.restartTimer)
1660
+ clearTimeout(this.restartTimer);
1661
+ this.restartTimer = null;
1662
+ }
1559
1663
  clearRestartResetTimer() {
1560
1664
  if (this.restartResetTimer) {
1561
1665
  clearTimeout(this.restartResetTimer);