@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.
@@ -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
@@ -180,6 +180,16 @@ export interface RunMatch {
180
180
  /** Optional capability: `null` means "fresh run, keep recording". */
181
181
  export interface MatchAware {
182
182
  getMatch(runId: string): Promise<RunMatch | null>;
183
+ /**
184
+ * The create call's own answer, whenever it lands. `getMatch` is what a hook
185
+ * may wait for, and a recorder may answer it null without waiting — while
186
+ * the service has a failure on record, or once the create's first attempt
187
+ * failed. The service can still match the prompt then, hand out the scenario
188
+ * and issue a ticket the turn owes a report; this is how the turn finds out.
189
+ * Never for a hook to wait on. Absent where `getMatch` always waits for the
190
+ * create's answer: it is the same answer then.
191
+ */
192
+ settledMatch?(runId: string): Promise<RunMatch | null>;
183
193
  }
184
194
  export declare function isMatchAware(r: Recorder): r is Recorder & MatchAware;
185
195
  /**
@@ -201,6 +211,21 @@ export interface RunCreationAware {
201
211
  runCreated(runId: string): Promise<RunCreation>;
202
212
  }
203
213
  export declare function isRunCreationAware(r: Recorder): r is Recorder & RunCreationAware;
214
+ /**
215
+ * Optional capability: a prompt is on its way, so the service verdict it will
216
+ * be decided by should be current.
217
+ *
218
+ * A hook never waits on a service with a failure on record, and is never the
219
+ * request that finds out whether it answers again. After hours with nothing
220
+ * sent — a laptop asleep, a network that dropped — the failure is still on
221
+ * record at the next prompt, whose match is then not waited for however fast
222
+ * the service answers. Called when a session starts, before its first prompt:
223
+ * it checks in the background, and sends nothing while no failure is on record.
224
+ */
225
+ export interface ServiceProber {
226
+ probeIfFailing(): void;
227
+ }
228
+ export declare function isServiceProber(r: Recorder): r is Recorder & ServiceProber;
204
229
  /** What a ReAct iteration sends to find a scenario by its intent (fallbk.md §Runner 4). */
205
230
  export interface IntentMatchRequest {
206
231
  /**
@@ -14,6 +14,9 @@ export function isMatchAware(r) {
14
14
  export function isRunCreationAware(r) {
15
15
  return typeof r.runCreated === "function";
16
16
  }
17
+ export function isServiceProber(r) {
18
+ return typeof r.probeIfFailing === "function";
19
+ }
17
20
  export function isIntentMatcher(r) {
18
21
  return typeof r.matchIntent === "function";
19
22
  }
@@ -43,8 +43,10 @@
43
43
  * SIMILAR-PROMPT HITS. `POST /recordings/runs` may answer `{ id: null, matched }`,
44
44
  * meaning the server recognised the prompt and did **not** create a run. Every
45
45
  * later step post for that id would then 404, so callers must stop recording —
46
- * {@link RemoteRecorder.getMatch} is how they find out — and this recorder skips
47
- * them itself too, for the caller that stopped waiting before the answer came.
46
+ * {@link RemoteRecorder.settledMatch} is how they find out, whenever the answer
47
+ * lands ({@link RemoteRecorder.getMatch} is only what a hook may wait for) — and
48
+ * this recorder skips them itself too, for the caller that stopped waiting
49
+ * before the answer came.
48
50
  */
49
51
  import { type AuthSession } from "../auth/client.js";
50
52
  import { type ServiceHealth } from "../util/service-health.js";
@@ -187,11 +189,34 @@ export declare class RemoteRecorder implements Recorder, RecorderHealthReporter
187
189
  matchIntent(req: IntentMatchRequest, opts?: IntentMatchOptions): Promise<IntentMatchAnswer | null>;
188
190
  /**
189
191
  * The similar-prompt match for a run's create call (null = fresh run). Like
190
- * {@link runCreated}, answers null at once while the service is cooling down,
191
- * and as soon as the create's first attempt fails — the first prompt of an
192
- * outage is not held for the retry's sleep (up to 2 s behind a Retry-After).
192
+ * {@link runCreated}, answers null at once while a failure is on record —
193
+ * in a cool-down, and after it until an answer clears it, however long ago
194
+ * it was — and as soon as the create's first attempt fails: the first prompt
195
+ * of an outage is not held for the retry's sleep (up to 2 s behind a
196
+ * Retry-After). The create goes on, and its own answer is
197
+ * {@link settledMatch}'s.
193
198
  */
194
199
  getMatch(runId: string): Promise<RunMatch | null>;
200
+ /**
201
+ * The create's own match (null = fresh run), whenever it lands — after the
202
+ * cool-down and the retry, if it took them — however {@link getMatch}
203
+ * answered the hook. A hook told "no match" at once, because a failure was
204
+ * on record or the first attempt failed, may be about a prompt the service
205
+ * matched all the same: it then handed out the scenario and issued a ticket,
206
+ * and the turn owes that ticket a report. Never for a hook to wait on. Null
207
+ * for an id this recorder never opened.
208
+ */
209
+ settledMatch(runId: string): Promise<RunMatch | null>;
210
+ /**
211
+ * A prompt is on its way (a session just started). While a failure is on
212
+ * record no hook waits on the service, and none is the request that finds
213
+ * out whether it answers again ({@link mayHotWait}). After hours with
214
+ * nothing sent, the failure is still on record at the first prompt, and its
215
+ * match is not waited for however fast the service answers — so find out
216
+ * now: one background `GET /health`, once the cool-down is over. Nothing
217
+ * while no failure is on record, during a cool-down, or with a probe out.
218
+ */
219
+ probeIfFailing(): void;
195
220
  recordToolSelected(runId: string, stepIndex: number, data: {
196
221
  toolName: string;
197
222
  toolInput: string;
@@ -43,8 +43,10 @@
43
43
  * SIMILAR-PROMPT HITS. `POST /recordings/runs` may answer `{ id: null, matched }`,
44
44
  * meaning the server recognised the prompt and did **not** create a run. Every
45
45
  * later step post for that id would then 404, so callers must stop recording —
46
- * {@link RemoteRecorder.getMatch} is how they find out — and this recorder skips
47
- * them itself too, for the caller that stopped waiting before the answer came.
46
+ * {@link RemoteRecorder.settledMatch} is how they find out, whenever the answer
47
+ * lands ({@link RemoteRecorder.getMatch} is only what a hook may wait for) — and
48
+ * this recorder skips them itself too, for the caller that stopped waiting
49
+ * before the answer came.
48
50
  */
49
51
  import { randomUUID } from "node:crypto";
50
52
  import { statSync } from "node:fs";
@@ -479,7 +481,8 @@ export class RemoteRecorder {
479
481
  }),
480
482
  // The id is client-generated, so the service can tell a repeat. A
481
483
  // hook waiting on this answer is let go before the retry: a match
482
- // found by the retry is not the turn's any more.
484
+ // found by the retry can no longer steer the turn, and only its
485
+ // ticket's report is left to it (`settledMatch`).
483
486
  { retryTransient: true, onRetry: () => entry.release() });
484
487
  const b = body && typeof body === "object"
485
488
  ? body
@@ -579,9 +582,12 @@ export class RemoteRecorder {
579
582
  }
580
583
  /**
581
584
  * The similar-prompt match for a run's create call (null = fresh run). Like
582
- * {@link runCreated}, answers null at once while the service is cooling down,
583
- * and as soon as the create's first attempt fails — the first prompt of an
584
- * outage is not held for the retry's sleep (up to 2 s behind a Retry-After).
585
+ * {@link runCreated}, answers null at once while a failure is on record —
586
+ * in a cool-down, and after it until an answer clears it, however long ago
587
+ * it was — and as soon as the create's first attempt fails: the first prompt
588
+ * of an outage is not held for the retry's sleep (up to 2 s behind a
589
+ * Retry-After). The create goes on, and its own answer is
590
+ * {@link settledMatch}'s.
585
591
  */
586
592
  async getMatch(runId) {
587
593
  const entry = this.runs.get(runId);
@@ -595,6 +601,39 @@ export class RemoteRecorder {
595
601
  await entry.hot;
596
602
  return entry.settled?.match ?? null;
597
603
  }
604
+ /**
605
+ * The create's own match (null = fresh run), whenever it lands — after the
606
+ * cool-down and the retry, if it took them — however {@link getMatch}
607
+ * answered the hook. A hook told "no match" at once, because a failure was
608
+ * on record or the first attempt failed, may be about a prompt the service
609
+ * matched all the same: it then handed out the scenario and issued a ticket,
610
+ * and the turn owes that ticket a report. Never for a hook to wait on. Null
611
+ * for an id this recorder never opened.
612
+ */
613
+ async settledMatch(runId) {
614
+ const entry = this.runs.get(runId);
615
+ if (!entry)
616
+ return null;
617
+ entry.lastUsed = Date.now();
618
+ if (entry.settled)
619
+ return entry.settled.match;
620
+ return (await entry.answer) ?? null;
621
+ }
622
+ /**
623
+ * A prompt is on its way (a session just started). While a failure is on
624
+ * record no hook waits on the service, and none is the request that finds
625
+ * out whether it answers again ({@link mayHotWait}). After hours with
626
+ * nothing sent, the failure is still on record at the first prompt, and its
627
+ * match is not waited for however fast the service answers — so find out
628
+ * now: one background `GET /health`, once the cool-down is over. Nothing
629
+ * while no failure is on record, during a cool-down, or with a probe out.
630
+ */
631
+ probeIfFailing() {
632
+ this.checkCredentials();
633
+ if (this.state !== "recording" || this.healthyNow())
634
+ return;
635
+ this.probeSoon();
636
+ }
598
637
  recordToolSelected(runId, stepIndex, data) {
599
638
  const id = "step_" + randomUUID();
600
639
  const toolInput = pend(data.toolInput, DEFAULT_MAX_PAYLOAD_BYTES, true);
@@ -796,6 +835,7 @@ export class RemoteRecorder {
796
835
  newEntry() {
797
836
  let resolveHot;
798
837
  let resolveCreated;
838
+ let resolveAnswer;
799
839
  const entry = {
800
840
  userId: this.session.user.id,
801
841
  hot: new Promise((r) => {
@@ -804,6 +844,9 @@ export class RemoteRecorder {
804
844
  created: new Promise((r) => {
805
845
  resolveCreated = r;
806
846
  }),
847
+ answer: new Promise((r) => {
848
+ resolveAnswer = r;
849
+ }),
807
850
  settle: (match, created) => {
808
851
  if (entry.settled)
809
852
  return;
@@ -811,9 +854,11 @@ export class RemoteRecorder {
811
854
  entry.settledAt = Date.now();
812
855
  resolveHot();
813
856
  resolveCreated(created);
857
+ resolveAnswer(match);
814
858
  // Callers that are waiting hold the promises themselves.
815
859
  entry.hot = undefined;
816
860
  entry.created = undefined;
861
+ entry.answer = undefined;
817
862
  },
818
863
  release: () => {
819
864
  if (entry.settled || entry.released)
@@ -46,10 +46,12 @@ import { NullRecorder, } from "./recorder.js";
46
46
  */
47
47
  const NEUTRAL = {
48
48
  getMatch: () => Promise.resolve(null),
49
+ settledMatch: () => Promise.resolve(null),
49
50
  runCreated: () => Promise.resolve({ created: true }),
50
51
  matchIntent: () => Promise.resolve(null),
51
52
  reportExecution: () => undefined,
52
53
  backfillContext: () => undefined,
54
+ probeIfFailing: () => undefined,
53
55
  };
54
56
  /**
55
57
  * `RemoteRecorder`'s private helpers. They are methods at runtime (TypeScript's
@@ -514,7 +514,13 @@ export type DeclineCode = "replay_disabled" | "not_ready" | "flatten_failed" | "
514
514
  * (`arm_unannounced`). Set by the control server, where a prompt's wait
515
515
  * ends, so the report can say why a matched turn was not steered.
516
516
  */
517
- | "arm_timeout" | "arm_unannounced";
517
+ | "arm_timeout" | "arm_unannounced"
518
+ /**
519
+ * Nothing was armed at all: the service's answer to the match landed after
520
+ * the prompt had been answered, or while prompts were not waiting on it
521
+ * (the match gate). Set by the control server, like the two above.
522
+ */
523
+ | "match_late";
518
524
  /** What `PreToolUse` should do about this call. */
519
525
  export type PreToolAction =
520
526
  /** Pin the arguments and let the real tool run. */
@@ -587,6 +593,17 @@ export declare class ReplayController {
587
593
  * covers both "no match" and "too slow", which are the same thing from here.
588
594
  */
589
595
  awaitMatch(pending: Promise<unknown>): Promise<RunMatchLike | null>;
596
+ /** A matched state before any gate has judged it: nothing planned, nothing run. */
597
+ private blankState;
598
+ /**
599
+ * The state of a prompt match that landed too late to be used
600
+ * (`match_late`). No gate is run and nothing is derived — a plan could no
601
+ * longer be announced, and a reading nobody waits for is money spent on
602
+ * nothing. It exists for the one thing the hand-out still owes: its ticket's
603
+ * report, with the reason, so the service does not show a turn the agent did
604
+ * itself as one that never reported.
605
+ */
606
+ lateMatch(match: RunMatchLike, host?: HostContext): ReplayState;
590
607
  /**
591
608
  * Run the gate ladder and, if every gate passes, arm a plan.
592
609
  *
@@ -305,18 +305,9 @@ export class ReplayController {
305
305
  clearTimeout(timer);
306
306
  }
307
307
  }
308
- /**
309
- * Run the gate ladder and, if every gate passes, arm a plan.
310
- *
311
- * Always returns a state — a declined match still has a ticket to redeem and a
312
- * baseline sample to contribute, and losing that is how a savings ledger ends
313
- * up with a denominator nobody measured.
314
- *
315
- * `host` is given for a session whose host is not Claude Code, and is kept
316
- * on the state (see {@link HostContext}).
317
- */
318
- async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host) {
319
- const state = {
308
+ /** A matched state before any gate has judged it: nothing planned, nothing run. */
309
+ blankState(match, wrapped, armedBy, host) {
310
+ return {
320
311
  scenarioId: match.scenarioId,
321
312
  ticket: match.executionTicket,
322
313
  similarity: match.similarity,
@@ -345,6 +336,33 @@ export class ReplayController {
345
336
  verified: match.verified,
346
337
  sharedWith: [],
347
338
  };
339
+ }
340
+ /**
341
+ * The state of a prompt match that landed too late to be used
342
+ * (`match_late`). No gate is run and nothing is derived — a plan could no
343
+ * longer be announced, and a reading nobody waits for is money spent on
344
+ * nothing. It exists for the one thing the hand-out still owes: its ticket's
345
+ * report, with the reason, so the service does not show a turn the agent did
346
+ * itself as one that never reported.
347
+ */
348
+ lateMatch(match, host) {
349
+ const state = this.blankState(match, new Set(), "prompt", host);
350
+ state.declined = "match_late";
351
+ state.retired = true;
352
+ return state;
353
+ }
354
+ /**
355
+ * Run the gate ladder and, if every gate passes, arm a plan.
356
+ *
357
+ * Always returns a state — a declined match still has a ticket to redeem and a
358
+ * baseline sample to contribute, and losing that is how a savings ledger ends
359
+ * up with a denominator nobody measured.
360
+ *
361
+ * `host` is given for a session whose host is not Claude Code, and is kept
362
+ * on the state (see {@link HostContext}).
363
+ */
364
+ async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host) {
365
+ const state = this.blankState(match, wrapped, armedBy, host);
348
366
  const isSegment = state.kind === "segment";
349
367
  const decline = (why, code) => {
350
368
  state.declined = code;
@@ -60,6 +60,8 @@ export interface DeriveOptions extends DeriveContext {
60
60
  prompt: string;
61
61
  intent: string;
62
62
  paramsObject: ParamsSchema | null;
63
+ /** The day on this machine. Injected by tests; defaults to {@link localDay}. */
64
+ today?: string;
63
65
  /** An override that keeps the reading on this machine. Absent → the service. */
64
66
  apiKey?: string;
65
67
  /** Used when there is no `apiKey`. Absent too → recorded samples. */
@@ -90,6 +92,16 @@ export interface DeriveResult {
90
92
  /** Settings filled from their recorded sample (R-PARAM-2). */
91
93
  sampled: string[];
92
94
  }
95
+ /**
96
+ * The day on this machine, `YYYY-MM-DD`, in its own time zone.
97
+ *
98
+ * Sent to the service with every reading, because a prompt says "tomorrow" and
99
+ * the date is in no prompt: the service works such a value out, and it must
100
+ * count from the user's day. Its own day is the wrong one — a server on UTC is
101
+ * a day ahead of a user in Seattle for a third of the working day. Local on
102
+ * purpose, then, and never `toISOString`, which is UTC.
103
+ */
104
+ export declare function localDay(now?: Date): string;
93
105
  /**
94
106
  * First balanced `{…}` in a text blob. Models wrap JSON in prose and fences more
95
107
  * often than they emit it bare, and a failed parse here costs a whole replay.
@@ -75,6 +75,19 @@ function serviceOk(startedAt) {
75
75
  const ok = serviceHealth.ok.bind(serviceHealth);
76
76
  ok(startedAt);
77
77
  }
78
+ /**
79
+ * The day on this machine, `YYYY-MM-DD`, in its own time zone.
80
+ *
81
+ * Sent to the service with every reading, because a prompt says "tomorrow" and
82
+ * the date is in no prompt: the service works such a value out, and it must
83
+ * count from the user's day. Its own day is the wrong one — a server on UTC is
84
+ * a day ahead of a user in Seattle for a third of the working day. Local on
85
+ * purpose, then, and never `toISOString`, which is UTC.
86
+ */
87
+ export function localDay(now = new Date()) {
88
+ const two = (n) => String(n).padStart(2, "0");
89
+ return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}`;
90
+ }
78
91
  /** Every parameter at its recorded sample value. The zero-cost fallback. */
79
92
  function sampleValues(schema) {
80
93
  const out = {};
@@ -251,6 +264,15 @@ export async function deriveParameters(opts) {
251
264
  const missing = [];
252
265
  const sampled = [];
253
266
  for (const key of keys) {
267
+ // A value worked out from the day ("tomorrow") cannot be read here: this
268
+ // copy of the text does not tell the model the day, and what comes back
269
+ // for such a value is then the recorded date or a guess. The service's
270
+ // reading does work it out; with a key of its own the value is missing.
271
+ if (isTarget(schema, key) && schema[key].from === "today") {
272
+ parsed[key] = null;
273
+ missing.push(key);
274
+ continue;
275
+ }
254
276
  if (parsed[key] !== null && parsed[key] !== undefined)
255
277
  continue;
256
278
  if (isTarget(schema, key)) {
@@ -318,6 +340,9 @@ async function deriveViaService(opts, service, schema, keys) {
318
340
  prompt: outgoingPrompt(opts.prompt),
319
341
  liveCall: context.liveCall,
320
342
  recentResults: context.recentResults,
343
+ // What "tomorrow" is counted from. A service that does not know the
344
+ // field ignores it.
345
+ today: opts.today ?? localDay(),
321
346
  }),
322
347
  signal: opts.signal ?? timeoutSignal(),
323
348
  });
@@ -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
  }
@@ -138,6 +138,13 @@ export type ParamsSchema = Record<string, {
138
138
  sampleValue: unknown;
139
139
  description: string;
140
140
  kind?: ParameterKind;
141
+ /**
142
+ * Where the recorded value came from, when the service calculated the plan
143
+ * beside its request. Only `today` is read here: a value worked out from
144
+ * the day the request is made on ("tomorrow"), which the service works out
145
+ * again from the day this runner sends (replay/derive.ts, `localDay`).
146
+ */
147
+ from?: string;
141
148
  }>;
142
149
  /** Whether this schema has anything the runner may not guess (R-PARAM-4). */
143
150
  export declare function hasTargets(schema: ParamsSchema | null | undefined): boolean;