@basein/runner 0.2.12 → 0.3.1

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.
Files changed (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2787 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -0,0 +1,126 @@
1
+ /**
2
+ * SwitchableRecorder — one recorder object whose backend can change while the
3
+ * control server holds it.
4
+ *
5
+ * WHY. `ControlServer` takes its recorder once, at construction, and keeps it
6
+ * for the life of the process — which for a background recorder is days. The
7
+ * recorder used to be decided before the server even listened: sign in over
8
+ * the network, then build a `RemoteRecorder`, or give up and build a
9
+ * `NullRecorder` for good. So a slow service held every hook off a closed port
10
+ * for the whole start-up, and a service that was down (or a person not yet
11
+ * signed in) at that one moment meant nothing was recorded until a reboot. The
12
+ * server now gets this object instead; `bir-hooks` listens first and swaps the
13
+ * backend in whenever a sign-in appears, is refreshed, or goes away
14
+ * (control/recorder-keeper.ts).
15
+ *
16
+ * WHAT IT PRESERVES.
17
+ *
18
+ * - **Runs stay with the backend that opened them.** A run started on a
19
+ * `NullRecorder` has a local id the service never saw; its later steps must
20
+ * not be posted there (every one would 404). Per-run calls are routed by
21
+ * run id to the backend whose `startRun` returned it. New runs go to the
22
+ * current backend.
23
+ * - **Optional capabilities look exactly like the backend's.** The server
24
+ * probes them with `typeof recorder.getMatch === "function"` and friends
25
+ * (record/recorder.ts), and a `NullRecorder` has none — which is what keeps
26
+ * a signed-out recorder from doing a probe's work at all. Until a backend
27
+ * with a capability has been installed, this object does not have it
28
+ * either. Once one has, the capability stays (a check made just before a
29
+ * swap must never find the method gone when it calls it), and a backend
30
+ * without it answers the way "no service" always answered: no match, run
31
+ * created, a miss, nothing reported. Every argument is passed on as it
32
+ * came — `matchIntent(req, opts)` keeps its deadline and abort signal.
33
+ * - **A caller's budget is the budget.** `flush(runId, opts)` passes `opts`
34
+ * to every backend it drains, and drains them side by side, so a hook
35
+ * that gave a flush 800 ms is not held for twice that.
36
+ * - **The backlog is the process's, not one backend's.** The keeper
37
+ * installs a new backend on every token refresh; `recorderHealth()` adds
38
+ * up the current one and every earlier one still sending, and carries the
39
+ * dropped and failed counts of those that finished — "lossy" must not be
40
+ * forgotten because an hour passed.
41
+ */
42
+ import { type FlushOptions, type Recorder, type RecorderHealth, type RunMetrics, type StartRunOptions } from "./recorder.js";
43
+ export interface SwitchableRecorderOptions {
44
+ /**
45
+ * Called before a run is started while the backend records nothing — the
46
+ * moment a sign-in that just appeared on disk is worth noticing, because
47
+ * this prompt could be recorded. Must be quick and synchronous; errors are
48
+ * swallowed.
49
+ */
50
+ beforeStartRunWhileIdle?: () => void;
51
+ /** Called on every `startRun`, after the run exists. Errors are swallowed. */
52
+ onStartRun?: () => void;
53
+ /**
54
+ * What `recorderHealth()` gives as `state` while the current backend has no
55
+ * health of its own (it records nothing): why. The keeper knows; without
56
+ * it, `signed_out`.
57
+ */
58
+ idleHealthState?: () => RecorderHealth["state"];
59
+ }
60
+ export declare class SwitchableRecorder implements Recorder {
61
+ private current;
62
+ /** runId → the backend whose `startRun` returned it. */
63
+ private readonly owners;
64
+ private readonly opts;
65
+ /**
66
+ * Earlier backends that may still be sending — they own a run, or had
67
+ * sends queued when they were replaced — so {@link recorderHealth} counts
68
+ * their backlog. Dropped once idle; their totals move to {@link carried}.
69
+ */
70
+ private retired;
71
+ /** Dropped and failed sends of backends no longer tracked. */
72
+ private readonly carried;
73
+ /** What each untracked backend added to {@link carried}, in case it comes back. */
74
+ private readonly carriedFrom;
75
+ constructor(initial: Recorder, opts?: SwitchableRecorderOptions);
76
+ /** The backend new runs go to. */
77
+ get backend(): Recorder;
78
+ /** True when steps reach a service: the backend is not a {@link NullRecorder}. */
79
+ get recording(): boolean;
80
+ /**
81
+ * Make `next` the backend for new runs. Runs already started keep theirs,
82
+ * so their remaining steps (and their flush) still go where they began.
83
+ */
84
+ switchTo(next: Recorder): void;
85
+ /**
86
+ * The access token the current backend holds right now, if it has one.
87
+ * Replay's service derivation and its source-run fetch read it on every use
88
+ * (C5): the token changes on every refresh and on every swap.
89
+ */
90
+ currentAccessToken(): string | undefined;
91
+ startRun(input: string, metadata?: Record<string, unknown>, options?: StartRunOptions): string;
92
+ recordToolSelected(runId: string, stepIndex: number, d: Parameters<Recorder["recordToolSelected"]>[2]): string;
93
+ recordToolResponse(runId: string, stepIndex: number, d: {
94
+ toolName: string;
95
+ toolOutput?: string;
96
+ toolError?: string;
97
+ }): string;
98
+ recordFinalAnswer(runId: string, stepIndex: number, d: {
99
+ answer: string;
100
+ }): string;
101
+ finishRun(runId: string, finalOutput?: string, metrics?: RunMetrics): void;
102
+ flush(runId: string, opts?: FlushOptions): Promise<void>;
103
+ /**
104
+ * Stop tracking earlier backends that own no run and have nothing queued,
105
+ * keeping what they dropped and what failed.
106
+ */
107
+ private pruneRetired;
108
+ /**
109
+ * `recorderHealth()` for the whole process: the current backend's state (or,
110
+ * while it records nothing, why), and the backlog of every backend still
111
+ * sending, with the losses of the ones that are done.
112
+ */
113
+ private processHealth;
114
+ private remember;
115
+ private ownerOf;
116
+ /**
117
+ * Give this object a forwarder for each optional member `backend` has and
118
+ * this object does not yet. Forwarders route by run id when the first
119
+ * argument is one, else to the current backend, and fall back to the
120
+ * no-service answer when that backend lacks the member. Every argument is
121
+ * passed on as it came. `recorderHealth` is the one exception: it answers
122
+ * for every backend still sending (see {@link processHealth}).
123
+ */
124
+ private adoptCapabilities;
125
+ }
126
+ //# sourceMappingURL=switchable-recorder.d.ts.map
@@ -0,0 +1,351 @@
1
+ /**
2
+ * SwitchableRecorder — one recorder object whose backend can change while the
3
+ * control server holds it.
4
+ *
5
+ * WHY. `ControlServer` takes its recorder once, at construction, and keeps it
6
+ * for the life of the process — which for a background recorder is days. The
7
+ * recorder used to be decided before the server even listened: sign in over
8
+ * the network, then build a `RemoteRecorder`, or give up and build a
9
+ * `NullRecorder` for good. So a slow service held every hook off a closed port
10
+ * for the whole start-up, and a service that was down (or a person not yet
11
+ * signed in) at that one moment meant nothing was recorded until a reboot. The
12
+ * server now gets this object instead; `bir-hooks` listens first and swaps the
13
+ * backend in whenever a sign-in appears, is refreshed, or goes away
14
+ * (control/recorder-keeper.ts).
15
+ *
16
+ * WHAT IT PRESERVES.
17
+ *
18
+ * - **Runs stay with the backend that opened them.** A run started on a
19
+ * `NullRecorder` has a local id the service never saw; its later steps must
20
+ * not be posted there (every one would 404). Per-run calls are routed by
21
+ * run id to the backend whose `startRun` returned it. New runs go to the
22
+ * current backend.
23
+ * - **Optional capabilities look exactly like the backend's.** The server
24
+ * probes them with `typeof recorder.getMatch === "function"` and friends
25
+ * (record/recorder.ts), and a `NullRecorder` has none — which is what keeps
26
+ * a signed-out recorder from doing a probe's work at all. Until a backend
27
+ * with a capability has been installed, this object does not have it
28
+ * either. Once one has, the capability stays (a check made just before a
29
+ * swap must never find the method gone when it calls it), and a backend
30
+ * without it answers the way "no service" always answered: no match, run
31
+ * created, a miss, nothing reported. Every argument is passed on as it
32
+ * came — `matchIntent(req, opts)` keeps its deadline and abort signal.
33
+ * - **A caller's budget is the budget.** `flush(runId, opts)` passes `opts`
34
+ * to every backend it drains, and drains them side by side, so a hook
35
+ * that gave a flush 800 ms is not held for twice that.
36
+ * - **The backlog is the process's, not one backend's.** The keeper
37
+ * installs a new backend on every token refresh; `recorderHealth()` adds
38
+ * up the current one and every earlier one still sending, and carries the
39
+ * dropped and failed counts of those that finished — "lossy" must not be
40
+ * forgotten because an hour passed.
41
+ */
42
+ import { NullRecorder, } from "./recorder.js";
43
+ /**
44
+ * What a capability answers when the backend a call is routed to lacks it —
45
+ * the answers a recorder with no service has always produced (recorder.ts).
46
+ */
47
+ const NEUTRAL = {
48
+ getMatch: () => Promise.resolve(null),
49
+ runCreated: () => Promise.resolve({ created: true }),
50
+ matchIntent: () => Promise.resolve(null),
51
+ reportExecution: () => undefined,
52
+ backfillContext: () => undefined,
53
+ };
54
+ /**
55
+ * `RemoteRecorder`'s private helpers. They are methods at runtime (TypeScript's
56
+ * `private` is erased), and a forwarder for them would be worse than clutter:
57
+ * a capability check by name (`typeof r.sweep === "function"`) would find one.
58
+ * Kept in step with record/remote-recorder.ts by a test that reads its source.
59
+ */
60
+ const INTERNAL = new Set([
61
+ "constructor",
62
+ "accountChanged",
63
+ "adoptStoredSession",
64
+ "beginProbe",
65
+ "checkCredentials",
66
+ "drain",
67
+ "enforceBound",
68
+ "enqueue",
69
+ "enqueueRun",
70
+ "entryFor",
71
+ "execute",
72
+ "healthyNow",
73
+ "laneIsIdle",
74
+ "markDead",
75
+ "mayHotWait",
76
+ "newEntry",
77
+ "noteBacklogRecovered",
78
+ "noteFileSeen",
79
+ "onSendFailed",
80
+ "patch",
81
+ "pause",
82
+ "pickVictim",
83
+ "post",
84
+ "probeInFlight",
85
+ "probeSoon",
86
+ "refresh",
87
+ "refreshAhead",
88
+ "refreshOverNetwork",
89
+ "refreshSingleFlight",
90
+ "releaseJitterMs",
91
+ "request",
92
+ "resume",
93
+ "retire",
94
+ "retryRejected",
95
+ "send",
96
+ "summarize",
97
+ "sweep",
98
+ "waitForLanes",
99
+ "waitForService",
100
+ "whyNotSend",
101
+ ]);
102
+ /** Keep at most this many run → backend entries (flush normally removes them). */
103
+ const MAX_OWNERS = 2_000;
104
+ /** A backend's own `recorderHealth()`, or undefined when it has none (or it threw). */
105
+ function healthOf(r) {
106
+ const fn = r.recorderHealth;
107
+ if (typeof fn !== "function")
108
+ return undefined;
109
+ try {
110
+ return fn.call(r);
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ /** Every function-valued member of `obj`, own or inherited, but not Object's. */
117
+ function methodNames(obj) {
118
+ const names = new Set();
119
+ for (let p = obj; p && p !== Object.prototype; p = Object.getPrototypeOf(p)) {
120
+ for (const name of Object.getOwnPropertyNames(p)) {
121
+ if (INTERNAL.has(name) || name.startsWith("_"))
122
+ continue;
123
+ const desc = Object.getOwnPropertyDescriptor(p, name);
124
+ if (desc && typeof desc.value === "function")
125
+ names.add(name);
126
+ }
127
+ }
128
+ return [...names];
129
+ }
130
+ export class SwitchableRecorder {
131
+ current;
132
+ /** runId → the backend whose `startRun` returned it. */
133
+ owners = new Map();
134
+ opts;
135
+ /**
136
+ * Earlier backends that may still be sending — they own a run, or had
137
+ * sends queued when they were replaced — so {@link recorderHealth} counts
138
+ * their backlog. Dropped once idle; their totals move to {@link carried}.
139
+ */
140
+ retired = [];
141
+ /** Dropped and failed sends of backends no longer tracked. */
142
+ carried = { droppedSends: 0, failedSends: 0 };
143
+ /** What each untracked backend added to {@link carried}, in case it comes back. */
144
+ carriedFrom = new WeakMap();
145
+ constructor(initial, opts = {}) {
146
+ this.current = initial;
147
+ this.opts = opts;
148
+ this.adoptCapabilities(initial);
149
+ }
150
+ /** The backend new runs go to. */
151
+ get backend() {
152
+ return this.current;
153
+ }
154
+ /** True when steps reach a service: the backend is not a {@link NullRecorder}. */
155
+ get recording() {
156
+ return !(this.current instanceof NullRecorder);
157
+ }
158
+ /**
159
+ * Make `next` the backend for new runs. Runs already started keep theirs,
160
+ * so their remaining steps (and their flush) still go where they began.
161
+ */
162
+ switchTo(next) {
163
+ if (next === this.current)
164
+ return;
165
+ const previous = this.current;
166
+ this.current = next;
167
+ // A backend coming back (the one idle recorder, after a sign-in and a
168
+ // sign-out) is counted as current again, not also as retired or carried.
169
+ this.retired = this.retired.filter((r) => r !== next);
170
+ const back = this.carriedFrom.get(next);
171
+ if (back) {
172
+ this.carried.droppedSends -= back.droppedSends;
173
+ this.carried.failedSends -= back.failedSends;
174
+ this.carriedFrom.delete(next);
175
+ }
176
+ this.retired.push(previous);
177
+ this.adoptCapabilities(next);
178
+ this.pruneRetired();
179
+ }
180
+ /**
181
+ * The access token the current backend holds right now, if it has one.
182
+ * Replay's service derivation and its source-run fetch read it on every use
183
+ * (C5): the token changes on every refresh and on every swap.
184
+ */
185
+ currentAccessToken() {
186
+ const r = this.current;
187
+ try {
188
+ return typeof r.currentAccessToken === "function" ? r.currentAccessToken() : undefined;
189
+ }
190
+ catch {
191
+ return undefined;
192
+ }
193
+ }
194
+ // ── the Recorder surface ────────────────────────────────────────────────
195
+ startRun(input, metadata, options) {
196
+ if (!this.recording && this.opts.beforeStartRunWhileIdle) {
197
+ try {
198
+ this.opts.beforeStartRunWhileIdle();
199
+ }
200
+ catch {
201
+ /* noticing a sign-in is an optimisation; recording nothing is the fallback */
202
+ }
203
+ }
204
+ const backend = this.current;
205
+ const runId = backend.startRun(input, metadata, options);
206
+ this.remember(runId, backend);
207
+ try {
208
+ this.opts.onStartRun?.();
209
+ }
210
+ catch {
211
+ /* bookkeeping only */
212
+ }
213
+ return runId;
214
+ }
215
+ recordToolSelected(runId, stepIndex, d) {
216
+ return this.ownerOf(runId).recordToolSelected(runId, stepIndex, d);
217
+ }
218
+ recordToolResponse(runId, stepIndex, d) {
219
+ return this.ownerOf(runId).recordToolResponse(runId, stepIndex, d);
220
+ }
221
+ recordFinalAnswer(runId, stepIndex, d) {
222
+ return this.ownerOf(runId).recordFinalAnswer(runId, stepIndex, d);
223
+ }
224
+ finishRun(runId, finalOutput, metrics) {
225
+ this.ownerOf(runId).finishRun(runId, finalOutput, metrics);
226
+ }
227
+ async flush(runId, opts) {
228
+ const owner = this.ownerOf(runId);
229
+ const current = this.current;
230
+ try {
231
+ // Execution reports go to the current backend (a matched turn has no run
232
+ // of its own to follow), so when the run began on an earlier one, drain
233
+ // the current one's report chain as well — that is what a flush
234
+ // promises. Side by side, each with the caller's budget: one after the
235
+ // other would be twice what the caller allowed.
236
+ await Promise.all([owner.flush(runId, opts), current !== owner ? current.flush(runId, opts) : undefined]);
237
+ }
238
+ finally {
239
+ // Only now: a step that arrives while the flush runs (a proxy report
240
+ // right behind Stop) still goes where the run began.
241
+ if (this.owners.get(runId) === owner)
242
+ this.owners.delete(runId);
243
+ }
244
+ }
245
+ // ── internals ───────────────────────────────────────────────────────────
246
+ /**
247
+ * Stop tracking earlier backends that own no run and have nothing queued,
248
+ * keeping what they dropped and what failed.
249
+ */
250
+ pruneRetired() {
251
+ if (this.retired.length === 0)
252
+ return;
253
+ const owning = new Set(this.owners.values());
254
+ this.retired = this.retired.filter((r) => {
255
+ if (r === this.current || owning.has(r))
256
+ return true;
257
+ const h = healthOf(r);
258
+ if (h && h.pendingSends > 0)
259
+ return true;
260
+ if (h) {
261
+ this.carried.droppedSends += h.droppedSends;
262
+ this.carried.failedSends += h.failedSends;
263
+ this.carriedFrom.set(r, { droppedSends: h.droppedSends, failedSends: h.failedSends });
264
+ }
265
+ return false;
266
+ });
267
+ }
268
+ /**
269
+ * `recorderHealth()` for the whole process: the current backend's state (or,
270
+ * while it records nothing, why), and the backlog of every backend still
271
+ * sending, with the losses of the ones that are done.
272
+ */
273
+ processHealth() {
274
+ this.pruneRetired();
275
+ const own = healthOf(this.current);
276
+ const total = {
277
+ pendingSends: 0,
278
+ pendingBytes: 0,
279
+ droppedSends: this.carried.droppedSends,
280
+ failedSends: this.carried.failedSends,
281
+ };
282
+ for (const h of [own, ...this.retired.map(healthOf)]) {
283
+ if (!h)
284
+ continue;
285
+ total.pendingSends += h.pendingSends;
286
+ total.pendingBytes += h.pendingBytes;
287
+ total.droppedSends += h.droppedSends;
288
+ total.failedSends += h.failedSends;
289
+ }
290
+ let state = own?.state ?? "signed_out";
291
+ if (!own && this.opts.idleHealthState) {
292
+ try {
293
+ state = this.opts.idleHealthState();
294
+ }
295
+ catch {
296
+ /* keep the default */
297
+ }
298
+ }
299
+ return { state, ...total };
300
+ }
301
+ remember(runId, backend) {
302
+ this.owners.set(runId, backend);
303
+ if (this.owners.size > MAX_OWNERS) {
304
+ // Oldest first (a Map iterates in insertion order). A run that old was
305
+ // abandoned without a flush; routing its stragglers to the current
306
+ // backend is the lesser harm.
307
+ const oldest = this.owners.keys().next().value;
308
+ if (oldest !== undefined)
309
+ this.owners.delete(oldest);
310
+ }
311
+ }
312
+ ownerOf(runId) {
313
+ return (typeof runId === "string" && this.owners.get(runId)) || this.current;
314
+ }
315
+ /**
316
+ * Give this object a forwarder for each optional member `backend` has and
317
+ * this object does not yet. Forwarders route by run id when the first
318
+ * argument is one, else to the current backend, and fall back to the
319
+ * no-service answer when that backend lacks the member. Every argument is
320
+ * passed on as it came. `recorderHealth` is the one exception: it answers
321
+ * for every backend still sending (see {@link processHealth}).
322
+ */
323
+ adoptCapabilities(backend) {
324
+ for (const name of methodNames(backend)) {
325
+ if (name in this)
326
+ continue;
327
+ if (name === "recorderHealth") {
328
+ Object.defineProperty(this, name, {
329
+ configurable: true,
330
+ enumerable: false,
331
+ writable: true,
332
+ value: () => this.processHealth(),
333
+ });
334
+ continue;
335
+ }
336
+ Object.defineProperty(this, name, {
337
+ configurable: true,
338
+ enumerable: false,
339
+ writable: true,
340
+ value: (...args) => {
341
+ const target = this.ownerOf(args[0]);
342
+ const fn = target[name];
343
+ if (typeof fn === "function")
344
+ return fn.apply(target, args);
345
+ return NEUTRAL[name]?.();
346
+ },
347
+ });
348
+ }
349
+ }
350
+ }
351
+ //# sourceMappingURL=switchable-recorder.js.map
@@ -24,8 +24,47 @@ export interface TruncateOptions {
24
24
  maxPayloadBytes?: number;
25
25
  maxStringBytes?: number;
26
26
  }
27
- /** Clip one string to `max` bytes, appending a marker naming what was dropped. */
27
+ /**
28
+ * Clip one string to `max` bytes, appending a marker naming what was dropped.
29
+ *
30
+ * A string that was already clipped keeps its *original* size in the new
31
+ * marker, so a leaf cut twice (once for speed, once for the payload cap) still
32
+ * says how big it really was.
33
+ */
28
34
  export declare function truncateString(text: string, max: number): string;
35
+ /**
36
+ * Cap a free-text field and pass it through `transform` (redaction), cheaply.
37
+ *
38
+ * The transform runs on a window a little longer than the cap, not on the
39
+ * whole string — a 3 MB pasted log costs a scan of 36 KB — and *before* the
40
+ * final cut, so a secret straddling the cut is still matched whole instead of
41
+ * leaving its first half behind.
42
+ */
43
+ export declare function capText(text: string, max: number, transform?: (s: string) => string,
44
+ /** The size before an earlier cut (see {@link preCut}), so the marker tells the truth. */
45
+ originalBytes?: number): string;
46
+ /** How far past a cap {@link capText} redacts, so a secret across the cut is matched whole. */
47
+ export declare const CAP_WINDOW = 4096;
48
+ /**
49
+ * Cut `text` to what {@link capText} will ever look at, and remember how big
50
+ * it was. For a value that waits in a queue before it is capped: the queue
51
+ * holds 36 KB of a 3 MB prompt, not the prompt.
52
+ */
53
+ export declare function preCut(text: string, max: number): {
54
+ text: string;
55
+ bytes: number;
56
+ };
57
+ /**
58
+ * Cap a string that is supposed to be JSON (a serialized tool input or result)
59
+ * without breaking it: an oversized one becomes the same
60
+ * `{ truncated, originalBytes, preview }` object {@link serializeCapped} uses.
61
+ * `slack` tolerates the few bytes a redaction pass can add.
62
+ *
63
+ * Valid JSON in, valid JSON out, whatever `transform` does: a transform that
64
+ * breaks the document (a text rule that met an escape) is run on each string
65
+ * leaf instead. Pass `redactJsonText` to redact structurally in the first place.
66
+ */
67
+ export declare function capJsonText(text: string, max?: number, transform?: (s: string) => string, originalBytes?: number, slack?: number): string;
29
68
  /** Deep-copy `value`, clipping every string leaf. Input is never mutated. */
30
69
  export declare function truncateStrings(value: unknown, max: number, depth?: number): unknown;
31
70
  /**
@@ -21,14 +21,121 @@ export const DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024;
21
21
  /** 8 KiB per individual string leaf. */
22
22
  export const DEFAULT_MAX_STRING_BYTES = 8 * 1024;
23
23
  const utf8 = (s) => Buffer.byteLength(s, "utf8");
24
- /** Clip one string to `max` bytes, appending a marker naming what was dropped. */
24
+ /** The marker {@link truncateString} appends, so a second cut can report the first size. */
25
+ const MARKER = /… \[truncated \d+ of (\d+) bytes\]$/;
26
+ /** The first `max` bytes of `text`, never splitting a multi-byte character. */
27
+ function headBytes(text, max) {
28
+ // Every UTF-16 unit is at least one UTF-8 byte, so the first `max` bytes lie
29
+ // inside the first `max` units: encode that much, not a 20 MB leaf.
30
+ return Buffer.from(text.slice(0, max), "utf8").subarray(0, max).toString("utf8").replace(/�$/, "");
31
+ }
32
+ /**
33
+ * Clip one string to `max` bytes, appending a marker naming what was dropped.
34
+ *
35
+ * A string that was already clipped keeps its *original* size in the new
36
+ * marker, so a leaf cut twice (once for speed, once for the payload cap) still
37
+ * says how big it really was.
38
+ */
25
39
  export function truncateString(text, max) {
26
40
  const bytes = utf8(text);
27
41
  if (bytes <= max)
28
42
  return text;
29
- // Slice on a byte boundary, then repair any split multi-byte character.
30
- const head = Buffer.from(text, "utf8").subarray(0, max).toString("utf8").replace(/�$/, "");
31
- return `${head}… [truncated ${bytes - utf8(head)} of ${bytes} bytes]`;
43
+ const prior = MARKER.exec(text);
44
+ const body = prior ? text.slice(0, prior.index) : text;
45
+ const original = prior ? Math.max(Number(prior[1]), bytes) : bytes;
46
+ const head = headBytes(body, max);
47
+ return `${head}… [truncated ${original - utf8(head)} of ${original} bytes]`;
48
+ }
49
+ /**
50
+ * Cap a free-text field and pass it through `transform` (redaction), cheaply.
51
+ *
52
+ * The transform runs on a window a little longer than the cap, not on the
53
+ * whole string — a 3 MB pasted log costs a scan of 36 KB — and *before* the
54
+ * final cut, so a secret straddling the cut is still matched whole instead of
55
+ * leaving its first half behind.
56
+ */
57
+ export function capText(text, max, transform = (s) => s,
58
+ /** The size before an earlier cut (see {@link preCut}), so the marker tells the truth. */
59
+ originalBytes) {
60
+ const original = Math.max(originalBytes ?? 0, utf8(text));
61
+ if (original <= max)
62
+ return transform(text);
63
+ const window = transform(text.slice(0, max + CAP_WINDOW));
64
+ const head = headBytes(window, max);
65
+ return `${head}… [truncated ${Math.max(0, original - utf8(head))} of ${original} bytes]`;
66
+ }
67
+ /** How far past a cap {@link capText} redacts, so a secret across the cut is matched whole. */
68
+ export const CAP_WINDOW = 4096;
69
+ /**
70
+ * Cut `text` to what {@link capText} will ever look at, and remember how big
71
+ * it was. For a value that waits in a queue before it is capped: the queue
72
+ * holds 36 KB of a 3 MB prompt, not the prompt.
73
+ */
74
+ export function preCut(text, max) {
75
+ const bytes = utf8(text);
76
+ return { text: bytes <= max + CAP_WINDOW ? text : text.slice(0, max + CAP_WINDOW), bytes };
77
+ }
78
+ /**
79
+ * Cap a string that is supposed to be JSON (a serialized tool input or result)
80
+ * without breaking it: an oversized one becomes the same
81
+ * `{ truncated, originalBytes, preview }` object {@link serializeCapped} uses.
82
+ * `slack` tolerates the few bytes a redaction pass can add.
83
+ *
84
+ * Valid JSON in, valid JSON out, whatever `transform` does: a transform that
85
+ * breaks the document (a text rule that met an escape) is run on each string
86
+ * leaf instead. Pass `redactJsonText` to redact structurally in the first place.
87
+ */
88
+ export function capJsonText(text, max = DEFAULT_MAX_PAYLOAD_BYTES, transform = (s) => s, originalBytes, slack = 1024) {
89
+ const original = Math.max(originalBytes ?? 0, utf8(text));
90
+ if (original <= max + slack)
91
+ return keepJson(text, transform);
92
+ return JSON.stringify({
93
+ truncated: true,
94
+ originalBytes: original,
95
+ preview: capText(text, Math.max(1024, Math.floor(max / 2)), transform, original),
96
+ });
97
+ }
98
+ /**
99
+ * `transform(text)` — unless `text` was JSON and the result is not. Checked
100
+ * only when the transform changed something, so a payload with nothing to
101
+ * redact costs no parse at all.
102
+ */
103
+ function keepJson(text, transform) {
104
+ const out = transform(text);
105
+ if (out === text)
106
+ return out;
107
+ try {
108
+ JSON.parse(out);
109
+ return out;
110
+ }
111
+ catch {
112
+ /* the transform broke it — or it was never JSON */
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(text);
117
+ }
118
+ catch {
119
+ return out; // never JSON: there is no structure to keep
120
+ }
121
+ return JSON.stringify(mapStrings(parsed, transform, 0)) ?? "null";
122
+ }
123
+ /** A copy of parsed JSON with `f` applied to every string leaf. */
124
+ function mapStrings(value, f, depth) {
125
+ if (typeof value === "string")
126
+ return f(value);
127
+ if (!value || typeof value !== "object")
128
+ return value;
129
+ if (depth > 64)
130
+ return "[max depth]";
131
+ if (Array.isArray(value))
132
+ return value.map((v) => mapStrings(v, f, depth + 1));
133
+ const out = {};
134
+ for (const [k, v] of Object.entries(value)) {
135
+ // `__proto__` is an ordinary key in parsed JSON; keep it one.
136
+ Object.defineProperty(out, k, { value: mapStrings(v, f, depth + 1), enumerable: true, writable: true, configurable: true });
137
+ }
138
+ return out;
32
139
  }
33
140
  /** Deep-copy `value`, clipping every string leaf. Input is never mutated. */
34
141
  export function truncateStrings(value, max, depth = 0) {