@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
|
@@ -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
|
/**
|
package/dist/record/recorder.js
CHANGED
|
@@ -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.
|
|
47
|
-
*
|
|
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
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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.
|
|
47
|
-
*
|
|
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
|
|
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
|
|
583
|
-
*
|
|
584
|
-
*
|
|
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
|
-
|
|
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;
|
package/dist/replay/derive.d.ts
CHANGED
|
@@ -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.
|
package/dist/replay/derive.js
CHANGED
|
@@ -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,
|
|
32
|
+
* proxy reported, from an earlier step of this session it ran, or, when a
|
|
33
|
+
* direct plan starts, from the host process it shares with the
|
|
34
|
+
* `bir-scenario` that asked for the plan ({@link ProxyWorkQueue.bindSiblings});
|
|
33
35
|
* 2. otherwise an unbound proxy, but ONLY while no other session is live
|
|
34
36
|
* (none bound elsewhere, and no server with two proxies polling — each
|
|
35
37
|
* session starts its own). A lone session's unbound proxy can only be
|
|
36
|
-
* its own. With two sessions open,
|
|
37
|
-
*
|
|
38
|
-
* when the older session asked it ran the step on the newer
|
|
39
|
-
* upstream — another browser page, another database session — and
|
|
38
|
+
* its own. With two sessions open, a proxy nothing has tied to a session
|
|
39
|
+
* cannot be told apart from the other session's: "the newest" was a
|
|
40
|
+
* guess, and when the older session asked it ran the step on the newer
|
|
41
|
+
* one's upstream — another browser page, another database session — and
|
|
40
42
|
* answered wrongly;
|
|
41
43
|
* 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
|
|
42
44
|
* session is never used for this one: the call rejects with a
|
|
@@ -76,6 +78,12 @@ export interface PollerIdentity {
|
|
|
76
78
|
/** When the proxy process started, epoch ms — "newest" is judged by this. */
|
|
77
79
|
startedAt?: number;
|
|
78
80
|
pid?: number;
|
|
81
|
+
/**
|
|
82
|
+
* The process that started the proxy: its session's host. `bir-scenario`
|
|
83
|
+
* sends its own with `/scenario/run`, so a session's proxies can be told
|
|
84
|
+
* apart before they have called anything ({@link ProxyWorkQueue.bindSiblings}).
|
|
85
|
+
*/
|
|
86
|
+
ppid?: number;
|
|
79
87
|
}
|
|
80
88
|
/** Rejection reason when no proxy is polling for a server. */
|
|
81
89
|
export declare const NO_PROXY = "no_proxy";
|
|
@@ -113,6 +121,19 @@ export declare class ProxyWorkQueue {
|
|
|
113
121
|
* `/clear` starts a new session on the same proxies.
|
|
114
122
|
*/
|
|
115
123
|
bindProxy(proxyId: string, sessionId: string): void;
|
|
124
|
+
/**
|
|
125
|
+
* Bind to `sessionId` every live proxy started by host process `hostPid` —
|
|
126
|
+
* the process that also started the `bir-scenario` asking for this
|
|
127
|
+
* session's plan. Claude Code starts a session's MCP servers as its own
|
|
128
|
+
* children, so a shared parent ties a proxy to the session before it has
|
|
129
|
+
* called anything. Without it a direct plan's first step was refused
|
|
130
|
+
* whenever another session held a proxy for the same server: `run_scenario`
|
|
131
|
+
* is the session's first call, so no correlated call could have bound one.
|
|
132
|
+
*
|
|
133
|
+
* A proxy proven to belong to another session (a correlated call) is left
|
|
134
|
+
* alone. A legacy one, which sends no id, stays a pool.
|
|
135
|
+
*/
|
|
136
|
+
bindSiblings(hostPid: number, sessionId: string): void;
|
|
116
137
|
/**
|
|
117
138
|
* `POST /proxy/poll`. Resolves with work, or with `undefined` at the poll
|
|
118
139
|
* deadline so the proxy re-polls. Work held for this proxy while it was
|
package/dist/replay/executor.js
CHANGED
|
@@ -29,14 +29,16 @@
|
|
|
29
29
|
* every proxy polls under its own id, and a step goes to, in order:
|
|
30
30
|
*
|
|
31
31
|
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
|
-
* proxy reported,
|
|
32
|
+
* proxy reported, from an earlier step of this session it ran, or, when a
|
|
33
|
+
* direct plan starts, from the host process it shares with the
|
|
34
|
+
* `bir-scenario` that asked for the plan ({@link ProxyWorkQueue.bindSiblings});
|
|
33
35
|
* 2. otherwise an unbound proxy, but ONLY while no other session is live
|
|
34
36
|
* (none bound elsewhere, and no server with two proxies polling — each
|
|
35
37
|
* session starts its own). A lone session's unbound proxy can only be
|
|
36
|
-
* its own. With two sessions open,
|
|
37
|
-
*
|
|
38
|
-
* when the older session asked it ran the step on the newer
|
|
39
|
-
* upstream — another browser page, another database session — and
|
|
38
|
+
* its own. With two sessions open, a proxy nothing has tied to a session
|
|
39
|
+
* cannot be told apart from the other session's: "the newest" was a
|
|
40
|
+
* guess, and when the older session asked it ran the step on the newer
|
|
41
|
+
* one's upstream — another browser page, another database session — and
|
|
40
42
|
* answered wrongly;
|
|
41
43
|
* 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
|
|
42
44
|
* session is never used for this one: the call rejects with a
|
|
@@ -139,6 +141,38 @@ export class ProxyWorkQueue {
|
|
|
139
141
|
by: "correlated",
|
|
140
142
|
});
|
|
141
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Bind to `sessionId` every live proxy started by host process `hostPid` —
|
|
146
|
+
* the process that also started the `bir-scenario` asking for this
|
|
147
|
+
* session's plan. Claude Code starts a session's MCP servers as its own
|
|
148
|
+
* children, so a shared parent ties a proxy to the session before it has
|
|
149
|
+
* called anything. Without it a direct plan's first step was refused
|
|
150
|
+
* whenever another session held a proxy for the same server: `run_scenario`
|
|
151
|
+
* is the session's first call, so no correlated call could have bound one.
|
|
152
|
+
*
|
|
153
|
+
* A proxy proven to belong to another session (a correlated call) is left
|
|
154
|
+
* alone. A legacy one, which sends no id, stays a pool.
|
|
155
|
+
*/
|
|
156
|
+
bindSiblings(hostPid, sessionId) {
|
|
157
|
+
for (const info of this.proxies.values()) {
|
|
158
|
+
if (info.ppid !== hostPid || !this.isLive(info) || isLegacy(info.proxyId))
|
|
159
|
+
continue;
|
|
160
|
+
if (info.sessionId === sessionId)
|
|
161
|
+
continue;
|
|
162
|
+
if (info.sessionId && info.boundBy === "correlated")
|
|
163
|
+
continue;
|
|
164
|
+
info.sessionId = sessionId;
|
|
165
|
+
info.boundBy = "sibling";
|
|
166
|
+
logDetail("replay.proxy_bound", {
|
|
167
|
+
server: info.serverName,
|
|
168
|
+
proxy: shortId(info.proxyId),
|
|
169
|
+
pid: info.pid,
|
|
170
|
+
sess: sessionId,
|
|
171
|
+
by: "sibling",
|
|
172
|
+
host: hostPid,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
142
176
|
/**
|
|
143
177
|
* `POST /proxy/poll`. Resolves with work, or with `undefined` at the poll
|
|
144
178
|
* deadline so the proxy re-polls. Work held for this proxy while it was
|
|
@@ -325,6 +359,7 @@ export class ProxyWorkQueue {
|
|
|
325
359
|
serverName,
|
|
326
360
|
startedAt: Number.isFinite(started) && started > 0 ? started : now,
|
|
327
361
|
pid: who.pid,
|
|
362
|
+
ppid: who.ppid,
|
|
328
363
|
lastSeen: now,
|
|
329
364
|
});
|
|
330
365
|
}
|
package/dist/replay/types.d.ts
CHANGED
|
@@ -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;
|