machine-bridge-mcp 3.0.0-beta.11 → 3.0.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0-beta.12 - 2026-07-23
4
+
5
+ ### ChatGPT Streamable HTTP task continuity
6
+
7
+ - Fix remote `tools/call` handling so an HTTP/SSE connection closing is no longer interpreted as MCP cancellation. Only an explicit session-scoped `notifications/cancelled` request may remove the pending request key and send `cancel_call` to the daemon.
8
+ - Negotiate `text/event-stream` for clients that advertise it, prime the response immediately, send a bounded ten-second keepalive comment while work is active, and deliver the terminal JSON-RPC result as an SSE message. This prevents a long-running local operation from leaving the ChatGPT-to-Worker HTTP path completely idle.
9
+ - Preserve the underlying Durable Object operation with `waitUntil` when the response stream is no longer writable, so transport disposal cannot silently terminate local work. JSON-only clients retain the existing single-response behavior.
10
+ - Replace duplicated relay timing literals with one shared contract. Same-daemon reconnect recovery is extended from thirty seconds to two minutes, and the Worker pauses only the remaining normal call deadline while detached, avoiding both premature expiry during recovery and inflated timeouts while the daemon is healthy.
11
+ - Add deterministic and live Worker regressions for SSE negotiation, immediate priming, keepalives, terminal result delivery, HTTP abort without cancellation, explicit cancellation after disconnect, shared timeout ceilings, and same-instance recovery timing.
12
+
3
13
  ## 3.0.0-beta.11 - 2026-07-23
4
14
 
5
15
  ### External-review verification and observability hardening
@@ -30,6 +30,6 @@
30
30
  "action": {
31
31
  "default_title": "Machine Bridge Browser"
32
32
  },
33
- "version_name": "3.0.0-beta.11",
33
+ "version_name": "3.0.0-beta.12",
34
34
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxryYkpZhq8+VAQLHcGS9BAHQcyKX8RHGIpIwvtIVRU/rcOcE0bNdnM0aZJ/h6xWQsGDHlhvjT2+1aJaAn/9k8473BRWajzVXld961CdHYVFVHoce2hHiSJ0xydWrHMMZhAm0mN0UzjEpgZ0tMw209efcZHIvSwuxhteZMRy4kyiVjwFlOf5oXFCxRuCJnPj3AK9CmCf4XgEBuPIJ0TZmjGHOOdBvJmbCNnAWXYEo5/mf7MfCGhV4IJ1hNuhpoNQfOFKMUcw9/v/IpT62XpfXdGYTfGYCmCjC+gntK1spbkr2P4/2+sYMQtLpse71mpSNGXfcf3abU55Vpn+gncSxRQIDAQAB"
35
35
  }
@@ -171,10 +171,10 @@ Remote OAuth binds each code, access token, and refresh token to a named Machine
171
171
  7. The MCP client initializes against the sole current protocol version; an obsolete client must upgrade rather than enter a legacy execution path. The Worker returns a stateless HMAC-bound `MCP-Session-Id`, and later request/cancellation correlation is scoped by OAuth token, MCP session, JSON-RPC id type, and id value. Two clients may therefore reuse the same JSON-RPC id concurrently without collision. Sessionless POSTs remain independent and are not inserted into a token-global cancellation index. When the daemon advertises `session_bootstrap`, the Worker requests bounded local instructions and appends them to the initialization result; failure degrades to static instructions.
172
172
  8. A new daemon first authenticates as a bounded `probing` socket. The Worker sends a random `relay_probe`; the local runtime returns it through the normal session-bound result-delivery path; only the matching result produces `ready_ack`, promotion to the active daemon, and safe replacement of an incumbent connection.
173
173
  9. `tools/list` is derived only from the active end-to-end-verified daemon; without one, only `server_info` is advertised.
174
- 10. `tools/call` receives a random relay call ID and is bound to the current daemon socket, that daemon process's ephemeral instance identifier, the authenticated client request key, and the incoming HTTP abort signal.
175
- 11. The runtime validates policy and arguments, executes the tool, and returns a bounded result.
176
- 12. If the socket remains ready, the Durable Object accepts the result only from that socket. If it drops, the Worker detaches the pending call for at most thirty seconds and accepts completion only after a replacement socket with the same daemon-process identifier has passed the end-to-end readiness probe. The local runtime preserves the operation and queues a completion over the same interval.
177
- 13. A matching cancellation notification or incoming HTTP client disconnect removes the pending indexes and sends best-effort cancellation to a connected daemon. Local completion that races with cancellation is discarded. On every readiness handover, the Worker first sends an authoritative bounded `resume_calls` set; the runtime cancels active calls and queued results absent from that set before accepting `ready_ack`. A request cancelled while disconnected therefore cannot be revived by a fast reconnect.
174
+ 10. `tools/call` receives a random relay call ID and is bound to the current daemon socket, that daemon process's ephemeral instance identifier, and the authenticated client request key. When the client accepts `text/event-stream`, the Worker immediately returns an SSE priming frame, emits bounded keepalive comments while the call runs, and retains the dispatch with Durable Object `waitUntil`; JSON-only clients retain the single terminal response.
175
+ 11. The runtime validates policy and arguments, executes the tool, and returns a bounded result. Closing or losing the HTTP response stream only makes that stream unwritable; it is not an MCP cancellation and does not remove the pending request.
176
+ 12. If the socket remains ready, the Durable Object accepts the result only from that socket. If it drops, the Worker detaches the pending call for at most two minutes and accepts completion only after a replacement socket with the same daemon-process identifier has passed the end-to-end readiness probe. The local runtime preserves the operation and queues a completion over the same shared interval. The Worker pauses the record's remaining normal deadline while detached and resumes it after same-instance rebinding, so connected calls are not granted an unconditional recovery extension.
177
+ 13. Only a matching session-scoped `notifications/cancelled` request removes the pending indexes and sends best-effort cancellation to a connected daemon. Local completion that races with explicit cancellation is discarded. On every readiness handover, the Worker first sends an authoritative bounded `resume_calls` set; the runtime cancels active calls and queued results absent from that set before accepting `ready_ack`. A request explicitly cancelled while disconnected therefore cannot be revived by a fast reconnect.
178
178
  14. If same-instance readiness does not return before the grace deadline, the Worker rejects the detached request and the local runtime cancels ordinary calls, terminates their process trees, and discards queued results. A newly started daemon has a different instance identifier and cannot inherit prior calls.
