@basein/runner 0.3.22 → 0.3.24

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.
@@ -109,6 +109,11 @@
109
109
  * call. Unset (or 0) is off: no probe is sent and
110
110
  * nothing arms mid-turn
111
111
  * BIR_INTENT_ARM_BUDGET_MS how long a call waits for that probe (default 500)
112
+ * BIR_CONTINUATIONS 0 stops asking, after a plan handed over, for the
113
+ * plan of the work recorded after an earlier
114
+ * hand-over at that step (default on: one request
115
+ * per hand-over; it runs only when the agent's next
116
+ * call is its first step)
112
117
  * BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40)
113
118
  * BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 1500)
114
119
  * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
@@ -109,6 +109,11 @@
109
109
  * call. Unset (or 0) is off: no probe is sent and
110
110
  * nothing arms mid-turn
111
111
  * BIR_INTENT_ARM_BUDGET_MS how long a call waits for that probe (default 500)
112
+ * BIR_CONTINUATIONS 0 stops asking, after a plan handed over, for the
113
+ * plan of the work recorded after an earlier
114
+ * hand-over at that step (default on: one request
115
+ * per hand-over; it runs only when the agent's next
116
+ * call is its first step)
112
117
  * BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40)
113
118
  * BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 1500)
114
119
  * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
