@basein/runner 0.3.11 → 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();
package/dist/bin/bir.js CHANGED
@@ -68,6 +68,7 @@ import { SCENARIO_EDIT_SUBCOMMANDS, scenarioEditCommand } from "./scenario-edit.
68
68
  import { loadCredentials } from "../auth/client.js";
69
69
  import { packageVersion } from "../util/version.js";
70
70
  import { runnerOutdated, serviceHeaders } from "../util/service-headers.js";
71
+ import { localDay } from "../replay/derive.js";
71
72
  const VERSION = packageVersion();
72
73
  /**
73
74
  * The npx SessionStart hook's own budget, passed as `ensure --budget-ms <n>`
@@ -3630,6 +3631,9 @@ async function replayCommand(args) {
3630
3631
  if (args.dry) {
3631
3632
  const { status, body } = await service("POST", `/scenarios/${scenarioId}/replay`, {
3632
3633
  prompt: args.prompt,
3634
+ // The day on this machine, as a session's reading sends it: a dry
3635
+ // replay works a value like "tomorrow" out from the same day.
3636
+ today: localDay(),
3633
3637
  });
3634
3638
  if (status !== 200) {
3635
3639
  out(`Dry replay failed (HTTP ${status}): ${JSON.stringify(body)}`);
@@ -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;
@@ -217,6 +223,11 @@ export const DECLINES = {
217
223
  cause: "the plan was not ready before the hook that would have announced it had to answer: the prompt hook waits 7.5 s in all, match included, and a tool call that starts a plan mid-task waits 1.5 s. What arming waits for is the reading of the prompt's values (the derivation). A plan that was not announced is never run.",
218
224
  fix: "run the prompt again. If it keeps happening the reading is slow: the recorder's log (`bir status` names the file) shows when its `replay.derived` line landed, and a dry replay shows what the service's reading took, under `reading` (`bir scenario replay <scnId> --prompt \"…\" --dry --json`). BIR_HOOK_DERIVE_BUDGET_MS does not help here, because the hook's own limit was reached first. To read values on this machine instead of asking the service, set BIR_DERIVE_API_KEY where Claude Code starts.",
219
225
  },
226
+ // Nor is this one: the match itself was late, so nothing was armed at all.
227
+ match_late: {
228
+ cause: "the service's answer to the match landed too late: after the prompt hook had stopped waiting for it (3 s, BIR_MATCH_BUDGET_MS); while prompts were not waiting on the match at all — after one slow answer they stop waiting for a few minutes (`replay.match_gate state=holding`); or with the hook not waiting from the start, because a failure to reach the service was still on record (a `service.degraded` line, perhaps hours before, and no `service.recovered` until after this prompt) or the match request's first attempt failed. The agent did the task itself.",
229
+ fix: "nothing in the prompt has to change: run it again a few minutes later. A `service.recovered` line just after this turn's `run.start` means an old failure was still on record, and the next prompt waits again. If it keeps happening without one, the service answers matches slowly: `bir doctor` shows its state, and `/health.matchGate` when prompts wait again.",
230
+ },
220
231
  runner_update_required: {
221
232
  cause: "the service matched the prompt and handed over no plan: this runner is older than its plans need.",
222
233
  fix: "update the runner (`bir update`), then run `bir setup` in each project that uses it; `bir doctor` says when the service is satisfied.",
@@ -308,13 +319,17 @@ const pct = (v) => `${(v * 100).toFixed(1)}%`;
308
319
  export function localFindings(turn) {
309
320
  const out = [];
310
321
  if (!turn.matched) {
311
- out.push({
312
- code: "no_match",
313
- severity: "info",
314
- title: "This turn matched no recording, so the agent did the task itself.",
315
- 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.",
316
- 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.",
317
- });
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
+ }
318
333
  }
319
334
  else if (turn.decision?.verdict === "no-steer") {
320
335
  const code = turn.decision.code ?? "";
@@ -478,6 +493,8 @@ export function savedPct(turn) {
478
493
  return baseline > 0 ? r.savedUsd / baseline : null;
479
494
  }
480
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, ",");
481
498
  const money = (v) => (v == null ? "—" : usd(v));
482
499
  const when = (iso) => (iso ? iso.replace("T", " ").replace(/\.\d+Z$/, "Z") : "—");
483
500
  function renderFindings(out, findings, heading) {
@@ -495,12 +512,21 @@ function renderFindings(out, findings, heading) {
495
512
  }
496
513
  export function renderTurn(out, turn) {
497
514
  out(`Turn ${turn.runId} ${when(turn.startedAt)}${turn.sessionId ? ` session ${turn.sessionId.slice(0, 8)}` : ""}`);
498
- if (turn.prompt)
515
+ if (turn.prompt) {
499
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
+ }
500
526
  if (turn.matched) {
501
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"}`);
502
528
  }
503
- else {
529
+ else if (!turn.eventOnly) {
504
530
  out(" matched no recording — the agent ran the task; this turn was recorded");
505
531
  }
506
532
  if (turn.decision?.verdict === "no-steer") {
@@ -667,6 +693,8 @@ export async function investigateCommand(args, deps) {
667
693
  out(` ${when(t.startedAt)} ${t.runId} ${verdict}${saved}`);
668
694
  if (t.prompt)
669
695
  out(` "${t.prompt.replace(/\s+/g, " ").slice(0, 90)}"`);
696
+ else if (t.eventOnly)
697
+ out(" (none: a task notification)");
670
698
  }
671
699
  out();
672
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;
@@ -582,6 +584,12 @@ export declare class ControlServer {
582
584
  * plan. Memoized, because `ensureRun` is reached from five different hooks and
583
585
  * arming twice would run the scenario twice.
584
586
  *
587
+ * Two answers are followed. `pending` is what the prompt hook may wait for,
588
+ * and the recorder answers it null at once while a failure is on record or
589
+ * once the create's first attempt failed. `settled` is the service's own
590
+ * answer, whenever it lands: the hit that stops the recording, and a ticket
591
+ * the turn owes a report even when nobody waited for it.
592
+ *
585
593
  * Resolves with the steering directive to inject, or undefined.
586
594
  */