179
179
  15. `start_job` is different: after durable acceptance, the detached runner is no longer bound to the relay call or socket. Later cancellation uses `cancel_job` or the local CLI.
180
180
 
@@ -254,7 +254,7 @@ Reconnect uses bounded exponential backoff with jitter. Brief self-healing inter
254
254
 
255
255
  The Worker stores socket transitions in `DaemonSocketRegistry`: `candidate` before hello, `probing` after authentication, `daemon` only after the end-to-end result probe, and `expired` after terminal failure. Durable Object alarms enforce separate hello, readiness, and steady-state liveness deadlines across hibernation. A healthy incumbent remains active while a replacement is probed; a malformed, silent, incompatible, or identity-mismatched replacement is closed without displacing it. Only a verified candidate receives `ready_ack` and then replaces the old socket. Ready daemons stay live only while inbound traffic refreshes `lastSeenAt`; silent half-open or hibernation-restored sockets are reclaimed instead of advertising `daemon.connected` while tool calls time out.
256
256
 
257
- Each daemon process generates a random bounded `instance_id` at startup and includes it in every reconnect hello. Pending calls normally retain their assigned socket. On an unexpected socket loss, only those records are detached and a thirty-second timer bounds recovery. A verified socket with the same instance ID rebinds them; another process or socket cannot resolve them. The local runtime mirrors that state machine by preserving active calls and completed-result envelopes until relay readiness returns. Before `ready_ack`, the Worker sends the exact IDs that still have remote waiters; the runtime cancels everything else and only then replays retained results through the verified socket. Grace expiry restores the terminal behavior: reject remote waiters, cancel local ordinary calls, terminate process trees, and discard undeliverable results. This does not make calls durable across daemon restart or machine failure; managed jobs remain the separate durable mechanism.
257
+ Each daemon process generates a random bounded `instance_id` at startup and includes it in every reconnect hello. Pending calls normally retain their assigned socket. On an unexpected socket loss, only those records are detached and the shared two-minute relay contract bounds recovery. A verified socket with the same instance ID rebinds them; another process or socket cannot resolve them. The local runtime mirrors that state machine by preserving active calls and completed-result envelopes until relay readiness returns. Before `ready_ack`, the Worker sends the exact IDs that still have remote waiters; the runtime cancels everything else and only then replays retained results through the verified socket. Grace expiry restores the terminal behavior: reject remote waiters, cancel local ordinary calls, terminate process trees, and discard undeliverable results. The shared execution envelope remains independent of reconnect grace. Worker operation countdown is paused while detached and resumed on same-instance rebinding, so a healthy unresponsive daemon still fails on the normal deadline while a recoverable disconnected call is not expired by two competing timers. This does not make calls durable across daemon restart or machine failure; managed jobs remain the separate durable mechanism.
258
258
 
259
259
  ## Persistence
260
260
 
package/docs/AUDIT.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Security and privacy audit notes
2
2
 
3
+ ## 2026-07-23 version 3.0.0-beta.12 ChatGPT task-continuity audit
4
+
5
+ The reported symptom was not evidence that the model simply chose to stop. Live beta.11 inspection found a healthy single launchd daemon and matching Worker, but the Worker could retain an orphaned active call after the ChatGPT turn had already ended. The service log independently recorded repeated relay outages with the same daemon process still alive, including an incident where the existing thirty-second recovery deadline cancelled an active call. A routine source search also lost its result through the same path during this audit. These observations separate process health from request continuity: the daemon can keep running while either the ChatGPT-to-Worker HTTP request or the Worker-to-daemon WebSocket disappears.
6
+
7
+ The Worker contained a protocol defect at the first boundary. Every remote tool call was returned as one buffered JSON response only after local completion, leaving a long-running request with no response bytes. It also propagated the HTTP request abort signal into the pending-call registry and sent `cancel_call` when that signal fired. An intermediary reclaiming an idle HTTP request was therefore indistinguishable from an explicit user cancellation. This directly explains the visible behavior: the current turn ends without a terminal tool result, while a later “continue” message starts a new model turn that can resume the broader task from conversation state.
8
+
9
+ Beta.12 separates transport disposal from MCP cancellation. A client advertising `text/event-stream` receives an immediate SSE priming frame, ten-second keepalive comments, and one terminal JSON-RPC message. The Durable Object registers the completion with `waitUntil`; closing the response stream only stops writes and its heartbeat timer. The pending request remains indexed until a daemon result, timeout, reconnect-grace expiry, or a session-scoped `notifications/cancelled` request. JSON-only clients remain supported, but their HTTP disconnect likewise no longer cancels the underlying MCP request.
10
+
11
+ The second failure mechanism was a timing-contract mismatch. Worker detachment and local result retention each used independent thirty-second literals, while observed routed-network outages regularly exceeded that interval. Beta.12 moves heartbeat, reconnect, execution, overhead, and maximum-call values into one packaged shared contract. Same-instance recovery is two minutes. The Worker pauses the remaining operation deadline only while a record is detached, then resumes it after same-instance rebinding; healthy connected calls retain their original timeout. The local relay accepts the same bounded execution envelope. This is bounded continuation, not durable execution across daemon restart or machine failure. Managed jobs remain the durable mechanism for work that must survive those boundaries.
12
+
13
+ Regressions exercise content negotiation including `q=0`, immediate stream priming before the daemon replies, keepalive framing, terminal result projection, response-stream cancellation without operation cancellation, real HTTP abort with retained pending/request-key indexes, subsequent explicit cancellation targeting the original daemon call, Worker/local timeout-envelope parity and detached-deadline pause/resume, and the existing same-instance socket-rebind state machine. No beta.12 Worker deployment, daemon/service replacement, global installation, acceptance record, Git push, tag, npm publication, or GitHub Release is implied by this source correction.
14
+
3
15
  ## 2026-07-23 version 3.0.0-beta.11 external-review verification
