@basein/runner 0.2.12 → 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 (142) hide show
  1. package/README.md +540 -369
  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 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  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 +2786 -302
  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/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -65,6 +65,7 @@ export interface DeriveOptions extends DeriveContext {
65
65
  /** Used when there is no `apiKey`. Absent too → recorded samples. */
66
66
  service?: DeriveService;
67
67
  model?: string;
68
+ /** Ends the request. Absent → the service timeout (`BIR_SERVICE_TIMEOUT_MS`). */
68
69
  signal?: AbortSignal;
69
70
  /** Injected by tests. Defaults to global `fetch`. */
70
71
  fetchImpl?: typeof fetch;
@@ -30,10 +30,50 @@
30
30
  * parameters from the same prompt — otherwise a scenario would behave one way
31
31
  * under RRepeat and another under BaseInstRunner.
32
32
  */
33
+ import { redactText } from "../record/redact.js";
34
+ import { isUnhealthyStatus, serviceHealth, timeoutSignal } from "../util/service-health.js";
33
35
  import { calculateCostUsd } from "./pricing.js";
34
36
  export const DEFAULT_DERIVE_MODEL = "claude-haiku-4-5-20251001";
35
37
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
36
38
  const ANTHROPIC_VERSION = "2023-06-01";
39
+ /**
40
+ * The turn's context as it may leave this machine.
41
+ *
42
+ * Tool results are whatever the agent read — a config file, an `.env`, a page
43
+ * of logs — and they travel to the service (or to the model API) inside the
44
+ * extraction prompt. The transcript reader redacts them already; this is the
45
+ * same pass again at the point they leave, so no future caller of `derive` can
46
+ * forget it. `redactText` is idempotent.
47
+ */
48
+ function outgoingContext(ctx) {
49
+ return {
50
+ liveCall: ctx.liveCall
51
+ ? { toolName: ctx.liveCall.toolName, toolInput: redactText(ctx.liveCall.toolInput) }
52
+ : undefined,
53
+ recentResults: ctx.recentResults?.map((r) => redactText(r)),
54
+ };
55
+ }
56
+ /**
57
+ * The prompt as it may leave this machine — the user's words, or on an intent
58
+ * arm the model's own reasoning, which often quotes a file it just read. The
59
+ * recorder redacts the same text before it sends it anywhere; derivation used
60
+ * to be the one copy that went out whole, to the service or to the model API.
61
+ * A redacted secret is never a parameter value a scenario could use, and
62
+ * `redactText` leaves ordinary prose (`region=EMEA`) as it is.
63
+ */
64
+ function outgoingPrompt(prompt) {
65
+ return redactText(prompt);
66
+ }
67
+ /**
68
+ * Report a service answer to the breaker with the time its request started,
69
+ * so an answer to a request sent *before* a hot path gave up on the service
70
+ * does not end the cool-down that give-up began (util/service-health.ts).
71
+ * A breaker that takes no start time reads the call as a plain `ok()`.
72
+ */
73
+ function serviceOk(startedAt) {
74
+ const ok = serviceHealth.ok.bind(serviceHealth);
75
+ ok(startedAt);
76
+ }
37
77
  /** Every parameter at its recorded sample value. The zero-cost fallback. */
38
78
  function sampleValues(schema) {
39
79
  const out = {};
@@ -102,19 +142,19 @@ export function buildExtractionPrompt(prompt, intent, schema, ctx = {}) {
102
142
  .map((r) => `\`\`\`\n${r}\n\`\`\``)
103
143
  .join("\n")}\n`
104
144
  : "";
105
- return `You are extracting parameter values from a user prompt to execute a pre-defined scenario.
106
-
107
- Scenario intent: ${intent}
108
- ${live}
109
- Parameters needed (with sample values from past runs):
110
- ${descriptions}
111
-
112
- User prompt:
113
- ${prompt}
114
- ${recent}
115
- Extract the parameter values. A setting you cannot find takes its sample value. A target you cannot find in the prompt, the reasoning, the call or the results is null — never invent it and never copy the sample.
116
-
117
- Respond ONLY with a valid JSON object mapping each parameter name to its value. No markdown fences, no prose.
145
+ return `You are extracting parameter values from a user prompt to execute a pre-defined scenario.
146
+
147
+ Scenario intent: ${intent}
148
+ ${live}
149
+ Parameters needed (with sample values from past runs):
150
+ ${descriptions}
151
+
152
+ User prompt:
153
+ ${prompt}
154
+ ${recent}
155
+ Extract the parameter values. A setting you cannot find takes its sample value. A target you cannot find in the prompt, the reasoning, the call or the results is null — never invent it and never copy the sample.
156
+
157
+ Respond ONLY with a valid JSON object mapping each parameter name to its value. No markdown fences, no prose.
118
158
  Example: { "directory": "src/utils", "pattern": "*.ts" }`;
119
159
  }
120
160
  /**
@@ -161,14 +201,11 @@ export async function deriveParameters(opts) {
161
201
  messages: [
162
202
  {
163
203
  role: "user",
164
- content: buildExtractionPrompt(opts.prompt, opts.intent, schema, {
165
- liveCall: opts.liveCall,
166
- recentResults: opts.recentResults,
167
- }),
204
+ content: buildExtractionPrompt(outgoingPrompt(opts.prompt), opts.intent, schema, outgoingContext({ liveCall: opts.liveCall, recentResults: opts.recentResults })),
168
205
  },
169
206
  ],
170
207
  }),
171
- signal: opts.signal,
208
+ signal: opts.signal ?? timeoutSignal(),
172
209
  });
173
210
  if (!res.ok) {
174
211
  const detail = await res.text().catch(() => "");
@@ -262,6 +299,12 @@ async function deriveViaService(opts, service, schema, keys) {
262
299
  const doFetch = opts.fetchImpl ?? fetch;
263
300
  const url = `${service.baseUrl.replace(/\/+$/, "")}/scenarios/${service.scenarioId}/derive`;
264
301
  let body;
302
+ // Every answer and every failure is reported to the process-wide breaker
303
+ // (util/service-health.ts): a derivation that timed out is the same news
304
+ // about the service as a step upload that did, and the next hook that would
305
+ // wait on it should know.
306
+ const context = outgoingContext({ liveCall: opts.liveCall, recentResults: opts.recentResults });
307
+ const startedAt = Date.now();
265
308
  try {
266
309
  const res = await doFetch(url, {
267
310
  method: "POST",
@@ -270,17 +313,31 @@ async function deriveViaService(opts, service, schema, keys) {
270
313
  authorization: `Bearer ${service.token}`,
271
314
  },
272
315
  body: JSON.stringify({
273
- prompt: opts.prompt,
274
- liveCall: opts.liveCall,
275
- recentResults: opts.recentResults,
316
+ prompt: outgoingPrompt(opts.prompt),
317
+ liveCall: context.liveCall,
318
+ recentResults: context.recentResults,
276
319
  }),
277
- signal: opts.signal,
320
+ signal: opts.signal ?? timeoutSignal(),
278
321
  });
279
- if (!res.ok)
322
+ if (!res.ok) {
323
+ if (isUnhealthyStatus(res.status))
324
+ serviceHealth.fail(`derive: HTTP ${res.status}`);
325
+ else
326
+ serviceOk(startedAt);
280
327
  return samples(`service HTTP ${res.status}`);
281
- body = (await res.json());
328
+ }
329
+ try {
330
+ body = (await res.json());
331
+ }
332
+ catch (err) {
333
+ // A 200 that is not the API's JSON — a captive portal, a proxy's page.
334
+ serviceHealth.fail("derive: the answer was not JSON");
335
+ return samples(err instanceof Error ? err.message : String(err));
336
+ }
337
+ serviceOk(startedAt);
282
338
  }
283
339
  catch (err) {
340
+ serviceHealth.fail(`derive: ${err instanceof Error ? err.message : String(err)}`);
284
341
  return samples(err instanceof Error ? err.message : String(err));
285
342
  }
286
343
  if (!body || typeof body !== "object")
@@ -16,8 +16,8 @@
16
16
  * already dominated by the upstream's own round trip.
17
17
  *
18
18
  * A proxy that never polls (an old build, or one started without `BIR_REPLAY`)
19
- * is not an error: `call` rejects with `no_proxy`, and the caller falls back to
20
- * the step's recorded output.
19
+ * is not an error: `call` rejects with `no_proxy`, and the plan stops at that
20
+ * step and hands the rest to the model (plan.ts, `DriveOptions`).
21
21
  *
22
22
  * WHICH PROXY. Several proxies can poll for the same server at once — one per
23
23
  * Claude Code session in the project, and a session's proxy outlives a
@@ -30,14 +30,21 @@
30
30
  *
31
31
  * 1. a proxy bound to the step's session — learned from a correlated step the
32
32
  * proxy reported, or from an earlier step of this session it ran;
33
- * 2. otherwise the newest unbound proxy (latest process start) — a session's
34
- * proxies are spawned when it starts, so the newest is the best guess for
35
- * a session that has not called the server yet;
36
- * 3. otherwise NOBODY. A proxy bound to another session is never used for
37
- * this one: the step resolves as a tool error, so the plan stops and hands
38
- * over to the agent, who calls the tool on its own upstream. It does not
39
- * reject — a rejection lets a recorded output stand in, or skips the step
40
- * and runs the rest of the plan past it.
33
+ * 2. otherwise an unbound proxy, but ONLY while no other session is live
34
+ * (none bound elsewhere, and no server with two proxies polling — each
35
+ * session starts its own). A lone session's unbound proxy can only be
36
+ * its own. With two sessions open, the one that has not called the server
37
+ * yet cannot be told apart from the other: "the newest" was a guess, and
38
+ * when the older session asked it ran the step on the newer one's
39
+ * upstream — another browser page, another database session — and
40
+ * answered wrongly;
41
+ * 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
42
+ * session is never used for this one: the call rejects with a
43
+ * {@link NoSessionProxyError} — nothing was sent, exactly like
44
+ * {@link NO_PROXY} — so the step is recorded as not run (never as a
45
+ * failure of the scenario: two sessions open is the environment, not the
46
+ * scenario being wrong), the plan stops, and the agent gets the hand-over
47
+ * and calls the tool on its own upstream, with the reason in front of it.
41
48
  *
42
49
  * A session's bindings are released when it ends (a `/clear` starts a new
43
50
  * session on the same proxies). Legacy proxies, which send no id, share one
@@ -72,6 +79,20 @@ export interface PollerIdentity {
72
79
  }
73
80
  /** Rejection reason when no proxy is polling for a server. */
74
81
  export declare const NO_PROXY = "no_proxy";
82
+ /**
83
+ * Rejection when proxies poll for the server but none can be shown to be the
84
+ * asking session's: every one is bound to another session, or the unbound ones
85
+ * may be another open session's. Like {@link NO_PROXY}, nothing was sent; the
86
+ * message is the explanation the model reads in the hand-over.
87
+ */
88
+ export declare class NoSessionProxyError extends Error {
89
+ readonly code = "no_session_proxy";
90
+ constructor(message: string);
91
+ }
92
+ /** Rejection reason at shutdown for work no proxy had taken yet: never sent. */
93
+ export declare const QUEUE_CLOSED = "control server closed";
94
+ /** Rejection reason at shutdown for work a proxy had already taken: it may have run. */
95
+ export declare const QUEUE_CLOSED_IN_FLIGHT = "control server closed while the call was in flight";
75
96
  export declare class ProxyWorkQueue {
76
97
  /** Work held for a proxy that was mid-round-trip when it was dispatched, per proxy id. */
77
98
  private readonly pending;
@@ -103,10 +124,13 @@ export declare class ProxyWorkQueue {
103
124
  /**
104
125
  * Dispatch one call and await its result.
105
126
  *
106
- * Rejects with {@link NO_PROXY} when nothing is polling for `serverName`, and
107
- * with `timeout` when the proxy takes the work but never answers. Both are
108
- * "could not be run here", which is exactly the condition under which the
109
- * caller may substitute a recorded output.
127
+ * Rejects with {@link NO_PROXY} when nothing is polling for `serverName`, with
128
+ * a {@link NoSessionProxyError} when proxies poll but none can be shown to be
129
+ * `route.sessionId`'s (nothing sent either), and with `timeout` when the
130
+ * proxy takes the work but never answers. The two
131
+ * are not the same news: `no_proxy` means nothing was sent, while a timeout
132
+ * means the call may well have done its work upstream — the controller tells
133
+ * the model which (controller.ts, `classifyWorkError`).
110
134
  */
111
135
  call(serverName: string, toolName: string, args: unknown, timeoutMs: number, route?: WorkRoute): Promise<unknown>;
112
136
  /** Remove queued work that timed out, so a later poll never gets stale work. */
@@ -121,10 +145,21 @@ export declare class ProxyWorkQueue {
121
145
  private present;
122
146
  /**
123
147
  * The proxy a step for `sessionId` should run on — see the file comment.
124
- * Undefined when no proxy is live at all; `other_sessions` when every live
125
- * one belongs to a different session.
148
+ * Undefined when no proxy is live at all; `other_sessions` when none can be
149
+ * shown to be this session's: every live one belongs to a different session,
150
+ * or the unbound ones might, because another session is live too.
126
151
  */
127
152
  private pickProxy;
153
+ /**
154
+ * Whether a session other than `sessionId` is evidently live.
155
+ *
156
+ * Two signs, both from proxies that are polling right now: one is bound to
157
+ * another session, or one server has two proxies polling. Claude Code starts
158
+ * one proxy per server per session, so a second proxy for the same server is
159
+ * a second session, whether or not it has called anything yet. A pool of
160
+ * legacy proxies shares one record and says nothing either way.
161
+ */
162
+ private otherSessionLive;
128
163
  /**
129
164
  * Free every proxy bound to `sessionId`: the session ended. After a `/clear`
130
165
  * the same proxies serve the next session, which must be able to pick them.
@@ -16,8 +16,8 @@
16
16
  * already dominated by the upstream's own round trip.
17
17
  *
18
18
  * A proxy that never polls (an old build, or one started without `BIR_REPLAY`)
19
- * is not an error: `call` rejects with `no_proxy`, and the caller falls back to
20
- * the step's recorded output.
19
+ * is not an error: `call` rejects with `no_proxy`, and the plan stops at that
20
+ * step and hands the rest to the model (plan.ts, `DriveOptions`).
21
21
  *
22
22
  * WHICH PROXY. Several proxies can poll for the same server at once — one per
23
23
  * Claude Code session in the project, and a session's proxy outlives a
@@ -30,14 +30,21 @@
30
30
  *
31
31
  * 1. a proxy bound to the step's session — learned from a correlated step the
32
32
  * proxy reported, or from an earlier step of this session it ran;
33
- * 2. otherwise the newest unbound proxy (latest process start) — a session's
34
- * proxies are spawned when it starts, so the newest is the best guess for
35
- * a session that has not called the server yet;
36
- * 3. otherwise NOBODY. A proxy bound to another session is never used for
37
- * this one: the step resolves as a tool error, so the plan stops and hands
38
- * over to the agent, who calls the tool on its own upstream. It does not
39
- * reject — a rejection lets a recorded output stand in, or skips the step
40
- * and runs the rest of the plan past it.
33
+ * 2. otherwise an unbound proxy, but ONLY while no other session is live
34
+ * (none bound elsewhere, and no server with two proxies polling — each
35
+ * session starts its own). A lone session's unbound proxy can only be
36
+ * its own. With two sessions open, the one that has not called the server
37
+ * yet cannot be told apart from the other: "the newest" was a guess, and
38
+ * when the older session asked it ran the step on the newer one's
39
+ * upstream — another browser page, another database session — and
40
+ * answered wrongly;
41
+ * 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
42
+ * session is never used for this one: the call rejects with a
43
+ * {@link NoSessionProxyError} — nothing was sent, exactly like
44
+ * {@link NO_PROXY} — so the step is recorded as not run (never as a
45
+ * failure of the scenario: two sessions open is the environment, not the
46
+ * scenario being wrong), the plan stops, and the agent gets the hand-over
47
+ * and calls the tool on its own upstream, with the reason in front of it.
41
48
  *
42
49
  * A session's bindings are released when it ends (a `/clear` starts a new
43
50
  * session on the same proxies). Legacy proxies, which send no id, share one
@@ -53,6 +60,23 @@ import { randomUUID } from "node:crypto";
53
60
  import { logDetail } from "../util/log.js";
54
61
  /** Rejection reason when no proxy is polling for a server. */
55
62
  export const NO_PROXY = "no_proxy";
63
+ /**
64
+ * Rejection when proxies poll for the server but none can be shown to be the
65
+ * asking session's: every one is bound to another session, or the unbound ones
66
+ * may be another open session's. Like {@link NO_PROXY}, nothing was sent; the
67
+ * message is the explanation the model reads in the hand-over.
68
+ */
69
+ export class NoSessionProxyError extends Error {
70
+ code = "no_session_proxy";
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = "NoSessionProxyError";
74
+ }
75
+ }
76
+ /** Rejection reason at shutdown for work no proxy had taken yet: never sent. */
77
+ export const QUEUE_CLOSED = "control server closed";
78
+ /** Rejection reason at shutdown for work a proxy had already taken: it may have run. */
79
+ export const QUEUE_CLOSED_IN_FLIGHT = "control server closed while the call was in flight";
56
80
  /**
57
81
  * How long after its last poll a proxy is still considered present.
58
82
  *
@@ -173,10 +197,13 @@ export class ProxyWorkQueue {
173
197
  /**
174
198
  * Dispatch one call and await its result.
175
199
  *
176
- * Rejects with {@link NO_PROXY} when nothing is polling for `serverName`, and
177
- * with `timeout` when the proxy takes the work but never answers. Both are
178
- * "could not be run here", which is exactly the condition under which the
179
- * caller may substitute a recorded output.
200
+ * Rejects with {@link NO_PROXY} when nothing is polling for `serverName`, with
201
+ * a {@link NoSessionProxyError} when proxies poll but none can be shown to be
202
+ * `route.sessionId`'s (nothing sent either), and with `timeout` when the
203
+ * proxy takes the work but never answers. The two
204
+ * are not the same news: `no_proxy` means nothing was sent, while a timeout
205
+ * means the call may well have done its work upstream — the controller tells
206
+ * the model which (controller.ts, `classifyWorkError`).
180
207
  */
181
208
  call(serverName, toolName, args, timeoutMs, route = {}) {
182
209
  if (this.closed)
@@ -186,19 +213,22 @@ export class ProxyWorkQueue {
186
213
  // No proxy has ever polled for this server, or one has been gone for a
187
214
  // minute. Do NOT queue: the caller is inside a turn the user is waiting on,
188
215
  // and work that sits until some proxy happens to appear would stall it past
189
- // every budget. Fail fast, and let the recorded-output fallback decide.
216
+ // every budget. Fail fast: the plan stops here and says so.
190
217
  return Promise.reject(new Error(NO_PROXY));
191
218
  }
192
219
  if (picked === "other_sessions") {
193
- const others = this.present(serverName).length;
220
+ const live = this.present(serverName);
221
+ const unbound = live.some((p) => !p.sessionId);
194
222
  logDetail("replay.no_session_proxy", {
195
223
  server: serverName,
196
224
  tool: toolName,
197
225
  sess: route.sessionId,
198
- others,
199
- why: "only other sessions' proxies are here — the step is not run on their upstream",
226
+ others: live.length,
227
+ why: unbound
228
+ ? "this session has no proxy of its own for the server yet, and another session is live — an unbound proxy may be theirs, so none is guessed"
229
+ : "only other sessions' proxies are here — the step is not run on their upstream",
200
230
  });
201
- return Promise.resolve(noSessionProxy(serverName, others));
231
+ return Promise.reject(noSessionProxy(serverName, live.length, unbound));
202
232
  }
203
233
  const { info, why } = picked;
204
234
  if (route.sessionId && !info.sessionId && !isLegacy(info.proxyId)) {
@@ -262,12 +292,22 @@ export class ProxyWorkQueue {
262
292
  }
263
293
  }
264
294
  this.waiters.clear();
295
+ // Work still held for a proxy that never came back for it was never sent:
296
+ // that, and only that, may be said to have not run. Work already handed to
297
+ // a proxy may be running on its upstream right now, and is said to be
298
+ // exactly that (controller.ts, `classifyWorkError`) — "nothing happened, do
299
+ // it yourself" about a write in flight is how it gets done twice.
300
+ const unsent = new Set();
301
+ for (const list of this.pending.values()) {
302
+ for (const work of list)
303
+ unsent.add(work.workId);
304
+ }
265
305
  this.pending.clear();
266
306
  // A closed queue has no proxies, whatever they were doing a moment ago.
267
307
  this.proxies.clear();
268
- for (const [, entry] of this.inFlight) {
308
+ for (const [workId, entry] of this.inFlight) {
269
309
  clearTimeout(entry.timer);
270
- entry.reject(new Error("control server closed"));
310
+ entry.reject(new Error(unsent.has(workId) ? QUEUE_CLOSED : QUEUE_CLOSED_IN_FLIGHT));
271
311
  }
272
312
  this.inFlight.clear();
273
313
  }
@@ -300,8 +340,9 @@ export class ProxyWorkQueue {
300
340
  }
301
341
  /**
302
342
  * The proxy a step for `sessionId` should run on — see the file comment.
303
- * Undefined when no proxy is live at all; `other_sessions` when every live
304
- * one belongs to a different session.
343
+ * Undefined when no proxy is live at all; `other_sessions` when none can be
344
+ * shown to be this session's: every live one belongs to a different session,
345
+ * or the unbound ones might, because another session is live too.
305
346
  */
306
347
  pickProxy(serverName, sessionId) {
307
348
  const live = this.present(serverName);
@@ -316,9 +357,40 @@ export class ProxyWorkQueue {
316
357
  if (mine[0])
317
358
  return { info: mine[0], why: "session" };
318
359
  const unbound = live.find((p) => !p.sessionId);
319
- if (unbound)
320
- return { info: unbound, why: "newest" };
321
- return "other_sessions";
360
+ if (!unbound)
361
+ return "other_sessions";
362
+ // Nothing ties an unbound proxy to this session. With another session
363
+ // live, it may well be that session's — a browser on its page, a database
364
+ // session in its state — and a wrong guess returns a wrong answer here,
365
+ // where not running the step only costs the saving.
366
+ if (this.otherSessionLive(sessionId))
367
+ return "other_sessions";
368
+ return { info: unbound, why: "only" };
369
+ }
370
+ /**
371
+ * Whether a session other than `sessionId` is evidently live.
372
+ *
373
+ * Two signs, both from proxies that are polling right now: one is bound to
374
+ * another session, or one server has two proxies polling. Claude Code starts
375
+ * one proxy per server per session, so a second proxy for the same server is
376
+ * a second session, whether or not it has called anything yet. A pool of
377
+ * legacy proxies shares one record and says nothing either way.
378
+ */
379
+ otherSessionLive(sessionId) {
380
+ const perServer = new Map();
381
+ for (const info of this.proxies.values()) {
382
+ if (!this.isLive(info))
383
+ continue;
384
+ if (info.sessionId && info.sessionId !== sessionId)
385
+ return true;
386
+ if (isLegacy(info.proxyId))
387
+ continue;
388
+ const n = (perServer.get(info.serverName) ?? 0) + 1;
389
+ if (n > 1)
390
+ return true;
391
+ perServer.set(info.serverName, n);
392
+ }
393
+ return false;
322
394
  }
323
395
  /**
324
396
  * Free every proxy bound to `sessionId`: the session ended. After a `/clear`
@@ -406,21 +478,22 @@ function shortId(proxyId) {
406
478
  return isLegacy(proxyId) ? proxyId : proxyId.slice(-8);
407
479
  }
408
480
  /**
409
- * The step's result when only other sessions' proxies are here: an MCP tool
410
- * error, so the plan stops on it and hands over (tool-error.ts), and the agent
411
- * reads why.
481
+ * The rejection when no proxy can be shown to be this session's. A rejection,
482
+ * not a tool-error result: a result is a call that ran, and the plan would
483
+ * count it as an executed step that failed — blaming the scenario for two
484
+ * sessions being open, and telling the model both that the step ran and that
485
+ * nothing did. As a rejection the controller records it as not run
486
+ * (controller.ts, `classifyWorkError`) and the plan hands the task over.
487
+ * `unsure` — some of them are unbound, and other sessions are live — words it
488
+ * as the doubt it is.
412
489
  */
413
- function noSessionProxy(serverName, others) {
414
- return {
415
- isError: true,
416
- content: [
417
- {
418
- type: "text",
419
- text: `Error: bir did not run this step. No ${serverName} proxy of this session is ` +
420
- `connected; the ${others} that are belong to other sessions, and a step never ` +
421
- `runs on another session's upstream. Call the tool yourself.`,
422
- },
423
- ],
424
- };
490
+ function noSessionProxy(serverName, others, unsure = false) {
491
+ const why = unsure
492
+ ? `no ${serverName} connection is known to be this session's yet, and other sessions ` +
493
+ `are open, so the ${others} connected may belong to them; a step never runs on ` +
494
+ `another session's upstream`
495
+ : `no ${serverName} proxy of this session is connected; the ${others} that are belong ` +
496
+ `to other sessions, and a step never runs on another session's upstream`;
497
+ return new NoSessionProxyError(why);
425
498
  }
426
499
  //# sourceMappingURL=executor.js.map
@@ -18,7 +18,34 @@ export type HandoverDelivery =
18
18
  /** Steer mode: the steps that ran are the model's own tool calls, just above. */
19
19
  "above"
20
20
  /** Direct mode: the steps that ran are in the bundle that follows the note. */
21
- | "below";
21
+ | "below"
22
+ /**
23
+ * Neither: the note travels alone — a run whose caller went away, or one
24
+ * whose tool answer is an error. The steps that ran are real, and the model
25
+ * is told so, but it has no results to read.
26
+ */
27
+ | "none";
28
+ /**
29
+ * Why the stopped step did not do its work, when it is not simply "it failed".
30
+ *
31
+ * The wording matters more than it looks. A step that timed out may well have
32
+ * finished on the upstream — the proxy stops waiting, the database does not —
33
+ * and a model told only "failed" reasonably runs it again: a second INSERT, a
34
+ * second deploy. So each reason says what the model may and may not assume.
35
+ */
36
+ export type HandoverStopReason =
37
+ /** Nothing was sent: no proxy for its server, a built-in, the allowlist. */
38
+ "not_run"
39
+ /** Sent and never answered (a timeout, a dropped connection). */
40
+ | "uncertain"
41
+ /** Sent, and the upstream answered with an error. */
42
+ | "failed"
43
+ /** The plan's time budget ran out before it. */
44
+ | "budget"
45
+ /** The run was cancelled before it. */
46
+ | "cancelled"
47
+ /** Another of the plan's calls was still in flight (steer mode runs one at a time). */
48
+ | "concurrent";
22
49
  export interface HandoverNoteInput {
23
50
  intent: string;
24
51
  kind: FallbackKind;
@@ -29,8 +56,34 @@ export interface HandoverNoteInput {
29
56
  failureCount?: number;
30
57
  /** Why it stopped (step_failed). */
31
58
  error?: string;
59
+ /** For `step_failed`: what kind of stop it was. Absent reads as a failure. */
60
+ stopReason?: HandoverStopReason;
32
61
  /** Steps that ran under the scenario before the stop. */
33
62
  executed: number;
63
+ /**
64
+ * Of {@link executed}, how many are the model's own tool calls above — the
65
+ * steps a steered session ran before a divergence took over. Only read when
66
+ * `delivery` is not `above` (where every step is), and only when it is
67
+ * fewer than `executed`; otherwise the delivery's own wording is right.
68
+ */
69
+ executedAbove?: number;
70
+ /**
71
+ * A pinned step the host was asked to run and has not reported back on —
72
+ * neither counted in {@link executed} nor said to be undone. It may be
73
+ * running still, or it may never run at all: the user's own deny rule or
74
+ * PreToolUse hook can block a call the plan approved, and then no report
75
+ * ever comes. `stepIndex` is its position in the chain.
76
+ */
77
+ inFlight?: {
78
+ stepIndex: number;
79
+ toolName?: string;
80
+ };
81
+ /**
82
+ * The stopped step's own tool ran and its result is real; what failed is the
83
+ * scenario reading that result (its output logic). It is counted in
84
+ * `executed` and absent from `remainingTools`, and must not be run again.
85
+ */
86
+ ranThisStep?: boolean;
34
87
  totalSteps: number;
35
88
  /** Tool names of the planned steps from the stopped one to the end. */
36
89
  remainingTools: string[];
@@ -36,13 +36,35 @@ export function buildHandoverNote(n) {
36
36
  if (intent)
37
37
  lines.push(`Intent: ${intent}`);
38
38
  if (n.executed > 0) {
39
- lines.push(n.delivery === "above"
40
- ? `Steps 1–${n.executed} of ${n.totalSteps} ran under the scenario; their calls and results are the tool calls above. Do not repeat them.`
41
- : `Steps 1–${n.executed} of ${n.totalSteps} ran under the scenario; their calls and results are below. Do not repeat them.`);
39
+ // Part of the work may be the model's own calls above (a steered session
40
+ // that diverged), and the rest the bundle below or side effects only.
41
+ const above = Math.max(0, Math.min(n.executedAbove ?? 0, n.executed));
42
+ const delivery = above >= n.executed ? "above" : n.delivery;
43
+ const ran = `Steps 1–${n.executed} of ${n.totalSteps} ran under the scenario`;
44
+ lines.push(delivery === "above"
45
+ ? `${ran}; their calls and results are the tool calls above. Do not repeat them.`
46
+ : above > 0
47
+ ? delivery === "none"
48
+ ? `${ran}: steps 1–${above} are the tool calls above, and the side effects of the rest are in place. Do not repeat them.`
49
+ : `${ran}: steps 1–${above} are the tool calls above, and the rest are below. Do not repeat them.`
50
+ : delivery === "none"
51
+ ? `${ran}, and their side effects are in place. Do not repeat them.`
52
+ : `${ran}; their calls and results are below. Do not repeat them.`);
53
+ }
54
+ else if (n.stopReason === "uncertain" || n.inFlight) {
55
+ // The stopped step was sent: "no step ran" would contradict the line that
56
+ // says it may have completed.
57
+ lines.push("No step of the scenario is known to have finished; the task is yours.");
42
58
  }
43
59
  else {
44
60
  lines.push("No step of the scenario ran; the whole task is yours.");
45
61
  }
62
+ if (n.inFlight) {
63
+ // Never "ran", never "did not run": the host has not said which.
64
+ const inTool = n.inFlight.toolName ? ` (${n.inFlight.toolName})` : "";
65
+ lines.push(`Step ${n.inFlight.stepIndex + 1} of ${n.totalSteps}${inTool} was started and has not reported back: ` +
66
+ "it may not have run, or may still complete. Check whether its work happened before you repeat it.");
67
+ }
46
68
  if (n.kind === "unusable_call") {
47
69
  // A call is a stretch of work, not a tool: the agent is told what the
48
70
  // sub-task was, not which tool did not run.
@@ -66,8 +88,7 @@ export function buildHandoverNote(n) {
66
88
  lines.push(`Step ${human} of ${n.totalSteps}${tool} did not run: its input needs a judgement the scenario cannot compute. Make that choice yourself and carry on.`);
67
89
  }
68
90
  else {
69
- const error = n.error ? `: ${truncate(n.error, MAX_ERROR_CHARS)}` : "";
70
- lines.push(`Step ${human} of ${n.totalSteps}${tool} failed${error}`);
91
+ lines.push(stepFailedLine(n, human, tool));
71
92
  }
72
93
  if (n.remainingTools.length > 0) {
73
94
  lines.push(`The remaining planned steps were: ${n.remainingTools.join(", ")}.`);
@@ -84,4 +105,33 @@ export function buildHandoverNote(n) {
84
105
  }
85
106
  return truncate(lines.join("\n"), MAX_HANDOVER_NOTE);
86
107
  }
108
+ /** The line that names a stopped step and says what may be assumed about it. */
109
+ function stepFailedLine(n, human, tool) {
110
+ const at = `Step ${human} of ${n.totalSteps}${tool}`;
111
+ const detail = n.error ? truncate(n.error, MAX_ERROR_CHARS) : "";
112
+ if (n.ranThisStep) {
113
+ // Its tool ran and did its work; only the scenario's reading of the result
114
+ // broke. A model told "failed" here runs the write again.
115
+ return (`${at} ran — its result is real and its side effects are in place — but the scenario ` +
116
+ `could not read its output${detail ? ` (${detail})` : ""}, so it stops there. Do not run it again.`);
117
+ }
118
+ switch (n.stopReason) {
119
+ case "not_run":
120
+ return `${at} did not run${detail ? `: ${detail}` : ""}. Nothing of it happened; do it yourself.`;
121
+ case "uncertain":
122
+ // Never "failed": the upstream may have finished what the proxy stopped
123
+ // waiting for, and a model told "failed" runs a write a second time.
124
+ return (`${at} was sent but did not answer${detail ? ` (${detail})` : ""}. It may or may not ` +
125
+ "have completed: check whether its work happened before you run it again.");
126
+ case "budget":
127
+ return `${at} did not run: the scenario's time budget ran out before it.`;
128
+ case "cancelled":
129
+ return `${at} did not run: the scenario run was cancelled before it.`;
130
+ case "concurrent":
131
+ return (`${at} was not run by the scenario: another of its calls was still in flight, ` +
132
+ "and a scenario runs one call at a time.");
133
+ default:
134
+ return `${at} failed${detail ? `: ${detail}` : ""}`;
135
+ }
136
+ }
87
137
  //# sourceMappingURL=handover.js.map