@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
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { redactText } from "../record/redact.js";
34
34
  import { isUnhealthyStatus, serviceHealth, timeoutSignal } from "../util/service-health.js";
35
+ import { serviceHeaders } from "../util/service-headers.js";
35
36
  import { calculateCostUsd } from "./pricing.js";
36
37
  export const DEFAULT_DERIVE_MODEL = "claude-haiku-4-5-20251001";
37
38
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
@@ -311,6 +312,7 @@ async function deriveViaService(opts, service, schema, keys) {
311
312
  headers: {
312
313
  "content-type": "application/json",
313
314
  authorization: `Bearer ${service.token}`,
315
+ ...serviceHeaders(),
314
316
  },
315
317
  body: JSON.stringify({
316
318
  prompt: outgoingPrompt(opts.prompt),
@@ -45,7 +45,19 @@ export type HandoverStopReason =
45
45
  /** The run was cancelled before it. */
46
46
  | "cancelled"
47
47
  /** Another of the plan's calls was still in flight (steer mode runs one at a time). */
48
- | "concurrent";
48
+ | "concurrent"
49
+ /**
50
+ * A host that is not Claude Code wrote the step's result itself, and its
51
+ * tool never ran: a person said no, a person answered in the tool's place,
52
+ * or a call limit blocked the call. Not a fault of the plan.
53
+ */
54
+ | "host_rejected"
55
+ /**
56
+ * The step's tool ran in such a host, with arguments a person chose in
57
+ * place of the plan's. Its result is real; the plan's later steps were
58
+ * computed for the call it planned, so it stops there.
59
+ */
60
+ | "host_edited";
49
61
  export interface HandoverNoteInput {
50
62
  intent: string;
51
63
  kind: FallbackKind;
@@ -56,6 +56,12 @@ export function buildHandoverNote(n) {
56
56
  // says it may have completed.
57
57
  lines.push("No step of the scenario is known to have finished; the task is yours.");
58
58
  }
59
+ else if (n.stopReason === "host_rejected" || n.stopReason === "host_edited") {
60
+ // The first step was answered by the host, or ran with a person's
61
+ // arguments: "the whole task is yours" would invite the model to do that
62
+ // step over, which the next line tells it not to.
63
+ lines.push("No step of the scenario ran as it was planned.");
64
+ }
59
65
  else {
60
66
  lines.push("No step of the scenario ran; the whole task is yours.");
61
67
  }
@@ -109,6 +115,19 @@ export function buildHandoverNote(n) {
109
115
  function stepFailedLine(n, human, tool) {
110
116
  const at = `Step ${human} of ${n.totalSteps}${tool}`;
111
117
  const detail = n.error ? truncate(n.error, MAX_ERROR_CHARS) : "";
118
+ // The two stops a host that is not Claude Code reports, first: neither is
119
+ // a failure, and neither is the model's to make good.
120
+ if (n.stopReason === "host_rejected") {
121
+ // One sentence for three causes — a person said no, a person answered in
122
+ // the tool's place, a call limit blocked the call — so it names none of
123
+ // them, and never says "do it yourself": the person may have meant no.
124
+ return (`${at} was not run by the host. The result above was written by the host, not by the tool. ` +
125
+ "Do not run the step again unless the user asks.");
126
+ }
127
+ if (n.stopReason === "host_edited") {
128
+ return (`${at} ran with arguments a person chose, not the scenario's. Its result above is real ` +
129
+ "and its side effects are in place. Do not run it again.");
130
+ }
112
131
  if (n.ranThisStep) {
113
132
  // Its tool ran and did its work; only the scenario's reading of the result
114
133
  // broke. A model told "failed" here runs the write again.
@@ -105,6 +105,12 @@ export interface StepInfo {
105
105
  */
106
106
  entry?: FlatEntry;
107
107
  input: Record<string, unknown>;
108
+ /**
109
+ * What the step's output logic was given: the tool's answer, or for
110
+ * `recorded` the source run's output that stood in for it. Absent when the
111
+ * call produced none — `skipped`, or a failure before the tool ran.
112
+ */
113
+ output?: string;
108
114
  outcome: "executed" | "recorded" | "skipped" | "failed";
109
115
  /** Which part of the step broke. Only set for `failed`. */
110
116
  stage?: ExecutionStage;
@@ -215,8 +215,11 @@ function settingsOf(paramsObject, mapped) {
215
215
  * still abandons the whole bundle rather than shipping a half-computed one. All
216
216
  * this adds is that the failure is now *attributable* — "step 2's tool input
217
217
  * logic", rather than a stack trace the console cannot line up against a chain.
218
+ *
219
+ * `output` is what the body was given, for a stage that runs on one: a throw
220
+ * in the output logic is only readable next to the output it threw on.
218
221
  */
219
- function reportingStage(onStep, entry, stage, input, startedAt, fn) {
222
+ function reportingStage(onStep, entry, stage, input, startedAt, fn, output) {
220
223
  try {
221
224
  return fn();
222
225
  }
@@ -225,6 +228,7 @@ function reportingStage(onStep, entry, stage, input, startedAt, fn) {
225
228
  step: entry.step,
226
229
  entry,
227
230
  input,
231
+ output,
228
232
  outcome: "failed",
229
233
  stage,
230
234
  error: err instanceof Error ? err.message : String(err),
@@ -817,6 +821,7 @@ export class ScenarioReplayPlan {
817
821
  step,
818
822
  entry,
819
823
  input: computed,
824
+ output: response,
820
825
  outcome: "failed",
821
826
  stage: "tool_call",
822
827
  error: toolError,
@@ -845,7 +850,7 @@ export class ScenarioReplayPlan {
845
850
  Object.assign(resp.get(entry.frame.id) ?? {}, derived);
846
851
  }
847
852
  this.leaveFrame(entry, resp);
848
- });
853
+ }, response);
849
854
  }
850
855
  catch (err) {
851
856
  if (!stopOnFailure)
@@ -872,6 +877,7 @@ export class ScenarioReplayPlan {
872
877
  step,
873
878
  entry,
874
879
  input: computed,
880
+ output: response,
875
881
  outcome: error ? "failed" : "executed",
876
882
  stage: error ? "tool_call" : undefined,
877
883
  error,
@@ -977,6 +983,7 @@ export class ScenarioReplayPlan {
977
983
  step,
978
984
  entry,
979
985
  input: computed,
986
+ output: response,
980
987
  outcome: "failed",
981
988
  stage: "tool_call",
982
989
  error: toolError,
@@ -997,7 +1004,7 @@ export class ScenarioReplayPlan {
997
1004
  }
998
1005
  let derivedKeys;
999
1006
  try {
1000
- derivedKeys = reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => this.applyOutput(response));
1007
+ derivedKeys = reportingStage(onStep, entry, "tool_output_logic", computed, startedAt, () => this.applyOutput(response), response);
1001
1008
  }
1002
1009
  catch (err) {
1003
1010
  if (!stopOnFailure)
@@ -1024,6 +1031,7 @@ export class ScenarioReplayPlan {
1024
1031
  step,
1025
1032
  entry,
1026
1033
  input: computed,
1034
+ output: response,
1027
1035
  outcome: error ? "failed" : "executed",
1028
1036
  stage: error ? "tool_call" : undefined,
1029
1037
  error,
@@ -1095,6 +1103,8 @@ export class ScenarioReplayPlan {
1095
1103
  step,
1096
1104
  entry,
1097
1105
  input: computed,
1106
+ // The stand-in is what the step's output logic runs on from here.
1107
+ output: standIn,
1098
1108
  outcome: standIn !== undefined ? "recorded" : "skipped",
1099
1109
  error,
1100
1110
  toolMissing: isMissingToolError(error) || undefined,
@@ -43,16 +43,27 @@ import type { ParamsSchema } from "./types.js";
43
43
  * or a database, `write` is only written into a file.
44
44
  */
45
45
  export type SinkKind = "command" | "write";
46
+ /**
47
+ * The sinks a host that is not Claude Code declared for its own tools, by
48
+ * tool name. Passing one — even an empty one — says the tool names are that
49
+ * host's: `Bash`, `Edit` and `mcp__server__tool` then mean nothing by
50
+ * themselves. A tool the host declared is what it was declared; any other is
51
+ * judged by the words of its plain name (`run_sql`, `execQuery`).
52
+ */
53
+ export interface HostSinks {
54
+ command?: readonly string[];
55
+ write?: readonly string[];
56
+ }
46
57
  /** Whether this tool's input is run by a shell. */
47
- export declare function isShellTool(toolName: string | null | undefined): boolean;
58
+ export declare function isShellTool(toolName: string | null | undefined, host?: HostSinks): boolean;
48
59
  /** How this tool consumes its input, or undefined when it is neither run nor written. */
49
- export declare function sinkOf(toolName: string | null | undefined): SinkKind | undefined;
60
+ export declare function sinkOf(toolName: string | null | undefined, host?: HostSinks): SinkKind | undefined;
50
61
  /** Whether this tool's input is run or written: a shell, a file edit, a query. */
51
62
  export declare function isCommandSink(toolName: string | null | undefined): boolean;
52
63
  /** The strictest sink among the planned steps, or undefined when there is none. */
53
- export declare function sinkOfPlan(entries: readonly FlatEntry[]): SinkKind | undefined;
64
+ export declare function sinkOfPlan(entries: readonly FlatEntry[], host?: HostSinks): SinkKind | undefined;
54
65
  /** Whether any planned step runs its input through a shell. */
55
- export declare function hasShellStep(entries: readonly FlatEntry[]): boolean;
66
+ export declare function hasShellStep(entries: readonly FlatEntry[], host?: HostSinks): boolean;
56
67
  /** Whether any planned step's input is run or written. */
57
68
  export declare function hasCommandSink(entries: readonly FlatEntry[]): boolean;
58
69
  /**
@@ -84,16 +84,29 @@ const UNSAFE = /[`$;&|<>\\"'\r\n\u0000]|--|\/\*/;
84
84
  * apostrophe, at the cost of a denied call and a model round trip each time.
85
85
  */
86
86
  const WRITE_UNSAFE = /[\r\n\u0000]/;
87
+ /** Whether a plain tool name of another host says its input is run. */
88
+ function plainNameSays(name, words) {
89
+ return words.test(nameWords(name));
90
+ }
87
91
  /** Whether this tool's input is run by a shell. */
88
- export function isShellTool(toolName) {
92
+ export function isShellTool(toolName, host) {
89
93
  const name = toolName ?? "";
94
+ if (host)
95
+ return plainNameSays(name, MCP_SHELL_WORD);
90
96
  if (SHELL_BUILTINS.has(name))
91
97
  return true;
92
98
  return mcpNameSays(name, MCP_SHELL_WORD);
93
99
  }
94
100
  /** How this tool consumes its input, or undefined when it is neither run nor written. */
95
- export function sinkOf(toolName) {
101
+ export function sinkOf(toolName, host) {
96
102
  const name = toolName ?? "";
103
+ if (host) {
104
+ if (host.command?.includes(name))
105
+ return "command";
106
+ if (host.write?.includes(name))
107
+ return "write";
108
+ return plainNameSays(name, MCP_COMMAND_WORD) ? "command" : undefined;
109
+ }
97
110
  if (SHELL_BUILTINS.has(name))
98
111
  return "command";
99
112
  if (WRITE_BUILTINS.has(name))
@@ -105,10 +118,10 @@ export function isCommandSink(toolName) {
105
118
  return sinkOf(toolName) !== undefined;
106
119
  }
107
120
  /** The strictest sink among the planned steps, or undefined when there is none. */
108
- export function sinkOfPlan(entries) {
121
+ export function sinkOfPlan(entries, host) {
109
122
  let found;
110
123
  for (const e of entries) {
111
- const sink = sinkOf(e.step.toolName);
124
+ const sink = sinkOf(e.step.toolName, host);
112
125
  if (sink === "command")
113
126
  return sink;
114
127
  found ??= sink;
@@ -116,8 +129,8 @@ export function sinkOfPlan(entries) {
116
129
  return found;
117
130
  }
118
131
  /** Whether any planned step runs its input through a shell. */
119
- export function hasShellStep(entries) {
120
- return entries.some((e) => isShellTool(e.step.toolName));
132
+ export function hasShellStep(entries, host) {
133
+ return entries.some((e) => isShellTool(e.step.toolName, host));
121
134
  }
122
135
  /** Whether any planned step's input is run or written. */
123
136
  export function hasCommandSink(entries) {
@@ -40,6 +40,7 @@
40
40
  */
41
41
  import { logDetail, logLine, errText } from "../util/log.js";
42
42
  import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal, } from "../util/service-health.js";
43
+ import { serviceHeaders } from "../util/service-headers.js";
43
44
  export class SourceRunCache {
44
45
  opts;
45
46
  /** run id → every recorded output of that run, in order. */
@@ -109,7 +110,7 @@ export class SourceRunCache {
109
110
  let res;
110
111
  try {
111
112
  res = await doFetch(`${this.opts.baseUrl.replace(/\/+$/, "")}/recordings/runs/${runId}`, {
112
- headers: { authorization: `Bearer ${token}` },
113
+ headers: { authorization: `Bearer ${token}`, ...serviceHeaders() },
113
114
  signal: timeoutSignal(this.opts.timeoutMs ?? serviceTimeoutMs()),
114
115
  });
115
116
  }
@@ -249,6 +249,22 @@ export interface ExecutionStepResult {
249
249
  /** Message only — never a tool payload; this is rendered in a web page. */
250
250
  error?: string;
251
251
  durationMs?: number;
252
+ /**
253
+ * What the step sent its tool on this run: the arguments its input logic
254
+ * computed, serialized and redacted as a recorded step's are, and cut to
255
+ * what a report carries. Absent when the step broke before it had any.
256
+ *
257
+ * Without the two payloads the scenario's page can only put the recording's
258
+ * sample under this run's verdict and time — a call that was not made, and
259
+ * an answer that was not given, on a card that says "passed".
260
+ */
261
+ toolInput?: string;
262
+ /**
263
+ * What the step's output logic was given, on the same terms: the tool's
264
+ * answer, or for `recorded` the source run's output that stood in for it.
265
+ * Absent on a step that got none.
266
+ */
267
+ toolOutput?: string;
252
268
  /**
253
269
  * The step's tool no longer exists on its server, read from the error text
254
270
  * (plan-services.md D8, kind 2). The service then repairs the plan without
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The contract this runner follows. It goes up by one, together with the
3
+ * service's own number, when the service starts to rely on something a runner
4
+ * must do — never for a field a runner may ignore.
5
+ *
6
+ * - **1** — stops in front of a step that carries `stop`, and reads
7
+ * `withheld: "runner_update_required"` on a match.
8
+ */
9
+ export declare const CONTRACT = 1;
10
+ /** Spread into the `headers` of a request to the service. */
11
+ export declare function serviceHeaders(): Record<string, string>;
12
+ /**
13
+ * `bir doctor`'s line for a service that hands plans only to a newer runner
14
+ * than this one: its `/health` names the lowest contract it serves
15
+ * (`minRunner`). Undefined when this runner is new enough, and when the
16
+ * service is an older one that names no minimum.
17
+ */
18
+ export declare function runnerOutdated(service: {
19
+ minRunner?: number;
20
+ } | undefined, ours?: {
21
+ contract: number;
22
+ version: string;
23
+ }): string | undefined;
24
+ //# sourceMappingURL=service-headers.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The two headers every call to the BaseIn service carries, so the service
3
+ * knows which runner it is talking to.
4
+ *
5
+ * WHY. The service hands every runner the same plan, and a safety mark added to
6
+ * a plan later works only in a runner that knows it. With these the service can
7
+ * count the versions in use, and hand no plan to a runner too old for it — the
8
+ * agent then does the task, and `bir doctor` says to update.
9
+ *
10
+ * `x-bir-version` is this package's version. `x-bir-contract` is
11
+ * {@link CONTRACT}: the newest set of rules this runner follows.
12
+ *
13
+ * On every call to the service, signed in or not, and never on a call to
14
+ * anyone else: not to Anthropic (replay/derive.ts), not to an upstream MCP
15
+ * server, not to the local control server.
16
+ */
17
+ import { packageVersion } from "./version.js";
18
+ /**
19
+ * The contract this runner follows. It goes up by one, together with the
20
+ * service's own number, when the service starts to rely on something a runner
21
+ * must do — never for a field a runner may ignore.
22
+ *
23
+ * - **1** — stops in front of a step that carries `stop`, and reads
24
+ * `withheld: "runner_update_required"` on a match.
25
+ */
26
+ export const CONTRACT = 1;
27
+ /** Spread into the `headers` of a request to the service. */
28
+ export function serviceHeaders() {
29
+ return {
30
+ "x-bir-version": packageVersion(),
31
+ "x-bir-contract": String(CONTRACT),
32
+ };
33
+ }
34
+ /**
35
+ * `bir doctor`'s line for a service that hands plans only to a newer runner
36
+ * than this one: its `/health` names the lowest contract it serves
37
+ * (`minRunner`). Undefined when this runner is new enough, and when the
38
+ * service is an older one that names no minimum.
39
+ */
40
+ export function runnerOutdated(service, ours = { contract: CONTRACT, version: packageVersion() }) {
41
+ if (service?.minRunner === undefined || service.minRunner <= ours.contract)
42
+ return undefined;
43
+ return (`the BaseIn service hands plans only to a newer runner than this one (${ours.version}): ` +
44
+ "it still records, but nothing is replayed until it is updated — run `bir update`, " +
45
+ "then `bir setup` in each project that uses it");
46
+ }
47
+ //# sourceMappingURL=service-headers.js.map
@@ -411,7 +411,7 @@ the discovery file required on every route):
411
411
  | `POST /session/end` | hook | `finishRun` + flush |
412
412
  | `POST /proxy/register` | proxy | `{ serverName, pid, cwd }` → `{ runId, sessionId }` |
413
413
  | `POST /proxy/step` | proxy | A `ProxyStepReport`; allocates `stepIndex` |
414
- | `GET /health` | CLI | `bir doctor` |
414
+ | `GET /health` | CLI, adapter | `bir doctor`; the adapter of another host reads `hostFields` (below) |
415
415
 
416
416
  **Ordering.** `ordering.ts` hands out `stepIndex` from one counter shared by hook
417
417
  and proxy reports. Allocation happens at *report* time, not call time, so
@@ -424,6 +424,99 @@ what actually happened.
424
424
  `PostToolUse` shape, and `SubagentStart` / `SubagentStop` (which carry `agent_id`)
425
425
  to attribute subagent steps correctly.
426
426
 
427
+ **A host that is not Claude Code.** The same routes serve an agent that runs
428
+ somewhere else. The one host `bir setup --host` knows is a LangChain
429
+ application: a Python program whose agent is built with LangChain's
430
+ `create_agent` and the package `langchain-basic2202`. The package (the adapter)
431
+ is a LangChain middleware. It runs inside the application, sees every tool call
432
+ itself, and finds the recorder by the folder's discovery file. The folder is set
433
+ up with `bir setup --host langchain`, which gives it a port and a token of its
434
+ own and starts the recorder. It writes no hooks and wraps no server
435
+ ([production.md](../production.md) §3.2). The how-to is `langChainGuide.md` in
436
+ the repository's root; it is not in the npm package, and the Python package is
437
+ not on PyPI yet.
438
+
439
+ Claude Code's hooks are unchanged, and every new field is optional. A session
440
+ whose requests carry `host` with an `app` other than `claude-code` is a session
441
+ of another host. What such a host adds:
442
+
443
+ | Route | Added | Meaning |
444
+ |---|---|---|
445
+ | every request | `host: { app, version?, adapter?, scope? }` | The host the session runs in. The session keeps the first one it names |
446
+ | `POST /session/prompt` | `replay: true` | The adapter can step through a plan, and asks for one |
447
+ | `POST /session/prompt` | `sinks: { command?: string[], write?: string[] }` | The host's tools whose input is run, or written into a file |
448
+ | `POST /session/prompt` | answer: `plan: { scenarioId, mode, steps: [{ stepIndex, toolName }], stopAt? }` | The plan as data, beside the directive |
449
+ | `POST /tool/pre` | `context: string`, `thinking: string` | What the model wrote, and what it reasoned, before the call |
450
+ | `POST /tool/pre` | `scripted: true` | The adapter asks for the next step's arguments |
451
+ | `POST /tool/pre` | `tools: [{ name }]` | The tools the agent holds, with the first such request of a run |
452
+ | `POST /tool/pre` | answer: `noPlan: true`, or `declined: "host_tool_missing"` | Why a request for a step got none |
453
+ | `POST /tool/post` | `scripted: true` | The result of a call the adapter made for a step of the plan |
454
+ | `POST /tool/post` | `host_stop: "rejected" \| "edited"` | The host stopped the step itself |
455
+ | `POST /session/stop` | `final_answer: string` | The run's final answer |
456
+ | `POST /session/stop` | `usage: [{ model, input_tokens, output_tokens, cache_read_input_tokens?, cache_creation_input_tokens? }]` | The tokens the run's model calls used, per model |
457
+ | `POST /session/stop` | `failed: string`, `incomplete: string` | The run failed, or its recording is not the whole run, and why |
458
+ | `GET /health` | answer: `hostFields: 2` | How much of this protocol the recorder understands. An adapter reads it before it relies on a field: with 1 it records, from 2 it may also ask for a plan and step through it |
459
+
460
+ A prompt's `recent_results` (the conversation's earlier tool results, which an
461
+ adapter used to send for the reading of new values) is no longer read. A new
462
+ prompt's values are read from the prompt alone: an earlier turn's results are
463
+ not this task's (the service's segmented.md, R-PARAM-2a).
464
+
465
+ What the control server does for such a session:
466
+
467
+ - **The run.** It is created with `framework` set to the app, or `app:scope`
468
+ (`langchain`, `langchain:billing`), and with the host in `metadata.host`. A
469
+ Claude Code run is created as before (`framework: "baseinstrunner"`). Only
470
+ `/session/prompt` opens a run. Any other request that finds none open records
471
+ nothing and is answered `{}`, or `{ noPlan: true }` when it asked for a step.
472
+ - **The scope.** It keeps plans apart inside one account, and is part of the
473
+ run's `framework`. It is 1 to 64 characters and is never cut or dropped. A
474
+ session that names one the recorder cannot keep (empty, longer, or not a
475
+ text) has every request answered `{}`, and nothing of it is recorded or
476
+ replayed (`session.host_refused`).
477
+ - **The plan.** One starts only for a prompt that said `replay: true`.
478
+ Otherwise a match is declined with `host_records_only`. The plan's mode is
479
+ always `steer`, and the runner runs no step itself: the host runs its own
480
+ tools. The prompt's answer carries the plan as data (`plan`), because the
481
+ directive beside it is a text written for a model.
482
+ - **Tool names.** They carry no Claude Code meaning. `Bash`, `Read`,
483
+ `TodoWrite` or `ToolSearch` are the application's own tools there, and
484
+ nothing is skipped as housekeeping except the `bir` server's own tools.
485
+ - **A step.** The adapter asks for each one (`scripted: true`). The answer is
486
+ the step's arguments (`hookSpecificOutput.updatedInput`), or
487
+ `{ noPlan: true }` when the recorder holds no live plan, or
488
+ `{ declined: "host_tool_missing" }` when the plan names a tool that is not in
489
+ `tools`.
490
+ - **The model's own call.** A `/tool/pre` without `scripted` is the model's own
491
+ call, and is never given the plan's arguments. A plan that is live then is
492
+ handed over, and the hand-over note comes with that call's `/tool/post`.
493
+ - **A step the host stopped.** `host_stop: "rejected"` means a person said no,
494
+ or answered in the tool's place, or a call limit blocked the call. `"edited"`
495
+ means a person changed the call. Either way the step's verdict is `skipped`,
496
+ not `failed`, and nothing is counted against the plan. After `rejected` the
497
+ report is `measured: false`; after `edited` the cost stays measured. Both are
498
+ `baselineEligible: false`.
499
+ - **The cost.** It comes from `usage`, not from a transcript. It is measured
500
+ only when at least one entry has tokens and every model in the list has a
501
+ price. With more than one plan in one run, none is measured.
502
+ - **The end.** `failed` closes the run with that text as its `error` (cleaned
503
+ of secrets, cut) and no final answer. `incomplete` marks the finish
504
+ `incomplete: { lostSteps, lossy }`; the finish carries that mark too when the
505
+ recorder itself lost one of the run's step sends. A run sealed after two
506
+ hours of silence is closed with `error: "turn ended early: idle"`. A run that
507
+ is closed while its plan is still live has the plan handed over first, so the
508
+ report says `fell_back` (a step ran) or `failed` (none ran), with
509
+ `measured: false` and `baselineEligible: false`.
510
+ - **Sinks.** A value that a plan reads from the prompt is checked before it goes
511
+ into a tool the host declared in `sinks`, or one whose plain name has a
512
+ command word (`sql`, `query`, `exec`, `shell`, `command`, `script` and `eval`
513
+ among them).
514
+
515
+ On the service's side, matching stays inside a host: a run created with
516
+ `framework` `langchain` or `langchain:<scope>` is matched only against
517
+ recordings and plans of that same text. Everything else is Claude Code's pool,
518
+ as before. That part of the service is built and not deployed yet.
519
+
427
520
  **Acceptance:** a Claude Code session running one built-in and one MCP tool
428
521
  produces a single run whose steps are in true execution order.
429
522
 
@@ -574,6 +667,11 @@ Run `metadata` carries what is new, and what BaseIn-side consumers will need:
574
667
  `tier` is the field that stops a Tier 2 partial trace being mistaken for a
575
668
  complete one.
576
669
 
670
+ A run of a host that is not Claude Code (Phase 5) names that host in
671
+ `metadata.host` (`{ "app": "langchain", … }`), and is created with a `framework`
672
+ of its own: the app, or `app:scope`. Its finish may carry an `error`, and the
673
+ `incomplete` mark.
674
+
577
675
  ---
578
676
 
579
677
  ## 8. Full-passthrough method map
@@ -618,6 +716,11 @@ observable.
618
716
  - Discovery files are mode `0600`.
619
717
  - `bir doctor` prints exactly which servers are wrapped, so a proxy can never sit
620
718
  in the path invisibly.
719
+ - A recorder started for a folder that is set up for another host and not for
720
+ Claude Code (Phase 5) gets a small environment, not the whole environment of
721
+ the window that started it. A plan's step code runs inside the recorder with
722
+ no sandbox, so a model key or a database password of the application must not
723
+ reach it. The names it keeps are in [production.md](../production.md) §9.
621
724
 
622
725
  ---
623
726
 
@@ -630,6 +733,7 @@ The governing rule: **the host session must never fail because of BaseInstRunner
630
733
  | Control server unreachable at startup | Buffer <= 10 s while retrying, then Tier 2 (passthrough for an entry that may not record here, §0.1). Keep looking every 15 s; bind when a recorder answers |
631
734
  | Control server dies mid-session | Log `tier.downgrade`; what follows goes to a Tier 2 run of the proxy's own (at most one per proxy; none where the entry may not record), flagged `lossy`, until a recorder answers again. A bound proxy in an installed project whose recorder died (pid gone, port refusing) starts it again in the background (`bir-hooks revive`); a recorder stopped with `bir down` stays stopped |
632
735
  | Recorder HTTP fails | Drop the send, log once per run, continue. Never retry inline: a background send is retried once, and only after a 429/503 or a connection that never reached the service. After a failure, hooks stop waiting on the service for a cool-down (5 s, doubling to 60 s) |
736
+ | The service answers `429`: the account is over one of its limits (these are built in the service, and not deployed yet) | Send the create, the step or the report once more, at most 2 s later, then drop it (`recorder.send_failed` with `HTTP 429`). A dropped step is a hole in its recording, and a run whose create got a `429` runs without its plan. A `429` that the second try did not cure starts the same cool-down. The limits are in [production.md](../production.md) §3.3 |
633
737
  | Upstream fails to start, or exits | Answer the requests in flight, then exit with the upstream's status (1 if it had none). The host shows that one server failed, with a reconnect offered, as it would unwrapped. `BIR_PROXY_STAY_UP=1` keeps the proxy up instead, answering every request `MCP server "x" is not available` |
634
738
  | A stdio upstream dies mid-call | Return `isError: true` with `MCP server "x" is not available` as content — a tool failure the model can react to, not a protocol error — then exit as above |
635
739
  | Remote upstream unreachable or refusing | The proxy stays up and answers each request with a short JSON-RPC error that never echoes the server's body (`upstream unreachable: …`, `upstream requires sign-in (HTTP 401)`). Only connecting is bounded (10 s, `BIR_UPSTREAM_CONNECT_TIMEOUT_MS`); a slow tool is the host's to time out |
@@ -659,6 +763,9 @@ The governing rule: **the host session must never fail because of BaseInstRunner
659
763
  6. **Redaction** — assert no fixture secret reaches the recorder.
660
764
  7. **Integration** — a real Claude Code session against `fake-upstream`, asserting
661
765
  run shape end to end. The only test that catches host-behaviour drift.
766
+ 8. **Another host** — `examples/langchain-proof/`, in the repository and not in
767
+ the npm package: a real LangChain agent against the real recorder, with a
768
+ stand-in service and a fake model (Phase 5). Its README lists the cases.
662
769
 
663
770
  ---
664
771
 
@@ -683,6 +790,13 @@ ship it before touching the control plane.
683
790
  `metadata` enough for the savings pipeline to exclude partial traces from
684
791
  baselines? A Tier 2 run has no prompt, so similarity matching on `input` behaves
685
792
  differently.
793
+ *Answered in part.* The service's new build (built, not deployed yet) lets a
794
+ finish say that the recording is not whole: `incomplete: { lostSteps, lossy }`.
795
+ A run with an `error`, or with that mark, is never picked for automatic
796
+ calculation and no longer catches its prompt, unless its owner preferred it
797
+ or it already has a plan. Today only a run of a host that is not Claude Code
798
+ (Phase 5) sends either: a Claude Code run that was interrupted or lost steps
799
+ is finished as before. The runner still sends `tier` in `metadata` only.
686
800
  2. **Coexistence with RRepeat.** If both are installed, both record the same session
687
801
  into the same service. Should `bir-hooks` detect `rrepeat-hooks` and refuse, or
688
802
  should runs carry a `recorder` discriminator and be deduped server-side? §7's