4
16
 
5
17
  The supplied read-only review was rechecked against source, package gates, platform plans, and the repository's explicit product/maintenance invariants rather than accepted as a backlog wholesale. Its central observability concern was correct: Worker field-name redaction was materially weaker than the local logger, so a future event could place a token, email address, credential URL, private-key header, or user-home path in an innocently named string field. The review did not notice a second defect in both structured loggers: sanitized caller fields were spread after core fields and could replace local `timestamp`, `level`, `component`, `message`, or `event` metadata and the corresponding Worker metadata, weakening incident chronology and event identity without executing code.
package/docs/LOGGING.md CHANGED
@@ -82,7 +82,7 @@ All per-tool starts, successes, failures, cancellations, timing, and expected la
82
82
 
83
83
  The layered repository check runner follows the same noise rule. Green child-task output is discarded after the child exits; only task name and elapsed time are printed. Failed tasks expose bounded head/tail stdout and stderr diagnostics. `MBM_CHECK_VERBOSE=1` is an explicit operator choice to stream raw child output and is not used by default or CI.
84
84
 
85
- A completed local result is normally sent on the ready relay connection. If that socket disappears, the runtime queues the bounded result envelope during the thirty-second same-daemon reconnect window rather than logging a terminal delivery failure. Debug output records only a shortened call ID and queue/reconnect counts. After the same daemon process completes readiness, replay emits one recovery event; a different process cannot inherit the result. Explicit caller cancellation suppresses eventual delivery. If the relay does not recover before the grace deadline, ordinary calls are cancelled, queued results are discarded, and the existing outage state machine determines whether the persistent failure warrants a warning. Tool arguments, commands, and result content are never logged.
85
+ A completed local result is normally sent on the ready relay connection. If that socket disappears, the runtime queues the bounded result envelope during the shared two-minute same-daemon reconnect window rather than logging a terminal delivery failure. Debug output records only a shortened call ID and queue/reconnect counts. After the same daemon process completes readiness, replay emits one recovery event; a different process cannot inherit the result. Explicit MCP cancellation suppresses eventual delivery; loss of the HTTP/SSE response stream does not. If the relay does not recover before the grace deadline, ordinary calls are cancelled, queued results are discarded, and the existing outage state machine determines whether the persistent failure warrants a warning. Tool arguments, commands, and result content are never logged.
86
86
 
87
87
  Debug per-tool fields may include tool name, duration, coarse outcome class, and a shortened random call identifier. The identifier is for correlating adjacent local events and is not a stable audit identifier. Authorization failures expose a random approval ID, scope, and expiry to the caller; daemon logs still omit normalized targets and request arguments.
88
88
 
@@ -51,7 +51,9 @@ A successful diagnostic result applies only to that probe. An MCP host can still
51
51
 
52
52
  Machine Bridge supports concurrent calls: the Worker admits up to 32 pending daemon calls and the local runtime admits up to 16 active tool calls. These are capacity limits, not a single global execution queue. Each successful MCP initialization receives a signed `MCP-Session-Id`; JSON-RPC ids and cancellation are scoped to that session, so separate chat windows may reuse the same numeric ids safely even when they share one OAuth account and token.
53
53
 
54
- `server_info.worker.pending_calls` reports `active`, `detached`, `request_keys`, `maximum`, `oldest_ms`, and `by_tool`. `worker.sockets_live` separately reports `authenticated`, `probing`, `ready`, and `candidates`; only `ready` sockets contribute to `daemon.connected` and tool advertisement. A nonzero `active` count means work is in flight, not that the bridge is locked. `detached > 0` means a daemon socket was lost and those requests are inside the bounded thirty-second same-instance reconnect window. Calls for simple reads and probes should continue while another independent process call runs. Explicit MCP cancellation, an incoming HTTP client disconnect, and timeout remove the pending record and its session request key. A daemon-socket closure detaches only calls assigned to that socket; the same daemon process can reclaim them after completing readiness, while another process cannot. Grace expiry rejects the request and cancels the local ordinary operation. Refreshing a chat page is not the recovery mechanism and should not be required.
54
+ `server_info.worker.pending_calls` reports `active`, `detached`, `request_keys`, `maximum`, `oldest_ms`, and `by_tool`. `worker.sockets_live` separately reports `authenticated`, `probing`, `ready`, and `candidates`; only `ready` sockets contribute to `daemon.connected` and tool advertisement. A nonzero `active` count means work is in flight, not that the bridge is locked. `detached > 0` means a daemon socket was lost and those requests are inside the bounded two-minute same-instance reconnect window. Calls for simple reads and probes should continue while another independent process call runs. Only explicit session-scoped MCP cancellation, timeout, or reconnect-grace expiry removes the pending record and its request key; an HTTP response disconnect is not cancellation. A daemon-socket closure detaches only calls assigned to that socket; the same daemon process can reclaim them after completing readiness, while another process cannot. Grace expiry rejects the request and cancels the local ordinary operation. Refreshing a chat page is not the recovery mechanism and should not be required.
55
+
56
+ For Streamable HTTP clients such as ChatGPT that advertise `text/event-stream`, remote tool calls return an immediate SSE frame and a keepalive comment every ten seconds until the terminal JSON-RPC result. This keeps a long call from presenting as an idle HTTP response. If the client or an intermediary nevertheless closes that stream, Machine Bridge keeps the bounded operation alive; only `notifications/cancelled` carries cancellation semantics. A completed result cannot be delivered back onto a stream that no longer exists, so the host may still need a later model turn to inspect resulting workspace state, but the bridge no longer kills the local operation merely because the response transport disappeared.
55
57
 
