@basein/runner 0.2.7 → 0.2.10

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 (45) hide show
  1. package/README.md +64 -21
  2. package/dist/auth/client.d.ts +40 -1
  3. package/dist/auth/client.js +77 -9
  4. package/dist/bin/bir-hooks.d.ts +18 -3
  5. package/dist/bin/bir-hooks.js +124 -38
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +362 -39
  8. package/dist/bin/investigate.js +5 -1
  9. package/dist/bin/setup.d.ts +72 -0
  10. package/dist/bin/setup.js +286 -0
  11. package/dist/config/adapters/claude-code.d.ts +90 -4
  12. package/dist/config/adapters/claude-code.js +164 -16
  13. package/dist/config/generate.d.ts +93 -1
  14. package/dist/config/generate.js +90 -3
  15. package/dist/control/client.d.ts +5 -0
  16. package/dist/control/client.js +8 -0
  17. package/dist/control/daemon.d.ts +116 -0
  18. package/dist/control/daemon.js +339 -0
  19. package/dist/control/discovery.d.ts +26 -0
  20. package/dist/control/discovery.js +41 -9
  21. package/dist/control/ensure-hook.d.ts +39 -0
  22. package/dist/control/ensure-hook.js +98 -0
  23. package/dist/control/paths.d.ts +14 -0
  24. package/dist/control/paths.js +20 -0
  25. package/dist/control/server.d.ts +28 -0
  26. package/dist/control/server.js +22 -6
  27. package/dist/proxy/session.d.ts +8 -1
  28. package/dist/proxy/session.js +28 -6
  29. package/dist/replay/controller.d.ts +24 -1
  30. package/dist/replay/controller.js +76 -20
  31. package/dist/replay/handover.js +5 -0
  32. package/dist/replay/plan.d.ts +2 -0
  33. package/dist/replay/plan.js +53 -6
  34. package/dist/replay/pricing.d.ts +1 -1
  35. package/dist/replay/pricing.js +12 -4
  36. package/dist/replay/tool-error.d.ts +15 -0
  37. package/dist/replay/tool-error.js +17 -0
  38. package/dist/replay/types.d.ts +48 -1
  39. package/docs/calculatedReplayGuide.md +157 -68
  40. package/docs/installRun.md +457 -111
  41. package/docs/loginWeb.md +1 -1
  42. package/docs/quickstart.md +193 -158
  43. package/package.json +2 -1
  44. package/scripts/install.ps1 +669 -0
  45. package/scripts/install.sh +586 -0
@@ -53,6 +53,14 @@ export interface ControlServerOptions {
53
53
  cwd: string;
54
54
  /** Preferred port; 0 (or a busy port) falls back to an ephemeral one. */
55
55
  port?: number;
56
+ /**
57
+ * Whether a busy `port` may fall back to an ephemeral one. Default true — a
58
+ * recorder in a terminal is better on some port than not at all. A background
59
+ * recorder whose port is written into a project's hooks passes false: on a
60
+ * fallback port it would answer SessionStart and then every other hook would
61
+ * post into the void, which is worse than exiting with a reason.
62
+ */
63
+ portFallback?: boolean;
56
64
  /** Loopback bearer token. Generated when omitted. */
57
65
  token?: string;
58
66
  /** Config keys of the servers a proxy wraps. Grows as proxies register. */
@@ -90,6 +98,26 @@ export interface ControlServerOptions {
90
98
  * recording and nothing else happens.
91
99
  */
92
100
  replay?: ReplayOptions;
101
+ /**
102
+ * Where the replay switches came from — the environment, the project's
103
+ * stored policy, or the defaults. Reported on `/health` so `bir doctor` can
104
+ * say which, because "the allow-list I set is gone" has exactly one cause.
105
+ */
106
+ replaySource?: "env" | "sidecar" | "default";
107
+ /**
108
+ * Whose account the recordings land in, for `/health` and `bir doctor`. "The
109
+ * run is not in Recordings" is, more often than not, "it is in somebody
110
+ * else's", and nothing said which until now.
111
+ */
112
+ account?: string;
113
+ /**
114
+ * What 'POST /control/stop' does after answering. 'bir-hooks' passes its own
115
+ * shutdown; a test passes nothing and the route merely says it would. The
116
+ * route exists so a background recorder can be stopped the way Ctrl-C stops
117
+ * one in a terminal — finishing the run and draining the queue — rather than
118
+ * killed, which on Windows is the only other option.
119
+ */
120
+ onStopRequested?: () => void;
93
121
  }
94
122
  export interface ControlServerAddress {
95
123
  url: string;
@@ -38,6 +38,7 @@ import { calculateCostUsd } from "../replay/pricing.js";
38
38
  import { logDetail, logLine, errText } from "../util/log.js";
39
39
  import { journal } from "../util/journal.js";
40
40
  import { packageVersion } from "../util/version.js";
41
+ import { resolveAuthUrl } from "../auth/client.js";
41
42
  /** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
42
43
  const PROXY_REPORT_GRACE_MS = 1_500;
43
44
  /** One MCP call, as seen from up to two sides. */
@@ -127,7 +128,7 @@ export class ControlServer {
127
128
  resolve(this.address);
128
129
  });
129
130
  };
