@basein/runner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +276 -0
  3. package/dist/auth/client.d.ts +85 -0
  4. package/dist/auth/client.js +284 -0
  5. package/dist/bin/bir-hooks.d.ts +48 -0
  6. package/dist/bin/bir-hooks.js +201 -0
  7. package/dist/bin/bir-proxy.d.ts +45 -0
  8. package/dist/bin/bir-proxy.js +207 -0
  9. package/dist/bin/bir-scenario.d.ts +24 -0
  10. package/dist/bin/bir-scenario.js +177 -0
  11. package/dist/bin/bir.d.ts +21 -0
  12. package/dist/bin/bir.js +876 -0
  13. package/dist/config/adapters/claude-code.d.ts +76 -0
  14. package/dist/config/adapters/claude-code.js +181 -0
  15. package/dist/config/adapters/generic.d.ts +17 -0
  16. package/dist/config/adapters/generic.js +36 -0
  17. package/dist/config/generate.d.ts +127 -0
  18. package/dist/config/generate.js +114 -0
  19. package/dist/config/resolve.d.ts +68 -0
  20. package/dist/config/resolve.js +132 -0
  21. package/dist/control/client.d.ts +56 -0
  22. package/dist/control/client.js +86 -0
  23. package/dist/control/correlation.d.ts +86 -0
  24. package/dist/control/correlation.js +0 -0
  25. package/dist/control/discovery.d.ts +50 -0
  26. package/dist/control/discovery.js +123 -0
  27. package/dist/control/ordering.d.ts +38 -0
  28. package/dist/control/ordering.js +44 -0
  29. package/dist/control/paths.d.ts +32 -0
  30. package/dist/control/paths.js +56 -0
  31. package/dist/control/server.d.ts +272 -0
  32. package/dist/control/server.js +1131 -0
  33. package/dist/control/transcript.d.ts +75 -0
  34. package/dist/control/transcript.js +241 -0
  35. package/dist/index.d.ts +37 -0
  36. package/dist/index.js +32 -0
  37. package/dist/jsonrpc/framing.d.ts +49 -0
  38. package/dist/jsonrpc/framing.js +143 -0
  39. package/dist/jsonrpc/types.d.ts +52 -0
  40. package/dist/jsonrpc/types.js +46 -0
  41. package/dist/proxy/intercept.d.ts +55 -0
  42. package/dist/proxy/intercept.js +147 -0
  43. package/dist/proxy/relay.d.ts +97 -0
  44. package/dist/proxy/relay.js +166 -0
  45. package/dist/proxy/session.d.ts +116 -0
  46. package/dist/proxy/session.js +319 -0
  47. package/dist/record/housekeeping.d.ts +34 -0
  48. package/dist/record/housekeeping.js +39 -0
  49. package/dist/record/queue.d.ts +48 -0
  50. package/dist/record/queue.js +96 -0
  51. package/dist/record/recorder.d.ts +111 -0
  52. package/dist/record/recorder.js +39 -0
  53. package/dist/record/redact.d.ts +37 -0
  54. package/dist/record/redact.js +119 -0
  55. package/dist/record/remote-recorder.d.ts +110 -0
  56. package/dist/record/remote-recorder.js +301 -0
  57. package/dist/record/truncate.d.ts +36 -0
  58. package/dist/record/truncate.js +85 -0
  59. package/dist/replay/bundle.d.ts +36 -0
  60. package/dist/replay/bundle.js +89 -0
  61. package/dist/replay/controller.d.ts +300 -0
  62. package/dist/replay/controller.js +807 -0
  63. package/dist/replay/coverage.d.ts +41 -0
  64. package/dist/replay/coverage.js +56 -0
  65. package/dist/replay/derive.d.ts +58 -0
  66. package/dist/replay/derive.js +166 -0
  67. package/dist/replay/executor.d.ts +78 -0
  68. package/dist/replay/executor.js +233 -0
  69. package/dist/replay/logic.d.ts +31 -0
  70. package/dist/replay/logic.js +50 -0
  71. package/dist/replay/plan.d.ts +181 -0
  72. package/dist/replay/plan.js +397 -0
  73. package/dist/replay/pricing.d.ts +41 -0
  74. package/dist/replay/pricing.js +76 -0
  75. package/dist/replay/source-run.d.ts +50 -0
  76. package/dist/replay/source-run.js +98 -0
  77. package/dist/replay/tool-error.d.ts +22 -0
  78. package/dist/replay/tool-error.js +60 -0
  79. package/dist/replay/types.d.ts +116 -0
  80. package/dist/replay/types.js +35 -0
  81. package/dist/upstream/client.d.ts +78 -0
  82. package/dist/upstream/client.js +114 -0
  83. package/dist/upstream/http-client.d.ts +78 -0
  84. package/dist/upstream/http-client.js +261 -0
  85. package/dist/upstream/lazy-client.d.ts +31 -0
  86. package/dist/upstream/lazy-client.js +53 -0
  87. package/dist/upstream/stdio-client.d.ts +57 -0
  88. package/dist/upstream/stdio-client.js +203 -0
  89. package/dist/util/log.d.ts +27 -0
  90. package/dist/util/log.js +51 -0
  91. package/dist/util/version.d.ts +2 -0
  92. package/dist/util/version.js +40 -0
  93. package/docs/BaseInstRunner.md +621 -0
  94. package/docs/calculatedReplay.md +1185 -0
  95. package/docs/calculatedReplayGuide.md +448 -0
  96. package/docs/installRun.md +413 -0
  97. package/docs/mcpmark.md +752 -0
  98. package/docs/quickstart.md +201 -0
  99. package/docs/t-bench.md +394 -0
  100. package/package.json +56 -0
