@basein/runner 0.3.8 → 0.3.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 (55) hide show
  1. package/README.md +46 -5
  2. package/dist/auth/client.d.ts +15 -0
  3. package/dist/auth/client.js +34 -12
  4. package/dist/bin/bir-hooks.d.ts +1 -1
  5. package/dist/bin/bir-hooks.js +1 -1
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +60 -6
  8. package/dist/bin/investigate.d.ts +5 -0
  9. package/dist/bin/investigate.js +117 -15
  10. package/dist/bin/setup.d.ts +12 -0
  11. package/dist/bin/setup.js +185 -56
  12. package/dist/bin/update.d.ts +81 -0
  13. package/dist/bin/update.js +194 -0
  14. package/dist/config/adapters/claude-code.d.ts +17 -0
  15. package/dist/config/adapters/claude-code.js +28 -1
  16. package/dist/config/generate.d.ts +14 -0
  17. package/dist/control/daemon.d.ts +26 -1
  18. package/dist/control/daemon.js +53 -3
  19. package/dist/control/pin-guard.d.ts +6 -1
  20. package/dist/control/pin-guard.js +15 -1
  21. package/dist/control/recorder-keeper.js +2 -1
  22. package/dist/control/server.d.ts +200 -2
  23. package/dist/control/server.js +741 -48
  24. package/dist/control/transcript.d.ts +4 -0
  25. package/dist/control/transcript.js +4 -0
  26. package/dist/proxy/session.js +1 -0
  27. package/dist/record/housekeeping.d.ts +7 -1
  28. package/dist/record/housekeeping.js +11 -3
  29. package/dist/record/recorder.d.ts +34 -0
  30. package/dist/record/remote-recorder.d.ts +14 -0
  31. package/dist/record/remote-recorder.js +107 -13
  32. package/dist/record/switchable-recorder.js +1 -0
  33. package/dist/record/truncate.d.ts +11 -0
  34. package/dist/record/truncate.js +11 -0
  35. package/dist/replay/controller.d.ts +146 -4
  36. package/dist/replay/controller.js +340 -31
  37. package/dist/replay/derive.js +2 -0
  38. package/dist/replay/handover.d.ts +13 -1
  39. package/dist/replay/handover.js +19 -0
  40. package/dist/replay/plan.d.ts +6 -0
  41. package/dist/replay/plan.js +13 -3
  42. package/dist/replay/safety.d.ts +15 -4
  43. package/dist/replay/safety.js +19 -6
  44. package/dist/replay/source-run.js +2 -1
  45. package/dist/replay/types.d.ts +16 -0
  46. package/dist/util/service-headers.d.ts +24 -0
  47. package/dist/util/service-headers.js +47 -0
  48. package/docs/BaseInstRunner.md +115 -1
  49. package/docs/calculatedReplay.md +239 -27
  50. package/docs/calculatedReplayGuide.md +134 -12
  51. package/docs/installRun.md +91 -10
  52. package/docs/quickstart.md +64 -9
  53. package/package.json +1 -1
  54. package/production.md +81 -16
  55. package/scripts/install.ps1 +25 -1
@@ -44,6 +44,10 @@ export declare function forgetTranscripts(): void;
44
44
  * learned from a tool, not from the user (segmented.md R-PARAM-2).
45
45
  *
46
46
  * "This prompt" is everything after the last user-role line that carries text.
47
+ * That is only true once the prompt is in the transcript, and at the prompt
48
+ * hook it is not yet: asked there, this returns the *previous* prompt's
49
+ * results. So the prompt hook does not ask (control/server.ts,
50
+ * `recentResultsFor`) — a prompt just submitted has no results anyway.
47
51
  * A transcript compacted mid-turn yields whatever follows that line, which may
48
52
  * be nothing; a transcript with no user prompt at all (a run opened by a proxy
49
53
  * step) yields nothing. Results with `is_error: true` are kept: a failure is