130
- bind(preferred, preferred !== 0);
131
+ bind(preferred, preferred !== 0 && this.opts.portFallback !== false);
131
132
  });
132
133
  }
133
134
  async close() {
@@ -150,6 +151,10 @@ export class ControlServer {
150
151
  const sessions = [...this.sessions.values()].map((s) => ({
151
152
  sessionId: s.sessionId,
152
153
  runId: s.run?.runId,
154
+ // Mid-run right now. What 'ensureDaemon' reads before it dares to restart
155
+ // an out-of-date recorder: a restart between turns costs nothing, one
156
+ // during a turn loses the turn.
157
+ active: Boolean(s.run) && !s.run.finished,
153
158
  steps: s.run?.ordering.next ?? 0,
154
159
  recording: s.run?.recording ?? false,
155
160
  replay: s.run?.replay
@@ -187,12 +192,13 @@ export class ControlServer {
187
192
  tier: "bound",
188
193
  // The authoritative answer to "is anything actually being saved?".
189
194
  recording: this.opts.recording ?? !(this.recorder instanceof NullRecorder),
195
+ account: this.opts.account ?? null,
190
196
  // Whether a handed-out segment may actually run mid-task, or whether the
191
197
  // runner is only watching (R-OUT-10, R-LIFE-8). Observe-only must never
192
198
  // be invisible: an operator has to be able to see which of the two this
193
199
  // machine is doing without reading a log file.
194
200
  segmentArm: this.replay.segmentArm,
195
- authUrl: process.env.BIR_AUTH_URL ?? null,
201
+ authUrl: resolveAuthUrl() || null,
196
202
  sessionId: this.sessionId,
197
203
  pid: process.pid,
198
204
  cwd: this.opts.cwd,
@@ -210,6 +216,7 @@ export class ControlServer {
210
216
  // (docs/calculatedReplay.md §13.2, mitigation 3).
211
217
  replay: {
212
218
  enabled: this.replay.enabled,
219
+ source: this.opts.replaySource ?? null,
213
220
  minSimilarity: this.opts.replay?.minSimilarity ?? null,
214
221
  allowServers: this.opts.replay?.allowServers ? [...this.opts.replay.allowServers] : null,
215
222
  deriveKey: Boolean(this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY),
@@ -248,6 +255,12 @@ export class ControlServer {
248
255
  }
249
256
  const body = await this.readJson(req);
250
257
  switch (route) {
258
+ case "/control/stop":
259
+ // Answer first, then leave: the caller is polling for the pid to go.
260
+ this.send(res, 200, { ok: Boolean(this.opts.onStopRequested) });
261
+ if (this.opts.onStopRequested)
262
+ setImmediate(() => this.opts.onStopRequested?.());
263
+ return;
251
264
  case "/session/start":
252
265
  this.send(res, 200, this.onSessionStart(body));
253
266
  return;
@@ -944,11 +957,14 @@ export class ControlServer {
944
957
  continue;
945
958
  let position;
946
959
  if (!state.plan) {
947
- // Only a known-bad first step means the turn is committed to doing this
948
- // recording's work itself from position 0 (R-HIT-14); every other
949
- // decline leaves the recording untouched.
950
- if (state.declined === "known_bad_first_step")
960
+ // Only a first step the plan cannot start on — parked, or a judgement
961
+ // — means the turn is committed to doing this recording's work itself
962
+ // from position 0 (R-HIT-14); every other decline leaves the recording
963
+ // untouched.
964
+ if (state.declined === "known_bad_first_step" ||
965
+ state.declined === "nondeterministic_first_step") {
951
966
  position = 0;
967
+ }
952
968
  }
953
969
  else if (state.handover) {
954
970
  position = state.handover.stepIndex;
@@ -25,7 +25,14 @@ import type { UpstreamClient } from "../upstream/client.js";
25
25
  import type { ProxyStepReport } from "../control/correlation.js";
26
26
  import { type Recorder } from "../record/recorder.js";
27
27
  /** How long a proxy waits for a control server before falling to Tier 2. */
28
- export declare const DISCOVERY_WINDOW_MS = 5000;
28
+ /**
29
+ * Ten seconds, not five: the recorder is now started by the SessionStart hook,
30
+ * which fires while the host is also spawning this proxy, and a cold start
31
+ * (sign-in refresh, the service's /health probe, then listen) has been
32
+ * measured at one to four seconds. Steps are buffered meanwhile, never
33
+ * dropped, and a host session is never waiting on this.
34
+ */
35
+ export declare const DISCOVERY_WINDOW_MS = 10000;
29
36
  export type Tier = "bound" | "standalone" | "pending";
30
37
  export interface ProxySessionOptions {
31
38
  serverName: string;
@@ -26,7 +26,7 @@ import { hostname } from "node:os";
26
26
  import { ControlClient } from "../control/client.js";
27
27
  import { resolveControl } from "../control/discovery.js";
28
28
  import { qualifyToolName } from "../control/correlation.js";
29
- import { authenticate } from "../auth/client.js";
29
+ import { authenticate, resolveAuthUrl } from "../auth/client.js";
30
30
  import { NullRecorder } from "../record/recorder.js";
31
31
  import { RemoteRecorder } from "../record/remote-recorder.js";
32
32
  import { StepQueue } from "../record/queue.js";
@@ -36,7 +36,14 @@ import { redact } from "../record/redact.js";
36
36
  import { logLine, logDetail, errText } from "../util/log.js";
37
37
  import { packageVersion } from "../util/version.js";
38
38
  /** How long a proxy waits for a control server before falling to Tier 2. */
39
- export const DISCOVERY_WINDOW_MS = 5_000;
39
+ /**
40
+ * Ten seconds, not five: the recorder is now started by the SessionStart hook,
41
+ * which fires while the host is also spawning this proxy, and a cold start
42
+ * (sign-in refresh, the service's /health probe, then listen) has been
43
+ * measured at one to four seconds. Steps are buffered meanwhile, never
44
+ * dropped, and a host session is never waiting on this.
45
+ */
46
+ export const DISCOVERY_WINDOW_MS = 10_000;
40
47
  export class ProxySession {
41
48
  serverName;
42
49
  /** This proxy process, as the control server tells proxies apart (replay/executor.ts). */
@@ -98,6 +105,7 @@ export class ProxySession {
98
105
  return;
99
106
  }
100
107
  const window = this.opts.discoveryWindowMs ?? DISCOVERY_WINDOW_MS;
108
+ const deadline = Date.now() + window;
101
109
  const found = await resolveControl(this.opts.cwd, window);
102
110
  if (!found?.url) {
103
111
  await this.becomeStandalone(`no control server within ${window}ms`);
@@ -108,12 +116,24 @@ export class ProxySession {
108
116
  startedAt: this.startedAt,
109
117
  pid: process.pid,
110
118
  });
111
- const registered = await client.register({
119
+ const info = {
112
120
  serverName: this.serverName,
113
121
  pid: process.pid,
114
122
  cwd: this.opts.cwd,
115
123
  version: packageVersion(),
116
- });
124
+ };
125
+ // A control server that is still coming up — the SessionStart hook is
126
+ // starting it while the host spawns us — answers nothing for a moment.
127
+ // Keep trying inside the same window rather than settle for Tier 2 on the
128
+ // first refusal.
129
+ let registered = await client.register(info);
130
+ while (!registered && Date.now() < deadline) {
131
+ await new Promise((resolve) => {
132
+ const t = setTimeout(resolve, 250);
133
+ t.unref?.();
134
+ });
135
+ registered = await client.register(info);
136
+ }
117
137
  if (!registered) {
118
138
  await this.becomeStandalone("control server did not answer /proxy/register");
119
139
  return;
@@ -216,9 +236,11 @@ export class ProxySession {
216
236
  if (this.opts.recorderFactory) {
217
237
  return (await this.opts.recorderFactory()) ?? new NullRecorder();
218
238
  }
219
- const baseUrl = (process.env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
239
+ // BIR_AUTH_URL, or the address 'bir setup' stored — the same lookup the
240
+ // control server and the CLI make, so no two processes disagree about it.
241
+ const baseUrl = resolveAuthUrl();
220
242
  if (!baseUrl) {
221
- logLine("recorder.disabled", { why: "BIR_AUTH_URL is not set" });
243
+ logLine("recorder.disabled", { why: "no service address — run 'bir setup', or set BIR_AUTH_URL" });
222
244
  return new NullRecorder();
223
245
  }
224
246
  // A proxy runs inside the host's process tree with its stdio bound to the
@@ -219,7 +219,7 @@ export interface ReplayState {
219
219
  * Why a match did not arm. One code per gate of the ladder, so the caller can
220
220
  * act on a decline without parsing the sentence that explains it.
221
221
  */
222
- export type DeclineCode = "replay_disabled" | "not_ready" | "flatten_failed" | "similarity" | "known_bad_first_step" | "unusable_first_step" | "coverage" | "missing_target" | "no_derive_key";
222
+ export type DeclineCode = "replay_disabled" | "not_ready" | "flatten_failed" | "similarity" | "known_bad_first_step" | "nondeterministic_first_step" | "unusable_first_step" | "coverage" | "missing_target" | "no_derive_key";
223
223
  /** What `PreToolUse` should do about this call. */
224
224
  export type PreToolAction =
225
225
  /** Pin the arguments and let the real tool run. */
@@ -417,6 +417,13 @@ export declare class ReplayController {
417
417
  * scenario that did nothing has nothing to book, and is a baseline sample.
418
418
  */
419
419
  private handOver;
420
+ /**
421
+ * Which kind of stop a plan's end is (plan-services.md D8). The stopping
422
+ * step's own served reason wins — the calculation's verdict is more exact
423
+ * than a counter — then a call the service could not answer, then a parked
424
+ * step.
425
+ */
426
+ private stopKindOf;
420
427
  private noteFor;
421
428
  /**
422
429
  * The executor handed to a plan: dispatch a step to the proxy that owns its
@@ -476,6 +483,22 @@ export interface RunMatchLike {
476
483
  /** The frozen name, beside the runtime intent the plan runs on (R-INTENT-12). */
477
484
  intentName?: string;
478
485
  }
486
+ /**
487
+ * The old rule, for a service that sends no `stop`: the position of the first
488
+ * step whose failure count is above `maxStepFailures` (fallbk.md D3), or
489
+ * undefined when none is — including when the service sent no policy, which an
490
+ * older service never does.
491
+ */
479
492
  export declare function knownBadStepIndex(steps: readonly SerializedScenarioStep[], maxStepFailures: number | undefined): number | undefined;
493
+ /**
494
+ * Position of the first step the plan must stop in front of (plan-services.md
495
+ * D8), or undefined when there is none.
496
+ *
497
+ * A step that carries `stop` was decided by the service — `null` means run it,
498
+ * even when its count is past the limit: its retry is due. A step without the
499
+ * field came from an older service, and the old counter rule decides it. Both
500
+ * can sit in one chain, since a called segment is served on its own terms.
501
+ */
502
+ export declare function stopIndex(steps: readonly SerializedScenarioStep[], maxStepFailures: number | undefined): number | undefined;
480
503
  export {};
481
504
  //# sourceMappingURL=controller.d.ts.map
@@ -204,20 +204,31 @@ export class ReplayController {
204
204
  const entries = flat.entries;
205
205
  if (entries.length === 0)
206
206
  return decline("the chain has no runnable step", "not_ready");
207
- // Gate 5 — known-bad steps (fallbk.md D3). The plan ends in front of the
208
- // first step the service has seen fail more than its limit — the counter on
209
- // whichever scenario owns that step (R-CALL-30). A call the service could
210
- // not answer stops the plan the same way, and the earlier of the two wins.
211
- const badAt = knownBadStepIndex(entries.map((e) => e.step), match.fallback?.maxStepFailures);
207
+ // Gate 5 — steps the plan must stop in front of (fallbk.md D3,
208
+ // plan-services.md D8): a step the service parked — failed more than its
209
+ // limit, retry not due — or one the calculation marked non-deterministic,
210
+ // on whichever scenario owns it (R-CALL-30). A call the service could not
211
+ // answer stops the plan the same way, and the earliest of them wins.
212
+ const badAt = stopIndex(entries.map((e) => e.step), match.fallback?.maxStepFailures);
212
213
  const rawStop = [flat.stopAt, badAt].filter((n) => typeof n === "number");
213
214
  // Clamped to the start of the outermost called frame it falls in: a plan
214
215
  // never runs half a sub-task and leaves its result mapping unevaluated.
215
216
  const stopAt = rawStop.length > 0 ? clampToFrameStart(entries, Math.min(...rawStop)) : undefined;
216
217
  if (stopAt === 0) {
217
- const why = flat.stopAt === 0 && flat.stopReason
218
- ? `its first step is a call that cannot run: ${flat.stopReason.reason}`
219
- : `its first step has failed ${entries[0].step.failureCount ?? 0} times — the agent does this task`;
220
- return decline(why, flat.stopAt === 0 && flat.stopReason ? "unusable_first_step" : "known_bad_first_step");
218
+ const unusable = flat.stopAt === 0 && flat.stopReason ? flat.stopReason : undefined;
219
+ // The step that stops the plan, before the clamp: the reason is its own.
220
+ const stopping = entries[Math.min(...rawStop)]?.step ?? entries[0].step;
221
+ const judgement = stopping.stop?.kind === "nondeterministic";
222
+ const why = unusable
223
+ ? `its first step is a call that cannot run: ${unusable.reason}`
224
+ : judgement
225
+ ? "its first step needs a judgement the plan cannot compute — the agent does this task"
226
+ : `its first step has failed ${stopping.failureCount ?? 0} times — the agent does this task`;
227
+ return decline(why, unusable
228
+ ? "unusable_first_step"
229
+ : judgement
230
+ ? "nondeterministic_first_step"
231
+ : "known_bad_first_step");
221
232
  }
222
233
  const planned = stopAt === undefined ? entries : entries.slice(0, stopAt);
223
234
  // Gate 6 — tool coverage, over the steps that will actually run.
@@ -333,6 +344,8 @@ export class ReplayController {
333
344
  // What this chain calls, and what answers each call right now (R-CALL-32).
334
345
  calls: callLines(scenario),
335
346
  unusable: flat.stopReason?.reason,
347
+ // Which steps the service told this plan to stop in front of, and why.
348
+ stops: stopLines(entries),
336
349
  coverage: coverageOf(entries.map((e) => e.step), wrapped, this.opts.allowServers).join(","),
337
350
  tools: entries.map((e) => e.step.toolName ?? "").join(","),
338
351
  });
@@ -562,7 +575,7 @@ export class ReplayController {
562
575
  // is a call the service could not answer (segmented.md R-CALL-29).
563
576
  const parked = plan.stopStep();
564
577
  this.handOver(state, {
565
- kind: state.flatStop ? "unusable_call" : "known_bad_step",
578
+ kind: this.stopKindOf(state, plan.stopEntry()),
566
579
  step: parked,
567
580
  entry: plan.stopEntry(),
568
581
  position: plan.stepCount,
@@ -639,7 +652,10 @@ export class ReplayController {
639
652
  const position = stop.flatIndex ?? plan.stepCount;
640
653
  const step = plan.allSteps()[position];
641
654
  this.handOver(state, {
642
- kind: stop.kind,
655
+ // The plan only knows it stopped in front of its end; which kind of
656
+ // stop that is — parked, a judgement, a call that cannot run — is
657
+ // decided here, where the served reasons are.
658
+ kind: stop.kind === "known_bad_step" ? this.stopKindOf(state, plan.stopEntry()) : stop.kind,
643
659
  step,
644
660
  entry: plan.allEntries()[position],
645
661
  position,
@@ -1070,7 +1086,7 @@ export class ReplayController {
1070
1086
  let text = composed.text;
1071
1087
  if (plan.stopsEarly()) {
1072
1088
  const parked = plan.stopStep();
1073
- const stopKind = state.flatStop ? "unusable_call" : "known_bad_step";
1089
+ const stopKind = this.stopKindOf(state, plan.stopEntry());
1074
1090
  state.handover = {
1075
1091
  kind: stopKind,
1076
1092
  stepIndex: parked.stepIndex,
@@ -1132,9 +1148,22 @@ export class ReplayController {
1132
1148
  ? "a sub-task of this chain cannot run — the agent does that part itself"
1133
1149
  : h.kind === "known_bad_step"
1134
1150
  ? "the next step has failed too often — the agent continues from here"
1135
- : "a step broke — the agent continues from here",
1151
+ : h.kind === "nondeterministic_step"
1152
+ ? "the next step needs a judgement the plan cannot compute — the agent makes it"
1153
+ : "a step broke — the agent continues from here",
1136
1154
  });
1137
1155
  }
1156
+ /**
1157
+ * Which kind of stop a plan's end is (plan-services.md D8). The stopping
1158
+ * step's own served reason wins — the calculation's verdict is more exact
1159
+ * than a counter — then a call the service could not answer, then a parked
1160
+ * step.
1161
+ */
1162
+ stopKindOf(state, entry) {
1163
+ if (entry?.step.stop?.kind === "nondeterministic")
1164
+ return "nondeterministic_step";
1165
+ return state.flatStop ? "unusable_call" : "known_bad_step";
1166
+ }
1138
1167
  noteFor(state, h) {
1139
1168
  const plan = state.plan;
1140
1169
  const executed = h.ranThisStep ? h.position + 1 : h.position;
@@ -1243,6 +1272,9 @@ export class ReplayController {
1243
1272
  stage: info.stage ?? (status === "skipped" ? "tool_call" : undefined),
1244
1273
  error,
1245
1274
  durationMs: info.ms,
1275
+ // The tool is gone from its server: the service repairs the plan without
1276
+ // waiting out the grace (plan-services.md D8, kind 2).
1277
+ toolMissing: info.toolMissing || undefined,
1246
1278
  // A verdict about a called segment's step is counted on that segment, at
1247
1279
  // the revision it was served (R-CALL-13).
1248
1280
  scenarioId: info.entry && info.entry.depth > 0 ? info.entry.scenarioId : undefined,
@@ -1340,11 +1372,6 @@ function targetKeys(schema) {
1340
1372
  return [];
1341
1373
  return Object.keys(schema).filter((k) => schema[k]?.kind !== "setting");
1342
1374
  }
1343
- /**
1344
- * Position of the first step whose failure count is above `maxStepFailures`
1345
- * (fallbk.md D3), or undefined when none is — including when the service sent
1346
- * no policy, which an older service never does.
1347
- */
1348
1375
  /**
1349
1376
  * The index a hand-over reports, in the *caller's* chain (R-CALL-32).
1350
1377
  *
@@ -1369,10 +1396,39 @@ function callLines(scenario) {
1369
1396
  .map((s) => `${s.segmentId ?? "-"}:${s.unusable ?? s.resolution?.state ?? "resolved"}`)
1370
1397
  .join(",");
1371
1398
  }
1399
+ /** The served stops in a flat chain, for the `plan.armed` line (plan-services.md D8). */
1400
+ function stopLines(entries) {
1401
+ const lines = entries
1402
+ .map((e, i) => e.step.stop ? `${i}:${e.step.stop.kind}${e.step.stop.repairDue ? "/repair" : ""}` : undefined)
1403
+ .filter((s) => s !== undefined);
1404
+ return lines.length > 0 ? lines.join(",") : undefined;
1405
+ }
1406
+ /**
1407
+ * The old rule, for a service that sends no `stop`: the position of the first
1408
+ * step whose failure count is above `maxStepFailures` (fallbk.md D3), or
1409
+ * undefined when none is — including when the service sent no policy, which an
1410
+ * older service never does.
1411
+ */
1372
1412
  export function knownBadStepIndex(steps, maxStepFailures) {
1413
+ const i = steps.findIndex((s) => isKnownBad(s, maxStepFailures));
1414
+ return i < 0 ? undefined : i;
1415
+ }
1416
+ function isKnownBad(step, maxStepFailures) {
1373
1417
  if (typeof maxStepFailures !== "number" || !Number.isFinite(maxStepFailures))
1374
- return undefined;
1375
- const i = steps.findIndex((s) => (s.failureCount ?? 0) > maxStepFailures);
1418
+ return false;
1419
+ return (step.failureCount ?? 0) > maxStepFailures;
1420
+ }
1421
+ /**
1422
+ * Position of the first step the plan must stop in front of (plan-services.md
1423
+ * D8), or undefined when there is none.
1424
+ *
1425
+ * A step that carries `stop` was decided by the service — `null` means run it,
1426
+ * even when its count is past the limit: its retry is due. A step without the
1427
+ * field came from an older service, and the old counter rule decides it. Both
1428
+ * can sit in one chain, since a called segment is served on its own terms.
1429
+ */
1430
+ export function stopIndex(steps, maxStepFailures) {
1431
+ const i = steps.findIndex((s) => s.stop !== undefined ? s.stop !== null : isKnownBad(s, maxStepFailures));
1376
1432
  return i < 0 ? undefined : i;
1377
1433
  }
1378
1434
  //# sourceMappingURL=controller.js.map
@@ -60,6 +60,11 @@ export function buildHandoverNote(n) {
60
60
  : "";
61
61
  lines.push(`Step ${human} of ${n.totalSteps}${tool.replace(/\)$/, `${owner})`)} did not run: it has failed${times} before, so the scenario stops in front of it.`);
62
62
  }
63
+ else if (n.kind === "nondeterministic_step") {
64
+ // The calculation could not write code for this step's input: it is a
65
+ // judgement, and the agent is the one to make it (plan-services.md D8).
66
+ 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
+ }
63
68
  else {
64
69
  const error = n.error ? `: ${truncate(n.error, MAX_ERROR_CHARS)}` : "";
65
70
  lines.push(`Step ${human} of ${n.totalSteps}${tool} failed${error}`);
@@ -65,6 +65,8 @@ export interface StepInfo {
65
65
  error?: string;
66
66
  derivedKeys?: string[];
67
67
  ms: number;
68
+ /** The tool is gone from its server, read from the error (plan-services.md D8). */
69
+ toolMissing?: boolean;
68
70
  }
69
71
  export type StepObserver = (info: StepInfo) => void;
70
72
  export interface ComposeResult {
@@ -23,7 +23,7 @@
23
23
  import { evalParamMapLogic, evalParamsLogic, evalResponseParamsLogic, evalResultMapLogic, evalToolInputLogic, evalToolOutputLogic, } from "./logic.js";
24
24
  import { flatEntriesOf } from "./flatten.js";
25
25
  import { assembleBundle, bundleInput, MAX_REPLAY_REASON } from "./bundle.js";
26
- import { toolResultError } from "./tool-error.js";
26
+ import { isMissingToolError, toolResultError } from "./tool-error.js";
27
27
  /**
28
28
  * The recorded values for the *settings* a mapping did not name (R-CALL-21).
29
29
  *
@@ -433,13 +433,29 @@ export class ScenarioReplayPlan {
433
433
  : undefined) ?? (await recordedOutputFor?.(entry).catch(() => undefined));
434
434
  if (!fallback) {
435
435
  skipped += 1;
436
- onStep?.({ step, entry, input: computed, outcome: "skipped", error, ms: Date.now() - startedAt });
436
+ onStep?.({
437
+ step,
438
+ entry,
439
+ input: computed,
440
+ outcome: "skipped",
441
+ error,
442
+ toolMissing: isMissingToolError(error) || undefined,
443
+ ms: Date.now() - startedAt,
444
+ });
437
445
  continue;
438
446
  }
439
447
  response = fallback;
440
448
  recorded = true;
441
449
  recordedCount += 1;
442
- onStep?.({ step, entry, input: computed, outcome: "recorded", error, ms: Date.now() - startedAt });
450
+ onStep?.({
451
+ step,
452
+ entry,
453
+ input: computed,
454
+ outcome: "recorded",
455
+ error,
456
+ toolMissing: isMissingToolError(error) || undefined,
457
+ ms: Date.now() - startedAt,
458
+ });
443
459
  }
444
460
  // Thread the output for later steps' inputs. A recorded output threads
445
461
  // too: `toolOutputLogic` was authored against exactly this shape, and a
@@ -465,6 +481,7 @@ export class ScenarioReplayPlan {
465
481
  outcome: error ? "failed" : "executed",
466
482
  stage: error ? "tool_call" : undefined,
467
483
  error,
484
+ toolMissing: isMissingToolError(error) || undefined,
468
485
  derivedKeys,
469
486
  ms: Date.now() - startedAt,
470
487
  });
@@ -518,6 +535,9 @@ export class ScenarioReplayPlan {
518
535
  }
519
536
  const entry = this.entries[this.stepIndex];
520
537
  const step = entry.step;
538
+ // Where this step sits in the flat list — what a stop reports. Taken now,
539
+ // because the cursor has moved on by the time some stops are written.
540
+ const flatIndex = this.stepIndex;
521
541
  const startedAt = Date.now();
522
542
  let computed;
523
543
  try {
@@ -529,6 +549,7 @@ export class ScenarioReplayPlan {
529
549
  stopped = {
530
550
  kind: "step_failed",
531
551
  stepIndex: step.stepIndex,
552
+ flatIndex,
532
553
  stage: "tool_input_logic",
533
554
  error: errText(err),
534
555
  };
@@ -547,7 +568,15 @@ export class ScenarioReplayPlan {
547
568
  : undefined) ?? (await recordedOutputFor?.(entry).catch(() => undefined));
548
569
  if (!fallback) {
549
570
  skipped += 1;
550
- onStep?.({ step, entry, input: computed, outcome: "skipped", error, ms: Date.now() - startedAt });
571
+ onStep?.({
572
+ step,
573
+ entry,
574
+ input: computed,
575
+ outcome: "skipped",
576
+ error,
577
+ toolMissing: isMissingToolError(error) || undefined,
578
+ ms: Date.now() - startedAt,
579
+ });
551
580
  // Advance past a step that cannot run, without threading anything.
552
581
  // The frame is still left behind it: a sub-task whose last step could
553
582
  // not run here still hands back whatever its earlier steps emitted.
@@ -558,7 +587,15 @@ export class ScenarioReplayPlan {
558
587
  response = fallback;
559
588
  recorded = true;
560
589
  recordedCount += 1;
561
- onStep?.({ step, entry, input: computed, outcome: "recorded", error, ms: Date.now() - startedAt });
590
+ onStep?.({
591
+ step,
592
+ entry,
593
+ input: computed,
594
+ outcome: "recorded",
595
+ error,
596
+ toolMissing: isMissingToolError(error) || undefined,
597
+ ms: Date.now() - startedAt,
598
+ });
562
599
  }
563
600
  if (stopOnFailure && !recorded) {
564
601
  // Judged before the output logic, which was written against a success
@@ -573,6 +610,7 @@ export class ScenarioReplayPlan {
573
610
  outcome: "failed",
574
611
  stage: "tool_call",
575
612
  error: toolError,
613
+ toolMissing: isMissingToolError(toolError) || undefined,
576
614
  ms: Date.now() - startedAt,
577
615
  });
578
616
  entries.push({
@@ -585,6 +623,7 @@ export class ScenarioReplayPlan {
585
623
  stopped = {
586
624
  kind: "step_failed",
587
625
  stepIndex: step.stepIndex,
626
+ flatIndex,
588
627
  stage: "tool_call",
589
628
  error: toolError,
590
629
  };
@@ -608,6 +647,7 @@ export class ScenarioReplayPlan {
608
647
  stopped = {
609
648
  kind: "step_failed",
610
649
  stepIndex: step.stepIndex,
650
+ flatIndex,
611
651
  stage: "tool_output_logic",
612
652
  error: errText(err),
613
653
  };
@@ -627,6 +667,7 @@ export class ScenarioReplayPlan {
627
667
  outcome: error ? "failed" : "executed",
628
668
  stage: error ? "tool_call" : undefined,
629
669
  error,
670
+ toolMissing: isMissingToolError(error) || undefined,
630
671
  derivedKeys,
631
672
  ms: Date.now() - startedAt,
632
673
  });
@@ -640,7 +681,13 @@ export class ScenarioReplayPlan {
640
681
  }
641
682
  // Every planned step ran and the plan ends in front of a parked one.
642
683
  if (!stopped && !partial && this.stopsEarly() && this.isDone()) {
643
- stopped = { kind: "known_bad_step", stepIndex: this.entries[this.stopAt].stepIndex };
684
+ // `known_bad_step` here means "in front of the plan's end": the
685
+ // controller reads the served reason and names the kind (D8).
686
+ stopped = {
687
+ kind: "known_bad_step",
688
+ stepIndex: this.entries[this.stopAt].stepIndex,
689
+ flatIndex: this.stopAt,
690
+ };
644
691
  }
645
692
  return {
646
693
  text: assembleBundle(entries, maxChars, {
@@ -17,7 +17,7 @@
17
17
  * Bump the version — on **every** side — whenever a row changes.
18
18
  */
19
19
  /** Identifies {@link MODEL_PRICING}. Must equal the service's constant. */
20
- export declare const PRICING_VERSION = "2026-08-30";
20
+ export declare const PRICING_VERSION = "2026-09-24";
21
21
  /**
22
22
  * Tolerate provider prefixes (`anthropic/`, `us.anthropic.`), date snapshots and
23
23
  * unseen version bumps by falling back to the longest matching family, so a new
@@ -17,24 +17,32 @@
17
17
  * Bump the version — on **every** side — whenever a row changes.
18
18
  */
19
19
  /** Identifies {@link MODEL_PRICING}. Must equal the service's constant. */
20
- export const PRICING_VERSION = "2026-08-30";
21
- /** Cache reads bill at 0.1x input; a 5-minute cache write at 1.25x. */
22
- const priced = (input, output) => ({
20
+ export const PRICING_VERSION = "2026-09-24";
21
+ /**
22
+ * A 5-minute cache write bills at 1.25x input. Cache reads bill at 0.1x input,
23
+ * except on models whose list price says otherwise (Opus 5.5, Fable 5.1), which
24
+ * pass their read price in dollars.
25
+ */
26
+ const priced = (input, output, cacheRead = input * 0.1) => ({
23
27
  input,
24
28
  output,
25
- cacheRead: input * 0.1,
29
+ cacheRead,
26
30
  cacheWrite: input * 1.25,
27
31
  });
28
32
  const MODEL_PRICING = {
29
33
  "claude-haiku-4-5": priced(1.0, 5.0),
30
34
  "claude-haiku-4-5-20251001": priced(1.0, 5.0),
35
+ "claude-sonnet-4-5": priced(3.0, 15.0),
31
36
  "claude-sonnet-4-6": priced(3.0, 15.0),
32
37
  "claude-sonnet-5": priced(2.0, 10.0),
38
+ "claude-opus-4-5": priced(5.0, 25.0),
33
39
  "claude-opus-4-6": priced(5.0, 25.0),
34
40
  "claude-opus-4-7": priced(5.0, 25.0),
35
41
  "claude-opus-4-8": priced(5.0, 25.0),
36
42
  "claude-opus-5": priced(5.0, 25.0),
43
+ "claude-opus-5-5": priced(4.0, 20.0, 0.2),
37
44
  "claude-fable-5": priced(10.0, 50.0),
45
+ "claude-fable-5-1": priced(10.0, 50.0, 0.25),
38
46
  };
39
47
  /**
40
48
  * Tolerate provider prefixes (`anthropic/`, `us.anthropic.`), date snapshots and