@@ -0,0 +1,55 @@
1
+ /**
2
+ * intercept — the whole intercept table (Phase 3). Three methods. That is all.
3
+ *
4
+ * | Method | Direction | Action |
5
+ * |--------------|----------------|-----------------------------------------------|
6
+ * | `initialize` | host → upstream| Relay, and rewrite as little as possible. |
7
+ * | `tools/list` | host → upstream| Relay; relax schemas only when Tier 1 needs it.|
8
+ * | `tools/call` | host → upstream| Strip the call id, time it, relay, report it. |
9
+ *
10
+ * `initialize` DESERVES ITS OWN PARAGRAPH, because getting it wrong is what makes
11
+ * a proxy silently swallow half a server. The host's `capabilities` go upstream
12
+ * **verbatim**, so the upstream learns whether sampling and elicitation exist;
13
+ * the upstream's `capabilities` and `serverInfo` come back **verbatim**, so
14
+ * prompts, resources and logging stay advertised. Nothing here hard-codes
15
+ * `{ tools: {} }`, and `serverInfo` stays the upstream's — `bir doctor` proves a
16
+ * proxy is in the path by asking the control server which proxies registered, not
17
+ * by branding the handshake.
18
+ *
19
+ * TWO RULES THAT ARE EASY TO GET WRONG, and are enforced here:
20
+ * - **Never rewrite a `tools/call` result.** The upstream object is returned
21
+ * as-is, `_meta` and `structuredContent` included. A *copy* is recorded.
22
+ * - **Never let recording block the response.** The step is queued after the
23
+ * result is already on its way to the host.
24
+ */
25
+ import { BIR_CALL_ID } from "../control/correlation.js";
26
+ import { type JsonRpcMessage, type JsonRpcResponse } from "../jsonrpc/types.js";
27
+ import type { Interceptor, PendingRequest } from "./relay.js";
28
+ import type { ProxySession } from "./session.js";
29
+ export interface RecordingInterceptorOptions {
30
+ serverName: string;
31
+ session: ProxySession;
32
+ }
33
+ export declare class RecordingInterceptor implements Interceptor {
34
+ private readonly serverName;
35
+ private readonly session;
36
+ /** Host request id → the call it opened. Only `tools/call` ids appear here. */
37
+ private readonly calls;
38
+ constructor(opts: RecordingInterceptorOptions);
39
+ onHostMessage(msg: JsonRpcMessage): ReturnType<NonNullable<Interceptor["onHostMessage"]>>;
40
+ onUpstreamResponse(pending: PendingRequest, response: JsonRpcResponse): JsonRpcMessage | void;
41
+ onAbandoned(pending: PendingRequest, reason: string): void;
42
+ /** Build the step report from a completed call. Redaction happens on the copy. */
43
+ private report;
44
+ /**
45
+ * Redact, then clip long strings, on the recording copy only.
46
+ *
47
+ * Clipping happens here rather than only at serialization time because a
48
+ * browser MCP's base64 screenshot would otherwise sit in the step queue in
49
+ * full, possibly for the length of the run.
50
+ */
51
+ private scrub;
52
+ }
53
+ /** Re-exported so callers wiring a proxy need one import for the correlation key. */
54
+ export { BIR_CALL_ID };
55
+ //# sourceMappingURL=intercept.d.ts.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * intercept — the whole intercept table (Phase 3). Three methods. That is all.
3
+ *
4
+ * | Method | Direction | Action |
5
+ * |--------------|----------------|-----------------------------------------------|
6
+ * | `initialize` | host → upstream| Relay, and rewrite as little as possible. |
7
+ * | `tools/list` | host → upstream| Relay; relax schemas only when Tier 1 needs it.|
8
+ * | `tools/call` | host → upstream| Strip the call id, time it, relay, report it. |
9
+ *
10
+ * `initialize` DESERVES ITS OWN PARAGRAPH, because getting it wrong is what makes
11
+ * a proxy silently swallow half a server. The host's `capabilities` go upstream
12
+ * **verbatim**, so the upstream learns whether sampling and elicitation exist;
13
+ * the upstream's `capabilities` and `serverInfo` come back **verbatim**, so
14
+ * prompts, resources and logging stay advertised. Nothing here hard-codes
15
+ * `{ tools: {} }`, and `serverInfo` stays the upstream's — `bir doctor` proves a
16
+ * proxy is in the path by asking the control server which proxies registered, not
17
+ * by branding the handshake.
18
+ *
19
+ * TWO RULES THAT ARE EASY TO GET WRONG, and are enforced here:
20
+ * - **Never rewrite a `tools/call` result.** The upstream object is returned
21
+ * as-is, `_meta` and `structuredContent` included. A *copy* is recorded.
22
+ * - **Never let recording block the response.** The step is queued after the
23
+ * result is already on its way to the host.
24
+ */
25
+ import { BIR_CALL_ID, extractCallId, qualifyToolName, relaxToolsListResult, } from "../control/correlation.js";
26
+ import { redact } from "../record/redact.js";
27
+ import { truncateStrings, DEFAULT_MAX_STRING_BYTES } from "../record/truncate.js";
28
+ import { paramsObject } from "../jsonrpc/types.js";
29
+ import { logDetail } from "../util/log.js";
30
+ export class RecordingInterceptor {
31
+ serverName;
32
+ session;
33
+ /** Host request id → the call it opened. Only `tools/call` ids appear here. */
34
+ calls = new Map();
35
+ constructor(opts) {
36
+ this.serverName = opts.serverName;
37
+ this.session = opts.session;
38
+ }
39
+ onHostMessage(msg) {
40
+ const method = msg.method;
41
+ const id = msg.id;
42
+ if (method !== "tools/call" || id === undefined || id === null) {
43
+ // Everything else — including `initialize` — is forwarded untouched.
44
+ return { action: "forward" };
45
+ }
46
+ const params = paramsObject(msg.params);
47
+ const toolName = String(params.name ?? "");
48
+ const { callId, cleaned } = extractCallId(params.arguments);
49
+ this.calls.set(id, { callId, toolName, args: cleaned, startedAt: Date.now() });
50
+ if (callId === undefined)
51
+ return { action: "forward" };
52
+ // The upstream must never see a key its schema does not declare, so the
53
+ // correlation id is stripped here rather than being relayed and ignored.
54
+ logDetail("call.correlated", { server: this.serverName, tool: toolName, callId });
55
+ return {
56
+ action: "forward",
57
+ message: {
58
+ ...msg,
59
+ params: { ...params, arguments: cleaned },
60
+ },
61
+ };
62
+ }
63
+ onUpstreamResponse(pending, response) {
64
+ if (pending.method === "tools/list") {
65
+ // The only rewrite in the whole proxy, and only when Tier 1 correlation is
66
+ // actually active: relaxation is visible to the model on every call, so it
67
+ // is never paid for unless it is buying a merged step (Phase 6).
68
+ if (!this.session.correlationActive || response.error)
69
+ return;
70
+ return { ...response, result: relaxToolsListResult(response.result) };
71
+ }
72
+ if (pending.method !== "tools/call")
73
+ return;
74
+ const call = this.calls.get(pending.id);
75
+ this.calls.delete(pending.id);
76
+ if (!call)
77
+ return;
78
+ // Report *after* returning: the relay writes what we return here, and the
79
+ // queue push below runs on this same tick but does no I/O of its own.
80
+ this.report(call, response);
81
+ return; // the upstream's result, verbatim
82
+ }
83
+ onAbandoned(pending, reason) {
84
+ const call = this.calls.get(pending.id);
85
+ if (!call)
86
+ return;
87
+ this.calls.delete(pending.id);
88
+ this.session.reportStep({
89
+ callId: call.callId,
90
+ serverName: this.serverName,
91
+ toolName: call.toolName,
92
+ qualifiedName: qualifyToolName(this.serverName, call.toolName),
93
+ args: this.scrub(call.args),
94
+ isError: true,
95
+ errorMessage: reason,
96
+ startedAt: call.startedAt,
97
+ durationMs: Date.now() - call.startedAt,
98
+ });
99
+ }
100
+ /** Build the step report from a completed call. Redaction happens on the copy. */
101
+ report(call, response) {
102
+ const durationMs = Date.now() - call.startedAt;
103
+ const result = response.result;
104
+ let isError = false;
105
+ let errorMessage;
106
+ if (response.error) {
107
+ // A protocol-level failure: the call never produced a tool result.
108
+ isError = true;
109
+ errorMessage = `${response.error.code}: ${response.error.message}`;
110
+ }
111
+ else if (result && typeof result === "object" && result.isError === true) {
112
+ isError = true;
113
+ }
114
+ const step = {
115
+ callId: call.callId,
116
+ serverName: this.serverName,
117
+ toolName: call.toolName,
118
+ qualifiedName: qualifyToolName(this.serverName, call.toolName),
119
+ args: this.scrub(call.args),
120
+ result: response.error ? this.scrub(response.error) : this.scrub(response.result),
121
+ isError,
122
+ errorMessage,
123
+ startedAt: call.startedAt,
124
+ durationMs,
125
+ };
126
+ this.session.reportStep(step);
127
+ logDetail("call.done", {
128
+ server: this.serverName,
129
+ tool: call.toolName,
130
+ ms: durationMs,
131
+ isError,
132
+ });
133
+ }
134
+ /**
135
+ * Redact, then clip long strings, on the recording copy only.
136
+ *
137
+ * Clipping happens here rather than only at serialization time because a
138
+ * browser MCP's base64 screenshot would otherwise sit in the step queue in
139
+ * full, possibly for the length of the run.
140
+ */
141
+ scrub(value) {
142
+ return truncateStrings(redact(value), DEFAULT_MAX_STRING_BYTES);
143
+ }
144
+ }
145
+ /** Re-exported so callers wiring a proxy need one import for the correlation key. */
146
+ export { BIR_CALL_ID };
147
+ //# sourceMappingURL=intercept.js.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * relay — the bidirectional JSON-RPC relay (Phase 1.2). The heart, and
3
+ * deliberately dumb.
4
+ *
5
+ * THE DESIGN RULE THAT MAKES FULL PASSTHROUGH TRACTABLE:
6
+ *
7
+ * > **Default-forward, intercept by exception.** Do not enumerate the
8
+ * > protocol. Forward any message you have no specific reason to touch.
9
+ *
10
+ * So this file knows about exactly two things: matching responses to the
11
+ * requests that caused them (needed to time a call and to answer in-flight
12
+ * requests when an upstream dies), and handing each message to an
13
+ * {@link Interceptor} that may look at it. Everything else — `prompts/*`,
14
+ * `resources/*`, `completion/complete`, `logging/setLevel`, progress,
15
+ * cancellation, and the whole reverse direction (`sampling/createMessage`,
16
+ * `elicitation/create`, `roots/list`, every `list_changed` notification) —
17
+ * passes through *because it is never mentioned*. Unknown methods work for the
18
+ * same reason. That is D7.
19
+ *
20
+ * IDS. The relay is 1:1, so host ids pass through unchanged; a `Map` of them is
21
+ * kept anyway because it is what makes timing and cancellation possible. Any
22
+ * request the proxy originates itself lives in the disjoint `bir:` id space
23
+ * (see `upstream/client.ts`), so a collision cannot happen.
24
+ */
25
+ import { type JsonRpcMessage, type JsonRpcResponse } from "../jsonrpc/types.js";
26
+ import type { UpstreamClient } from "../upstream/client.js";
27
+ import type { Readable, Writable } from "node:stream";
28
+ /** A host request the upstream has not answered yet. */
29
+ export interface PendingRequest {
30
+ id: string | number;
31
+ method: string;
32
+ /** Params as the *upstream* received them (post-transform). */
33
+ params: unknown;
34
+ startedAt: number;
35
+ }
36
+ export type HostDecision =
37
+ /** Send `message` (or the original) upstream. */
38
+ {
39
+ action: "forward";
40
+ message?: JsonRpcMessage;
41
+ }
42
+ /** Answer the host directly; the upstream never sees it. */
43
+ | {
44
+ action: "respond";
45
+ message: JsonRpcMessage;
46
+ };
47
+ /**
48
+ * The intercept table's interface. Every method is optional and **synchronous**:
49
+ * recording must never sit between the upstream's answer and the host (§3,
50
+ * Phase 3), so an interceptor queues work rather than awaiting it.
51
+ */
52
+ export interface Interceptor {
53
+ /** Called for every host→upstream message. Default: forward unchanged. */
54
+ onHostMessage?(msg: JsonRpcMessage): HostDecision | void;
55
+ /**
56
+ * Called for an upstream response that answers a tracked host request.
57
+ * Returning a message replaces it — used *only* for `tools/list` schema
58
+ * relaxation. A `tools/call` result is always returned verbatim.
59
+ */
60
+ onUpstreamResponse?(pending: PendingRequest, response: JsonRpcResponse): JsonRpcMessage | void;
61
+ /** Called for anything else the upstream sends. Observation only. */
62
+ onUpstreamMessage?(msg: JsonRpcMessage): void;
63
+ /** Called when a tracked request will never be answered (upstream died). */
64
+ onAbandoned?(pending: PendingRequest, reason: string): void;
65
+ }
66
+ export interface RelayOptions {
67
+ upstream: UpstreamClient;
68
+ hostIn: Readable;
69
+ hostOut: Writable;
70
+ interceptor?: Interceptor;
71
+ /** Label for log lines. */
72
+ label?: string;
73
+ /** Called when the host closes its side. */
74
+ onHostDisconnect?: () => void;
75
+ }
76
+ export declare class Relay {
77
+ private readonly pending;
78
+ private readonly opts;
79
+ private detach?;
80
+ private stopped;
81
+ constructor(opts: RelayOptions);
82
+ /** Number of host requests awaiting an upstream answer. */
83
+ get inFlight(): number;
84
+ start(): void;
85
+ stop(): void;
86
+ private onHostMessage;
87
+ /**
88
+ * What the host gets for a request the upstream can no longer answer.
89
+ *
90
+ * A dead upstream mid-`tools/call` is a **tool failure**, not a protocol
91
+ * failure: returning `isError: true` gives the model something it can react to,
92
+ * where a JSON-RPC error just breaks the client's tool plumbing (§10).
93
+ */
94
+ private deadUpstreamAnswer;
95
+ private abandonAll;
96
+ }
97
+ //# sourceMappingURL=relay.d.ts.map
@@ -0,0 +1,166 @@
1
+ /**
2
+ * relay — the bidirectional JSON-RPC relay (Phase 1.2). The heart, and
3
+ * deliberately dumb.
4
+ *
5
+ * THE DESIGN RULE THAT MAKES FULL PASSTHROUGH TRACTABLE:
6
+ *
7
+ * > **Default-forward, intercept by exception.** Do not enumerate the
8
+ * > protocol. Forward any message you have no specific reason to touch.
9
+ *
10
+ * So this file knows about exactly two things: matching responses to the
11
+ * requests that caused them (needed to time a call and to answer in-flight
12
+ * requests when an upstream dies), and handing each message to an
13
+ * {@link Interceptor} that may look at it. Everything else — `prompts/*`,
14
+ * `resources/*`, `completion/complete`, `logging/setLevel`, progress,
15
+ * cancellation, and the whole reverse direction (`sampling/createMessage`,
16
+ * `elicitation/create`, `roots/list`, every `list_changed` notification) —
17
+ * passes through *because it is never mentioned*. Unknown methods work for the
18
+ * same reason. That is D7.
19
+ *
20
+ * IDS. The relay is 1:1, so host ids pass through unchanged; a `Map` of them is
21
+ * kept anyway because it is what makes timing and cancellation possible. Any
22
+ * request the proxy originates itself lives in the disjoint `bir:` id space
23
+ * (see `upstream/client.ts`), so a collision cannot happen.
24
+ */
25
+ import { readFrames, writeFrame } from "../jsonrpc/framing.js";
26
+ import { JsonRpcErrorCode, errorResponse, isRequest, isResponse, paramsObject, } from "../jsonrpc/types.js";
27
+ import { logDetail, logLine, errText } from "../util/log.js";
28
+ export class Relay {
29
+ pending = new Map();
30
+ opts;
31
+ detach;
32
+ stopped = false;
33
+ constructor(opts) {
34
+ this.opts = opts;
35
+ }
36
+ /** Number of host requests awaiting an upstream answer. */
37
+ get inFlight() {
38
+ return this.pending.size;
39
+ }
40
+ start() {
41
+ const { upstream, hostIn, hostOut, interceptor, label } = this.opts;
42
+ upstream.onMessage((msg) => {
43
+ const id = msg.id;
44
+ if (isResponse(msg) && id !== undefined && id !== null && this.pending.has(id)) {
45
+ const p = this.pending.get(id);
46
+ this.pending.delete(id);
47
+ let out = msg;
48
+ try {
49
+ const replaced = interceptor?.onUpstreamResponse?.(p, msg);
50
+ if (replaced)
51
+ out = replaced;
52
+ }
53
+ catch (err) {
54
+ // An interceptor bug must not swallow the upstream's answer.
55
+ logLine("relay.intercept_failed", { label, method: p.method, error: errText(err) });
56
+ }
57
+ writeFrame(hostOut, out, (err) => logLine("relay.write_failed", { label, error: errText(err) }));
58
+ return;
59
+ }
60
+ try {
61
+ interceptor?.onUpstreamMessage?.(msg);
62
+ }
63
+ catch (err) {
64
+ logLine("relay.intercept_failed", { label, error: errText(err) });
65
+ }
66
+ writeFrame(hostOut, msg, (err) => logLine("relay.write_failed", { label, error: errText(err) }));
67
+ });
68
+ upstream.onClose(() => this.abandonAll("upstream closed"));
69
+ this.detach = readFrames(hostIn, {
70
+ onMessage: (msg) => this.onHostMessage(msg),
71
+ onParseError: (detail) => {
72
+ // The host sent something unparseable. Answer -32700 with a null id (we
73
+ // have no id to echo) and keep going — never crash on bad input.
74
+ logLine("relay.host_unparseable", { label, reason: detail.reason, bytes: detail.bytes });
75
+ writeFrame(hostOut, errorResponse(null, JsonRpcErrorCode.ParseError, `parse error: ${detail.reason}`));
76
+ },
77
+ onEnd: () => {
78
+ logDetail("relay.host_disconnected", { label });
79
+ this.opts.onHostDisconnect?.();
80
+ },
81
+ });
82
+ }
83
+ stop() {
84
+ if (this.stopped)
85
+ return;
86
+ this.stopped = true;
87
+ this.detach?.();
88
+ this.abandonAll("relay stopped");
89
+ }
90
+ onHostMessage(msg) {
91
+ const { upstream, hostOut, interceptor, label } = this.opts;
92
+ let decision = { action: "forward" };
93
+ try {
94
+ decision = interceptor?.onHostMessage?.(msg) ?? { action: "forward" };
95
+ }
96
+ catch (err) {
97
+ logLine("relay.intercept_failed", { label, error: errText(err) });
98
+ }
99
+ if (decision.action === "respond") {
100
+ writeFrame(hostOut, decision.message, (err) => logLine("relay.write_failed", { label, error: errText(err) }));
101
+ return;
102
+ }
103
+ const outgoing = decision.message ?? msg;
104
+ const method = outgoing.method;
105
+ // A cancellation retires the request it names; the notification itself still
106
+ // goes upstream, because only the upstream can actually stop the work.
107
+ if (method === "notifications/cancelled") {
108
+ const requestId = paramsObject(outgoing.params).requestId;
109
+ if (typeof requestId === "string" || typeof requestId === "number") {
110
+ const p = this.pending.get(requestId);
111
+ if (p) {
112
+ this.pending.delete(requestId);
113
+ interceptor?.onAbandoned?.(p, "cancelled by host");
114
+ }
115
+ }
116
+ }
117
+ if (isRequest(outgoing)) {
118
+ if (upstream.dead) {
119
+ // §10: the host must get an answer, or it waits forever.
120
+ writeFrame(hostOut, this.deadUpstreamAnswer(outgoing.id, outgoing.method));
121
+ return;
122
+ }
123
+ this.pending.set(outgoing.id, {
124
+ id: outgoing.id,
125
+ method: outgoing.method,
126
+ params: outgoing.params,
127
+ startedAt: Date.now(),
128
+ });
129
+ }
130
+ if (upstream.dead) {
131
+ logDetail("relay.dropped", { label, method, why: "upstream is dead" });
132
+ return;
133
+ }
134
+ upstream.send(outgoing);
135
+ }
136
+ /**
137
+ * What the host gets for a request the upstream can no longer answer.
138
+ *
139
+ * A dead upstream mid-`tools/call` is a **tool failure**, not a protocol
140
+ * failure: returning `isError: true` gives the model something it can react to,
141
+ * where a JSON-RPC error just breaks the client's tool plumbing (§10).
142
+ */
143
+ deadUpstreamAnswer(id, method) {
144
+ const message = `upstream MCP server "${this.opts.label ?? "unknown"}" is not available`;
145
+ if (method === "tools/call") {
146
+ return {
147
+ jsonrpc: "2.0",
148
+ id,
149
+ result: { content: [{ type: "text", text: message }], isError: true },
150
+ };
151
+ }
152
+ return errorResponse(id, JsonRpcErrorCode.UpstreamUnavailable, message);
153
+ }
154
+ abandonAll(reason) {
155
+ if (this.pending.size === 0)
156
+ return;
157
+ const { hostOut, interceptor, label } = this.opts;
158
+ logLine("relay.abandoning", { label, requests: this.pending.size, reason });
159
+ for (const [id, p] of [...this.pending]) {
160
+ this.pending.delete(id);
161
+ interceptor?.onAbandoned?.(p, reason);
162
+ writeFrame(hostOut, this.deadUpstreamAnswer(p.id, p.method), (err) => logLine("relay.write_failed", { label, error: errText(err) }));
163
+ }
164
+ }
165
+ }
166
+ //# sourceMappingURL=relay.js.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * proxy/session — tier negotiation and the proxy's recording lifecycle (§0.1).
3
+ *
4
+ * D5 says the hook binary owns run identity. D8 says support any MCP client.
5
+ * Only Claude Code has hooks, so both cannot hold universally, and ownership is
6
+ * negotiated here at startup:
7
+ *
8
+ * **Tier 1 — Bound.** A control server is discoverable for this cwd. It owns
9
+ * the run; this proxy only reports steps. Built-ins and MCP land in one
10
+ * ordered stream, with the prompt and the final answer.
11
+ *
12
+ * **Tier 2 — Standalone.** No control server inside the discovery window. The
13
+ * proxy owns a run of its own and records MCP calls only — no prompt, no final
14
+ * answer, no built-in steps. That is not a degraded bug; it is the honest
15
+ * ceiling of what an MCP proxy can observe, and the run says so in
16
+ * `metadata.tier` so nothing downstream mistakes it for a complete trace.
17
+ *
18
+ * THE DISCOVERY WINDOW EXISTS BECAUSE OF A RACE: the host may spawn MCP servers
19
+ * before `SessionStart` fires, and the ordering between the two is not
20
+ * guaranteed. So steps are **buffered in memory** while the search runs, and
21
+ * flushed into whichever owner wins. Nothing is lost to the race, and nothing
22
+ * blocks: relaying never waits on this.
23
+ */
24
+ import type { UpstreamClient } from "../upstream/client.js";
25
+ import type { ProxyStepReport } from "../control/correlation.js";
26
+ import { type Recorder } from "../record/recorder.js";
27
+ /** How long a proxy waits for a control server before falling to Tier 2. */
28
+ export declare const DISCOVERY_WINDOW_MS = 5000;
29
+ export type Tier = "bound" | "standalone" | "pending";
30
+ export interface ProxySessionOptions {
31
+ serverName: string;
32
+ cwd: string;
33
+ /** Skip discovery and go straight to Tier 2. */
34
+ standalone?: boolean;
35
+ /** Suppress schema relaxation; the control server joins on fingerprints instead. */
36
+ noCorrelation?: boolean;
37
+ discoveryWindowMs?: number;
38
+ /** Injected by tests. */
39
+ recorderFactory?: () => Promise<Recorder | undefined>;
40
+ /**
41
+ * The live upstream, so the control server can have this proxy run a
42
+ * calculated scenario's steps on the connection it already holds
43
+ * (docs/calculatedReplay.md §16.2). Absent → no work loop, and every step that
44
+ * would have come here falls back to a recorded output.
45
+ */
46
+ upstream?: UpstreamClient;
47
+ }
48
+ export declare class ProxySession {
49
+ readonly serverName: string;
50
+ private readonly opts;
51
+ private tierValue;
52
+ private control?;
53
+ /** Tier 2 only. */
54
+ private recorder?;
55
+ private runId?;
56
+ private ordering;
57
+ private queue;
58
+ /** Steps that arrived before the tier was known. */
59
+ private buffer;
60
+ private lossyValue;
61
+ private negotiation?;
62
+ private startedAt;
63
+ /** The replay work loop, when one was started. */
64
+ private workLoop?;
65
+ private closing;
66
+ constructor(opts: ProxySessionOptions);
67
+ get tier(): Tier;
68
+ get lossy(): boolean;
69
+ /**
70
+ * Whether the schema-relaxation half of correlation should be applied to
71
+ * `tools/list` (Phase 6). Only ever true in Tier 1 with correlation enabled:
72
+ * relaxation is visible to the model on every call, so it is never paid for
73
+ * unless it is actually buying a merge.
74
+ */
75
+ get correlationActive(): boolean;
76
+ /** Begin tier negotiation. Returns immediately; relaying never waits on it. */
77
+ start(): void;
78
+ private negotiate;
79
+ /**
80
+ * Take calculated-scenario steps from the control server and run them on the
81
+ * upstream this proxy already owns (docs/calculatedReplay.md §16.2).
82
+ *
83
+ * The whole point of the design lives in three lines below: `upstream.request`
84
+ * allocates an id in the reserved `bir:` space and the transport consumes its
85
+ * own response in `accept()` before the relay's handlers ever see it — so this
86
+ * costs **zero tokens**, the model is not involved, and `RecordingInterceptor`
87
+ * cannot double-record it. That interface comment says `request()` is "never
88
+ * used by the relay"; it was built for `bir doctor` and tests, and this is its
89
+ * second, load-bearing user.
90
+ */
91
+ private startWorkLoop;
92
+ /**
93
+ * Take ownership of a run ourselves.
94
+ *
95
+ * ORDER MATTERS HERE. The recorder is built (which may mean an auth round
96
+ * trip) *before* the tier is published, because `dispatch` routes on the tier:
97
+ * publishing "standalone" first opens a window in which a step is neither
98
+ * buffered nor recordable, and every `tools/call` in that window is lost.
99
+ * `dispatch` re-buffers defensively too — belt and braces on a race whose
100
+ * failure mode is silent data loss.
101
+ */
102
+ private becomeStandalone;
103
+ private buildRecorder;
104
+ /**
105
+ * Record one completed `tools/call`. Fire-and-forget by contract: it is called
106
+ * *after* the result is already on its way to the host, and it never awaits.
107
+ */
108
+ reportStep(step: ProxyStepReport): void;
109
+ /** Hold a step until an owner exists. Bounded, and honest when it overflows. */
110
+ private bufferStep;
111
+ private flushBuffer;
112
+ private dispatch;
113
+ /** Close the Tier 2 run, if we own one, and drain the queue. */
114
+ close(): Promise<void>;
115
+ }
116
+ //# sourceMappingURL=session.d.ts.map