587
595
  private watchForMatch;
@@ -593,6 +601,11 @@ export declare class ControlServer {
593
601
  * match. A match that misses its budget, or a plan still deriving when the
594
602
  * budget runs out, is never delivered: the turn runs as an ordinary one, and
595
603
  * one `replay.decision` line says why (docs/calculatedReplay.md §10).
604
+ *
605
+ * `pending` is what the prompt may wait for; `settled` is the service's own
606
+ * answer, whenever it lands (see {@link watchForMatch}). Every way of going
607
+ * ahead without a match follows `settled`, so a hand-out nobody waited for
608
+ * still has its ticket reported ({@link followLateMatch}).
596
609
  */
597
610
  private armFromMatch;
598
611
  /**
@@ -607,6 +620,25 @@ export declare class ControlServer {
607
620
  * not several, so the hold doubles at most once per hold (on its probe).
608
621
  */
609
622
  private noteMatchLatency;
623
+ /**
624
+ * The prompt went ahead without the match: when the service's own answer
625
+ * lands, a hit is a hand-out nobody used. It is said once, as `match_late`
626
+ * with `why`, and its ticket is carried to the turn's report
627
+ * ({@link noteLateMatch}). A miss says nothing: the service has no ticket out.
628
+ */
629
+ private followLateMatch;
630
+ /**
631
+ * A match that landed after its prompt had been answered, or while prompts
632
+ * were not waiting on it. Nothing is armed for it, and nothing derived. But
633
+ * the service issued a ticket with that hand-out, and without a report under
634
+ * it the console can only say the turn never reported — for a turn the agent
635
+ * simply did itself. So the turn carries the ticket to its report, as a
636
+ * decline with its own code, like a plan that was only late ({@link shelve}).
637
+ *
638
+ * Only a hand-out with a ticket: a match the service handed nothing for has
639
+ * its reason on the service already. And only while the turn is open.
640
+ */
641
+ private noteLateMatch;
610
642
  /**
611
643
  * {@link armFromMatch} with replay off: nothing can be announced, and no hook
612
644
  * waits for the answer (`onPrompt` has already returned). So there is no
@@ -614,7 +646,8 @@ export declare class ControlServer {
614
646
  * had, and must not mark the service slow (that would open the process-wide
615
647
  * cool-down, and `/health` would report a degradation the user never had).
616
648
  * A hit still books its decline — the ledger's baseline sample — whenever
617
- * it lands, as long as the turn is still open to carry it.
649
+ * it lands, as long as the turn is still open to carry it. So `settled` is
650
+ * the service's own answer, not the one a hook may have been given at once.
618
651
  */
619
652
  private noteDisabledMatch;
620
653
  /**
@@ -1135,6 +1168,14 @@ export declare class ControlServer {
1135
1168
  * ONE RUN PER PLAN. A second call while the plan is executing would run
1136
1169
  * every step again beside the first — duplicated writes — and is told so.
1137
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
+ *
1138
1179
  * `signal` aborts when `bir-scenario`'s request goes away — the host
1139
1180
  * cancelled the call (Esc) or its own timeout fired — so no further step is
1140
1181
  * started for a caller that can no longer read the result.
@@ -33,10 +33,11 @@ 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";
39
- import { NullRecorder, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioReporter, } from "../record/recorder.js";
40
+ import { NullRecorder, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioReporter, isServiceProber, } from "../record/recorder.js";
40
41
  import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
41
42
  import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
42
43
  import { logDetail, logLine, logOnce, errText } from "../util/log.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);
@@ -1640,6 +1650,12 @@ export class ControlServer {
1640
1650
  * plan. Memoized, because `ensureRun` is reached from five different hooks and
1641
1651
  * arming twice would run the scenario twice.
1642
1652
  *
1653
+ * Two answers are followed. `pending` is what the prompt hook may wait for,
1654
+ * and the recorder answers it null at once while a failure is on record or
1655
+ * once the create's first attempt failed. `settled` is the service's own
1656
+ * answer, whenever it lands: the hit that stops the recording, and a ticket
1657
+ * the turn owes a report even when nobody waited for it.
1658
+ *
1643
1659
  * Resolves with the steering directive to inject, or undefined.
1644
1660
  */
1645
1661
  watchForMatch(session, run, prompt = "") {
@@ -1650,7 +1666,10 @@ export class ControlServer {
1650
1666
  const runId = run.runId;
1651
1667
  const startedAt = Date.now();
1652
1668
  const pending = this.recorder.getMatch(runId).catch(() => null);
1653
- void pending.then((match) => {
1669
+ const settled = this.recorder.settledMatch
1670
+ ? this.recorder.settledMatch(runId).catch(() => null)
1671
+ : pending;
1672
+ void settled.then((match) => {
1654
1673
  if (!match || run.runId !== runId)
1655
1674
  return;
1656
1675
  run.recording = false;
@@ -1662,7 +1681,7 @@ export class ControlServer {
1662
1681
  why: "similar prompt — the service kept its own run; not recording this one",
1663
1682
  });
1664
1683
  });
1665
- run.matchWatch = this.armFromMatch(session, run, pending, prompt, startedAt).catch((err) => {
1684
+ run.matchWatch = this.armFromMatch(session, run, pending, settled, prompt, startedAt).catch((err) => {
1666
1685
  // Replay is an optimisation; a broken one must never cost a turn.
1667
1686
  logLine("replay.arm_failed", { run: run.runId, error: errText(err) });
1668
1687
  return undefined;
@@ -1677,12 +1696,17 @@ export class ControlServer {
1677
1696
  * match. A match that misses its budget, or a plan still deriving when the
1678
1697
  * budget runs out, is never delivered: the turn runs as an ordinary one, and
1679
1698
  * one `replay.decision` line says why (docs/calculatedReplay.md §10).
1699
+ *
1700
+ * `pending` is what the prompt may wait for; `settled` is the service's own
1701
+ * answer, whenever it lands (see {@link watchForMatch}). Every way of going
1702
+ * ahead without a match follows `settled`, so a hand-out nobody waited for
1703
+ * still has its ticket reported ({@link followLateMatch}).
1680
1704
  */
1681
- async armFromMatch(session, run, pending, prompt, startedAt) {
1705
+ async armFromMatch(session, run, pending, settled, prompt, startedAt) {
1682
1706
  // Off for the recorder, or — in a session of another host — not asked
1683
1707
  // for by this run's adapter: either way nothing can be announced.
1684
1708
  if (!this.replay.enabled || this.recordsOnly(session, run)) {
1685
- return this.noteDisabledMatch(session, run, pending, prompt);
1709
+ return this.noteDisabledMatch(session, run, settled, prompt);
1686
1710
  }
1687
1711
  const promptWaitMs = this.promptWaitMs();
1688
1712
  const matchBudget = Math.min(this.replay.budgets.matchMs, promptWaitMs);
@@ -1699,18 +1723,7 @@ export class ControlServer {
1699
1723
  run: run.runId,
1700
1724
  why: "the service has been answering matches slowly — not waited on; this turn runs normally",
1701
1725
  });
1702
- void pending.then((late) => {
1703
- if (!late)
1704
- return;
1705
- logLine("replay.decision", {
1706
- verdict: "no-steer",
1707
- code: "match_late",
1708
- run: run.runId,
1709
- scenario: late.scenarioId ?? undefined,
1710
- ms: Date.now() - startedAt,
1711
- why: "the match landed while prompts were not waiting on it — not steered, and not recorded",
1712
- });
1713
- });
1726
+ this.followLateMatch(session, run, settled, startedAt, "the match landed while prompts were not waiting on it — not steered, and not recorded");
1714
1727
  return undefined;
1715
1728
  }
1716
1729
  const cooling = !this.service.canWait();
@@ -1734,23 +1747,18 @@ export class ControlServer {
1734
1747
  ? "the service is failing — not waited on; this turn runs normally"
1735
1748
  : "the match did not answer inside its budget; this turn runs normally",
1736
1749
  });
1737
- void pending.then((late) => {
1738
- if (!late)
1739
- return;
1740
- logLine("replay.decision", {
1741
- verdict: "no-steer",
1742
- code: "match_late",
1743
- run: run.runId,
1744
- scenario: late.scenarioId ?? undefined,
1745
- ms: Date.now() - startedAt,
1746
- why: "the match landed after the prompt was answered — not steered, and not recorded",
1747
- });
1748
- });
1750
+ this.followLateMatch(session, run, settled, startedAt, "the match landed after the prompt was answered — not steered, and not recorded");
1749
1751
  }
1750
1752
  return undefined;
1751
1753
  }
1752
- if (!match)
1754
+ if (!match) {
1755
+ // "No match" — which may have come without waiting: the recorder answers
1756
+ // the prompt so at once while a service failure is on record, however
1757
+ // old, and once the create's first attempt failed. The service may have
1758
+ // matched the prompt all the same, and issued a ticket.
1759
+ this.followLateMatch(session, run, settled, startedAt, "the match landed after the prompt had gone ahead without it (a service failure was still on record, or the create's first attempt failed) — not steered, and not recorded");
1753
1760
  return undefined;
1761
+ }
1754
1762
  // Awaited: a scenario with a target derives before the directive goes
1755
1763
  // out, so a plan that cannot find its target is never delivered
1756
1764
  // (segmented.md R-PARAM-4). Inside the prompt's own budget.
@@ -1869,6 +1877,45 @@ export class ControlServer {
1869
1877
  why: "the match answered fast again — prompts wait on it as before",
1870
1878
  });
1871
1879
  }
1880
+ /**
1881
+ * The prompt went ahead without the match: when the service's own answer
1882
+ * lands, a hit is a hand-out nobody used. It is said once, as `match_late`
1883
+ * with `why`, and its ticket is carried to the turn's report
1884
+ * ({@link noteLateMatch}). A miss says nothing: the service has no ticket out.
1885
+ */
1886
+ followLateMatch(session, run, settled, startedAt, why) {
1887
+ void settled.then((late) => {
1888
+ if (!late)
1889
+ return;
1890
+ logLine("replay.decision", {
1891
+ verdict: "no-steer",
1892
+ code: "match_late",
1893
+ run: run.runId,
1894
+ scenario: late.scenarioId ?? undefined,
1895
+ ms: Date.now() - startedAt,
1896
+ why,
1897
+ });
1898
+ this.noteLateMatch(session, run, late);
1899
+ });
1900
+ }
1901
+ /**
1902
+ * A match that landed after its prompt had been answered, or while prompts
1903
+ * were not waiting on it. Nothing is armed for it, and nothing derived. But
1904
+ * the service issued a ticket with that hand-out, and without a report under
1905
+ * it the console can only say the turn never reported — for a turn the agent
1906
+ * simply did itself. So the turn carries the ticket to its report, as a
1907
+ * decline with its own code, like a plan that was only late ({@link shelve}).
1908
+ *
1909
+ * Only a hand-out with a ticket: a match the service handed nothing for has
1910
+ * its reason on the service already. And only while the turn is open.
1911
+ */
1912
+ noteLateMatch(session, run, match) {
1913
+ if (run.finished || run.lateMatch || !match.scenarioId || !match.executionTicket)
1914
+ return;
1915
+ const state = this.replay.lateMatch(match, this.hostContext(session, run));
1916
+ state.sessionId = session.sessionId;
1917
+ run.lateMatch = state;
1918
+ }
1872
1919
  /**
1873
1920
  * {@link armFromMatch} with replay off: nothing can be announced, and no hook
1874
1921
  * waits for the answer (`onPrompt` has already returned). So there is no
@@ -1876,10 +1923,11 @@ export class ControlServer {
1876
1923
  * had, and must not mark the service slow (that would open the process-wide
1877
1924
  * cool-down, and `/health` would report a degradation the user never had).
1878
1925
  * A hit still books its decline — the ledger's baseline sample — whenever
1879
- * it lands, as long as the turn is still open to carry it.
1926
+ * it lands, as long as the turn is still open to carry it. So `settled` is
1927
+ * the service's own answer, not the one a hook may have been given at once.
1880
1928
  */
1881
- async noteDisabledMatch(session, run, pending, prompt) {
1882
- const match = await pending;
1929
+ async noteDisabledMatch(session, run, settled, prompt) {
1930
+ const match = await settled;
1883
1931
  if (!match || run.finished)
1884
1932
  return undefined;
1885
1933
  // Declined at its first gate, before derivation: no transcript is read for it.
@@ -2436,6 +2484,25 @@ export class ControlServer {
2436
2484
  }
2437
2485
  this.queue.push(() => recorder.reportExecution(report));
2438
2486
  });
2487
+ // The prompt match that landed too late (see `noteLateMatch`). Not when a
2488
+ // plan of the same scenario armed later in the turn: that one has a ticket
2489
+ // of its own, and says what happened. The turn's cost is a sample of the
2490
+ // task's usual cost only when no plan ran in it at all.
2491
+ const late = run.lateMatch;
2492
+ if (late && !states.some((s) => s.scenarioId === late.scenarioId)) {
2493
+ const alone = states.length === 0;
2494
+ const report = this.replay.buildReport(late, {
2495
+ sessionCostUsd: alone ? cost.usd : 0,
2496
+ measured: alone && cost.measured,
2497
+ durationMs,
2498
+ prompt: run.input || undefined,
2499
+ siblings: [...states, late],
2500
+ runId: run.runId,
2501
+ cutShort: cutShort || undefined,
2502
+ });
2503
+ if (report)
2504
+ this.queue.push(() => recorder.reportExecution(report));
2505
+ }
2439
2506
  }
2440
2507
  /**
2441
2508
  * Seal the session's run now, and drain it to the service in the background.
@@ -2513,11 +2580,24 @@ export class ControlServer {
2513
2580
  cwd: session.cwd,
2514
2581
  why: "run deferred to prompt",
2515
2582
  });
2583
+ // The first prompt is seconds away, and its match is not waited for while
2584
+ // a service failure is on record — one from hours ago included, when the
2585
+ // machine slept or went offline and nothing was sent since. Find out now,
2586
+ // in the background, whether the service answers again.
2587
+ if (isServiceProber(this.recorder))
2588
+ this.recorder.probeIfFailing();
2516
2589
  return {};
2517
2590
  }
2518
2591
  async onPrompt(payload) {
2519
2592
  const session = this.ensureSession(payload);
2520
- 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;
2521
2601
  const open = session.run && !session.run.finished ? session.run : undefined;
2522
2602
  if (open?.promptSeen) {
2523
2603
  // A second prompt in the same session: close the previous turn's run and
@@ -2564,14 +2644,13 @@ export class ControlServer {
2564
2644
  });
2565
2645
  return {};
2566
2646
  }
2567
- const other = otherHost(session);
2568
2647
  if (other) {
2569
2648
  // What a host hands over with its prompt, kept before the run opens:
2570
2649
  // the match watched from there may start a plan at once, and the places
2571
2650
  // that do have no prompt payload in reach.
2572
2651
  session.hostSinks = hostSinksOf(payload);
2573
2652
  }
2574
- const run = this.ensureRun(session, input, { replay: other && payload.replay === true });
2653
+ const run = this.ensureRun(session, input, { replay: other && payload.replay === true }, prompt);
2575
2654
  run.promptSeen = true;
2576
2655
  run.input = input;
2577
2656
  session.prompts += 1;
@@ -4369,10 +4448,13 @@ export class ControlServer {
4369
4448
  return { error: "serverName required" };
4370
4449
  const hold = Number(body.holdMs);
4371
4450
  const startedAt = Number(body.startedAt);
4451
+ const ppid = Number(body.ppid);
4372
4452
  const work = await this.replay.work.waitForWork(serverName, Number.isFinite(hold) && hold > 0 ? Math.min(hold, POLL_HOLD_MS) : POLL_HOLD_MS, signal, {
4373
4453
  proxyId: typeof body.proxyId === "string" ? body.proxyId : undefined,
4374
4454
  startedAt: Number.isFinite(startedAt) ? startedAt : undefined,
4375
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,
4376
4458
  });
4377
4459
  return work ? { work } : {};
4378
4460
  }
@@ -4446,6 +4528,14 @@ export class ControlServer {
4446
4528
  * ONE RUN PER PLAN. A second call while the plan is executing would run
4447
4529
  * every step again beside the first — duplicated writes — and is told so.
4448
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
+ *
4449
4539
  * `signal` aborts when `bir-scenario`'s request goes away — the host
4450
4540
  * cancelled the call (Esc) or its own timeout fired — so no further step is
4451
4541
  * started for a caller that can no longer read the result.
@@ -4467,6 +4557,12 @@ export class ControlServer {
4467
4557
  });
4468
4558
  return { ok: false, why: ALREADY_RUNNING };
4469
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);
4470
4566
  this.runningPlans.add(state);
4471
4567
  // The session is busy for as long as its plan runs, and sends no hook
4472
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