@@ -410,6 +410,10 @@ export function forgetTranscripts() {
410
410
  * learned from a tool, not from the user (segmented.md R-PARAM-2).
411
411
  *
412
412
  * "This prompt" is everything after the last user-role line that carries text.
413
+ * That is only true once the prompt is in the transcript, and at the prompt
414
+ * hook it is not yet: asked there, this returns the *previous* prompt's
415
+ * results. So the prompt hook does not ask (control/server.ts,
416
+ * `recentResultsFor`) — a prompt just submitted has no results anyway.
413
417
  * A transcript compacted mid-turn yields whatever follows that line, which may
414
418
  * be nothing; a transcript with no user prompt at all (a run opened by a proxy
415
419
  * step) yields nothing. Results with `is_error: true` are kept: a failure is
@@ -716,6 +716,7 @@ export class ProxySession {
716
716
  this.recorder = await this.buildRecorder();
717
717
  this.runId = this.recorder.startRun("", {
718
718
  recorder: "baseinstrunner",
719
+ runnerVersion: packageVersion(),
719
720
  tier: "standalone",
720
721
  host: { app: process.env.BIR_HOST_APP ?? "unknown", version: process.env.BIR_HOST_VERSION },
721
722
  wrappedServers: [this.serverName],
@@ -68,8 +68,14 @@ export declare const RUN_SCENARIO_TOOL = "mcp__bir__run_scenario";
68
68
  * Whether one call is host housekeeping: never recorded as a step, never a
69
69
  * divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
70
70
  * a `Bash` call's `command` is read from it.
71
+ *
72
+ * `otherHost` is a session whose host is not Claude Code. Its tool names are
73
+ * its own: a tool it calls `TodoWrite` or `Bash` is the application's work and
74
+ * is recorded like any other. Only the runner's own `bir` server stays
75
+ * housekeeping there. (A host with bookkeeping tools of its own does not
76
+ * report them at all.)
71
77
  */
72
- export declare function isHousekeepingCall(toolName: string, toolInput?: unknown): boolean;
78
+ export declare function isHousekeepingCall(toolName: string, toolInput?: unknown, otherHost?: boolean): boolean;
73
79
  /**
74
80
  * {@link isBirOnlyCommand} for PowerShell, whose rules differ enough that the
75
81
  * Bash reader would get it wrong: a backslash is a path separator, not an
@@ -76,12 +76,20 @@ export const RUN_SCENARIO_TOOL = `${SCENARIO_SERVER_PREFIX}run_scenario`;
76
76
  * Whether one call is host housekeeping: never recorded as a step, never a
77
77
  * divergence from a plan. `toolInput` is what the hook saw (`tool_input`); only
78
78
  * a `Bash` call's `command` is read from it.
79
+ *
80
+ * `otherHost` is a session whose host is not Claude Code. Its tool names are
81
+ * its own: a tool it calls `TodoWrite` or `Bash` is the application's work and
82
+ * is recorded like any other. Only the runner's own `bir` server stays
83
+ * housekeeping there. (A host with bookkeeping tools of its own does not
84
+ * report them at all.)
79
85
  */
80
- export function isHousekeepingCall(toolName, toolInput) {
81
- if (HOUSEKEEPING_TOOLS.has(toolName))
82
- return true;
86
+ export function isHousekeepingCall(toolName, toolInput, otherHost = false) {
83
87
  if (toolName.startsWith(SCENARIO_SERVER_PREFIX))
84
88
  return toolName !== RUN_SCENARIO_TOOL;
89
+ if (otherHost)
90
+ return false;
91
+ if (HOUSEKEEPING_TOOLS.has(toolName))
92
+ return true;
85
93
  if (toolName === "Bash" || toolName === "PowerShell") {
86
94
  const command = toolInput?.command;
87
95
  if (typeof command !== "string")
@@ -11,10 +11,31 @@ export interface StartRunOptions {
11
11
  scenarioId: string;
12
12
  stepIndex: number;
13
13
  };
14
+ /**
15
+ * The run's `framework`, when the session named a host that is not Claude
16
+ * Code: its `app`, or `app:scope` (`langchain`, `langchain:billing`). The
17
+ * service keeps a plan inside the host that recorded it by this text.
18
+ * Omitted, the recorder's own framework is sent, as before.
19
+ */
20
+ framework?: string;
14
21
  }
15
22
  export interface RunMetrics {
16
23
  costUsd?: number;
17
24
  durationMs?: number;
25
+ /**
26
+ * Why the run ended before its turn did, when it did. Sent as the run's
27
+ * `error`, cleaned and cut like any text; such a run has no final answer.
28
+ */
29
+ error?: string;
30
+ /**
31
+ * The caller's word on whether steps of this run were lost. Present only
32
+ * for a run whose finish may carry the service's `incomplete` mark: the
33
+ * recorder adds the step sends it lost itself, and sends the mark when
34
+ * either says so. A run that carries it is never calculated.
35
+ */
36
+ incomplete?: {
37
+ lossy: boolean;
38
+ };
18
39
  }
19
40
  /** How long a caller is prepared to wait for {@link Recorder.flush}. */
20
41
  export interface FlushOptions {
@@ -148,6 +169,13 @@ export interface RunMatch {
148
169
  verified?: MatchVerdict;
149
170
  /** The frozen name, beside the runtime `intent` the plan runs on (R-INTENT-12). */
150
171
  intentName?: string;
172
+ /**
173
+ * Why there is a match and no scenario to run, when the service says:
174
+ * `switched_off`, `repairing`, or `runner_update_required` — this runner is
175
+ * older than the plan needs (util/service-headers.ts). Absent or null on an
176
+ * ordinary hand-out, and from an older service.
177
+ */
178
+ withheld?: string | null;
151
179
  }
152
180
  /** Optional capability: `null` means "fresh run, keep recording". */
153
181
  export interface MatchAware {
@@ -206,6 +234,12 @@ export interface IntentMatchRequest {
206
234
  acceptSegments?: boolean;
207
235
  /** Always true from this runner (R-CALL-28). */
208
236
  supportsCalls?: boolean;
237
+ /**
238
+ * The host the turn runs in, as its runs' `framework` says it (see
239
+ * {@link StartRunOptions.framework}). Left out for Claude Code, which is
240
+ * what a missing value means to the service.
241
+ */
242
+ framework?: string;
209
243
  }
210
244
  /** Which text a step's intent came from. */
211
245
  export type IntentSourceName = "text" | "thinking" | "tool";
@@ -69,6 +69,14 @@ export declare const TEXT_CAPS: {
69
69
  /** An execution report's error lines, rendered in a web page. */
70
70
  readonly reportError: number;
71
71
  };
72
+ /**
73
+ * The most an execution report's request may weigh. The service reads this
74
+ * route with its 100 kB JSON parser — only `/recordings` has the larger one —
75
+ * and a report it refuses for its size loses the turn's cost with it. So the
76
+ * steps' tool payloads get what is left of this after everything else in the
77
+ * report: the costs, the prompt, the verdicts.
78
+ */
79
+ export declare const REPORT_MAX_BYTES: number;
72
80
  /** Queued sends past which the oldest unsent step is dropped (`BIR_RECORDER_MAX_PENDING`). */
73
81
  export declare const DEFAULT_MAX_PENDING_SENDS = 500;
74
82
  /** Queued bytes past which the same happens (`BIR_RECORDER_MAX_PENDING_BYTES`). */
@@ -207,6 +215,12 @@ export declare class RemoteRecorder implements Recorder, RecorderHealthReporter
207
215
  /** Backfill a step's `context` (the model's reasoning) after the fact. */
208
216
  backfillContext(runId: string, stepId: string, context: string): void;
209
217
  finishRun(runId: string, finalOutput?: string, metrics?: RunMetrics): void;
218
+ /**
219
+ * The service's mark for a run that is not whole, or undefined for one that
220
+ * is. Read when the finish is sent, not when it was queued: a step dropped
221
+ * or refused in between is as lost as one before.
222
+ */
223
+ private incompleteOf;
210
224
  /**
211
225
  * Report what a matched turn cost (docs/calculatedReplay.md §11.2).
212
226
  *
@@ -50,9 +50,10 @@ import { randomUUID } from "node:crypto";
50
50
  import { statSync } from "node:fs";
51
51
  import { credentialsPath, describeBody, isAuthResponse, issuedFor, loadCredentials, saveCredentials, } from "../auth/client.js";
52
52
  import { errText, logDetail, logEvery, logLine } from "../util/log.js";
53
+ import { serviceHeaders } from "../util/service-headers.js";
53
54
  import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
54
55
  import { redactJsonText, redactText } from "./redact.js";
55
- import { capJsonText, capText, DEFAULT_MAX_PAYLOAD_BYTES, preCut } from "./truncate.js";
56
+ import { capJsonText, capText, DEFAULT_MAX_PAYLOAD_BYTES, preCut, REPORT_PAYLOAD_MAX_BYTES, } from "./truncate.js";
56
57
  /**
57
58
  * Caps for the free text this recorder sends, in UTF-8 bytes. Tool payloads
58
59
  * arrive capped at 64 KiB by their producers; text never passed through one,
@@ -73,6 +74,14 @@ export const TEXT_CAPS = {
73
74
  /** An execution report's error lines, rendered in a web page. */
74
75
  reportError: 2 * 1024,
75
76
  };
77
+ /**
78
+ * The most an execution report's request may weigh. The service reads this
79
+ * route with its 100 kB JSON parser — only `/recordings` has the larger one —
80
+ * and a report it refuses for its size loses the turn's cost with it. So the
81
+ * steps' tool payloads get what is left of this after everything else in the
82
+ * report: the costs, the prompt, the verdicts.
83
+ */
84
+ export const REPORT_MAX_BYTES = 96 * 1024;
76
85
  /** Queued sends past which the oldest unsent step is dropped (`BIR_RECORDER_MAX_PENDING`). */
77
86
  export const DEFAULT_MAX_PENDING_SENDS = 500;
78
87
  /** Queued bytes past which the same happens (`BIR_RECORDER_MAX_PENDING_BYTES`). */
@@ -177,6 +186,61 @@ function sizeOf(...parts) {
177
186
  n += p?.text.length ?? 0;
178
187
  return n;
179
188
  }
189
+ function pendStep(step) {
190
+ return {
191
+ step,
192
+ toolInput: pend(step.toolInput, REPORT_PAYLOAD_MAX_BYTES, true),
193
+ toolOutput: pend(step.toolOutput, REPORT_PAYLOAD_MAX_BYTES, true),
194
+ };
195
+ }
196
+ /**
197
+ * An execution report's request body: `fields`, and its steps as sent — each
198
+ * error and each tool payload capped and redacted, like every other field here.
199
+ *
200
+ * The payloads are what makes a report heavy, so they are added last and only
201
+ * while the whole body stays within {@link REPORT_MAX_BYTES}: the failed
202
+ * steps' first — theirs are the ones somebody will read — then every step's
203
+ * input, then the outputs, each in the order the steps ran. A payload that
204
+ * does not fit is left out; its step keeps its verdict, and the costs the
205
+ * report exists for are never what gives way.
206
+ */
207
+ function reportBody(fields, steps) {
208
+ if (!steps)
209
+ return JSON.stringify(fields);
210
+ const sent = steps.map(({ step }) => ({
211
+ ...step,
212
+ error: typeof step.error === "string"
213
+ ? capText(step.error, TEXT_CAPS.reportError, redactText)
214
+ : step.error,
215
+ toolInput: undefined,
216
+ toolOutput: undefined,
217
+ }));
218
+ const bare = Buffer.byteLength(JSON.stringify({ ...fields, steps: sent }), "utf8");
219
+ let left = REPORT_MAX_BYTES - bare;
220
+ const add = (i, key) => {
221
+ if (sent[i][key] !== undefined)
222
+ return;
223
+ const text = out(steps[i][key]);
224
+ if (text === undefined)
225
+ return;
226
+ // The value as a JSON string, and `,"<key>":` in front of it.
227
+ const size = Buffer.byteLength(JSON.stringify(text), "utf8") + key.length + 4;
228
+ if (size > left)
229
+ return;
230
+ sent[i][key] = text;
231
+ left -= size;
232
+ };
233
+ const all = sent.map((_, i) => i);
234
+ for (const i of all.filter((n) => sent[n].status === "failed")) {
235
+ add(i, "toolInput");
236
+ add(i, "toolOutput");
237
+ }
238
+ for (const i of all)
239
+ add(i, "toolInput");
240
+ for (const i of all)
241
+ add(i, "toolOutput");
242
+ return JSON.stringify({ ...fields, steps: sent });
243
+ }
180
244
  /** A tool error is JSON when a producer serialized it, and prose when it did not. */
181
245
  function pendError(text) {
182
246
  if (typeof text !== "string")
@@ -402,7 +466,8 @@ export class RemoteRecorder {
402
466
  try {
403
467
  const body = await this.request("POST", "/recordings/runs", JSON.stringify({
404
468
  id,
405
- framework: this.framework,
469
+ // The session's own host, when it named one that is not Claude Code.
470
+ framework: options?.framework ?? this.framework,
406
471
  input: out(prompt),
407
472
  metadata,
408
473
  // A fragment (fallbk.md D7). Omitted, not null, so an older service
@@ -586,12 +651,30 @@ export class RemoteRecorder {
586
651
  }
587
652
  finishRun(runId, finalOutput, metrics) {
588
653
  const output = pend(finalOutput, TEXT_CAPS.answer);
589
- this.enqueueRun(runId, "finish", sizeOf(output), () => this.post(`/recordings/runs/${runId}/finish`, {
654
+ // An exception's text, more often than not: it can hold a password or a
655
+ // user's data, so it is cleaned and cut like every other text sent.
656
+ const error = pend(metrics?.error, TEXT_CAPS.reportError);
657
+ const asked = metrics?.incomplete;
658
+ this.enqueueRun(runId, "finish", sizeOf(output, error), () =>
659
+ // `error` and `incomplete` are omitted, not nulled, when absent, so the
660
+ // finish of an ordinary run is the body it has always been.
661
+ this.post(`/recordings/runs/${runId}/finish`, {
590
662
  finalOutput: out(output),
663
+ error: out(error),
591
664
  originalCostUsd: metrics?.costUsd,
592
665
  originalMs: metrics?.durationMs,
666
+ incomplete: asked ? this.incompleteOf(runId, asked.lossy) : undefined,
593
667
  }));
594
668
  }
669
+ /**
670
+ * The service's mark for a run that is not whole, or undefined for one that
671
+ * is. Read when the finish is sent, not when it was queued: a step dropped
672
+ * or refused in between is as lost as one before.
673
+ */
674
+ incompleteOf(runId, lossy) {
675
+ const lostSteps = this.runs.get(runId)?.lostSteps ?? 0;
676
+ return lossy || lostSteps > 0 ? { lostSteps, lossy } : undefined;
677
+ }
595
678
  /**
596
679
  * Report what a matched turn cost (docs/calculatedReplay.md §11.2).
597
680
  *
@@ -609,12 +692,21 @@ export class RemoteRecorder {
609
692
  const costUsd = report.deriveCostUsd + report.sessionCostUsd + report.fallbackCostUsd;
610
693
  const prompt = pend(report.prompt, TEXT_CAPS.prompt);
611
694
  const error = pend(report.error, TEXT_CAPS.reportError);
695
+ const steps = report.steps?.map(pendStep);
612
696
  this.enqueue({
613
697
  lane: EXECUTIONS,
614
698
  kind: "report",
615
- bytes: sizeOf(prompt, error) + 1024,
699
+ bytes: sizeOf(prompt, error, ...(steps ?? []).flatMap((s) => [s.toolInput, s.toolOutput])) + 1024,
616
700
  send: async () => {
617
- const body = await this.request("POST", `/scenarios/${report.scenarioId}/executions`, JSON.stringify({
701
+ const body = await this.request("POST", `/scenarios/${report.scenarioId}/executions`,
702
+ // Per-step verdicts and the headline failure (errorshandling.md) ride
703
+ // beside the costs. All optional on the service side, so an older
704
+ // service ignores them rather than rejecting the report — and the
705
+ // costs still book. Errors are shown on a web page, so they are
706
+ // capped and redacted like any text; so is what each step sent and
707
+ // received, which the page shows under the verdict in place of the
708
+ // recording's sample (`reportBody`).
709
+ reportBody({
618
710
  ticket: report.ticket,
619
711
  outcome: report.outcome,
620
712
  costUsd,
@@ -627,11 +719,6 @@ export class RemoteRecorder {
627
719
  measured: report.measured,
628
720
  pricingVersion: report.pricingVersion,
629
721
  prompt: out(prompt),
630
- // Per-step verdicts and the headline failure (errorshandling.md). All
631
- // optional on the service side, so an older service ignores them rather
632
- // than rejecting the report — and the costs still book. Errors are
633
- // shown on a web page, so they are capped and redacted like any text.
634
- steps: report.steps?.map((s) => typeof s.error === "string" ? { ...s, error: capText(s.error, TEXT_CAPS.reportError, redactText) } : s),
635
722
  error: out(error),
636
723
  errorStage: report.errorStage,
637
724
  errorStepIndex: report.errorStepIndex,
@@ -643,7 +730,7 @@ export class RemoteRecorder {
643
730
  // Why nothing ran, when nothing ran. Additive: an older service drops
644
731
  // the field and books the cost exactly as it always did.
645
732
  declined: report.declined,
646
- }), { retryTransient: Boolean(report.ticket) });
733
+ }, steps), { retryTransient: Boolean(report.ticket) });
647
734
  const r = (body ?? {});
648
735
  const failed = report.steps?.filter((s) => s.status === "failed").length ?? 0;
649
736
  logLine("execution.reported", {
@@ -738,6 +825,7 @@ export class RemoteRecorder {
738
825
  lastUsed: Date.now(),
739
826
  failed: 0,
740
827
  dropped: 0,
828
+ lostSteps: 0,
741
829
  };
742
830
  return entry;
743
831
  }
@@ -971,8 +1059,11 @@ export class RemoteRecorder {
971
1059
  // A matched turn records nothing by design: nothing was lost.
972
1060
  if (why !== "not_created") {
973
1061
  this.droppedSends += 1;
974
- if (run)
1062
+ if (run) {
975
1063
  run.dropped += 1;
1064
+ if (job.kind === "step")
1065
+ run.lostSteps += 1;
1066
+ }
976
1067
  }
977
1068
  if (job.kind === "finish" && run && job.runId)
978
1069
  this.summarize(job.runId, run);
@@ -1004,6 +1095,8 @@ export class RemoteRecorder {
1004
1095
  const status = err instanceof ServiceError ? err.status : undefined;
1005
1096
  if (run && job.runId) {
1006
1097
  run.failed += 1;
1098
+ if (job.kind === "step")
1099
+ run.lostSteps += 1;
1007
1100
  if (job.kind === "create") {
1008
1101
  // A definite refusal means there is no run; an ambiguous failure (a
1009
1102
  // timeout, a 5xx) may have created it, and the first step will say.
@@ -1302,6 +1395,7 @@ export class RemoteRecorder {
1302
1395
  headers: {
1303
1396
  ...(body !== undefined ? { "content-type": "application/json" } : {}),
1304
1397
  ...(opts.anonymous ? {} : { authorization: `Bearer ${token}` }),
1398
+ ...serviceHeaders(),
1305
1399
  },
1306
1400
  body,
1307
1401
  signal,
@@ -1529,7 +1623,7 @@ export class RemoteRecorder {
1529
1623
  try {
1530
1624
  res = await fetch(`${this.baseUrl}/auth/refresh`, {
1531
1625
  method: "POST",
1532
- headers: { "content-type": "application/json" },
1626
+ headers: { "content-type": "application/json", ...serviceHeaders() },
1533
1627
  body: JSON.stringify({ refreshToken: this.session.refreshToken }),
1534
1628
  signal,
1535
1629
  });
@@ -70,6 +70,7 @@ const INTERNAL = new Set([
70
70
  "entryFor",
71
71
  "execute",
72
72
  "healthyNow",
73
+ "incompleteOf",
73
74
  "laneIsIdle",
74
75
  "markDead",
75
76
  "mayHotWait",
@@ -20,6 +20,17 @@
20
20
  export declare const DEFAULT_MAX_PAYLOAD_BYTES: number;
21
21
  /** 8 KiB per individual string leaf. */
22
22
  export declare const DEFAULT_MAX_STRING_BYTES: number;
23
+ /**
24
+ * What one tool payload may take inside an execution report. A report carries
25
+ * every step of a replay in one small request, so a step's input or output
26
+ * gets far less there than the 64 KiB a recorded step has to itself.
27
+ *
28
+ * 3,800 bytes, not a round 4 KiB: the service keeps 4,000 characters of each,
29
+ * and a payload cut here still fits that with its marker. So the scenario's
30
+ * page shows this cut, which says how large the payload was, and never a
31
+ * second one made on top of it.
32
+ */
33
+ export declare const REPORT_PAYLOAD_MAX_BYTES = 3800;
23
34
  export interface TruncateOptions {
24
35
  maxPayloadBytes?: number;
25
36
  maxStringBytes?: number;
@@ -20,6 +20,17 @@
20
20
  export const DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024;
21
21
  /** 8 KiB per individual string leaf. */
22
22
  export const DEFAULT_MAX_STRING_BYTES = 8 * 1024;
23
+ /**
24
+ * What one tool payload may take inside an execution report. A report carries
25
+ * every step of a replay in one small request, so a step's input or output
26
+ * gets far less there than the 64 KiB a recorded step has to itself.
27
+ *
28
+ * 3,800 bytes, not a round 4 KiB: the service keeps 4,000 characters of each,
29
+ * and a payload cut here still fits that with its marker. So the scenario's
30
+ * page shows this cut, which says how large the payload was, and never a
31
+ * second one made on top of it.
32
+ */
33
+ export const REPORT_PAYLOAD_MAX_BYTES = 3_800;
23
34
  const utf8 = (s) => Buffer.byteLength(s, "utf8");
24
35
  /** The marker {@link truncateString} appends, so a second cut can report the first size. */
25
36
  const MARKER = /… \[truncated \d+ of (\d+) bytes\]$/;
@@ -16,6 +16,7 @@ import { type ReplayMode, type StepReach } from "./coverage.js";
16
16
  import { deriveParameters, type DeriveContext } from "./derive.js";
17
17
  import { ProxyWorkQueue } from "./executor.js";
18
18
  import { ScenarioReplayPlan } from "./plan.js";
19
+ import { type HostSinks } from "./safety.js";
19
20
  import { SourceRunOutputs } from "./source-run.js";
20
21
  import { type FlatStop } from "./flatten.js";
21
22
  import { type ExecutionOutcome, type ExecutionStepResult, type FallbackKind, type SerializedScenario, type SerializedScenarioStep } from "./types.js";
@@ -99,6 +100,57 @@ export interface PostToolOptions {
99
100
  uncertain?: boolean;
100
101
  /** The call was never sent: its upstream was already gone. Nothing of it happened. */
101
102
  notSent?: boolean;
103
+ /**
104
+ * A host that is not Claude Code stopped the step itself, and says so: the
105
+ * tool never ran and the host wrote its result (`rejected` — a person said
106
+ * no or answered in its place, or a call limit blocked it), or it ran with
107
+ * arguments a person chose (`edited`). Neither is a fault of the plan: the
108
+ * step is recorded `skipped`, never `failed`, and the plan hands over.
109
+ */
110
+ hostStop?: HostStop;
111
+ }
112
+ /** How a host that is not Claude Code stopped a planned step (see {@link PostToolOptions.hostStop}). */
113
+ export type HostStop = "rejected" | "edited";
114
+ /**
115
+ * What the control server knows of a session whose host is not Claude Code —
116
+ * a LangChain application, say — handed to {@link ReplayController.arm} and
117
+ * kept on the plan's state.
118
+ *
119
+ * In such a host the tools are the application's own and only it can run
120
+ * them: it steps through the plan by asking for each step's arguments, and
121
+ * its tool names carry none of Claude Code's meanings (`Bash`, `Read`,
122
+ * `TodoWrite`, an `mcp__` prefix). Most of what replay decides by a tool's
123
+ * name is decided where there is no session to ask, so the fact travels with
124
+ * the plan.
125
+ */
126
+ export interface HostContext {
127
+ /** The host's `app`, for the audit lines (`langchain`). */
128
+ app: string;
129
+ /**
130
+ * The sinks the host declared for its own tools. Always present, empty or
131
+ * not: being there is what tells the safety rules whose names these are
132
+ * (safety.ts, `HostSinks`).
133
+ */
134
+ sinks: HostSinks;
135
+ /**
136
+ * The run's adapter did not ask for a plan with its prompt: it only
137
+ * records. A match is then declined at gate 2, under its own code.
138
+ */
139
+ recordsOnly?: boolean;
140
+ }
141
+ /**
142
+ * An armed plan as data, for a host that steps through it itself. The same
143
+ * steps the directive lists — those the plan will run — by their position.
144
+ */
145
+ export interface PlanData {
146
+ scenarioId: string | null;
147
+ mode: ReplayMode;
148
+ steps: Array<{
149
+ stepIndex: number;
150
+ toolName: string;
151
+ }>;
152
+ /** Only when the plan stops before its end: the first step that will not run. */
153
+ stopAt?: number;
102
154
  }
103
155
  /**
104
156
  * The longest a direct run may take by its budgets, end to end.
@@ -273,6 +325,12 @@ interface PinnedCall {
273
325
  toolName: string;
274
326
  /** When it was pinned — a steered step's duration is measured across hooks. */
275
327
  pinnedAt: number;
328
+ /**
329
+ * The arguments the pin gave the call, for the step's verdict: the plan
330
+ * never sees a steered call, so this is the only place they are known when
331
+ * its result lands.
332
+ */
333
+ input?: Record<string, unknown>;
276
334
  /**
277
335
  * When the host said the call finished (`PostToolUse`), if the control
278
336
  * server told us ({@link ReplayController.hostCompleted}). A wrapped step
@@ -425,12 +483,31 @@ export interface ReplayState {
425
483
  * is waiting on the service, and obeys its breaker (util/service-health.ts).
426
484
  */
427
485
  deriveOnService?: boolean;
486
+ /**
487
+ * Set when the session's host is not Claude Code (see {@link HostContext}).
488
+ * Such a plan is always `steer` with every step `live`, and nothing here
489
+ * runs a step of it or reads a Claude Code meaning into a tool's name.
490
+ */
491
+ host?: HostContext;
492
+ /**
493
+ * The host stopped a planned step itself ({@link PostToolOptions.hostStop}).
494
+ * It decides what the report may claim: after `rejected` the task ended
495
+ * early, so the turn is no measure of it; after either, its cost is no
496
+ * sample of what the task costs unaided.
497
+ */
498
+ hostStop?: HostStop;
428
499
  }
429
500
  /**
430
501
  * Why a match did not arm. One code per gate of the ladder, so the caller can
431
502
  * act on a decline without parsing the sentence that explains it.
432
503
  */
433
- export type DeclineCode = "replay_disabled" | "not_ready" | "flatten_failed" | "known_bad_first_step" | "nondeterministic_first_step" | "unusable_first_step" | "coverage" | "missing_target" | "no_derive_key";
504
+ export type DeclineCode = "replay_disabled" | "not_ready" | "flatten_failed" | "known_bad_first_step" | "nondeterministic_first_step" | "unusable_first_step" | "coverage" | "missing_target" | "no_derive_key"
505
+ /** The service handed over no plan: this runner is too old for it. */
506
+ | "runner_update_required"
507
+ /** The session's host only records: its adapter asked for no plan with the prompt. */
508
+ | "host_records_only"
509
+ /** The plan names a tool the host's agent does not hold. */
510
+ | "host_tool_missing";
434
511
  /** What `PreToolUse` should do about this call. */
435
512
  export type PreToolAction =
436
513
  /** Pin the arguments and let the real tool run. */
@@ -509,8 +586,46 @@ export declare class ReplayController {
509
586
  * Always returns a state — a declined match still has a ticket to redeem and a
510
587
  * baseline sample to contribute, and losing that is how a savings ledger ends
511
588
  * up with a denominator nobody measured.
589
+ *
590
+ * `host` is given for a session whose host is not Claude Code, and is kept
591
+ * on the state (see {@link HostContext}).
592
+ */
593
+ arm(match: RunMatchLike, prompt: string, wrapped: ReadonlySet<string>, armedBy?: "prompt" | "intent", ctx?: DeriveContext, host?: HostContext): Promise<ReplayState>;
594
+ /**
595
+ * The armed plan as data: the steps it will run, in order, and where it
596
+ * stops when that is before its end. For a host that steps through the plan
597
+ * itself and must not have to pick the tool names out of a directive
598
+ * written for a model — a reworded sentence would break every adapter in
599
+ * the field. Built from the same entries the directive lists.
600
+ */
601
+ planData(state: ReplayState): PlanData | undefined;
602
+ /**
603
+ * Take back a plan that armed and has run nothing, as a decline.
604
+ *
605
+ * For what `arm` could not know: which tools the agent of another host
606
+ * holds. Such a host shows its tools only when its model is about to be
607
+ * called, with the first request for a step, and a plan that names a tool
608
+ * the agent lacks cannot be finished there. It ends before any step runs,
609
+ * booked like any gate's decline: `not_steered` under `code`, no step
610
+ * marked failed or missing — either would count against a plan that is
611
+ * not at fault. Returns false, changing nothing, once a step has run.
512
612
  */
513
- arm(match: RunMatchLike, prompt: string, wrapped: ReadonlySet<string>, armedBy?: "prompt" | "intent", ctx?: DeriveContext): Promise<ReplayState>;
613
+ declineArmed(state: ReplayState, code: DeclineCode, why: string): boolean;
614
+ /**
615
+ * Hand a live plan over for something its host did, not the plan: the
616
+ * model made a call of its own while the plan was live (`own_call`), the
617
+ * run was closed with the plan still live (`closed`), or the host was
618
+ * answered without a step's arguments because they were not ready in time
619
+ * (`late`).
620
+ *
621
+ * Only for a host that is not Claude Code, where a call the plan did not
622
+ * hand out is by definition the model's own — it is never pinned and never
623
+ * answered with a bundle. No step is marked failed, so nothing is counted
624
+ * against the plan; the outcome is `fell_back` when a step ran and `failed`
625
+ * when none did, as for every hand-over. The note is the caller's to
626
+ * deliver ({@link takeNote}). Returns false when there was no live plan.
627
+ */
628
+ hostHandOver(state: ReplayState, why: "own_call" | "closed" | "late"): boolean;
514
629
  /**
515
630
  * The directive to inject via `additionalContext`, or undefined when declined.
516
631
  *
@@ -553,8 +668,12 @@ export declare class ReplayController {
553
668
  * Deliver text to the model in place of the tool call it was about to make:
554
669
  * as genuine command output for `Bash`, as the denial reason otherwise — the
555
670
  * two channels a divergence bundle already uses.
671
+ *
672
+ * `otherHost` is a session whose host is not Claude Code: a tool it calls
673
+ * `Bash` is its own, and its command is never rewritten. The text is always
674
+ * the refusal reason there.
556
675
  */
557
- deliverInstead(text: string, toolName: string): PreToolAction;
676
+ deliverInstead(text: string, toolName: string, otherHost?: boolean): PreToolAction;
558
677
  /**
559
678
  * `PreToolUse`, while a plan is active.
560
679
  *
@@ -633,6 +752,13 @@ export declare class ReplayController {
633
752
  * moves from `failed` to `fell_back` when it is the first that did.
634
753
  */
635
754
  private lateThread;
755
+ /**
756
+ * Record a step its host stopped ({@link PostToolOptions.hostStop}) as
757
+ * `skipped`, in plain words, and remember the stop for the report. Never
758
+ * `failed`: the service counts failures against the plan. And not counted
759
+ * in `stepsPinned`: the plan's own call did not run.
760
+ */
761
+ private hostStopped;
636
762
  /** Whether a pinned call's output is threaded from the proxy's report (§7.2). */
637
763
  threadsFromProxy(state: ReplayState, toolUseId: string): boolean;
638
764
  /**
@@ -679,6 +805,13 @@ export declare class ReplayController {
679
805
  siblings?: readonly ReplayState[];
680
806
  /** The control server's own run id for this turn — journal-only, never sent. */
681
807
  runId?: string;
808
+ /**
809
+ * The run was closed before its task ended for the caller — it failed,
810
+ * went quiet, or stopped with the plan still live. Its cost is no
811
+ * measure of the task: reported unmeasured, and never a sample of the
812
+ * usual cost.
813
+ */
814
+ cutShort?: boolean;
682
815
  }): ExecutionReport | undefined;
683
816
  /**
684
817
  * The step a failed replay is fairly blamed on: the first whose own logic
@@ -854,7 +987,14 @@ export declare class ReplayController {
854
987
  * never be told something the log does not also say.
855
988
  */
856
989
  private observeStep;
857
- /** Upsert one step's verdict. Later news about a step replaces earlier news. */
990
+ /**
991
+ * Upsert one step's verdict. Later news about a step replaces earlier news.
992
+ *
993
+ * The verdict keeps what the step sent and what came back, when the caller
994
+ * has them. They are this run's, which the service can learn nowhere else:
995
+ * a matched turn is not recorded, so without them the scenario's page shows
996
+ * the recording's input and output beside this run's verdict.
997
+ */
858
998
  private recordStep;
859
999
  /** The step verdicts, in chain order, for the execution report. */
860
1000
  private stepResultsOf;
@@ -880,6 +1020,8 @@ export interface RunMatchLike {
880
1020
  verified?: MatchVerdict;
881
1021
  /** The frozen name, beside the runtime intent the plan runs on (R-INTENT-12). */
882
1022
  intentName?: string;
1023
+ /** Why the service handed over no plan, when it says. Read for `runner_update_required`. */
1024
+ withheld?: string | null;
883
1025
  }
884
1026
  /**
885
1027
  * The old rule, for a service that sends no `stop`: the position of the first