@basein/runner 0.3.9 → 0.3.11
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/README.md +47 -6
- package/dist/auth/client.d.ts +15 -0
- package/dist/auth/client.js +34 -12
- package/dist/bin/bir-hooks.d.ts +1 -1
- package/dist/bin/bir-hooks.js +1 -1
- package/dist/bin/bir.d.ts +2 -0
- package/dist/bin/bir.js +60 -6
- package/dist/bin/investigate.d.ts +5 -0
- package/dist/bin/investigate.js +117 -15
- package/dist/bin/setup.d.ts +12 -0
- package/dist/bin/setup.js +185 -56
- package/dist/bin/update.d.ts +81 -0
- package/dist/bin/update.js +194 -0
- package/dist/config/adapters/claude-code.d.ts +18 -1
- package/dist/config/adapters/claude-code.js +30 -3
- package/dist/config/generate.d.ts +14 -0
- package/dist/control/daemon.d.ts +26 -1
- package/dist/control/daemon.js +53 -3
- package/dist/control/pin-guard.d.ts +6 -1
- package/dist/control/pin-guard.js +15 -1
- package/dist/control/recorder-keeper.js +2 -1
- package/dist/control/server.d.ts +205 -2
- package/dist/control/server.js +766 -56
- package/dist/control/transcript.d.ts +4 -0
- package/dist/control/transcript.js +4 -0
- package/dist/proxy/session.js +1 -0
- package/dist/record/housekeeping.d.ts +7 -1
- package/dist/record/housekeeping.js +11 -3
- package/dist/record/recorder.d.ts +34 -0
- package/dist/record/remote-recorder.d.ts +6 -0
- package/dist/record/remote-recorder.js +31 -4
- package/dist/record/switchable-recorder.js +1 -0
- package/dist/replay/controller.d.ts +139 -3
- package/dist/replay/controller.js +248 -24
- package/dist/replay/derive.js +2 -0
- package/dist/replay/handover.d.ts +13 -1
- package/dist/replay/handover.js +19 -0
- package/dist/replay/safety.d.ts +15 -4
- package/dist/replay/safety.js +19 -6
- package/dist/replay/source-run.js +2 -1
- package/dist/util/service-headers.d.ts +24 -0
- package/dist/util/service-headers.js +47 -0
- package/docs/BaseInstRunner.md +115 -1
- package/docs/calculatedReplay.md +209 -32
- package/docs/calculatedReplayGuide.md +133 -12
- package/docs/installRun.md +91 -10
- package/docs/quickstart.md +64 -9
- package/package.json +1 -1
- package/production.md +83 -19
- package/scripts/install.ps1 +25 -1
package/dist/control/server.js
CHANGED
|
@@ -33,12 +33,12 @@ 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 { redact } from "../record/redact.js";
|
|
37
|
-
import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateStrings } from "../record/truncate.js";
|
|
36
|
+
import { redact, redactText } from "../record/redact.js";
|
|
37
|
+
import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
|
|
38
38
|
import { StepQueue } from "../record/queue.js";
|
|
39
39
|
import { NullRecorder, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioReporter, } from "../record/recorder.js";
|
|
40
40
|
import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
|
|
41
|
-
import { calculateCostUsd } from "../replay/pricing.js";
|
|
41
|
+
import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
|
|
42
42
|
import { logDetail, logLine, logOnce, errText } from "../util/log.js";
|
|
43
43
|
import { journal } from "../util/journal.js";
|
|
44
44
|
import { packageVersion } from "../util/version.js";
|
|
@@ -64,7 +64,9 @@ const PROXY_REPORT_GRACE_MS = 1_500;
|
|
|
64
64
|
*/
|
|
65
65
|
const HOOK_DEADLINE_MS = {
|
|
66
66
|
"/session/start": 2_000,
|
|
67
|
-
|
|
67
|
+
// A second past {@link PROMPT_WAIT_MS}, and a second and a half inside the
|
|
68
|
+
// 10 s Claude Code gives the hook.
|
|
69
|
+
"/session/prompt": 8_500,
|
|
68
70
|
"/tool/pre": 12_000,
|
|
69
71
|
"/tool/post": 2_000,
|
|
70
72
|
"/session/agent": 2_000,
|
|
@@ -79,10 +81,19 @@ const PREVIEW_RESERVE_MS = 500;
|
|
|
79
81
|
/**
|
|
80
82
|
* What `UserPromptSubmit` may spend on replay: the similar-prompt match plus
|
|
81
83
|
* any derivation a matched scenario with a target needs before its directive
|
|
82
|
-
* can go out (segmented.md R-PARAM-4). Below the route's
|
|
83
|
-
* decision to steer is always taken here and never by the guard.
|
|
84
|
+
* can go out (segmented.md R-PARAM-4). Below the route's 8.5 s deadline, so
|
|
85
|
+
* the decision to steer is always taken here and never by the guard.
|
|
86
|
+
*
|
|
87
|
+
* LONGER THAN THE SERVICE MAY TAKE TO READ, ON PURPOSE. The service gives a
|
|
88
|
+
* reading 6 s (its RUNNER_DERIVE_TIMEOUT_MS) and answers either way, so with
|
|
89
|
+
* the match and the round trip an answer can be 6.5 s after the prompt. At
|
|
90
|
+
* 6 s this wait ended before that answer could arrive: on 2026-10-08 a
|
|
91
|
+
* reading that took 6.0 s armed its plan 0.3 s after the prompt had been
|
|
92
|
+
* answered, and a good 20-step plan was thrown away (`arm_timeout`) for a
|
|
93
|
+
* turn the agent then spent 39 s on. The wait is only ever this long while a
|
|
94
|
+
* matched plan's values are still being read.
|
|
84
95
|
*/
|
|
85
|
-
const PROMPT_WAIT_MS =
|
|
96
|
+
const PROMPT_WAIT_MS = 7_500;
|
|
86
97
|
/**
|
|
87
98
|
* How long a `PreToolUse` waits for the intent probe when that probe may arm a
|
|
88
99
|
* plan in the call's place (`BIR_INTENT_ARM_BUDGET_MS`). A clear match or a
|
|
@@ -257,6 +268,137 @@ function isOn(value) {
|
|
|
257
268
|
return true;
|
|
258
269
|
return typeof value === "string" && ["1", "true"].includes(value.trim().toLowerCase());
|
|
259
270
|
}
|
|
271
|
+
/** The longest text a host may name itself, its version, its adapter or its scope by. */
|
|
272
|
+
const HOST_TEXT_MAX = 64;
|
|
273
|
+
/**
|
|
274
|
+
* The host a payload names, or undefined when it names none: a `host` whose
|
|
275
|
+
* `app` is missing, empty or not a text is ignored, and the session is a
|
|
276
|
+
* Claude Code session as before. Only the four known keys are kept. `app`,
|
|
277
|
+
* `version` and `adapter` are cut to {@link HOST_TEXT_MAX}; a scope is never
|
|
278
|
+
* cut — see {@link badScope}.
|
|
279
|
+
*/
|
|
280
|
+
function hostOf(payload) {
|
|
281
|
+
const h = payload.host;
|
|
282
|
+
if (!h || typeof h !== "object")
|
|
283
|
+
return undefined;
|
|
284
|
+
const app = typeof h.app === "string" ? h.app.trim().toLowerCase().slice(0, HOST_TEXT_MAX) : "";
|
|
285
|
+
if (!app)
|
|
286
|
+
return undefined;
|
|
287
|
+
const host = { app };
|
|
288
|
+
if (typeof h.version === "string" && h.version)
|
|
289
|
+
host.version = h.version.slice(0, HOST_TEXT_MAX);
|
|
290
|
+
if (typeof h.adapter === "string" && h.adapter)
|
|
291
|
+
host.adapter = h.adapter.slice(0, HOST_TEXT_MAX);
|
|
292
|
+
if (typeof h.scope === "string" && h.scope)
|
|
293
|
+
host.scope = h.scope;
|
|
294
|
+
return host;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Whether a payload's host names a scope the recorder cannot keep as it is:
|
|
298
|
+
* there, but empty, longer than {@link HOST_TEXT_MAX} or not a text.
|
|
299
|
+
*
|
|
300
|
+
* Such a scope is never cut and never dropped. The scope is part of what
|
|
301
|
+
* keeps one tenant's plans away from another's: cut or dropped, the run
|
|
302
|
+
* would land in another scope's pool.
|
|
303
|
+
*/
|
|
304
|
+
function badScope(payload) {
|
|
305
|
+
const h = payload.host;
|
|
306
|
+
if (!h || typeof h !== "object" || !hostOf(payload))
|
|
307
|
+
return false;
|
|
308
|
+
if (h.scope === undefined)
|
|
309
|
+
return false;
|
|
310
|
+
return typeof h.scope !== "string" || h.scope.length === 0 || h.scope.length > HOST_TEXT_MAX;
|
|
311
|
+
}
|
|
312
|
+
/** Whether `session` said its host is not Claude Code. */
|
|
313
|
+
function otherHost(session) {
|
|
314
|
+
return session?.host !== undefined && session.host.app !== "claude-code";
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The `framework` text of a run in a session of another host — its `app`, or
|
|
318
|
+
* `app:scope` when it named a scope — and undefined for Claude Code, whose
|
|
319
|
+
* runs keep the recorder's own framework. Up to 129 characters.
|
|
320
|
+
*/
|
|
321
|
+
function frameworkOf(session) {
|
|
322
|
+
if (!otherHost(session))
|
|
323
|
+
return undefined;
|
|
324
|
+
const host = session.host;
|
|
325
|
+
return host.scope ? `${host.app}:${host.scope}` : host.app;
|
|
326
|
+
}
|
|
327
|
+
/** What a host may hand over of one text: a transcript index keeps no more of a message. */
|
|
328
|
+
const HOST_TEXT_CHARS = 16 * 1024;
|
|
329
|
+
/** Tool names a host may declare as sinks, or as held by its agent. */
|
|
330
|
+
const HOST_NAMES_MAX = 4_096;
|
|
331
|
+
/** Models one run's usage may name. */
|
|
332
|
+
const HOST_USAGE_MAX = 32;
|
|
333
|
+
/** A payload text as the recorder takes it: a non-empty string, cut to `max` characters. */
|
|
334
|
+
function hostText(value, max = HOST_TEXT_CHARS) {
|
|
335
|
+
if (typeof value !== "string")
|
|
336
|
+
return undefined;
|
|
337
|
+
const text = value.trim();
|
|
338
|
+
return text ? text.slice(0, max) : undefined;
|
|
339
|
+
}
|
|
340
|
+
/** A payload's list of tool names, or undefined when it is not a list. */
|
|
341
|
+
function hostNames(value) {
|
|
342
|
+
if (!Array.isArray(value))
|
|
343
|
+
return undefined;
|
|
344
|
+
const names = [];
|
|
345
|
+
for (const item of value.slice(0, HOST_NAMES_MAX)) {
|
|
346
|
+
// A name alone, or `{ name }`, which is how an agent's tools are listed.
|
|
347
|
+
const name = typeof item === "string" ? item : item?.name;
|
|
348
|
+
if (typeof name === "string" && name)
|
|
349
|
+
names.push(name.slice(0, 200));
|
|
350
|
+
}
|
|
351
|
+
return names;
|
|
352
|
+
}
|
|
353
|
+
/** The sinks a host declared with its prompt. Always an object: see `HostSinks`. */
|
|
354
|
+
function hostSinksOf(payload) {
|
|
355
|
+
const sinks = payload.sinks;
|
|
356
|
+
if (!sinks || typeof sinks !== "object")
|
|
357
|
+
return {};
|
|
358
|
+
return { command: hostNames(sinks.command) ?? [], write: hostNames(sinks.write) ?? [] };
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* A payload's `usage`, under the names the price table takes. The host sends
|
|
362
|
+
* Claude's own names, the ones a transcript line carries, and they are mapped
|
|
363
|
+
* here as the transcript's are folded: a missing count is 0. Undefined when
|
|
364
|
+
* the payload carries no usable list — which is "no usage", never zero.
|
|
365
|
+
*/
|
|
366
|
+
function hostUsageOf(payload) {
|
|
367
|
+
if (!Array.isArray(payload.usage))
|
|
368
|
+
return undefined;
|
|
369
|
+
const count = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
|
|
370
|
+
const out = [];
|
|
371
|
+
for (const item of payload.usage.slice(0, HOST_USAGE_MAX)) {
|
|
372
|
+
if (!item || typeof item !== "object")
|
|
373
|
+
continue;
|
|
374
|
+
const u = item;
|
|
375
|
+
out.push({
|
|
376
|
+
model: typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown",
|
|
377
|
+
inputTokens: count(u.input_tokens),
|
|
378
|
+
outputTokens: count(u.output_tokens),
|
|
379
|
+
cacheReadTokens: count(u.cache_read_input_tokens),
|
|
380
|
+
cacheCreationTokens: count(u.cache_creation_input_tokens),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Price a turn's token usage. Measured only when tokens were used and every
|
|
387
|
+
* model that used them has a price: a model outside the table costs "$0",
|
|
388
|
+
* and a zero called measured is booked as a saving nobody made.
|
|
389
|
+
*/
|
|
390
|
+
function priceUsage(usage) {
|
|
391
|
+
const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
|
|
392
|
+
const unpriced = [...new Set(usage.filter((u) => !hasModelPricing(u.model)).map((u) => u.model))];
|
|
393
|
+
return { usd, measured: usage.length > 0 && unpriced.length === 0, unpriced };
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* What `/health` answers as `hostFields`: how much of another host's protocol
|
|
397
|
+
* this recorder understands. An adapter reads it to know which fields it may
|
|
398
|
+
* rely on — with 1 it records; from 2 it may also ask for a plan and step
|
|
399
|
+
* through it, because the checks a replay in another host needs are here.
|
|
400
|
+
*/
|
|
401
|
+
export const HOST_FIELDS = 2;
|
|
260
402
|
/** One MCP call, as seen from up to two sides. */
|
|
261
403
|
/**
|
|
262
404
|
* Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
|
|
@@ -327,6 +469,13 @@ export class ControlServer {
|
|
|
327
469
|
* SessionStart.
|
|
328
470
|
*/
|
|
329
471
|
offSessions = new Map();
|
|
472
|
+
/**
|
|
473
|
+
* Sessions of another host that named a scope the recorder cannot keep as
|
|
474
|
+
* it is (see {@link badScope}) → when last heard. Nothing of such a session
|
|
475
|
+
* is recorded or replayed: every hook of it is answered `{}`. Kept apart
|
|
476
|
+
* from {@link sessions}, so it never holds a run or takes a proxy's step.
|
|
477
|
+
*/
|
|
478
|
+
refusedSessions = new Map();
|
|
330
479
|
/**
|
|
331
480
|
* Proxy ids that registered with *this* recorder (UPG-7). A poll from any
|
|
332
481
|
* other id is a proxy from before this recorder started — a 0.2.12 proxy
|
|
@@ -421,6 +570,7 @@ export class ControlServer {
|
|
|
421
570
|
if (!this.lossy)
|
|
422
571
|
logLine("run.lossy", { why: "step queue overflowed", dropped });
|
|
423
572
|
this.lossy = true;
|
|
573
|
+
this.markOpenRunsLossy();
|
|
424
574
|
},
|
|
425
575
|
onError: (err) => logLine("control.step_failed", { error: errText(err) }),
|
|
426
576
|
});
|
|
@@ -472,7 +622,7 @@ export class ControlServer {
|
|
|
472
622
|
this.heldPolls.clear();
|
|
473
623
|
for (const session of this.sessions.values()) {
|
|
474
624
|
if (session.run && !session.run.finished) {
|
|
475
|
-
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close });
|
|
625
|
+
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close, reason: "recorder_closed" });
|
|
476
626
|
}
|
|
477
627
|
}
|
|
478
628
|
// Bounded: a recorder being stopped must stop, and a service that is down
|
|
@@ -567,6 +717,9 @@ export class ControlServer {
|
|
|
567
717
|
url: this.address?.url,
|
|
568
718
|
wrappedServers: [...this.wrapped],
|
|
569
719
|
hooksVersion: packageVersion(),
|
|
720
|
+
// How much of another host's protocol this recorder understands: what
|
|
721
|
+
// an adapter reads before it relies on a field (see HOST_FIELDS).
|
|
722
|
+
hostFields: HOST_FIELDS,
|
|
570
723
|
registeredProxies: [...this.registrations].map(([serverName, r]) => ({
|
|
571
724
|
serverName,
|
|
572
725
|
pid: r.pid,
|
|
@@ -947,6 +1100,9 @@ export class ControlServer {
|
|
|
947
1100
|
bytes: err.bytes,
|
|
948
1101
|
why: "read and discarded, answered {} — this event is not recorded",
|
|
949
1102
|
});
|
|
1103
|
+
// Whose event it was went unread with the body: every run open now
|
|
1104
|
+
// may be the one that lost it.
|
|
1105
|
+
this.markOpenRunsLossy();
|
|
950
1106
|
answer({});
|
|
951
1107
|
return;
|
|
952
1108
|
}
|
|
@@ -964,6 +1120,12 @@ export class ControlServer {
|
|
|
964
1120
|
answer({});
|
|
965
1121
|
return;
|
|
966
1122
|
}
|
|
1123
|
+
// A session of another host that named a scope the recorder cannot
|
|
1124
|
+
// keep: nothing of it is recorded or replayed.
|
|
1125
|
+
if (this.hostRefused(payload)) {
|
|
1126
|
+
answer({});
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
967
1129
|
const out = await this.dispatchHook(route, payload, (fn) => {
|
|
968
1130
|
late = fn;
|
|
969
1131
|
});
|
|
@@ -1055,8 +1217,48 @@ export class ControlServer {
|
|
|
1055
1217
|
}
|
|
1056
1218
|
this.offSessions.set(sessionId, { at: Date.now() });
|
|
1057
1219
|
const session = this.sessions.get(sessionId);
|
|
1058
|
-
if (session?.run && !session.run.finished)
|
|
1059
|
-
this.closeRun(session, { detach: true });
|
|
1220
|
+
if (session?.run && !session.run.finished) {
|
|
1221
|
+
this.closeRun(session, { detach: true, reason: "session_off" });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Whether this hook belongs to a session of another host whose scope the
|
|
1226
|
+
* recorder cannot keep as it is (see {@link badScope}), and so is answered
|
|
1227
|
+
* `{}` unprocessed — every hook of it, like a session that is off. One
|
|
1228
|
+
* line says so, once for the session.
|
|
1229
|
+
*
|
|
1230
|
+
* A session that already has a host keeps it: a later payload cannot turn
|
|
1231
|
+
* a run that is being recorded into one that is not.
|
|
1232
|
+
*/
|
|
1233
|
+
hostRefused(payload) {
|
|
1234
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
|
|
1235
|
+
if (sessionId && this.sessions.get(sessionId)?.host)
|
|
1236
|
+
return false;
|
|
1237
|
+
if (badScope(payload)) {
|
|
1238
|
+
if (!sessionId || !this.refusedSessions.has(sessionId)) {
|
|
1239
|
+
logLine("session.host_refused", {
|
|
1240
|
+
sess: sessionId,
|
|
1241
|
+
host: hostOf(payload)?.app,
|
|
1242
|
+
why: `its scope is empty, longer than ${HOST_TEXT_MAX} characters or not a text — a scope is never cut or dropped, ` +
|
|
1243
|
+
"because the run would land in another scope's plans; nothing of this session is recorded or replayed",
|
|
1244
|
+
fix: `give the adapter a scope of 1 to ${HOST_TEXT_MAX} characters, or none`,
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
if (sessionId)
|
|
1248
|
+
this.refusedSessions.set(sessionId, Date.now());
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
if (!sessionId || !this.refusedSessions.has(sessionId))
|
|
1252
|
+
return false;
|
|
1253
|
+
this.refusedSessions.set(sessionId, Date.now());
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
/** Flag every run open right now as having lost steps (see `RunState.lossy`). */
|
|
1257
|
+
markOpenRunsLossy() {
|
|
1258
|
+
for (const session of this.sessions.values()) {
|
|
1259
|
+
if (session.run && !session.run.finished)
|
|
1260
|
+
session.run.lossy = true;
|
|
1261
|
+
}
|
|
1060
1262
|
}
|
|
1061
1263
|
/** Whether `sessionId` is a session started with `BIR_OFF=1`. */
|
|
1062
1264
|
isSessionOff(sessionId) {
|
|
@@ -1223,6 +1425,11 @@ export class ControlServer {
|
|
|
1223
1425
|
session.transcriptPath = payload.transcript_path;
|
|
1224
1426
|
if (payload.cwd)
|
|
1225
1427
|
session.cwd = payload.cwd;
|
|
1428
|
+
// The first host a session names is the one it keeps. Any route may carry
|
|
1429
|
+
// it: a recorder that restarted in the middle of a run learns it from the
|
|
1430
|
+
// run's next hook, and so opens no Claude Code run for it.
|
|
1431
|
+
if (!session.host)
|
|
1432
|
+
session.host = hostOf(payload);
|
|
1226
1433
|
return session;
|
|
1227
1434
|
}
|
|
1228
1435
|
/**
|
|
@@ -1287,7 +1494,7 @@ export class ControlServer {
|
|
|
1287
1494
|
idleMin: Math.round((now - session.lastSeenAt) / 60_000),
|
|
1288
1495
|
why: "no hook for this session in a long time — the turn was interrupted and never resumed",
|
|
1289
1496
|
});
|
|
1290
|
-
this.closeRun(session, { detach: false });
|
|
1497
|
+
this.closeRun(session, { detach: false, reason: "idle" });
|
|
1291
1498
|
}
|
|
1292
1499
|
if (session.run && !session.run.finished)
|
|
1293
1500
|
continue;
|
|
@@ -1327,6 +1534,16 @@ export class ControlServer {
|
|
|
1327
1534
|
if (now - hello.at > DELIVERY_HELLO_TTL_MS || !pidAlive(pid))
|
|
1328
1535
|
this.scenarioHellos.delete(pid);
|
|
1329
1536
|
}
|
|
1537
|
+
for (const [sessionId, at] of this.refusedSessions) {
|
|
1538
|
+
if (now - at >= t.forgetMs)
|
|
1539
|
+
this.refusedSessions.delete(sessionId);
|
|
1540
|
+
}
|
|
1541
|
+
if (this.refusedSessions.size > OFF_SESSIONS_MAX) {
|
|
1542
|
+
const oldest = [...this.refusedSessions].sort((a, b) => a[1] - b[1]);
|
|
1543
|
+
for (const [sessionId] of oldest.slice(0, this.refusedSessions.size - OFF_SESSIONS_MAX)) {
|
|
1544
|
+
this.refusedSessions.delete(sessionId);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1330
1547
|
}
|
|
1331
1548
|
/** Drop a session and everything keyed on it. Its last run was sealed and drained already. */
|
|
1332
1549
|
forgetSession(session) {
|
|
@@ -1339,15 +1556,23 @@ export class ControlServer {
|
|
|
1339
1556
|
}
|
|
1340
1557
|
logDetail("session.forgotten", { sess: session.sessionId });
|
|
1341
1558
|
}
|
|
1342
|
-
/**
|
|
1343
|
-
|
|
1559
|
+
/**
|
|
1560
|
+
* Open a run for `session`, or return the open one.
|
|
1561
|
+
*
|
|
1562
|
+
* `asked` is what the prompt that opens the run asked for, in a session of
|
|
1563
|
+
* another host: whether its adapter wants a plan (`HookPayload.replay`).
|
|
1564
|
+
*/
|
|
1565
|
+
ensureRun(session, input = "", asked = {}) {
|
|
1344
1566
|
if (session.run && !session.run.finished)
|
|
1345
1567
|
return session.run;
|
|
1346
1568
|
// The sealed run stays reachable for a moment: a report or a post that
|
|
1347
1569
|
// arrives just after its turn ended is that turn's, not this one's.
|
|
1348
1570
|
if (session.run)
|
|
1349
1571
|
session.previous = session.run;
|
|
1350
|
-
|
|
1572
|
+
// A run of another host says so in its `framework`: the service keeps a
|
|
1573
|
+
// plan inside the host, and the scope, that recorded it.
|
|
1574
|
+
const framework = frameworkOf(session);
|
|
1575
|
+
const runId = this.recorder.startRun(input, this.runMetadata(session), framework ? { framework } : undefined);
|
|
1351
1576
|
const run = {
|
|
1352
1577
|
runId,
|
|
1353
1578
|
input,
|
|
@@ -1364,9 +1589,20 @@ export class ControlServer {
|
|
|
1364
1589
|
// Taken here, beside `startedAtMs`, so the cost window and the duration
|
|
1365
1590
|
// window are the same window (docs/calculatedReplay.md §11.4).
|
|
1366
1591
|
usageMark: markTranscriptUsage(session.transcriptPath),
|
|
1592
|
+
// Written here and not by `onPrompt` after this returns: the match
|
|
1593
|
+
// watched below asks at once whether replay is on for this run
|
|
1594
|
+
// (`armFromMatch`), and a flag set afterwards would come too late —
|
|
1595
|
+
// every run of another host would then only record.
|
|
1596
|
+
hostReplay: asked.replay === true ? true : undefined,
|
|
1367
1597
|
};
|
|
1368
1598
|
session.run = run;
|
|
1369
|
-
logLine("run.start", {
|
|
1599
|
+
logLine("run.start", {
|
|
1600
|
+
run: runId,
|
|
1601
|
+
sess: session.sessionId,
|
|
1602
|
+
tier: "bound",
|
|
1603
|
+
// Named only for another host: `bir investigate` reads it from here.
|
|
1604
|
+
host: otherHost(session) ? session.host?.app : undefined,
|
|
1605
|
+
});
|
|
1370
1606
|
// Journal-only: the prompt is what `bir investigate` lists a turn by. Never
|
|
1371
1607
|
// on stderr, and never more than a preview.
|
|
1372
1608
|
if (input)
|
|
@@ -1379,8 +1615,10 @@ export class ControlServer {
|
|
|
1379
1615
|
runMetadata(session) {
|
|
1380
1616
|
return {
|
|
1381
1617
|
recorder: "baseinstrunner",
|
|
1618
|
+
// Which runner made the recording: the service keeps it with the run.
|
|
1619
|
+
runnerVersion: packageVersion(),
|
|
1382
1620
|
tier: "bound",
|
|
1383
|
-
host: this.opts.host ?? { app: "claude-code" },
|
|
1621
|
+
host: session.host ?? this.opts.host ?? { app: "claude-code" },
|
|
1384
1622
|
wrappedServers: [...this.wrapped],
|
|
1385
1623
|
// Known-at-start value. A run that turns lossy later says so on the audit
|
|
1386
1624
|
// log; carrying it here too would need a metadata patch the service does
|
|
@@ -1441,8 +1679,11 @@ export class ControlServer {
|
|
|
1441
1679
|
* one `replay.decision` line says why (docs/calculatedReplay.md §10).
|
|
1442
1680
|
*/
|
|
1443
1681
|
async armFromMatch(session, run, pending, prompt, startedAt) {
|
|
1444
|
-
|
|
1682
|
+
// Off for the recorder, or — in a session of another host — not asked
|
|
1683
|
+
// for by this run's adapter: either way nothing can be announced.
|
|
1684
|
+
if (!this.replay.enabled || this.recordsOnly(session, run)) {
|
|
1445
1685
|
return this.noteDisabledMatch(session, run, pending, prompt);
|
|
1686
|
+
}
|
|
1446
1687
|
const promptWaitMs = this.promptWaitMs();
|
|
1447
1688
|
const matchBudget = Math.min(this.replay.budgets.matchMs, promptWaitMs);
|
|
1448
1689
|
if (Date.now() < this.matchGate.holdUntil) {
|
|
@@ -1513,9 +1754,7 @@ export class ControlServer {
|
|
|
1513
1754
|
// Awaited: a scenario with a target derives before the directive goes
|
|
1514
1755
|
// out, so a plan that cannot find its target is never delivered
|
|
1515
1756
|
// (segmented.md R-PARAM-4). Inside the prompt's own budget.
|
|
1516
|
-
const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
|
|
1517
|
-
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
1518
|
-
}));
|
|
1757
|
+
const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", { recentResults: this.recentResultsFor(session, "prompt") }, this.hostContext(session, run)));
|
|
1519
1758
|
const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
|
|
1520
1759
|
const state = await this.withinBudget(arming, remaining);
|
|
1521
1760
|
if (state === undefined)
|
|
@@ -1540,12 +1779,12 @@ export class ControlServer {
|
|
|
1540
1779
|
});
|
|
1541
1780
|
}
|
|
1542
1781
|
if (state) {
|
|
1543
|
-
this.shelve(session, run, state);
|
|
1782
|
+
this.shelve(session, run, state, "arm_unannounced");
|
|
1544
1783
|
}
|
|
1545
1784
|
else {
|
|
1546
1785
|
void arming.then((late) => {
|
|
1547
1786
|
if (late)
|
|
1548
|
-
this.shelve(session, run, late);
|
|
1787
|
+
this.shelve(session, run, late, "arm_timeout");
|
|
1549
1788
|
});
|
|
1550
1789
|
}
|
|
1551
1790
|
return undefined;
|
|
@@ -1567,7 +1806,9 @@ export class ControlServer {
|
|
|
1567
1806
|
await this.replay.settleForPreview(state, settleMs);
|
|
1568
1807
|
// The argument preview runs logic; it spends what is left of the prompt's
|
|
1569
1808
|
// own budget, never time on top of it (under 10 ms left, none is shown).
|
|
1570
|
-
|
|
1809
|
+
// None at all for another host: its adapter reads the plan as data, and
|
|
1810
|
+
// no model reads this text.
|
|
1811
|
+
const previewMs = otherHost(session) ? 0 : Math.max(0, promptWaitMs - (Date.now() - startedAt));
|
|
1571
1812
|
const directive = state.plan ? this.replay.directiveFor(state, { previewMs }) : undefined;
|
|
1572
1813
|
if (state.plan && !directive) {
|
|
1573
1814
|
// A plan with nothing to announce is a plan the model is never told of.
|
|
@@ -1642,7 +1883,7 @@ export class ControlServer {
|
|
|
1642
1883
|
if (!match || run.finished)
|
|
1643
1884
|
return undefined;
|
|
1644
1885
|
// Declined at its first gate, before derivation: no transcript is read for it.
|
|
1645
|
-
const state = await this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt"));
|
|
1886
|
+
const state = await this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {}, this.hostContext(session, run)));
|
|
1646
1887
|
if (!state || run.finished || run.replays.includes(state))
|
|
1647
1888
|
return undefined;
|
|
1648
1889
|
if (state.plan) {
|
|
@@ -1830,6 +2071,55 @@ export class ControlServer {
|
|
|
1830
2071
|
return undefined;
|
|
1831
2072
|
});
|
|
1832
2073
|
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Whether `run` only records: a run of another host whose adapter did not
|
|
2076
|
+
* ask for a plan with its prompt. Treated as a run with replay off in the
|
|
2077
|
+
* three places that ask (`onPrompt`, `armFromMatch`, `tryIntentMatch`).
|
|
2078
|
+
*
|
|
2079
|
+
* An adapter that only records never sends `replay`. Without this the
|
|
2080
|
+
* recorder would start a plan for every prompt that matches one, read the
|
|
2081
|
+
* model's own calls against it, and book a replay nobody ran. A run another
|
|
2082
|
+
* hook opened carries no flag either, and only records.
|
|
2083
|
+
*/
|
|
2084
|
+
recordsOnly(session, run) {
|
|
2085
|
+
return otherHost(session) && run.hostReplay !== true;
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* What {@link ReplayController.arm} is told of `session` when its host is
|
|
2089
|
+
* not Claude Code, and undefined when it is.
|
|
2090
|
+
*/
|
|
2091
|
+
hostContext(session, run) {
|
|
2092
|
+
if (!otherHost(session))
|
|
2093
|
+
return undefined;
|
|
2094
|
+
return {
|
|
2095
|
+
app: session.host.app,
|
|
2096
|
+
sinks: session.hostSinks ?? {},
|
|
2097
|
+
recordsOnly: this.recordsOnly(session, run) || undefined,
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* The earlier tool results the reading of new values may look at: this
|
|
2102
|
+
* prompt's own, from the transcript — and so none for a plan that is
|
|
2103
|
+
* started at the prompt (segmented.md R-PARAM-2a).
|
|
2104
|
+
*
|
|
2105
|
+
* `at` is where the plan is being started, and at the prompt a transcript
|
|
2106
|
+
* has nothing to give. The prompt was only just submitted, so it has no
|
|
2107
|
+
* results of its own; and the transcript does not hold it yet, so what the
|
|
2108
|
+
* transcript calls "this prompt's results" are still the *previous*
|
|
2109
|
+
* prompt's. They used to be sent as this task's, and they answered for it:
|
|
2110
|
+
* a second prompt that named one person ran on the person the first
|
|
2111
|
+
* prompt's results were full of.
|
|
2112
|
+
*
|
|
2113
|
+
* A session of another host has no transcript, and a plan starts there at
|
|
2114
|
+
* the prompt only: none either. Its adapter used to hand the conversation's
|
|
2115
|
+
* earlier results over with the prompt (`recent_results`) — the same
|
|
2116
|
+
* mistake, made on purpose. An adapter that still sends them is not read.
|
|
2117
|
+
*/
|
|
2118
|
+
recentResultsFor(session, at) {
|
|
2119
|
+
if (at === "prompt" || otherHost(session))
|
|
2120
|
+
return [];
|
|
2121
|
+
return recentToolResults(session.transcriptPath, this.deriveRecentResults);
|
|
2122
|
+
}
|
|
1833
2123
|
/**
|
|
1834
2124
|
* The prompt hook answered without a directive: retire any prompt-armed plan
|
|
1835
2125
|
* of `run` that could still steer. `armFromMatch` arms one live only while
|
|
@@ -1849,7 +2139,7 @@ export class ControlServer {
|
|
|
1849
2139
|
scenario: state.scenarioId ?? undefined,
|
|
1850
2140
|
why: "the prompt was answered without this plan's directive — retired unannounced, so never run",
|
|
1851
2141
|
});
|
|
1852
|
-
this.shelve(session, run, state);
|
|
2142
|
+
this.shelve(session, run, state, "arm_unannounced");
|
|
1853
2143
|
}
|
|
1854
2144
|
}
|
|
1855
2145
|
/**
|
|
@@ -1857,14 +2147,20 @@ export class ControlServer {
|
|
|
1857
2147
|
* told of it, so it must never steer or diverge a call: it is kept only as
|
|
1858
2148
|
* what it now is — a matched turn the agent does itself, which still owes the
|
|
1859
2149
|
* ledger its baseline sample (§11.1) — exactly like a declined match.
|
|
2150
|
+
*
|
|
2151
|
+
* `why` is the code the report carries when the plan was only late. Without
|
|
2152
|
+
* it the service got a matched turn that was not steered and no reason, and
|
|
2153
|
+
* its console could only say that the runner had given none. A plan a gate
|
|
2154
|
+
* declined keeps the gate's own code.
|
|
1860
2155
|
*/
|
|
1861
|
-
shelve(session, run, late) {
|
|
2156
|
+
shelve(session, run, late, why) {
|
|
1862
2157
|
if (late.plan && !late.retired) {
|
|
1863
2158
|
logDetail("replay.shelved", {
|
|
1864
2159
|
run: run.runId,
|
|
1865
2160
|
scenario: late.scenarioId ?? undefined,
|
|
1866
2161
|
why: "armed without its directive reaching the model — retired, never steers",
|
|
1867
2162
|
});
|
|
2163
|
+
late.declined ??= why;
|
|
1868
2164
|
}
|
|
1869
2165
|
late.sessionId = session.sessionId;
|
|
1870
2166
|
late.plan = undefined;
|
|
@@ -1889,11 +2185,30 @@ export class ControlServer {
|
|
|
1889
2185
|
* routinely left half-open, which is what happened in practice.
|
|
1890
2186
|
*
|
|
1891
2187
|
* Idempotent: the second caller sees `finished` and does nothing.
|
|
2188
|
+
*
|
|
2189
|
+
* `reason` says why the run is closed. It decides something only in a
|
|
2190
|
+
* session of another host (see {@link CloseReason}).
|
|
1892
2191
|
*/
|
|
1893
|
-
sealRun(session) {
|
|
2192
|
+
sealRun(session, reason = "stop") {
|
|
1894
2193
|
const run = session.run;
|
|
1895
2194
|
if (!run || run.finished)
|
|
1896
2195
|
return;
|
|
2196
|
+
const other = otherHost(session);
|
|
2197
|
+
// A run of another host that is closed before its turn's work ended: it
|
|
2198
|
+
// failed, went quiet, or its session ended under it. A LangChain run does
|
|
2199
|
+
// not always end with a stop — nothing calls an end hook when it fails.
|
|
2200
|
+
const early = other && reason !== "stop";
|
|
2201
|
+
// Such a run's cost says nothing of what the task costs. Nor does that of
|
|
2202
|
+
// a run closed — in any way — with its plan still live. That plan is
|
|
2203
|
+
// handed over first, before the report is built, so the report says what
|
|
2204
|
+
// happened: `fell_back` when a step ran, `failed` when none did, and not
|
|
2205
|
+
// `not_steered`, as if no plan had started.
|
|
2206
|
+
let cutShort = early;
|
|
2207
|
+
if (other) {
|
|
2208
|
+
const live = this.livePlan(run);
|
|
2209
|
+
if (live && this.replay.hostHandOver(live, "closed"))
|
|
2210
|
+
cutShort = true;
|
|
2211
|
+
}
|
|
1897
2212
|
run.finished = true;
|
|
1898
2213
|
for (const correlation of run.correlations.values()) {
|
|
1899
2214
|
if (correlation.fallback)
|
|
@@ -1917,20 +2232,39 @@ export class ControlServer {
|
|
|
1917
2232
|
// A matched turn records nothing, but it still owes the ledger a number —
|
|
1918
2233
|
// including when it declined to steer, which is a *baseline* sample and is
|
|
1919
2234
|
// what every saving is measured against (docs/calculatedReplay.md §11).
|
|
1920
|
-
this.reportExecution(session, run, durationMs, cost);
|
|
2235
|
+
this.reportExecution(session, run, durationMs, cost, cutShort);
|
|
1921
2236
|
// A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
|
|
1922
2237
|
// and duration are the model's own work, not the steps the scenario did.
|
|
2238
|
+
// Another host hands over one number for the whole run, and it is the
|
|
2239
|
+
// fragment's: the plan's own steps made no model call, so all of it was
|
|
2240
|
+
// spent after the hand-over.
|
|
1923
2241
|
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
1924
|
-
const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
|
|
2242
|
+
const recordedCost = fragment && !other ? this.costSince(session, fragment.usageMark) : cost;
|
|
1925
2243
|
const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
|
|
1926
2244
|
if (run.recording) {
|
|
1927
2245
|
const transcriptPath = session.transcriptPath;
|
|
2246
|
+
// The answer is the host's when it handed one over, and the
|
|
2247
|
+
// transcript's otherwise — never both. A run that ended early has none:
|
|
2248
|
+
// whatever text there is, it is not the answer to the prompt.
|
|
2249
|
+
const answerText = early
|
|
2250
|
+
? Promise.resolve("")
|
|
2251
|
+
: run.hostAnswer !== undefined
|
|
2252
|
+
? Promise.resolve(run.hostAnswer)
|
|
2253
|
+
: settledLastAssistantText(transcriptPath).catch(() => "");
|
|
2254
|
+
// What the finish of another host's run says beside its answer and its
|
|
2255
|
+
// cost: why it ended early, when it did, and whether it is whole. A
|
|
2256
|
+
// run with either is never calculated and stops catching its prompt.
|
|
2257
|
+
const closing = other
|
|
2258
|
+
? {
|
|
2259
|
+
error: early ? (run.hostFailed ?? `turn ended early: ${reason}`) : undefined,
|
|
2260
|
+
incomplete: { lossy: run.lossy === true },
|
|
2261
|
+
}
|
|
2262
|
+
: undefined;
|
|
1928
2263
|
// Resolve the answer *outside* the queue: the wait below is up to two
|
|
1929
2264
|
// seconds, and the queue is serial — blocking it here would delay the
|
|
1930
2265
|
// first step of the user's next turn by that much. Kept on the run, so a
|
|
1931
2266
|
// drain of this run waits for this seal and for nobody else's.
|
|
1932
|
-
run.sealing =
|
|
1933
|
-
.catch(() => "")
|
|
2267
|
+
run.sealing = answerText
|
|
1934
2268
|
.then((answer) => {
|
|
1935
2269
|
run.finishQueued = true;
|
|
1936
2270
|
this.queue.push(() => {
|
|
@@ -1949,6 +2283,7 @@ export class ControlServer {
|
|
|
1949
2283
|
this.recorder.finishRun(run.runId, answer || undefined, {
|
|
1950
2284
|
durationMs: recordedMs,
|
|
1951
2285
|
costUsd: recordedCost.measured ? recordedCost.usd : undefined,
|
|
2286
|
+
...closing,
|
|
1952
2287
|
});
|
|
1953
2288
|
});
|
|
1954
2289
|
});
|
|
@@ -1961,6 +2296,9 @@ export class ControlServer {
|
|
|
1961
2296
|
measured: cost.measured,
|
|
1962
2297
|
lossy: this.lossy,
|
|
1963
2298
|
recorded: run.recording,
|
|
2299
|
+
// Only for another host, and only when the run did not end with an
|
|
2300
|
+
// ordinary stop.
|
|
2301
|
+
ended: early ? reason : undefined,
|
|
1964
2302
|
});
|
|
1965
2303
|
}
|
|
1966
2304
|
/**
|
|
@@ -1985,6 +2323,11 @@ export class ControlServer {
|
|
|
1985
2323
|
* false` and zero rather than a number that is confidently wrong.
|
|
1986
2324
|
*/
|
|
1987
2325
|
runCost(session, run) {
|
|
2326
|
+
// Another host has no transcript: its numbers are the ones it handed
|
|
2327
|
+
// over with its stop, and they are looked at before any watermark — a
|
|
2328
|
+
// run of such a host never has one.
|
|
2329
|
+
if (otherHost(session))
|
|
2330
|
+
return this.hostCost(run);
|
|
1988
2331
|
const measurable = run.usageMark !== undefined;
|
|
1989
2332
|
if (!measurable) {
|
|
1990
2333
|
logLine("run.unmeasured", {
|
|
@@ -1993,21 +2336,57 @@ export class ControlServer {
|
|
|
1993
2336
|
});
|
|
1994
2337
|
return { usd: 0, measured: false };
|
|
1995
2338
|
}
|
|
1996
|
-
|
|
2339
|
+
const cost = this.costSince(session, run.usageMark);
|
|
2340
|
+
if (cost.unpriced?.length) {
|
|
2341
|
+
logLine("run.unmeasured", {
|
|
2342
|
+
run: run.runId,
|
|
2343
|
+
why: "the runner has no price for a model of this turn — update the runner",
|
|
2344
|
+
models: cost.unpriced.join(","),
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
return cost;
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* What a run of another host cost, from the tokens its adapter handed over
|
|
2351
|
+
* with the stop. Measured only when at least one entry has tokens and every
|
|
2352
|
+
* model in the list has a price; with no usage at all, not measured — and
|
|
2353
|
+
* one `run.unmeasured` line says which.
|
|
2354
|
+
*/
|
|
2355
|
+
hostCost(run) {
|
|
2356
|
+
const usage = run.hostUsage;
|
|
2357
|
+
if (!usage || usage.length === 0) {
|
|
2358
|
+
logLine("run.unmeasured", { run: run.runId, why: "the host sent no usage" });
|
|
2359
|
+
return { usd: 0, measured: false };
|
|
2360
|
+
}
|
|
2361
|
+
const cost = priceUsage(usage);
|
|
2362
|
+
const used = usage.some((u) => u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreationTokens > 0);
|
|
2363
|
+
if (used && cost.measured)
|
|
2364
|
+
return cost;
|
|
2365
|
+
logLine("run.unmeasured", {
|
|
2366
|
+
run: run.runId,
|
|
2367
|
+
why: cost.unpriced?.length
|
|
2368
|
+
? "the runner has no price for a model of this run"
|
|
2369
|
+
: "the host's usage holds no tokens — the run made no model call",
|
|
2370
|
+
models: cost.unpriced?.length ? cost.unpriced.join(",") : undefined,
|
|
2371
|
+
});
|
|
2372
|
+
return { ...cost, measured: false };
|
|
1997
2373
|
}
|
|
1998
|
-
/** Transcript cost since `mark`; unmeasured (and zero) without one. */
|
|
2374
|
+
/** Transcript cost since `mark`; unmeasured (and zero) without one, or with a model that has no price. */
|
|
1999
2375
|
costSince(session, mark) {
|
|
2000
2376
|
if (mark === undefined)
|
|
2001
2377
|
return { usd: 0, measured: false };
|
|
2002
|
-
|
|
2003
|
-
const usd = deltas.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
|
|
2004
|
-
return { usd, measured: deltas.length > 0 };
|
|
2378
|
+
return priceUsage(usageSince(session.transcriptPath, mark));
|
|
2005
2379
|
}
|
|
2006
|
-
reportExecution(session, run, durationMs, cost
|
|
2380
|
+
reportExecution(session, run, durationMs, cost,
|
|
2381
|
+
/** The run was closed before its task ended for the caller (another host only). */
|
|
2382
|
+
cutShort = false) {
|
|
2007
2383
|
if (!isScenarioReporter(this.recorder))
|
|
2008
2384
|
return;
|
|
2009
2385
|
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
2010
2386
|
const recorder = this.recorder;
|
|
2387
|
+
// Another host hands over one number for the whole run. It cannot be
|
|
2388
|
+
// split between two plans, so with more than one neither is measured.
|
|
2389
|
+
const unsplit = otherHost(session) && states.length > 1;
|
|
2011
2390
|
// One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
|
|
2012
2391
|
// for its own window of the transcript — from when it armed to when the next
|
|
2013
2392
|
// one did — so the turn's tokens are split between them, never counted twice.
|
|
@@ -2039,11 +2418,12 @@ export class ControlServer {
|
|
|
2039
2418
|
sessionCostUsd: windowCost.usd,
|
|
2040
2419
|
// A direct run the user cancelled cut its turn short: its cost is no
|
|
2041
2420
|
// baseline, and no saving is measured against it.
|
|
2042
|
-
measured: windowCost.measured && !this.cancelledPlans.has(state),
|
|
2421
|
+
measured: windowCost.measured && !this.cancelledPlans.has(state) && !unsplit,
|
|
2043
2422
|
durationMs: Math.max(0, windowMs),
|
|
2044
2423
|
prompt: run.input || undefined,
|
|
2045
2424
|
siblings: states,
|
|
2046
2425
|
runId: run.runId,
|
|
2426
|
+
cutShort: cutShort || undefined,
|
|
2047
2427
|
});
|
|
2048
2428
|
if (!report)
|
|
2049
2429
|
return;
|
|
@@ -2076,7 +2456,7 @@ export class ControlServer {
|
|
|
2076
2456
|
const run = session.run;
|
|
2077
2457
|
if (!run)
|
|
2078
2458
|
return;
|
|
2079
|
-
this.sealRun(session);
|
|
2459
|
+
this.sealRun(session, opts.reason);
|
|
2080
2460
|
if (opts.detach && session.run === run) {
|
|
2081
2461
|
session.run = undefined;
|
|
2082
2462
|
session.previous = run;
|
|
@@ -2145,7 +2525,7 @@ export class ControlServer {
|
|
|
2145
2525
|
// → Stop is the only honest run boundary. The previous turn was
|
|
2146
2526
|
// interrupted — Stop does not fire on Esc — so it is sealed here, and its
|
|
2147
2527
|
// drain runs behind this prompt rather than in front of it.
|
|
2148
|
-
this.closeRun(session, { detach: true });
|
|
2528
|
+
this.closeRun(session, { detach: true, reason: "interrupted" });
|
|
2149
2529
|
}
|
|
2150
2530
|
else if (open && open.ordering.next === 0) {
|
|
2151
2531
|
// A run was opened early by a proxy step (the host spawned MCP servers
|
|
@@ -2184,16 +2564,24 @@ export class ControlServer {
|
|
|
2184
2564
|
});
|
|
2185
2565
|
return {};
|
|
2186
2566
|
}
|
|
2187
|
-
const
|
|
2567
|
+
const other = otherHost(session);
|
|
2568
|
+
if (other) {
|
|
2569
|
+
// What a host hands over with its prompt, kept before the run opens:
|
|
2570
|
+
// the match watched from there may start a plan at once, and the places
|
|
2571
|
+
// that do have no prompt payload in reach.
|
|
2572
|
+
session.hostSinks = hostSinksOf(payload);
|
|
2573
|
+
}
|
|
2574
|
+
const run = this.ensureRun(session, input, { replay: other && payload.replay === true });
|
|
2188
2575
|
run.promptSeen = true;
|
|
2189
2576
|
run.input = input;
|
|
2190
2577
|
session.prompts += 1;
|
|
2191
2578
|
logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
|
|
2192
2579
|
// With replay off there is nothing this hook could say, so it says nothing
|
|
2193
2580
|
// at once: the match still stops recording on a hit (v1), in the
|
|
2194
|
-
// background, and no prompt waits on the service for it.
|
|
2581
|
+
// background, and no prompt waits on the service for it. The same for a
|
|
2582
|
+
// run of another host whose adapter asked for no plan.
|
|
2195
2583
|
const watch = this.watchForMatch(session, run, input);
|
|
2196
|
-
if (!this.replay.enabled)
|
|
2584
|
+
if (!this.replay.enabled || this.recordsOnly(session, run))
|
|
2197
2585
|
return {};
|
|
2198
2586
|
// Plan mode is the user asking to see a plan before anything runs. A
|
|
2199
2587
|
// replay would run tools, auto-approved: nothing is announced, and a plan
|
|
@@ -2225,12 +2613,18 @@ export class ControlServer {
|
|
|
2225
2613
|
this.retireUnannounced(session, run);
|
|
2226
2614
|
return {};
|
|
2227
2615
|
}
|
|
2228
|
-
|
|
2616
|
+
const answer = {
|
|
2229
2617
|
hookSpecificOutput: {
|
|
2230
2618
|
hookEventName: "UserPromptSubmit",
|
|
2231
2619
|
additionalContext: directive,
|
|
2232
2620
|
},
|
|
2233
2621
|
};
|
|
2622
|
+
// Another host steps through the plan itself, so it is told the plan as
|
|
2623
|
+
// data too: the directive is a text written for a model. At the top
|
|
2624
|
+
// level, where `noPlan` and `declined` are — `forEvent` can drop
|
|
2625
|
+
// `hookSpecificOutput` as a whole. A Claude Code session gets no new key.
|
|
2626
|
+
const plan = other && run.replay ? this.replay.planData(run.replay) : undefined;
|
|
2627
|
+
return plan ? { ...answer, plan } : answer;
|
|
2234
2628
|
}
|
|
2235
2629
|
/**
|
|
2236
2630
|
* `PreToolUse`. Two shapes of answer:
|
|
@@ -2250,6 +2644,10 @@ export class ControlServer {
|
|
|
2250
2644
|
*/
|
|
2251
2645
|
async onToolPre(payload, onLate) {
|
|
2252
2646
|
const session = this.ensureSession(payload);
|
|
2647
|
+
// A session of another host has a route of its own: its tools are the
|
|
2648
|
+
// host's, and everything below reads a tool's name as Claude Code's.
|
|
2649
|
+
if (otherHost(session))
|
|
2650
|
+
return this.onHostToolPre(session, payload, onLate);
|
|
2253
2651
|
const run = this.ensureRun(session);
|
|
2254
2652
|
const toolName = payload.tool_name ?? "unknown";
|
|
2255
2653
|
const toolUseId = payload.tool_use_id ?? "";
|
|
@@ -2605,6 +3003,238 @@ export class ControlServer {
|
|
|
2605
3003
|
}
|
|
2606
3004
|
return { stepIndex: pair.selected };
|
|
2607
3005
|
}
|
|
3006
|
+
/**
|
|
3007
|
+
* `/tool/pre` in a session whose host is not Claude Code — a LangChain
|
|
3008
|
+
* application, say (the service's integratioLangChane.md). Two kinds of
|
|
3009
|
+
* request, told apart by `scripted`:
|
|
3010
|
+
*
|
|
3011
|
+
* - **The adapter asks for the plan's next step** (`scripted: true`). It
|
|
3012
|
+
* is answering in the model's place and wants the step's arguments; the
|
|
3013
|
+
* host then runs its own tool with them. See {@link onScriptedPre}.
|
|
3014
|
+
* - **The model's own call.** Recorded, never pinned. See {@link onOwnPre}.
|
|
3015
|
+
*
|
|
3016
|
+
* ONLY A PROMPT OPENS A RUN for such a host. A hook that finds none open
|
|
3017
|
+
* opens none and answers `{}`: the run failed and its adapter closed it,
|
|
3018
|
+
* or the two-hour limit sealed it, or the recorder restarted under it. What
|
|
3019
|
+
* arrives afterwards — the application going on from its checkpoint, a
|
|
3020
|
+
* retry middleware trying the call again — must not become a second
|
|
3021
|
+
* recording with an empty prompt.
|
|
3022
|
+
*/
|
|
3023
|
+
async onHostToolPre(session, payload, onLate) {
|
|
3024
|
+
const scripted = payload.scripted === true;
|
|
3025
|
+
const toolName = hostText(payload.tool_name, 200) ?? "unknown";
|
|
3026
|
+
const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
|
|
3027
|
+
const run = session.run && !session.run.finished ? session.run : undefined;
|
|
3028
|
+
if (!run) {
|
|
3029
|
+
logDetail("tool.pre.skipped", {
|
|
3030
|
+
sess: session.sessionId,
|
|
3031
|
+
tool: toolName,
|
|
3032
|
+
why: "no open run — only a prompt opens one for this host",
|
|
3033
|
+
});
|
|
3034
|
+
// A request for a step is told so in words: an empty answer is also
|
|
3035
|
+
// what a recorder that is slow, or switched off, gives.
|
|
3036
|
+
return scripted ? { noPlan: true } : {};
|
|
3037
|
+
}
|
|
3038
|
+
// The runner's own `bir` server is housekeeping in every host.
|
|
3039
|
+
if (isHousekeepingCall(toolName, payload.tool_input, true)) {
|
|
3040
|
+
if (toolUseId)
|
|
3041
|
+
(run.housekeepingUses ??= new Set()).add(toolUseId);
|
|
3042
|
+
logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
|
|
3043
|
+
return {};
|
|
3044
|
+
}
|
|
3045
|
+
return scripted
|
|
3046
|
+
? this.onScriptedPre(run, toolName, toolUseId, payload, onLate)
|
|
3047
|
+
: this.onOwnPre(session, run, toolName, toolUseId, payload);
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* The adapter of another host asks for the plan's next step. The answer
|
|
3051
|
+
* either pins the call — `allow`, with the step's computed arguments — or
|
|
3052
|
+
* carries no pin, which ends the plan for the adapter. It then says why:
|
|
3053
|
+
*
|
|
3054
|
+
* - `noPlan`: the recorder holds no live plan for the run. It ran to its
|
|
3055
|
+
* end, was handed over, or the recorder restarted while the run waited
|
|
3056
|
+
* for a person. Nothing is recorded: no call follows this request.
|
|
3057
|
+
* - `declined`: the plan names a tool the agent does not hold.
|
|
3058
|
+
* - a refusal whose reason is the hand-over note, when the plan stopped
|
|
3059
|
+
* in this very request.
|
|
3060
|
+
*/
|
|
3061
|
+
async onScriptedPre(run, toolName, toolUseId, payload, onLate) {
|
|
3062
|
+
const live = this.livePlan(run);
|
|
3063
|
+
if (!live)
|
|
3064
|
+
return { noPlan: true };
|
|
3065
|
+
// A call cannot be pinned without an id to join its result by.
|
|
3066
|
+
if (!toolUseId)
|
|
3067
|
+
return {};
|
|
3068
|
+
// THE TOOLS THE AGENT HOLDS ride on the first request for a step: a host
|
|
3069
|
+
// shows them only when its model is about to be called, never at the
|
|
3070
|
+
// prompt. The recorder judges them, not the adapter — one that ended the
|
|
3071
|
+
// plan by itself would leave a live plan here that nobody steps through.
|
|
3072
|
+
const missing = this.missingHostTool(live, payload);
|
|
3073
|
+
if (missing !== undefined &&
|
|
3074
|
+
this.replay.declineArmed(live, "host_tool_missing", `the plan calls ${missing}, a tool the agent that asked does not hold — it cannot be finished there, so no step of it is started`)) {
|
|
3075
|
+
return { declined: "host_tool_missing" };
|
|
3076
|
+
}
|
|
3077
|
+
let overran = false;
|
|
3078
|
+
const answeredLate = new AbortController();
|
|
3079
|
+
onLate?.(() => {
|
|
3080
|
+
overran = true;
|
|
3081
|
+
answeredLate.abort();
|
|
3082
|
+
return {};
|
|
3083
|
+
});
|
|
3084
|
+
const action = await this.replay.preTool(live, toolName, toolUseId, {}, { signal: answeredLate.signal });
|
|
3085
|
+
if (overran) {
|
|
3086
|
+
// The deadline guard answered `{}` — no pin — and the adapter took the
|
|
3087
|
+
// plan as ended. A pin decided now never reached it: the plan ends here
|
|
3088
|
+
// too, and what it would have said waits for the model's next result.
|
|
3089
|
+
live.pinned.delete(toolUseId);
|
|
3090
|
+
if (action.kind === "deny")
|
|
3091
|
+
run.handback = action.reason;
|
|
3092
|
+
else
|
|
3093
|
+
this.replay.hostHandOver(live, "late");
|
|
3094
|
+
this.noteHandover(run);
|
|
3095
|
+
return {};
|
|
3096
|
+
}
|
|
3097
|
+
// A hand-over inside `preTool` must still schedule the fragment.
|
|
3098
|
+
this.noteHandover(run);
|
|
3099
|
+
if (action.kind === "deny") {
|
|
3100
|
+
return {
|
|
3101
|
+
hookSpecificOutput: {
|
|
3102
|
+
hookEventName: "PreToolUse",
|
|
3103
|
+
permissionDecision: "deny",
|
|
3104
|
+
permissionDecisionReason: action.reason,
|
|
3105
|
+
},
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
// `passthrough`, `abort`: the plan ended while this request waited.
|
|
3109
|
+
if (action.kind !== "pin")
|
|
3110
|
+
return { noPlan: true };
|
|
3111
|
+
// Recorded as any step the hook alone observes — in a recording, which a
|
|
3112
|
+
// matched run is not — tagged with the plan that pinned it. The same id
|
|
3113
|
+
// asked for twice is one step.
|
|
3114
|
+
const known = run.builtIns.get(toolUseId);
|
|
3115
|
+
const pair = known
|
|
3116
|
+
? { selected: known.stepIndex, response: known.responseIndex }
|
|
3117
|
+
: run.ordering.allocatePair();
|
|
3118
|
+
const pinnedBy = live.scenarioId ?? undefined;
|
|
3119
|
+
if (!known) {
|
|
3120
|
+
run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
|
|
3121
|
+
if (run.recording) {
|
|
3122
|
+
const toolInput = serializeCapped(redact(action.input));
|
|
3123
|
+
this.queue.push(() => {
|
|
3124
|
+
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3125
|
+
toolName,
|
|
3126
|
+
toolInput,
|
|
3127
|
+
metadata: pinnedBy ? { pinnedBy } : undefined,
|
|
3128
|
+
});
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
|
|
3133
|
+
return {
|
|
3134
|
+
stepIndex: pair.selected,
|
|
3135
|
+
hookSpecificOutput: {
|
|
3136
|
+
hookEventName: "PreToolUse",
|
|
3137
|
+
permissionDecision: "allow",
|
|
3138
|
+
updatedInput: action.input,
|
|
3139
|
+
},
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* The first tool the plan will call that the agent does not hold, or
|
|
3144
|
+
* undefined when it holds them all — or did not say which it holds.
|
|
3145
|
+
*/
|
|
3146
|
+
missingHostTool(state, payload) {
|
|
3147
|
+
const held = hostNames(payload.tools);
|
|
3148
|
+
const plan = held ? this.replay.planData(state) : undefined;
|
|
3149
|
+
if (!held || !plan)
|
|
3150
|
+
return undefined;
|
|
3151
|
+
const names = new Set(held);
|
|
3152
|
+
return plan.steps.find((step) => !names.has(step.toolName))?.toolName;
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* The model's own call, in a session of another host: any `/tool/pre`
|
|
3156
|
+
* without `scripted`.
|
|
3157
|
+
*
|
|
3158
|
+
* NEVER PINNED, and never answered with a bundle. In Claude Code a call to
|
|
3159
|
+
* the plan's next tool is pinned, because there the model is the one who
|
|
3160
|
+
* makes the plan's calls. Here the adapter makes them, so a call the model
|
|
3161
|
+
* made is the model's whatever tool it names: answered with the plan's
|
|
3162
|
+
* arguments, the tool would run with arguments the model never wrote, and
|
|
3163
|
+
* the model would read a result that does not belong to the call it sees.
|
|
3164
|
+
*
|
|
3165
|
+
* A plan that is still live is handed over. The call runs as the model
|
|
3166
|
+
* wrote it — the note is not given in its place — and the note goes out
|
|
3167
|
+
* with this call's `/tool/post`. The recording of the model's own work
|
|
3168
|
+
* after the hand-over (the fragment) opens with this very call: opened one
|
|
3169
|
+
* call later, the call that left the plan would be in no recording.
|
|
3170
|
+
*/
|
|
3171
|
+
async onOwnPre(session, run, toolName, toolUseId, payload) {
|
|
3172
|
+
const modelArgs = payload.tool_input ?? {};
|
|
3173
|
+
// A tool that pauses itself passes its hook again when the run resumes:
|
|
3174
|
+
// the same call announced twice is one step, not two.
|
|
3175
|
+
const announced = toolUseId ? run.builtIns.get(toolUseId) : undefined;
|
|
3176
|
+
if (announced) {
|
|
3177
|
+
logDetail("tool.pre.repeated", { run: run.runId, tool: toolName, step: announced.stepIndex });
|
|
3178
|
+
return { stepIndex: announced.stepIndex };
|
|
3179
|
+
}
|
|
3180
|
+
const live = this.livePlan(run);
|
|
3181
|
+
if (live && this.replay.hostHandOver(live, "own_call")) {
|
|
3182
|
+
logLine("replay.own_call", {
|
|
3183
|
+
run: run.runId,
|
|
3184
|
+
scenario: live.scenarioId ?? undefined,
|
|
3185
|
+
tool: toolName,
|
|
3186
|
+
why: "the model made a call of its own while the plan was live — the plan is handed over, and the call runs as written",
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
this.noteHandover(run);
|
|
3190
|
+
const words = this.hostWords(payload);
|
|
3191
|
+
// Counted, never waited on, and never arming: see `tryIntentMatch`.
|
|
3192
|
+
await this.tryIntentMatch(session, run, toolName, words.intent, modelArgs);
|
|
3193
|
+
if (run.fragment?.state === "due" && !run.pendingDirective) {
|
|
3194
|
+
await this.openFragment(session, run, words.context, toolName, modelArgs);
|
|
3195
|
+
}
|
|
3196
|
+
// The hook is the only observer of every call of such a host: nothing of
|
|
3197
|
+
// it goes through a proxy of this folder, whatever a tool is named.
|
|
3198
|
+
const pair = run.ordering.allocatePair();
|
|
3199
|
+
run.builtIns.set(toolUseId || `anon_${pair.selected}`, {
|
|
3200
|
+
stepIndex: pair.selected,
|
|
3201
|
+
responseIndex: pair.response,
|
|
3202
|
+
toolName,
|
|
3203
|
+
});
|
|
3204
|
+
if (run.recording) {
|
|
3205
|
+
const toolInput = serializeCapped(redact(modelArgs));
|
|
3206
|
+
this.queue.push(() => {
|
|
3207
|
+
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3208
|
+
toolName,
|
|
3209
|
+
toolInput,
|
|
3210
|
+
context: words.context || undefined,
|
|
3211
|
+
intent: words.thinking,
|
|
3212
|
+
});
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3215
|
+
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
|
|
3216
|
+
return { stepIndex: pair.selected };
|
|
3217
|
+
}
|
|
3218
|
+
/**
|
|
3219
|
+
* What the model wrote and reasoned before a call, as its host handed it
|
|
3220
|
+
* over with the call — the values {@link intentForToolUse} and
|
|
3221
|
+
* {@link thinkingForToolUse} read from a transcript, under the same switch:
|
|
3222
|
+
* a source `BIR_INTENT_SOURCES` turned off is not taken from the payload
|
|
3223
|
+
* either. `context` is the written text only; thinking rides apart.
|
|
3224
|
+
*/
|
|
3225
|
+
hostWords(payload) {
|
|
3226
|
+
const written = this.intentSources.has("text") ? hostText(payload.context) : undefined;
|
|
3227
|
+
const reasoned = this.intentSources.has("thinking") ? hostText(payload.thinking) : undefined;
|
|
3228
|
+
return {
|
|
3229
|
+
intent: written
|
|
3230
|
+
? { text: written, source: "text" }
|
|
3231
|
+
: reasoned
|
|
3232
|
+
? { text: reasoned, source: "thinking" }
|
|
3233
|
+
: undefined,
|
|
3234
|
+
context: written ?? "",
|
|
3235
|
+
thinking: reasoned ? { text: reasoned, source: "thinking" } : undefined,
|
|
3236
|
+
};
|
|
3237
|
+
}
|
|
2608
3238
|
/** This run's plan, when it is armed, still live, and announced to the model. */
|
|
2609
3239
|
livePlan(run) {
|
|
2610
3240
|
const state = run.replay;
|
|
@@ -2915,6 +3545,10 @@ export class ControlServer {
|
|
|
2915
3545
|
const opts = this.replay.intentMatch;
|
|
2916
3546
|
if (!this.replay.enabled || !opts.enabled)
|
|
2917
3547
|
return undefined;
|
|
3548
|
+
// A run of another host that only records is a run with replay off: no
|
|
3549
|
+
// probe is sent, so it adds nothing to the step counts either.
|
|
3550
|
+
if (this.recordsOnly(session, run))
|
|
3551
|
+
return undefined;
|
|
2918
3552
|
if (!isIntentMatcher(this.recorder))
|
|
2919
3553
|
return undefined;
|
|
2920
3554
|
if (this.replay.isDirectTool(toolName))
|
|
@@ -2953,6 +3587,7 @@ export class ControlServer {
|
|
|
2953
3587
|
.map((s) => s.scenarioId)
|
|
2954
3588
|
.filter((id) => typeof id === "string");
|
|
2955
3589
|
const startedAt = Date.now();
|
|
3590
|
+
const framework = frameworkOf(session);
|
|
2956
3591
|
const probe = this.recorder
|
|
2957
3592
|
.matchIntent({
|
|
2958
3593
|
text,
|
|
@@ -2969,13 +3604,19 @@ export class ControlServer {
|
|
|
2969
3604
|
ranThrough: this.ranThroughFor(run),
|
|
2970
3605
|
...(this.replay.segmentArm ? { acceptSegments: true } : {}),
|
|
2971
3606
|
supportsCalls: true,
|
|
3607
|
+
// Left out for Claude Code: a missing framework means Claude Code to
|
|
3608
|
+
// the service, which matches a step only inside its own host.
|
|
3609
|
+
...(framework ? { framework } : {}),
|
|
2972
3610
|
},
|
|
2973
3611
|
// The probe's own budget (`BIR_INTENT_MATCH_BUDGET_MS`) ends the request
|
|
2974
3612
|
// itself — waited on or not — so an abandoned probe releases its socket
|
|
2975
3613
|
// rather than holding it for as long as the service takes to answer.
|
|
2976
3614
|
{ timeoutMs: opts.budgetMs })
|
|
2977
3615
|
.catch(() => null);
|
|
2978
|
-
|
|
3616
|
+
// In a session of another host a probe only counts: a plan is started
|
|
3617
|
+
// there by the prompt, and stepped through by an adapter that was told of
|
|
3618
|
+
// it then. Nothing could announce one that armed in the middle of a run.
|
|
3619
|
+
if (!this.intentArming() || otherHost(session)) {
|
|
2979
3620
|
// Counted, never waited on: whatever the answer says is bookkeeping.
|
|
2980
3621
|
void probe.then((late) => {
|
|
2981
3622
|
if (late)
|
|
@@ -3017,10 +3658,7 @@ export class ControlServer {
|
|
|
3017
3658
|
// seconds, and this call is waiting. A plan not armed in time is dropped
|
|
3018
3659
|
// unused — it was never announced, so it never steers — and its ticket is
|
|
3019
3660
|
// swept like any unredeemed one (R-OUT-12).
|
|
3020
|
-
const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
|
|
3021
|
-
liveCall: { toolName, toolInput },
|
|
3022
|
-
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
3023
|
-
}));
|
|
3661
|
+
const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", { liveCall: { toolName, toolInput }, recentResults: this.recentResultsFor(session, "call") }, this.hostContext(session, run)));
|
|
3024
3662
|
const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
|
|
3025
3663
|
if (state === undefined)
|
|
3026
3664
|
return undefined; // it threw, and said so
|
|
@@ -3233,7 +3871,9 @@ export class ControlServer {
|
|
|
3233
3871
|
return;
|
|
3234
3872
|
const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
|
|
3235
3873
|
const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
3236
|
-
|
|
3874
|
+
// The fragment is a run of the same host as the turn it continues.
|
|
3875
|
+
const framework = frameworkOf(session);
|
|
3876
|
+
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
|
|
3237
3877
|
for (const c of run.correlations.values()) {
|
|
3238
3878
|
c.settled = true;
|
|
3239
3879
|
if (c.fallback)
|
|
@@ -3362,6 +4002,17 @@ export class ControlServer {
|
|
|
3362
4002
|
const session = this.ensureSession(payload);
|
|
3363
4003
|
const toolUseId = payload.tool_use_id ?? "";
|
|
3364
4004
|
const toolName = payload.tool_name ?? "unknown";
|
|
4005
|
+
const other = otherHost(session);
|
|
4006
|
+
// Only a prompt opens a run for another host (see `onHostToolPre`): a
|
|
4007
|
+
// result that finds none open is a closed run's, and is not recorded.
|
|
4008
|
+
if (other && !(session.run && !session.run.finished)) {
|
|
4009
|
+
logDetail("tool.post.skipped", {
|
|
4010
|
+
sess: session.sessionId,
|
|
4011
|
+
tool: toolName,
|
|
4012
|
+
why: "no open run — only a prompt opens one for this host",
|
|
4013
|
+
});
|
|
4014
|
+
return {};
|
|
4015
|
+
}
|
|
3365
4016
|
if (toolName.startsWith(`mcp__${SCENARIO_SERVER_KEY}__`))
|
|
3366
4017
|
this.birToolSeen = true;
|
|
3367
4018
|
// Every completion, pinned or not, before anything below can return: it
|
|
@@ -3412,7 +4063,7 @@ export class ControlServer {
|
|
|
3412
4063
|
const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
|
|
3413
4064
|
if (skippedAtPre)
|
|
3414
4065
|
return {};
|
|
3415
|
-
if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
|
|
4066
|
+
if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input, other))
|
|
3416
4067
|
return {};
|
|
3417
4068
|
// Nor for an unarmed `run_scenario`. The plan may have retired since the
|
|
3418
4069
|
// pre hook, so the test is whether that hook opened a step, not the plan.
|
|
@@ -3427,12 +4078,29 @@ export class ControlServer {
|
|
|
3427
4078
|
// recorded, and only fall back to the hook's view if no report arrives.
|
|
3428
4079
|
const replay = run.replay;
|
|
3429
4080
|
const pinnedHere = replay && this.replay.isPinned(replay, toolUseId);
|
|
4081
|
+
// The result of a call the adapter of another host made for the plan,
|
|
4082
|
+
// which the recorder does not hold as pinned: the plan is gone — the run
|
|
4083
|
+
// was sealed and opened again, or the recorder restarted. Nothing of it
|
|
4084
|
+
// is recorded: it was never the model's call.
|
|
4085
|
+
if (other && payload.scripted === true && !pinnedHere) {
|
|
4086
|
+
logDetail("tool.post.skipped", { run: run.runId, tool: toolName, why: "a scripted call this recorder did not pin" });
|
|
4087
|
+
return {};
|
|
4088
|
+
}
|
|
3430
4089
|
const correlation = [...run.correlations.values()].find((c) => c.toolUseId && c.toolUseId === toolUseId);
|
|
3431
4090
|
if (pinnedHere && replay) {
|
|
4091
|
+
// What another host says it did to the step itself, read before any
|
|
4092
|
+
// failure: a person's "no" arrives as an error result, and is no fault
|
|
4093
|
+
// of the plan (controller.ts, `PostToolOptions.hostStop`).
|
|
4094
|
+
const hostStop = other && (payload.host_stop === "rejected" || payload.host_stop === "edited")
|
|
4095
|
+
? payload.host_stop
|
|
4096
|
+
: undefined;
|
|
3432
4097
|
// The host's verdict, which outranks anything read from the output: a
|
|
3433
4098
|
// step that failed must never thread as if it worked (controller.ts).
|
|
3434
|
-
const hostFailure = failed ? this.errorText(payload) : undefined;
|
|
3435
|
-
if (
|
|
4099
|
+
const hostFailure = failed && !hostStop ? this.errorText(payload) : undefined;
|
|
4100
|
+
if (hostStop) {
|
|
4101
|
+
this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}), undefined, { hostStop });
|
|
4102
|
+
}
|
|
4103
|
+
else if (correlation && this.replay.threadsFromProxy(replay, toolUseId)) {
|
|
3436
4104
|
correlation.threadToolUseId = toolUseId;
|
|
3437
4105
|
correlation.threadFallback = setTimeout(() => {
|
|
3438
4106
|
if (!this.replay.isPinned(replay, toolUseId))
|
|
@@ -3554,9 +4222,48 @@ export class ControlServer {
|
|
|
3554
4222
|
// arriving. A later prompt in the same session opens a fresh run, exactly as
|
|
3555
4223
|
// it did before. The drain that follows is background work: it is what
|
|
3556
4224
|
// lets the recorder forget the run once the service has it.
|
|
3557
|
-
|
|
4225
|
+
//
|
|
4226
|
+
// Another host hands the run's answer and its tokens over here — it has
|
|
4227
|
+
// no transcript to read them from — and says when the run failed or its
|
|
4228
|
+
// recording is not whole.
|
|
4229
|
+
const reason = otherHost(session) ? this.noteHostStop(run, payload) : "stop";
|
|
4230
|
+
this.closeRun(session, { detach: false, reason });
|
|
3558
4231
|
return {};
|
|
3559
4232
|
}
|
|
4233
|
+
/**
|
|
4234
|
+
* Keep what another host handed over with its stop — on the run, not on the
|
|
4235
|
+
* session: a later prompt of the session opens a new run, which must not
|
|
4236
|
+
* inherit an answer or a cost. Returns why the run is closed.
|
|
4237
|
+
*
|
|
4238
|
+
* - `final_answer`, `usage`: the answer and the tokens (see `sealRun`,
|
|
4239
|
+
* `hostCost`).
|
|
4240
|
+
* - `incomplete`: the recording is not the whole run — the adapter lost
|
|
4241
|
+
* calls, a person changed one, or one was answered in its tool's place.
|
|
4242
|
+
* The run is flagged lossy, so its finish carries the service's mark; the
|
|
4243
|
+
* mark has no place for a text, which goes to the log only.
|
|
4244
|
+
* - `failed`: the run failed. It is closed with that text as its error
|
|
4245
|
+
* and no final answer. An exception's text can hold a password or a
|
|
4246
|
+
* user's data: it is cleaned and cut like every text that is sent.
|
|
4247
|
+
*/
|
|
4248
|
+
noteHostStop(run, payload) {
|
|
4249
|
+
run.hostAnswer = typeof payload.final_answer === "string" && payload.final_answer ? payload.final_answer : undefined;
|
|
4250
|
+
run.hostUsage = hostUsageOf(payload);
|
|
4251
|
+
const incomplete = hostText(payload.incomplete, 500);
|
|
4252
|
+
if (incomplete !== undefined) {
|
|
4253
|
+
run.lossy = true;
|
|
4254
|
+
logLine("run.incomplete", {
|
|
4255
|
+
run: run.runId,
|
|
4256
|
+
why: redactText(incomplete),
|
|
4257
|
+
effect: "its recording is marked incomplete: never calculated, and it no longer catches its prompt",
|
|
4258
|
+
});
|
|
4259
|
+
}
|
|
4260
|
+
if (payload.failed === undefined || payload.failed === null || payload.failed === false)
|
|
4261
|
+
return "stop";
|
|
4262
|
+
const failed = hostText(payload.failed, 2_000);
|
|
4263
|
+
run.hostFailed = failed ? redactText(failed) : "the host said the run failed";
|
|
4264
|
+
logLine("run.host_failed", { run: run.runId, error: run.hostFailed });
|
|
4265
|
+
return "host_failed";
|
|
4266
|
+
}
|
|
3560
4267
|
/**
|
|
3561
4268
|
* `SessionEnd` — `/clear`, exit, logout. Answered at once: the host is
|
|
3562
4269
|
* waiting on it to exit, and this process outlives the session, so the run
|
|
@@ -3570,7 +4277,7 @@ export class ControlServer {
|
|
|
3570
4277
|
if (sessionId === session.sessionId)
|
|
3571
4278
|
this.proxySessions.delete(proxyId);
|
|
3572
4279
|
}
|
|
3573
|
-
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd });
|
|
4280
|
+
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd, reason: "session_end" });
|
|
3574
4281
|
// Forgotten a little later by the reaper, not now: a late report or post
|
|
3575
4282
|
// for its last turn must still find that turn rather than open a new one.
|
|
3576
4283
|
session.endedAt = Date.now();
|
|
@@ -4062,7 +4769,10 @@ export class ControlServer {
|
|
|
4062
4769
|
const known = bound ? this.sessions.get(bound) : undefined;
|
|
4063
4770
|
if (known)
|
|
4064
4771
|
return known;
|
|
4065
|
-
|
|
4772
|
+
// Never a session of another host: none of its calls goes through a
|
|
4773
|
+
// proxy, and a proxy's step must not open a run there — only its own
|
|
4774
|
+
// prompt does (see `onHostToolPre`).
|
|
4775
|
+
const recent = this.recentSessions().filter((s) => !otherHost(s));
|
|
4066
4776
|
const active = recent.find((s) => this.isActive(s));
|
|
4067
4777
|
if (active)
|
|
4068
4778
|
return active;
|