@basein/runner 0.3.12 → 0.3.13

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.
@@ -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 an empty body, as before.
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
@@ -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 an empty body, as before.
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
- body: JSON.stringify(toolUseId ? { toolUseId } : {}),
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;
@@ -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
- out.push({
317
- code: "no_match",
318
- severity: "info",
319
- title: "This turn matched no recording, so the agent did the task itself.",
320
- 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.",
321
- 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.",
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`.");
@@ -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;
@@ -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;
@@ -1166,6 +1168,14 @@ export declare class ControlServer {
1166
1168
  * ONE RUN PER PLAN. A second call while the plan is executing would run
1167
1169
  * every step again beside the first — duplicated writes — and is told so.
1168
1170
  *
1171
+ * WHOSE PROXIES. `run_scenario` is the session's first call, so no
1172
+ * correlated call has bound its proxies yet, and with another session open
1173
+ * an unbound proxy may be either's: the plan's first step was refused. But
1174
+ * Claude Code starts all of a session's MCP servers as its own children, and
1175
+ * `bir-scenario` sends its parent's pid (`ppid`), as every proxy does with its
1176
+ * poll. The proxies with that parent are this session's, and are bound to it
1177
+ * before the plan runs (replay/executor.ts, `bindSiblings`).
1178
+ *
1169
1179
  * `signal` aborts when `bir-scenario`'s request goes away — the host
1170
1180
  * cancelled the call (Esc) or its own timeout fired — so no further step is
1171
1181
  * started for a caller that can no longer read the result.
@@ -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";
@@ -1561,8 +1562,10 @@ export class ControlServer {
1561
1562
  *
1562
1563
  * `asked` is what the prompt that opens the run asked for, in a session of
1563
1564
  * another host: whether its adapter wants a plan (`HookPayload.replay`).
1565
+ * `cleaned` is how `onPrompt` got `input` from the hook's prompt, the only
1566
+ * caller that has one (fx121.md).
1564
1567
  */
1565
- ensureRun(session, input = "", asked = {}) {
1568
+ ensureRun(session, input = "", asked = {}, cleaned) {
1566
1569
  if (session.run && !session.run.finished)
1567
1570
  return session.run;
1568
1571
  // The sealed run stays reachable for a moment: a report or a post that
@@ -1572,7 +1575,12 @@ export class ControlServer {
1572
1575
  // A run of another host says so in its `framework`: the service keeps a
1573
1576
  // plan inside the host, and the scope, that recorded it.
1574
1577
  const framework = frameworkOf(session);
1575
- const runId = this.recorder.startRun(input, this.runMetadata(session), framework ? { framework } : undefined);
1578
+ // fx121 R8: blocks taken off the front of the prompt are said on the run
1579
+ // itself, and in the journal below. A prompt that was only unwrapped is not.
1580
+ const removed = cleaned && cleaned.stripped.length > 0
1581
+ ? { stripped: cleaned.stripped, eventOnly: cleaned.eventOnly, rawChars: cleaned.rawChars }
1582
+ : undefined;
1583
+ const runId = this.recorder.startRun(input, removed ? { ...this.runMetadata(session), prompt: removed } : this.runMetadata(session), framework ? { framework } : undefined);
1576
1584
  const run = {
1577
1585
  runId,
1578
1586
  input,
@@ -1607,6 +1615,8 @@ export class ControlServer {
1607
1615
  // on stderr, and never more than a preview.
1608
1616
  if (input)
1609
1617
  journal("run.prompt", { run: runId, prompt: input.slice(0, 200) });
1618
+ if (removed)
1619
+ journal("run.prompt_stripped", { run: runId, ...removed, chars: input.length });
1610
1620
  // Started here so the round trip overlaps whatever the caller does next;
1611
1621
  // `onPrompt` awaits the same memoized promise under its own budget.
1612
1622
  void this.watchForMatch(session, run);
@@ -2580,7 +2590,14 @@ export class ControlServer {
2580
2590
  }
2581
2591
  async onPrompt(payload) {
2582
2592
  const session = this.ensureSession(payload);
2583
- const input = payload.prompt ?? "";
2593
+ const other = otherHost(session);
2594
+ const raw = typeof payload.prompt === "string" ? payload.prompt : "";
2595
+ // fx121 R1, R6: the user's words, without Claude Code's own blocks in front
2596
+ // of them, and from here on the prompt everywhere. `input` is what the
2597
+ // service embeds, and its model reads 512 tokens: the Claude in Chrome
2598
+ // guide alone is 1,008. Another host's text goes as it was sent.
2599
+ const prompt = other ? asSent(raw) : userText(raw);
2600
+ const input = prompt.text;
2584
2601
  const open = session.run && !session.run.finished ? session.run : undefined;
2585
2602
  if (open?.promptSeen) {
2586
2603
  // A second prompt in the same session: close the previous turn's run and
@@ -2627,14 +2644,13 @@ export class ControlServer {
2627
2644
  });
2628
2645
  return {};
2629
2646
  }
2630
- const other = otherHost(session);
2631
2647
  if (other) {
2632
2648
  // What a host hands over with its prompt, kept before the run opens:
2633
2649
  // the match watched from there may start a plan at once, and the places
2634
2650
  // that do have no prompt payload in reach.
2635
2651
  session.hostSinks = hostSinksOf(payload);
2636
2652
  }
2637
- const run = this.ensureRun(session, input, { replay: other && payload.replay === true });
2653
+ const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
2638
2654
  run.promptSeen = true;
2639
2655
  run.input = input;
2640
2656
  session.prompts += 1;
@@ -4432,10 +4448,13 @@ export class ControlServer {
4432
4448
  return { error: "serverName required" };
4433
4449
  const hold = Number(body.holdMs);
4434
4450
  const startedAt = Number(body.startedAt);
4451
+ const ppid = Number(body.ppid);
4435
4452
  const work = await this.replay.work.waitForWork(serverName, Number.isFinite(hold) && hold > 0 ? Math.min(hold, POLL_HOLD_MS) : POLL_HOLD_MS, signal, {
4436
4453
  proxyId: typeof body.proxyId === "string" ? body.proxyId : undefined,
4437
4454
  startedAt: Number.isFinite(startedAt) ? startedAt : undefined,
4438
4455
  pid: Number.isFinite(pid) ? pid : undefined,
4456
+ // The host process that started the proxy (see `onScenarioRun`).
4457
+ ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : undefined,
4439
4458
  });
4440
4459
  return work ? { work } : {};
4441
4460
  }
@@ -4509,6 +4528,14 @@ export class ControlServer {
4509
4528
  * ONE RUN PER PLAN. A second call while the plan is executing would run
4510
4529
  * every step again beside the first — duplicated writes — and is told so.
4511
4530
  *
4531
+ * WHOSE PROXIES. `run_scenario` is the session's first call, so no
4532
+ * correlated call has bound its proxies yet, and with another session open
4533
+ * an unbound proxy may be either's: the plan's first step was refused. But
4534
+ * Claude Code starts all of a session's MCP servers as its own children, and
4535
+ * `bir-scenario` sends its parent's pid (`ppid`), as every proxy does with its
4536
+ * poll. The proxies with that parent are this session's, and are bound to it
4537
+ * before the plan runs (replay/executor.ts, `bindSiblings`).
4538
+ *
4512
4539
  * `signal` aborts when `bir-scenario`'s request goes away — the host
4513
4540
  * cancelled the call (Esc) or its own timeout fired — so no further step is
4514
4541
  * started for a caller that can no longer read the result.
@@ -4530,6 +4557,12 @@ export class ControlServer {
4530
4557
  });
4531
4558
  return { ok: false, why: ALREADY_RUNNING };
4532
4559
  }
4560
+ // `bir-scenario` and this session's proxies share the host process that
4561
+ // started them: bind those proxies to the session now, so the plan's first
4562
+ // step can run even while another session's proxies are live.
4563
+ const hostPid = Number(body.ppid);
4564
+ if (Number.isInteger(hostPid) && hostPid > 0)
4565
+ this.replay.work.bindSiblings(hostPid, call.sessionId);
4533
4566
  this.runningPlans.add(state);
4534
4567
  // The session is busy for as long as its plan runs, and sends no hook
4535
4568
  // meanwhile: the host is waiting on this very call.
@@ -438,6 +438,7 @@ export class ProxySession {
438
438
  proxyId: this.proxyId,
439
439
  startedAt: this.startedAt,
440
440
  pid: process.pid,
441
+ ppid: process.ppid,
441
442
  });
442
443
  const registered = await client.register({
443
444
  serverName: this.serverName,
@@ -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
@@ -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, or from an earlier step of this session it ran;
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, the one that has not called the server
37
- * yet cannot be told apart from the other: "the newest" was a guess, and
38
- * when the older session asked it ran the step on the newer one's
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
@@ -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, or from an earlier step of this session it ran;
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, the one that has not called the server
37
- * yet cannot be told apart from the other: "the newest" was a guess, and
38
- * when the older session asked it ran the step on the newer one's
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
  }
@@ -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`** — the user's prompt — computed by
130
- `Xenova/all-MiniLM-L6-v2` (`EMBEDDING_MODEL`), mean-pooled and normalized
131
- (`recordings/embeddings.ts`).
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**. With two sessions open, a session with no bound proxy for the server gets none:
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.12",
3
+ "version": "0.3.13",
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.