codex-chatgpt-control 0.5.1-alpha.2 → 0.5.1-alpha.4

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 (231) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +43 -0
  3. package/contracts/v1/fixtures/backend-capabilities.json +40 -1
  4. package/contracts/v1/fixtures/backend-compatibility.json +21 -0
  5. package/contracts/v1/fixtures/backend-version.json +6 -1
  6. package/contracts/v1/fixtures/download-blocked-by-browser.json +22 -0
  7. package/contracts/v1/fixtures/download-receipt-failed.json +22 -0
  8. package/contracts/v1/fixtures/download-receipt-timeout.json +22 -0
  9. package/contracts/v1/fixtures/journal-rpc-indeterminate.json +32 -0
  10. package/contracts/v1/fixtures/journal-rpc-unsupported-platform.json +32 -0
  11. package/contracts/v1/fixtures/journal-runtime-unavailable.json +32 -0
  12. package/contracts/v1/fixtures/operation-action-prepared-event.json +42 -0
  13. package/contracts/v1/fixtures/operation-action.json +13 -0
  14. package/contracts/v1/fixtures/operation-artifact-receipt.json +14 -0
  15. package/contracts/v1/fixtures/operation-artifact-transfer-intent-event.json +24 -0
  16. package/contracts/v1/fixtures/operation-artifact-transfer-receipt-event.json +26 -0
  17. package/contracts/v1/fixtures/operation-artifact-transfer-state.json +178 -0
  18. package/contracts/v1/fixtures/operation-blocker.json +10 -0
  19. package/contracts/v1/fixtures/operation-collect-request.json +17 -0
  20. package/contracts/v1/fixtures/operation-collect-result.json +42 -0
  21. package/contracts/v1/fixtures/operation-control-receipt.json +13 -0
  22. package/contracts/v1/fixtures/operation-control-request.json +18 -0
  23. package/contracts/v1/fixtures/operation-control-result.json +31 -0
  24. package/contracts/v1/fixtures/operation-event.json +18 -0
  25. package/contracts/v1/fixtures/operation-handle.json +10 -0
  26. package/contracts/v1/fixtures/operation-inspect-request.json +13 -0
  27. package/contracts/v1/fixtures/operation-inspect-result.json +205 -0
  28. package/contracts/v1/fixtures/operation-ownership-baseline-event.json +34 -0
  29. package/contracts/v1/fixtures/operation-receipt.json +32 -0
  30. package/contracts/v1/fixtures/operation-recovery-decision.json +7 -0
  31. package/contracts/v1/fixtures/operation-recovery-observation.json +12 -0
  32. package/contracts/v1/fixtures/operation-request.json +33 -0
  33. package/contracts/v1/fixtures/operation-state.json +144 -0
  34. package/contracts/v1/fixtures/operation-submission-witness-event.json +20 -0
  35. package/contracts/v1/fixtures/operation-submission-witness.json +11 -0
  36. package/contracts/v1/fixtures/operation-submit-result.json +16 -0
  37. package/contracts/v1/fixtures/operation-target-established-event.json +21 -0
  38. package/contracts/v1/manifest.json +185 -1
  39. package/contracts/v1/parity-suite.json +106 -8
  40. package/contracts/v1/schemas/backend-compatibility.schema.json +65 -0
  41. package/contracts/v1/schemas/backend-request.schema.json +8 -2
  42. package/contracts/v1/schemas/capabilities.schema.json +81 -1
  43. package/contracts/v1/schemas/manifest.schema.json +57 -0
  44. package/contracts/v1/schemas/operation-action.schema.json +155 -0
  45. package/contracts/v1/schemas/operation-artifact-receipt.schema.json +89 -0
  46. package/contracts/v1/schemas/operation-blocker.schema.json +70 -0
  47. package/contracts/v1/schemas/operation-collect-request.schema.json +46 -0
  48. package/contracts/v1/schemas/operation-collect-result.schema.json +74 -0
  49. package/contracts/v1/schemas/operation-control-receipt.schema.json +108 -0
  50. package/contracts/v1/schemas/operation-control-request.schema.json +111 -0
  51. package/contracts/v1/schemas/operation-control-result.schema.json +112 -0
  52. package/contracts/v1/schemas/operation-event.schema.json +756 -0
  53. package/contracts/v1/schemas/operation-handle.schema.json +55 -0
  54. package/contracts/v1/schemas/operation-inspect-request.schema.json +42 -0
  55. package/contracts/v1/schemas/operation-inspect-result.schema.json +1662 -0
  56. package/contracts/v1/schemas/operation-receipt.schema.json +153 -0
  57. package/contracts/v1/schemas/operation-recovery.schema.json +314 -0
  58. package/contracts/v1/schemas/operation-request.schema.json +158 -0
  59. package/contracts/v1/schemas/operation-state.schema.json +691 -0
  60. package/contracts/v1/schemas/operation-submission-witness.schema.json +48 -0
  61. package/contracts/v1/schemas/operation-submit-result.schema.json +94 -0
  62. package/contracts/v1/surface-drift-policy.json +50 -0
  63. package/contracts/v1/vectors/operation-request-digest-v1.json +43 -0
  64. package/dist/codex-chatgpt-control-backend.mjs +45882 -9361
  65. package/dist/codex-chatgpt-control-journal.mjs +5547 -0
  66. package/dist/codex-chatgpt-control-live-smoke.bundle.mjs +51677 -0
  67. package/dist/codex-chatgpt-control-release-canary.bundle.mjs +52104 -0
  68. package/dist/codex-chatgpt-control.bundle.mjs +48392 -10260
  69. package/dist/src/backend/client.d.ts +103 -2
  70. package/dist/src/backend/client.js +1436 -97
  71. package/dist/src/backend/compatibility.d.ts +12 -0
  72. package/dist/src/backend/compatibility.js +208 -0
  73. package/dist/src/backend/protocol.d.ts +84 -1
  74. package/dist/src/backend/protocol.js +31 -5
  75. package/dist/src/backend/runtime-identity.d.ts +10 -0
  76. package/dist/src/backend/runtime-identity.js +141 -0
  77. package/dist/src/backend/session.d.ts +10 -2
  78. package/dist/src/backend/session.js +478 -7
  79. package/dist/src/backend/stdio-server.d.ts +4 -1
  80. package/dist/src/backend/stdio-server.js +240 -28
  81. package/dist/src/browser/active-composer-file-input.d.ts +9 -0
  82. package/dist/src/browser/active-composer-file-input.js +33 -0
  83. package/dist/src/browser/attach.d.ts +4 -1
  84. package/dist/src/browser/attach.js +136 -28
  85. package/dist/src/browser/chatgpt-url.d.ts +2 -0
  86. package/dist/src/browser/chatgpt-url.js +9 -0
  87. package/dist/src/browser/downloads.d.ts +17 -1
  88. package/dist/src/browser/downloads.js +150 -28
  89. package/dist/src/browser/page-state.js +6 -0
  90. package/dist/src/client.d.ts +43 -0
  91. package/dist/src/client.js +1279 -59
  92. package/dist/src/commands/artifacts.js +6 -4
  93. package/dist/src/commands/chat-popover.d.ts +37 -0
  94. package/dist/src/commands/chat-popover.js +537 -0
  95. package/dist/src/commands/configuration.d.ts +5 -0
  96. package/dist/src/commands/configuration.js +134 -21
  97. package/dist/src/commands/context.js +10 -3
  98. package/dist/src/commands/doctor.d.ts +1 -1
  99. package/dist/src/commands/doctor.js +27 -4
  100. package/dist/src/commands/experience.d.ts +5 -0
  101. package/dist/src/commands/experience.js +104 -21
  102. package/dist/src/commands/files.js +14 -33
  103. package/dist/src/commands/modes.js +85 -21
  104. package/dist/src/commands/power-discovery.d.ts +130 -0
  105. package/dist/src/commands/power-discovery.js +669 -0
  106. package/dist/src/commands/project-sources.js +325 -31
  107. package/dist/src/commands/sequence.js +59 -32
  108. package/dist/src/commands/session.js +19 -6
  109. package/dist/src/commands/work.js +31 -0
  110. package/dist/src/dom/composer-text.d.ts +6 -0
  111. package/dist/src/dom/composer-text.js +85 -0
  112. package/dist/src/errors.d.ts +9 -0
  113. package/dist/src/errors.js +22 -0
  114. package/dist/src/index.d.ts +6 -0
  115. package/dist/src/index.js +9 -0
  116. package/dist/src/operations/artifact-output.d.ts +69 -0
  117. package/dist/src/operations/artifact-output.js +1748 -0
  118. package/dist/src/operations/artifact-stream.d.ts +19 -0
  119. package/dist/src/operations/artifact-stream.js +35 -0
  120. package/dist/src/operations/artifact-transfer.d.ts +149 -0
  121. package/dist/src/operations/artifact-transfer.js +1312 -0
  122. package/dist/src/operations/browser-adapter.d.ts +167 -0
  123. package/dist/src/operations/browser-adapter.js +2103 -0
  124. package/dist/src/operations/browser-observation.d.ts +110 -0
  125. package/dist/src/operations/browser-observation.js +1256 -0
  126. package/dist/src/operations/browser-target.d.ts +105 -0
  127. package/dist/src/operations/browser-target.js +531 -0
  128. package/dist/src/operations/canonical.d.ts +4 -0
  129. package/dist/src/operations/canonical.js +412 -0
  130. package/dist/src/operations/chatgpt-runtime.d.ts +72 -0
  131. package/dist/src/operations/chatgpt-runtime.js +1232 -0
  132. package/dist/src/operations/client.d.ts +138 -0
  133. package/dist/src/operations/client.js +1145 -0
  134. package/dist/src/operations/collector.d.ts +195 -0
  135. package/dist/src/operations/collector.js +1039 -0
  136. package/dist/src/operations/configuration-routing.d.ts +4 -0
  137. package/dist/src/operations/configuration-routing.js +14 -0
  138. package/dist/src/operations/control.d.ts +372 -0
  139. package/dist/src/operations/control.js +1498 -0
  140. package/dist/src/operations/file-identity.d.ts +33 -0
  141. package/dist/src/operations/file-identity.js +153 -0
  142. package/dist/src/operations/handle.d.ts +19 -0
  143. package/dist/src/operations/handle.js +727 -0
  144. package/dist/src/operations/index.d.ts +25 -0
  145. package/dist/src/operations/index.js +25 -0
  146. package/dist/src/operations/journal-authority.d.ts +12 -0
  147. package/dist/src/operations/journal-authority.js +1 -0
  148. package/dist/src/operations/journal-rpc-client.d.ts +12 -0
  149. package/dist/src/operations/journal-rpc-client.js +225 -0
  150. package/dist/src/operations/journal-rpc-protocol.d.ts +32 -0
  151. package/dist/src/operations/journal-rpc-protocol.js +214 -0
  152. package/dist/src/operations/journal-rpc-server.d.ts +15 -0
  153. package/dist/src/operations/journal-rpc-server.js +272 -0
  154. package/dist/src/operations/journal.d.ts +143 -0
  155. package/dist/src/operations/journal.js +1957 -0
  156. package/dist/src/operations/production-attachments.d.ts +118 -0
  157. package/dist/src/operations/production-attachments.js +1035 -0
  158. package/dist/src/operations/production-chatgpt-artifacts.d.ts +74 -0
  159. package/dist/src/operations/production-chatgpt-artifacts.js +1146 -0
  160. package/dist/src/operations/production-chatgpt-attachments.d.ts +80 -0
  161. package/dist/src/operations/production-chatgpt-attachments.js +1639 -0
  162. package/dist/src/operations/production-configuration.d.ts +42 -0
  163. package/dist/src/operations/production-configuration.js +1544 -0
  164. package/dist/src/operations/production-primitives.d.ts +53 -0
  165. package/dist/src/operations/production-primitives.js +856 -0
  166. package/dist/src/operations/production-work-steer.d.ts +203 -0
  167. package/dist/src/operations/production-work-steer.js +1218 -0
  168. package/dist/src/operations/recovery.d.ts +80 -0
  169. package/dist/src/operations/recovery.js +174 -0
  170. package/dist/src/operations/runtime-adapter.d.ts +126 -0
  171. package/dist/src/operations/runtime-adapter.js +802 -0
  172. package/dist/src/operations/send-once.d.ts +224 -0
  173. package/dist/src/operations/send-once.js +1081 -0
  174. package/dist/src/operations/service.d.ts +286 -0
  175. package/dist/src/operations/service.js +2831 -0
  176. package/dist/src/operations/staging.d.ts +136 -0
  177. package/dist/src/operations/staging.js +630 -0
  178. package/dist/src/operations/state-machine.d.ts +29 -0
  179. package/dist/src/operations/state-machine.js +2047 -0
  180. package/dist/src/operations/submission.d.ts +423 -0
  181. package/dist/src/operations/submission.js +1676 -0
  182. package/dist/src/operations/transactional-chat-power.d.ts +17 -0
  183. package/dist/src/operations/transactional-chat-power.js +212 -0
  184. package/dist/src/operations/turn-ownership.d.ts +202 -0
  185. package/dist/src/operations/turn-ownership.js +700 -0
  186. package/dist/src/operations/types.d.ts +454 -0
  187. package/dist/src/operations/types.js +20 -0
  188. package/dist/src/operations/wire-requests.d.ts +21 -0
  189. package/dist/src/operations/wire-requests.js +396 -0
  190. package/dist/src/operations/wire-results.d.ts +97 -0
  191. package/dist/src/operations/wire-results.js +818 -0
  192. package/dist/src/runner/responses.d.ts +3 -1
  193. package/dist/src/runner/responses.js +19 -1
  194. package/dist/src/runner/result.js +227 -55
  195. package/dist/src/runner/types.d.ts +12 -0
  196. package/dist/src/runtime/command-routing.d.ts +149 -0
  197. package/dist/src/runtime/command-routing.js +431 -0
  198. package/dist/src/runtime/coordinated-browser.d.ts +24 -0
  199. package/dist/src/runtime/coordinated-browser.js +315 -0
  200. package/dist/src/runtime/coordinated-page.d.ts +41 -0
  201. package/dist/src/runtime/coordinated-page.js +651 -0
  202. package/dist/src/runtime/operation-context.d.ts +142 -0
  203. package/dist/src/runtime/operation-context.js +410 -0
  204. package/dist/src/runtime/runtime-session.d.ts +95 -0
  205. package/dist/src/runtime/runtime-session.js +314 -0
  206. package/dist/src/runtime/tab-coordinator.d.ts +200 -0
  207. package/dist/src/runtime/tab-coordinator.js +1200 -0
  208. package/dist/src/runtime/value-boundaries.d.ts +18 -0
  209. package/dist/src/runtime/value-boundaries.js +49 -0
  210. package/dist/src/safety/blockers.js +8 -1
  211. package/dist/src/safety/untrusted-output.js +2 -1
  212. package/dist/src/scripts/backend-server.js +4 -1
  213. package/dist/src/scripts/capture-surface-profile.js +7 -2
  214. package/dist/src/scripts/journal-server.d.ts +2 -0
  215. package/dist/src/scripts/journal-server.js +37 -0
  216. package/dist/src/scripts/live-smoke/harness.js +79 -7
  217. package/dist/src/scripts/live-smoke/scenarios.d.ts +42 -1
  218. package/dist/src/scripts/live-smoke/scenarios.js +147 -30
  219. package/dist/src/scripts/live-smoke/transactional.d.ts +17 -0
  220. package/dist/src/scripts/live-smoke/transactional.js +254 -0
  221. package/dist/src/scripts/live-smoke/types.d.ts +3 -0
  222. package/dist/src/scripts/release-canary-module.js +17 -4
  223. package/dist/src/types.d.ts +38 -1
  224. package/package.json +8 -3
  225. package/references/2026-08-16-transactional-operations.md +459 -0
  226. package/references/2026-09-06-journal-service.md +140 -0
  227. package/references/agents-runner.md +34 -0
  228. package/references/backend-protocol.md +176 -0
  229. package/references/python-parity.md +137 -0
  230. package/references/responses-adapter.md +19 -0
  231. package/references/streaming.md +6 -0
