@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.
- package/README.md +46 -5
- package/dist/auth/client.d.ts +15 -0
- package/dist/auth/client.js +34 -12
- package/dist/bin/bir-hooks.d.ts +1 -1
- package/dist/bin/bir-hooks.js +1 -1
- package/dist/bin/bir.d.ts +2 -0
- package/dist/bin/bir.js +60 -6
- package/dist/bin/investigate.d.ts +5 -0
- package/dist/bin/investigate.js +117 -15
- package/dist/bin/setup.d.ts +12 -0
- package/dist/bin/setup.js +185 -56
- package/dist/bin/update.d.ts +81 -0
- package/dist/bin/update.js +194 -0
- package/dist/config/adapters/claude-code.d.ts +17 -0
- package/dist/config/adapters/claude-code.js +28 -1
- package/dist/config/generate.d.ts +14 -0
- package/dist/control/daemon.d.ts +26 -1
- package/dist/control/daemon.js +53 -3
- package/dist/control/pin-guard.d.ts +6 -1
- package/dist/control/pin-guard.js +15 -1
- package/dist/control/recorder-keeper.js +2 -1
- package/dist/control/server.d.ts +200 -2
- package/dist/control/server.js +741 -48
- package/dist/control/transcript.d.ts +4 -0
- package/dist/control/transcript.js +4 -0
- package/dist/proxy/session.js +1 -0
- package/dist/record/housekeeping.d.ts +7 -1
- package/dist/record/housekeeping.js +11 -3
- package/dist/record/recorder.d.ts +34 -0
- package/dist/record/remote-recorder.d.ts +14 -0
- package/dist/record/remote-recorder.js +107 -13
- package/dist/record/switchable-recorder.js +1 -0
- package/dist/record/truncate.d.ts +11 -0
- package/dist/record/truncate.js +11 -0
- package/dist/replay/controller.d.ts +146 -4
- package/dist/replay/controller.js +340 -31
- package/dist/replay/derive.js +2 -0
- package/dist/replay/handover.d.ts +13 -1
- package/dist/replay/handover.js +19 -0
- package/dist/replay/plan.d.ts +6 -0
- package/dist/replay/plan.js +13 -3
- package/dist/replay/safety.d.ts +15 -4
- package/dist/replay/safety.js +19 -6
- package/dist/replay/source-run.js +2 -1
- package/dist/replay/types.d.ts +16 -0
- package/dist/util/service-headers.d.ts +24 -0
- package/dist/util/service-headers.js +47 -0
- package/docs/BaseInstRunner.md +115 -1
- package/docs/calculatedReplay.md +239 -27
- package/docs/calculatedReplayGuide.md +134 -12
- package/docs/installRun.md +91 -10
- package/docs/quickstart.md +64 -9
- package/package.json +1 -1
- package/production.md +81 -16
- package/scripts/install.ps1 +25 -1
package/dist/replay/derive.js
CHANGED
|
@@ -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;
|
package/dist/replay/handover.js
CHANGED
|
@@ -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.
|
package/dist/replay/plan.d.ts
CHANGED
|
@@ -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;
|
package/dist/replay/plan.js
CHANGED
|
@@ -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,
|
package/dist/replay/safety.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/replay/safety.js
CHANGED
|
@@ -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
|
}
|
package/dist/replay/types.d.ts
CHANGED
|
@@ -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
|
package/docs/BaseInstRunner.md
CHANGED
|
@@ -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
|