56
58
  `server_info.worker.observability.calls.unmatched_results` counts results that reached the Worker after their pending record was already removed. A small increase can accompany cancellation or timeout races, especially during mixed-version upgrade convergence; sustained growth together with old pending calls indicates incompatible components or a lifecycle defect. The counter contains no tool arguments or result data.
57
59
 
package/docs/TESTING.md CHANGED
@@ -80,7 +80,7 @@ The suite includes:
80
80
  - P-256 root generation, root-certified ephemeral session issuance, macOS trust-broker build/signature checks, signed WebSocket preflight, one-time transactional nonce consumption, challenge transcript binding, wrong-root/session/tamper/expiry/replay rejection, and prevention of unauthenticated candidate churn;
81
81
  - request-scoped effective authority and catalog-wide risk review; non-escalatable reviewer/editor/operator ceilings; authenticated-owner direct execution; control-plane root denial; external and sensitive path composition; persistence-target rejection; symbolic-link ancestor and patch-move canonicalization; owner-only browser/application/data-export and persistent-plan effects; account/client/refresh-family ownership of processes, output sessions, and jobs; delegated sandbox fail-closed behavior; legacy-lease non-consumption; and malformed-record rejection;
82
82
  - root-certified ephemeral P-256 account-administration requests with origin/method/path/body/key/time/nonce binding, transactional one-time nonce consumption, removal of the long-lived administration secret, certificate/signature/body tamper rejection, nonce replay rejection, and malformed nonce-state fail-closed behavior;
83
- - live local Worker OAuth registration, the unauthenticated `resource_metadata` challenge, protected-resource and authorization-server discovery, Streamable transport metadata, consent, URL-constructed `303` callbacks including the ChatGPT and hosted Claude redirect URIs with encoded state, PKCE, `offline_access`, form-encoded authorization-code and refresh-token exchanges, fifteen-minute access tokens, trusted single-account client binding, optional DPoP proof and token-family binding, unsupported critical-header rejection, proof-verification non-consumption, post-authorization replay consumption, invalid-grant cache-exhaustion resistance, independent client revocation, refresh-family idle/absolute limits, bounded consumed-token/revoked-family replay state, record-level schema validation, access/refresh rotation, stale refresh replay rejection with whole-family access/refresh revocation, account-version refresh revocation, authorization-code replay rejection, pending-registration throttling that excludes already authorized DCR clients, exact built-in ChatGPT/Grok browser origins, additive custom origins, unrelated-origin preflight rejection, no CORS response sharing for unrelated or opaque origins, opaque-origin authorization-form routing, exact per-request redirect-origin CSP with narrowly scoped Microsoft regional-consent and final Copilot Studio handoff exceptions, accessible credential-error rendering, protocol negotiation, HMAC-bound MCP session issuance, two-session same-id concurrency, sessionless same-id independence, session-scoped cancellation isolation, same-session duplicate rejection, daemon-backed session bootstrap, dynamic tool advertisement, rich content, candidate/probing/ready transitions, invalid readiness-result rejection, incumbent preservation until verified handover, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure. The metadata/refresh contract is the path used by Claude DCR and Copilot Studio Dynamic discovery. The same integration runs an `editor` account against a canonical `full` daemon and proves that `server_info` and remote `project_overview` report effective `edit` authority while retaining the full daemon ceiling only in explicitly scoped fields.
83
+ - live local Worker OAuth registration, the unauthenticated `resource_metadata` challenge, protected-resource and authorization-server discovery, Streamable transport metadata, consent, URL-constructed `303` callbacks including the ChatGPT and hosted Claude redirect URIs with encoded state, PKCE, `offline_access`, form-encoded authorization-code and refresh-token exchanges, fifteen-minute access tokens, trusted single-account client binding, optional DPoP proof and token-family binding, unsupported critical-header rejection, proof-verification non-consumption, post-authorization replay consumption, invalid-grant cache-exhaustion resistance, independent client revocation, refresh-family idle/absolute limits, bounded consumed-token/revoked-family replay state, record-level schema validation, access/refresh rotation, stale refresh replay rejection with whole-family access/refresh revocation, account-version refresh revocation, authorization-code replay rejection, pending-registration throttling that excludes already authorized DCR clients, exact built-in ChatGPT/Grok browser origins, additive custom origins, unrelated-origin preflight rejection, no CORS response sharing for unrelated or opaque origins, opaque-origin authorization-form routing, exact per-request redirect-origin CSP with narrowly scoped Microsoft regional-consent and final Copilot Studio handoff exceptions, accessible credential-error rendering, protocol negotiation, HMAC-bound MCP session issuance, SSE content negotiation including `q=0`, immediate stream priming, keepalive and terminal-event framing, HTTP abort without implicit cancellation, explicit cancellation after response disconnect, shared Worker/local timeout ceilings, two-session same-id concurrency, sessionless same-id independence, session-scoped cancellation isolation, same-session duplicate rejection, daemon-backed session bootstrap, dynamic tool advertisement, rich content, candidate/probing/ready transitions, invalid readiness-result rejection, incumbent preservation until verified handover, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure. The metadata/refresh contract is the path used by Claude DCR and Copilot Studio Dynamic discovery. The same integration runs an `editor` account against a canonical `full` daemon and proves that `server_info` and remote `project_overview` report effective `edit` authority while retaining the full daemon ceiling only in explicitly scoped fields.
84
84
  - local runtime proof that one blocked tool handler does not serialize an independent handler, plus relay fault injection proving an undeliverable terminal result interrupts the ambiguous socket and enters reconnect backoff.
