@basein/runner 0.3.12 → 0.3.14
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/dist/bin/bir-scenario.d.ts +8 -1
- package/dist/bin/bir-scenario.js +10 -2
- package/dist/bin/investigate.d.ts +10 -0
- package/dist/bin/investigate.js +32 -9
- package/dist/control/client.d.ts +6 -0
- package/dist/control/server.d.ts +35 -0
- package/dist/control/server.js +146 -6
- package/dist/proxy/session.js +1 -0
- package/dist/record/prompt-text.d.ts +32 -0
- package/dist/record/prompt-text.js +65 -0
- package/dist/record/recorder.d.ts +28 -1
- package/dist/record/remote-recorder.js +8 -1
- package/dist/replay/executor.d.ts +26 -5
- package/dist/replay/executor.js +40 -5
- package/docs/calculatedReplay.md +13 -5
- package/docs/calculatedReplayGuide.md +8 -1
- package/package.json +1 -1
- package/production.md +2 -0
|
@@ -24,7 +24,14 @@
|
|
|
24
24
|
* MCP call's `params._meta["claudecode/toolUseId"]` — the same id that hook
|
|
25
25
|
* carried as `tool_use_id` — so the request forwards it as `toolUseId` and the
|
|
26
26
|
* control server matches it exactly instead of inferring the session. A host
|
|
27
|
-
* that names nothing sends
|
|
27
|
+
* that names nothing sends no `toolUseId`, as before.
|
|
28
|
+
*
|
|
29
|
+
* WHOSE PROXIES. The request also carries `ppid`, the process that started
|
|
30
|
+
* this server. Claude Code starts all of a session's MCP servers, so that is
|
|
31
|
+
* the parent of the session's proxies too, and the control server binds them
|
|
32
|
+
* to the session before the plan's first step (replay/executor.ts,
|
|
33
|
+
* `bindSiblings`): that step is the session's first call to their server, so
|
|
34
|
+
* nothing else could yet say which proxies are its own.
|
|
28
35
|
*
|
|
29
36
|
* IT RUNS IN EVERY SESSION, SO IT MUST NEVER BE WHY ONE GOES WRONG. Startup and
|
|
30
37
|
* `tools/list` are answered from local files alone (the hello to the recorder
|
package/dist/bin/bir-scenario.js
CHANGED
|
@@ -24,7 +24,14 @@
|
|
|
24
24
|
* MCP call's `params._meta["claudecode/toolUseId"]` — the same id that hook
|
|
25
25
|
* carried as `tool_use_id` — so the request forwards it as `toolUseId` and the
|
|
26
26
|
* control server matches it exactly instead of inferring the session. A host
|
|
27
|
-
* that names nothing sends
|
|
27
|
+
* that names nothing sends no `toolUseId`, as before.
|
|
28
|
+
*
|
|
29
|
+
* WHOSE PROXIES. The request also carries `ppid`, the process that started
|
|
30
|
+
* this server. Claude Code starts all of a session's MCP servers, so that is
|
|
31
|
+
* the parent of the session's proxies too, and the control server binds them
|
|
32
|
+
* to the session before the plan's first step (replay/executor.ts,
|
|
33
|
+
* `bindSiblings`): that step is the session's first call to their server, so
|
|
34
|
+
* nothing else could yet say which proxies are its own.
|
|
28
35
|
*
|
|
29
36
|
* IT RUNS IN EVERY SESSION, SO IT MUST NEVER BE WHY ONE GOES WRONG. Startup and
|
|
30
37
|
* `tools/list` are answered from local files alone (the hello to the recorder
|
|
@@ -206,7 +213,8 @@ async function runArmedPlan(cwd, flight, toolUseId) {
|
|
|
206
213
|
"content-type": "application/json",
|
|
207
214
|
authorization: `Bearer ${found.token}`,
|
|
208
215
|
},
|
|
209
|
-
|
|
216
|
+
// `ppid`: the host that started this server and the session's proxies (WHOSE PROXIES above).
|
|
217
|
+
body: JSON.stringify(toolUseId ? { toolUseId, ppid: process.ppid } : { ppid: process.ppid }),
|
|
210
218
|
signal: controller.signal,
|
|
211
219
|
});
|
|
212
220
|
const raw = await res.text();
|
|
@@ -29,6 +29,16 @@ export interface Turn {
|
|
|
29
29
|
startedAt: string;
|
|
30
30
|
finishedAt?: string;
|
|
31
31
|
prompt?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Claude Code's own blocks the runner took off the front of the prompt, in
|
|
34
|
+
* order (fx121.md): `run.prompt_stripped`. Absent when there were none.
|
|
35
|
+
*/
|
|
36
|
+
stripped?: string[];
|
|
37
|
+
/** The turn was a task notification, with no words of the user's: never matched. */
|
|
38
|
+
eventOnly?: boolean;
|
|
39
|
+
/** The prompt's length as the hook gave it, and as the service got it. */
|
|
40
|
+
rawChars?: number;
|
|
41
|
+
promptChars?: number;
|
|
32
42
|
matched?: {
|
|
33
43
|
runId: string;
|
|
34
44
|
scenarioId?: string;
|
package/dist/bin/investigate.js
CHANGED
|
@@ -99,6 +99,12 @@ export function turnsFromJournal(entries) {
|
|
|
99
99
|
case "run.prompt":
|
|
100
100
|
turn.prompt = str(e.prompt);
|
|
101
101
|
break;
|
|
102
|
+
case "run.prompt_stripped":
|
|
103
|
+
turn.stripped = list(e.stripped);
|
|
104
|
+
turn.eventOnly = bool(e.eventOnly);
|
|
105
|
+
turn.rawChars = num(e.rawChars);
|
|
106
|
+
turn.promptChars = num(e.chars);
|
|
107
|
+
break;
|
|
102
108
|
case "run.matched":
|
|
103
109
|
turn.matched = { runId: str(e.matchedRun) ?? "", scenarioId: str(e.scenario), similarity: num(e.similarity) };
|
|
104
110
|
break;
|
|
@@ -313,13 +319,17 @@ const pct = (v) => `${(v * 100).toFixed(1)}%`;
|
|
|
313
319
|
export function localFindings(turn) {
|
|
314
320
|
const out = [];
|
|
315
321
|
if (!turn.matched) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
322
|
+
// A task notification is never matched (fx121 R7), so no prompt was
|
|
323
|
+
// compared and none can be reworded: "no match" would explain nothing.
|
|
324
|
+
if (!turn.eventOnly) {
|
|
325
|
+
out.push({
|
|
326
|
+
code: "no_match",
|
|
327
|
+
severity: "info",
|
|
328
|
+
title: "This turn matched no recording, so the agent did the task itself.",
|
|
329
|
+
cause: "The service found no recording whose prompt is similar enough (its SIMILARITY_THRESHOLD). A run that is below the recording threshold is never embedded and can never be matched.",
|
|
330
|
+
fix: "If a recording of this task exists, run the prompt closer to its wording. The service section below says whether this turn became a recording.",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
323
333
|
}
|
|
324
334
|
else if (turn.decision?.verdict === "no-steer") {
|
|
325
335
|
const code = turn.decision.code ?? "";
|
|
@@ -483,6 +493,8 @@ export function savedPct(turn) {
|
|
|
483
493
|
return baseline > 0 ? r.savedUsd / baseline : null;
|
|
484
494
|
}
|
|
485
495
|
const short = (id) => (id ? id.slice(0, 13) : "—");
|
|
496
|
+
/** 4624 → "4,624", whatever the machine's locale. */
|
|
497
|
+
const count = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
486
498
|
const money = (v) => (v == null ? "—" : usd(v));
|
|
487
499
|
const when = (iso) => (iso ? iso.replace("T", " ").replace(/\.\d+Z$/, "Z") : "—");
|
|
488
500
|
function renderFindings(out, findings, heading) {
|
|
@@ -500,12 +512,21 @@ function renderFindings(out, findings, heading) {
|
|
|
500
512
|
}
|
|
501
513
|
export function renderTurn(out, turn) {
|
|
502
514
|
out(`Turn ${turn.runId} ${when(turn.startedAt)}${turn.sessionId ? ` session ${turn.sessionId.slice(0, 8)}` : ""}`);
|
|
503
|
-
if (turn.prompt)
|
|
515
|
+
if (turn.prompt) {
|
|
504
516
|
out(` prompt ${turn.prompt.replace(/\s+/g, " ").slice(0, 110)}`);
|
|
517
|
+
}
|
|
518
|
+
else if (turn.eventOnly) {
|
|
519
|
+
// No `run.prompt` line: nothing of the user's was left to send (fx121 R7).
|
|
520
|
+
out(" prompt (none: a task notification, never matched)");
|
|
521
|
+
}
|
|
522
|
+
if (turn.stripped?.length) {
|
|
523
|
+
const sizes = turn.rawChars != null ? ` (${count(turn.rawChars)} → ${count(turn.promptChars ?? 0)} chars)` : "";
|
|
524
|
+
out(` stripped ${turn.stripped.join(", ")}${sizes}`);
|
|
525
|
+
}
|
|
505
526
|
if (turn.matched) {
|
|
506
527
|
out(` matched ${turn.matched.runId}${turn.matched.similarity != null ? ` (similarity ${turn.matched.similarity.toFixed(3)})` : ""}${turn.matched.scenarioId ? ` → scenario ${turn.matched.scenarioId}` : " → no scenario"}`);
|
|
507
528
|
}
|
|
508
|
-
else {
|
|
529
|
+
else if (!turn.eventOnly) {
|
|
509
530
|
out(" matched no recording — the agent ran the task; this turn was recorded");
|
|
510
531
|
}
|
|
511
532
|
if (turn.decision?.verdict === "no-steer") {
|
|
@@ -672,6 +693,8 @@ export async function investigateCommand(args, deps) {
|
|
|
672
693
|
out(` ${when(t.startedAt)} ${t.runId} ${verdict}${saved}`);
|
|
673
694
|
if (t.prompt)
|
|
674
695
|
out(` "${t.prompt.replace(/\s+/g, " ").slice(0, 90)}"`);
|
|
696
|
+
else if (t.eventOnly)
|
|
697
|
+
out(" (none: a task notification)");
|
|
675
698
|
}
|
|
676
699
|
out();
|
|
677
700
|
out("Investigate one with `bir investigate <run_…>`; the newest with `bir investigate`.");
|
package/dist/control/client.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface ProxyIdentity {
|
|
|
46
46
|
/** Process start, epoch ms. */
|
|
47
47
|
startedAt: number;
|
|
48
48
|
pid: number;
|
|
49
|
+
/**
|
|
50
|
+
* The process that started the proxy: its session's host. `bir-scenario`
|
|
51
|
+
* reports its own, so a session's proxies can be told apart before they
|
|
52
|
+
* have called anything (replay/executor.ts, `bindSiblings`).
|
|
53
|
+
*/
|
|
54
|
+
ppid: number;
|
|
49
55
|
}
|
|
50
56
|
export declare class ControlClient {
|
|
51
57
|
private readonly url;
|
package/dist/control/server.d.ts
CHANGED
|
@@ -567,6 +567,8 @@ export declare class ControlServer {
|
|
|
567
567
|
*
|
|
568
568
|
* `asked` is what the prompt that opens the run asked for, in a session of
|
|
569
569
|
* another host: whether its adapter wants a plan (`HookPayload.replay`).
|
|
570
|
+
* `cleaned` is how `onPrompt` got `input` from the hook's prompt, the only
|
|
571
|
+
* caller that has one (fx121.md).
|
|
570
572
|
*/
|
|
571
573
|
private ensureRun;
|
|
572
574
|
private runMetadata;
|
|
@@ -1071,6 +1073,31 @@ export declare class ControlServer {
|
|
|
1071
1073
|
private openFragment;
|
|
1072
1074
|
/** What the fragment's create call said: `created: false` is a hit, not a recording. */
|
|
1073
1075
|
private applyFragmentCreation;
|
|
1076
|
+
/**
|
|
1077
|
+
* Whether this matched turn's task is now the model's own, so its work is
|
|
1078
|
+
* the repeat the service offered to record (the service's newestRun.md N1):
|
|
1079
|
+
* offered, nothing recording, no fragment, and no plan of the prompt match
|
|
1080
|
+
* that ran a step or still may. A plan that handed over is the fragment's to
|
|
1081
|
+
* record (fallbk.md D7).
|
|
1082
|
+
*/
|
|
1083
|
+
private repeatDue;
|
|
1084
|
+
/**
|
|
1085
|
+
* Open the repeat run and record from this call on (newestRun.md N1).
|
|
1086
|
+
*
|
|
1087
|
+
* The same swap as {@link openFragment}: nothing allocated before it belongs
|
|
1088
|
+
* to it. Unlike a fragment it carries the turn's prompt, and its cost and
|
|
1089
|
+
* duration are the whole turn's, because no plan did any of the task. Nothing
|
|
1090
|
+
* waits for the service's answer: the steps queue behind the create, and a
|
|
1091
|
+
* create answered `id: null` — the service records no repeat of it — drops
|
|
1092
|
+
* them.
|
|
1093
|
+
*/
|
|
1094
|
+
private openRepeat;
|
|
1095
|
+
/**
|
|
1096
|
+
* Why the matched turn's plan did not run, as the service shows it: what
|
|
1097
|
+
* the service withheld it for, else the code of the gate that declined it
|
|
1098
|
+
* here, a plan that was late included.
|
|
1099
|
+
*/
|
|
1100
|
+
private repeatReason;
|
|
1074
1101
|
/** Resolve with `p`, or null once `ms` passes or it rejects. */
|
|
1075
1102
|
private withinBudget;
|
|
1076
1103
|
/**
|
|
@@ -1166,6 +1193,14 @@ export declare class ControlServer {
|
|
|
1166
1193
|
* ONE RUN PER PLAN. A second call while the plan is executing would run
|
|
1167
1194
|
* every step again beside the first — duplicated writes — and is told so.
|
|
1168
1195
|
*
|
|
1196
|
+
* WHOSE PROXIES. `run_scenario` is the session's first call, so no
|
|
1197
|
+
* correlated call has bound its proxies yet, and with another session open
|
|
1198
|
+
* an unbound proxy may be either's: the plan's first step was refused. But
|
|
1199
|
+
* Claude Code starts all of a session's MCP servers as its own children, and
|
|
1200
|
+
* `bir-scenario` sends its parent's pid (`ppid`), as every proxy does with its
|
|
1201
|
+
* poll. The proxies with that parent are this session's, and are bound to it
|
|
1202
|
+
* before the plan runs (replay/executor.ts, `bindSiblings`).
|
|
1203
|
+
*
|
|
1169
1204
|
* `signal` aborts when `bir-scenario`'s request goes away — the host
|
|
1170
1205
|
* cancelled the call (Esc) or its own timeout fired — so no further step is
|
|
1171
1206
|
* started for a caller that can no longer read the result.
|
package/dist/control/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import { pidAlive } from "./discovery.js";
|
|
|
33
33
|
import { pinCompatible } from "./pin-guard.js";
|
|
34
34
|
import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, thinkingForToolUse, usageSince, } from "./transcript.js";
|
|
35
35
|
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
36
|
+
import { asSent, userText } from "../record/prompt-text.js";
|
|
36
37
|
import { redact, redactText } from "../record/redact.js";
|
|
37
38
|
import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
|
|
38
39
|
import { StepQueue } from "../record/queue.js";
|
|
@@ -696,6 +697,9 @@ export class ControlServer {
|
|
|
696
697
|
}
|
|
697
698
|
: null,
|
|
698
699
|
fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
|
|
700
|
+
// The service's offer to record the turn as a recording's newest run,
|
|
701
|
+
// and what became of it (newestRun.md).
|
|
702
|
+
repeat: s.run?.repeat ? { state: s.run.repeat.state, of: s.run.repeat.runId } : null,
|
|
699
703
|
}));
|
|
700
704
|
const sending = this.recorderBacklog();
|
|
701
705
|
return {
|
|
@@ -1561,8 +1565,10 @@ export class ControlServer {
|
|
|
1561
1565
|
*
|
|
1562
1566
|
* `asked` is what the prompt that opens the run asked for, in a session of
|
|
1563
1567
|
* another host: whether its adapter wants a plan (`HookPayload.replay`).
|
|
1568
|
+
* `cleaned` is how `onPrompt` got `input` from the hook's prompt, the only
|
|
1569
|
+
* caller that has one (fx121.md).
|
|
1564
1570
|
*/
|
|
1565
|
-
ensureRun(session, input = "", asked = {}) {
|
|
1571
|
+
ensureRun(session, input = "", asked = {}, cleaned) {
|
|
1566
1572
|
if (session.run && !session.run.finished)
|
|
1567
1573
|
return session.run;
|
|
1568
1574
|
// The sealed run stays reachable for a moment: a report or a post that
|
|
@@ -1572,7 +1578,12 @@ export class ControlServer {
|
|
|
1572
1578
|
// A run of another host says so in its `framework`: the service keeps a
|
|
1573
1579
|
// plan inside the host, and the scope, that recorded it.
|
|
1574
1580
|
const framework = frameworkOf(session);
|
|
1575
|
-
|
|
1581
|
+
// fx121 R8: blocks taken off the front of the prompt are said on the run
|
|
1582
|
+
// itself, and in the journal below. A prompt that was only unwrapped is not.
|
|
1583
|
+
const removed = cleaned && cleaned.stripped.length > 0
|
|
1584
|
+
? { stripped: cleaned.stripped, eventOnly: cleaned.eventOnly, rawChars: cleaned.rawChars }
|
|
1585
|
+
: undefined;
|
|
1586
|
+
const runId = this.recorder.startRun(input, removed ? { ...this.runMetadata(session), prompt: removed } : this.runMetadata(session), framework ? { framework } : undefined);
|
|
1576
1587
|
const run = {
|
|
1577
1588
|
runId,
|
|
1578
1589
|
input,
|
|
@@ -1607,6 +1618,8 @@ export class ControlServer {
|
|
|
1607
1618
|
// on stderr, and never more than a preview.
|
|
1608
1619
|
if (input)
|
|
1609
1620
|
journal("run.prompt", { run: runId, prompt: input.slice(0, 200) });
|
|
1621
|
+
if (removed)
|
|
1622
|
+
journal("run.prompt_stripped", { run: runId, ...removed, chars: input.length });
|
|
1610
1623
|
// Started here so the round trip overlaps whatever the caller does next;
|
|
1611
1624
|
// `onPrompt` awaits the same memoized promise under its own budget.
|
|
1612
1625
|
void this.watchForMatch(session, run);
|
|
@@ -1663,6 +1676,12 @@ export class ControlServer {
|
|
|
1663
1676
|
if (!match || run.runId !== runId)
|
|
1664
1677
|
return;
|
|
1665
1678
|
run.recording = false;
|
|
1679
|
+
// The service's offer to record the turn after all, should the model end
|
|
1680
|
+
// up doing the task itself (newestRun.md N5). Taken up at the model's
|
|
1681
|
+
// first call of its own, once no plan of this match runs (`repeatDue`).
|
|
1682
|
+
if (match.repeat?.runId && !run.repeat) {
|
|
1683
|
+
run.repeat = { runId: match.repeat.runId, withheld: match.withheld ?? undefined, state: "offered" };
|
|
1684
|
+
}
|
|
1666
1685
|
logLine("run.matched", {
|
|
1667
1686
|
run: runId,
|
|
1668
1687
|
matchedRun: match.runId,
|
|
@@ -2580,7 +2599,14 @@ export class ControlServer {
|
|
|
2580
2599
|
}
|
|
2581
2600
|
async onPrompt(payload) {
|
|
2582
2601
|
const session = this.ensureSession(payload);
|
|
2583
|
-
const
|
|
2602
|
+
const other = otherHost(session);
|
|
2603
|
+
const raw = typeof payload.prompt === "string" ? payload.prompt : "";
|
|
2604
|
+
// fx121 R1, R6: the user's words, without Claude Code's own blocks in front
|
|
2605
|
+
// of them, and from here on the prompt everywhere. `input` is what the
|
|
2606
|
+
// service embeds, and its model reads 512 tokens: the Claude in Chrome
|
|
2607
|
+
// guide alone is 1,008. Another host's text goes as it was sent.
|
|
2608
|
+
const prompt = other ? asSent(raw) : userText(raw);
|
|
2609
|
+
const input = prompt.text;
|
|
2584
2610
|
const open = session.run && !session.run.finished ? session.run : undefined;
|
|
2585
2611
|
if (open?.promptSeen) {
|
|
2586
2612
|
// A second prompt in the same session: close the previous turn's run and
|
|
@@ -2627,14 +2653,13 @@ export class ControlServer {
|
|
|
2627
2653
|
});
|
|
2628
2654
|
return {};
|
|
2629
2655
|
}
|
|
2630
|
-
const other = otherHost(session);
|
|
2631
2656
|
if (other) {
|
|
2632
2657
|
// What a host hands over with its prompt, kept before the run opens:
|
|
2633
2658
|
// the match watched from there may start a plan at once, and the places
|
|
2634
2659
|
// that do have no prompt payload in reach.
|
|
2635
2660
|
session.hostSinks = hostSinksOf(payload);
|
|
2636
2661
|
}
|
|
2637
|
-
const run = this.ensureRun(session, input, { replay: other && payload.replay === true });
|
|
2662
|
+
const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
|
|
2638
2663
|
run.promptSeen = true;
|
|
2639
2664
|
run.input = input;
|
|
2640
2665
|
session.prompts += 1;
|
|
@@ -2918,6 +2943,12 @@ export class ControlServer {
|
|
|
2918
2943
|
this.noteHandover(run);
|
|
2919
2944
|
return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
|
|
2920
2945
|
}
|
|
2946
|
+
// The model is doing the matched task itself, and the service asked for
|
|
2947
|
+
// that run to be recorded as the recording's newest (newestRun.md N1).
|
|
2948
|
+
// Opened before the probe below, so the turn is recorded from this call
|
|
2949
|
+
// on like any recording turn — a plan an intent match arms in it too.
|
|
2950
|
+
if (this.repeatDue(run))
|
|
2951
|
+
this.openRepeat(session, run);
|
|
2921
2952
|
// 2. The model is driving: look for a scenario by this iteration's intent.
|
|
2922
2953
|
// Never in plan mode, where nothing may run on the user's behalf.
|
|
2923
2954
|
// If the deadline answers first, it answers `{}`: the call runs as
|
|
@@ -3250,6 +3281,9 @@ export class ControlServer {
|
|
|
3250
3281
|
});
|
|
3251
3282
|
}
|
|
3252
3283
|
this.noteHandover(run);
|
|
3284
|
+
// The model doing the matched task itself, as in Claude Code (newestRun.md N1).
|
|
3285
|
+
if (this.repeatDue(run))
|
|
3286
|
+
this.openRepeat(session, run);
|
|
3253
3287
|
const words = this.hostWords(payload);
|
|
3254
3288
|
// Counted, never waited on, and never arming: see `tryIntentMatch`.
|
|
3255
3289
|
await this.tryIntentMatch(session, run, toolName, words.intent, modelArgs);
|
|
@@ -3530,6 +3564,10 @@ export class ControlServer {
|
|
|
3530
3564
|
out.add(prompt);
|
|
3531
3565
|
if (run.fragment?.hitRunId)
|
|
3532
3566
|
out.add(run.fragment.hitRunId);
|
|
3567
|
+
// The recording the match offered a repeat of: the same recording, and
|
|
3568
|
+
// known even when the match landed too late to be armed (newestRun.md).
|
|
3569
|
+
if (run.repeat)
|
|
3570
|
+
out.add(run.repeat.runId);
|
|
3533
3571
|
return [...out];
|
|
3534
3572
|
}
|
|
3535
3573
|
/**
|
|
@@ -3969,8 +4007,16 @@ export class ControlServer {
|
|
|
3969
4007
|
/** What the fragment's create call said: `created: false` is a hit, not a recording. */
|
|
3970
4008
|
applyFragmentCreation(run, id, created) {
|
|
3971
4009
|
const f = run.fragment;
|
|
3972
|
-
if (
|
|
4010
|
+
if (!f || run.runId !== id)
|
|
4011
|
+
return;
|
|
4012
|
+
if (created.created) {
|
|
4013
|
+
// Recorded as the newest run of the fragment it is a hit on (the
|
|
4014
|
+
// service's newestRun.md N2): still that fragment's repeat, so no step
|
|
4015
|
+
// hit may be counted against it (R-HIT-7).
|
|
4016
|
+
if (created.fragmentOf)
|
|
4017
|
+
f.hitRunId = created.fragmentOf.runId;
|
|
3973
4018
|
return;
|
|
4019
|
+
}
|
|
3974
4020
|
run.recording = false;
|
|
3975
4021
|
f.state = "hit";
|
|
3976
4022
|
// Which run it hit matters from here on: this turn must not count step
|
|
@@ -3984,6 +4030,83 @@ export class ControlServer {
|
|
|
3984
4030
|
why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
|
|
3985
4031
|
});
|
|
3986
4032
|
}
|
|
4033
|
+
/**
|
|
4034
|
+
* Whether this matched turn's task is now the model's own, so its work is
|
|
4035
|
+
* the repeat the service offered to record (the service's newestRun.md N1):
|
|
4036
|
+
* offered, nothing recording, no fragment, and no plan of the prompt match
|
|
4037
|
+
* that ran a step or still may. A plan that handed over is the fragment's to
|
|
4038
|
+
* record (fallbk.md D7).
|
|
4039
|
+
*/
|
|
4040
|
+
repeatDue(run) {
|
|
4041
|
+
const r = run.repeat;
|
|
4042
|
+
if (!r || r.state !== "offered" || run.recording || run.finished || run.fragment)
|
|
4043
|
+
return false;
|
|
4044
|
+
const prompt = run.replays.find((s) => s.armedBy === "prompt");
|
|
4045
|
+
if (!prompt?.plan)
|
|
4046
|
+
return true;
|
|
4047
|
+
return prompt.retired && prompt.stepsPinned === 0 && !prompt.handover;
|
|
4048
|
+
}
|
|
4049
|
+
/**
|
|
4050
|
+
* Open the repeat run and record from this call on (newestRun.md N1).
|
|
4051
|
+
*
|
|
4052
|
+
* The same swap as {@link openFragment}: nothing allocated before it belongs
|
|
4053
|
+
* to it. Unlike a fragment it carries the turn's prompt, and its cost and
|
|
4054
|
+
* duration are the whole turn's, because no plan did any of the task. Nothing
|
|
4055
|
+
* waits for the service's answer: the steps queue behind the create, and a
|
|
4056
|
+
* create answered `id: null` — the service records no repeat of it — drops
|
|
4057
|
+
* them.
|
|
4058
|
+
*/
|
|
4059
|
+
openRepeat(session, run) {
|
|
4060
|
+
const r = run.repeat;
|
|
4061
|
+
if (!r || r.state !== "offered")
|
|
4062
|
+
return;
|
|
4063
|
+
const reason = this.repeatReason(run);
|
|
4064
|
+
const repeatOf = reason ? { runId: r.runId, reason } : { runId: r.runId };
|
|
4065
|
+
// The repeat is a run of the same host as the turn it records.
|
|
4066
|
+
const framework = frameworkOf(session);
|
|
4067
|
+
const id = this.recorder.startRun(run.input, { ...this.runMetadata(session), repeatOf }, framework ? { repeatOf, framework } : { repeatOf });
|
|
4068
|
+
for (const c of run.correlations.values()) {
|
|
4069
|
+
c.settled = true;
|
|
4070
|
+
if (c.fallback)
|
|
4071
|
+
clearTimeout(c.fallback);
|
|
4072
|
+
}
|
|
4073
|
+
run.builtIns.clear();
|
|
4074
|
+
run.runId = id;
|
|
4075
|
+
run.ordering = new StepIndexAllocator();
|
|
4076
|
+
run.recording = true;
|
|
4077
|
+
r.state = "open";
|
|
4078
|
+
logLine("run.repeat", {
|
|
4079
|
+
run: id,
|
|
4080
|
+
of: r.runId,
|
|
4081
|
+
reason,
|
|
4082
|
+
why: "the agent is doing a recorded task itself — recorded as that recording's newest run",
|
|
4083
|
+
});
|
|
4084
|
+
if (!isRunCreationAware(this.recorder))
|
|
4085
|
+
return;
|
|
4086
|
+
void this.recorder
|
|
4087
|
+
.runCreated(id)
|
|
4088
|
+
.catch(() => ({ created: true }))
|
|
4089
|
+
.then((created) => {
|
|
4090
|
+
if (created.created || run.runId !== id)
|
|
4091
|
+
return;
|
|
4092
|
+
run.recording = false;
|
|
4093
|
+
r.state = "refused";
|
|
4094
|
+
logLine("run.repeat_refused", {
|
|
4095
|
+
run: id,
|
|
4096
|
+
of: r.runId,
|
|
4097
|
+
why: "the service records no repeat of this recording — nothing recorded",
|
|
4098
|
+
});
|
|
4099
|
+
});
|
|
4100
|
+
}
|
|
4101
|
+
/**
|
|
4102
|
+
* Why the matched turn's plan did not run, as the service shows it: what
|
|
4103
|
+
* the service withheld it for, else the code of the gate that declined it
|
|
4104
|
+
* here, a plan that was late included.
|
|
4105
|
+
*/
|
|
4106
|
+
repeatReason(run) {
|
|
4107
|
+
const prompt = run.replays.find((s) => s.armedBy === "prompt") ?? run.lateMatch;
|
|
4108
|
+
return run.repeat?.withheld ?? prompt?.declined ?? undefined;
|
|
4109
|
+
}
|
|
3987
4110
|
/** Resolve with `p`, or null once `ms` passes or it rejects. */
|
|
3988
4111
|
async withinBudget(p, ms) {
|
|
3989
4112
|
let timer;
|
|
@@ -4432,10 +4555,13 @@ export class ControlServer {
|
|
|
4432
4555
|
return { error: "serverName required" };
|
|
4433
4556
|
const hold = Number(body.holdMs);
|
|
4434
4557
|
const startedAt = Number(body.startedAt);
|
|
4558
|
+
const ppid = Number(body.ppid);
|
|
4435
4559
|
const work = await this.replay.work.waitForWork(serverName, Number.isFinite(hold) && hold > 0 ? Math.min(hold, POLL_HOLD_MS) : POLL_HOLD_MS, signal, {
|
|
4436
4560
|
proxyId: typeof body.proxyId === "string" ? body.proxyId : undefined,
|
|
4437
4561
|
startedAt: Number.isFinite(startedAt) ? startedAt : undefined,
|
|
4438
4562
|
pid: Number.isFinite(pid) ? pid : undefined,
|
|
4563
|
+
// The host process that started the proxy (see `onScenarioRun`).
|
|
4564
|
+
ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : undefined,
|
|
4439
4565
|
});
|
|
4440
4566
|
return work ? { work } : {};
|
|
4441
4567
|
}
|
|
@@ -4509,6 +4635,14 @@ export class ControlServer {
|
|
|
4509
4635
|
* ONE RUN PER PLAN. A second call while the plan is executing would run
|
|
4510
4636
|
* every step again beside the first — duplicated writes — and is told so.
|
|
4511
4637
|
*
|
|
4638
|
+
* WHOSE PROXIES. `run_scenario` is the session's first call, so no
|
|
4639
|
+
* correlated call has bound its proxies yet, and with another session open
|
|
4640
|
+
* an unbound proxy may be either's: the plan's first step was refused. But
|
|
4641
|
+
* Claude Code starts all of a session's MCP servers as its own children, and
|
|
4642
|
+
* `bir-scenario` sends its parent's pid (`ppid`), as every proxy does with its
|
|
4643
|
+
* poll. The proxies with that parent are this session's, and are bound to it
|
|
4644
|
+
* before the plan runs (replay/executor.ts, `bindSiblings`).
|
|
4645
|
+
*
|
|
4512
4646
|
* `signal` aborts when `bir-scenario`'s request goes away — the host
|
|
4513
4647
|
* cancelled the call (Esc) or its own timeout fired — so no further step is
|
|
4514
4648
|
* started for a caller that can no longer read the result.
|
|
@@ -4530,6 +4664,12 @@ export class ControlServer {
|
|
|
4530
4664
|
});
|
|
4531
4665
|
return { ok: false, why: ALREADY_RUNNING };
|
|
4532
4666
|
}
|
|
4667
|
+
// `bir-scenario` and this session's proxies share the host process that
|
|
4668
|
+
// started them: bind those proxies to the session now, so the plan's first
|
|
4669
|
+
// step can run even while another session's proxies are live.
|
|
4670
|
+
const hostPid = Number(body.ppid);
|
|
4671
|
+
if (Number.isInteger(hostPid) && hostPid > 0)
|
|
4672
|
+
this.replay.work.bindSiblings(hostPid, call.sessionId);
|
|
4533
4673
|
this.runningPlans.add(state);
|
|
4534
4674
|
// The session is busy for as long as its plan runs, and sends no hook
|
|
4535
4675
|
// meanwhile: the host is waiting on this very call.
|
package/dist/proxy/session.js
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt-text — the user's words in a Claude Code prompt (fx121.md).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code puts blocks of its own in front of what the user typed: the Claude
|
|
5
|
+
* in Chrome guide, the file open in the IDE, the selection, a background task's
|
|
6
|
+
* notification. The hook's `prompt` carries them all, and the service embeds the
|
|
7
|
+
* prompt with a model that reads 512 tokens. The Chrome guide alone is 1,008: in
|
|
8
|
+
* a session with Claude in Chrome connected, every prompt was embedded as the
|
|
9
|
+
* guide, and every one matched the same recording at 1.000.
|
|
10
|
+
*
|
|
11
|
+
* The prompt-side twin of `housekeeping.ts`. That file covers host tools that
|
|
12
|
+
* are not work; this one covers host text that is not the user's. Like it, it is
|
|
13
|
+
* deliberately narrow: a closed list of blocks, complete ones only, from the
|
|
14
|
+
* front only. When in doubt, the text is the user's.
|
|
15
|
+
*/
|
|
16
|
+
/** Blocks Claude Code adds in front of the user's text (fx121 R2). Closed list. */
|
|
17
|
+
export declare const HOST_BLOCKS: readonly string[];
|
|
18
|
+
export interface PromptText {
|
|
19
|
+
/** The user's words: what the service embeds and matches, and what derive reads. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** Tags of the blocks removed from the front, in order. Empty for a plain prompt. */
|
|
22
|
+
stripped: string[];
|
|
23
|
+
/** Nothing of the user's is left, and the turn was a task notification (fx121 R7). */
|
|
24
|
+
eventOnly: boolean;
|
|
25
|
+
/** Length of the prompt as the hook gave it. */
|
|
26
|
+
rawChars: number;
|
|
27
|
+
}
|
|
28
|
+
/** The prompt exactly as the hook gave it: another host's text (fx121 R1), or one with nothing to clean. */
|
|
29
|
+
export declare function asSent(raw: string): PromptText;
|
|
30
|
+
/** The user's words in a Claude Code prompt, and what was taken off the front of them. */
|
|
31
|
+
export declare function userText(raw: string): PromptText;
|
|
32
|
+
//# sourceMappingURL=prompt-text.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt-text — the user's words in a Claude Code prompt (fx121.md).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code puts blocks of its own in front of what the user typed: the Claude
|
|
5
|
+
* in Chrome guide, the file open in the IDE, the selection, a background task's
|
|
6
|
+
* notification. The hook's `prompt` carries them all, and the service embeds the
|
|
7
|
+
* prompt with a model that reads 512 tokens. The Chrome guide alone is 1,008: in
|
|
8
|
+
* a session with Claude in Chrome connected, every prompt was embedded as the
|
|
9
|
+
* guide, and every one matched the same recording at 1.000.
|
|
10
|
+
*
|
|
11
|
+
* The prompt-side twin of `housekeeping.ts`. That file covers host tools that
|
|
12
|
+
* are not work; this one covers host text that is not the user's. Like it, it is
|
|
13
|
+
* deliberately narrow: a closed list of blocks, complete ones only, from the
|
|
14
|
+
* front only. When in doubt, the text is the user's.
|
|
15
|
+
*/
|
|
16
|
+
/** Blocks Claude Code adds in front of the user's text (fx121 R2). Closed list. */
|
|
17
|
+
export const HOST_BLOCKS = [
|
|
18
|
+
"browser_instruction", // Claude in Chrome: the tool guide, or "[Browser disconnected …]"
|
|
19
|
+
"ide_opened_file", // the IDE: the file that happens to be open
|
|
20
|
+
"ide_selection", // the IDE: the selected lines and their text
|
|
21
|
+
"system-reminder", // Claude Code's general wrapper for injected text
|
|
22
|
+
"task-notification", // a background task finished: a turn with no user text
|
|
23
|
+
];
|
|
24
|
+
/** An opening tag, after any whitespace, starting exactly at `lastIndex`. */
|
|
25
|
+
const OPENING_TAG = /\s*<([a-z][\w-]*)(?:\s[^<>]*)?>/y;
|
|
26
|
+
/** Claude Code's wrapper around text the user pasted, opening and closing. */
|
|
27
|
+
const PASTED_TAGS = /<\/?pasted_content(?:\s[^<>]*)?>/g;
|
|
28
|
+
/** The prompt exactly as the hook gave it: another host's text (fx121 R1), or one with nothing to clean. */
|
|
29
|
+
export function asSent(raw) {
|
|
30
|
+
return { text: raw, stripped: [], eventOnly: false, rawChars: raw.length };
|
|
31
|
+
}
|
|
32
|
+
/** The user's words in a Claude Code prompt, and what was taken off the front of them. */
|
|
33
|
+
export function userText(raw) {
|
|
34
|
+
const stripped = [];
|
|
35
|
+
let at = 0;
|
|
36
|
+
// fx121 R2, R3: complete host blocks, from the front only. A block ends at its
|
|
37
|
+
// first closing tag; one with no closing tag stays, and so does all after it.
|
|
38
|
+
for (;;) {
|
|
39
|
+
OPENING_TAG.lastIndex = at;
|
|
40
|
+
const open = OPENING_TAG.exec(raw);
|
|
41
|
+
if (!open || !HOST_BLOCKS.includes(open[1]))
|
|
42
|
+
break;
|
|
43
|
+
const closing = `</${open[1]}>`;
|
|
44
|
+
const end = raw.indexOf(closing, OPENING_TAG.lastIndex);
|
|
45
|
+
if (end < 0)
|
|
46
|
+
break;
|
|
47
|
+
stripped.push(open[1]);
|
|
48
|
+
at = end + closing.length;
|
|
49
|
+
}
|
|
50
|
+
const rest = raw.slice(at);
|
|
51
|
+
// fx121 R4: after R2, never before. A paste that starts with a copy of the
|
|
52
|
+
// Chrome guide would otherwise become a leading block, and R2 would remove it.
|
|
53
|
+
const unwrapped = rest.replace(PASTED_TAGS, "");
|
|
54
|
+
// fx121 R3: nothing to remove or unwrap, so the prompt goes as it came.
|
|
55
|
+
if (stripped.length === 0 && unwrapped === rest)
|
|
56
|
+
return asSent(raw);
|
|
57
|
+
const text = unwrapped.trim();
|
|
58
|
+
return {
|
|
59
|
+
text,
|
|
60
|
+
stripped,
|
|
61
|
+
eventOnly: text === "" && stripped.includes("task-notification"), // fx121 R7
|
|
62
|
+
rawChars: raw.length,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=prompt-text.js.map
|
|
@@ -11,6 +11,16 @@ export interface StartRunOptions {
|
|
|
11
11
|
scenarioId: string;
|
|
12
12
|
stepIndex: number;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Marks a *repeat* (the service's newestRun.md): a matched turn whose plan
|
|
16
|
+
* did not run, so the model is doing the recording's task itself. Sent only
|
|
17
|
+
* when the match offered it ({@link RunMatch.repeat}). `reason` is why the
|
|
18
|
+
* plan did not run. The service never prompt-matches a repeat.
|
|
19
|
+
*/
|
|
20
|
+
repeatOf?: {
|
|
21
|
+
runId: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
};
|
|
14
24
|
/**
|
|
15
25
|
* The run's `framework`, when the session named a host that is not Claude
|
|
16
26
|
* Code: its `app`, or `app:scope` (`langchain`, `langchain:billing`). The
|
|
@@ -176,6 +186,15 @@ export interface RunMatch {
|
|
|
176
186
|
* ordinary hand-out, and from an older service.
|
|
177
187
|
*/
|
|
178
188
|
withheld?: string | null;
|
|
189
|
+
/**
|
|
190
|
+
* The service's offer (its newestRun.md N5): should the model end up doing
|
|
191
|
+
* this task itself — the plan declined, withheld, late or off — record the
|
|
192
|
+
* turn as a repeat of `runId`, the recording's newest run. Absent or null
|
|
193
|
+
* from a service that does not record repeats: then nothing is recorded.
|
|
194
|
+
*/
|
|
195
|
+
repeat?: {
|
|
196
|
+
runId: string;
|
|
197
|
+
} | null;
|
|
179
198
|
}
|
|
180
199
|
/** Optional capability: `null` means "fresh run, keep recording". */
|
|
181
200
|
export interface MatchAware {
|
|
@@ -202,10 +221,18 @@ export declare function isMatchAware(r: Recorder): r is Recorder & MatchAware;
|
|
|
202
221
|
/** What the create call said about a run: fresh, or a hit on a fragment. */
|
|
203
222
|
export interface RunCreation {
|
|
204
223
|
created: boolean;
|
|
205
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* Set when this was a hit on an existing fragment (R-FALL-7, R-HIT-7) —
|
|
226
|
+
* with `created` too when the service recorded it all the same, as that
|
|
227
|
+
* fragment's newest run (its newestRun.md N2).
|
|
228
|
+
*/
|
|
206
229
|
fragmentOf?: {
|
|
207
230
|
runId: string;
|
|
208
231
|
};
|
|
232
|
+
/** Set when the service recorded the run as a repeat: the run it repeats. */
|
|
233
|
+
repeatOf?: {
|
|
234
|
+
runId: string;
|
|
235
|
+
};
|
|
209
236
|
}
|
|
210
237
|
export interface RunCreationAware {
|
|
211
238
|
runCreated(runId: string): Promise<RunCreation>;
|
|
@@ -475,6 +475,9 @@ export class RemoteRecorder {
|
|
|
475
475
|
// A fragment (fallbk.md D7). Omitted, not null, so an older service
|
|
476
476
|
// that does not know the field sees the body it always saw.
|
|
477
477
|
...(options?.fallbackOf ? { fallbackOf: options.fallbackOf } : {}),
|
|
478
|
+
// A repeat (the service's newestRun.md). Sent only after a match
|
|
479
|
+
// that offered it, so a service that would not know it never sees it.
|
|
480
|
+
...(options?.repeatOf ? { repeatOf: options.repeatOf } : {}),
|
|
478
481
|
// A chain with calls is inlined for this runner rather than stopped in
|
|
479
482
|
// front of its first call (segmented.md R-CALL-28).
|
|
480
483
|
supportsCalls: true,
|
|
@@ -496,7 +499,11 @@ export class RemoteRecorder {
|
|
|
496
499
|
why: "the create's first attempt failed, so the turn went ahead as a fresh one; the service kept its own run, and this one's steps are not sent",
|
|
497
500
|
});
|
|
498
501
|
}
|
|
499
|
-
entry.settle(b.matched ?? null, {
|
|
502
|
+
entry.settle(b.matched ?? null, {
|
|
503
|
+
created: b.id !== null,
|
|
504
|
+
fragmentOf: b.fragmentOf,
|
|
505
|
+
...(b.repeatOf ? { repeatOf: b.repeatOf } : {}),
|
|
506
|
+
});
|
|
500
507
|
}
|
|
501
508
|
catch (err) {
|
|
502
509
|
entry.settle(null, { created: true }); // fail-safe: on a create error, record normally
|
|
@@ -29,14 +29,16 @@
|
|
|
29
29
|
* every proxy polls under its own id, and a step goes to, in order:
|
|
30
30
|
*
|
|
31
31
|
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
|
-
* proxy reported,
|
|
32
|
+
* proxy reported, from an earlier step of this session it ran, or, when a
|
|
33
|
+
* direct plan starts, from the host process it shares with the
|
|
34
|
+
* `bir-scenario` that asked for the plan ({@link ProxyWorkQueue.bindSiblings});
|
|
33
35
|
* 2. otherwise an unbound proxy, but ONLY while no other session is live
|
|
34
36
|
* (none bound elsewhere, and no server with two proxies polling — each
|
|
35
37
|
* session starts its own). A lone session's unbound proxy can only be
|
|
36
|
-
* its own. With two sessions open,
|
|
37
|
-
*
|
|
38
|
-
* when the older session asked it ran the step on the newer
|
|
39
|
-
* upstream — another browser page, another database session — and
|
|
38
|
+
* its own. With two sessions open, a proxy nothing has tied to a session
|
|
39
|
+
* cannot be told apart from the other session's: "the newest" was a
|
|
40
|
+
* guess, and when the older session asked it ran the step on the newer
|
|
41
|
+
* one's upstream — another browser page, another database session — and
|
|
40
42
|
* answered wrongly;
|
|
41
43
|
* 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
|
|
42
44
|
* session is never used for this one: the call rejects with a
|
|
@@ -76,6 +78,12 @@ export interface PollerIdentity {
|
|
|
76
78
|
/** When the proxy process started, epoch ms — "newest" is judged by this. */
|
|
77
79
|
startedAt?: number;
|
|
78
80
|
pid?: number;
|
|
81
|
+
/**
|
|
82
|
+
* The process that started the proxy: its session's host. `bir-scenario`
|
|
83
|
+
* sends its own with `/scenario/run`, so a session's proxies can be told
|
|
84
|
+
* apart before they have called anything ({@link ProxyWorkQueue.bindSiblings}).
|
|
85
|
+
*/
|
|
86
|
+
ppid?: number;
|
|
79
87
|
}
|
|
80
88
|
/** Rejection reason when no proxy is polling for a server. */
|
|
81
89
|
export declare const NO_PROXY = "no_proxy";
|
|
@@ -113,6 +121,19 @@ export declare class ProxyWorkQueue {
|
|
|
113
121
|
* `/clear` starts a new session on the same proxies.
|
|
114
122
|
*/
|
|
115
123
|
bindProxy(proxyId: string, sessionId: string): void;
|
|
124
|
+
/**
|
|
125
|
+
* Bind to `sessionId` every live proxy started by host process `hostPid` —
|
|
126
|
+
* the process that also started the `bir-scenario` asking for this
|
|
127
|
+
* session's plan. Claude Code starts a session's MCP servers as its own
|
|
128
|
+
* children, so a shared parent ties a proxy to the session before it has
|
|
129
|
+
* called anything. Without it a direct plan's first step was refused
|
|
130
|
+
* whenever another session held a proxy for the same server: `run_scenario`
|
|
131
|
+
* is the session's first call, so no correlated call could have bound one.
|
|
132
|
+
*
|
|
133
|
+
* A proxy proven to belong to another session (a correlated call) is left
|
|
134
|
+
* alone. A legacy one, which sends no id, stays a pool.
|
|
135
|
+
*/
|
|
136
|
+
bindSiblings(hostPid: number, sessionId: string): void;
|
|
116
137
|
/**
|
|
117
138
|
* `POST /proxy/poll`. Resolves with work, or with `undefined` at the poll
|
|
118
139
|
* deadline so the proxy re-polls. Work held for this proxy while it was
|
package/dist/replay/executor.js
CHANGED
|
@@ -29,14 +29,16 @@
|
|
|
29
29
|
* every proxy polls under its own id, and a step goes to, in order:
|
|
30
30
|
*
|
|
31
31
|
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
|
-
* proxy reported,
|
|
32
|
+
* proxy reported, from an earlier step of this session it ran, or, when a
|
|
33
|
+
* direct plan starts, from the host process it shares with the
|
|
34
|
+
* `bir-scenario` that asked for the plan ({@link ProxyWorkQueue.bindSiblings});
|
|
33
35
|
* 2. otherwise an unbound proxy, but ONLY while no other session is live
|
|
34
36
|
* (none bound elsewhere, and no server with two proxies polling — each
|
|
35
37
|
* session starts its own). A lone session's unbound proxy can only be
|
|
36
|
-
* its own. With two sessions open,
|
|
37
|
-
*
|
|
38
|
-
* when the older session asked it ran the step on the newer
|
|
39
|
-
* upstream — another browser page, another database session — and
|
|
38
|
+
* its own. With two sessions open, a proxy nothing has tied to a session
|
|
39
|
+
* cannot be told apart from the other session's: "the newest" was a
|
|
40
|
+
* guess, and when the older session asked it ran the step on the newer
|
|
41
|
+
* one's upstream — another browser page, another database session — and
|
|
40
42
|
* answered wrongly;
|
|
41
43
|
* 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
|
|
42
44
|
* session is never used for this one: the call rejects with a
|
|
@@ -139,6 +141,38 @@ export class ProxyWorkQueue {
|
|
|
139
141
|
by: "correlated",
|
|
140
142
|
});
|
|
141
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Bind to `sessionId` every live proxy started by host process `hostPid` —
|
|
146
|
+
* the process that also started the `bir-scenario` asking for this
|
|
147
|
+
* session's plan. Claude Code starts a session's MCP servers as its own
|
|
148
|
+
* children, so a shared parent ties a proxy to the session before it has
|
|
149
|
+
* called anything. Without it a direct plan's first step was refused
|
|
150
|
+
* whenever another session held a proxy for the same server: `run_scenario`
|
|
151
|
+
* is the session's first call, so no correlated call could have bound one.
|
|
152
|
+
*
|
|
153
|
+
* A proxy proven to belong to another session (a correlated call) is left
|
|
154
|
+
* alone. A legacy one, which sends no id, stays a pool.
|
|
155
|
+
*/
|
|
156
|
+
bindSiblings(hostPid, sessionId) {
|
|
157
|
+
for (const info of this.proxies.values()) {
|
|
158
|
+
if (info.ppid !== hostPid || !this.isLive(info) || isLegacy(info.proxyId))
|
|
159
|
+
continue;
|
|
160
|
+
if (info.sessionId === sessionId)
|
|
161
|
+
continue;
|
|
162
|
+
if (info.sessionId && info.boundBy === "correlated")
|
|
163
|
+
continue;
|
|
164
|
+
info.sessionId = sessionId;
|
|
165
|
+
info.boundBy = "sibling";
|
|
166
|
+
logDetail("replay.proxy_bound", {
|
|
167
|
+
server: info.serverName,
|
|
168
|
+
proxy: shortId(info.proxyId),
|
|
169
|
+
pid: info.pid,
|
|
170
|
+
sess: sessionId,
|
|
171
|
+
by: "sibling",
|
|
172
|
+
host: hostPid,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
142
176
|
/**
|
|
143
177
|
* `POST /proxy/poll`. Resolves with work, or with `undefined` at the poll
|
|
144
178
|
* deadline so the proxy re-polls. Work held for this proxy while it was
|
|
@@ -325,6 +359,7 @@ export class ProxyWorkQueue {
|
|
|
325
359
|
serverName,
|
|
326
360
|
startedAt: Number.isFinite(started) && started > 0 ? started : now,
|
|
327
361
|
pid: who.pid,
|
|
362
|
+
ppid: who.ppid,
|
|
328
363
|
lastSeen: now,
|
|
329
364
|
});
|
|
330
365
|
}
|
package/docs/calculatedReplay.md
CHANGED
|
@@ -126,9 +126,12 @@ plan, and keeps the stop.
|
|
|
126
126
|
|
|
127
127
|
Server-side, and worth stating because the threshold in §4 is a judgement about it:
|
|
128
128
|
|
|
129
|
-
- The embedding is of the run's **`input
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
- The embedding is of the run's **`input`**, computed by `Xenova/all-MiniLM-L6-v2`
|
|
130
|
+
(`EMBEDDING_MODEL`), mean-pooled and normalized (`recordings/embeddings.ts`). The `input` is the
|
|
131
|
+
user's prompt as the runner cleaned it: Claude Code's own blocks in front of it removed and pasted
|
|
132
|
+
text unwrapped (fx121.md). Another host's prompt goes as it was sent. The model reads the first
|
|
133
|
+
512 tokens and drops the rest. The Claude in Chrome guide alone is 1,008, so a runner that sent it
|
|
134
|
+
in front of the prompt had only the guide embedded.
|
|
132
135
|
- Candidates are **the caller's own canonical runs only** (`listUserRunEmbeddings(userId)`);
|
|
133
136
|
duplicates and sub-threshold runs are excluded from the pool at record time.
|
|
134
137
|
- **Inside one host** — on the service as built, which is not deployed yet. A run created with
|
|
@@ -1383,7 +1386,7 @@ fail because of BaseInstRunner.**
|
|
|
1383
1386
|
| A call arrives while a pinned step is still running | It waits briefly for the pin to thread; still beside it, the plan hands over (`replay.concurrent_call`), `fell_back` or `failed` |
|
|
1384
1387
|
| A pinned step fails (`PostToolUseFailure`, an error report from its proxy) | The plan hands over; nothing after it is pinned or approved |
|
|
1385
1388
|
| A step cannot be sent: a built-in reached by a divergence, no proxy polling for its server, a server off `BIR_REPLAY_ALLOW_SERVERS` | Nothing is sent. The plan stops at that step and hands over (*"did not run … Nothing of it happened"*). With `BIR_REPLAY_RECORDED_OUTPUTS=1` its recorded output stands in instead |
|
|
1386
|
-
| Two sessions are open, and none of the server's proxies is known to be this session's | Nothing is sent: the step is not run, the plan hands over, and the model calls the tool on its own upstream. A lone session uses its unbound proxy as before |
|
|
1389
|
+
| Two sessions are open, and none of the server's proxies is known to be this session's | Nothing is sent: the step is not run, the plan hands over, and the model calls the tool on its own upstream. A lone session uses its unbound proxy as before. A direct plan first binds the proxies started by the same host process as its `bir-scenario`, so for one this is left to a proxy or `bir-scenario` that sends no `ppid` (an older runner), or a server the host started through a wrapper (a shell, `npx`), which gives it another parent |
|
|
1387
1390
|
| A direct step exceeds `BIR_STEP_TIMEOUT_MS` | The proxy cancels the call upstream (`notifications/cancelled`) just before the control server gives up; the plan stops and hands over (*"was sent but did not answer … may or may not have completed"*). No recorded output stands in |
|
|
1388
1391
|
| Whole plan exceeds its budget (`BIR_REPLAY_PLAN_BUDGET_MS`) | Return what completed behind a hand-over note, marked partial; outcome `diverged` (`failed` when no step had run) |
|
|
1389
1392
|
| `run_scenario` cancelled (Esc) or `bir-scenario`'s 290 s timeout | No further step starts. The note saying which steps ran reaches the model on its next prompt or tool call; `fell_back` or `failed`, `measured: false` |
|
|
@@ -1512,7 +1515,12 @@ export class ProxyWorkQueue {
|
|
|
1512
1515
|
> interchangeable: each holds its own upstream (another browser page, another database session). A
|
|
1513
1516
|
> step goes to a proxy bound to the step's session — learned from a correlated call it reported, or
|
|
1514
1517
|
> an earlier step of the session it ran — and otherwise to an unbound proxy **only while no other
|
|
1515
|
-
> session is live**.
|
|
1518
|
+
> session is live**. A direct plan starts with `run_scenario`, the session's first call, so no call
|
|
1519
|
+
> of its own can have bound a proxy yet. But Claude Code starts all of a session's MCP servers as its
|
|
1520
|
+
> own children: every proxy sends its parent's pid (`ppid`) with its poll, `bir-scenario` sends its
|
|
1521
|
+
> own with `/scenario/run`, and before the plan runs the proxies with that parent are bound to the
|
|
1522
|
+
> session (`bindSiblings`). A proxy proven by a correlated call to be another session's is never
|
|
1523
|
+
> rebound. With two sessions open, a session with no bound proxy for the server still gets none:
|
|
1516
1524
|
> the call rejects with `NoSessionProxyError`, nothing is sent, the step is recorded as `skipped`,
|
|
1517
1525
|
> and the plan hands over so the model calls the tool on its own upstream. `bir replay` has no
|
|
1518
1526
|
> session and takes the newest proxy.
|
|
@@ -637,6 +637,7 @@ Code's pool, as before.
|
|
|
637
637
|
|---|---|---|
|
|
638
638
|
| No `run.matched`, ever | Prompt below the server's `SIMILARITY_THRESHOLD` | Rephrase closer, or lower it server-side. Confirm the original run is in the list — a sub-`RECORDING_MIN_ACTIONS` run is never embedded |
|
|
639
639
|
| `run.matched` but no `plan.armed` | A gate declined | Read the `replay.decision` line's `why=`. Ranked by frequency: replay off — `bir replay off`, `BIR_REPLAY=0`, or never switched on here (a `bir install` without `--replay`; `bir replay status` says which) · scenario not `ready` · similarity below threshold · no step is executable |
|
|
640
|
+
| Every prompt in a session with Claude in Chrome connected matches the same recording, at 1.000 | A runner up to 0.3.12 sent the Claude in Chrome guide (1,008 tokens) as part of the prompt, and the service's model reads 512, so only the guide was embedded | Update the runner. Switch off the recordings whose prompt starts with `<browser_instruction>` (`bir investigate list` shows them), and record those tasks again: a recording keeps the embedding it was made with |
|
|
640
641
|
| `replay.decision … code=match_timeout` or `code=service_unhealthy` on many prompts | The service answered the match slower than `BIR_MATCH_BUDGET_MS` (3 s), or is failing and in its cool-down | Nothing breaks: those turns run normally, and after a slow match prompts stop waiting on it for a while by themselves (`replay.match_gate`). `bir doctor` shows the service's state. Raise `BIR_MATCH_BUDGET_MS` only if the service is reliably just over it — every prompt pays it |
|
|
641
642
|
| `replay.decision … code=arm_timeout`, `code=missing_target … derivation did not answer within …ms`, or `code=no_derive_key why="service: …"`, on a prompt that names its values | The reading of the prompt's values (the derivation) was too slow for the hook, or failed on the service. A hook waits `BIR_HOOK_DERIVE_BUDGET_MS` (8 s) for it, and the prompt hook never past 7.5 s from the prompt, match included; the service gives one reading `RUNNER_DERIVE_TIMEOUT_MS` (6 s) and then answers `service: error` or `service: timeout`, so its answer arrives inside the prompt's wait either way | Nothing to change in the prompt, and nothing to sign in to: that turn ran normally. The `replay.derived` line that follows says when the reading landed and what it cost. If it is slow every time, the service's reading model is the cause; `BIR_DERIVE_API_KEY` where Claude Code starts moves the reading to this machine |
|
|
642
643
|
| `replay.decision … code=no_delivery_tool` | A direct plan, and no running `bir` server has told the recorder it serves `run_scenario`: the server is missing, disabled or failed in `/mcp`, was not added (a subdirectory or worktree of a repository), or the session was started with `BIR_OFF=1` | `/mcp` shows whether `bir` is connected. `bir install --replay` adds it, in the project's own directory; then start a new session |
|
|
@@ -652,7 +653,7 @@ Code's pool, as before.
|
|
|
652
653
|
| `run_scenario` answers "Nothing to run for this request; continue normally." | The model called it on a turn with no plan, the runner is off, or no recorder is running for this directory | Harmless, and not an error. It happens when the model remembers the tool from an earlier turn |
|
|
653
654
|
| `run_scenario` answers "The prepared results are not available for this request. Do the task normally with your tools." | The plan could not be delivered — a recorder that does not answer, a timeout, an HTTP error — and the model does the task itself | The `scenario.answered` line on the `bir` server's stderr has the real reason. `BIR_SCENARIO_DEBUG=1` in that server's environment hands the raw reason to the model as an error, for debugging |
|
|
654
655
|
| A hand-over note says a step "was sent but did not answer … may or may not have completed" | The step timed out, or its upstream went away mid-call; nothing after it ran | Check whether its work happened before running it again — the model is told the same. Repeats: look at that upstream, or raise `BIR_STEP_TIMEOUT_MS` |
|
|
655
|
-
| A hand-over note says "a step never runs on another session's upstream" | Two Claude Code sessions are open in the project, and this one has not yet made a call of its own to that server, so bir cannot tell its proxy from the other session's | Expected: the model calls the tool itself. Once the session's own call to the server has gone through, later replays use its proxy |
|
|
656
|
+
| A hand-over note says "a step never runs on another session's upstream" | Two Claude Code sessions are open in the project, and this one has not yet made a call of its own to that server, so bir cannot tell its proxy from the other session's. A direct plan is told apart by the process that started both the `bir` server and the session's proxies, so for one this means an older proxy or `bir` server (restart the session after an update), or a server started through a wrapper such as a shell or `npx` | Expected: the model calls the tool itself. Once the session's own call to the server has gone through, later replays use its proxy |
|
|
656
657
|
| A hand-over note says a step "was not run by the host", or "ran with arguments a person chose, not the scenario's" | A LangChain application (§6.5). A person said no to the step, or answered in the tool's place, or a call limit blocked the call (`host_rejected`); or a person changed the call before it ran (`host_edited`) | Expected, and no fault of the plan: the step is booked `skipped`, the plan stops there and the model takes over. It is told not to run the step again — after a refusal, unless the user asks. §7 has what the report says |
|
|
657
658
|
| `recorder.send_failed … error="HTTP 409 … invalid_ticket"` | The execution report's ticket was already redeemed, or belongs to another scenario. Or the report came more than an hour after its plan was handed out | Harmless if the body says `duplicate: true`. Otherwise a rollover raced `SessionEnd`; the server books once. A report that came too late is not booked; the service as built (not deployed yet) takes a report for 24 hours |
|
|
658
659
|
| `recorder.send_failed … error="HTTP 429 …"` | The account is over one of the service's limits, and the answer is `429` with `Retry-After`. The limits are built on the service and not deployed yet; each is a setting there. By default: 120 runs a minute · 3,000 steps a minute (a tool call is two steps) · 10 title requests a minute · 20 requests in any one hour for Calculate, Prefer and Recalculate together (`bir scenario calc` then prints a line that begins `Could not start calculation (HTTP 429`) | What it costs: a create, a step or a report is sent once more, at most 2 s later, then dropped. A dropped step is a hole in its recording. A run whose create got the 429 runs without its plan. A 429 that the second try did not cure counts as the service being unwell: for 5 to 60 seconds no prompt of that recorder waits for a match. An intent probe over its own limit (1,500 a minute) is answered with no match, never with `429` |
|
|
@@ -702,6 +703,12 @@ fix*. The three questions it answers:
|
|
|
702
703
|
from the transcript, or the runner has no price for the model: update it."*
|
|
703
704
|
The `run.unmeasured` line names what was missing.
|
|
704
705
|
|
|
706
|
+
**A prompt the runner cleaned** has a `stripped` line under it: the blocks
|
|
707
|
+
Claude Code put in front of what you typed (the Claude in Chrome guide, the
|
|
708
|
+
open file, the selection), which the service never got, and the size before
|
|
709
|
+
and after. A background task's notification has no words of yours at all: it
|
|
710
|
+
shows `prompt (none: a task notification, never matched)`.
|
|
711
|
+
|
|
705
712
|
**A turn of another host** (a LangChain application, §6.5) is told apart by
|
|
706
713
|
the journal's `run.start` line, which carries `host=<app>`. For such a turn:
|
|
707
714
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/production.md
CHANGED
|
@@ -537,6 +537,8 @@ Over 2-minute runs with one turn every 8 s (92 hooks):
|
|
|
537
537
|
|
|
538
538
|
Truncation is always marked with the original size.
|
|
539
539
|
|
|
540
|
+
**The prompt.** In a Claude Code session the prompt is sent without the blocks Claude Code puts in front of it (the Claude in Chrome guide, the open file, the selection, a task notification), and pasted text is unwrapped (fx121.md). So the open file's path and the selected text stay on the machine. A task notification with no words of the user's is sent with an empty prompt, which the service never matches. Another host's prompt is sent as it came.
|
|
541
|
+
|
|
540
542
|
**What the service does with a report.** The service's new build refuses a report less easily for its size or its age (built, not deployed yet):
|
|
541
543
|
|
|
542
544
|
- The prompt inside a report is cut at 10,000 characters. It used to be refused with 400.
|