@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.
- package/dist/bin/bir-scenario.d.ts +8 -1
- package/dist/bin/bir-scenario.js +10 -2
- package/dist/bin/bir.js +4 -0
- package/dist/bin/investigate.d.ts +10 -0
- package/dist/bin/investigate.js +37 -9
- package/dist/control/client.d.ts +6 -0
- package/dist/control/server.d.ts +42 -1
- package/dist/control/server.js +134 -38
- package/dist/proxy/session.js +1 -0
- package/dist/record/prompt-text.d.ts +32 -0
- package/dist/record/prompt-text.js +65 -0
- package/dist/record/recorder.d.ts +25 -0
- package/dist/record/recorder.js +3 -0
- package/dist/record/remote-recorder.d.ts +30 -5
- package/dist/record/remote-recorder.js +51 -6
- package/dist/record/switchable-recorder.js +2 -0
- package/dist/replay/controller.d.ts +18 -1
- package/dist/replay/controller.js +30 -12
- package/dist/replay/derive.d.ts +12 -0
- package/dist/replay/derive.js +25 -0
- package/dist/replay/executor.d.ts +26 -5
- package/dist/replay/executor.js +40 -5
- package/dist/replay/types.d.ts +7 -0
- package/docs/calculatedReplay.md +31 -10
- package/docs/calculatedReplayGuide.md +8 -1
- package/package.json +1 -1
- package/production.md +3 -1
|
@@ -24,7 +24,14 @@
|
|
|
24
24
|
* MCP call's `params._meta["claudecode/toolUseId"]` — the same id that hook
|
|
25
25
|
* carried as `tool_use_id` — so the request forwards it as `toolUseId` and the
|
|
26
26
|
* control server matches it exactly instead of inferring the session. A host
|
|
27
|
-
* that names nothing sends
|
|
27
|
+
* that names nothing sends no `toolUseId`, as before.
|
|
28
|
+
*
|
|
29
|
+
* WHOSE PROXIES. The request also carries `ppid`, the process that started
|
|
30
|
+
* this server. Claude Code starts all of a session's MCP servers, so that is
|
|
31
|
+
* the parent of the session's proxies too, and the control server binds them
|
|
32
|
+
* to the session before the plan's first step (replay/executor.ts,
|
|
33
|
+
* `bindSiblings`): that step is the session's first call to their server, so
|
|
34
|
+
* nothing else could yet say which proxies are its own.
|
|
28
35
|
*
|
|
29
36
|
* IT RUNS IN EVERY SESSION, SO IT MUST NEVER BE WHY ONE GOES WRONG. Startup and
|
|
30
37
|
* `tools/list` are answered from local files alone (the hello to the recorder
|
package/dist/bin/bir-scenario.js
CHANGED
|
@@ -24,7 +24,14 @@
|
|
|
24
24
|
* MCP call's `params._meta["claudecode/toolUseId"]` — the same id that hook
|
|
25
25
|
* carried as `tool_use_id` — so the request forwards it as `toolUseId` and the
|
|
26
26
|
* control server matches it exactly instead of inferring the session. A host
|
|
27
|
-
* that names nothing sends
|
|
27
|
+
* that names nothing sends no `toolUseId`, as before.
|
|
28
|
+
*
|
|
29
|
+
* WHOSE PROXIES. The request also carries `ppid`, the process that started
|
|
30
|
+
* this server. Claude Code starts all of a session's MCP servers, so that is
|
|
31
|
+
* the parent of the session's proxies too, and the control server binds them
|
|
32
|
+
* to the session before the plan's first step (replay/executor.ts,
|
|
33
|
+
* `bindSiblings`): that step is the session's first call to their server, so
|
|
34
|
+
* nothing else could yet say which proxies are its own.
|
|
28
35
|
*
|
|
29
36
|
* IT RUNS IN EVERY SESSION, SO IT MUST NEVER BE WHY ONE GOES WRONG. Startup and
|
|
30
37
|
* `tools/list` are answered from local files alone (the hello to the recorder
|
|
@@ -206,7 +213,8 @@ async function runArmedPlan(cwd, flight, toolUseId) {
|
|
|
206
213
|
"content-type": "application/json",
|
|
207
214
|
authorization: `Bearer ${found.token}`,
|
|
208
215
|
},
|
|
209
|
-
|
|
216
|
+
// `ppid`: the host that started this server and the session's proxies (WHOSE PROXIES above).
|
|
217
|
+
body: JSON.stringify(toolUseId ? { toolUseId, ppid: process.ppid } : { ppid: process.ppid }),
|
|
210
218
|
signal: controller.signal,
|
|
211
219
|
});
|
|
212
220
|
const raw = await res.text();
|
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;
|
package/dist/bin/investigate.js
CHANGED
|
@@ -99,6 +99,12 @@ export function turnsFromJournal(entries) {
|
|
|
99
99
|
case "run.prompt":
|
|
100
100
|
turn.prompt = str(e.prompt);
|
|
101
101
|
break;
|
|
102
|
+
case "run.prompt_stripped":
|
|
103
|
+
turn.stripped = list(e.stripped);
|
|
104
|
+
turn.eventOnly = bool(e.eventOnly);
|
|
105
|
+
turn.rawChars = num(e.rawChars);
|
|
106
|
+
turn.promptChars = num(e.chars);
|
|
107
|
+
break;
|
|
102
108
|
case "run.matched":
|
|
103
109
|
turn.matched = { runId: str(e.matchedRun) ?? "", scenarioId: str(e.scenario), similarity: num(e.similarity) };
|
|
104
110
|
break;
|
|
@@ -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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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`.");
|
package/dist/control/client.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ export interface ProxyIdentity {
|
|
|
46
46
|
/** Process start, epoch ms. */
|
|
47
47
|
startedAt: number;
|
|
48
48
|
pid: number;
|
|
49
|
+
/**
|
|
50
|
+
* The process that started the proxy: its session's host. `bir-scenario`
|
|
51
|
+
* reports its own, so a session's proxies can be told apart before they
|
|
52
|
+
* have called anything (replay/executor.ts, `bindSiblings`).
|
|
53
|
+
*/
|
|
54
|
+
ppid: number;
|
|
49
55
|
}
|
|
50
56
|
export declare class ControlClient {
|
|
51
57
|
private readonly url;
|
package/dist/control/server.d.ts
CHANGED
|
@@ -567,6 +567,8 @@ export declare class ControlServer {
|
|
|
567
567
|
*
|
|
568
568
|
* `asked` is what the prompt that opens the run asked for, in a session of
|
|
569
569
|
* another host: whether its adapter wants a plan (`HookPayload.replay`).
|
|
570
|
+
* `cleaned` is how `onPrompt` got `input` from the hook's prompt, the only
|
|
571
|
+
* caller that has one (fx121.md).
|
|
570
572
|
*/
|
|
571
573
|
private ensureRun;
|
|
572
574
|
private runMetadata;
|
|
@@ -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.
|
package/dist/control/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1882
|
-
const match = await
|
|
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
|
|
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.
|
package/dist/proxy/session.js
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt-text — the user's words in a Claude Code prompt (fx121.md).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code puts blocks of its own in front of what the user typed: the Claude
|
|
5
|
+
* in Chrome guide, the file open in the IDE, the selection, a background task's
|
|
6
|
+
* notification. The hook's `prompt` carries them all, and the service embeds the
|
|
7
|
+
* prompt with a model that reads 512 tokens. The Chrome guide alone is 1,008: in
|
|
8
|
+
* a session with Claude in Chrome connected, every prompt was embedded as the
|
|
9
|
+
* guide, and every one matched the same recording at 1.000.
|
|
10
|
+
*
|
|
11
|
+
* The prompt-side twin of `housekeeping.ts`. That file covers host tools that
|
|
12
|
+
* are not work; this one covers host text that is not the user's. Like it, it is
|
|
13
|
+
* deliberately narrow: a closed list of blocks, complete ones only, from the
|
|
14
|
+
* front only. When in doubt, the text is the user's.
|
|
15
|
+
*/
|
|
16
|
+
/** Blocks Claude Code adds in front of the user's text (fx121 R2). Closed list. */
|
|
17
|
+
export declare const HOST_BLOCKS: readonly string[];
|
|
18
|
+
export interface PromptText {
|
|
19
|
+
/** The user's words: what the service embeds and matches, and what derive reads. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** Tags of the blocks removed from the front, in order. Empty for a plain prompt. */
|
|
22
|
+
stripped: string[];
|
|
23
|
+
/** Nothing of the user's is left, and the turn was a task notification (fx121 R7). */
|
|
24
|
+
eventOnly: boolean;
|
|
25
|
+
/** Length of the prompt as the hook gave it. */
|
|
26
|
+
rawChars: number;
|
|
27
|
+
}
|
|
28
|
+
/** The prompt exactly as the hook gave it: another host's text (fx121 R1), or one with nothing to clean. */
|
|
29
|
+
export declare function asSent(raw: string): PromptText;
|
|
30
|
+
/** The user's words in a Claude Code prompt, and what was taken off the front of them. */
|
|
31
|
+
export declare function userText(raw: string): PromptText;
|
|
32
|
+
//# sourceMappingURL=prompt-text.d.ts.map
|