85
85
  - a real headless-Chrome OAuth navigation regression with four cases: `form-action 'self'` blocks the first cross-origin callback, allowing only the registered callback blocks the regional redirect, allowing the registered and regional callbacks blocks the final Copilot Studio redirect, and the complete policy preserves `code` and `state` through all three cross-origin hops. Linux CI fails if Chrome is unavailable; other environments skip only this browser executable check while retaining the Worker CSP assertions.
86
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "3.0.0-beta.11",
3
+ "version": "3.0.0-beta.12",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import relayContract from "../shared/relay-contract.json" with { type: "json" };
4
+
3
5
  /** @typedef {{id?: unknown, [key: string]: unknown}} RelayResult */
4
6
  /** @typedef {{event?: (level: string, name: string, fields: Record<string, unknown>, message: string) => void, warn?: (message: string) => void}} RecoveryLogger */
5
7
  /** @typedef {{setTimeout: (callback: () => void, delay: number) => any, clearTimeout: (handle: any) => void}} RecoveryScheduler */
@@ -17,7 +19,7 @@
17
19
  * }} RelayCallRecoveryOptions
18
20
  */
19
21
 
20
- const DEFAULT_RECONNECT_GRACE_MS = 30_000;
22
+ const DEFAULT_RECONNECT_GRACE_MS = relayContract.reconnectGraceMs;
21
23
 
22
24
  export class RelayCallRecovery {
23
25
  /** @param {RelayCallRecoveryOptions} [options] */
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import relayContract from "../shared/relay-contract.json" with { type: "json" };
2
3
  import { RelayConnection } from "./relay-connection.mjs";
3
4
  import { createDaemonAuthentication, createDaemonPreflightHeaders, createDeviceSessionIdentity, validateDeviceSessionIdentity } from "./device-identity.mjs";
4
5
  import { MCP_SUPPORTED_PROTOCOL_VERSIONS, SERVER_NAME } from "./tools.mjs";
@@ -72,7 +73,7 @@ export function normalizeRelayToolCall(message) {
72
73
  tool,
73
74
  arguments: argumentsValue,
74
75
  authorization,
75
- timeoutMs: clampInteger(message.timeout_ms, 60_000, 1000, 610_000),
76
+ timeoutMs: clampInteger(message.timeout_ms, 60_000, 1000, relayContract.maximumRelayToolTimeoutMs),
76
77
  };
77
78
  }
78
79
 
@@ -0,0 +1,7 @@
1
+ {
2
+ "reconnectGraceMs": 120000,
3
+ "streamHeartbeatMs": 10000,
4
+ "maximumExecutionTimeoutMs": 600000,
5
+ "toolCallOverheadMs": 5000,
6
+ "maximumRelayToolTimeoutMs": 610000
7
+ }
@@ -1,5 +1,6 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
3
+ import relayContract from "../shared/relay-contract.json" with { type: "json" };
3
4
  import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls.ts";
4
5
  import {
5
6
  DAEMON_HELLO_TIMEOUT_MS,
@@ -13,6 +14,7 @@ import {
13
14
  import { DaemonSocketRegistry } from "./daemon-sockets.ts";
14
15
  import { consumeDaemonPreflightNonce, createDaemonChallenge, verifyDaemonAuthentication, verifyDaemonPreflight } from "./daemon-auth.ts";
15
16
  import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session.ts";
17
+ import { acceptsEventStream, streamJsonRpcResponse } from "./mcp-stream.ts";
16
18
  import { daemonToolTimeoutMs } from "./tool-timeout.ts";
17
19
  import { WorkerObservability } from "./observability.ts";
18
20
  import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors.ts";
@@ -36,14 +38,14 @@ import {
36
38
  } from "./websocket-protocol.ts";
37
39
 
38
40
  const SERVER_NAME = String(serverMetadata.name);
39
- const SERVER_VERSION = "3.0.0-beta.11";
41
+ const SERVER_VERSION = "3.0.0-beta.12";
40
42
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
41
43
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
42
44
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
43
45
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
44
46
  const MAX_PENDING_CALLS = 32;
45
47
  const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
46
- const DAEMON_RECONNECT_GRACE_MS = 30_000;
48
+ const DAEMON_RECONNECT_GRACE_MS = relayContract.reconnectGraceMs;
47
49
 
48
50
  interface BridgeEnv extends OAuthControllerEnv {
49
51
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
@@ -363,13 +365,23 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
363
365
 
364
366
  const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
365
367
  if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
366
- const response = await this.dispatchJsonRpc(
368
+ const dispatch = this.dispatchJsonRpc(
367
369
  body,
368
370
  base,
369
371
  authorized,
370
372
  session.kind === "active" ? session.sessionId : "",
371
- request.signal,
372
373
  );
374
+ if (body.method === "tools/call") {
375
+ this.ctx.waitUntil(dispatch.then(() => undefined, () => undefined));
376
+ if (acceptsEventStream(request)) {
377
+ const streamed = dispatch.catch((error) => {
378
+ this.observability.event("error", "mcp.stream.dispatch.failed", { error_class: workerErrorClass(error) });
379
+ return rpcError(body.id, -32603, "Internal error");
380
+ });
381
+ return streamJsonRpcResponse(streamed);
382
+ }
383
+ }
384
+ const response = await dispatch;
373
385
  if (response === null) return new Response(null, { status: 202 });
374
386
  return session.kind === "initialize" ? json(response, 200, { "mcp-session-id": session.sessionId }) : json(response);
375
387
  }
@@ -379,7 +391,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
379
391
  base: string,
380
392
  authorized: AuthorizedToken,
381
393
  sessionId: string,
382
- signal?: AbortSignal,
383
394
  ): Promise<Record<string, unknown> | null> {
384
395
  if (request.method === "initialize") {
385
396
  const requested = asObject(request.params).protocolVersion;
@@ -387,7 +398,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
387
398
  ? requested
388
399
  : MCP_PROTOCOL_VERSION;
389
400
  const bootstrap = this.daemonToolEnabled("session_bootstrap")
390
- ? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized, undefined, signal).catch(() => null)
401
+ ? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized).catch(() => null)
391
402
  : null;
392
403
  const localInstructions = sessionInstructionText(bootstrap);
393
404
  return rpcResult(request.id, {
@@ -422,7 +433,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
422
433
  base,
423
434
  authorized,
424
435
  mcpClientRequestKey(authorized.tokenKey, sessionId, request.id),
425
- signal,
426
436
  );
427
437
  return rpcResult(request.id, textToolResult(result));
428
438
  } catch (error) {
@@ -437,7 +447,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
437
447
  base: string,
438
448
  authorized: AuthorizedToken,
439
449
  requestKey?: string,
440
- signal?: AbortSignal,
441
450
  ): Promise<unknown> {
442
451
  if (name === "server_info") {
443
452
  const { daemon, tools, authorization } = this.authorityContext(authorized);
@@ -478,7 +487,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
478
487
  if (workspaceTools.some((tool) => tool.name === name)) {
479
488
  if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
480
489
  if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
481
- const result = await this.callDaemonTool(name, args, authorized, requestKey, signal);
490
+ const result = await this.callDaemonTool(name, args, authorized, requestKey);
482
491
  return name === "project_overview" ? decorateProjectOverview(result, { accountId: authorized.accountId,
483
492
  accountVersion: authorized.accountVersion, role: authorized.role }) : result;
484
493
  }
@@ -489,7 +498,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
489
498
  args: Record<string, unknown>,
490
499
  authorized: AuthorizedToken,
491
500
  requestKey?: string,
492
- signal?: AbortSignal,
493
501
  ): Promise<unknown> {
494
502
  this.reclaimStaleDaemonSockets();
495
503
  const socket = this.daemonRegistry.readySockets()[0];
@@ -518,11 +526,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
518
526
  }
519
527
  return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
520
528
  },
521
- signal,
522
- onAbort: (record) => {
523
- if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
524
- return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
525
- },
526
529
  });