@@ -282,6 +282,15 @@ export interface ControlServerOptions {
282
282
  * failing. Unset or `BIR_INTENT_ARM=0` is off.
283
283
  */
284
284
  intentArm?: boolean;
285
+ /**
286
+ * Whether the model's first call after a plan handed over asks the service
287
+ * for the plan of the work recorded after an earlier hand-over at that step,
288
+ * and runs it when that call is its first step (the service's fallbk.md
289
+ * Part 3). On by default: it is one request per hand-over, never per call.
290
+ * `BIR_CONTINUATIONS=0` turns it off; so does `BIR_INTENT_MATCH=0`, which
291
+ * turns off every mid-turn request.
292
+ */
293
+ continuations?: boolean;
285
294
  /**
286
295
  * The verdict hot paths consult before they wait on the service. Defaults to
287
296
  * the process-wide one every service call reports to; tests pass their own.
@@ -1156,6 +1165,77 @@ export declare class ControlServer {
1156
1165
  * matched call has run by then, and the plan's first step would repeat it.
1157
1166
  */
1158
1167
  private tryIntentMatch;
1168
+ /**
1169
+ * A continuation (the service's fallbk.md Part 3), on the model's first call
1170
+ * after a plan handed over.
1171
+ *
1172
+ * The model's work after an earlier hand-over of the same plan at the same
1173
+ * step was recorded once, as a fragment, and the service may have calculated
1174
+ * it into a plan. Until 0.3.23 nothing asked for that plan: the only way a
1175
+ * plan started mid-task was a match of the model's words against plan
1176
+ * intents ({@link tryIntentMatch}, off by default), and a one-line note
1177
+ * after a hand-over does not read like a plan's intent paragraph. Here the
1178
+ * hand-over itself is the question — the plan and the step — so the service
1179
+ * names one recording and matches no words.
1180
+ *
1181
+ * It arms on the model's first call after the hand-over, whatever that call
1182
+ * is (Eran, 2026-10-26). Runner 0.3.23 armed it only when the call was the
1183
+ * plan's first step, and lost it whenever the model first re-checked what
1184
+ * the plan before had already done. How the model is told depends on the
1185
+ * call. A call to one of the plan's own tools is answered in its place: the
1186
+ * plan makes that call itself, and letting it run would make it twice. Any
1187
+ * other call runs as the model wrote it, and the plan is told on its
1188
+ * PostToolUse, as an intent plan armed beside the model's own work is. The
1189
+ * plan before it stopped because the next step needed a judgement; the
1190
+ * continuation runs the half that was recorded, whatever the model would
1191
+ * have judged.
1192
+ *
1193
+ * Its values are read from the turn's own request — the rest of the task is
1194
+ * the one the user asked for — with the model's words, the call it was about
1195
+ * to make and this turn's tool results beside it, as for any plan started at
1196
+ * a tool call.
1197
+ *
1198
+ * Asked once per hand-over, waited on {@link CONTINUATION_PROBE_MS} and
1199
+ * {@link CONTINUATION_ARM_MS} at most, and not at all while the service is
1200
+ * failing. Every way it does not arm leaves the turn as it was: the model's
1201
+ * call runs, and its work is recorded as the fragment. A plan handed out and
1202
+ * not armed is reported for its ticket, with the reason.
1203
+ */
1204
+ private tryContinuation;
1205
+ /** The body of {@link tryContinuation}, once the continuation is asked for. */
1206
+ private continuationAttempt;
1207
+ /**
1208
+ * A call meeting a continuation that armed beside another call of the same
1209
+ * message and still waits to be told (`pendingDirective`). A call to one of
1210
+ * the plan's own tools gets the plan's directive in its place: letting it
1211
+ * run would have the plan make the same call again — a duplicated write.
1212
+ * Any other call runs, and the plan is told on the first PostToolUse.
1213
+ */
1214
+ private announceContinuationInstead;
1215
+ /**
1216
+ * Put back the fragment a continuation took the place of, when that
1217
+ * continuation retired having run no step and handed nothing over —
1218
+ * `run_scenario` refused, the plan vetoed, or a steer pin left by the
1219
+ * model. The model is doing the rest of the task itself, and that work is
1220
+ * recorded from its next call, as it would have been with no continuation.
1221
+ */
1222
+ private restoreParkedFragment;
1223
+ /**
1224
+ * A continuation handed out and not armed: kept, retired, for its ticket's
1225
+ * report at the seal (`not_steered`, with the reason, unmeasured). `code`
1226
+ * says why when no gate did.
1227
+ */
1228
+ private keepDeclinedContinuation;
1229
+ /** A continuation request's answer that came after the call it was for went ahead. */
1230
+ private noteLateContinuation;
1231
+ /**
1232
+ * Every scenario this turn was handed and armed or declined: never handed
1233
+ * out again in it. A plan that handed over is among them, so its own
1234
+ * continuation request can never return it.
1235
+ */
1236
+ private armedScenarioIds;
1237
+ /** Whether continuations are asked for: on unless `BIR_CONTINUATIONS=0`. */
1238
+ private continuationsOn;
1159
1239
  /**
1160
1240
  * Whether the model's call is plausibly `state`'s first step: its tool, and
1161
1241
  * arguments the pin guard would pin to that step's input. Anything that
@@ -106,6 +106,27 @@ const INTENT_ARM_PROBE_MS = 500;
106
106
  const INTENT_ARM_DERIVE_MS = 1_500;
107
107
  /** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
108
108
  const FRAGMENT_CREATE_MS = 300;
109
+ /**
110
+ * How long the model's first call after a hand-over waits for the service to
111
+ * say whether the work recorded after an earlier hand-over at that step has a
112
+ * plan (the service's fallbk.md Part 3). One lookup, no words matched: it
113
+ * answers in a round trip. Asked once per hand-over, never per call.
114
+ */
115
+ const CONTINUATION_PROBE_MS = 1_500;
116
+ /**
117
+ * How long the continuation request itself may take — longer than the call
118
+ * waits for it, so that an answer too late for the call still lands, and the
119
+ * ticket it may carry is reported as late instead of never.
120
+ */
121
+ const CONTINUATION_REQUEST_MS = 6_000;
122
+ /**
123
+ * What a continuation may spend arming — reading its values — inside that
124
+ * `PreToolUse`. Longer than an intent arm may: it is asked once per hand-over,
125
+ * the plan it arms does the rest of the task, and a value the request names
126
+ * is read by the service, which gives a reading 6 s. With the probe it stays
127
+ * well inside the route's 12 s.
128
+ */
129
+ const CONTINUATION_ARM_MS = 7_000;
109
130
  /** What `close()` spends draining the last runs to the service before it lets go. */
110
131
  const CLOSE_DRAIN_MS = 5_000;
111
132
  /** How long a background drain follows one run before it stops waiting for it. */
@@ -920,6 +941,14 @@ export class ControlServer {
920
941
  unannounced: s.run.pendingDirective?.state === s.run.replay ? true : undefined,
921
942
  running: this.runningPlans.has(s.run.replay) ? true : undefined,
922
943
  armedBy: s.run.replay.armedBy,
944
+ // A continuation (the service's fallbk.md Part 3): the plan it
945
+ // continues, and the step that plan stopped in front of.
946
+ continues: s.run.replay.continuation
947
+ ? {
948
+ scenarioId: s.run.replay.continuation.ofScenarioId,
949
+ stepIndex: s.run.replay.continuation.stepIndex,
950
+ }
951
+ : null,
923
952
  handover: s.run.replay.handover
924
953
  ? { kind: s.run.replay.handover.kind, stepIndex: s.run.replay.handover.stepIndex }
925
954
  : null,
@@ -934,7 +963,13 @@ export class ControlServer {
934
963
  armed: s.run.intent.armed,
935
964
  }
936
965
  : null,
937
- fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
966
+ fragment: s.run?.fragment
967
+ ? {
968
+ state: s.run.fragment.state,
969
+ ...(s.run.fragment.asked ? { asked: true } : {}),
970
+ ...(s.run.fragment.counted ? { counted: true } : {}),
971
+ }
972
+ : null,
938
973
  // The service's offer to record the turn as a recording's newest run,
939
974
  // and what became of it (newestRun.md).
940
975
  repeat: s.run?.repeat ? { state: s.run.repeat.state, of: s.run.repeat.runId } : null,
@@ -2989,12 +3024,43 @@ export class ControlServer {
2989
3024
  ? { runId: r.openedRunId, ownCalls: r.ownCalls ?? 0 }
2990
3025
  : undefined;
2991
3026
  const prompt = states.find((s) => s.armedBy === "prompt");
3027
+ // The plans that continued a plan in this turn (the service's fallbk.md
3028
+ // Part 3), directly or through one another: its hand-over's follow-on
3029
+ // work. Its baseline is the whole task, theirs only the part after the
3030
+ // hand-over, so its report bills the whole turn — its window runs through
3031
+ // theirs, and their readings are booked on it — and theirs book no saving.
3032
+ // Billed apart, the part after the hand-over would be saved twice.
3033
+ const continuedBy = (state, from) => {
3034
+ const chain = [];
3035
+ // A plan that ran no step reports `failed`, which books no saving: its
3036
+ // continuations then book their own, and it bills only its own window.
3037
+ if (state.outcome === "failed")
3038
+ return { chain, deriveUsd: 0 };
3039
+ const ids = new Set(state.scenarioId ? [state.scenarioId] : []);
3040
+ for (const s of states.slice(from + 1)) {
3041
+ if (s.continuation && ids.has(s.continuation.ofScenarioId)) {
3042
+ chain.push(s);
3043
+ if (s.scenarioId)
3044
+ ids.add(s.scenarioId);
3045
+ }
3046
+ }
3047
+ // A continuation declined after its values were read spent that reading
3048
+ // on this turn too, and its own report books nothing.
3049
+ const declined = (run.declinedContinuations ?? []).filter((s) => s.continuation !== undefined && ids.has(s.continuation.ofScenarioId));
3050
+ const deriveUsd = [...chain, ...declined].reduce((usd, s) => usd + s.deriveCostUsd, 0);
3051
+ return { chain, deriveUsd };
3052
+ };
3053
+ const chains = states.map((state, i) => continuedBy(state, i));
3054
+ /** A continuation no plan of this turn books: its plan before it ran nothing. */
3055
+ const booksOwn = (state) => state.continuation !== undefined && !chains.some((c, j) => states[j] !== state && c.chain.includes(state));
2992
3056
  // One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
2993
3057
  // for its own window of the transcript — from when it armed to when the next
2994
3058
  // one did — so the turn's tokens are split between them, never counted twice.
2995
3059
  // A turn with a single prompt-armed plan is exactly the report it always was.
2996
3060
  states.forEach((state, i) => {
2997
- const next = states[i + 1];
3061
+ const { chain, deriveUsd: chainDeriveUsd } = chains[i];
3062
+ // The plan whose arm ends this one's window: never one that continued it.
3063
+ const next = states.slice(i + 1).find((s) => !chain.includes(s));
2998
3064
  const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
2999
3065
  let windowCost = cost;
3000
3066
  let windowMs = durationMs;
@@ -3027,6 +3093,8 @@ export class ControlServer {
3027
3093
  runId: run.runId,
3028
3094
  cutShort: cutShort || undefined,
3029
3095
  redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
3096
+ chainDeriveUsd,
3097
+ booksOwnSaving: booksOwn(state),
3030
3098
  });
3031
3099
  if (!report)
3032
3100
  return;
@@ -3087,6 +3155,23 @@ export class ControlServer {
3087
3155
  if (report)
3088
3156
  this.queue.push(() => recorder.reportExecution(report));
3089
3157
  }
3158
+ // Continuations handed out and not armed (the service's fallbk.md Part 3):
3159
+ // a report for each ticket, with the reason, so the console says why and
3160
+ // not "no report". Nothing of theirs ran: unmeasured and costless.
3161
+ for (const declined of run.declinedContinuations ?? []) {
3162
+ if (states.includes(declined))
3163
+ continue;
3164
+ const report = this.replay.buildReport(declined, {
3165
+ sessionCostUsd: 0,
3166
+ measured: false,
3167
+ durationMs: 0,
3168
+ prompt: run.input || undefined,
3169
+ siblings: [...states, declined],
3170
+ runId: run.runId,
3171
+ });
3172
+ if (report)
3173
+ this.queue.push(() => recorder.reportExecution(report));
3174
+ }
3090
3175
  }
3091
3176
  /**
3092
3177
  * The plan's own calls of the turn, whole, as an answered turn's report
@@ -3476,6 +3561,21 @@ export class ControlServer {
3476
3561
  if (stashed)
3477
3562
  return this.preToolAnswer(this.replay.deliverInstead(stashed, toolName)) ?? {};
3478
3563
  }
3564
+ // A continuation request of another call of this same message is still
3565
+ // out (the service's fallbk.md Part 3): calls of one message reach their
3566
+ // hooks at once. This one waits for it, so it meets whatever that request
3567
+ // armed — a direct plan whose first call is not `run_scenario` is vetoed
3568
+ // just below — and never opens the fragment beside it.
3569
+ const attempt = run.fragment?.attempt;
3570
+ if (attempt)
3571
+ await this.withinBudget(attempt, CONTINUATION_PROBE_MS + CONTINUATION_ARM_MS);
3572
+ // A continuation armed beside another call of this message is told on
3573
+ // that call's PostToolUse. A call to one of its own tools must not run
3574
+ // before then — the plan makes that call itself — so it is answered in
3575
+ // its place, and that answer tells the model of the plan.
3576
+ const told = planning ? undefined : this.announceContinuationInstead(run, toolName);
3577
+ if (told)
3578
+ return told;
3479
3579
  const live = this.livePlan(run);
3480
3580
  /** `run_scenario` for this session's own announced direct plan: allowed, see below. */
3481
3581
  let allowDirect = false;
@@ -3675,7 +3775,46 @@ export class ControlServer {
3675
3775
  return {};
3676
3776
  });
3677
3777
  }
3678
- const armed = planning
3778
+ // 2a. The model's first call after a plan handed over: the work recorded
3779
+ // after an earlier hand-over at the same step may have a plan of its
3780
+ // own (the service's fallbk.md Part 3). Asked with the hand-over, not
3781
+ // with words, and armed on this call, whatever the call is.
3782
+ const before = run.replay;
3783
+ const continued = planning
3784
+ ? undefined
3785
+ : await this.tryContinuation(session, run, toolName, intent, modelArgs);
3786
+ if (continued) {
3787
+ if (!overran)
3788
+ return continued;
3789
+ // The deadline answered first: the call ran as the model wrote it, and
3790
+ // the plan was never announced. The model's own work from this call is
3791
+ // the fragment, as it would have been with no plan.
3792
+ const late = run.replay;
3793
+ if (late && late !== before && late.continuation) {
3794
+ logLine("replay.decision", {
3795
+ verdict: "no-steer",
3796
+ code: "arm_unannounced",
3797
+ run: run.runId,
3798
+ scenario: late.scenarioId ?? undefined,
3799
+ why: "the call had to go ahead before this continuation's directive could replace it — retired unannounced, so never run",
3800
+ });
3801
+ const at = run.replays.indexOf(late);
3802
+ if (at >= 0)
3803
+ run.replays.splice(at, 1);
3804
+ run.replay = before;
3805
+ this.keepDeclinedContinuation(session, run, late, "arm_unannounced");
3806
+ if (run.parkedFragment?.by === late) {
3807
+ run.fragment = run.parkedFragment.fragment;
3808
+ run.parkedFragment = undefined;
3809
+ }
3810
+ }
3811
+ }
3812
+ // A continuation that retired having run no step — refused, vetoed, or
3813
+ // left by the model — gives the model's work back to the fragment.
3814
+ this.restoreParkedFragment(run);
3815
+ // A continuation armed beside this call is this turn's plan, waiting for
3816
+ // the call's PostToolUse: no other plan is looked for in its place.
3817
+ const armed = planning || continued || run.replay !== before
3679
3818
  ? undefined
3680
3819
  : await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
3681
3820
  if (armed) {
@@ -3696,8 +3835,9 @@ export class ControlServer {
3696
3835
  }
3697
3836
  // 3. No scenario took over: the model's own work after a hand-over is
3698
3837
  // recorded as a fragment, starting with this call.
3699
- // Not for the call an intent match just armed a plan beside: the plan
3700
- // runs next, and the fragment opens on the model's own work after it.
3838
+ // Not for the call a continuation or an intent match just armed a plan
3839
+ // beside: the plan runs next, and the fragment opens on the model's
3840
+ // own work after it.
3701
3841
  if (run.fragment?.state === "due" && !run.pendingDirective) {
3702
3842
  await this.openFragment(session, run, reasoning, toolName, modelArgs);
3703
3843
  }
@@ -4327,9 +4467,7 @@ export class ControlServer {
4327
4467
  run.intent.requests += 1;
4328
4468
  // Every scenario armed this turn, so a continuation cannot re-arm the plan
4329
4469
  // that just handed over.
4330
- const exclude = run.replays
4331
- .map((s) => s.scenarioId)
4332
- .filter((id) => typeof id === "string");
4470
+ const exclude = this.armedScenarioIds(run);
4333
4471
  const startedAt = Date.now();
4334
4472
  const framework = frameworkOf(session);
4335
4473
  const probe = this.recorder
@@ -4441,6 +4579,356 @@ export class ControlServer {
4441
4579
  run.pendingDirective = { state, text: directive };
4442
4580
  return undefined;
4443
4581
  }
4582
+ /**
4583
+ * A continuation (the service's fallbk.md Part 3), on the model's first call
4584
+ * after a plan handed over.
4585
+ *
4586
+ * The model's work after an earlier hand-over of the same plan at the same
4587
+ * step was recorded once, as a fragment, and the service may have calculated
4588
+ * it into a plan. Until 0.3.23 nothing asked for that plan: the only way a
4589
+ * plan started mid-task was a match of the model's words against plan
4590
+ * intents ({@link tryIntentMatch}, off by default), and a one-line note
4591
+ * after a hand-over does not read like a plan's intent paragraph. Here the
4592
+ * hand-over itself is the question — the plan and the step — so the service
4593
+ * names one recording and matches no words.
4594
+ *
4595
+ * It arms on the model's first call after the hand-over, whatever that call
4596
+ * is (Eran, 2026-10-26). Runner 0.3.23 armed it only when the call was the
4597
+ * plan's first step, and lost it whenever the model first re-checked what
4598
+ * the plan before had already done. How the model is told depends on the
4599
+ * call. A call to one of the plan's own tools is answered in its place: the
4600
+ * plan makes that call itself, and letting it run would make it twice. Any
4601
+ * other call runs as the model wrote it, and the plan is told on its
4602
+ * PostToolUse, as an intent plan armed beside the model's own work is. The
4603
+ * plan before it stopped because the next step needed a judgement; the
4604
+ * continuation runs the half that was recorded, whatever the model would
4605
+ * have judged.
4606
+ *
4607
+ * Its values are read from the turn's own request — the rest of the task is
4608
+ * the one the user asked for — with the model's words, the call it was about
4609
+ * to make and this turn's tool results beside it, as for any plan started at
4610
+ * a tool call.
4611
+ *
4612
+ * Asked once per hand-over, waited on {@link CONTINUATION_PROBE_MS} and
4613
+ * {@link CONTINUATION_ARM_MS} at most, and not at all while the service is
4614
+ * failing. Every way it does not arm leaves the turn as it was: the model's
4615
+ * call runs, and its work is recorded as the fragment. A plan handed out and
4616
+ * not armed is reported for its ticket, with the reason.
4617
+ */
4618
+ async tryContinuation(session, run, toolName, intent, modelArgs) {
4619
+ const f = run.fragment;
4620
+ if (!f || f.state !== "due" || f.asked)
4621
+ return undefined;
4622
+ if (!this.replay.enabled || !this.replay.intentMatch.enabled || !this.continuationsOn())
4623
+ return undefined;
4624
+ // Never in a session of another host: a plan is started there by the
4625
+ // prompt, and stepped through by an adapter that was told of it then.
4626
+ if (otherHost(session) || this.recordsOnly(session, run))
4627
+ return undefined;
4628
+ if (!isIntentMatcher(this.recorder))
4629
+ return undefined;
4630
+ // The model calling the delivery tool itself is no step of a plan.
4631
+ if (this.replay.isDirectTool(toolName))
4632
+ return undefined;
4633
+ if (!this.service.canWait()) {
4634
+ logDetail("replay.continuation_skipped", { run: run.runId, tool: toolName, why: "service unhealthy" });
4635
+ return undefined;
4636
+ }
4637
+ f.asked = true;
4638
+ // Calls of one message reach their hooks at once. The others wait for
4639
+ // this attempt (`onToolPre`), so they meet whatever it armed, and none
4640
+ // opens the fragment while it is still out.
4641
+ let done;
4642
+ f.attempt = new Promise((resolve) => {
4643
+ done = resolve;
4644
+ });
4645
+ try {
4646
+ return await this.continuationAttempt(session, run, f, toolName, intent, modelArgs);
4647
+ }
4648
+ finally {
4649
+ f.attempt = undefined;
4650
+ done();
4651
+ }
4652
+ }
4653
+ /** The body of {@link tryContinuation}, once the continuation is asked for. */
4654
+ async continuationAttempt(session, run, f, toolName, intent, modelArgs) {
4655
+ const recorder = this.recorder;
4656
+ if (!isIntentMatcher(recorder))
4657
+ return undefined;
4658
+ const framework = frameworkOf(session);
4659
+ const handover = {
4660
+ scenarioId: f.scenarioId,
4661
+ stepIndex: f.stepIndex,
4662
+ ...(f.kind ? { kind: f.kind } : {}),
4663
+ ...(f.handoverId ? { id: f.handoverId } : {}),
4664
+ };
4665
+ // Whether this hand-over is still the turn's: its fragment due, nothing
4666
+ // recording, the turn open. Checked after every wait.
4667
+ const stillDue = () => run.fragment === f && f.state === "due" && !run.recording && !run.finished;
4668
+ let landed = false;
4669
+ const probe = recorder
4670
+ .matchIntent({
4671
+ // Answered by the hand-over alone, so no words go: a service from
4672
+ // before continuations would match them against plan intents, and
4673
+ // the agent's reasoning is not sent for nothing.
4674
+ text: "",
4675
+ currentToolName: toolName,
4676
+ exclude: this.armedScenarioIds(run),
4677
+ handover,
4678
+ ...(framework ? { framework } : {}),
4679
+ }, { timeoutMs: CONTINUATION_REQUEST_MS })
4680
+ .catch(() => null)
4681
+ .then((answer) => {
4682
+ landed = true;
4683
+ return answer;
4684
+ });
4685
+ const answer = await this.withinBudget(probe, CONTINUATION_PROBE_MS);
4686
+ if (!answer) {
4687
+ // A plan a late answer hands out is reported as late, for its ticket.
4688
+ // Whether the hand-over was counted is the service's to know: the
4689
+ // fragment's create call carries the same id.
4690
+ if (!landed)
4691
+ void probe.then((late) => this.noteLateContinuation(run, f, late));
4692
+ logDetail("replay.continuation_miss", {
4693
+ run: run.runId,
4694
+ tool: toolName,
4695
+ why: landed ? "the request failed" : "no answer in time",
4696
+ });
4697
+ return undefined;
4698
+ }
4699
+ // Only a service that knows continuations answers one. An older service
4700
+ // matched the text by words instead, and that is no continuation.
4701
+ if (answer.continuation === undefined)
4702
+ return undefined;
4703
+ f.counted = answer.continuation?.counted === true;
4704
+ const match = answer.matched;
4705
+ if (!answer.continuation || !match) {
4706
+ logDetail("replay.continuation_none", {
4707
+ run: run.runId,
4708
+ continues: `${f.scenarioId}@${f.stepIndex}`,
4709
+ recording: answer.continuation?.runId,
4710
+ why: answer.continuation
4711
+ ? "the work recorded after an earlier hand-over here has no plan to hand out"
4712
+ : "no hand-over at this step was recorded before",
4713
+ });
4714
+ return undefined;
4715
+ }
4716
+ const continuation = {
4717
+ ofScenarioId: f.scenarioId,
4718
+ stepIndex: f.stepIndex,
4719
+ runId: answer.continuation.runId,
4720
+ };
4721
+ if (!stillDue()) {
4722
+ this.keepDeclinedContinuation(session, run, this.replay.declinedContinuation(match, "match_late", continuation));
4723
+ return undefined;
4724
+ }
4725
+ const startedAt = Date.now();
4726
+ // Valid JSON with the serializer's own truncation marker, never a raw
4727
+ // slice: the live call handed to the reading.
4728
+ const toolInput = serializeCapped(redact(modelArgs), {
4729
+ maxPayloadBytes: 2048,
4730
+ maxStringBytes: 256,
4731
+ });
4732
+ // The rest of the task the user asked for: its values are in the turn's
4733
+ // request, and the model's words say what it is doing next.
4734
+ const words = (intent?.text ?? "").trim().slice(0, 4_000);
4735
+ const prompt = [run.input, words].filter((s) => s.trim().length > 0).join("\n\n");
4736
+ const arming = this.armOrLog(run, this.replay.arm(match, prompt, this.wrapped, "intent", {
4737
+ liveCall: { toolName, toolInput },
4738
+ recentResults: this.recentResultsFor(session, "call"),
4739
+ agentDay: this.agentDayFor(session),
4740
+ }, undefined, { continuation }));
4741
+ const state = await this.withinBudget(arming, CONTINUATION_ARM_MS);
4742
+ if (state === undefined)
4743
+ return undefined; // it threw, and said so
4744
+ if (state === null) {
4745
+ logLine("replay.decision", {
4746
+ verdict: "no-steer",
4747
+ code: "arm_timeout",
4748
+ run: run.runId,
4749
+ scenario: match.scenarioId ?? undefined,
4750
+ continues: `${f.scenarioId}@${f.stepIndex}`,
4751
+ ms: CONTINUATION_ARM_MS,
4752
+ why: "the continuation's values were not read before this call had to go ahead — never delivered, so never run",
4753
+ });
4754
+ void arming.then((late) => {
4755
+ if (late)
4756
+ this.keepDeclinedContinuation(session, run, late, "arm_timeout");
4757
+ });
4758
+ return undefined;
4759
+ }
4760
+ state.sessionId = session.sessionId;
4761
+ state.sessionCwd = session.cwd ?? this.opts.cwd;
4762
+ this.declineWithoutDeliveryTool(state);
4763
+ if (!state.plan) {
4764
+ // A gate declined it, and said why (`state.declined`).
4765
+ this.keepDeclinedContinuation(session, run, state);
4766
+ return undefined;
4767
+ }
4768
+ // Its values, worked out before the model is told of it: a reading that
4769
+ // did not settle in time, or the plan's own code for its values failing,
4770
+ // is said as what it is, and the plan is never handed out.
4771
+ let paramsFailed = false;
4772
+ const settled = await this.withinBudget(state.plan.ready().then(() => true, () => {
4773
+ paramsFailed = true;
4774
+ return false;
4775
+ }), Math.max(0, CONTINUATION_ARM_MS - (Date.now() - startedAt)));
4776
+ if (!stillDue()) {
4777
+ this.keepDeclinedContinuation(session, run, state, "arm_unannounced");
4778
+ return undefined;
4779
+ }
4780
+ if (settled === null) {
4781
+ this.keepDeclinedContinuation(session, run, state, "arm_timeout");
4782
+ return undefined;
4783
+ }
4784
+ if (paramsFailed) {
4785
+ this.keepDeclinedContinuation(session, run, state, "params_failed");
4786
+ return undefined;
4787
+ }
4788
+ // Whatever the model's call is, the plan takes the rest of the task. A
4789
+ // call to one of its own tools is answered in its place: the plan makes
4790
+ // that call itself, and letting it run first would make it twice — a
4791
+ // duplicated write. Any other call (a re-check of what the plan before
4792
+ // did, say) runs as the model wrote it, and the plan is told after it.
4793
+ const inPlace = state.plan.usesTool(toolName);
4794
+ state.usageMark = markTranscriptUsage(session.transcriptPath);
4795
+ state.markUsage = () => markTranscriptUsage(session.transcriptPath);
4796
+ run.replays.push(state);
4797
+ run.replay = state;
4798
+ // From here the continuation is this turn's plan. A hand-over of its own,
4799
+ // if it makes one, schedules its own fragment. The one the plan before it
4800
+ // left due is parked, and put back should this plan run no step.
4801
+ run.parkedFragment = { by: state, fragment: f };
4802
+ run.fragment = undefined;
4803
+ const directive = this.replay.directiveFor(state) ?? "";
4804
+ logLine("replay.continuation_armed", {
4805
+ run: run.runId,
4806
+ scenario: state.scenarioId ?? undefined,
4807
+ continues: `${continuation.ofScenarioId}@${continuation.stepIndex}`,
4808
+ recording: continuation.runId,
4809
+ mode: state.mode,
4810
+ instead: inPlace ? toolName : undefined,
4811
+ after: inPlace ? undefined : toolName,
4812
+ ms: Date.now() - startedAt,
4813
+ why: inPlace
4814
+ ? "the plan calculated from the work recorded after an earlier hand-over at this step takes the rest of the task, in place of the model's call to one of its own tools"
4815
+ : "the plan calculated from the work recorded after an earlier hand-over at this step takes the rest of the task after the model's call, which runs as written",
4816
+ });
4817
+ if (inPlace)
4818
+ return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
4819
+ run.pendingDirective = { state, text: directive };
4820
+ return undefined;
4821
+ }
4822
+ /**
4823
+ * A call meeting a continuation that armed beside another call of the same
4824
+ * message and still waits to be told (`pendingDirective`). A call to one of
4825
+ * the plan's own tools gets the plan's directive in its place: letting it
4826
+ * run would have the plan make the same call again — a duplicated write.
4827
+ * Any other call runs, and the plan is told on the first PostToolUse.
4828
+ */
4829
+ announceContinuationInstead(run, toolName) {
4830
+ const pending = run.pendingDirective;
4831
+ const state = pending?.state;
4832
+ if (!pending || !state || state !== run.replay || !state.continuation)
4833
+ return undefined;
4834
+ if (!state.plan || state.retired || !pending.text || !state.plan.usesTool(toolName))
4835
+ return undefined;
4836
+ run.pendingDirective = undefined;
4837
+ logLine("replay.continuation_told", {
4838
+ run: run.runId,
4839
+ scenario: state.scenarioId ?? undefined,
4840
+ instead: toolName,
4841
+ why: "a call to one of the continuation's own tools, made beside the call it armed on — answered in its place, so the plan does not make it twice",
4842
+ });
4843
+ return this.preToolAnswer(this.replay.deliverInstead(pending.text, toolName));
4844
+ }
4845
+ /**
4846
+ * Put back the fragment a continuation took the place of, when that
4847
+ * continuation retired having run no step and handed nothing over —
4848
+ * `run_scenario` refused, the plan vetoed, or a steer pin left by the
4849
+ * model. The model is doing the rest of the task itself, and that work is
4850
+ * recorded from its next call, as it would have been with no continuation.
4851
+ */
4852
+ restoreParkedFragment(run) {
4853
+ const parked = run.parkedFragment;
4854
+ if (!parked || run.fragment || run.recording || run.finished)
4855
+ return;
4856
+ const s = parked.by;
4857
+ if (!s.retired || s.stepsPinned > 0 || s.handover || s.ranToEnd || s.running || this.runningPlans.has(s))
4858
+ return;
4859
+ run.parkedFragment = undefined;
4860
+ run.fragment = parked.fragment;
4861
+ // Its report says why, for its ticket: told of the plan, the model never
4862
+ // asked for it — another call of the same message came first, or it chose
4863
+ // its own way after the directive.
4864
+ if (!s.declined && !s.directRequested)
4865
+ s.declined = "not_taken";
4866
+ logLine("replay.continuation_unused", {
4867
+ run: run.runId,
4868
+ scenario: s.scenarioId ?? undefined,
4869
+ why: "the continuation retired without running a step — the model's own work is recorded from this call, as before",
4870
+ });
4871
+ }
4872
+ /**
4873
+ * A continuation handed out and not armed: kept, retired, for its ticket's
4874
+ * report at the seal (`not_steered`, with the reason, unmeasured). `code`
4875
+ * says why when no gate did.
4876
+ */
4877
+ keepDeclinedContinuation(session, run, state, code) {
4878
+ if (code) {
4879
+ state.declined = code;
4880
+ logLine("replay.decision", {
4881
+ verdict: "no-steer",
4882
+ code,
4883
+ run: run.runId,
4884
+ scenario: state.scenarioId ?? undefined,
4885
+ continues: state.continuation
4886
+ ? `${state.continuation.ofScenarioId}@${state.continuation.stepIndex}`
4887
+ : undefined,
4888
+ why: code === "params_failed"
4889
+ ? "the continuation's own code for its values failed — never handed out, so never run"
4890
+ : "the continuation was not armed in time — never handed out, so never run",
4891
+ });
4892
+ }
4893
+ state.sessionId = session.sessionId;
4894
+ state.plan = undefined;
4895
+ state.mode = "none";
4896
+ state.stepsPlanned = 0;
4897
+ state.retired = true;
4898
+ state.pinned.clear();
4899
+ // A seal has reported already: nothing more can be said for this turn.
4900
+ if (run.finished)
4901
+ return;
4902
+ (run.declinedContinuations ??= []).push(state);
4903
+ }
4904
+ /** A continuation request's answer that came after the call it was for went ahead. */
4905
+ noteLateContinuation(run, handover, late) {
4906
+ const match = late?.matched;
4907
+ if (!late?.continuation || !match?.executionTicket || run.finished)
4908
+ return;
4909
+ const state = this.replay.declinedContinuation(match, "match_late", {
4910
+ ofScenarioId: handover.scenarioId,
4911
+ stepIndex: handover.stepIndex,
4912
+ runId: late.continuation.runId,
4913
+ });
4914
+ (run.declinedContinuations ??= []).push(state);
4915
+ }
4916
+ /**
4917
+ * Every scenario this turn was handed and armed or declined: never handed
4918
+ * out again in it. A plan that handed over is among them, so its own
4919
+ * continuation request can never return it.
4920
+ */
4921
+ armedScenarioIds(run) {
4922
+ return [...run.replays, ...(run.declinedContinuations ?? [])]
4923
+ .map((s) => s.scenarioId)
4924
+ .filter((id) => typeof id === "string");
4925
+ }
4926
+ /** Whether continuations are asked for: on unless `BIR_CONTINUATIONS=0`. */
4927
+ continuationsOn() {
4928
+ if (this.opts.continuations !== undefined)
4929
+ return this.opts.continuations;
4930
+ return process.env.BIR_CONTINUATIONS !== "0";
4931
+ }
4444
4932
  /**
4445
4933
  * Whether the model's call is plausibly `state`'s first step: its tool, and
4446
4934
  * arguments the pin guard would pin to that step's input. Anything that
@@ -4501,7 +4989,15 @@ export class ControlServer {
4501
4989
  const h = state?.handover;
4502
4990
  if (!h || !state?.scenarioId)
4503
4991
  return;
4504
- run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
4992
+ run.fragment = {
4993
+ scenarioId: state.scenarioId,
4994
+ stepIndex: h.stepIndex,
4995
+ state: "due",
4996
+ // Which half the model's work after it is (the service's fallbk.md
4997
+ // Part 3), and the id that makes the hand-over one hit.
4998
+ kind: h.kind,
4999
+ handoverId: `ho_${randomUUID()}`,
5000
+ };
4505
5001
  }
4506
5002
  /**
4507
5003
  * Open the fragment run and record from this call on (fallbk.md D7).
@@ -4521,9 +5017,18 @@ export class ControlServer {
4521
5017
  return;
4522
5018
  const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
4523
5019
  const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
5020
+ // Which half of a step's test this work is, and the hand-over's id, which
5021
+ // its continuation request carried too: the service counts the hand-over
5022
+ // once (Part 3). `counted` says the same to a service from before ids.
5023
+ const sent = {
5024
+ ...fallbackOf,
5025
+ ...(f.kind ? { kind: f.kind } : {}),
5026
+ ...(f.handoverId ? { handoverId: f.handoverId } : {}),
5027
+ ...(f.counted ? { counted: true } : {}),
5028
+ };
4524
5029
  // The fragment is a run of the same host as the turn it continues.
4525
5030
  const framework = frameworkOf(session);
4526
- const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf, framework } : { fallbackOf });
5031
+ const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, framework ? { fallbackOf: sent, framework } : { fallbackOf: sent });
4527
5032
  for (const c of run.correlations.values()) {
4528
5033
  c.settled = true;
4529
5034
  if (c.fallback)
@@ -7,9 +7,20 @@
7
7
  */
8
8
  import type { ExecutionStage, ExecutionStepResult, FallbackKind } from "../replay/types.js";
9
9
  export interface StartRunOptions {
10
+ /**
11
+ * Marks a *fragment* (fallbk.md D7). `kind`: the kind of hand-over, so the
12
+ * other half of a step's test gets a fragment of its own (Part 3).
13
+ * `handoverId`: the hand-over's id, which its continuation request carried
14
+ * too, so the service counts the hand-over once. `counted`: the same for a
15
+ * service from before ids — it counted the hand-over when asked for its
16
+ * continuation.
17
+ */
10
18
  fallbackOf?: {
11
19
  scenarioId: string;
12
20
  stepIndex: number;
21
+ kind?: string;
22
+ handoverId?: string;
23
+ counted?: boolean;
13
24
  };
14
25
  /**
15
26
  * Marks a *repeat* (the service's newestRun.md): a matched turn whose plan
@@ -287,7 +298,12 @@ export interface ServiceProber {
287
298
  export declare function isServiceProber(r: Recorder): r is Recorder & ServiceProber;
288
299
  /** What a ReAct iteration sends to find a scenario by its intent (fallbk.md §Runner 4). */
289
300
  export interface IntentMatchRequest {
290
- /** What the agent wrote, or was thinking, as it chose the call. Never empty. */
301
+ /**
302
+ * What the agent wrote, or was thinking, as it chose the call. Never empty
303
+ * on an intent probe. A continuation request ({@link handover}) is answered
304
+ * by the hand-over alone: it sends the text, possibly empty, and the service
305
+ * does not read it.
306
+ */
291
307
  text: string;
292
308
  currentToolName?: string;
293
309
  /** Scenario ids already armed this turn. */
@@ -298,12 +314,37 @@ export interface IntentMatchRequest {
298
314
  * what a missing value means to the service.
299
315
  */
300
316
  framework?: string;
317
+ /**
318
+ * A continuation request (the service's fallbk.md Part 3): the plan that
319
+ * just handed the task to the model, and the step it stopped in front of.
320
+ * The service answers with the plan of the work recorded after an earlier
321
+ * hand-over at that step, if there is one, and matches no words. `kind`:
322
+ * the kind of hand-over (the other half of a step's test is continued only
323
+ * by the other half's recording); `id`: the hand-over's id, which its
324
+ * fragment's create call carries too, so the hand-over counts once.
325
+ */
326
+ handover?: {
327
+ scenarioId: string;
328
+ stepIndex: number;
329
+ kind?: string;
330
+ id?: string;
331
+ };
301
332
  }
302
333
  /** The answer to a probe. */
303
334
  export interface IntentMatchAnswer {
304
335
  matched: RunMatch | null;
305
336
  /** The best score the service saw, matched or not — for the log, to tune by. */
306
337
  bestSimilarity?: number | null;
338
+ /**
339
+ * The answer to a continuation request: the recording of the earlier
340
+ * hand-over at that step, and whether this hand-over was counted as a hit
341
+ * on it. Null when there is none; absent from a service that does not know
342
+ * continuations, which matched the text by words instead.
343
+ */
344
+ continuation?: {
345
+ runId: string;
346
+ counted?: boolean;
347
+ } | null;
307
348
  }
308
349
  /** How long a probe's caller will wait; the request is abandoned past it. */
309
350
  export interface IntentMatchOptions {
@@ -374,6 +415,15 @@ export interface ExecutionReport {
374
415
  * which is what every report said before this field existed.
375
416
  */
376
417
  baselineEligible?: boolean;
418
+ /**
419
+ * Set on a continuation's report (the service's fallbk.md Part 3): the plan
420
+ * it continued in this turn, which handed over in front of it. That plan's
421
+ * report bills the whole turn against its own baseline, which covers this
422
+ * part of the task too, so the service books this one no saving.
423
+ */
424
+ continues?: {
425
+ scenarioId: string;
426
+ };
377
427
  /**
378
428
  * Which gate of the ladder declined, on a `not_steered` report.
379
429
  *
@@ -683,6 +683,9 @@ export class RemoteRecorder {
683
683
  return {
684
684
  matched: b.matched ?? null,
685
685
  ...(typeof b.bestSimilarity === "number" ? { bestSimilarity: b.bestSimilarity } : {}),
686
+ // Null and absent mean different things: no continuation, or a
687
+ // service that does not know continuations (fallbk.md Part 3).
688
+ ...(b.continuation !== undefined ? { continuation: b.continuation } : {}),
686
689
  };
687
690
  }
688
691
  catch (err) {
@@ -1040,6 +1043,9 @@ export class RemoteRecorder {
1040
1043
  fallbackStepIndex: report.fallbackStepIndex,
1041
1044
  fallbackKind: report.fallbackKind,
1042
1045
  baselineEligible: report.baselineEligible,
1046
+ // A continuation's report: the plan it continued, whose report
1047
+ // books the turn (the service's fallbk.md Part 3). Additive.
1048
+ continues: report.continues,
1043
1049
  // Why nothing ran, when nothing ran. Additive: an older service drops
1044
1050
  // the field and books the cost exactly as it always did.
1045
1051
  declined: report.declined,
@@ -1070,6 +1076,8 @@ export class RemoteRecorder {
1070
1076
  stepsFailed: failed || undefined,
1071
1077
  baselineEligible: report.baselineEligible === false ? false : undefined,
1072
1078
  declined: report.declined,
1079
+ // A continuation's report: the plan whose report books the turn.
1080
+ continues: report.continues?.scenarioId,
1073
1081
  // `bir investigate` prints it beside the outcome: "redone by run_…".
1074
1082
  redoneBy: report.redoneBy?.runId,
1075
1083
  ownCalls: report.redoneBy?.ownCalls,
@@ -388,6 +388,16 @@ export interface ReplayState {
388
388
  retired: boolean;
389
389
  /** What armed it: the prompt, or a ReAct iteration's intent (fallbk.md). */
390
390
  armedBy: "prompt" | "intent";
391
+ /**
392
+ * Set on a continuation (the service's fallbk.md Part 3): a plan armed
393
+ * mid-turn because the plan before it handed over at a step, and the work
394
+ * recorded after an earlier hand-over at that step was calculated into this
395
+ * plan. `armedBy` is `intent` — it arms mid-turn, and is billed for its own
396
+ * window like any plan armed there — but it finishes the user's request
397
+ * rather than a part of the agent's own task, and its report books no
398
+ * saving: the plan it continued books the whole turn.
399
+ */
400
+ continuation?: ContinuationOf;
391
401
  /**
392
402
  * The Claude Code session this plan serves, set by the control server. Its
393
403
  * steps run on that session's own proxies (executor.ts). Absent for `bir
@@ -596,7 +606,41 @@ export type DeclineCode = "replay_disabled" | "not_ready" | "known_bad_first_ste
596
606
  * the prompt had been answered, or while prompts were not waiting on it
597
607
  * (the match gate). Set by the control server, like the two above.
598
608
  */
599
- | "match_late";
609
+ | "match_late"
610
+ /**
611
+ * A continuation (the service's fallbk.md Part 3) armed after the hand-over
612
+ * and told to the model, which never ran it: `run_scenario` was never asked
613
+ * for — another call of the same message came first, the model did the
614
+ * rest its own way, or the call was refused. Set by the control server.
615
+ * (Runner 0.3.23 armed a continuation only when the model's call was its
616
+ * first step and declined it otherwise as `not_first_step`. From 0.3.24 it
617
+ * arms on the model's first call after the hand-over, whatever that is.)
618
+ */
619
+ | "not_taken"
620
+ /**
621
+ * A continuation whose own code for working out its values (`paramsLogic`)
622
+ * failed on this request, so none of its steps could be computed. Set by
623
+ * the control server.
624
+ */
625
+ | "params_failed";
626
+ /**
627
+ * What a continuation continues (the service's fallbk.md Part 3): the plan
628
+ * that handed over, the step it stopped in front of, and the recording of the
629
+ * work done after an earlier hand-over there, which this plan was calculated
630
+ * from.
631
+ */
632
+ export interface ContinuationOf {
633
+ ofScenarioId: string;
634
+ stepIndex: number;
635
+ runId: string;
636
+ }
637
+ /**
638
+ * Whether a plan is a part of the task the agent is already doing — armed
639
+ * mid-turn by its intent — so its words say "continue" and never "answer the
640
+ * user". A continuation is armed mid-turn too, and it is the rest of the
641
+ * user's request: it ends with the answer.
642
+ */
643
+ export declare function isSubTask(state: Pick<ReplayState, "armedBy" | "continuation">): boolean;
600
644
  /** What `PreToolUse` should do about this call. */
601
645
  export type PreToolAction =
602
646
  /** Pin the arguments and let the real tool run. */
@@ -674,6 +718,13 @@ export declare class ReplayController {
674
718
  * itself as one that never reported.
675
719
  */
676
720
  lateMatch(match: RunMatchLike, host?: HostContext): ReplayState;
721
+ /**
722
+ * The state of a continuation handed out and not armed (the service's
723
+ * fallbk.md Part 3): declined before any gate ran — the service's answer
724
+ * came too late (`match_late`). Like {@link lateMatch}, it exists only for
725
+ * its ticket's report, with the reason.
726
+ */
727
+ declinedContinuation(match: RunMatchLike, code: DeclineCode, continuation: ContinuationOf): ReplayState;
677
728
  /**
678
729
  * Run the gate ladder and, if every gate passes, arm a plan.
679
730
  *
@@ -682,9 +733,13 @@ export declare class ReplayController {
682
733
  * up with a denominator nobody measured.
683
734
  *
684
735
  * `host` is given for a session whose host is not Claude Code, and is kept
685
- * on the state (see {@link HostContext}).
736
+ * on the state (see {@link HostContext}). `extra.continuation` arms a
737
+ * continuation (the service's fallbk.md Part 3): see
738
+ * {@link ReplayState.continuation}.
686
739
  */
687
- arm(match: RunMatchLike, prompt: string, wrapped: ReadonlySet<string>, armedBy?: "prompt" | "intent", ctx?: DeriveContext, host?: HostContext): Promise<ReplayState>;
740
+ arm(match: RunMatchLike, prompt: string, wrapped: ReadonlySet<string>, armedBy?: "prompt" | "intent", ctx?: DeriveContext, host?: HostContext, extra?: {
741
+ continuation?: ContinuationOf;
742
+ }): Promise<ReplayState>;
688
743
  /**
689
744
  * The armed plan as data: the steps it will run, in order, and where it
690
745
  * stops when that is before its end. For a host that steps through the plan
@@ -960,6 +1015,19 @@ export declare class ReplayController {
960
1015
  runId: string;
961
1016
  ownCalls: number;
962
1017
  };
1018
+ /**
1019
+ * What the continuations of this plan spent reading their values this
1020
+ * turn (the service's fallbk.md Part 3). Booked here, on the plan whose
1021
+ * report bills the whole turn: theirs book no saving, so a cost left on
1022
+ * them would be left out of the turn's saving.
1023
+ */
1024
+ chainDeriveUsd?: number;
1025
+ /**
1026
+ * A continuation whose plan before it ran no step (its report is
1027
+ * `failed`, which books no saving): this one books its own, against its
1028
+ * own baseline, so the turn's saving is booked somewhere.
1029
+ */
1030
+ booksOwnSaving?: boolean;
963
1031
  }): ExecutionReport | undefined;
964
1032
  /**
965
1033
  * The step a failed replay is fairly blamed on: the first whose own logic
@@ -196,6 +196,15 @@ const MAX_STEP_ERROR_CHARS = 500;
196
196
  * cannot hold more than a few megabytes in the recorder.
197
197
  */
198
198
  const TURN_CALLS_MAX_CHARS = 8 * 1024 * 1024;
199
+ /**
200
+ * Whether a plan is a part of the task the agent is already doing — armed
201
+ * mid-turn by its intent — so its words say "continue" and never "answer the
202
+ * user". A continuation is armed mid-turn too, and it is the rest of the
203
+ * user's request: it ends with the answer.
204
+ */
205
+ export function isSubTask(state) {
206
+ return state.armedBy === "intent" && state.continuation === undefined;
207
+ }
199
208
  export class ReplayController {
200
209
  enabled;
201
210
  work = new ProxyWorkQueue();
@@ -365,6 +374,27 @@ export class ReplayController {
365
374
  state.retired = true;
366
375
  return state;
367
376
  }
377
+ /**
378
+ * The state of a continuation handed out and not armed (the service's
379
+ * fallbk.md Part 3): declined before any gate ran — the service's answer
380
+ * came too late (`match_late`). Like {@link lateMatch}, it exists only for
381
+ * its ticket's report, with the reason.
382
+ */
383
+ declinedContinuation(match, code, continuation) {
384
+ const state = this.blankState(match, new Set(), "intent");
385
+ state.continuation = continuation;
386
+ state.declined = code;
387
+ state.retired = true;
388
+ logLine("replay.decision", {
389
+ verdict: "no-steer",
390
+ code,
391
+ run: match.runId,
392
+ scenario: match.scenarioId ?? undefined,
393
+ continues: `${continuation.ofScenarioId}@${continuation.stepIndex}`,
394
+ why: "the continuation came after the call it could have replaced had gone ahead",
395
+ });
396
+ return state;
397
+ }
368
398
  /**
369
399
  * Run the gate ladder and, if every gate passes, arm a plan.
370
400
  *
@@ -373,10 +403,14 @@ export class ReplayController {
373
403
  * up with a denominator nobody measured.
374
404
  *
375
405
  * `host` is given for a session whose host is not Claude Code, and is kept
376
- * on the state (see {@link HostContext}).
406
+ * on the state (see {@link HostContext}). `extra.continuation` arms a
407
+ * continuation (the service's fallbk.md Part 3): see
408
+ * {@link ReplayState.continuation}.
377
409
  */
378
- async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host) {
410
+ async arm(match, prompt, wrapped, armedBy = "prompt", ctx = {}, host, extra = {}) {
379
411
  const state = this.blankState(match, wrapped, armedBy, host);
412
+ if (extra.continuation)
413
+ state.continuation = extra.continuation;
380
414
  const decline = (why, code) => {
381
415
  state.declined = code;
382
416
  logLine("replay.decision", {
@@ -597,12 +631,13 @@ export class ReplayController {
597
631
  }
598
632
  // A plan armed by intent runs inside a task the agent is already doing, so
599
633
  // its directive and its bundles say "continue", never "answer the user".
634
+ // A continuation is the rest of the user's request: it ends with the answer.
600
635
  state.plan = new ScenarioReplayPlan({
601
636
  scenario,
602
637
  params,
603
638
  mode,
604
639
  stopAt,
605
- subTask: armedBy === "intent",
640
+ subTask: isSubTask(state),
606
641
  });
607
642
  if (mode === "steer") {
608
643
  // A steer directive shows the arguments the steps will run with, so the
@@ -643,7 +678,11 @@ export class ReplayController {
643
678
  host: host?.app,
644
679
  steps: steps.length,
645
680
  stopsAt: stopAt,
646
- by: armedBy === "intent" ? "intent" : undefined,
681
+ by: armedBy === "intent" ? (state.continuation ? "continuation" : "intent") : undefined,
682
+ // The plan it continues and the step that plan stopped in front of.
683
+ continues: state.continuation
684
+ ? `${state.continuation.ofScenarioId}@${state.continuation.stepIndex}`
685
+ : undefined,
647
686
  similarity: match.similarity.toFixed(3),
648
687
  // Which steps the service told this plan to stop in front of, and why.
649
688
  stops: stopLines(steps),
@@ -752,7 +791,7 @@ export class ReplayController {
752
791
  directiveFor(state, opts = {}) {
753
792
  const asked = opts.previewMs;
754
793
  return state.plan?.steeringDirective(DIRECT_TOOL_NAME, {
755
- subTask: state.armedBy === "intent",
794
+ subTask: isSubTask(state),
756
795
  // NaN is a caller's arithmetic gone wrong: no time known, no preview.
757
796
  previewBudgetMs: typeof asked === "number"
758
797
  ? Math.max(0, Math.min(Number.isNaN(asked) ? 0 : asked, PREVIEW_BUDGET_MS))
@@ -1499,10 +1538,9 @@ export class ReplayController {
1499
1538
  const what = left.length === 1 ? `Not needed for this request: ${named[0]}${why}.` : `Not needed for this request: ${named.join(", ")}${why}.`;
1500
1539
  let next;
1501
1540
  if (plan.isDone() && !plan.atStop()) {
1502
- next =
1503
- state.armedBy === "intent"
1504
- ? "The sequence is complete: continue your task, calling tools as needed."
1505
- : "The sequence is complete: answer the user's request from the tool results.";
1541
+ next = isSubTask(state)
1542
+ ? "The sequence is complete: continue your task, calling tools as needed."
1543
+ : "The sequence is complete: answer the user's request from the tool results.";
1506
1544
  }
1507
1545
  else {
1508
1546
  const step = plan.currentStep();
@@ -1890,7 +1928,8 @@ export class ReplayController {
1890
1928
  if (state.reported || !state.scenarioId)
1891
1929
  return undefined;
1892
1930
  state.reported = true;
1893
- const total = state.deriveCostUsd + d.sessionCostUsd + state.fallbackCostUsd;
1931
+ const deriveCostUsd = state.deriveCostUsd + Math.max(0, d.chainDeriveUsd ?? 0);
1932
+ const total = deriveCostUsd + d.sessionCostUsd + state.fallbackCostUsd;
1894
1933
  const steps = this.stepResultsOf(state);
1895
1934
  const isBaseline = state.outcome === "not_steered" || state.outcome === "failed";
1896
1935
  // A costless decline is noise on both sides — *unless* it says something the
@@ -1912,7 +1951,7 @@ export class ReplayController {
1912
1951
  runId: d.runId,
1913
1952
  ticket: state.ticket,
1914
1953
  outcome: state.outcome,
1915
- deriveCostUsd: state.deriveCostUsd,
1954
+ deriveCostUsd,
1916
1955
  sessionCostUsd: d.sessionCostUsd,
1917
1956
  fallbackCostUsd: state.fallbackCostUsd,
1918
1957
  durationMs: d.durationMs,
@@ -1955,6 +1994,12 @@ export class ReplayController {
1955
1994
  : undefined,
1956
1995
  // The agent did not take the plan's result and redid the task (AC1).
1957
1996
  ...(d.redoneBy ? { redoneBy: d.redoneBy } : {}),
1997
+ // A continuation (the service's fallbk.md Part 3): the plan it continued
1998
+ // books the whole turn, so this report books no saving of its own —
1999
+ // unless that plan ran nothing and books none.
2000
+ ...(state.continuation && !d.booksOwnSaving
2001
+ ? { continues: { scenarioId: state.continuation.ofScenarioId } }
2002
+ : {}),
1958
2003
  // A turn in which another plan ran is not a measurement of what this task
1959
2004
  // costs unaided, so it is no baseline sample (segmented.md R-MONEY-5).
1960
2005
  // This covers two plans that both armed, and — the case that was silently
@@ -373,6 +373,7 @@ so there is exactly one switch and it cannot get out of step with itself.
373
373
  | `BIR_BASH_ALLOW` | *(unset: any command)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset means any command; `none` means no command, and `Bash` steps are steered in the session. A plan's Bash steps share a working directory, exactly as the host's own Bash tool does — variables and functions do not persist, also as the host's. A call with `run_in_background`, or with its own `timeout` longer than the step's clock (`BIR_STEP_TIMEOUT_MS`), is never started on the runner: the plan hands over in front of it |
374
374
  | `BIR_PATH_ALLOW` | *(unset: any folder, from 0.3.21)* | Folders a runner-side `Read`, `Glob`, `Grep`, `Edit` or `Write` may touch (`*` for all). Unset means any path, as the agent's own tools reach it — a plan that reads the agent's memory file under `~/.claude` runs. `project` is the session's project folder alone (the rule up to 0.3.20); add folders after a comma, `~` for the home folder: `project,~/.claude`. Under a list, a path outside it, symlinks included, is refused and the plan hands over in front of it. `none` means no folder, and the file steps are steered in the session. `Bash` is `BIR_BASH_ALLOW`'s |
375
375
  | `BIR_INTENT_ARM` | *(unset: off)* | `1` lets a whole plan whose intent matches the agent's reasoning mid-turn arm in the calling tool's place, which means waiting for it (`BIR_INTENT_ARM_BUDGET_MS`, 500). Unset or `0`: off — no probe is sent and nothing arms mid-turn, as up to 0.3.18 |
376
+ | `BIR_CONTINUATIONS` | *(unset: on, from 0.3.23)* | After a plan hands the task to the agent at a step, the agent's first call asks the service for the plan of the work recorded after an earlier hand-over at that step — once per hand-over, waiting 1.5 s at most for the answer and up to 7 s for its values — and that plan does the rest of the task, whatever the agent's call was (from 0.3.24; 0.3.23 ran it only when the call was its first step). A call to one of the plan's own tools is answered in its place, so the plan never makes it twice; any other call runs as the agent wrote it, and the agent is told of the plan on its result. Its saving is counted on the plan that handed over. `0`: off, and the agent's work is recorded as before. `BIR_INTENT_MATCH=0` turns it off too |
376
377
  | `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
377
378
 
378
379
  The `bir` server reads its own, from the environment Claude Code starts it with:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",