@basein/runner 0.3.9 → 0.3.10
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 +46 -5
- 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 +17 -0
- package/dist/config/adapters/claude-code.js +28 -1
- 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 +200 -2
- package/dist/control/server.js +741 -48
- 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 +132 -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 +81 -17
- 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";
|
|
@@ -257,6 +257,137 @@ function isOn(value) {
|
|
|
257
257
|
return true;
|
|
258
258
|
return typeof value === "string" && ["1", "true"].includes(value.trim().toLowerCase());
|
|
259
259
|
}
|
|
260
|
+
/** The longest text a host may name itself, its version, its adapter or its scope by. */
|
|
261
|
+
const HOST_TEXT_MAX = 64;
|
|
262
|
+
/**
|
|
263
|
+
* The host a payload names, or undefined when it names none: a `host` whose
|
|
264
|
+
* `app` is missing, empty or not a text is ignored, and the session is a
|
|
265
|
+
* Claude Code session as before. Only the four known keys are kept. `app`,
|
|
266
|
+
* `version` and `adapter` are cut to {@link HOST_TEXT_MAX}; a scope is never
|
|
267
|
+
* cut — see {@link badScope}.
|
|
268
|
+
*/
|
|
269
|
+
function hostOf(payload) {
|
|
270
|
+
const h = payload.host;
|
|
271
|
+
if (!h || typeof h !== "object")
|
|
272
|
+
return undefined;
|
|
273
|
+
const app = typeof h.app === "string" ? h.app.trim().toLowerCase().slice(0, HOST_TEXT_MAX) : "";
|
|
274
|
+
if (!app)
|
|
275
|
+
return undefined;
|
|
276
|
+
const host = { app };
|
|
277
|
+
if (typeof h.version === "string" && h.version)
|
|
278
|
+
host.version = h.version.slice(0, HOST_TEXT_MAX);
|
|
279
|
+
if (typeof h.adapter === "string" && h.adapter)
|
|
280
|
+
host.adapter = h.adapter.slice(0, HOST_TEXT_MAX);
|
|
281
|
+
if (typeof h.scope === "string" && h.scope)
|
|
282
|
+
host.scope = h.scope;
|
|
283
|
+
return host;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Whether a payload's host names a scope the recorder cannot keep as it is:
|
|
287
|
+
* there, but empty, longer than {@link HOST_TEXT_MAX} or not a text.
|
|
288
|
+
*
|
|
289
|
+
* Such a scope is never cut and never dropped. The scope is part of what
|
|
290
|
+
* keeps one tenant's plans away from another's: cut or dropped, the run
|
|
291
|
+
* would land in another scope's pool.
|
|
292
|
+
*/
|
|
293
|
+
function badScope(payload) {
|
|
294
|
+
const h = payload.host;
|
|
295
|
+
if (!h || typeof h !== "object" || !hostOf(payload))
|
|
296
|
+
return false;
|
|
297
|
+
if (h.scope === undefined)
|
|
298
|
+
return false;
|
|
299
|
+
return typeof h.scope !== "string" || h.scope.length === 0 || h.scope.length > HOST_TEXT_MAX;
|
|
300
|
+
}
|
|
301
|
+
/** Whether `session` said its host is not Claude Code. */
|
|
302
|
+
function otherHost(session) {
|
|
303
|
+
return session?.host !== undefined && session.host.app !== "claude-code";
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The `framework` text of a run in a session of another host — its `app`, or
|
|
307
|
+
* `app:scope` when it named a scope — and undefined for Claude Code, whose
|
|
308
|
+
* runs keep the recorder's own framework. Up to 129 characters.
|
|
309
|
+
*/
|
|
310
|
+
function frameworkOf(session) {
|
|
311
|
+
if (!otherHost(session))
|
|
312
|
+
return undefined;
|
|
313
|
+
const host = session.host;
|
|
314
|
+
return host.scope ? `${host.app}:${host.scope}` : host.app;
|
|
315
|
+
}
|
|
316
|
+
/** What a host may hand over of one text: a transcript index keeps no more of a message. */
|
|
317
|
+
const HOST_TEXT_CHARS = 16 * 1024;
|
|
318
|
+
/** Tool names a host may declare as sinks, or as held by its agent. */
|
|
319
|
+
const HOST_NAMES_MAX = 4_096;
|
|
320
|
+
/** Models one run's usage may name. */
|
|
321
|
+
const HOST_USAGE_MAX = 32;
|
|
322
|
+
/** A payload text as the recorder takes it: a non-empty string, cut to `max` characters. */
|
|
323
|
+
function hostText(value, max = HOST_TEXT_CHARS) {
|
|
324
|
+
if (typeof value !== "string")
|
|
325
|
+
return undefined;
|
|
326
|
+
const text = value.trim();
|
|
327
|
+
return text ? text.slice(0, max) : undefined;
|
|
328
|
+
}
|
|
329
|
+
/** A payload's list of tool names, or undefined when it is not a list. */
|
|
330
|
+
function hostNames(value) {
|
|
331
|
+
if (!Array.isArray(value))
|
|
332
|
+
return undefined;
|
|
333
|
+
const names = [];
|
|
334
|
+
for (const item of value.slice(0, HOST_NAMES_MAX)) {
|
|
335
|
+
// A name alone, or `{ name }`, which is how an agent's tools are listed.
|
|
336
|
+
const name = typeof item === "string" ? item : item?.name;
|
|
337
|
+
if (typeof name === "string" && name)
|
|
338
|
+
names.push(name.slice(0, 200));
|
|
339
|
+
}
|
|
340
|
+
return names;
|
|
341
|
+
}
|
|
342
|
+
/** The sinks a host declared with its prompt. Always an object: see `HostSinks`. */
|
|
343
|
+
function hostSinksOf(payload) {
|
|
344
|
+
const sinks = payload.sinks;
|
|
345
|
+
if (!sinks || typeof sinks !== "object")
|
|
346
|
+
return {};
|
|
347
|
+
return { command: hostNames(sinks.command) ?? [], write: hostNames(sinks.write) ?? [] };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* A payload's `usage`, under the names the price table takes. The host sends
|
|
351
|
+
* Claude's own names, the ones a transcript line carries, and they are mapped
|
|
352
|
+
* here as the transcript's are folded: a missing count is 0. Undefined when
|
|
353
|
+
* the payload carries no usable list — which is "no usage", never zero.
|
|
354
|
+
*/
|
|
355
|
+
function hostUsageOf(payload) {
|
|
356
|
+
if (!Array.isArray(payload.usage))
|
|
357
|
+
return undefined;
|
|
358
|
+
const count = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
|
|
359
|
+
const out = [];
|
|
360
|
+
for (const item of payload.usage.slice(0, HOST_USAGE_MAX)) {
|
|
361
|
+
if (!item || typeof item !== "object")
|
|
362
|
+
continue;
|
|
363
|
+
const u = item;
|
|
364
|
+
out.push({
|
|
365
|
+
model: typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown",
|
|
366
|
+
inputTokens: count(u.input_tokens),
|
|
367
|
+
outputTokens: count(u.output_tokens),
|
|
368
|
+
cacheReadTokens: count(u.cache_read_input_tokens),
|
|
369
|
+
cacheCreationTokens: count(u.cache_creation_input_tokens),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Price a turn's token usage. Measured only when tokens were used and every
|
|
376
|
+
* model that used them has a price: a model outside the table costs "$0",
|
|
377
|
+
* and a zero called measured is booked as a saving nobody made.
|
|
378
|
+
*/
|
|
379
|
+
function priceUsage(usage) {
|
|
380
|
+
const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
|
|
381
|
+
const unpriced = [...new Set(usage.filter((u) => !hasModelPricing(u.model)).map((u) => u.model))];
|
|
382
|
+
return { usd, measured: usage.length > 0 && unpriced.length === 0, unpriced };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* What `/health` answers as `hostFields`: how much of another host's protocol
|
|
386
|
+
* this recorder understands. An adapter reads it to know which fields it may
|
|
387
|
+
* rely on — with 1 it records; from 2 it may also ask for a plan and step
|
|
388
|
+
* through it, because the checks a replay in another host needs are here.
|
|
389
|
+
*/
|
|
390
|
+
export const HOST_FIELDS = 2;
|
|
260
391
|
/** One MCP call, as seen from up to two sides. */
|
|
261
392
|
/**
|
|
262
393
|
* Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
|
|
@@ -327,6 +458,13 @@ export class ControlServer {
|
|
|
327
458
|
* SessionStart.
|
|
328
459
|
*/
|
|
329
460
|
offSessions = new Map();
|
|
461
|
+
/**
|
|
462
|
+
* Sessions of another host that named a scope the recorder cannot keep as
|
|
463
|
+
* it is (see {@link badScope}) → when last heard. Nothing of such a session
|
|
464
|
+
* is recorded or replayed: every hook of it is answered `{}`. Kept apart
|
|
465
|
+
* from {@link sessions}, so it never holds a run or takes a proxy's step.
|
|
466
|
+
*/
|
|
467
|
+
refusedSessions = new Map();
|
|
330
468
|
/**
|
|
331
469
|
* Proxy ids that registered with *this* recorder (UPG-7). A poll from any
|
|
332
470
|
* other id is a proxy from before this recorder started — a 0.2.12 proxy
|
|
@@ -421,6 +559,7 @@ export class ControlServer {
|
|
|
421
559
|
if (!this.lossy)
|
|
422
560
|
logLine("run.lossy", { why: "step queue overflowed", dropped });
|
|
423
561
|
this.lossy = true;
|
|
562
|
+
this.markOpenRunsLossy();
|
|
424
563
|
},
|
|
425
564
|
onError: (err) => logLine("control.step_failed", { error: errText(err) }),
|
|
426
565
|
});
|
|
@@ -472,7 +611,7 @@ export class ControlServer {
|
|
|
472
611
|
this.heldPolls.clear();
|
|
473
612
|
for (const session of this.sessions.values()) {
|
|
474
613
|
if (session.run && !session.run.finished) {
|
|
475
|
-
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close });
|
|
614
|
+
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close, reason: "recorder_closed" });
|
|
476
615
|
}
|
|
477
616
|
}
|
|
478
617
|
// Bounded: a recorder being stopped must stop, and a service that is down
|
|
@@ -567,6 +706,9 @@ export class ControlServer {
|
|
|
567
706
|
url: this.address?.url,
|
|
568
707
|
wrappedServers: [...this.wrapped],
|
|
569
708
|
hooksVersion: packageVersion(),
|
|
709
|
+
// How much of another host's protocol this recorder understands: what
|
|
710
|
+
// an adapter reads before it relies on a field (see HOST_FIELDS).
|
|
711
|
+
hostFields: HOST_FIELDS,
|
|
570
712
|
registeredProxies: [...this.registrations].map(([serverName, r]) => ({
|
|
571
713
|
serverName,
|
|
572
714
|
pid: r.pid,
|
|
@@ -947,6 +1089,9 @@ export class ControlServer {
|
|
|
947
1089
|
bytes: err.bytes,
|
|
948
1090
|
why: "read and discarded, answered {} — this event is not recorded",
|
|
949
1091
|
});
|
|
1092
|
+
// Whose event it was went unread with the body: every run open now
|
|
1093
|
+
// may be the one that lost it.
|
|
1094
|
+
this.markOpenRunsLossy();
|
|
950
1095
|
answer({});
|
|
951
1096
|
return;
|
|
952
1097
|
}
|
|
@@ -964,6 +1109,12 @@ export class ControlServer {
|
|
|
964
1109
|
answer({});
|
|
965
1110
|
return;
|
|
966
1111
|
}
|
|
1112
|
+
// A session of another host that named a scope the recorder cannot
|
|
1113
|
+
// keep: nothing of it is recorded or replayed.
|
|
1114
|
+
if (this.hostRefused(payload)) {
|
|
1115
|
+
answer({});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
967
1118
|
const out = await this.dispatchHook(route, payload, (fn) => {
|
|
968
1119
|
late = fn;
|
|
969
1120
|
});
|
|
@@ -1055,8 +1206,48 @@ export class ControlServer {
|
|
|
1055
1206
|
}
|
|
1056
1207
|
this.offSessions.set(sessionId, { at: Date.now() });
|
|
1057
1208
|
const session = this.sessions.get(sessionId);
|
|
1058
|
-
if (session?.run && !session.run.finished)
|
|
1059
|
-
this.closeRun(session, { detach: true });
|
|
1209
|
+
if (session?.run && !session.run.finished) {
|
|
1210
|
+
this.closeRun(session, { detach: true, reason: "session_off" });
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Whether this hook belongs to a session of another host whose scope the
|
|
1215
|
+
* recorder cannot keep as it is (see {@link badScope}), and so is answered
|
|
1216
|
+
* `{}` unprocessed — every hook of it, like a session that is off. One
|
|
1217
|
+
* line says so, once for the session.
|
|
1218
|
+
*
|
|
1219
|
+
* A session that already has a host keeps it: a later payload cannot turn
|
|
1220
|
+
* a run that is being recorded into one that is not.
|
|
1221
|
+
*/
|
|
1222
|
+
hostRefused(payload) {
|
|
1223
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
|
|
1224
|
+
if (sessionId && this.sessions.get(sessionId)?.host)
|
|
1225
|
+
return false;
|
|
1226
|
+
if (badScope(payload)) {
|
|
1227
|
+
if (!sessionId || !this.refusedSessions.has(sessionId)) {
|
|
1228
|
+
logLine("session.host_refused", {
|
|
1229
|
+
sess: sessionId,
|
|
1230
|
+
host: hostOf(payload)?.app,
|
|
1231
|
+
why: `its scope is empty, longer than ${HOST_TEXT_MAX} characters or not a text — a scope is never cut or dropped, ` +
|
|
1232
|
+
"because the run would land in another scope's plans; nothing of this session is recorded or replayed",
|
|
1233
|
+
fix: `give the adapter a scope of 1 to ${HOST_TEXT_MAX} characters, or none`,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
if (sessionId)
|
|
1237
|
+
this.refusedSessions.set(sessionId, Date.now());
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
if (!sessionId || !this.refusedSessions.has(sessionId))
|
|
1241
|
+
return false;
|
|
1242
|
+
this.refusedSessions.set(sessionId, Date.now());
|
|
1243
|
+
return true;
|
|
1244
|
+
}
|
|
1245
|
+
/** Flag every run open right now as having lost steps (see `RunState.lossy`). */
|
|
1246
|
+
markOpenRunsLossy() {
|
|
1247
|
+
for (const session of this.sessions.values()) {
|
|
1248
|
+
if (session.run && !session.run.finished)
|
|
1249
|
+
session.run.lossy = true;
|
|
1250
|
+
}
|
|
1060
1251
|
}
|
|
1061
1252
|
/** Whether `sessionId` is a session started with `BIR_OFF=1`. */
|
|
1062
1253
|
isSessionOff(sessionId) {
|
|
@@ -1223,6 +1414,11 @@ export class ControlServer {
|
|
|
1223
1414
|
session.transcriptPath = payload.transcript_path;
|
|
1224
1415
|
if (payload.cwd)
|
|
1225
1416
|
session.cwd = payload.cwd;
|
|
1417
|
+
// The first host a session names is the one it keeps. Any route may carry
|
|
1418
|
+
// it: a recorder that restarted in the middle of a run learns it from the
|
|
1419
|
+
// run's next hook, and so opens no Claude Code run for it.
|
|
1420
|
+
if (!session.host)
|
|
1421
|
+
session.host = hostOf(payload);
|
|
1226
1422
|
return session;
|
|
1227
1423
|
}
|
|
1228
1424
|
/**
|
|
@@ -1287,7 +1483,7 @@ export class ControlServer {
|
|
|
1287
1483
|
idleMin: Math.round((now - session.lastSeenAt) / 60_000),
|
|
1288
1484
|
why: "no hook for this session in a long time — the turn was interrupted and never resumed",
|
|
1289
1485
|
});
|
|
1290
|
-
this.closeRun(session, { detach: false });
|
|
1486
|
+
this.closeRun(session, { detach: false, reason: "idle" });
|
|
1291
1487
|
}
|
|
1292
1488
|
if (session.run && !session.run.finished)
|
|
1293
1489
|
continue;
|
|
@@ -1327,6 +1523,16 @@ export class ControlServer {
|
|
|
1327
1523
|
if (now - hello.at > DELIVERY_HELLO_TTL_MS || !pidAlive(pid))
|
|
1328
1524
|
this.scenarioHellos.delete(pid);
|
|
1329
1525
|
}
|
|
1526
|
+
for (const [sessionId, at] of this.refusedSessions) {
|
|
1527
|
+
if (now - at >= t.forgetMs)
|
|
1528
|
+
this.refusedSessions.delete(sessionId);
|
|
1529
|
+
}
|
|
1530
|
+
if (this.refusedSessions.size > OFF_SESSIONS_MAX) {
|
|
1531
|
+
const oldest = [...this.refusedSessions].sort((a, b) => a[1] - b[1]);
|
|
1532
|
+
for (const [sessionId] of oldest.slice(0, this.refusedSessions.size - OFF_SESSIONS_MAX)) {
|
|
1533
|
+
this.refusedSessions.delete(sessionId);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1330
1536
|
}
|
|
1331
1537
|
/** Drop a session and everything keyed on it. Its last run was sealed and drained already. */
|
|
1332
1538
|
forgetSession(session) {
|
|
@@ -1339,15 +1545,23 @@ export class ControlServer {
|
|
|
1339
1545
|
}
|
|
1340
1546
|
logDetail("session.forgotten", { sess: session.sessionId });
|
|
1341
1547
|
}
|
|
1342
|
-
/**
|
|
1343
|
-
|
|
1548
|
+
/**
|
|
1549
|
+
* Open a run for `session`, or return the open one.
|
|
1550
|
+
*
|
|
1551
|
+
* `asked` is what the prompt that opens the run asked for, in a session of
|
|
1552
|
+
* another host: whether its adapter wants a plan (`HookPayload.replay`).
|
|
1553
|
+
*/
|
|
1554
|
+
ensureRun(session, input = "", asked = {}) {
|
|
1344
1555
|
if (session.run && !session.run.finished)
|
|
1345
1556
|
return session.run;
|
|
1346
1557
|
// The sealed run stays reachable for a moment: a report or a post that
|
|
1347
1558
|
// arrives just after its turn ended is that turn's, not this one's.
|
|
1348
1559
|
if (session.run)
|
|
1349
1560
|
session.previous = session.run;
|
|
1350
|
-
|
|
1561
|
+
// A run of another host says so in its `framework`: the service keeps a
|
|
1562
|
+
// plan inside the host, and the scope, that recorded it.
|
|
1563
|
+
const framework = frameworkOf(session);
|
|
1564
|
+
const runId = this.recorder.startRun(input, this.runMetadata(session), framework ? { framework } : undefined);
|
|
1351
1565
|
const run = {
|
|
1352
1566
|
runId,
|
|
1353
1567
|
input,
|
|
@@ -1364,9 +1578,20 @@ export class ControlServer {
|
|
|
1364
1578
|
// Taken here, beside `startedAtMs`, so the cost window and the duration
|
|
1365
1579
|
// window are the same window (docs/calculatedReplay.md §11.4).
|
|
1366
1580
|
usageMark: markTranscriptUsage(session.transcriptPath),
|
|
1581
|
+
// Written here and not by `onPrompt` after this returns: the match
|
|
1582
|
+
// watched below asks at once whether replay is on for this run
|
|
1583
|
+
// (`armFromMatch`), and a flag set afterwards would come too late —
|
|
1584
|
+
// every run of another host would then only record.
|
|
1585
|
+
hostReplay: asked.replay === true ? true : undefined,
|
|
1367
1586
|
};
|
|
1368
1587
|
session.run = run;
|
|
1369
|
-
logLine("run.start", {
|
|
1588
|
+
logLine("run.start", {
|
|
1589
|
+
run: runId,
|
|
1590
|
+
sess: session.sessionId,
|
|
1591
|
+
tier: "bound",
|
|
1592
|
+
// Named only for another host: `bir investigate` reads it from here.
|
|
1593
|
+
host: otherHost(session) ? session.host?.app : undefined,
|
|
1594
|
+
});
|
|
1370
1595
|
// Journal-only: the prompt is what `bir investigate` lists a turn by. Never
|
|
1371
1596
|
// on stderr, and never more than a preview.
|
|
1372
1597
|
if (input)
|
|
@@ -1379,8 +1604,10 @@ export class ControlServer {
|
|
|
1379
1604
|
runMetadata(session) {
|
|
1380
1605
|
return {
|
|
1381
1606
|
recorder: "baseinstrunner",
|
|
1607
|
+
// Which runner made the recording: the service keeps it with the run.
|
|
1608
|
+
runnerVersion: packageVersion(),
|
|
1382
1609
|
tier: "bound",
|
|
1383
|
-
host: this.opts.host ?? { app: "claude-code" },
|
|
1610
|
+
host: session.host ?? this.opts.host ?? { app: "claude-code" },
|
|
1384
1611
|
wrappedServers: [...this.wrapped],
|
|
1385
1612
|
// Known-at-start value. A run that turns lossy later says so on the audit
|
|
1386
1613
|
// log; carrying it here too would need a metadata patch the service does
|
|
@@ -1441,8 +1668,11 @@ export class ControlServer {
|
|
|
1441
1668
|
* one `replay.decision` line says why (docs/calculatedReplay.md §10).
|
|
1442
1669
|
*/
|
|
1443
1670
|
async armFromMatch(session, run, pending, prompt, startedAt) {
|
|
1444
|
-
|
|
1671
|
+
// Off for the recorder, or — in a session of another host — not asked
|
|
1672
|
+
// for by this run's adapter: either way nothing can be announced.
|
|
1673
|
+
if (!this.replay.enabled || this.recordsOnly(session, run)) {
|
|
1445
1674
|
return this.noteDisabledMatch(session, run, pending, prompt);
|
|
1675
|
+
}
|
|
1446
1676
|
const promptWaitMs = this.promptWaitMs();
|
|
1447
1677
|
const matchBudget = Math.min(this.replay.budgets.matchMs, promptWaitMs);
|
|
1448
1678
|
if (Date.now() < this.matchGate.holdUntil) {
|
|
@@ -1513,9 +1743,7 @@ export class ControlServer {
|
|
|
1513
1743
|
// Awaited: a scenario with a target derives before the directive goes
|
|
1514
1744
|
// out, so a plan that cannot find its target is never delivered
|
|
1515
1745
|
// (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
|
-
}));
|
|
1746
|
+
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
1747
|
const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
|
|
1520
1748
|
const state = await this.withinBudget(arming, remaining);
|
|
1521
1749
|
if (state === undefined)
|
|
@@ -1567,7 +1795,9 @@ export class ControlServer {
|
|
|
1567
1795
|
await this.replay.settleForPreview(state, settleMs);
|
|
1568
1796
|
// The argument preview runs logic; it spends what is left of the prompt's
|
|
1569
1797
|
// own budget, never time on top of it (under 10 ms left, none is shown).
|
|
1570
|
-
|
|
1798
|
+
// None at all for another host: its adapter reads the plan as data, and
|
|
1799
|
+
// no model reads this text.
|
|
1800
|
+
const previewMs = otherHost(session) ? 0 : Math.max(0, promptWaitMs - (Date.now() - startedAt));
|
|
1571
1801
|
const directive = state.plan ? this.replay.directiveFor(state, { previewMs }) : undefined;
|
|
1572
1802
|
if (state.plan && !directive) {
|
|
1573
1803
|
// A plan with nothing to announce is a plan the model is never told of.
|
|
@@ -1642,7 +1872,7 @@ export class ControlServer {
|
|
|
1642
1872
|
if (!match || run.finished)
|
|
1643
1873
|
return undefined;
|
|
1644
1874
|
// 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"));
|
|
1875
|
+
const state = await this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {}, this.hostContext(session, run)));
|
|
1646
1876
|
if (!state || run.finished || run.replays.includes(state))
|
|
1647
1877
|
return undefined;
|
|
1648
1878
|
if (state.plan) {
|
|
@@ -1830,6 +2060,55 @@ export class ControlServer {
|
|
|
1830
2060
|
return undefined;
|
|
1831
2061
|
});
|
|
1832
2062
|
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Whether `run` only records: a run of another host whose adapter did not
|
|
2065
|
+
* ask for a plan with its prompt. Treated as a run with replay off in the
|
|
2066
|
+
* three places that ask (`onPrompt`, `armFromMatch`, `tryIntentMatch`).
|
|
2067
|
+
*
|
|
2068
|
+
* An adapter that only records never sends `replay`. Without this the
|
|
2069
|
+
* recorder would start a plan for every prompt that matches one, read the
|
|
2070
|
+
* model's own calls against it, and book a replay nobody ran. A run another
|
|
2071
|
+
* hook opened carries no flag either, and only records.
|
|
2072
|
+
*/
|
|
2073
|
+
recordsOnly(session, run) {
|
|
2074
|
+
return otherHost(session) && run.hostReplay !== true;
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* What {@link ReplayController.arm} is told of `session` when its host is
|
|
2078
|
+
* not Claude Code, and undefined when it is.
|
|
2079
|
+
*/
|
|
2080
|
+
hostContext(session, run) {
|
|
2081
|
+
if (!otherHost(session))
|
|
2082
|
+
return undefined;
|
|
2083
|
+
return {
|
|
2084
|
+
app: session.host.app,
|
|
2085
|
+
sinks: session.hostSinks ?? {},
|
|
2086
|
+
recordsOnly: this.recordsOnly(session, run) || undefined,
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* The earlier tool results the reading of new values may look at: this
|
|
2091
|
+
* prompt's own, from the transcript — and so none for a plan that is
|
|
2092
|
+
* started at the prompt (segmented.md R-PARAM-2a).
|
|
2093
|
+
*
|
|
2094
|
+
* `at` is where the plan is being started, and at the prompt a transcript
|
|
2095
|
+
* has nothing to give. The prompt was only just submitted, so it has no
|
|
2096
|
+
* results of its own; and the transcript does not hold it yet, so what the
|
|
2097
|
+
* transcript calls "this prompt's results" are still the *previous*
|
|
2098
|
+
* prompt's. They used to be sent as this task's, and they answered for it:
|
|
2099
|
+
* a second prompt that named one person ran on the person the first
|
|
2100
|
+
* prompt's results were full of.
|
|
2101
|
+
*
|
|
2102
|
+
* A session of another host has no transcript, and a plan starts there at
|
|
2103
|
+
* the prompt only: none either. Its adapter used to hand the conversation's
|
|
2104
|
+
* earlier results over with the prompt (`recent_results`) — the same
|
|
2105
|
+
* mistake, made on purpose. An adapter that still sends them is not read.
|
|
2106
|
+
*/
|
|
2107
|
+
recentResultsFor(session, at) {
|
|
2108
|
+
if (at === "prompt" || otherHost(session))
|
|
2109
|
+
return [];
|
|
2110
|
+
return recentToolResults(session.transcriptPath, this.deriveRecentResults);
|
|
2111
|
+
}
|
|
1833
2112
|
/**
|
|
1834
2113
|
* The prompt hook answered without a directive: retire any prompt-armed plan
|
|
1835
2114
|
* of `run` that could still steer. `armFromMatch` arms one live only while
|
|
@@ -1889,11 +2168,30 @@ export class ControlServer {
|
|
|
1889
2168
|
* routinely left half-open, which is what happened in practice.
|
|
1890
2169
|
*
|
|
1891
2170
|
* Idempotent: the second caller sees `finished` and does nothing.
|
|
2171
|
+
*
|
|
2172
|
+
* `reason` says why the run is closed. It decides something only in a
|
|
2173
|
+
* session of another host (see {@link CloseReason}).
|
|
1892
2174
|
*/
|
|
1893
|
-
sealRun(session) {
|
|
2175
|
+
sealRun(session, reason = "stop") {
|
|
1894
2176
|
const run = session.run;
|
|
1895
2177
|
if (!run || run.finished)
|
|
1896
2178
|
return;
|
|
2179
|
+
const other = otherHost(session);
|
|
2180
|
+
// A run of another host that is closed before its turn's work ended: it
|
|
2181
|
+
// failed, went quiet, or its session ended under it. A LangChain run does
|
|
2182
|
+
// not always end with a stop — nothing calls an end hook when it fails.
|
|
2183
|
+
const early = other && reason !== "stop";
|
|
2184
|
+
// Such a run's cost says nothing of what the task costs. Nor does that of
|
|
2185
|
+
// a run closed — in any way — with its plan still live. That plan is
|
|
2186
|
+
// handed over first, before the report is built, so the report says what
|
|
2187
|
+
// happened: `fell_back` when a step ran, `failed` when none did, and not
|
|
2188
|
+
// `not_steered`, as if no plan had started.
|
|
2189
|
+
let cutShort = early;
|
|
2190
|
+
if (other) {
|
|
2191
|
+
const live = this.livePlan(run);
|
|
2192
|
+
if (live && this.replay.hostHandOver(live, "closed"))
|
|
2193
|
+
cutShort = true;
|
|
2194
|
+
}
|
|
1897
2195
|
run.finished = true;
|
|
1898
2196
|
for (const correlation of run.correlations.values()) {
|
|
1899
2197
|
if (correlation.fallback)
|
|
@@ -1917,20 +2215,39 @@ export class ControlServer {
|
|
|
1917
2215
|
// A matched turn records nothing, but it still owes the ledger a number —
|
|
1918
2216
|
// including when it declined to steer, which is a *baseline* sample and is
|
|
1919
2217
|
// what every saving is measured against (docs/calculatedReplay.md §11).
|
|
1920
|
-
this.reportExecution(session, run, durationMs, cost);
|
|
2218
|
+
this.reportExecution(session, run, durationMs, cost, cutShort);
|
|
1921
2219
|
// A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
|
|
1922
2220
|
// and duration are the model's own work, not the steps the scenario did.
|
|
2221
|
+
// Another host hands over one number for the whole run, and it is the
|
|
2222
|
+
// fragment's: the plan's own steps made no model call, so all of it was
|
|
2223
|
+
// spent after the hand-over.
|
|
1923
2224
|
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
1924
|
-
const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
|
|
2225
|
+
const recordedCost = fragment && !other ? this.costSince(session, fragment.usageMark) : cost;
|
|
1925
2226
|
const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
|
|
1926
2227
|
if (run.recording) {
|
|
1927
2228
|
const transcriptPath = session.transcriptPath;
|
|
2229
|
+
// The answer is the host's when it handed one over, and the
|
|
2230
|
+
// transcript's otherwise — never both. A run that ended early has none:
|
|
2231
|
+
// whatever text there is, it is not the answer to the prompt.
|
|
2232
|
+
const answerText = early
|
|
2233
|
+
? Promise.resolve("")
|
|
2234
|
+
: run.hostAnswer !== undefined
|
|
2235
|
+
? Promise.resolve(run.hostAnswer)
|
|
2236
|
+
: settledLastAssistantText(transcriptPath).catch(() => "");
|
|
2237
|
+
// What the finish of another host's run says beside its answer and its
|
|
2238
|
+
// cost: why it ended early, when it did, and whether it is whole. A
|
|
2239
|
+
// run with either is never calculated and stops catching its prompt.
|
|
2240
|
+
const closing = other
|
|
2241
|
+
? {
|
|
2242
|
+
error: early ? (run.hostFailed ?? `turn ended early: ${reason}`) : undefined,
|
|
2243
|
+
incomplete: { lossy: run.lossy === true },
|
|
2244
|
+
}
|
|
2245
|
+
: undefined;
|
|
1928
2246
|
// Resolve the answer *outside* the queue: the wait below is up to two
|
|
1929
2247
|
// seconds, and the queue is serial — blocking it here would delay the
|
|
1930
2248
|
// first step of the user's next turn by that much. Kept on the run, so a
|
|
1931
2249
|
// drain of this run waits for this seal and for nobody else's.
|
|
1932
|
-
run.sealing =
|
|
1933
|
-
.catch(() => "")
|
|
2250
|
+
run.sealing = answerText
|
|
1934
2251
|
.then((answer) => {
|
|
1935
2252
|
run.finishQueued = true;
|
|
1936
2253
|
this.queue.push(() => {
|
|
@@ -1949,6 +2266,7 @@ export class ControlServer {
|
|
|
1949
2266
|
this.recorder.finishRun(run.runId, answer || undefined, {
|
|
1950
2267
|
durationMs: recordedMs,
|
|
1951
2268
|
costUsd: recordedCost.measured ? recordedCost.usd : undefined,
|
|
2269
|
+
...closing,
|
|
1952
2270
|
});
|
|
1953
2271
|
});
|
|
1954
2272
|
});
|
|
@@ -1961,6 +2279,9 @@ export class ControlServer {
|
|
|
1961
2279
|
measured: cost.measured,
|
|
1962
2280
|
lossy: this.lossy,
|
|
1963
2281
|
recorded: run.recording,
|
|
2282
|
+
// Only for another host, and only when the run did not end with an
|
|
2283
|
+
// ordinary stop.
|
|
2284
|
+
ended: early ? reason : undefined,
|
|
1964
2285
|
});
|
|
1965
2286
|
}
|
|
1966
2287
|
/**
|
|
@@ -1985,6 +2306,11 @@ export class ControlServer {
|
|
|
1985
2306
|
* false` and zero rather than a number that is confidently wrong.
|
|
1986
2307
|
*/
|
|
1987
2308
|
runCost(session, run) {
|
|
2309
|
+
// Another host has no transcript: its numbers are the ones it handed
|
|
2310
|
+
// over with its stop, and they are looked at before any watermark — a
|
|
2311
|
+
// run of such a host never has one.
|
|
2312
|
+
if (otherHost(session))
|
|
2313
|
+
return this.hostCost(run);
|
|
1988
2314
|
const measurable = run.usageMark !== undefined;
|
|
1989
2315
|
if (!measurable) {
|
|
1990
2316
|
logLine("run.unmeasured", {
|
|
@@ -1993,21 +2319,57 @@ export class ControlServer {
|
|
|
1993
2319
|
});
|
|
1994
2320
|
return { usd: 0, measured: false };
|
|
1995
2321
|
}
|
|
1996
|
-
|
|
2322
|
+
const cost = this.costSince(session, run.usageMark);
|
|
2323
|
+
if (cost.unpriced?.length) {
|
|
2324
|
+
logLine("run.unmeasured", {
|
|
2325
|
+
run: run.runId,
|
|
2326
|
+
why: "the runner has no price for a model of this turn — update the runner",
|
|
2327
|
+
models: cost.unpriced.join(","),
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
return cost;
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* What a run of another host cost, from the tokens its adapter handed over
|
|
2334
|
+
* with the stop. Measured only when at least one entry has tokens and every
|
|
2335
|
+
* model in the list has a price; with no usage at all, not measured — and
|
|
2336
|
+
* one `run.unmeasured` line says which.
|
|
2337
|
+
*/
|
|
2338
|
+
hostCost(run) {
|
|
2339
|
+
const usage = run.hostUsage;
|
|
2340
|
+
if (!usage || usage.length === 0) {
|
|
2341
|
+
logLine("run.unmeasured", { run: run.runId, why: "the host sent no usage" });
|
|
2342
|
+
return { usd: 0, measured: false };
|
|
2343
|
+
}
|
|
2344
|
+
const cost = priceUsage(usage);
|
|
2345
|
+
const used = usage.some((u) => u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreationTokens > 0);
|
|
2346
|
+
if (used && cost.measured)
|
|
2347
|
+
return cost;
|
|
2348
|
+
logLine("run.unmeasured", {
|
|
2349
|
+
run: run.runId,
|
|
2350
|
+
why: cost.unpriced?.length
|
|
2351
|
+
? "the runner has no price for a model of this run"
|
|
2352
|
+
: "the host's usage holds no tokens — the run made no model call",
|
|
2353
|
+
models: cost.unpriced?.length ? cost.unpriced.join(",") : undefined,
|
|
2354
|
+
});
|
|
2355
|
+
return { ...cost, measured: false };
|
|
1997
2356
|
}
|
|
1998
|
-
/** Transcript cost since `mark`; unmeasured (and zero) without one. */
|
|
2357
|
+
/** Transcript cost since `mark`; unmeasured (and zero) without one, or with a model that has no price. */
|
|
1999
2358
|
costSince(session, mark) {
|
|
2000
2359
|
if (mark === undefined)
|
|
2001
2360
|
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 };
|
|
2361
|
+
return priceUsage(usageSince(session.transcriptPath, mark));
|
|
2005
2362
|
}
|
|
2006
|
-
reportExecution(session, run, durationMs, cost
|
|
2363
|
+
reportExecution(session, run, durationMs, cost,
|
|
2364
|
+
/** The run was closed before its task ended for the caller (another host only). */
|
|
2365
|
+
cutShort = false) {
|
|
2007
2366
|
if (!isScenarioReporter(this.recorder))
|
|
2008
2367
|
return;
|
|
2009
2368
|
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
2010
2369
|
const recorder = this.recorder;
|
|
2370
|
+
// Another host hands over one number for the whole run. It cannot be
|
|
2371
|
+
// split between two plans, so with more than one neither is measured.
|
|
2372
|
+
const unsplit = otherHost(session) && states.length > 1;
|
|
2011
2373
|
// One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
|
|
2012
2374
|
// for its own window of the transcript — from when it armed to when the next
|
|
2013
2375
|
// one did — so the turn's tokens are split between them, never counted twice.
|
|
@@ -2039,11 +2401,12 @@ export class ControlServer {
|
|
|
2039
2401
|
sessionCostUsd: windowCost.usd,
|
|
2040
2402
|
// A direct run the user cancelled cut its turn short: its cost is no
|
|
2041
2403
|
// baseline, and no saving is measured against it.
|
|
2042
|
-
measured: windowCost.measured && !this.cancelledPlans.has(state),
|
|
2404
|
+
measured: windowCost.measured && !this.cancelledPlans.has(state) && !unsplit,
|
|
2043
2405
|
durationMs: Math.max(0, windowMs),
|
|
2044
2406
|
prompt: run.input || undefined,
|
|
2045
2407
|
siblings: states,
|
|
2046
2408
|
runId: run.runId,
|
|
2409
|
+
cutShort: cutShort || undefined,
|
|
2047
2410
|
});
|
|
2048
2411
|
if (!report)
|
|
2049
2412
|
return;
|
|
@@ -2076,7 +2439,7 @@ export class ControlServer {
|
|
|
2076
2439
|
const run = session.run;
|
|
2077
2440
|
if (!run)
|
|
2078
2441
|
return;
|
|
2079
|
-
this.sealRun(session);
|
|
2442
|
+
this.sealRun(session, opts.reason);
|
|
2080
2443
|
if (opts.detach && session.run === run) {
|
|
2081
2444
|
session.run = undefined;
|
|
2082
2445
|
session.previous = run;
|
|
@@ -2145,7 +2508,7 @@ export class ControlServer {
|
|
|
2145
2508
|
// → Stop is the only honest run boundary. The previous turn was
|
|
2146
2509
|
// interrupted — Stop does not fire on Esc — so it is sealed here, and its
|
|
2147
2510
|
// drain runs behind this prompt rather than in front of it.
|
|
2148
|
-
this.closeRun(session, { detach: true });
|
|
2511
|
+
this.closeRun(session, { detach: true, reason: "interrupted" });
|
|
2149
2512
|
}
|
|
2150
2513
|
else if (open && open.ordering.next === 0) {
|
|
2151
2514
|
// A run was opened early by a proxy step (the host spawned MCP servers
|
|
@@ -2184,16 +2547,24 @@ export class ControlServer {
|
|
|
2184
2547
|
});
|
|
2185
2548
|
return {};
|
|
2186
2549
|
}
|
|
2187
|
-
const
|
|
2550
|
+
const other = otherHost(session);
|
|
2551
|
+
if (other) {
|
|
2552
|
+
// What a host hands over with its prompt, kept before the run opens:
|
|
2553
|
+
// the match watched from there may start a plan at once, and the places
|
|
2554
|
+
// that do have no prompt payload in reach.
|
|
2555
|
+
session.hostSinks = hostSinksOf(payload);
|
|
2556
|
+
}
|
|
2557
|
+
const run = this.ensureRun(session, input, { replay: other && payload.replay === true });
|
|
2188
2558
|
run.promptSeen = true;
|
|
2189
2559
|
run.input = input;
|
|
2190
2560
|
session.prompts += 1;
|
|
2191
2561
|
logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
|
|
2192
2562
|
// With replay off there is nothing this hook could say, so it says nothing
|
|
2193
2563
|
// at once: the match still stops recording on a hit (v1), in the
|
|
2194
|
-
// background, and no prompt waits on the service for it.
|
|
2564
|
+
// background, and no prompt waits on the service for it. The same for a
|
|
2565
|
+
// run of another host whose adapter asked for no plan.
|
|
2195
2566
|
const watch = this.watchForMatch(session, run, input);
|
|
2196
|
-
if (!this.replay.enabled)
|
|
2567
|
+
if (!this.replay.enabled || this.recordsOnly(session, run))
|
|
2197
2568
|
return {};
|
|
2198
2569
|
// Plan mode is the user asking to see a plan before anything runs. A
|
|
2199
2570
|
// replay would run tools, auto-approved: nothing is announced, and a plan
|
|
@@ -2225,12 +2596,18 @@ export class ControlServer {
|
|
|
2225
2596
|
this.retireUnannounced(session, run);
|
|
2226
2597
|
return {};
|
|
2227
2598
|
}
|
|
2228
|
-
|
|
2599
|
+
const answer = {
|
|
2229
2600
|
hookSpecificOutput: {
|
|
2230
2601
|
hookEventName: "UserPromptSubmit",
|
|
2231
2602
|
additionalContext: directive,
|
|
2232
2603
|
},
|
|
2233
2604
|
};
|
|
2605
|
+
// Another host steps through the plan itself, so it is told the plan as
|
|
2606
|
+
// data too: the directive is a text written for a model. At the top
|
|
2607
|
+
// level, where `noPlan` and `declined` are — `forEvent` can drop
|
|
2608
|
+
// `hookSpecificOutput` as a whole. A Claude Code session gets no new key.
|
|
2609
|
+
const plan = other && run.replay ? this.replay.planData(run.replay) : undefined;
|
|
2610
|
+
return plan ? { ...answer, plan } : answer;
|
|
2234
2611
|
}
|
|
2235
2612
|
/**
|
|
2236
2613
|
* `PreToolUse`. Two shapes of answer:
|
|
@@ -2250,6 +2627,10 @@ export class ControlServer {
|
|
|
2250
2627
|
*/
|
|
2251
2628
|
async onToolPre(payload, onLate) {
|
|
2252
2629
|
const session = this.ensureSession(payload);
|
|
2630
|
+
// A session of another host has a route of its own: its tools are the
|
|
2631
|
+
// host's, and everything below reads a tool's name as Claude Code's.
|
|
2632
|
+
if (otherHost(session))
|
|
2633
|
+
return this.onHostToolPre(session, payload, onLate);
|
|
2253
2634
|
const run = this.ensureRun(session);
|
|
2254
2635
|
const toolName = payload.tool_name ?? "unknown";
|
|
2255
2636
|
const toolUseId = payload.tool_use_id ?? "";
|
|
@@ -2605,6 +2986,238 @@ export class ControlServer {
|
|
|
2605
2986
|
}
|
|
2606
2987
|
return { stepIndex: pair.selected };
|
|
2607
2988
|
}
|
|
2989
|
+
/**
|
|
2990
|
+
* `/tool/pre` in a session whose host is not Claude Code — a LangChain
|
|
2991
|
+
* application, say (the service's integratioLangChane.md). Two kinds of
|
|
2992
|
+
* request, told apart by `scripted`:
|
|
2993
|
+
*
|
|
2994
|
+
* - **The adapter asks for the plan's next step** (`scripted: true`). It
|
|
2995
|
+
* is answering in the model's place and wants the step's arguments; the
|
|
2996
|
+
* host then runs its own tool with them. See {@link onScriptedPre}.
|
|
2997
|
+
* - **The model's own call.** Recorded, never pinned. See {@link onOwnPre}.
|
|
2998
|
+
*
|
|
2999
|
+
* ONLY A PROMPT OPENS A RUN for such a host. A hook that finds none open
|
|
3000
|
+
* opens none and answers `{}`: the run failed and its adapter closed it,
|
|
3001
|
+
* or the two-hour limit sealed it, or the recorder restarted under it. What
|
|
3002
|
+
* arrives afterwards — the application going on from its checkpoint, a
|
|
3003
|
+
* retry middleware trying the call again — must not become a second
|
|
3004
|
+
* recording with an empty prompt.
|
|
3005
|
+
*/
|
|
3006
|
+
async onHostToolPre(session, payload, onLate) {
|
|
3007
|
+
const scripted = payload.scripted === true;
|
|
3008
|
+
const toolName = hostText(payload.tool_name, 200) ?? "unknown";
|
|
3009
|
+
const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
|
|
3010
|
+
const run = session.run && !session.run.finished ? session.run : undefined;
|
|
3011
|
+
if (!run) {
|
|
3012
|
+
logDetail("tool.pre.skipped", {
|
|
3013
|
+
sess: session.sessionId,
|
|
3014
|
+
tool: toolName,
|
|
3015
|
+
why: "no open run — only a prompt opens one for this host",
|
|
3016
|
+
});
|
|
3017
|
+
// A request for a step is told so in words: an empty answer is also
|
|
3018
|
+
// what a recorder that is slow, or switched off, gives.
|
|
3019
|
+
return scripted ? { noPlan: true } : {};
|
|
3020
|
+
}
|
|
3021
|
+
// The runner's own `bir` server is housekeeping in every host.
|
|
3022
|
+
if (isHousekeepingCall(toolName, payload.tool_input, true)) {
|
|
3023
|
+
if (toolUseId)
|
|
3024
|
+
(run.housekeepingUses ??= new Set()).add(toolUseId);
|
|
3025
|
+
logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
|
|
3026
|
+
return {};
|
|
3027
|
+
}
|
|
3028
|
+
return scripted
|
|
3029
|
+
? this.onScriptedPre(run, toolName, toolUseId, payload, onLate)
|
|
3030
|
+
: this.onOwnPre(session, run, toolName, toolUseId, payload);
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* The adapter of another host asks for the plan's next step. The answer
|
|
3034
|
+
* either pins the call — `allow`, with the step's computed arguments — or
|
|
3035
|
+
* carries no pin, which ends the plan for the adapter. It then says why:
|
|
3036
|
+
*
|
|
3037
|
+
* - `noPlan`: the recorder holds no live plan for the run. It ran to its
|
|
3038
|
+
* end, was handed over, or the recorder restarted while the run waited
|
|
3039
|
+
* for a person. Nothing is recorded: no call follows this request.
|
|
3040
|
+
* - `declined`: the plan names a tool the agent does not hold.
|
|
3041
|
+
* - a refusal whose reason is the hand-over note, when the plan stopped
|
|
3042
|
+
* in this very request.
|
|
3043
|
+
*/
|
|
3044
|
+
async onScriptedPre(run, toolName, toolUseId, payload, onLate) {
|
|
3045
|
+
const live = this.livePlan(run);
|
|
3046
|
+
if (!live)
|
|
3047
|
+
return { noPlan: true };
|
|
3048
|
+
// A call cannot be pinned without an id to join its result by.
|
|
3049
|
+
if (!toolUseId)
|
|
3050
|
+
return {};
|
|
3051
|
+
// THE TOOLS THE AGENT HOLDS ride on the first request for a step: a host
|
|
3052
|
+
// shows them only when its model is about to be called, never at the
|
|
3053
|
+
// prompt. The recorder judges them, not the adapter — one that ended the
|
|
3054
|
+
// plan by itself would leave a live plan here that nobody steps through.
|
|
3055
|
+
const missing = this.missingHostTool(live, payload);
|
|
3056
|
+
if (missing !== undefined &&
|
|
3057
|
+
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`)) {
|
|
3058
|
+
return { declined: "host_tool_missing" };
|
|
3059
|
+
}
|
|
3060
|
+
let overran = false;
|
|
3061
|
+
const answeredLate = new AbortController();
|
|
3062
|
+
onLate?.(() => {
|
|
3063
|
+
overran = true;
|
|
3064
|
+
answeredLate.abort();
|
|
3065
|
+
return {};
|
|
3066
|
+
});
|
|
3067
|
+
const action = await this.replay.preTool(live, toolName, toolUseId, {}, { signal: answeredLate.signal });
|
|
3068
|
+
if (overran) {
|
|
3069
|
+
// The deadline guard answered `{}` — no pin — and the adapter took the
|
|
3070
|
+
// plan as ended. A pin decided now never reached it: the plan ends here
|
|
3071
|
+
// too, and what it would have said waits for the model's next result.
|
|
3072
|
+
live.pinned.delete(toolUseId);
|
|
3073
|
+
if (action.kind === "deny")
|
|
3074
|
+
run.handback = action.reason;
|
|
3075
|
+
else
|
|
3076
|
+
this.replay.hostHandOver(live, "late");
|
|
3077
|
+
this.noteHandover(run);
|
|
3078
|
+
return {};
|
|
3079
|
+
}
|
|
3080
|
+
// A hand-over inside `preTool` must still schedule the fragment.
|
|
3081
|
+
this.noteHandover(run);
|
|
3082
|
+
if (action.kind === "deny") {
|
|
3083
|
+
return {
|
|
3084
|
+
hookSpecificOutput: {
|
|
3085
|
+
hookEventName: "PreToolUse",
|
|
3086
|
+
permissionDecision: "deny",
|
|
3087
|
+
permissionDecisionReason: action.reason,
|
|
3088
|
+
},
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
// `passthrough`, `abort`: the plan ended while this request waited.
|
|
3092
|
+
if (action.kind !== "pin")
|
|
3093
|
+
return { noPlan: true };
|
|
3094
|
+
// Recorded as any step the hook alone observes — in a recording, which a
|
|
3095
|
+
// matched run is not — tagged with the plan that pinned it. The same id
|
|
3096
|
+
// asked for twice is one step.
|
|
3097
|
+
const known = run.builtIns.get(toolUseId);
|
|
3098
|
+
const pair = known
|
|
3099
|
+
? { selected: known.stepIndex, response: known.responseIndex }
|
|
3100
|
+
: run.ordering.allocatePair();
|
|
3101
|
+
const pinnedBy = live.scenarioId ?? undefined;
|
|
3102
|
+
if (!known) {
|
|
3103
|
+
run.builtIns.set(toolUseId, { stepIndex: pair.selected, responseIndex: pair.response, toolName });
|
|
3104
|
+
if (run.recording) {
|
|
3105
|
+
const toolInput = serializeCapped(redact(action.input));
|
|
3106
|
+
this.queue.push(() => {
|
|
3107
|
+
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3108
|
+
toolName,
|
|
3109
|
+
toolInput,
|
|
3110
|
+
metadata: pinnedBy ? { pinnedBy } : undefined,
|
|
3111
|
+
});
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
|
|
3116
|
+
return {
|
|
3117
|
+
stepIndex: pair.selected,
|
|
3118
|
+
hookSpecificOutput: {
|
|
3119
|
+
hookEventName: "PreToolUse",
|
|
3120
|
+
permissionDecision: "allow",
|
|
3121
|
+
updatedInput: action.input,
|
|
3122
|
+
},
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
/**
|
|
3126
|
+
* The first tool the plan will call that the agent does not hold, or
|
|
3127
|
+
* undefined when it holds them all — or did not say which it holds.
|
|
3128
|
+
*/
|
|
3129
|
+
missingHostTool(state, payload) {
|
|
3130
|
+
const held = hostNames(payload.tools);
|
|
3131
|
+
const plan = held ? this.replay.planData(state) : undefined;
|
|
3132
|
+
if (!held || !plan)
|
|
3133
|
+
return undefined;
|
|
3134
|
+
const names = new Set(held);
|
|
3135
|
+
return plan.steps.find((step) => !names.has(step.toolName))?.toolName;
|
|
3136
|
+
}
|
|
3137
|
+
/**
|
|
3138
|
+
* The model's own call, in a session of another host: any `/tool/pre`
|
|
3139
|
+
* without `scripted`.
|
|
3140
|
+
*
|
|
3141
|
+
* NEVER PINNED, and never answered with a bundle. In Claude Code a call to
|
|
3142
|
+
* the plan's next tool is pinned, because there the model is the one who
|
|
3143
|
+
* makes the plan's calls. Here the adapter makes them, so a call the model
|
|
3144
|
+
* made is the model's whatever tool it names: answered with the plan's
|
|
3145
|
+
* arguments, the tool would run with arguments the model never wrote, and
|
|
3146
|
+
* the model would read a result that does not belong to the call it sees.
|
|
3147
|
+
*
|
|
3148
|
+
* A plan that is still live is handed over. The call runs as the model
|
|
3149
|
+
* wrote it — the note is not given in its place — and the note goes out
|
|
3150
|
+
* with this call's `/tool/post`. The recording of the model's own work
|
|
3151
|
+
* after the hand-over (the fragment) opens with this very call: opened one
|
|
3152
|
+
* call later, the call that left the plan would be in no recording.
|
|
3153
|
+
*/
|
|
3154
|
+
async onOwnPre(session, run, toolName, toolUseId, payload) {
|
|
3155
|
+
const modelArgs = payload.tool_input ?? {};
|
|
3156
|
+
// A tool that pauses itself passes its hook again when the run resumes:
|
|
3157
|
+
// the same call announced twice is one step, not two.
|
|
3158
|
+
const announced = toolUseId ? run.builtIns.get(toolUseId) : undefined;
|
|
3159
|
+
if (announced) {
|
|
3160
|
+
logDetail("tool.pre.repeated", { run: run.runId, tool: toolName, step: announced.stepIndex });
|
|
3161
|
+
return { stepIndex: announced.stepIndex };
|
|
3162
|
+
}
|
|
3163
|
+
const live = this.livePlan(run);
|
|
3164
|
+
if (live && this.replay.hostHandOver(live, "own_call")) {
|
|
3165
|
+
logLine("replay.own_call", {
|
|
3166
|
+
run: run.runId,
|
|
3167
|
+
scenario: live.scenarioId ?? undefined,
|
|
3168
|
+
tool: toolName,
|
|
3169
|
+
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",
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
3172
|
+
this.noteHandover(run);
|
|
3173
|
+
const words = this.hostWords(payload);
|
|
3174
|
+
// Counted, never waited on, and never arming: see `tryIntentMatch`.
|
|
3175
|
+
await this.tryIntentMatch(session, run, toolName, words.intent, modelArgs);
|
|
3176
|
+
if (run.fragment?.state === "due" && !run.pendingDirective) {
|
|
3177
|
+
await this.openFragment(session, run, words.context, toolName, modelArgs);
|
|
3178
|
+
}
|
|
3179
|
+
// The hook is the only observer of every call of such a host: nothing of
|
|
3180
|
+
// it goes through a proxy of this folder, whatever a tool is named.
|
|
3181
|
+
const pair = run.ordering.allocatePair();
|
|
3182
|
+
run.builtIns.set(toolUseId || `anon_${pair.selected}`, {
|
|
3183
|
+
stepIndex: pair.selected,
|
|
3184
|
+
responseIndex: pair.response,
|
|
3185
|
+
toolName,
|
|
3186
|
+
});
|
|
3187
|
+
if (run.recording) {
|
|
3188
|
+
const toolInput = serializeCapped(redact(modelArgs));
|
|
3189
|
+
this.queue.push(() => {
|
|
3190
|
+
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
3191
|
+
toolName,
|
|
3192
|
+
toolInput,
|
|
3193
|
+
context: words.context || undefined,
|
|
3194
|
+
intent: words.thinking,
|
|
3195
|
+
});
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
|
|
3199
|
+
return { stepIndex: pair.selected };
|
|
3200
|
+
}
|
|
3201
|
+
/**
|
|
3202
|
+
* What the model wrote and reasoned before a call, as its host handed it
|
|
3203
|
+
* over with the call — the values {@link intentForToolUse} and
|
|
3204
|
+
* {@link thinkingForToolUse} read from a transcript, under the same switch:
|
|
3205
|
+
* a source `BIR_INTENT_SOURCES` turned off is not taken from the payload
|
|
3206
|
+
* either. `context` is the written text only; thinking rides apart.
|
|
3207
|
+
*/
|
|
3208
|
+
hostWords(payload) {
|
|
3209
|
+
const written = this.intentSources.has("text") ? hostText(payload.context) : undefined;
|
|
3210
|
+
const reasoned = this.intentSources.has("thinking") ? hostText(payload.thinking) : undefined;
|
|
3211
|
+
return {
|
|
3212
|
+
intent: written
|
|
3213
|
+
? { text: written, source: "text" }
|
|
3214
|
+
: reasoned
|
|
3215
|
+
? { text: reasoned, source: "thinking" }
|
|
3216
|
+
: undefined,
|
|
3217
|
+
context: written ?? "",
|
|
3218
|
+
thinking: reasoned ? { text: reasoned, source: "thinking" } : undefined,
|
|
3219
|
+
};
|
|
3220
|
+
}
|
|
2608
3221
|
/** This run's plan, when it is armed, still live, and announced to the model. */
|
|
2609
3222
|
livePlan(run) {
|
|
2610
3223
|
const state = run.replay;
|
|
@@ -2915,6 +3528,10 @@ export class ControlServer {
|
|
|
2915
3528
|
const opts = this.replay.intentMatch;
|
|
2916
3529
|
if (!this.replay.enabled || !opts.enabled)
|
|
2917
3530
|
return undefined;
|
|
3531
|
+
// A run of another host that only records is a run with replay off: no
|
|
3532
|
+
// probe is sent, so it adds nothing to the step counts either.
|
|
3533
|
+
if (this.recordsOnly(session, run))
|
|
3534
|
+
return undefined;
|
|
2918
3535
|
if (!isIntentMatcher(this.recorder))
|
|
2919
3536
|
return undefined;
|
|
2920
3537
|
if (this.replay.isDirectTool(toolName))
|
|
@@ -2953,6 +3570,7 @@ export class ControlServer {
|
|
|
2953
3570
|
.map((s) => s.scenarioId)
|
|
2954
3571
|
.filter((id) => typeof id === "string");
|
|
2955
3572
|
const startedAt = Date.now();
|
|
3573
|
+
const framework = frameworkOf(session);
|
|
2956
3574
|
const probe = this.recorder
|
|
2957
3575
|
.matchIntent({
|
|
2958
3576
|
text,
|
|
@@ -2969,13 +3587,19 @@ export class ControlServer {
|
|
|
2969
3587
|
ranThrough: this.ranThroughFor(run),
|
|
2970
3588
|
...(this.replay.segmentArm ? { acceptSegments: true } : {}),
|
|
2971
3589
|
supportsCalls: true,
|
|
3590
|
+
// Left out for Claude Code: a missing framework means Claude Code to
|
|
3591
|
+
// the service, which matches a step only inside its own host.
|
|
3592
|
+
...(framework ? { framework } : {}),
|
|
2972
3593
|
},
|
|
2973
3594
|
// The probe's own budget (`BIR_INTENT_MATCH_BUDGET_MS`) ends the request
|
|
2974
3595
|
// itself — waited on or not — so an abandoned probe releases its socket
|
|
2975
3596
|
// rather than holding it for as long as the service takes to answer.
|
|
2976
3597
|
{ timeoutMs: opts.budgetMs })
|
|
2977
3598
|
.catch(() => null);
|
|
2978
|
-
|
|
3599
|
+
// In a session of another host a probe only counts: a plan is started
|
|
3600
|
+
// there by the prompt, and stepped through by an adapter that was told of
|
|
3601
|
+
// it then. Nothing could announce one that armed in the middle of a run.
|
|
3602
|
+
if (!this.intentArming() || otherHost(session)) {
|
|
2979
3603
|
// Counted, never waited on: whatever the answer says is bookkeeping.
|
|
2980
3604
|
void probe.then((late) => {
|
|
2981
3605
|
if (late)
|
|
@@ -3017,10 +3641,7 @@ export class ControlServer {
|
|
|
3017
3641
|
// seconds, and this call is waiting. A plan not armed in time is dropped
|
|
3018
3642
|
// unused — it was never announced, so it never steers — and its ticket is
|
|
3019
3643
|
// 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
|
-
}));
|
|
3644
|
+
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
3645
|
const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
|
|
3025
3646
|
if (state === undefined)
|
|
3026
3647
|
return undefined; // it threw, and said so
|
|
@@ -3233,7 +3854,9 @@ export class ControlServer {
|
|
|
3233
3854
|
return;
|
|
3234
3855
|
const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
|
|
3235
3856
|
const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
3236
|
-
|
|
3857
|
+
// The fragment is a run of the same host as the turn it continues.
|
|
3858
|
+
const framework = frameworkOf(session);
|
|
3859
|
+
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
|
|
3237
3860
|
for (const c of run.correlations.values()) {
|
|
3238
3861
|
c.settled = true;
|
|
3239
3862
|
if (c.fallback)
|
|
@@ -3362,6 +3985,17 @@ export class ControlServer {
|
|
|
3362
3985
|
const session = this.ensureSession(payload);
|
|
3363
3986
|
const toolUseId = payload.tool_use_id ?? "";
|
|
3364
3987
|
const toolName = payload.tool_name ?? "unknown";
|
|
3988
|
+
const other = otherHost(session);
|
|
3989
|
+
// Only a prompt opens a run for another host (see `onHostToolPre`): a
|
|
3990
|
+
// result that finds none open is a closed run's, and is not recorded.
|
|
3991
|
+
if (other && !(session.run && !session.run.finished)) {
|
|
3992
|
+
logDetail("tool.post.skipped", {
|
|
3993
|
+
sess: session.sessionId,
|
|
3994
|
+
tool: toolName,
|
|
3995
|
+
why: "no open run — only a prompt opens one for this host",
|
|
3996
|
+
});
|
|
3997
|
+
return {};
|
|
3998
|
+
}
|
|
3365
3999
|
if (toolName.startsWith(`mcp__${SCENARIO_SERVER_KEY}__`))
|
|
3366
4000
|
this.birToolSeen = true;
|
|
3367
4001
|
// Every completion, pinned or not, before anything below can return: it
|
|
@@ -3412,7 +4046,7 @@ export class ControlServer {
|
|
|
3412
4046
|
const skippedAtPre = toolUseId !== "" && run.housekeepingUses?.delete(toolUseId) === true;
|
|
3413
4047
|
if (skippedAtPre)
|
|
3414
4048
|
return {};
|
|
3415
|
-
if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input))
|
|
4049
|
+
if (!run.builtIns.has(toolUseId) && isHousekeepingCall(toolName, payload.tool_input, other))
|
|
3416
4050
|
return {};
|
|
3417
4051
|
// Nor for an unarmed `run_scenario`. The plan may have retired since the
|
|
3418
4052
|
// pre hook, so the test is whether that hook opened a step, not the plan.
|
|
@@ -3427,12 +4061,29 @@ export class ControlServer {
|
|
|
3427
4061
|
// recorded, and only fall back to the hook's view if no report arrives.
|
|
3428
4062
|
const replay = run.replay;
|
|
3429
4063
|
const pinnedHere = replay && this.replay.isPinned(replay, toolUseId);
|
|
4064
|
+
// The result of a call the adapter of another host made for the plan,
|
|
4065
|
+
// which the recorder does not hold as pinned: the plan is gone — the run
|
|
4066
|
+
// was sealed and opened again, or the recorder restarted. Nothing of it
|
|
4067
|
+
// is recorded: it was never the model's call.
|
|
4068
|
+
if (other && payload.scripted === true && !pinnedHere) {
|
|
4069
|
+
logDetail("tool.post.skipped", { run: run.runId, tool: toolName, why: "a scripted call this recorder did not pin" });
|
|
4070
|
+
return {};
|
|
4071
|
+
}
|
|
3430
4072
|
const correlation = [...run.correlations.values()].find((c) => c.toolUseId && c.toolUseId === toolUseId);
|
|
3431
4073
|
if (pinnedHere && replay) {
|
|
4074
|
+
// What another host says it did to the step itself, read before any
|
|
4075
|
+
// failure: a person's "no" arrives as an error result, and is no fault
|
|
4076
|
+
// of the plan (controller.ts, `PostToolOptions.hostStop`).
|
|
4077
|
+
const hostStop = other && (payload.host_stop === "rejected" || payload.host_stop === "edited")
|
|
4078
|
+
? payload.host_stop
|
|
4079
|
+
: undefined;
|
|
3432
4080
|
// The host's verdict, which outranks anything read from the output: a
|
|
3433
4081
|
// step that failed must never thread as if it worked (controller.ts).
|
|
3434
|
-
const hostFailure = failed ? this.errorText(payload) : undefined;
|
|
3435
|
-
if (
|
|
4082
|
+
const hostFailure = failed && !hostStop ? this.errorText(payload) : undefined;
|
|
4083
|
+
if (hostStop) {
|
|
4084
|
+
this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}), undefined, { hostStop });
|
|
4085
|
+
}
|
|
4086
|
+
else if (correlation && this.replay.threadsFromProxy(replay, toolUseId)) {
|
|
3436
4087
|
correlation.threadToolUseId = toolUseId;
|
|
3437
4088
|
correlation.threadFallback = setTimeout(() => {
|
|
3438
4089
|
if (!this.replay.isPinned(replay, toolUseId))
|
|
@@ -3554,9 +4205,48 @@ export class ControlServer {
|
|
|
3554
4205
|
// arriving. A later prompt in the same session opens a fresh run, exactly as
|
|
3555
4206
|
// it did before. The drain that follows is background work: it is what
|
|
3556
4207
|
// lets the recorder forget the run once the service has it.
|
|
3557
|
-
|
|
4208
|
+
//
|
|
4209
|
+
// Another host hands the run's answer and its tokens over here — it has
|
|
4210
|
+
// no transcript to read them from — and says when the run failed or its
|
|
4211
|
+
// recording is not whole.
|
|
4212
|
+
const reason = otherHost(session) ? this.noteHostStop(run, payload) : "stop";
|
|
4213
|
+
this.closeRun(session, { detach: false, reason });
|
|
3558
4214
|
return {};
|
|
3559
4215
|
}
|
|
4216
|
+
/**
|
|
4217
|
+
* Keep what another host handed over with its stop — on the run, not on the
|
|
4218
|
+
* session: a later prompt of the session opens a new run, which must not
|
|
4219
|
+
* inherit an answer or a cost. Returns why the run is closed.
|
|
4220
|
+
*
|
|
4221
|
+
* - `final_answer`, `usage`: the answer and the tokens (see `sealRun`,
|
|
4222
|
+
* `hostCost`).
|
|
4223
|
+
* - `incomplete`: the recording is not the whole run — the adapter lost
|
|
4224
|
+
* calls, a person changed one, or one was answered in its tool's place.
|
|
4225
|
+
* The run is flagged lossy, so its finish carries the service's mark; the
|
|
4226
|
+
* mark has no place for a text, which goes to the log only.
|
|
4227
|
+
* - `failed`: the run failed. It is closed with that text as its error
|
|
4228
|
+
* and no final answer. An exception's text can hold a password or a
|
|
4229
|
+
* user's data: it is cleaned and cut like every text that is sent.
|
|
4230
|
+
*/
|
|
4231
|
+
noteHostStop(run, payload) {
|
|
4232
|
+
run.hostAnswer = typeof payload.final_answer === "string" && payload.final_answer ? payload.final_answer : undefined;
|
|
4233
|
+
run.hostUsage = hostUsageOf(payload);
|
|
4234
|
+
const incomplete = hostText(payload.incomplete, 500);
|
|
4235
|
+
if (incomplete !== undefined) {
|
|
4236
|
+
run.lossy = true;
|
|
4237
|
+
logLine("run.incomplete", {
|
|
4238
|
+
run: run.runId,
|
|
4239
|
+
why: redactText(incomplete),
|
|
4240
|
+
effect: "its recording is marked incomplete: never calculated, and it no longer catches its prompt",
|
|
4241
|
+
});
|
|
4242
|
+
}
|
|
4243
|
+
if (payload.failed === undefined || payload.failed === null || payload.failed === false)
|
|
4244
|
+
return "stop";
|
|
4245
|
+
const failed = hostText(payload.failed, 2_000);
|
|
4246
|
+
run.hostFailed = failed ? redactText(failed) : "the host said the run failed";
|
|
4247
|
+
logLine("run.host_failed", { run: run.runId, error: run.hostFailed });
|
|
4248
|
+
return "host_failed";
|
|
4249
|
+
}
|
|
3560
4250
|
/**
|
|
3561
4251
|
* `SessionEnd` — `/clear`, exit, logout. Answered at once: the host is
|
|
3562
4252
|
* waiting on it to exit, and this process outlives the session, so the run
|
|
@@ -3570,7 +4260,7 @@ export class ControlServer {
|
|
|
3570
4260
|
if (sessionId === session.sessionId)
|
|
3571
4261
|
this.proxySessions.delete(proxyId);
|
|
3572
4262
|
}
|
|
3573
|
-
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd });
|
|
4263
|
+
this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd, reason: "session_end" });
|
|
3574
4264
|
// Forgotten a little later by the reaper, not now: a late report or post
|
|
3575
4265
|
// for its last turn must still find that turn rather than open a new one.
|
|
3576
4266
|
session.endedAt = Date.now();
|
|
@@ -4062,7 +4752,10 @@ export class ControlServer {
|
|
|
4062
4752
|
const known = bound ? this.sessions.get(bound) : undefined;
|
|
4063
4753
|
if (known)
|
|
4064
4754
|
return known;
|
|
4065
|
-
|
|
4755
|
+
// Never a session of another host: none of its calls goes through a
|
|
4756
|
+
// proxy, and a proxy's step must not open a run there — only its own
|
|
4757
|
+
// prompt does (see `onHostToolPre`).
|
|
4758
|
+
const recent = this.recentSessions().filter((s) => !otherHost(s));
|
|
4066
4759
|
const active = recent.find((s) => this.isActive(s));
|
|
4067
4760
|
if (active)
|
|
4068
4761
|
return active;
|