527
530
  } catch (error) {
528
531
  if (error instanceof PendingCallRegistrationError) {
@@ -531,9 +534,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
531
534
  throw error;
532
535
  }
533
536
  this.observability.callStarted(name);
534
- if (!signal?.aborted) {
535
- try {
536
- socket.send(JSON.stringify({
537
+ try {
538
+ socket.send(JSON.stringify({
537
539
  type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
538
540
  authorization: {
539
541
  account_id: authorized.accountId,
@@ -542,11 +544,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
542
544
  family_id: authorized.familyId,
543
545
  role: authorized.role,
544
546
  },
545
- }));
546
- } catch {
547
- this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
548
- this.invalidateDaemonSocket(socket, "failed to send daemon tool call", "daemon send failed");
549
- }
547
+ }));
548
+ } catch {
549
+ this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
550
+ this.invalidateDaemonSocket(socket, "failed to send daemon tool call", "daemon send failed");
550
551
  }
551
552
  try {
552
553
  const value = await result;
@@ -0,0 +1,106 @@
1
+ import relayContract from "../shared/relay-contract.json" with { type: "json" };
2
+
3
+ const DEFAULT_HEARTBEAT_MS = relayContract.streamHeartbeatMs;
4
+
5
+ type JsonRpcMessage = Record<string, unknown> | null;
6
+ type IntervalHandle = ReturnType<typeof setInterval>;
7
+
8
+ type StreamScheduler = {
9
+ setInterval: (callback: () => void, delay: number) => IntervalHandle;
10
+ clearInterval: (handle: IntervalHandle) => void;
11
+ };
12
+
13
+ type StreamResponseOptions = {
14
+ heartbeatMs?: number;
15
+ streamId?: string;
16
+ scheduler?: StreamScheduler;
17
+ keepAlive?: (promise: Promise<void>) => void;
18
+ };
19
+
20
+ export function acceptsEventStream(request: Pick<Request, "headers">): boolean {
21
+ const accept = request.headers.get("accept") ?? "";
22
+ return accept.split(",").some((entry) => {
23
+ const [mediaType, ...parameters] = entry.split(";").map((value) => value.trim().toLowerCase());
24
+ if (mediaType !== "text/event-stream") return false;
25
+ const quality = parameters.find((value) => value.startsWith("q="));
26
+ if (!quality) return true;
27
+ const parsed = Number(quality.slice(2));
28
+ return Number.isFinite(parsed) && parsed > 0;
29
+ });
30
+ }
31
+
32
+ export function streamJsonRpcResponse(
33
+ result: Promise<JsonRpcMessage>,
34
+ options: StreamResponseOptions = {},
35
+ ): Response {
36
+ const encoder = new TextEncoder();
37
+ const heartbeatMs = positiveInteger(options.heartbeatMs, DEFAULT_HEARTBEAT_MS);
38
+ const streamId = options.streamId || `stream_${crypto.randomUUID()}`;
39
+ const scheduler = options.scheduler ?? { setInterval, clearInterval };
40
+ let interval: IntervalHandle | undefined;
41
+ let writable = true;
42
+
43
+ const completion = result.then(() => undefined, () => undefined);
44
+ options.keepAlive?.(completion);
45
+
46
+ const body = new ReadableStream<Uint8Array>({
47
+ start(controller) {
48
+ const enqueue = (value: string): boolean => {
49
+ if (!writable) return false;
50
+ try {
51
+ controller.enqueue(encoder.encode(value));
52
+ return true;
53
+ } catch {
54
+ writable = false;
55
+ return false;
56
+ }
57
+ };
58
+ const stop = () => {
59
+ if (interval !== undefined) scheduler.clearInterval(interval);
60
+ interval = undefined;
61
+ };
62
+ const close = () => {
63
+ stop();
64
+ if (!writable) return;
65
+ writable = false;
66
+ try { controller.close(); } catch { /* Client disconnect is not MCP cancellation. */ }
67
+ };
68
+
69
+ enqueue(": connected\n\n");
70
+ interval = scheduler.setInterval(() => {
71
+ enqueue(": keepalive\n\n");
72
+ }, heartbeatMs);
73
+
74
+ void result.then(
75
+ (message) => {
76
+ if (message !== null) {
77
+ enqueue(`id: ${streamId}:1\nevent: message\ndata: ${JSON.stringify(message)}\n\n`);
78
+ }
79
+ close();
80
+ },
81
+ () => close(),
82
+ );
83
+ },
84
+ cancel() {
85
+ writable = false;
86
+ if (interval !== undefined) scheduler.clearInterval(interval);
87
+ interval = undefined;
88
+ // Streamable HTTP disconnect is not cancellation. The operation remains
89
+ // alive through keepAlive and can only be cancelled by MCP notification.
90
+ },
91
+ });
92
+
93
+ return new Response(body, {
94
+ status: 200,
95
+ headers: {
96
+ "content-type": "text/event-stream; charset=utf-8",
97
+ "cache-control": "no-store, no-transform",
98
+ "x-content-type-options": "nosniff",
99
+ },
100
+ });
101
+ }
102
+
103
+ function positiveInteger(value: unknown, fallback: number): number {
104
+ const parsed = Number(value);
105
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
106
+ }
@@ -6,7 +6,10 @@ export interface PendingCallRecord {
6
6
  clientRequestKey?: string;
7
7
  tool: string;
8
8
  startedAt: number;
9
- timeout: ReturnType<typeof setTimeout>;
9
+ timeout?: ReturnType<typeof setTimeout>;
10
+ deadlineAt: number;
11
+ remainingTimeoutMs: number;
12
+ onTimeout: (record: PendingCallRecord) => Error;
10
13
  resolve: (value: unknown) => void;
11
14
  reject: (error: Error) => void;
12
15
  signal?: AbortSignal;
@@ -0,0 +1,57 @@
1
+ import type { PendingCallRecord } from "./pending-call-contract.ts";
2
+
3
+ type TimerHandle = ReturnType<typeof setTimeout>;
4
+ export type PendingCallDeadlineOptions = {
5
+ now?: () => number;
6
+ scheduler?: {
7
+ setTimeout: (callback: () => void, delay: number) => TimerHandle;
8
+ clearTimeout: (handle: TimerHandle) => void;
9
+ };
10
+ };
11
+
12
+ export class PendingCallDeadlines {
13
+ private readonly clock: () => number;
14
+ private readonly scheduler: Required<PendingCallDeadlineOptions>["scheduler"];
15
+
16
+ constructor(options: PendingCallDeadlineOptions = {}) {
17
+ this.clock = options.now ?? (() => performance.now());
18
+ this.scheduler = options.scheduler ?? { setTimeout, clearTimeout };
19
+ }
20
+
21
+ now(): number {
22
+ return this.clock();
23
+ }
24
+
25
+ armOperation(record: PendingCallRecord, delayMs: number, expire: (id: string) => void): void {
26
+ if (record.timeout) this.scheduler.clearTimeout(record.timeout);
27
+ const delay = positiveDelay(delayMs);
28
+ record.remainingTimeoutMs = delay;
29
+ record.deadlineAt = this.clock() + delay;
30
+ record.timeout = this.scheduler.setTimeout(() => expire(record.id), delay);
31
+ }
32
+
33
+ pauseOperation(record: PendingCallRecord): void {
34
+ record.remainingTimeoutMs = Math.max(1, Math.ceil(record.deadlineAt - this.clock()));
35
+ if (record.timeout) this.scheduler.clearTimeout(record.timeout);
36
+ record.timeout = undefined;
37
+ }
38
+
39
+ armReconnect(record: PendingCallRecord, delayMs: number, expire: (id: string) => void): void {
40
+ if (record.reconnectTimeout) this.scheduler.clearTimeout(record.reconnectTimeout);
41
+ record.reconnectTimeout = this.scheduler.setTimeout(() => expire(record.id), positiveDelay(delayMs));
42
+ }
43
+
44
+ clearReconnect(record: PendingCallRecord): void {
45
+ if (record.reconnectTimeout) this.scheduler.clearTimeout(record.reconnectTimeout);
46
+ record.reconnectTimeout = undefined;
47
+ }
48
+
49
+ clear(record: PendingCallRecord): void {
50
+ if (record.timeout) this.scheduler.clearTimeout(record.timeout);
51
+ if (record.reconnectTimeout) this.scheduler.clearTimeout(record.reconnectTimeout);
52
+ }
53
+ }
54
+
55
+ function positiveDelay(value: unknown): number {
56
+ return Math.max(1, Math.floor(Number(value) || 1));
57
+ }
@@ -1,4 +1,5 @@
1
1
  import type { PendingCallRecord, RegisterPendingCall } from "./pending-call-contract.ts";
2
+ import { PendingCallDeadlines, type PendingCallDeadlineOptions } from "./pending-call-deadlines.ts";
2
3
 
3
4
  export class PendingCallRegistrationError extends Error {
4
5
  readonly code: "conflict" | "limit_exceeded";
@@ -16,11 +17,12 @@ export class PendingCallRegistry {
16
17
  private readonly maximum: number;
17
18
  private readonly byId = new Map<string, PendingCallRecord>();
18
19
  private readonly byRequestKey = new Map<string, string>();
20
+ private readonly deadlines: PendingCallDeadlines;
19
21
 
20
- constructor(maximum: number) {
22
+ constructor(maximum: number, options: PendingCallDeadlineOptions = {}) {
21
23
  this.maximum = maximum;
24
+ this.deadlines = new PendingCallDeadlines(options);
22
25
  }
23
-
24
26
  get size(): number {
25
27
  return this.byId.size;
26
28
  }
@@ -35,16 +37,9 @@ export class PendingCallRegistry {
35
37
  if (input.clientRequestKey && this.byRequestKey.has(input.clientRequestKey)) {
36
38
  throw new PendingCallRegistrationError("conflict", "duplicate in-flight JSON-RPC request id within this MCP session");
37
39
  }
38
- const startedAt = performance.now();
40
+ const startedAt = this.deadlines.now();
41
+ const timeoutMs = Math.max(1, Math.floor(Number(input.timeoutMs) || 1));
39
42
  return new Promise((resolve, reject) => {
40
- const timeout = setTimeout(() => {
41
- const record = this.take(input.id);
42
- if (!record) return;
43
- let error: unknown;
44
- try { error = input.onTimeout(record); }
45
- catch { error = new Error("pending daemon call timed out"); }
46
- reject(error instanceof Error ? error : new Error("pending daemon call timed out"));
47
- }, input.timeoutMs);
48
43
  const abortHandler = () => {
49
44
  const record = this.take(input.id);
50
45
  if (!record) return;
@@ -60,7 +55,9 @@ export class PendingCallRegistry {
60
55
  clientRequestKey: input.clientRequestKey,
61
56
  tool: String(input.tool || "unknown"),
62
57
  startedAt,
63
- timeout,
58
+ deadlineAt: startedAt + timeoutMs,
59
+ remainingTimeoutMs: timeoutMs,
60
+ onTimeout: input.onTimeout,
64
61
  resolve,
65
62
  reject,
66
63
  signal: input.signal,
@@ -68,6 +65,7 @@ export class PendingCallRegistry {
68
65
  };
69
66
  this.byId.set(input.id, record);
70
67
  if (input.clientRequestKey) this.byRequestKey.set(input.clientRequestKey, input.id);
68
+ this.deadlines.armOperation(record, timeoutMs, (id) => this.expireOperation(id));
71
69
  if (input.signal?.aborted) abortHandler();
72
70
  else input.signal?.addEventListener("abort", abortHandler, { once: true });
73
71
  });
@@ -114,13 +112,13 @@ export class PendingCallRegistry {
114
112
  const delay = Math.max(1, Math.floor(Number(graceMs) || 1));
115
113
  for (const record of records) {
116
114
  record.socket = undefined;
117
- if (record.reconnectTimeout) clearTimeout(record.reconnectTimeout);
118
- record.reconnectTimeout = setTimeout(() => {
119
- const current = this.byId.get(record.id);
115
+ this.deadlines.pauseOperation(record);
116
+ this.deadlines.armReconnect(record, delay, (id) => {
117
+ const current = this.byId.get(id);
120
118
  if (!current || current.socket) return;
121
- const expired = this.take(record.id);
119
+ const expired = this.take(id);
122
120
  if (expired) expired.reject(createError(expired));
123
- }, delay);
121
+ });
124
122
  }
125
123
  return records.length;
126
124
  }
@@ -130,16 +128,16 @@ export class PendingCallRegistry {
130
128
  const rebound: string[] = [];
131
129
  for (const record of this.byId.values()) {
132
130
  if (record.socket || record.daemonInstanceId !== daemonInstanceId) continue;
133
- if (record.reconnectTimeout) clearTimeout(record.reconnectTimeout);
134
- record.reconnectTimeout = undefined;
131
+ this.deadlines.clearReconnect(record);
135
132
  record.socket = socket;
133
+ this.deadlines.armOperation(record, record.remainingTimeoutMs, (id) => this.expireOperation(id));
136
134
  rebound.push(record.id);
137
135
  }
138
136
  return rebound;
139
137
  }
140
138
 
141
139
  snapshot(): { active: number; detached: number; request_keys: number; maximum: number; oldest_ms: number; by_tool: Record<string, number> } {
142
- const now = performance.now();
140
+ const now = this.deadlines.now();
143
141
  const byTool: Record<string, number> = {};
144
142
  let detached = 0;
145
143
  let oldestMs = 0;
@@ -158,11 +156,19 @@ export class PendingCallRegistry {
158
156
  };
159
157
  }
160
158
 
159
+ private expireOperation(id: string): void {
160
+ const record = this.take(id);
161
+ if (!record) return;
162
+ let error: unknown;
163
+ try { error = record.onTimeout(record); }
164
+ catch { error = new Error("pending daemon call timed out"); }
165
+ record.reject(error instanceof Error ? error : new Error("pending daemon call timed out"));
166
+ }
167
+
161
168
  private take(id: string): PendingCallRecord | undefined {
162
169
  const record = this.byId.get(id);
163
170
  if (!record) return undefined;
164
- clearTimeout(record.timeout);
165
- if (record.reconnectTimeout) clearTimeout(record.reconnectTimeout);
171
+ this.deadlines.clear(record);
166
172
  if (record.signal && record.abortHandler) record.signal.removeEventListener("abort", record.abortHandler);
167
173
  this.byId.delete(id);
168
174
  if (record.clientRequestKey && this.byRequestKey.get(record.clientRequestKey) === id) {
@@ -1,3 +1,5 @@
1
+ import relayContract from "../shared/relay-contract.json" with { type: "json" };
2
+
1
3
  export function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
2
4
  if (name === "session_bootstrap") return 10_000;
3
5
  const configurable = new Set([
@@ -8,7 +10,8 @@ export function daemonToolTimeoutMs(name: string, args: Record<string, unknown>)
8
10
  ]);
9
11
  if (!configurable.has(name)) return 60_000;
10
12
  const seconds = clampNumber(args.timeout_seconds, name === "browser_fill_form" ? 60 : 120, 1, 600);
11
- return Math.min((seconds + 5) * 1000, 610_000);
13
+ const executionMs = Math.min(seconds * 1000, relayContract.maximumExecutionTimeoutMs);
14
+ return Math.min(executionMs + relayContract.toolCallOverheadMs, relayContract.maximumRelayToolTimeoutMs);
12
15
  }
13
16
 
14
17
  function clampNumber(value: unknown, fallback: number, min: number, max: number): number {