@@ -1,7 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createInterface } from "node:readline";
2
+ import { randomUUID } from "node:crypto";
3
+ import { TextDecoder } from "node:util";
3
4
  import { createChatGPTAgent } from "../runner/agent.js";
4
- import { BACKEND_REQUEST_SCHEMA_VERSION, BACKEND_RESPONSE_SCHEMA_VERSION, BACKEND_EVENT_SCHEMA_VERSION } from "./protocol.js";
5
+ import { OperationWireRequestError, validateOperationCollectRequest as validateWireCollectRequest, validateOperationControlRequest as validateWireControlRequest, validateOperationInspectRequest as validateWireInspectRequest, validateOperationSubmitRequest as validateWireSubmitRequest } from "../operations/wire-requests.js";
6
+ import { validateOperationCollectWireResult, validateOperationControlWireResult, validateOperationInspectWireResult, validateOperationSubmitWireResult } from "../operations/wire-results.js";
7
+ import { BACKEND_REQUEST_SCHEMA_VERSION, BACKEND_RESPONSE_SCHEMA_VERSION, BACKEND_EVENT_SCHEMA_VERSION, BACKEND_HELLO_COMMAND, BACKEND_CONTROL_REQUEST_ID_PREFIX, BACKEND_NDJSON_FRAME_LIMIT_BYTES, isValidBackendRequestId } from "./protocol.js";
8
+ import { blockedCompatibilityReport, compatibilityReportFromHello, compatibilityReportFromLegacy, validateBackendCompatibilityReport } from "./compatibility.js";
5
9
  export class BackendClientError extends Error {
6
10
  code;
7
11
  recoverable;
@@ -14,29 +18,66 @@ export class BackendClientError extends Error {
14
18
  }
15
19
  export function createChatGPTBackendClient(transport) {
16
20
  let nextRequestId = 0;
21
+ const requestIdPrefix = `req_${process.pid}_${randomUUID()}`;
22
+ const allocateRequestId = () => `${requestIdPrefix}_${++nextRequestId}`;
17
23
  const request = async (command, payload = {}) => {
18
24
  const response = await transport.request({
19
25
  schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
20
- requestId: `req_${++nextRequestId}`,
26
+ requestId: allocateRequestId(),
21
27
  command,
22
28
  payload
23
29
  });
24
30
  return unwrapResponse(response);
25
31
  };
32
+ const operations = {
33
+ submit: async (operationRequest) => {
34
+ validateOperationSubmitRequest(operationRequest);
35
+ return parseOperationResult(await request("operations.submit", operationRequest), validateOperationSubmitWireResult);
36
+ },
37
+ collect: async (operationRequest) => {
38
+ validateOperationCollectRequest(operationRequest);
39
+ return parseOperationResult(await request("operations.collect", operationRequest), validateOperationCollectWireResult);
40
+ },
41
+ inspect: async (operationRequest) => {
42
+ validateOperationInspectRequest(operationRequest);
43
+ const result = parseOperationResult(await request("operations.inspect", operationRequest), validateOperationInspectWireResult);
44
+ return attachOperationCompatibility(result, transport);
45
+ },
46
+ control: async (operationRequest) => {
47
+ validateOperationControlRequest(operationRequest);
48
+ return parseOperationResult(await request("operations.control", operationRequest), validateOperationControlWireResult);
49
+ }
50
+ };
51
+ const compatibility = () => {
52
+ const report = transport.getCompatibilityReport?.();
53
+ if (report === undefined)
54
+ return undefined;
55
+ try {
56
+ return validateBackendCompatibilityReport(report);
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ };
26
62
  const runner = {
27
63
  run: (agent, input) => request("runner.run", { agent, input }),
28
64
  plan: (agent, input) => request("runner.plan", { agent, input }),
29
- stream: (agent, input) => streamFromBackendEvents(transport.stream({
30
- schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
31
- requestId: `req_${++nextRequestId}`,
32
- command: "runner.stream",
33
- payload: { agent, input }
34
- }))
65
+ stream: (agent, input) => {
66
+ const requestId = allocateRequestId();
67
+ return streamFromBackendEvents(transport.stream({
68
+ schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
69
+ requestId,
70
+ command: "runner.stream",
71
+ payload: { agent, input }
72
+ }), () => transport.cancel?.(requestId));
73
+ }
35
74
  };
36
75
  return {
37
76
  agent: config => createChatGPTAgent(config),
38
77
  run: runner.run,
39
78
  runner,
79
+ compatibility,
80
+ operations,
40
81
  responses: {
41
82
  create: args => request("responses.create", args)
42
83
  },
@@ -53,7 +94,10 @@ export function createChatGPTBackendClient(transport) {
53
94
  copyLatest: args => request("copyLatest", args ?? {}),
54
95
  downloadLatest: args => request("downloadLatest", args),
55
96
  runPlan: plan => request("runPlan", plan),
56
- doctor: args => request("doctor", args ?? {}),
97
+ doctor: async (args) => {
98
+ const result = await request("doctor", args ?? {});
99
+ return attachDoctorCompatibility(result, args, compatibility());
100
+ },
57
101
  createReport: (result, args) => request("createReport", args === undefined ? { result } : { result, args }),
58
102
  reports: {
59
103
  create: (result, args) => request("reports.create", args === undefined ? { result } : { result, args }),
@@ -123,53 +167,235 @@ export function createChatGPTBackendClient(transport) {
123
167
  };
124
168
  }
125
169
  const DEFAULT_BACKEND_TIMEOUT_MS = 600_000;
170
+ const DEFAULT_BACKEND_HANDSHAKE_TIMEOUT_MS = 10_000;
171
+ // One slot is reserved for the first caller while the transport performs its
172
+ // hello/legacy probes. A lower bound of two keeps that control route inside
173
+ // the aggregate bound instead of making the first request impossible.
174
+ const MIN_BACKEND_IN_FLIGHT_LIMIT = 2;
175
+ const DEFAULT_BACKEND_MAX_IN_FLIGHT = 256;
176
+ const DEFAULT_BACKEND_STREAM_QUEUE_LIMIT = 256;
177
+ const DEFAULT_BACKEND_STREAM_QUEUE_BYTES_LIMIT = 16 * 1024 * 1024;
178
+ const DEFAULT_BACKEND_WRITE_QUEUE_LIMIT = 256;
179
+ const DEFAULT_BACKEND_WRITE_QUEUE_BYTES_LIMIT = 16 * 1024 * 1024;
180
+ const DEFAULT_BACKEND_LATE_OUTPUT_GRACE_MS = 5_000;
181
+ const DEFAULT_BACKEND_TOMBSTONE_LIMIT = 256;
182
+ const DEFAULT_BACKEND_QUARANTINE_LIMIT = 256;
183
+ const MAX_BACKEND_BUFFER_LIMIT = 1_000_000;
184
+ const MAX_BACKEND_STREAM_QUEUE_BYTES_LIMIT = 64 * 1024 * 1024;
185
+ const MAX_BACKEND_WRITE_QUEUE_BYTES_LIMIT = 64 * 1024 * 1024;
186
+ const MAX_BACKEND_TIMER_MS = 2_147_483_647;
187
+ const MAX_BACKEND_IDENTITY_FIELD_LENGTH = 512;
188
+ const REQUIRED_NEGOTIATION_COMMANDS = [
189
+ "backend.hello",
190
+ "backend.version",
191
+ "backend.capabilities",
192
+ "backend.health",
193
+ "runner.run",
194
+ "runner.stream"
195
+ ];
196
+ const LEGACY_HELLO_ERROR_CODES = new Set(["unknown_command"]);
126
197
  export class StdioBackendTransport {
127
198
  options;
128
199
  child;
129
200
  stdout;
130
201
  pendingResponses = new Map();
131
202
  pendingStreams = new Map();
132
- stderrText = "";
203
+ waitingRequests = new Map();
204
+ waitingStreams = new Map();
205
+ // A caller route remains in this set while it waits for handshake or a
206
+ // legacy single-flight slot. Once promoted, activeRequestIds owns its
207
+ // admission through the terminal response/event.
208
+ waitingAdmissionIds = new Set();
209
+ activeRequestIds = new Set();
210
+ // Control routes are a subset of activeRequestIds. Keeping this explicit
211
+ // lets admission reserve virtual handshake headroom between sequential
212
+ // legacy probes while still using the full bound during an active probe.
213
+ activeControlRequestIds = new Set();
214
+ activeWrites = new Set();
215
+ writeQueueCount = 0;
216
+ writeQueueBytes = 0;
217
+ tombstones = new Map();
218
+ quarantinedRequestIds = new Map();
219
+ // Keep one lifecycle tail across child generations. A reset while an old
220
+ // stdin write is unresolved would orphan its queued line closures and let
221
+ // repeated recycle cycles accumulate memory outside the admission budget.
222
+ writeTail = Promise.resolve();
223
+ retiredWriteTail;
224
+ recycleBlockedByWriteTeardown = false;
225
+ legacyTail = Promise.resolve();
226
+ handshakeState = "unknown";
227
+ handshakePromise;
228
+ handshakeGeneration = 0;
229
+ requestIdPrefix = `transport_${process.pid}_${randomUUID()}`;
230
+ handshakeError;
231
+ compatibilityReport;
232
+ protocolQuarantined = false;
233
+ quarantineRecycleTimer;
234
+ tombstoneRecycleTimer;
235
+ stderrBytes = 0;
236
+ stderrTruncated = false;
237
+ closed = false;
133
238
  constructor(options) {
134
239
  this.options = options;
240
+ validateTransportOptions(options);
135
241
  }
136
242
  async request(request) {
137
243
  const requestId = requireRequestId(request);
138
- this.start();
244
+ this.reserveWaitingAdmission(requestId);
139
245
  return new Promise((resolve, reject) => {
140
- const timeout = this.createDeadline(requestId);
141
- this.pendingResponses.set(requestId, { resolve, reject, timeout });
142
- this.write(request, error => {
143
- this.clearResponse(requestId);
246
+ let settled = false;
247
+ const cancelWaiting = error => {
248
+ if (settled)
249
+ return false;
250
+ settled = true;
251
+ this.waitingRequests.delete(requestId);
252
+ this.releaseWaitingAdmission(requestId);
253
+ this.releaseRequestId(requestId, undefined);
144
254
  reject(error);
145
- });
255
+ return true;
256
+ };
257
+ this.waitingRequests.set(requestId, cancelWaiting);
258
+ void (async () => {
259
+ let legacyRelease;
260
+ try {
261
+ await this.ensureHandshake();
262
+ if (settled)
263
+ return;
264
+ this.promoteWaitingAdmission(requestId);
265
+ legacyRelease = isSingleFlightState(this.handshakeState)
266
+ ? await this.acquireLegacySlot()
267
+ : undefined;
268
+ if (settled) {
269
+ legacyRelease?.();
270
+ return;
271
+ }
272
+ this.assertCanIssue();
273
+ this.waitingRequests.delete(requestId);
274
+ const response = await this.issueResponse(request, legacyRelease !== undefined);
275
+ legacyRelease?.();
276
+ if (settled)
277
+ return;
278
+ settled = true;
279
+ resolve(response);
280
+ }
281
+ catch (error) {
282
+ if (settled)
283
+ return;
284
+ settled = true;
285
+ this.waitingRequests.delete(requestId);
286
+ // If the request never reached stdin there is no late output to
287
+ // guard against, so release the reservation without a tombstone.
288
+ legacyRelease?.();
289
+ this.releaseWaitingAdmission(requestId);
290
+ this.releaseRequestId(requestId, undefined);
291
+ reject(error);
292
+ }
293
+ })();
146
294
  });
147
295
  }
148
296
  stream(request) {
149
297
  const requestId = requireRequestId(request);
150
- this.start();
151
- const queue = new AsyncQueue();
152
- const timeout = this.createDeadline(requestId);
153
- this.pendingStreams.set(requestId, { queue, timeout });
154
- this.write(request, error => {
155
- this.clearStream(requestId);
298
+ const queue = new AsyncQueue(this.options.streamQueueLimit ?? DEFAULT_BACKEND_STREAM_QUEUE_LIMIT, () => {
299
+ this.cancel(requestId, new BackendClientError("backend_stream_iterator_closed", `Backend stream requestId ${requestId} was abandoned by its iterator.`, true));
300
+ }, this.options.streamQueueBytesLimit ?? DEFAULT_BACKEND_STREAM_QUEUE_BYTES_LIMIT);
301
+ try {
302
+ this.reserveWaitingAdmission(requestId);
303
+ }
304
+ catch (error) {
305
+ queue.fail(error);
306
+ return queue;
307
+ }
308
+ let settled = false;
309
+ const cancelWaiting = error => {
310
+ if (settled)
311
+ return false;
312
+ settled = true;
313
+ this.waitingStreams.delete(requestId);
314
+ this.releaseWaitingAdmission(requestId);
315
+ this.releaseRequestId(requestId, undefined);
316
+ queue.fail(error);
317
+ return true;
318
+ };
319
+ this.waitingStreams.set(requestId, cancelWaiting);
320
+ void Promise.resolve().then(() => this.ensureHandshake())
321
+ .then(() => {
322
+ if (settled)
323
+ return;
324
+ const legacyReleasePromise = isSingleFlightState(this.handshakeState)
325
+ ? this.acquireLegacySlot()
326
+ : Promise.resolve(undefined);
327
+ return legacyReleasePromise.then(legacyRelease => {
328
+ if (settled) {
329
+ legacyRelease?.();
330
+ return;
331
+ }
332
+ try {
333
+ this.promoteWaitingAdmission(requestId);
334
+ this.assertCanIssue();
335
+ }
336
+ catch (error) {
337
+ legacyRelease?.();
338
+ throw error;
339
+ }
340
+ this.waitingStreams.delete(requestId);
341
+ return this.issueStream(request, queue, legacyRelease);
342
+ });
343
+ })
344
+ .catch(error => {
345
+ if (settled)
346
+ return;
347
+ settled = true;
348
+ this.waitingStreams.delete(requestId);
349
+ this.releaseWaitingAdmission(requestId);
350
+ this.releaseRequestId(requestId, undefined);
156
351
  queue.fail(error);
157
352
  });
158
353
  return queue;
159
354
  }
355
+ cancel(requestId, reason) {
356
+ const cancellationError = reason ?? new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled locally.`, true);
357
+ const waitingRequest = this.waitingRequests.get(requestId);
358
+ if (waitingRequest !== undefined)
359
+ return waitingRequest(cancellationError);
360
+ const waitingStream = this.waitingStreams.get(requestId);
361
+ if (waitingStream !== undefined)
362
+ return waitingStream(cancellationError);
363
+ const response = this.pendingResponses.get(requestId);
364
+ if (response !== undefined) {
365
+ const writeStarted = this.hasStartedWrite(requestId);
366
+ this.clearResponse(requestId, true);
367
+ response.reject(cancellationError);
368
+ if (writeStarted)
369
+ this.terminate(cancellationError);
370
+ return true;
371
+ }
372
+ const stream = this.pendingStreams.get(requestId);
373
+ if (stream !== undefined) {
374
+ const writeStarted = this.hasStartedWrite(requestId);
375
+ this.clearStream(requestId, true);
376
+ stream.queue.fail(cancellationError);
377
+ if (writeStarted)
378
+ this.terminate(cancellationError);
379
+ return true;
380
+ }
381
+ return false;
382
+ }
160
383
  async close() {
384
+ this.closed = true;
161
385
  const child = this.child;
162
- if (child === undefined)
386
+ if (child === undefined) {
387
+ this.failAll(new BackendClientError("backend_closed", "Backend transport was closed.", true));
163
388
  return;
164
- this.child = undefined;
165
- this.stdout?.close();
166
- this.stdout = undefined;
167
- child.removeAllListeners("error");
168
- child.removeAllListeners("exit");
169
- child.kill();
170
- this.failAll(new BackendClientError("backend_closed", "Backend transport was closed.", true));
389
+ }
390
+ this.terminate(new BackendClientError("backend_closed", "Backend transport was closed.", true), child);
171
391
  }
172
392
  start() {
393
+ if (this.closed) {
394
+ throw new BackendClientError("backend_closed", "Backend transport is closed.", true);
395
+ }
396
+ if (this.recycleBlockedByWriteTeardown) {
397
+ throw new BackendClientError("backend_write_teardown_pending", "Backend transport cannot start a new child while a previous stdin write is unresolved.", true);
398
+ }
173
399
  if (this.child !== undefined)
174
400
  return;
175
401
  const [command, ...args] = this.options.command;
@@ -182,45 +408,344 @@ export class StdioBackendTransport {
182
408
  stdio: ["pipe", "pipe", "pipe"]
183
409
  });
184
410
  this.child = child;
185
- this.stdout = createInterface({ input: child.stdout, crlfDelay: Infinity });
186
- this.stdout.on("line", line => {
187
- if (this.child !== child)
188
- return;
189
- this.handleLine(line);
190
- });
411
+ this.handshakeState = "unknown";
412
+ this.handshakeError = undefined;
413
+ this.compatibilityReport = undefined;
414
+ // A new child generation starts before its first hello route is charged.
415
+ // Normal teardown calls failAll(), but keep the control subset explicit
416
+ // here as a defensive reset for a child that never reached that path.
417
+ this.activeControlRequestIds.clear();
418
+ this.protocolQuarantined = false;
419
+ this.clearQuarantineRecycleTimer();
420
+ this.clearTombstoneRecycleTimer();
421
+ this.tombstones.clear();
422
+ this.quarantinedRequestIds.clear();
423
+ // Do not reset writeTail here. If an old child has not settled its stdin
424
+ // callback yet, new-generation writes remain bounded behind that one
425
+ // lifecycle tail instead of creating an untracked queue.
426
+ this.legacyTail = Promise.resolve();
427
+ this.stderrBytes = 0;
428
+ this.stderrTruncated = false;
429
+ this.stdout = child.stdout;
430
+ void this.readStdout(child);
191
431
  child.stderr.setEncoding("utf8");
192
432
  child.stderr.on("data", chunk => {
193
433
  if (this.child !== child)
194
434
  return;
195
- this.stderrText = `${this.stderrText}${String(chunk)}`.slice(-4000);
435
+ const bytes = Buffer.byteLength(String(chunk));
436
+ this.stderrBytes = Math.min(MAX_BACKEND_BUFFER_LIMIT, this.stderrBytes + bytes);
437
+ if (this.stderrBytes >= MAX_BACKEND_BUFFER_LIMIT)
438
+ this.stderrTruncated = true;
196
439
  });
197
440
  child.on("error", error => {
198
441
  if (this.child !== child)
199
442
  return;
200
- this.child = undefined;
201
- this.stdout?.close();
202
- this.stdout = undefined;
203
- this.failAll(error);
443
+ this.handleProcessFailure(child, error);
204
444
  });
205
445
  child.on("exit", (code, signal) => {
206
446
  if (this.child !== child)
207
447
  return;
208
- const suffix = this.stderrText.length > 0 ? ` stderr=${this.stderrText}` : "";
209
- this.child = undefined;
210
- this.stdout?.close();
211
- this.stdout = undefined;
212
- this.failAll(new BackendClientError("backend_exited", `Backend process exited with code ${String(code)} signal ${String(signal)}.${suffix}`, true));
448
+ const suffix = this.stderrBytes > 0
449
+ ? ` stderr_present=true stderr_bytes=${this.stderrBytes}${this.stderrTruncated ? " stderr_truncated=true" : ""}`
450
+ : "";
451
+ this.handleProcessFailure(child, new BackendClientError("backend_exited", `Backend process exited with code ${String(code)} signal ${String(signal)}.${suffix}`, true));
452
+ });
453
+ }
454
+ async readStdout(child) {
455
+ try {
456
+ for await (const line of readBoundedNdjsonLines(child.stdout, this.frameLimitBytes())) {
457
+ if (this.child !== child)
458
+ return;
459
+ this.handleLine(line);
460
+ }
461
+ // Node emits the child exit event after stdout closes in the normal
462
+ // process-failure path; let that authoritative lifecycle signal carry
463
+ // the public error instead of racing it with a synthetic EOF failure.
464
+ }
465
+ catch (error) {
466
+ if (this.child !== child)
467
+ return;
468
+ const protocolError = error instanceof BackendFrameError
469
+ ? new BackendClientError(error.code, error.message, true)
470
+ : new BackendClientError("invalid_backend_framing", "Backend stdout framing failed.", true);
471
+ this.terminate(protocolError, child);
472
+ }
473
+ }
474
+ ensureHandshake() {
475
+ if (this.closed) {
476
+ return Promise.reject(new BackendClientError("backend_closed", "Backend transport is closed.", true));
477
+ }
478
+ if (this.handshakeState === "ready" || isSingleFlightState(this.handshakeState))
479
+ return Promise.resolve();
480
+ if (this.handshakeState === "blocked") {
481
+ return Promise.reject(this.handshakeError ?? new BackendClientError("backend_hello_rejected", "Backend hello negotiation has blocked this backend transport.", false));
482
+ }
483
+ if (this.handshakePromise !== undefined)
484
+ return this.handshakePromise;
485
+ this.start();
486
+ const requestId = `${BACKEND_CONTROL_REQUEST_ID_PREFIX}${this.requestIdPrefix}_hello_${++this.handshakeGeneration}`;
487
+ this.reserveRequestId(requestId, true);
488
+ const request = {
489
+ schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
490
+ requestId,
491
+ command: BACKEND_HELLO_COMMAND,
492
+ payload: {
493
+ protocolVersion: BACKEND_REQUEST_SCHEMA_VERSION,
494
+ capabilities: {
495
+ commands: [...REQUIRED_NEGOTIATION_COMMANDS],
496
+ transports: ["stdio"],
497
+ streaming: { modes: ["ndjson"], tokenDeltas: false },
498
+ supportedProtocolVersions: [BACKEND_REQUEST_SCHEMA_VERSION],
499
+ requestIds: { required: true, scope: "connection" },
500
+ multiplexing: { unary: true, streams: true },
501
+ cancellation: { supported: false, requests: false, streams: false },
502
+ tabs: {
503
+ stableProviderIdentity: false,
504
+ stableBrowserIdentity: false,
505
+ stableTabIdentity: false,
506
+ coordinationScope: "none",
507
+ authoritativeClaim: false,
508
+ fencing: false,
509
+ concurrentTabs: false,
510
+ stableIdentity: false,
511
+ coordination: false,
512
+ concurrent: false
513
+ }
514
+ }
515
+ }
516
+ };
517
+ this.handshakePromise = this.issueResponse(request, true, true)
518
+ .then(response => {
519
+ if (!response.ok && LEGACY_HELLO_ERROR_CODES.has(response.error.code)) {
520
+ return this.negotiateLegacyBackend();
521
+ }
522
+ if (!response.ok) {
523
+ this.compatibilityReport = blockedCompatibilityReport();
524
+ throw new BackendClientError(response.error.code, response.error.message, response.error.recoverable);
525
+ }
526
+ if (!isNegotiatedHello(response.result, request.payload)) {
527
+ this.compatibilityReport = blockedCompatibilityReport();
528
+ throw new BackendClientError("backend_hello_rejected", "Backend hello negotiation was malformed or did not advertise the required transport capabilities.", false);
529
+ }
530
+ const multiplexed = negotiatedMultiplexing(response.result);
531
+ this.compatibilityReport = compatibilityReportFromHello(response.result, this.options.expectedIdentity, multiplexed ? "multiplexed" : "single-flight");
532
+ this.handshakeState = multiplexed ? "ready" : "single-flight";
533
+ })
534
+ .catch(error => {
535
+ if (error instanceof BackendClientError && error.code === "backend_hello_rejected") {
536
+ // A failed negotiation is not a usable legacy/modern route. Kill
537
+ // the sidecar before caching the rejection so stray output cannot
538
+ // keep a rejected process alive or be mistaken for a later session.
539
+ this.terminate(error);
540
+ this.handshakeError = error;
541
+ this.handshakeState = "blocked";
542
+ }
543
+ else {
544
+ this.handshakeState = "unknown";
545
+ }
546
+ throw error;
547
+ })
548
+ .finally(() => {
549
+ this.handshakePromise = undefined;
550
+ this.maybeRecycleQuarantined();
551
+ });
552
+ return this.handshakePromise;
553
+ }
554
+ async negotiateLegacyBackend() {
555
+ const probes = new Map();
556
+ for (const command of ["backend.version", "backend.capabilities"]) {
557
+ const requestId = `${BACKEND_CONTROL_REQUEST_ID_PREFIX}${this.requestIdPrefix}_legacy_${++this.handshakeGeneration}`;
558
+ this.reserveRequestId(requestId, true);
559
+ const response = await this.issueResponse({
560
+ schemaVersion: BACKEND_REQUEST_SCHEMA_VERSION,
561
+ requestId,
562
+ command,
563
+ payload: {}
564
+ }, true, true);
565
+ if (!response.ok) {
566
+ throw new BackendClientError("backend_hello_rejected", `Legacy backend ${command} probe did not return a successful compatible result.`, false);
567
+ }
568
+ probes.set(command, response);
569
+ }
570
+ const versionProbe = probes.get("backend.version");
571
+ const capabilitiesProbe = probes.get("backend.capabilities");
572
+ const version = versionProbe?.ok === true ? versionProbe.result : undefined;
573
+ const capabilities = capabilitiesProbe?.ok === true ? capabilitiesProbe.result : undefined;
574
+ if (!isCompatibleLegacyVersion(version) || !isCompatibleLegacyCapabilities(capabilities)) {
575
+ this.compatibilityReport = blockedCompatibilityReport();
576
+ throw new BackendClientError("backend_hello_rejected", "Legacy backend probes did not advertise a compatible protocol and command set.", false);
577
+ }
578
+ this.compatibilityReport = compatibilityReportFromLegacy(version, this.options.expectedIdentity);
579
+ this.handshakeState = "legacy";
580
+ }
581
+ getCompatibilityReport() {
582
+ return this.compatibilityReport;
583
+ }
584
+ issueResponse(request, fatalOnTimeout, handshake = false) {
585
+ const requestId = requireRequestId(request);
586
+ return new Promise((resolve, reject) => {
587
+ const timeout = this.createDeadline(requestId, fatalOnTimeout, handshake);
588
+ this.pendingResponses.set(requestId, { resolve, reject, timeout, fatalOnTimeout });
589
+ void this.write(request, handshake).catch(error => {
590
+ const pending = this.pendingResponses.get(requestId);
591
+ if (pending === undefined)
592
+ return;
593
+ this.clearResponse(requestId, !isDefinitelyUnsentWriteError(error));
594
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
595
+ });
596
+ });
597
+ }
598
+ async issueStream(request, queue, legacyRelease) {
599
+ const requestId = requireRequestId(request);
600
+ const timeout = this.createDeadline(requestId, legacyRelease !== undefined, false);
601
+ this.pendingStreams.set(requestId, {
602
+ queue,
603
+ timeout,
604
+ fatalOnTimeout: legacyRelease !== undefined,
605
+ ...(legacyRelease === undefined ? {} : { legacyRelease })
213
606
  });
607
+ try {
608
+ await this.write(request);
609
+ }
610
+ catch (error) {
611
+ const pending = this.pendingStreams.get(requestId);
612
+ if (pending !== undefined) {
613
+ this.clearStream(requestId, !isDefinitelyUnsentWriteError(error));
614
+ pending.queue.fail(error instanceof Error ? error : new Error(String(error)));
615
+ }
616
+ }
214
617
  }
215
- write(request, reject) {
618
+ write(request, control = false) {
619
+ const requestId = requireRequestId(request);
216
620
  const child = this.child;
217
- if (child === undefined) {
218
- reject(new BackendClientError("backend_not_started", "Backend process is not running.", true));
621
+ if (child === undefined || this.closed) {
622
+ return Promise.reject(new BackendClientError("backend_closed", "Backend process is not running.", true));
623
+ }
624
+ let line;
625
+ try {
626
+ line = `${JSON.stringify(request)}\n`;
627
+ }
628
+ catch {
629
+ return Promise.reject(new BackendClientError("invalid_backend_request", "Backend request could not be encoded as JSON.", false));
630
+ }
631
+ if (Buffer.byteLength(line, "utf8") > this.frameLimitBytes()) {
632
+ return Promise.reject(new BackendClientError("backend_frame_too_large", `Backend request frame exceeds the ${this.frameLimitBytes()} byte limit.`, false));
633
+ }
634
+ let admission;
635
+ try {
636
+ admission = this.admitWrite(requestId, line, child);
637
+ }
638
+ catch (error) {
639
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
640
+ }
641
+ const next = this.writeTail.then(() => {
642
+ if (admission.released) {
643
+ throw new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled before it could be written.`, true);
644
+ }
645
+ if (!control)
646
+ this.assertCanIssue();
647
+ if (this.child !== admission.child) {
648
+ throw new BackendClientError("backend_closed", "Backend process is not running.", true);
649
+ }
650
+ if (!this.isWriteRouteActive(requestId)) {
651
+ throw new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled before it could be written.`, true);
652
+ }
653
+ admission.started = true;
654
+ return this.writeLine(child, line).finally(() => this.releaseWrite(admission));
655
+ }).catch(error => {
656
+ this.releaseWrite(admission);
657
+ throw error;
658
+ });
659
+ this.writeTail = next.catch(() => { });
660
+ return next;
661
+ }
662
+ admitWrite(requestId, line, child) {
663
+ const bytes = Buffer.byteLength(line, "utf8");
664
+ const countLimit = this.options.writeQueueLimit ?? DEFAULT_BACKEND_WRITE_QUEUE_LIMIT;
665
+ const bytesLimit = this.options.writeQueueBytesLimit ?? DEFAULT_BACKEND_WRITE_QUEUE_BYTES_LIMIT;
666
+ if (this.writeQueueCount >= countLimit || this.writeQueueBytes > bytesLimit - bytes) {
667
+ throw new BackendClientError("backend_write_queue_overflow", "Backend outbound request buffering exceeded its bounded limit.", true);
668
+ }
669
+ const admission = { requestId, bytes, child, started: false, released: false };
670
+ this.activeWrites.add(admission);
671
+ this.writeQueueCount += 1;
672
+ this.writeQueueBytes += bytes;
673
+ return admission;
674
+ }
675
+ releaseWrite(admission) {
676
+ if (admission.released)
677
+ return;
678
+ admission.released = true;
679
+ if (!this.activeWrites.delete(admission))
680
+ return;
681
+ this.writeQueueCount = Math.max(0, this.writeQueueCount - 1);
682
+ this.writeQueueBytes = Math.max(0, this.writeQueueBytes - admission.bytes);
683
+ this.maybeUnblockWriteTeardown();
684
+ }
685
+ retireWriteLifecycle(retiredChild) {
686
+ if (![...this.activeWrites].some(admission => admission.child === retiredChild))
687
+ return;
688
+ if (this.retiredWriteTail === undefined) {
689
+ const retiredTail = this.writeTail;
690
+ this.retiredWriteTail = retiredTail;
691
+ // Detach this child generation so a replacement child never inherits a
692
+ // permanently blocked stdin callback. The detached tail remains bounded
693
+ // by the charged admissions until its callbacks settle.
694
+ this.writeTail = Promise.resolve();
695
+ void retiredTail.then(() => this.finishRetiredWriteTail(retiredTail), () => this.finishRetiredWriteTail(retiredTail));
219
696
  return;
220
697
  }
221
- child.stdin.write(`${JSON.stringify(request)}\n`, error => {
222
- if (error !== null && error !== undefined)
223
- reject(error);
698
+ // A second recycle while the detached generation is still unresolved may
699
+ // not create another orphaned tail. Leave the current tail attached and
700
+ // fail closed until both generations settle.
701
+ this.recycleBlockedByWriteTeardown = true;
702
+ void this.writeTail.then(() => this.maybeUnblockWriteTeardown(), () => this.maybeUnblockWriteTeardown());
703
+ }
704
+ finishRetiredWriteTail(retiredTail) {
705
+ if (this.retiredWriteTail === retiredTail)
706
+ this.retiredWriteTail = undefined;
707
+ this.maybeUnblockWriteTeardown();
708
+ }
709
+ maybeUnblockWriteTeardown() {
710
+ if (!this.recycleBlockedByWriteTeardown)
711
+ return;
712
+ if (this.retiredWriteTail !== undefined || this.activeWrites.size > 0)
713
+ return;
714
+ this.recycleBlockedByWriteTeardown = false;
715
+ }
716
+ isWriteRouteActive(requestId) {
717
+ return this.pendingResponses.has(requestId) || this.pendingStreams.has(requestId);
718
+ }
719
+ hasStartedWrite(requestId) {
720
+ return [...this.activeWrites].some(admission => admission.requestId === requestId && admission.started);
721
+ }
722
+ writeLine(child, line) {
723
+ if (this.child !== child) {
724
+ return Promise.reject(new BackendClientError("backend_closed", "Backend process is not running.", true));
725
+ }
726
+ if (Buffer.byteLength(line, "utf8") > this.frameLimitBytes()) {
727
+ return Promise.reject(new BackendClientError("backend_frame_too_large", `Backend request frame exceeds the ${this.frameLimitBytes()} byte limit.`, false));
728
+ }
729
+ return new Promise((resolve, reject) => {
730
+ let settled = false;
731
+ const onError = (error) => finish(error);
732
+ const finish = (error) => {
733
+ if (settled)
734
+ return;
735
+ settled = true;
736
+ child.stdin.off("error", onError);
737
+ if (error !== undefined && error !== null)
738
+ reject(error);
739
+ else
740
+ resolve();
741
+ };
742
+ child.stdin.once("error", onError);
743
+ try {
744
+ child.stdin.write(line, error => finish(error));
745
+ }
746
+ catch (error) {
747
+ finish(error instanceof Error ? error : new Error(String(error)));
748
+ }
224
749
  });
225
750
  }
226
751
  handleLine(line) {
@@ -228,82 +753,101 @@ export class StdioBackendTransport {
228
753
  try {
229
754
  value = JSON.parse(line);
230
755
  }
231
- catch (error) {
232
- this.failAll(new BackendClientError("invalid_backend_json", error instanceof Error ? error.message : String(error), true));
756
+ catch {
757
+ this.terminate(new BackendClientError("invalid_backend_json", "Backend emitted an invalid JSON frame.", true));
233
758
  return;
234
759
  }
235
760
  if (!isRecord(value)) {
236
- this.failAll(new BackendClientError("invalid_backend_message", "Backend protocol line must be a JSON object.", true));
761
+ this.terminate(new BackendClientError("invalid_backend_message", "Backend protocol line must be a JSON object.", true));
237
762
  return;
238
763
  }
239
764
  if (value.schemaVersion === BACKEND_RESPONSE_SCHEMA_VERSION) {
240
- this.handleResponse(value);
765
+ try {
766
+ this.handleResponse(parseBackendResponseMessage(value));
767
+ }
768
+ catch (error) {
769
+ this.terminate(asProtocolClientError(error));
770
+ }
241
771
  return;
242
772
  }
243
773
  if (value.schemaVersion === BACKEND_EVENT_SCHEMA_VERSION) {
244
- this.handleEvent(value);
774
+ try {
775
+ this.handleEvent(parseBackendEventMessage(value));
776
+ }
777
+ catch (error) {
778
+ this.terminate(asProtocolClientError(error));
779
+ }
245
780
  return;
246
781
  }
247
- this.failAll(new BackendClientError("unsupported_backend_schema", `Unsupported backend protocol schemaVersion: ${String(value.schemaVersion)}`, true));
782
+ this.terminate(new BackendClientError("unsupported_backend_schema", "Backend emitted an unsupported protocol schema.", true));
248
783
  }
249
784
  handleResponse(response) {
250
785
  const requestId = response.requestId;
251
786
  if (requestId === undefined) {
252
- this.failAll(new BackendClientError("missing_backend_request_id", "Backend response is missing requestId.", true));
787
+ this.terminate(new BackendClientError("missing_backend_request_id", "Backend response is missing requestId.", true));
253
788
  return;
254
789
  }
255
790
  const pending = this.pendingResponses.get(requestId);
256
791
  if (pending === undefined) {
257
792
  const stream = this.pendingStreams.get(requestId);
258
793
  if (stream !== undefined) {
259
- stream.queue.fail(new BackendClientError("unexpected_backend_response", `Backend sent a response for streaming requestId ${requestId}.`, true));
260
- this.clearStream(requestId);
794
+ this.terminate(new BackendClientError("unexpected_backend_response", `Backend sent a response for streaming requestId ${requestId}.`, true));
261
795
  return;
262
796
  }
263
- this.failAll(new BackendClientError("unknown_backend_request_id", `Backend response used unknown requestId ${requestId}.`, true));
797
+ if (this.consumeTombstoneResponse(requestId))
798
+ return;
799
+ this.discardLateOrQuarantine(requestId);
264
800
  return;
265
801
  }
266
802
  if (typeof response.ok !== "boolean") {
267
- this.clearResponse(requestId);
803
+ this.clearResponse(requestId, true);
268
804
  pending.reject(new BackendClientError("invalid_backend_response", `Backend response for requestId ${requestId} is missing boolean ok.`, true));
269
805
  return;
270
806
  }
271
- this.clearResponse(requestId);
807
+ this.clearResponse(requestId, false);
272
808
  pending.resolve(response);
273
809
  }
274
810
  handleEvent(event) {
275
811
  const requestId = event.requestId;
276
812
  if (requestId === undefined) {
277
- this.failAll(new BackendClientError("missing_backend_request_id", "Backend event is missing requestId.", true));
813
+ this.terminate(new BackendClientError("missing_backend_request_id", "Backend event is missing requestId.", true));
278
814
  return;
279
815
  }
280
816
  const pending = this.pendingStreams.get(requestId);
281
817
  if (pending === undefined) {
282
818
  const response = this.pendingResponses.get(requestId);
283
819
  if (response !== undefined) {
284
- this.clearResponse(requestId);
285
- response.reject(new BackendClientError("unexpected_backend_event", `Backend sent an event for non-streaming requestId ${requestId}.`, true));
820
+ this.terminate(new BackendClientError("unexpected_backend_event", `Backend sent an event for non-streaming requestId ${requestId}.`, true));
286
821
  return;
287
822
  }
288
- this.failAll(new BackendClientError("unknown_backend_request_id", `Backend event used unknown requestId ${requestId}.`, true));
823
+ if (this.consumeTombstoneEvent(requestId, event.type))
824
+ return;
825
+ this.discardLateOrQuarantine(requestId);
289
826
  return;
290
827
  }
291
828
  if (typeof event.type !== "string") {
292
829
  pending.queue.fail(new BackendClientError("invalid_backend_event", `Backend event for requestId ${requestId} is missing type.`, true));
293
- this.clearStream(requestId);
830
+ this.clearStream(requestId, true);
831
+ return;
832
+ }
833
+ if (!pending.queue.push(event)) {
834
+ pending.queue.fail(new BackendClientError("backend_stream_overflow", `Backend stream requestId ${requestId} exceeded its bounded event queue.`, true));
835
+ this.clearStream(requestId, true);
294
836
  return;
295
837
  }
296
- pending.queue.push(event);
297
838
  if (event.type === "completed") {
298
839
  pending.queue.finish();
299
- this.clearStream(requestId);
840
+ this.clearStream(requestId, false);
300
841
  }
301
842
  if (event.type === "error") {
302
843
  pending.queue.fail(new BackendClientError(event.error.code, event.error.message, event.error.recoverable));
303
- this.clearStream(requestId);
844
+ this.clearStream(requestId, false);
304
845
  }
305
846
  }
306
847
  failAll(error) {
848
+ // Keep active write admissions charged until their tail entries settle.
849
+ // Clearing them here would make a blocked old-generation tail invisible
850
+ // to the next generation and defeat the aggregate memory bound.
307
851
  for (const pending of this.pendingResponses.values()) {
308
852
  clearTimeout(pending.timeout);
309
853
  pending.reject(error);
@@ -312,47 +856,448 @@ export class StdioBackendTransport {
312
856
  for (const pending of this.pendingStreams.values()) {
313
857
  clearTimeout(pending.timeout);
314
858
  pending.queue.fail(error);
859
+ pending.legacyRelease?.();
315
860
  }
316
861
  this.pendingStreams.clear();
862
+ for (const cancelWaiting of this.waitingRequests.values())
863
+ cancelWaiting(error);
864
+ this.waitingRequests.clear();
865
+ for (const cancelWaiting of this.waitingStreams.values())
866
+ cancelWaiting(error);
867
+ this.waitingStreams.clear();
868
+ this.waitingAdmissionIds.clear();
869
+ this.activeRequestIds.clear();
870
+ this.activeControlRequestIds.clear();
317
871
  }
318
- clearResponse(requestId) {
872
+ clearResponse(requestId, tombstone) {
319
873
  const pending = this.pendingResponses.get(requestId);
320
874
  if (pending !== undefined) {
321
875
  clearTimeout(pending.timeout);
322
876
  this.pendingResponses.delete(requestId);
877
+ this.releaseRequestId(requestId, tombstone ? "unary" : undefined);
878
+ this.maybeRecycleQuarantined();
323
879
  }
324
880
  }
325
- clearStream(requestId) {
881
+ clearStream(requestId, tombstone) {
326
882
  const pending = this.pendingStreams.get(requestId);
327
883
  if (pending !== undefined) {
328
884
  clearTimeout(pending.timeout);
329
885
  this.pendingStreams.delete(requestId);
886
+ pending.legacyRelease?.();
887
+ this.releaseRequestId(requestId, tombstone ? "stream" : undefined);
888
+ this.maybeRecycleQuarantined();
330
889
  }
331
890
  }
332
- createDeadline(requestId) {
333
- const timeoutMs = this.options.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS;
891
+ createDeadline(requestId, fatalOnTimeout, handshake) {
892
+ const timeoutMs = handshake
893
+ ? this.options.handshakeTimeoutMs ?? DEFAULT_BACKEND_HANDSHAKE_TIMEOUT_MS
894
+ : this.options.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS;
334
895
  return setTimeout(() => {
335
896
  const error = new BackendClientError("backend_timeout", `Backend request ${requestId} timed out after ${timeoutMs}ms.`, true);
336
- const child = this.child;
337
- this.child = undefined;
338
- this.stdout?.close();
339
- this.stdout = undefined;
340
- if (child !== undefined) {
341
- child.removeAllListeners("error");
342
- child.removeAllListeners("exit");
343
- child.kill();
897
+ const response = this.pendingResponses.get(requestId);
898
+ if (response !== undefined) {
899
+ const writeStarted = this.hasStartedWrite(requestId);
900
+ this.clearResponse(requestId, true);
901
+ response.reject(error);
902
+ if (response.fatalOnTimeout || writeStarted)
903
+ this.terminate(error);
904
+ return;
905
+ }
906
+ const stream = this.pendingStreams.get(requestId);
907
+ if (stream !== undefined) {
908
+ const writeStarted = this.hasStartedWrite(requestId);
909
+ this.clearStream(requestId, true);
910
+ stream.queue.fail(error);
911
+ if (stream.fatalOnTimeout || writeStarted)
912
+ this.terminate(error);
344
913
  }
345
- this.failAll(error);
346
914
  }, timeoutMs);
347
915
  }
916
+ acquireLegacySlot() {
917
+ const previous = this.legacyTail;
918
+ let releasePrevious;
919
+ this.legacyTail = new Promise(resolve => {
920
+ releasePrevious = resolve;
921
+ });
922
+ return previous.then(() => {
923
+ let released = false;
924
+ return () => {
925
+ if (released)
926
+ return;
927
+ released = true;
928
+ releasePrevious();
929
+ };
930
+ });
931
+ }
932
+ reserveWaitingAdmission(requestId) {
933
+ this.validateRequestId(requestId);
934
+ this.assertAdmissionCapacity(false);
935
+ this.waitingAdmissionIds.add(requestId);
936
+ }
937
+ releaseWaitingAdmission(requestId) {
938
+ this.waitingAdmissionIds.delete(requestId);
939
+ }
940
+ promoteWaitingAdmission(requestId) {
941
+ if (!this.waitingAdmissionIds.delete(requestId)) {
942
+ throw new BackendClientError("backend_request_cancelled", `Backend request ${requestId} was cancelled before it could be issued.`, true);
943
+ }
944
+ try {
945
+ this.reserveRequestId(requestId);
946
+ }
947
+ catch (error) {
948
+ // Keep the admission state truthful if promotion fails after the
949
+ // caller's waiting slot has been removed.
950
+ this.releaseRequestId(requestId, undefined);
951
+ throw error;
952
+ }
953
+ }
954
+ reserveRequestId(requestId, control = false) {
955
+ this.pruneIdState();
956
+ this.validateRequestId(requestId, control);
957
+ this.assertAdmissionCapacity(control);
958
+ this.activeRequestIds.add(requestId);
959
+ if (control)
960
+ this.activeControlRequestIds.add(requestId);
961
+ }
962
+ validateRequestId(requestId, control = false) {
963
+ if (this.closed) {
964
+ throw new BackendClientError("backend_closed", "Backend transport is closed.", true);
965
+ }
966
+ if (!isValidBackendRequestId(requestId)) {
967
+ throw new BackendClientError("invalid_request_id", "Backend requestId must be a bounded, non-empty string without control characters.", false);
968
+ }
969
+ if (!control && requestId.startsWith(BACKEND_CONTROL_REQUEST_ID_PREFIX)) {
970
+ throw new BackendClientError("reserved_request_id", "Backend requestId uses a transport-reserved control namespace.", false);
971
+ }
972
+ if (this.protocolQuarantined) {
973
+ throw new BackendClientError("backend_protocol_quarantined", "Backend transport is quarantined after an unknown requestId; wait for it to recycle before sending new work.", true);
974
+ }
975
+ if (this.activeRequestIds.has(requestId)) {
976
+ throw new BackendClientError("duplicate_request_id", `Backend requestId ${requestId} is already active.`, false);
977
+ }
978
+ if (this.waitingAdmissionIds.has(requestId)) {
979
+ throw new BackendClientError("duplicate_request_id", `Backend requestId ${requestId} is already waiting for admission.`, false);
980
+ }
981
+ if (this.tombstones.has(requestId)) {
982
+ throw new BackendClientError("request_id_reused", `Backend requestId ${requestId} was recently completed or cancelled and cannot be reused yet.`, false);
983
+ }
984
+ if (this.quarantinedRequestIds.has(requestId)) {
985
+ throw new BackendClientError("request_id_quarantined", `Backend requestId ${requestId} was quarantined after an unknown backend message and cannot be reused yet.`, false);
986
+ }
987
+ }
988
+ assertAdmissionCapacity(control = false) {
989
+ // Before the first handshake control route exists, leave one aggregate
990
+ // slot free for that route. This matters for streams, whose handshake is
991
+ // deliberately deferred to a microtask and can therefore have multiple
992
+ // callers reserved synchronously. The same virtual slot is restored
993
+ // between sequential legacy probes. Once a control route is active (or
994
+ // negotiation has completed), caller routes use the full configured bound.
995
+ const limit = !control
996
+ && this.handshakeState === "unknown"
997
+ && this.activeControlRequestIds.size === 0
998
+ ? this.maxInFlight() - 1
999
+ : this.maxInFlight();
1000
+ if (this.waitingAdmissionIds.size + this.activeRequestIds.size >= limit) {
1001
+ throw new BackendClientError("backend_in_flight_limit", "Backend transport reached its bounded in-flight route limit.", true);
1002
+ }
1003
+ }
1004
+ releaseRequestId(requestId, tombstone) {
1005
+ this.activeRequestIds.delete(requestId);
1006
+ this.activeControlRequestIds.delete(requestId);
1007
+ if (tombstone === undefined)
1008
+ return;
1009
+ const kind = tombstone;
1010
+ if (this.tombstones.size >= this.tombstoneLimit() && !this.tombstones.has(requestId)) {
1011
+ this.terminate(new BackendClientError("backend_tombstone_limit", "Backend transport recycled because its late-output tombstone bound was reached.", true));
1012
+ return;
1013
+ }
1014
+ this.tombstones.set(requestId, {
1015
+ kind,
1016
+ expiresAt: Date.now() + this.lateOutputGraceMs()
1017
+ });
1018
+ this.scheduleTombstoneRecycle();
1019
+ }
1020
+ discardLateOrQuarantine(requestId) {
1021
+ this.pruneIdState();
1022
+ if (this.tombstones.has(requestId) || this.quarantinedRequestIds.has(requestId))
1023
+ return;
1024
+ if (this.quarantinedRequestIds.size >= this.quarantineLimit()) {
1025
+ this.terminate(new BackendClientError("backend_quarantine_limit", "Backend transport recycled because its unknown-requestId quarantine bound was reached.", true));
1026
+ return;
1027
+ }
1028
+ this.quarantinedRequestIds.set(requestId, Date.now() + this.lateOutputGraceMs());
1029
+ this.protocolQuarantined = true;
1030
+ this.scheduleQuarantineRecycle();
1031
+ }
1032
+ consumeTombstoneResponse(requestId) {
1033
+ const route = this.tombstones.get(requestId);
1034
+ if (route === undefined)
1035
+ return false;
1036
+ if (route.kind !== "unary") {
1037
+ this.terminate(new BackendClientError("unexpected_backend_response", `Backend sent a unary response for tombstoned stream requestId ${requestId}.`, true));
1038
+ return true;
1039
+ }
1040
+ this.tombstones.delete(requestId);
1041
+ this.clearTombstoneRecycleTimer();
1042
+ this.scheduleTombstoneRecycleIfNeeded();
1043
+ return true;
1044
+ }
1045
+ consumeTombstoneEvent(requestId, type) {
1046
+ const route = this.tombstones.get(requestId);
1047
+ if (route === undefined)
1048
+ return false;
1049
+ if (route.kind !== "stream") {
1050
+ this.terminate(new BackendClientError("unexpected_backend_event", `Backend sent a stream event for tombstoned unary requestId ${requestId}.`, true));
1051
+ return true;
1052
+ }
1053
+ if (type === "completed" || type === "error") {
1054
+ this.tombstones.delete(requestId);
1055
+ this.clearTombstoneRecycleTimer();
1056
+ this.scheduleTombstoneRecycleIfNeeded();
1057
+ }
1058
+ return true;
1059
+ }
1060
+ lateOutputGraceMs() {
1061
+ return this.options.lateOutputGraceMs ?? DEFAULT_BACKEND_LATE_OUTPUT_GRACE_MS;
1062
+ }
1063
+ tombstoneLimit() {
1064
+ return this.options.tombstoneLimit ?? DEFAULT_BACKEND_TOMBSTONE_LIMIT;
1065
+ }
1066
+ quarantineLimit() {
1067
+ return this.options.quarantineLimit ?? DEFAULT_BACKEND_QUARANTINE_LIMIT;
1068
+ }
1069
+ frameLimitBytes() {
1070
+ return this.options.frameLimitBytes ?? BACKEND_NDJSON_FRAME_LIMIT_BYTES;
1071
+ }
1072
+ maxInFlight() {
1073
+ return this.options.maxInFlight ?? DEFAULT_BACKEND_MAX_IN_FLIGHT;
1074
+ }
1075
+ assertCanIssue() {
1076
+ if (this.closed) {
1077
+ throw new BackendClientError("backend_closed", "Backend transport is closed.", true);
1078
+ }
1079
+ if (this.protocolQuarantined) {
1080
+ throw new BackendClientError("backend_protocol_quarantined", "Backend transport is quarantined after an unknown requestId; wait for it to recycle before sending new work.", true);
1081
+ }
1082
+ if (this.handshakeState === "blocked") {
1083
+ throw this.handshakeError ?? new BackendClientError("backend_hello_rejected", "Backend hello negotiation has blocked this backend transport.", false);
1084
+ }
1085
+ if (this.child === undefined) {
1086
+ throw new BackendClientError("backend_closed", "Backend process is not running.", true);
1087
+ }
1088
+ }
1089
+ scheduleTombstoneRecycle() {
1090
+ if (this.tombstoneRecycleTimer !== undefined)
1091
+ return;
1092
+ const nextExpiry = Math.min(...[...this.tombstones.values()].map(route => route.expiresAt));
1093
+ const delay = Math.max(1, nextExpiry - Date.now());
1094
+ this.tombstoneRecycleTimer = setTimeout(() => {
1095
+ this.tombstoneRecycleTimer = undefined;
1096
+ const expired = [...this.tombstones.values()].some(route => route.expiresAt <= Date.now());
1097
+ if (expired) {
1098
+ this.terminate(new BackendClientError("backend_late_output_timeout", "Backend transport recycled because a timed-out or cancelled route did not produce its terminal output within the bounded grace period.", true));
1099
+ return;
1100
+ }
1101
+ this.scheduleTombstoneRecycle();
1102
+ }, delay);
1103
+ this.tombstoneRecycleTimer.unref?.();
1104
+ }
1105
+ scheduleTombstoneRecycleIfNeeded() {
1106
+ if (this.tombstones.size === 0)
1107
+ return;
1108
+ this.scheduleTombstoneRecycle();
1109
+ }
1110
+ clearTombstoneRecycleTimer() {
1111
+ if (this.tombstoneRecycleTimer === undefined)
1112
+ return;
1113
+ clearTimeout(this.tombstoneRecycleTimer);
1114
+ this.tombstoneRecycleTimer = undefined;
1115
+ }
1116
+ scheduleQuarantineRecycle() {
1117
+ if (this.quarantineRecycleTimer === undefined) {
1118
+ this.quarantineRecycleTimer = setTimeout(() => {
1119
+ this.quarantineRecycleTimer = undefined;
1120
+ this.recycleQuarantinedTransport();
1121
+ }, this.lateOutputGraceMs());
1122
+ this.quarantineRecycleTimer.unref?.();
1123
+ }
1124
+ this.maybeRecycleQuarantined();
1125
+ }
1126
+ maybeRecycleQuarantined() {
1127
+ if (!this.protocolQuarantined || this.handshakePromise !== undefined)
1128
+ return;
1129
+ if (this.pendingResponses.size > 0
1130
+ || this.pendingStreams.size > 0
1131
+ || this.waitingRequests.size > 0
1132
+ || this.waitingStreams.size > 0)
1133
+ return;
1134
+ this.recycleQuarantinedTransport();
1135
+ }
1136
+ recycleQuarantinedTransport() {
1137
+ if (!this.protocolQuarantined)
1138
+ return;
1139
+ this.protocolQuarantined = false;
1140
+ this.clearQuarantineRecycleTimer();
1141
+ const child = this.child;
1142
+ if (child !== undefined) {
1143
+ this.terminate(new BackendClientError("backend_protocol_quarantined", "Backend transport was recycled after an unknown requestId.", true), child);
1144
+ }
1145
+ }
1146
+ clearQuarantineRecycleTimer() {
1147
+ if (this.quarantineRecycleTimer === undefined)
1148
+ return;
1149
+ clearTimeout(this.quarantineRecycleTimer);
1150
+ this.quarantineRecycleTimer = undefined;
1151
+ }
1152
+ pruneIdState() {
1153
+ // Tombstones are safety routes, not cache entries. They are removed only
1154
+ // by the expected late terminal message or by process recycle.
1155
+ }
1156
+ handleProcessFailure(child, error) {
1157
+ if (this.child !== child)
1158
+ return;
1159
+ this.child = undefined;
1160
+ this.stdout?.destroy();
1161
+ this.stdout = undefined;
1162
+ this.handshakeState = "unknown";
1163
+ this.handshakeError = undefined;
1164
+ this.protocolQuarantined = false;
1165
+ this.clearQuarantineRecycleTimer();
1166
+ this.clearTombstoneRecycleTimer();
1167
+ this.tombstones.clear();
1168
+ this.quarantinedRequestIds.clear();
1169
+ this.retireWriteLifecycle(child);
1170
+ this.failAll(error);
1171
+ }
1172
+ terminate(error, expectedChild = this.child) {
1173
+ const child = expectedChild;
1174
+ if (child === undefined) {
1175
+ this.failAll(error);
1176
+ return;
1177
+ }
1178
+ if (this.child === child) {
1179
+ this.child = undefined;
1180
+ this.stdout?.destroy();
1181
+ this.stdout = undefined;
1182
+ this.handshakeState = "unknown";
1183
+ this.handshakeError = undefined;
1184
+ this.retireWriteLifecycle(child);
1185
+ }
1186
+ this.protocolQuarantined = false;
1187
+ this.clearQuarantineRecycleTimer();
1188
+ this.clearTombstoneRecycleTimer();
1189
+ this.tombstones.clear();
1190
+ this.quarantinedRequestIds.clear();
1191
+ child.removeAllListeners("error");
1192
+ child.removeAllListeners("exit");
1193
+ child.kill();
1194
+ this.failAll(error);
1195
+ }
348
1196
  }
349
1197
  function unwrapResponse(response) {
350
1198
  if (response.ok)
351
1199
  return response.result;
352
1200
  throw new BackendClientError(response.error.code, response.error.message, response.error.recoverable);
353
1201
  }
354
- function streamFromBackendEvents(events) {
355
- const queue = new AsyncQueue();
1202
+ function attachOperationCompatibility(result, transport) {
1203
+ const report = transport.getCompatibilityReport?.();
1204
+ if (report === undefined)
1205
+ return result;
1206
+ try {
1207
+ return {
1208
+ ...result,
1209
+ compatibility: validateBackendCompatibilityReport(report)
1210
+ };
1211
+ }
1212
+ catch {
1213
+ return result;
1214
+ }
1215
+ }
1216
+ function attachDoctorCompatibility(result, args, report) {
1217
+ if (report === undefined || (args?.check !== undefined && !args.check.includes("compatibility")))
1218
+ return result;
1219
+ if (result.data === undefined)
1220
+ return result;
1221
+ const check = compatibilityCheckFromReport(report);
1222
+ return {
1223
+ ...result,
1224
+ data: {
1225
+ ...result.data,
1226
+ checks: { ...result.data.checks, compatibility: check },
1227
+ ready: result.data.ready && check.status !== "blocked"
1228
+ }
1229
+ };
1230
+ }
1231
+ function compatibilityCheckFromReport(report) {
1232
+ const warning = report.warnings[0];
1233
+ return {
1234
+ status: report.status === "blocked" ? "blocked" : report.status === "warning" ? "unknown" : report.status === "compatible" ? "ok" : "unknown",
1235
+ message: warning?.message ?? "Backend protocol and advertised capabilities are compatible.",
1236
+ ...(warning?.code === undefined ? {} : { code: warning.code }),
1237
+ details: report
1238
+ };
1239
+ }
1240
+ function parseOperationResult(value, validator) {
1241
+ try {
1242
+ return validator(value);
1243
+ }
1244
+ catch {
1245
+ // Result validation errors must not leak provider text, prompts, paths, or
1246
+ // opaque journal diagnostics through the backend facade.
1247
+ throw new BackendClientError("invalid_operation_result", "Backend returned an invalid transactional operation result.", true);
1248
+ }
1249
+ }
1250
+ function validateOperationSubmitRequest(value) {
1251
+ validateOperationRequestForClient(value, validateWireSubmitRequest);
1252
+ }
1253
+ function validateOperationCollectRequest(value) {
1254
+ validateOperationRequestForClient(value, validateWireCollectRequest);
1255
+ }
1256
+ function validateOperationInspectRequest(value) {
1257
+ validateOperationRequestForClient(value, validateWireInspectRequest);
1258
+ }
1259
+ function validateOperationControlRequest(value) {
1260
+ validateOperationRequestForClient(value, validateWireControlRequest);
1261
+ }
1262
+ function validateOperationRequestForClient(value, validator) {
1263
+ try {
1264
+ validator(value);
1265
+ }
1266
+ catch (error) {
1267
+ if (error instanceof OperationWireRequestError)
1268
+ throw invalidOperationRequest();
1269
+ throw error;
1270
+ }
1271
+ }
1272
+ function invalidOperationRequest() {
1273
+ return new BackendClientError("invalid_operation_request", "Transactional operation request is invalid.", false);
1274
+ }
1275
+ function streamFromBackendEvents(events, onReturn) {
1276
+ let returnRequested = false;
1277
+ let sourceReturned = false;
1278
+ const sourceIterator = events[Symbol.asyncIterator]();
1279
+ const returnSource = () => {
1280
+ if (sourceReturned)
1281
+ return;
1282
+ sourceReturned = true;
1283
+ try {
1284
+ const result = sourceIterator.return?.();
1285
+ if (result !== undefined)
1286
+ void Promise.resolve(result).catch(() => { });
1287
+ }
1288
+ catch {
1289
+ // A source iterator's cleanup must not turn caller cancellation into a
1290
+ // second observable stream failure.
1291
+ }
1292
+ };
1293
+ const cancel = () => {
1294
+ if (returnRequested)
1295
+ return;
1296
+ returnRequested = true;
1297
+ onReturn?.();
1298
+ returnSource();
1299
+ };
1300
+ const queue = new AsyncQueue(DEFAULT_BACKEND_STREAM_QUEUE_LIMIT, cancel, DEFAULT_BACKEND_STREAM_QUEUE_BYTES_LIMIT);
356
1301
  let resolveCompleted;
357
1302
  let rejectCompleted;
358
1303
  const completed = new Promise((resolve, reject) => {
@@ -361,18 +1306,26 @@ function streamFromBackendEvents(events) {
361
1306
  });
362
1307
  void (async () => {
363
1308
  try {
364
- for await (const event of events) {
1309
+ while (true) {
1310
+ const next = await sourceIterator.next();
1311
+ if (next.done)
1312
+ break;
1313
+ const event = next.value;
365
1314
  if (event.type === "run_item_stream_event") {
366
- queue.push({
1315
+ if (!queue.push({
367
1316
  type: "run_item_stream_event",
368
1317
  name: event.name,
369
1318
  item: event.item
370
- });
1319
+ })) {
1320
+ cancel();
1321
+ throw new BackendClientError("backend_stream_overflow", "High-level backend stream buffering exceeded its bounded event queue.", true);
1322
+ }
371
1323
  continue;
372
1324
  }
373
1325
  if (event.type === "completed") {
374
1326
  resolveCompleted(event.result);
375
1327
  queue.finish();
1328
+ returnSource();
376
1329
  return;
377
1330
  }
378
1331
  if (event.type === "error") {
@@ -382,6 +1335,7 @@ function streamFromBackendEvents(events) {
382
1335
  throw new BackendClientError("stream_incomplete", "Backend stream ended before a completed event.", true);
383
1336
  }
384
1337
  catch (error) {
1338
+ returnSource();
385
1339
  queue.fail(error);
386
1340
  rejectCompleted(error);
387
1341
  }
@@ -392,41 +1346,319 @@ function streamFromBackendEvents(events) {
392
1346
  };
393
1347
  }
394
1348
  function requireRequestId(request) {
395
- if (request.requestId === undefined) {
1349
+ if (typeof request.requestId !== "string" || request.requestId.length === 0) {
396
1350
  throw new BackendClientError("missing_request_id", "Backend transport requests require requestId.", false);
397
1351
  }
1352
+ if (!isValidBackendRequestId(request.requestId)) {
1353
+ throw new BackendClientError("invalid_request_id", "Backend transport requestId is malformed or exceeds its bound.", false);
1354
+ }
398
1355
  return request.requestId;
399
1356
  }
1357
+ function parseBackendResponseMessage(value) {
1358
+ requireExactSchema(value, BACKEND_RESPONSE_SCHEMA_VERSION, "response");
1359
+ const requestId = requireMessageRequestId(value);
1360
+ const ok = value.ok;
1361
+ if (typeof ok !== "boolean") {
1362
+ throw new BackendClientError("invalid_backend_response", "Backend response ok must be a boolean.", true);
1363
+ }
1364
+ if (ok) {
1365
+ ensureAllowedKeys(value, ["schemaVersion", "requestId", "ok", "result"]);
1366
+ if (!Object.hasOwn(value, "result") || Object.hasOwn(value, "error")) {
1367
+ throw new BackendClientError("invalid_backend_response", `Backend response for requestId ${requestId} must contain exactly one result branch.`, true);
1368
+ }
1369
+ return value;
1370
+ }
1371
+ ensureAllowedKeys(value, ["schemaVersion", "requestId", "ok", "error"]);
1372
+ if (!isRecord(value.error)
1373
+ || typeof value.error.code !== "string"
1374
+ || value.error.code.length === 0
1375
+ || typeof value.error.message !== "string"
1376
+ || value.error.message.length === 0
1377
+ || typeof value.error.recoverable !== "boolean"
1378
+ || Object.hasOwn(value, "result")) {
1379
+ throw new BackendClientError("invalid_backend_response", `Backend error payload for requestId ${requestId} is malformed.`, true);
1380
+ }
1381
+ ensureAllowedKeys(value.error, ["code", "message", "recoverable"]);
1382
+ return value;
1383
+ }
1384
+ function parseBackendEventMessage(value) {
1385
+ requireExactSchema(value, BACKEND_EVENT_SCHEMA_VERSION, "event");
1386
+ const requestId = requireMessageRequestId(value);
1387
+ const type = value.type;
1388
+ if (typeof type !== "string") {
1389
+ throw new BackendClientError("invalid_backend_event", `Backend event for requestId ${requestId} is missing type.`, true);
1390
+ }
1391
+ switch (type) {
1392
+ case "run_item_stream_event":
1393
+ ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "name", "item"]);
1394
+ if (typeof value.name !== "string"
1395
+ || value.name.length === 0
1396
+ || !isRecord(value.item)) {
1397
+ throw new BackendClientError("invalid_backend_event", `Backend run-item event for requestId ${requestId} is malformed.`, true);
1398
+ }
1399
+ break;
1400
+ case "agent_updated_stream_event":
1401
+ ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "agent"]);
1402
+ if (!isRecord(value.agent)) {
1403
+ throw new BackendClientError("invalid_backend_event", `Backend agent-update event for requestId ${requestId} is malformed.`, true);
1404
+ }
1405
+ break;
1406
+ case "completed":
1407
+ ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "result"]);
1408
+ if (!Object.hasOwn(value, "result")) {
1409
+ throw new BackendClientError("invalid_backend_event", `Backend completed event for requestId ${requestId} is missing result.`, true);
1410
+ }
1411
+ break;
1412
+ case "error":
1413
+ ensureAllowedKeys(value, ["schemaVersion", "requestId", "type", "error"]);
1414
+ if (!isRecord(value.error)
1415
+ || typeof value.error.code !== "string"
1416
+ || value.error.code.length === 0
1417
+ || typeof value.error.message !== "string"
1418
+ || value.error.message.length === 0
1419
+ || typeof value.error.recoverable !== "boolean") {
1420
+ throw new BackendClientError("invalid_backend_event", `Backend error event for requestId ${requestId} is malformed.`, true);
1421
+ }
1422
+ ensureAllowedKeys(value.error, ["code", "message", "recoverable"]);
1423
+ break;
1424
+ default:
1425
+ throw new BackendClientError("invalid_backend_event", `Backend event for requestId ${requestId} has unsupported type ${type}.`, true);
1426
+ }
1427
+ return value;
1428
+ }
1429
+ function requireExactSchema(value, schemaVersion, kind) {
1430
+ if (value.schemaVersion !== schemaVersion) {
1431
+ throw new BackendClientError("unsupported_backend_schema", `Backend emitted an unsupported ${kind} protocol schema.`, true);
1432
+ }
1433
+ }
1434
+ function requireMessageRequestId(value) {
1435
+ if (!isValidBackendRequestId(value.requestId)) {
1436
+ throw new BackendClientError("missing_backend_request_id", "Backend protocol message requires a bounded requestId.", true);
1437
+ }
1438
+ return value.requestId;
1439
+ }
1440
+ function ensureAllowedKeys(value, allowed) {
1441
+ const allowedSet = new Set(allowed);
1442
+ if (Object.keys(value).some(key => !allowedSet.has(key))) {
1443
+ throw new BackendClientError("invalid_backend_message", "Backend protocol message contains unsupported fields.", true);
1444
+ }
1445
+ }
1446
+ function isBoundedIdentityField(value) {
1447
+ return typeof value === "string"
1448
+ && value.length > 0
1449
+ && value.length <= MAX_BACKEND_IDENTITY_FIELD_LENGTH
1450
+ && value.trim() === value
1451
+ && !/[\u0000-\u001f\u007f]/u.test(value);
1452
+ }
1453
+ function asProtocolClientError(error) {
1454
+ if (error instanceof BackendClientError)
1455
+ return error;
1456
+ return new BackendClientError("invalid_backend_message", "Backend protocol message validation failed.", true);
1457
+ }
1458
+ function isCompatibleLegacyVersion(value) {
1459
+ return isRecord(value)
1460
+ && value.protocolVersion === BACKEND_REQUEST_SCHEMA_VERSION
1461
+ && typeof value.name === "string"
1462
+ && value.name.length > 0
1463
+ && typeof value.runtime === "string"
1464
+ && value.runtime.length > 0;
1465
+ }
1466
+ function isCompatibleLegacyCapabilities(value) {
1467
+ if (!isRecord(value)
1468
+ || value.protocolVersion !== BACKEND_REQUEST_SCHEMA_VERSION
1469
+ || !Array.isArray(value.commands)
1470
+ || value.commands.some(command => typeof command !== "string"))
1471
+ return false;
1472
+ const commands = value.commands;
1473
+ const requiredCommands = [
1474
+ "backend.version",
1475
+ "backend.health",
1476
+ "backend.capabilities",
1477
+ "runner.run",
1478
+ "runner.stream"
1479
+ ];
1480
+ if (requiredCommands.some(command => !commands.includes(command)))
1481
+ return false;
1482
+ if (!Array.isArray(value.transports)
1483
+ || value.transports.some(transport => transport !== "stdio" && transport !== "http")
1484
+ || !value.transports.includes("stdio"))
1485
+ return false;
1486
+ return isRecord(value.streaming)
1487
+ && Array.isArray(value.streaming.modes)
1488
+ && value.streaming.modes.every(mode => mode === "ndjson" || mode === "sse")
1489
+ && value.streaming.modes.includes("ndjson")
1490
+ && value.streaming.tokenDeltas === false;
1491
+ }
1492
+ function isNegotiatedHello(value, requestPayload) {
1493
+ if (!isRecord(value) || value.accepted !== true || !isRecord(value.capabilities))
1494
+ return false;
1495
+ const identityFields = [
1496
+ "backendSessionId",
1497
+ "packageName",
1498
+ "packageVersion",
1499
+ "runtime",
1500
+ "runtimeVersion",
1501
+ "buildDigest",
1502
+ "protocolVersion"
1503
+ ];
1504
+ if (identityFields.some(field => !isBoundedIdentityField(value[field])))
1505
+ return false;
1506
+ const capabilities = value.capabilities;
1507
+ if (capabilities.protocolVersion !== BACKEND_REQUEST_SCHEMA_VERSION)
1508
+ return false;
1509
+ if (value.protocolVersion !== capabilities.protocolVersion
1510
+ || value.backendSessionId !== capabilities.backendSessionId
1511
+ || value.packageName !== capabilities.packageName
1512
+ || value.packageVersion !== capabilities.packageVersion
1513
+ || value.runtime !== capabilities.runtime
1514
+ || value.runtimeVersion !== capabilities.runtimeVersion
1515
+ || value.buildDigest !== capabilities.buildDigest)
1516
+ return false;
1517
+ if (!Array.isArray(capabilities.supportedProtocolVersions)
1518
+ || !capabilities.supportedProtocolVersions.includes(BACKEND_REQUEST_SCHEMA_VERSION))
1519
+ return false;
1520
+ if (["backendSessionId", "packageName", "packageVersion", "runtime", "runtimeVersion", "buildDigest"]
1521
+ .some(field => !isBoundedIdentityField(capabilities[field])))
1522
+ return false;
1523
+ const requestedCapabilities = requestPayload.capabilities;
1524
+ if (!isRecord(requestedCapabilities))
1525
+ return false;
1526
+ const supportedCommands = capabilities.commands;
1527
+ const requestedCommands = requestedCapabilities.commands;
1528
+ if (!Array.isArray(supportedCommands)
1529
+ || supportedCommands.length === 0
1530
+ || supportedCommands.some(command => typeof command !== "string")
1531
+ || !Array.isArray(requestedCommands)
1532
+ || requestedCommands.some(command => typeof command !== "string" || !supportedCommands.includes(command)))
1533
+ return false;
1534
+ if (!Array.isArray(capabilities.transports)
1535
+ || capabilities.transports.some(transport => transport !== "stdio" && transport !== "http")
1536
+ || !capabilities.transports.includes("stdio"))
1537
+ return false;
1538
+ if (!isRecord(capabilities.streaming)
1539
+ || !Array.isArray(capabilities.streaming.modes)
1540
+ || capabilities.streaming.modes.some(mode => mode !== "ndjson" && mode !== "sse")
1541
+ || !capabilities.streaming.modes.includes("ndjson")
1542
+ || capabilities.streaming.tokenDeltas !== false)
1543
+ return false;
1544
+ const requestIds = capabilities.requestIds;
1545
+ if (!isRecord(requestIds)
1546
+ || requestIds.required !== true
1547
+ || (requestIds.scope !== "connection" && requestIds.scope !== "process"))
1548
+ return false;
1549
+ const multiplexing = capabilities.multiplexing;
1550
+ if (!isRecord(multiplexing)
1551
+ || typeof multiplexing.unary !== "boolean"
1552
+ || typeof multiplexing.streams !== "boolean")
1553
+ return false;
1554
+ const cancellation = capabilities.cancellation;
1555
+ if (!isRecord(cancellation)
1556
+ || typeof cancellation.supported !== "boolean"
1557
+ || typeof cancellation.requests !== "boolean"
1558
+ || typeof cancellation.streams !== "boolean")
1559
+ return false;
1560
+ const tabs = capabilities.tabs;
1561
+ if (!isRecord(tabs)
1562
+ || typeof tabs.stableProviderIdentity !== "boolean"
1563
+ || typeof tabs.stableBrowserIdentity !== "boolean"
1564
+ || typeof tabs.stableTabIdentity !== "boolean"
1565
+ || (tabs.coordinationScope !== "none" && tabs.coordinationScope !== "process" && tabs.coordinationScope !== "provider")
1566
+ || typeof tabs.authoritativeClaim !== "boolean"
1567
+ || typeof tabs.fencing !== "boolean"
1568
+ || typeof tabs.concurrentTabs !== "boolean"
1569
+ || !consistentDeprecatedTabAliases(tabs))
1570
+ return false;
1571
+ return true;
1572
+ }
1573
+ function negotiatedMultiplexing(value) {
1574
+ if (!isRecord(value) || !isRecord(value.capabilities) || !isRecord(value.capabilities.multiplexing))
1575
+ return false;
1576
+ return value.capabilities.multiplexing.unary === true && value.capabilities.multiplexing.streams === true;
1577
+ }
1578
+ function consistentDeprecatedTabAliases(tabs) {
1579
+ const expectedStableIdentity = tabs.stableProviderIdentity === true
1580
+ && tabs.stableBrowserIdentity === true
1581
+ && tabs.stableTabIdentity === true;
1582
+ const expectedCoordination = tabs.coordinationScope !== "none";
1583
+ const expectedConcurrent = tabs.concurrentTabs === true;
1584
+ return (tabs.stableIdentity === undefined || tabs.stableIdentity === expectedStableIdentity)
1585
+ && (tabs.coordination === undefined || tabs.coordination === expectedCoordination)
1586
+ && (tabs.concurrent === undefined || tabs.concurrent === expectedConcurrent);
1587
+ }
400
1588
  class AsyncQueue {
1589
+ maxValues;
1590
+ onReturn;
1591
+ maxBytes;
1592
+ sizeOf;
401
1593
  values = [];
402
1594
  waiters = [];
1595
+ queuedBytes = 0;
403
1596
  done = false;
404
1597
  error;
1598
+ returnCalled = false;
1599
+ constructor(maxValues = Number.POSITIVE_INFINITY, onReturn, maxBytes = Number.POSITIVE_INFINITY, sizeOf = boundedValueBytes) {
1600
+ this.maxValues = maxValues;
1601
+ this.onReturn = onReturn;
1602
+ this.maxBytes = maxBytes;
1603
+ this.sizeOf = sizeOf;
1604
+ }
405
1605
  push(value) {
406
1606
  if (this.done || this.error !== undefined)
407
- return;
1607
+ return false;
1608
+ if (this.values.length >= this.maxValues)
1609
+ return false;
1610
+ const valueBytes = this.sizeOf(value);
1611
+ if (!Number.isFinite(valueBytes) || valueBytes < 0 || this.queuedBytes > this.maxBytes - valueBytes)
1612
+ return false;
408
1613
  this.values.push(value);
1614
+ this.queuedBytes += valueBytes;
409
1615
  this.wake();
1616
+ return true;
410
1617
  }
411
1618
  finish() {
1619
+ if (this.done || this.error !== undefined)
1620
+ return;
412
1621
  this.done = true;
413
1622
  this.wake();
414
1623
  }
415
1624
  fail(error) {
1625
+ if (this.done || this.error !== undefined)
1626
+ return;
1627
+ this.values.length = 0;
1628
+ this.queuedBytes = 0;
416
1629
  this.error = error;
417
1630
  this.wake();
418
1631
  }
419
- async *[Symbol.asyncIterator]() {
1632
+ [Symbol.asyncIterator]() {
1633
+ return {
1634
+ next: () => this.nextValue(),
1635
+ return: async () => {
1636
+ if (!this.returnCalled) {
1637
+ this.returnCalled = true;
1638
+ this.onReturn?.();
1639
+ }
1640
+ this.values.length = 0;
1641
+ this.queuedBytes = 0;
1642
+ this.done = true;
1643
+ this.wake();
1644
+ return { done: true, value: undefined };
1645
+ },
1646
+ [Symbol.asyncIterator]() {
1647
+ return this;
1648
+ }
1649
+ };
1650
+ }
1651
+ async nextValue() {
420
1652
  while (true) {
421
1653
  const value = this.values.shift();
422
1654
  if (value !== undefined) {
423
- yield value;
424
- continue;
1655
+ this.queuedBytes = Math.max(0, this.queuedBytes - this.sizeOf(value));
1656
+ return { done: false, value };
425
1657
  }
426
1658
  if (this.error !== undefined)
427
1659
  throw this.error;
428
1660
  if (this.done)
429
- return;
1661
+ return { done: true, value: undefined };
430
1662
  await new Promise(resolve => {
431
1663
  this.waiters.push(resolve);
432
1664
  });
@@ -438,6 +1670,113 @@ class AsyncQueue {
438
1670
  waiter();
439
1671
  }
440
1672
  }
1673
+ function boundedValueBytes(value) {
1674
+ try {
1675
+ const encoded = JSON.stringify(value);
1676
+ return encoded === undefined ? Number.POSITIVE_INFINITY : Buffer.byteLength(encoded, "utf8");
1677
+ }
1678
+ catch {
1679
+ return Number.POSITIVE_INFINITY;
1680
+ }
1681
+ }
441
1682
  function isRecord(value) {
442
1683
  return value !== null && typeof value === "object" && !Array.isArray(value);
443
1684
  }
1685
+ function isSingleFlightState(state) {
1686
+ return state === "single-flight" || state === "legacy";
1687
+ }
1688
+ function isDefinitelyUnsentWriteError(error) {
1689
+ return error instanceof BackendClientError
1690
+ && (error.code === "backend_frame_too_large"
1691
+ || error.code === "invalid_backend_request"
1692
+ || error.code === "backend_protocol_quarantined"
1693
+ || error.code === "backend_closed"
1694
+ || error.code === "backend_write_queue_overflow"
1695
+ || error.code === "backend_request_cancelled");
1696
+ }
1697
+ class BackendFrameError extends Error {
1698
+ code;
1699
+ constructor(code, message) {
1700
+ super(message);
1701
+ this.code = code;
1702
+ this.name = "BackendFrameError";
1703
+ }
1704
+ }
1705
+ async function* readBoundedNdjsonLines(input, limitBytes) {
1706
+ let buffered = Buffer.alloc(0);
1707
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1708
+ for await (const chunk of input) {
1709
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
1710
+ buffered = buffered.length === 0 ? Buffer.from(bytes) : Buffer.concat([buffered, bytes]);
1711
+ let newlineIndex = buffered.indexOf(0x0a);
1712
+ while (newlineIndex >= 0) {
1713
+ const frame = buffered.subarray(0, newlineIndex);
1714
+ buffered = buffered.subarray(newlineIndex + 1);
1715
+ if (frame.length > limitBytes) {
1716
+ throw new BackendFrameError("backend_frame_too_large", `Backend frame exceeds the ${limitBytes} byte limit.`);
1717
+ }
1718
+ const body = frame.length > 0 && frame[frame.length - 1] === 0x0d
1719
+ ? frame.subarray(0, frame.length - 1)
1720
+ : frame;
1721
+ try {
1722
+ yield decoder.decode(body);
1723
+ }
1724
+ catch {
1725
+ throw new BackendFrameError("backend_invalid_encoding", "Backend stdout contained invalid UTF-8.");
1726
+ }
1727
+ newlineIndex = buffered.indexOf(0x0a);
1728
+ }
1729
+ if (buffered.length > limitBytes) {
1730
+ throw new BackendFrameError("backend_frame_too_large", `Backend frame exceeds the ${limitBytes} byte limit.`);
1731
+ }
1732
+ }
1733
+ if (buffered.length > 0) {
1734
+ throw new BackendFrameError("backend_unterminated_frame", "Backend stdout ended with an unterminated NDJSON frame.");
1735
+ }
1736
+ }
1737
+ function validateTransportOptions(options) {
1738
+ if (!Array.isArray(options.command)
1739
+ || options.command.length === 0
1740
+ || options.command.some(part => typeof part !== "string")
1741
+ || options.command[0]?.trim().length === 0) {
1742
+ throw new BackendClientError("invalid_backend_options", "Stdio backend command must contain a non-empty executable.", false);
1743
+ }
1744
+ for (const [name, value] of [
1745
+ ["timeoutMs", options.timeoutMs],
1746
+ ["handshakeTimeoutMs", options.handshakeTimeoutMs],
1747
+ ["maxInFlight", options.maxInFlight],
1748
+ ["streamQueueLimit", options.streamQueueLimit],
1749
+ ["streamQueueBytesLimit", options.streamQueueBytesLimit],
1750
+ ["writeQueueLimit", options.writeQueueLimit],
1751
+ ["writeQueueBytesLimit", options.writeQueueBytesLimit],
1752
+ ["lateOutputGraceMs", options.lateOutputGraceMs],
1753
+ ["tombstoneLimit", options.tombstoneLimit],
1754
+ ["quarantineLimit", options.quarantineLimit],
1755
+ ["frameLimitBytes", options.frameLimitBytes]
1756
+ ]) {
1757
+ const max = name === "timeoutMs" || name === "handshakeTimeoutMs" || name === "lateOutputGraceMs"
1758
+ ? MAX_BACKEND_TIMER_MS
1759
+ : name === "frameLimitBytes" ? BACKEND_NDJSON_FRAME_LIMIT_BYTES
1760
+ : name === "streamQueueBytesLimit" ? MAX_BACKEND_STREAM_QUEUE_BYTES_LIMIT
1761
+ : name === "writeQueueBytesLimit" ? MAX_BACKEND_WRITE_QUEUE_BYTES_LIMIT
1762
+ : MAX_BACKEND_BUFFER_LIMIT;
1763
+ const minimum = name === "maxInFlight" ? MIN_BACKEND_IN_FLIGHT_LIMIT : 1;
1764
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum || value > max)) {
1765
+ throw new BackendClientError("invalid_backend_options", `Stdio backend option ${name} must be a safe integer at least ${minimum}.`, false);
1766
+ }
1767
+ }
1768
+ if (options.expectedIdentity !== undefined) {
1769
+ if (options.expectedIdentity === null || typeof options.expectedIdentity !== "object" || Array.isArray(options.expectedIdentity)) {
1770
+ throw new BackendClientError("invalid_backend_options", "Stdio backend expectedIdentity must be an object.", false);
1771
+ }
1772
+ const allowed = new Set(["backendSessionId", "packageName", "packageVersion", "runtime", "runtimeVersion", "buildDigest"]);
1773
+ if (Object.keys(options.expectedIdentity).some(key => !allowed.has(key))) {
1774
+ throw new BackendClientError("invalid_backend_options", "Stdio backend expectedIdentity contains unsupported fields.", false);
1775
+ }
1776
+ for (const value of Object.values(options.expectedIdentity)) {
1777
+ if (!isBoundedIdentityField(value)) {
1778
+ throw new BackendClientError("invalid_backend_options", "Stdio backend expectedIdentity fields must be bounded strings.", false);
1779
+ }
1780
+ }
1781
+ }
1782
+ }