@basein/runner 0.2.11 → 0.3.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 (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -4,7 +4,7 @@
4
4
  * | Method | Direction | Action |
5
5
  * |--------------|----------------|-----------------------------------------------|
6
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.|
7
+ * | `tools/list` | host → upstream| Relay; declare the call-id key when Tier 1 may inject it.|
8
8
  * | `tools/call` | host → upstream| Strip the call id, time it, relay, report it. |
9
9
  *
10
10
  * `initialize` DESERVES ITS OWN PARAGRAPH, because getting it wrong is what makes
@@ -19,14 +19,85 @@
19
19
  * TWO RULES THAT ARE EASY TO GET WRONG, and are enforced here:
20
20
  * - **Never rewrite a `tools/call` result.** The upstream object is returned
21
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.
22
+ * - **Never let recording block the response.** Only bookkeeping happens on
23
+ * the response's own tick; the copy is redacted, clipped and queued on a
24
+ * later one (`setImmediate`), after the relay has written the answer. A
25
+ * 20 MB result used to be redacted and deep-copied twice *in front of* its
26
+ * own delivery.
24
27
  */
25
- import { BIR_CALL_ID, extractCallId, qualifyToolName, relaxToolsListResult, } from "../control/correlation.js";
28
+ import { BIR_CALL_ID, extractCallId, fingerprint, qualifyToolName, } from "../control/correlation.js";
26
29
  import { redact } from "../record/redact.js";
27
- import { truncateStrings, DEFAULT_MAX_STRING_BYTES } from "../record/truncate.js";
30
+ import { truncateStrings, serializeCapped, DEFAULT_MAX_STRING_BYTES, DEFAULT_MAX_PAYLOAD_BYTES, } from "../record/truncate.js";
28
31
  import { paramsObject } from "../jsonrpc/types.js";
29
- import { logDetail } from "../util/log.js";
32
+ import { logDetail, logLine, errText } from "../util/log.js";
33
+ /**
34
+ * Declare the call-id key on one tool's input schema so a host that validates
35
+ * `updatedInput` against it accepts an injected id.
36
+ *
37
+ * Deliberately minimal — the model reads these schemas on every request: no
38
+ * text naming the runner, and `additionalProperties` stays whatever the upstream
39
+ * declared (declaring the key is already enough; forcing `true` advertised a
40
+ * strict server as accepting anything, inviting arguments it then rejects).
41
+ */
42
+ export function tolerateCallId(schema) {
43
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
44
+ return schema;
45
+ const base = schema;
46
+ const props = base.properties;
47
+ if (props !== undefined && (!props || typeof props !== "object" || Array.isArray(props)))
48
+ return schema;
49
+ return {
50
+ ...base,
51
+ properties: {
52
+ ...(props ?? {}),
53
+ [BIR_CALL_ID]: { type: "string", description: "Reserved. Leave unset." },
54
+ },
55
+ };
56
+ }
57
+ /** Apply {@link tolerateCallId} to every tool of a `tools/list` result. */
58
+ export function tolerateCallIdInToolsList(result) {
59
+ if (!result || typeof result !== "object" || Array.isArray(result))
60
+ return result;
61
+ const r = result;
62
+ if (!Array.isArray(r.tools))
63
+ return result;
64
+ return {
65
+ ...r,
66
+ tools: r.tools.map((tool) => {
67
+ if (!tool || typeof tool !== "object" || Array.isArray(tool))
68
+ return tool;
69
+ const t = tool;
70
+ return { ...t, inputSchema: tolerateCallId(t.inputSchema) };
71
+ }),
72
+ };
73
+ }
74
+ /**
75
+ * Keep a recorded payload inside the per-payload cap *before* it is queued.
76
+ * An oversized value becomes exactly the marker `serializeCapped` would have
77
+ * written for it at record time, so what is finally recorded is unchanged —
78
+ * but the queue, and the loopback POST to the control server (which serves
79
+ * every hook on the same event loop), no longer carry megabytes to get there.
80
+ */
81
+ function capForReport(value) {
82
+ let json;
83
+ try {
84
+ json = JSON.stringify(value);
85
+ }
86
+ catch {
87
+ return { truncated: true, reason: "unserializable" };
88
+ }
89
+ if (json === undefined)
90
+ return value;
91
+ if (json.length <= DEFAULT_MAX_PAYLOAD_BYTES / 3 || Buffer.byteLength(json, "utf8") <= DEFAULT_MAX_PAYLOAD_BYTES) {
92
+ return value;
93
+ }
94
+ try {
95
+ return JSON.parse(serializeCapped(value));
96
+ }
97
+ catch {
98
+ return { truncated: true, originalBytes: Buffer.byteLength(json, "utf8") };
99
+ }
100
+ }
30
101
  export class RecordingInterceptor {
31
102
  serverName;
32
103
  session;
@@ -46,12 +117,19 @@ export class RecordingInterceptor {
46
117
  const params = paramsObject(msg.params);
47
118
  const toolName = String(params.name ?? "");
48
119
  const { callId, cleaned } = extractCallId(params.arguments);
49
- this.calls.set(id, { callId, toolName, args: cleaned, startedAt: Date.now() });
50
- if (callId === undefined)
120
+ // Nothing to record when the session records nothing (the project never
121
+ // opted in, or the runner is off): no call is tracked, so nothing is held.
122
+ if (this.session.recording) {
123
+ this.calls.set(id, { callId, toolName, args: cleaned, startedAt: Date.now() });
124
+ }
125
+ // `extractCallId` hands back the very same object when the key is absent.
126
+ if (cleaned === params.arguments)
51
127
  return { action: "forward" };
52
128
  // 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 });
129
+ // correlation id is stripped here — in every tier, whatever its value —
130
+ // rather than being relayed and ignored.
131
+ if (callId !== undefined)
132
+ logDetail("call.correlated", { server: this.serverName, tool: toolName, callId });
55
133
  return {
56
134
  action: "forward",
57
135
  message: {
@@ -62,12 +140,13 @@ export class RecordingInterceptor {
62
140
  }
63
141
  onUpstreamResponse(pending, response) {
64
142
  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)
143
+ // The only rewrite in the whole proxy, and only when a control server may
144
+ // inject call ids into this server's calls (Phase 6). Decided by the
145
+ // session so it does not depend on whether registration happened to beat
146
+ // this answer.
147
+ if (!this.session.declaresCallId || response.error)
69
148
  return;
70
- return { ...response, result: relaxToolsListResult(response.result) };
149
+ return { ...response, result: tolerateCallIdInToolsList(response.result) };
71
150
  }
72
151
  if (pending.method !== "tools/call")
73
152
  return;
@@ -75,9 +154,9 @@ export class RecordingInterceptor {
75
154
  this.calls.delete(pending.id);
76
155
  if (!call)
77
156
  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);
157
+ // Report on a later tick: the relay writes the answer as soon as this
158
+ // returns, and the copy's redaction and clipping cost nothing it waits on.
159
+ setImmediate(() => this.report(call, response));
81
160
  return; // the upstream's result, verbatim
82
161
  }
83
162
  onAbandoned(pending, reason) {
@@ -85,61 +164,111 @@ export class RecordingInterceptor {
85
164
  if (!call)
86
165
  return;
87
166
  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,
167
+ const durationMs = Date.now() - call.startedAt;
168
+ setImmediate(() => {
169
+ try {
170
+ const step = {
171
+ callId: call.callId,
172
+ serverName: this.serverName,
173
+ toolName: call.toolName,
174
+ qualifiedName: qualifyToolName(this.serverName, call.toolName),
175
+ args: capForReport(this.scrub(call.args)),
176
+ argsFingerprint: fingerprint(this.serverName, call.toolName, call.args),
177
+ isError: true,
178
+ errorMessage: reason,
179
+ startedAt: call.startedAt,
180
+ durationMs,
181
+ };
182
+ this.session.reportStep(step);
183
+ }
184
+ catch (err) {
185
+ logLine("proxy.report_failed", { server: this.serverName, error: errText(err) });
186
+ }
98
187
  });
99
188
  }
100
189
  /** Build the step report from a completed call. Redaction happens on the copy. */
101
190
  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}`;
191
+ try {
192
+ const durationMs = Date.now() - call.startedAt;
193
+ const result = response.result;
194
+ let isError = false;
195
+ let errorMessage;
196
+ if (response.error) {
197
+ // A protocol-level failure: the call never produced a tool result.
198
+ isError = true;
199
+ errorMessage = `${response.error.code}: ${response.error.message}`;
200
+ }
201
+ else if (result && typeof result === "object" && result.isError === true) {
202
+ isError = true;
203
+ }
204
+ const step = {
205
+ callId: call.callId,
206
+ serverName: this.serverName,
207
+ toolName: call.toolName,
208
+ qualifiedName: qualifyToolName(this.serverName, call.toolName),
209
+ args: capForReport(this.scrub(call.args)),
210
+ argsFingerprint: fingerprint(this.serverName, call.toolName, call.args),
211
+ result: capForReport(response.error ? this.scrub(response.error) : this.scrub(response.result)),
212
+ isError,
213
+ errorMessage,
214
+ startedAt: call.startedAt,
215
+ durationMs,
216
+ };
217
+ this.session.reportStep(step);
218
+ logDetail("call.done", {
219
+ server: this.serverName,
220
+ tool: call.toolName,
221
+ ms: durationMs,
222
+ isError,
223
+ });
110
224
  }
111
- else if (result && typeof result === "object" && result.isError === true) {
112
- isError = true;
225
+ catch (err) {
226
+ // Recording is best-effort; nothing here may become an uncaught exception.
227
+ logLine("proxy.report_failed", { server: this.serverName, error: errText(err) });
113
228
  }
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
229
  }
134
230
  /**
135
- * Redact, then clip long strings, on the recording copy only.
231
+ * Clip, redact, clip — on the recording copy only.
232
+ *
233
+ * Clipping first keeps redaction's regexes off megabytes that are about to be
234
+ * thrown away. It clips to *twice* the cap first, so a secret straddling the
235
+ * final cut is still whole — and still recognisable — when redaction runs;
236
+ * clipping straight to the cap could leave a token prefix no pattern matches.
136
237
  *
137
238
  * Clipping happens here rather than only at serialization time because a
138
239
  * browser MCP's base64 screenshot would otherwise sit in the step queue in
139
240
  * full, possibly for the length of the run.
140
241
  */
141
242
  scrub(value) {
142
- return truncateStrings(redact(value), DEFAULT_MAX_STRING_BYTES);
243
+ return truncateStrings(redact(truncateStrings(value, DEFAULT_MAX_STRING_BYTES * 2)), DEFAULT_MAX_STRING_BYTES);
244
+ }
245
+ }
246
+ /**
247
+ * The interceptor of a proxy that records nothing — started while the runner
248
+ * was off (`bir off`, `BIR_OFF=1`) with a remote upstream, where there is no
249
+ * process to hand stdio to and the relay must stay in the path.
250
+ *
251
+ * It does one thing: strip the call id. The runner can be switched back on
252
+ * mid-session, and a control server may then inject an id into a call routed
253
+ * here (a replay-pinned step, `bypassPermissions`, an explicit opt-in). A
254
+ * strict upstream would reject the unknown key, and the user would see a
255
+ * tool error the runner caused. No tracking, no reporting, no schema change.
256
+ */
257
+ export class StripCallIdInterceptor {
258
+ onHostMessage(msg) {
259
+ if (msg.method !== "tools/call")
260
+ return { action: "forward" };
261
+ const params = paramsObject(msg.params);
262
+ const { cleaned } = extractCallId(params.arguments);
263
+ if (cleaned === params.arguments)
264
+ return { action: "forward" };
265
+ return {
266
+ action: "forward",
267
+ message: {
268
+ ...msg,
269
+ params: { ...params, arguments: cleaned },
270
+ },
271
+ };
143
272
  }
144
273
  }
145
274
  /** Re-exported so callers wiring a proxy need one import for the correlation key. */
@@ -92,6 +92,22 @@ export declare class Relay {
92
92
  * where a JSON-RPC error just breaks the client's tool plumbing (§10).
93
93
  */
94
94
  private deadUpstreamAnswer;
95
+ /** An `isError` tool result for `tools/call`, a JSON-RPC error for anything else. */
96
+ private failureAnswer;
97
+ /**
98
+ * The upstream answered, but the answer could not be delivered (a line over
99
+ * the framing cap). Answer the request it was for, so the host's call ends
100
+ * with an error the model can react to instead of spinning until Esc.
101
+ *
102
+ * The id comes from the line's ends and may be missing. Without it, the
103
+ * request is identifiable only when the line was recognisably an answer
104
+ * (`info.response`) and exactly one host call — a `tools/call`, the request
105
+ * whose answers are the big ones — is in flight. Anything looser is a guess:
106
+ * an oversized notification used to be "matched" to the one call in flight,
107
+ * which got a false error and then its real answer too. With several in
108
+ * flight nothing is answered, and each keeps waiting for its own reply.
109
+ */
110
+ private onUndeliverable;
95
111
  private abandonAll;
96
112
  }
97
113
  //# sourceMappingURL=relay.d.ts.map
@@ -66,6 +66,9 @@ export class Relay {
66
66
  writeFrame(hostOut, msg, (err) => logLine("relay.write_failed", { label, error: errText(err) }));
67
67
  });
68
68
  upstream.onClose(() => this.abandonAll("upstream closed"));
69
+ // `--restart`: nothing in flight will be answered by the new process.
70
+ upstream.onRestart?.(() => this.abandonAll("upstream restarted"));
71
+ upstream.onUndeliverable?.((info) => this.onUndeliverable(info));
69
72
  this.detach = readFrames(hostIn, {
70
73
  onMessage: (msg) => this.onHostMessage(msg),
71
74
  onParseError: (detail) => {
@@ -110,14 +113,32 @@ export class Relay {
110
113
  const p = this.pending.get(requestId);
111
114
  if (p) {
112
115
  this.pending.delete(requestId);
113
- interceptor?.onAbandoned?.(p, "cancelled by host");
116
+ try {
117
+ interceptor?.onAbandoned?.(p, "cancelled by host");
118
+ }
119
+ catch (err) {
120
+ logLine("relay.intercept_failed", { label, error: errText(err) });
121
+ }
114
122
  }
115
123
  }
116
124
  }
117
125
  if (isRequest(outgoing)) {
118
126
  if (upstream.dead) {
119
- // §10: the host must get an answer, or it waits forever.
120
- writeFrame(hostOut, this.deadUpstreamAnswer(outgoing.id, outgoing.method));
127
+ // §10: the host must get an answer, or it waits forever. The
128
+ // interceptor has already opened a call for it; close that too, or it
129
+ // is held (arguments and all) for the life of the process.
130
+ try {
131
+ interceptor?.onAbandoned?.({
132
+ id: outgoing.id,
133
+ method: outgoing.method,
134
+ params: outgoing.params,
135
+ startedAt: Date.now(),
136
+ }, "upstream unavailable");
137
+ }
138
+ catch (err) {
139
+ logLine("relay.intercept_failed", { label, error: errText(err) });
140
+ }
141
+ writeFrame(hostOut, this.deadUpstreamAnswer(outgoing.id, outgoing.method), (err) => logLine("relay.write_failed", { label, error: errText(err) }));
121
142
  return;
122
143
  }
123
144
  this.pending.set(outgoing.id, {
@@ -141,7 +162,12 @@ export class Relay {
141
162
  * where a JSON-RPC error just breaks the client's tool plumbing (§10).
142
163
  */
143
164
  deadUpstreamAnswer(id, method) {
144
- const message = `upstream MCP server "${this.opts.label ?? "unknown"}" is not available`;
165
+ // Phrased as the host would phrase it unwrapped — about the server, not
166
+ // about a proxy the user never needs to know is there.
167
+ return this.failureAnswer(id, method, `MCP server "${this.opts.label ?? "unknown"}" is not available`);
168
+ }
169
+ /** An `isError` tool result for `tools/call`, a JSON-RPC error for anything else. */
170
+ failureAnswer(id, method, message) {
145
171
  if (method === "tools/call") {
146
172
  return {
147
173
  jsonrpc: "2.0",
@@ -151,6 +177,44 @@ export class Relay {
151
177
  }
152
178
  return errorResponse(id, JsonRpcErrorCode.UpstreamUnavailable, message);
153
179
  }
180
+ /**
181
+ * The upstream answered, but the answer could not be delivered (a line over
182
+ * the framing cap). Answer the request it was for, so the host's call ends
183
+ * with an error the model can react to instead of spinning until Esc.
184
+ *
185
+ * The id comes from the line's ends and may be missing. Without it, the
186
+ * request is identifiable only when the line was recognisably an answer
187
+ * (`info.response`) and exactly one host call — a `tools/call`, the request
188
+ * whose answers are the big ones — is in flight. Anything looser is a guess:
189
+ * an oversized notification used to be "matched" to the one call in flight,
190
+ * which got a false error and then its real answer too. With several in
191
+ * flight nothing is answered, and each keeps waiting for its own reply.
192
+ */
193
+ onUndeliverable(info) {
194
+ const { hostOut, interceptor, label } = this.opts;
195
+ let target;
196
+ if (info.id !== undefined) {
197
+ target = this.pending.get(info.id);
198
+ }
199
+ else if (info.response && this.pending.size === 1) {
200
+ const only = [...this.pending.values()][0];
201
+ if (only.method === "tools/call")
202
+ target = only;
203
+ }
204
+ if (!target) {
205
+ logLine("relay.undeliverable_unmatched", { label, bytes: info.bytes, id: info.id, inFlight: this.pending.size });
206
+ return;
207
+ }
208
+ this.pending.delete(target.id);
209
+ logLine("relay.undeliverable", { label, method: target.method, id: target.id, bytes: info.bytes });
210
+ try {
211
+ interceptor?.onAbandoned?.(target, info.reason);
212
+ }
213
+ catch (err) {
214
+ logLine("relay.intercept_failed", { label, error: errText(err) });
215
+ }
216
+ writeFrame(hostOut, this.failureAnswer(target.id, target.method, capitalise(info.reason)), (err) => logLine("relay.write_failed", { label, error: errText(err) }));
217
+ }
154
218
  abandonAll(reason) {
155
219
  if (this.pending.size === 0)
156
220
  return;
@@ -158,9 +222,17 @@ export class Relay {
158
222
  logLine("relay.abandoning", { label, requests: this.pending.size, reason });
159
223
  for (const [id, p] of [...this.pending]) {
160
224
  this.pending.delete(id);
161
- interceptor?.onAbandoned?.(p, reason);
225
+ try {
226
+ interceptor?.onAbandoned?.(p, reason);
227
+ }
228
+ catch (err) {
229
+ logLine("relay.intercept_failed", { label, error: errText(err) });
230
+ }
162
231
  writeFrame(hostOut, this.deadUpstreamAnswer(p.id, p.method), (err) => logLine("relay.write_failed", { label, error: errText(err) }));
163
232
  }
164
233
  }
165
234
  }
235
+ function capitalise(text) {
236
+ return text ? text[0].toUpperCase() + text.slice(1) : text;
237
+ }
166
238
  //# sourceMappingURL=relay.js.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * revive — a bound proxy brings back the recorder that died under it.
3
+ *
4
+ * Every Claude Code hook but SessionStart is an HTTP post to the project's
5
+ * recorder. When that process dies mid-session (killed by an antivirus or the
6
+ * OOM killer, or a restart from another window that did not come back), every
7
+ * later prompt and tool call shows the user a hook error, and only a new
8
+ * session, `/clear` or a compaction — the next SessionStart — started it again.
9
+ * A bound proxy is the one long-lived process of the session that notices, so
10
+ * it asks for a new one: it launches `bir-hooks revive --cwd <project>`,
11
+ * detached, which starts the recorder under the same start lock the
12
+ * SessionStart hook uses, so any number of proxies (and bir-scenario) asking
13
+ * at once start one recorder.
14
+ *
15
+ * ONLY WHEN IT IS DEAD, NOT SLOW. All of these, checked in this order, cheapest
16
+ * first:
17
+ *
18
+ * - the project's discovery file is still there and names a pid that is no
19
+ * longer running. A recorder that stopped cleanly — `bir down`, a restart —
20
+ * removed its file, so a recorder the user stopped on purpose is never
21
+ * brought back from here; a pid that is alive (busy, or recycled by an
22
+ * unrelated process) is never second-guessed either;
23
+ * - the recorder's address refuses a TCP connection. Anything that accepts
24
+ * one — a recorder coming up, whatever else took the port — or does not
25
+ * answer within a couple of seconds is left alone;
26
+ * - `bir install` set this directory up (the sidecar has its project record).
27
+ *
28
+ * NEVER IN ANYONE'S WAY. {@link RecorderReviver.nudge} is synchronous and
29
+ * constant-time: it only arms a timer. The checks run on that timer, after a
30
+ * random 0-5 s (so the proxies of one session do not all launch in the same
31
+ * instant), and the launch is a detached, unref'd process with no stdio: the
32
+ * proxy neither waits for it nor learns how it went. At most one launch per
33
+ * {@link REVIVE_MIN_INTERVAL_MS} per process, backing off to
34
+ * {@link REVIVE_MAX_INTERVAL_MS} while launches keep not taking (a recorder
35
+ * that dies on start). Each launch is one audit line.
36
+ *
37
+ * Not for an explicit control channel (`BIR_CONTROL_URL` + `BIR_CONTROL_TOKEN`,
38
+ * the Agent SDK's): that control server is its owner's to run.
39
+ */
40
+ /** At most one launch per process in this long. */
41
+ export declare const REVIVE_MIN_INTERVAL_MS = 30000;
42
+ /** ...doubling per launch that did not take, up to this. */
43
+ export declare const REVIVE_MAX_INTERVAL_MS: number;
44
+ /** A launch is delayed by a random 0 to this, so sibling proxies spread out. */
45
+ export declare const REVIVE_MAX_JITTER_MS = 5000;
46
+ /** The recorder a discovery file still names although its process is gone. */
47
+ export interface DeadRecorder {
48
+ pid: number;
49
+ url: string;
50
+ }
51
+ /**
52
+ * The project's discovery file as it is on disk, when the pid it names is no
53
+ * longer running. `readDiscovery` hides exactly these files — correctly, for
54
+ * binding — and they are the evidence here: a recorder that stopped cleanly
55
+ * removed its own.
56
+ */
57
+ export declare function deadRecorder(cwd: string): Promise<DeadRecorder | undefined>;
58
+ /** Whether the install sidecar has a project record for `cwd`. Unreadable → no. */
59
+ export declare function projectRecordedHere(cwd: string): Promise<boolean>;
60
+ /**
61
+ * True only when a loopback `url`'s port actively refuses a TCP connection.
62
+ * Accepted, timed out, any other error, or not a loopback address: false.
63
+ */
64
+ export declare function refusesConnections(url: string, timeoutMs?: number): Promise<boolean>;
65
+ /**
66
+ * Launch `bir-hooks revive --cwd <cwd>` detached, and forget it. Never throws.
67
+ *
68
+ * On Windows the revive always starts the recorder through PowerShell's
69
+ * `Start-Process`, whatever `BIR_WINDOWS_LAUNCH` says: a process started from
70
+ * here inherits this proxy's handles — its stdout is the host's JSON-RPC pipe
71
+ * — and a recorder launched directly from it would hold that pipe for days
72
+ * (see HOW IT IS LAUNCHED in control/daemon.ts). The revive itself lives for
73
+ * seconds.
74
+ */
75
+ export declare function launchRevive(cwd: string, opts?: {
76
+ script?: string;
77
+ env?: NodeJS.ProcessEnv;
78
+ }): Promise<boolean>;
79
+ export interface ReviverOptions {
80
+ /** The project directory: the proxy's cwd. */
81
+ cwd: string;
82
+ /** The wrapped server, for the audit line. */
83
+ server: string;
84
+ /**
85
+ * The environment the revive (and so the recorder it starts) is given:
86
+ * the one this process was started with. Default `process.env`.
87
+ */
88
+ env?: NodeJS.ProcessEnv;
89
+ /** Launch the revive. Default {@link launchRevive}; tests inject one. */
90
+ launch?: (cwd: string) => Promise<boolean> | boolean;
91
+ minIntervalMs?: number;
92
+ maxIntervalMs?: number;
93
+ maxJitterMs?: number;
94
+ checkIntervalMs?: number;
95
+ }
96
+ /** One per bound proxy: decides whether, and when, to launch a revive. */
97
+ export declare class RecorderReviver {
98
+ private readonly opts;
99
+ private readonly minIntervalMs;
100
+ private readonly maxIntervalMs;
101
+ private readonly maxJitterMs;
102
+ private readonly checkIntervalMs;
103
+ private interval;
104
+ private timer?;
105
+ private pending;
106
+ private stopped;
107
+ private nextLaunchAt;
108
+ private nextCheckAt;
109
+ private lastLaunchAt;
110
+ /** How many revives this process launched. */
111
+ launches: number;
112
+ constructor(opts: ReviverOptions);
113
+ /**
114
+ * The recorder did not answer. Arms the check, unless one is pending or
115
+ * the rate limit says not yet. Synchronous, constant-time, never throws.
116
+ */
117
+ nudge(why: string): void;
118
+ /** No more launches: the proxy is closing. */
119
+ stop(): void;
120
+ private attempt;
121
+ }
122
+ //# sourceMappingURL=revive.d.ts.map