@basein/runner 0.3.13 → 0.3.15

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.
@@ -1073,6 +1073,46 @@ export declare class ControlServer {
1073
1073
  private openFragment;
1074
1074
  /** What the fragment's create call said: `created: false` is a hit, not a recording. */
1075
1075
  private applyFragmentCreation;
1076
+ /**
1077
+ * Whether this matched turn's task is now the model's own, so its work is
1078
+ * the repeat the service offered to record: offered, nothing recording, no
1079
+ * fragment, and the prompt match's plan done with — retired, not running,
1080
+ * not diverging — and either:
1081
+ *
1082
+ * - it ran no step: the model does the whole task (newestRun.md N1); or
1083
+ * - it ran to its end, and the model, told to answer from its results,
1084
+ * called tools of its own instead (N7). Before 0.3.15 that work was
1085
+ * lost: a model that did not take the plan's result as the answer and
1086
+ * did the task again left nothing to fix the plan from.
1087
+ *
1088
+ * A plan that handed over is the fragment's to record (fallbk.md D7). One
1089
+ * that ran part of the task and stopped without a hand-over records
1090
+ * nothing, as before: the call where it stopped ran while it was still
1091
+ * live, so a run of what came after would lack the step that matters.
1092
+ */
1093
+ private repeatDue;
1094
+ /**
1095
+ * Whether the prompt match's plan ran to its end before the model went on
1096
+ * by itself (newestRun.md N7). False when it ran no step, which is N1.
1097
+ */
1098
+ private redoneAfterPlan;
1099
+ /**
1100
+ * Open the repeat run and record from this call on (newestRun.md N1, N7).
1101
+ *
1102
+ * The same swap as {@link openFragment}: nothing allocated before it belongs
1103
+ * to it. Unlike a fragment it carries the turn's prompt. When no plan did
1104
+ * any of the task, its cost and duration are the whole turn's; after a plan
1105
+ * that ran to its end, they start here, as a fragment's do. Nothing waits
1106
+ * for the service's answer: the steps queue behind the create, and a create
1107
+ * answered `id: null` — the service records no repeat of it — drops them.
1108
+ */
1109
+ private openRepeat;
1110
+ /**
1111
+ * Why the matched turn's plan did not run, as the service shows it: what
1112
+ * the service withheld it for, else the code of the gate that declined it
1113
+ * here, a plan that was late included.
1114
+ */
1115
+ private repeatReason;
1076
1116
  /** Resolve with `p`, or null once `ms` passes or it rejects. */
1077
1117
  private withinBudget;
1078
1118
  /**
@@ -697,6 +697,9 @@ export class ControlServer {
697
697
  }
698
698
  : null,
699
699
  fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
700
+ // The service's offer to record the turn as a recording's newest run,
701
+ // and what became of it (newestRun.md).
702
+ repeat: s.run?.repeat ? { state: s.run.repeat.state, of: s.run.repeat.runId } : null,
700
703
  }));
701
704
  const sending = this.recorderBacklog();
702
705
  return {
@@ -1673,6 +1676,12 @@ export class ControlServer {
1673
1676
  if (!match || run.runId !== runId)
1674
1677
  return;
1675
1678
  run.recording = false;
1679
+ // The service's offer to record the turn after all, should the model end
1680
+ // up doing the task itself (newestRun.md N5). Taken up at the model's
1681
+ // first call of its own, once no plan of this match runs (`repeatDue`).
1682
+ if (match.repeat?.runId && !run.repeat) {
1683
+ run.repeat = { runId: match.repeat.runId, withheld: match.withheld ?? undefined, state: "offered" };
1684
+ }
1676
1685
  logLine("run.matched", {
1677
1686
  run: runId,
1678
1687
  matchedRun: match.runId,
@@ -2283,12 +2292,15 @@ export class ControlServer {
2283
2292
  this.reportExecution(session, run, durationMs, cost, cutShort);
2284
2293
  // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
2285
2294
  // and duration are the model's own work, not the steps the scenario did.
2286
- // Another host hands over one number for the whole run, and it is the
2287
- // fragment's: the plan's own steps made no model call, so all of it was
2288
- // spent after the hand-over.
2295
+ // So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
2296
+ // hands over one number for the whole run, and it is the fragment's: the
2297
+ // plan's own steps made no model call, so all of it was spent after the
2298
+ // hand-over.
2289
2299
  const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
2290
- const recordedCost = fragment && !other ? this.costSince(session, fragment.usageMark) : cost;
2291
- const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
2300
+ const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
2301
+ const ownWork = fragment ?? repeatAfterPlan;
2302
+ const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark) : cost;
2303
+ const recordedMs = ownWork?.startedAtMs ? Date.now() - ownWork.startedAtMs : durationMs;
2292
2304
  if (run.recording) {
2293
2305
  const transcriptPath = session.transcriptPath;
2294
2306
  // The answer is the host's when it handed one over, and the
@@ -2934,6 +2946,12 @@ export class ControlServer {
2934
2946
  this.noteHandover(run);
2935
2947
  return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
2936
2948
  }
2949
+ // The model is doing the matched task itself, and the service asked for
2950
+ // that run to be recorded as the recording's newest (newestRun.md N1).
2951
+ // Opened before the probe below, so the turn is recorded from this call
2952
+ // on like any recording turn — a plan an intent match arms in it too.
2953
+ if (this.repeatDue(run))
2954
+ this.openRepeat(session, run);
2937
2955
  // 2. The model is driving: look for a scenario by this iteration's intent.
2938
2956
  // Never in plan mode, where nothing may run on the user's behalf.
2939
2957
  // If the deadline answers first, it answers `{}`: the call runs as
@@ -3266,6 +3284,9 @@ export class ControlServer {
3266
3284
  });
3267
3285
  }
3268
3286
  this.noteHandover(run);
3287
+ // The model doing the matched task itself, as in Claude Code (newestRun.md N1).
3288
+ if (this.repeatDue(run))
3289
+ this.openRepeat(session, run);
3269
3290
  const words = this.hostWords(payload);
3270
3291
  // Counted, never waited on, and never arming: see `tryIntentMatch`.
3271
3292
  await this.tryIntentMatch(session, run, toolName, words.intent, modelArgs);
@@ -3546,6 +3567,10 @@ export class ControlServer {
3546
3567
  out.add(prompt);
3547
3568
  if (run.fragment?.hitRunId)
3548
3569
  out.add(run.fragment.hitRunId);
3570
+ // The recording the match offered a repeat of: the same recording, and
3571
+ // known even when the match landed too late to be armed (newestRun.md).
3572
+ if (run.repeat)
3573
+ out.add(run.repeat.runId);
3549
3574
  return [...out];
3550
3575
  }
3551
3576
  /**
@@ -3985,8 +4010,16 @@ export class ControlServer {
3985
4010
  /** What the fragment's create call said: `created: false` is a hit, not a recording. */
3986
4011
  applyFragmentCreation(run, id, created) {
3987
4012
  const f = run.fragment;
3988
- if (created.created || !f || run.runId !== id)
4013
+ if (!f || run.runId !== id)
4014
+ return;
4015
+ if (created.created) {
4016
+ // Recorded as the newest run of the fragment it is a hit on (the
4017
+ // service's newestRun.md N2): still that fragment's repeat, so no step
4018
+ // hit may be counted against it (R-HIT-7).
4019
+ if (created.fragmentOf)
4020
+ f.hitRunId = created.fragmentOf.runId;
3989
4021
  return;
4022
+ }
3990
4023
  run.recording = false;
3991
4024
  f.state = "hit";
3992
4025
  // Which run it hit matters from here on: this turn must not count step
@@ -4000,6 +4033,112 @@ export class ControlServer {
4000
4033
  why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
4001
4034
  });
4002
4035
  }
4036
+ /**
4037
+ * Whether this matched turn's task is now the model's own, so its work is
4038
+ * the repeat the service offered to record: offered, nothing recording, no
4039
+ * fragment, and the prompt match's plan done with — retired, not running,
4040
+ * not diverging — and either:
4041
+ *
4042
+ * - it ran no step: the model does the whole task (newestRun.md N1); or
4043
+ * - it ran to its end, and the model, told to answer from its results,
4044
+ * called tools of its own instead (N7). Before 0.3.15 that work was
4045
+ * lost: a model that did not take the plan's result as the answer and
4046
+ * did the task again left nothing to fix the plan from.
4047
+ *
4048
+ * A plan that handed over is the fragment's to record (fallbk.md D7). One
4049
+ * that ran part of the task and stopped without a hand-over records
4050
+ * nothing, as before: the call where it stopped ran while it was still
4051
+ * live, so a run of what came after would lack the step that matters.
4052
+ */
4053
+ repeatDue(run) {
4054
+ const r = run.repeat;
4055
+ if (!r || r.state !== "offered" || run.recording || run.finished || run.fragment)
4056
+ return false;
4057
+ const prompt = run.replays.find((s) => s.armedBy === "prompt");
4058
+ if (!prompt?.plan)
4059
+ return true;
4060
+ if (!prompt.retired || prompt.diverging || this.runningPlans.has(prompt) || prompt.handover)
4061
+ return false;
4062
+ return prompt.stepsPinned === 0 || prompt.ranToEnd === true;
4063
+ }
4064
+ /**
4065
+ * Whether the prompt match's plan ran to its end before the model went on
4066
+ * by itself (newestRun.md N7). False when it ran no step, which is N1.
4067
+ */
4068
+ redoneAfterPlan(run) {
4069
+ const prompt = run.replays.find((s) => s.armedBy === "prompt");
4070
+ return prompt?.plan !== undefined && prompt.ranToEnd === true;
4071
+ }
4072
+ /**
4073
+ * Open the repeat run and record from this call on (newestRun.md N1, N7).
4074
+ *
4075
+ * The same swap as {@link openFragment}: nothing allocated before it belongs
4076
+ * to it. Unlike a fragment it carries the turn's prompt. When no plan did
4077
+ * any of the task, its cost and duration are the whole turn's; after a plan
4078
+ * that ran to its end, they start here, as a fragment's do. Nothing waits
4079
+ * for the service's answer: the steps queue behind the create, and a create
4080
+ * answered `id: null` — the service records no repeat of it — drops them.
4081
+ */
4082
+ openRepeat(session, run) {
4083
+ const r = run.repeat;
4084
+ if (!r || r.state !== "offered")
4085
+ return;
4086
+ const redone = this.redoneAfterPlan(run);
4087
+ const reason = redone ? "redone" : this.repeatReason(run);
4088
+ const repeatOf = reason ? { runId: r.runId, reason } : { runId: r.runId };
4089
+ // The repeat is a run of the same host as the turn it records.
4090
+ const framework = frameworkOf(session);
4091
+ const id = this.recorder.startRun(run.input, { ...this.runMetadata(session), repeatOf }, framework ? { repeatOf, framework } : { repeatOf });
4092
+ for (const c of run.correlations.values()) {
4093
+ c.settled = true;
4094
+ if (c.fallback)
4095
+ clearTimeout(c.fallback);
4096
+ }
4097
+ run.builtIns.clear();
4098
+ run.runId = id;
4099
+ run.ordering = new StepIndexAllocator();
4100
+ run.recording = true;
4101
+ r.state = "open";
4102
+ if (redone) {
4103
+ // The plan's steps are not the model's work: this run is billed and
4104
+ // timed from its own first call on, as a fragment is.
4105
+ r.usageMark = markTranscriptUsage(session.transcriptPath);
4106
+ r.startedAtMs = Date.now();
4107
+ }
4108
+ logLine("run.repeat", {
4109
+ run: id,
4110
+ of: r.runId,
4111
+ reason,
4112
+ why: redone
4113
+ ? "the plan ran to its end, and the agent went on with calls of its own instead of answering from its results — recorded as that recording's newest run"
4114
+ : "the agent is doing a recorded task itself — recorded as that recording's newest run",
4115
+ });
4116
+ if (!isRunCreationAware(this.recorder))
4117
+ return;
4118
+ void this.recorder
4119
+ .runCreated(id)
4120
+ .catch(() => ({ created: true }))
4121
+ .then((created) => {
4122
+ if (created.created || run.runId !== id)
4123
+ return;
4124
+ run.recording = false;
4125
+ r.state = "refused";
4126
+ logLine("run.repeat_refused", {
4127
+ run: id,
4128
+ of: r.runId,
4129
+ why: "the service records no repeat of this recording — nothing recorded",
4130
+ });
4131
+ });
4132
+ }
4133
+ /**
4134
+ * Why the matched turn's plan did not run, as the service shows it: what
4135
+ * the service withheld it for, else the code of the gate that declined it
4136
+ * here, a plan that was late included.
4137
+ */
4138
+ repeatReason(run) {
4139
+ const prompt = run.replays.find((s) => s.armedBy === "prompt") ?? run.lateMatch;
4140
+ return run.repeat?.withheld ?? prompt?.declined ?? undefined;
4141
+ }
4003
4142
  /** Resolve with `p`, or null once `ms` passes or it rejects. */
4004
4143
  async withinBudget(p, ms) {
4005
4144
  let timer;
@@ -11,6 +11,19 @@ export interface StartRunOptions {
11
11
  scenarioId: string;
12
12
  stepIndex: number;
13
13
  };
14
+ /**
15
+ * Marks a *repeat* (the service's newestRun.md): a matched turn whose plan
16
+ * did not run, so the model is doing the recording's task itself — or whose
17
+ * plan ran to its end, after which the model went on with calls of its own
18
+ * instead of answering from its results (N7). Sent only when the match
19
+ * offered it ({@link RunMatch.repeat}). `reason` is why the plan did not
20
+ * run, or `redone` after a plan that ran to its end. The service never
21
+ * prompt-matches a repeat.
22
+ */
23
+ repeatOf?: {
24
+ runId: string;
25
+ reason?: string;
26
+ };
14
27
  /**
15
28
  * The run's `framework`, when the session named a host that is not Claude
16
29
  * Code: its `app`, or `app:scope` (`langchain`, `langchain:billing`). The
@@ -176,6 +189,15 @@ export interface RunMatch {
176
189
  * ordinary hand-out, and from an older service.
177
190
  */
178
191
  withheld?: string | null;
192
+ /**
193
+ * The service's offer (its newestRun.md N5): should the model end up doing
194
+ * this task itself — the plan declined, withheld, late or off — record the
195
+ * turn as a repeat of `runId`, the recording's newest run. Absent or null
196
+ * from a service that does not record repeats: then nothing is recorded.
197
+ */
198
+ repeat?: {
199
+ runId: string;
200
+ } | null;
179
201
  }
180
202
  /** Optional capability: `null` means "fresh run, keep recording". */
181
203
  export interface MatchAware {
@@ -202,10 +224,18 @@ export declare function isMatchAware(r: Recorder): r is Recorder & MatchAware;
202
224
  /** What the create call said about a run: fresh, or a hit on a fragment. */
203
225
  export interface RunCreation {
204
226
  created: boolean;
205
- /** Set when this was a hit on an existing fragment (R-FALL-7, R-HIT-7). */
227
+ /**
228
+ * Set when this was a hit on an existing fragment (R-FALL-7, R-HIT-7) —
229
+ * with `created` too when the service recorded it all the same, as that
230
+ * fragment's newest run (its newestRun.md N2).
231
+ */
206
232
  fragmentOf?: {
207
233
  runId: string;
208
234
  };
235
+ /** Set when the service recorded the run as a repeat: the run it repeats. */
236
+ repeatOf?: {
237
+ runId: string;
238
+ };
209
239
  }
210
240
  export interface RunCreationAware {
211
241
  runCreated(runId: string): Promise<RunCreation>;
@@ -475,6 +475,9 @@ export class RemoteRecorder {
475
475
  // A fragment (fallbk.md D7). Omitted, not null, so an older service
476
476
  // that does not know the field sees the body it always saw.
477
477
  ...(options?.fallbackOf ? { fallbackOf: options.fallbackOf } : {}),
478
+ // A repeat (the service's newestRun.md). Sent only after a match
479
+ // that offered it, so a service that would not know it never sees it.
480
+ ...(options?.repeatOf ? { repeatOf: options.repeatOf } : {}),
478
481
  // A chain with calls is inlined for this runner rather than stopped in
479
482
  // front of its first call (segmented.md R-CALL-28).
480
483
  supportsCalls: true,
@@ -496,7 +499,11 @@ export class RemoteRecorder {
496
499
  why: "the create's first attempt failed, so the turn went ahead as a fresh one; the service kept its own run, and this one's steps are not sent",
497
500
  });
498
501
  }
499
- entry.settle(b.matched ?? null, { created: b.id !== null, fragmentOf: b.fragmentOf });
502
+ entry.settle(b.matched ?? null, {
503
+ created: b.id !== null,
504
+ fragmentOf: b.fragmentOf,
505
+ ...(b.repeatOf ? { repeatOf: b.repeatOf } : {}),
506
+ });
500
507
  }
501
508
  catch (err) {
502
509
  entry.settle(null, { created: true }); // fail-safe: on a create error, record normally
@@ -435,6 +435,24 @@ export interface ReplayState {
435
435
  sharedWith: string[];
436
436
  /** Set when the plan handed the task to the model part-way (fallbk.md). */
437
437
  handover?: Handover;
438
+ /**
439
+ * Every planned step ran and the model was handed the results to answer
440
+ * from: a steered plan whose last step threaded, a direct run that ran to
441
+ * its end, or a divergence whose bundle carried the rest of the chain. A
442
+ * call the model makes of its own after this is the task done again — it
443
+ * did not take the plan's result as the answer (the service's newestRun.md
444
+ * N7). Never set on a plan that handed over: the hand-over says the rest
445
+ * is the model's.
446
+ */
447
+ ranToEnd?: boolean;
448
+ /**
449
+ * A divergence is running the plan's remaining steps for real (§8). The
450
+ * plan is already retired by then, so this is what tells a call made beside
451
+ * that work from one made after it: a repeat never opens while steps are
452
+ * still landing, and whether the plan ran to its end or handed over is not
453
+ * known yet (the service's newestRun.md N7).
454
+ */
455
+ diverging?: boolean;
438
456
  /**
439
457
  * Which gate declined, as a fixed code (segmented.md R-HIT-14). The prose
440
458
  * `why` that `decline()` logs stays free text; this is what code reads — the
@@ -1375,6 +1375,7 @@ export class ReplayController {
1375
1375
  }
1376
1376
  if (done) {
1377
1377
  this.upgrade(state, "steered_full");
1378
+ state.ranToEnd = true;
1378
1379
  // A completing retire: the last step threaded and the plan's work is over
1379
1380
  // (segmented.md R-MONEY-3). Everything the agent spends after this is the
1380
1381
  // agent's own, not this plan's.
@@ -1687,6 +1688,7 @@ export class ReplayController {
1687
1688
  // `runToCompletion` returned without a stop: the plan ran to its end, so
1688
1689
  // this is a completing retire (R-MONEY-3). The stopped path handed over
1689
1690
  // above and took no mark.
1691
+ state.ranToEnd = true;
1690
1692
  this.retire(state, undefined, true);
1691
1693
  logLine("replay.done", {
1692
1694
  scenario: state.scenarioId ?? undefined,
@@ -2075,6 +2077,9 @@ export class ReplayController {
2075
2077
  step: `${plan.currentStepIndex}/${plan.stepCount}`,
2076
2078
  why,
2077
2079
  });
2080
+ // Retired, and still working: until the bundle is composed, a call the
2081
+ // model makes beside this one is beside the plan, not after it.
2082
+ state.diverging = true;
2078
2083
  this.retire(state, undefined);
2079
2084
  // The bundle, and the note in front of it, must fit the channel it goes
2080
2085
  // out on whole: the host cuts a long Bash output in the middle.
@@ -2099,6 +2104,11 @@ export class ReplayController {
2099
2104
  });
2100
2105
  return { kind: "abort" };
2101
2106
  }
2107
+ finally {
2108
+ // Nothing below waits: what this divergence ends in — a hand-over, or
2109
+ // a bundle that ran to the end — is settled before any other hook runs.
2110
+ state.diverging = false;
2111
+ }
2102
2112
  const stop = composed.stopped;
2103
2113
  // Same rule as `runArmed`: a recovered chain whose tools errored is
2104
2114
  // `failed`, and so is one that stopped at a step that broke — its logic
@@ -2175,6 +2185,11 @@ export class ReplayController {
2175
2185
  };
2176
2186
  text = `${state.handover.note}\n\n${composed.text}`;
2177
2187
  }
2188
+ else if (!stop) {
2189
+ // The bundle carries the rest of the chain, and its header tells the
2190
+ // model the work is done: from here the model answers from it.
2191
+ state.ranToEnd = true;
2192
+ }
2178
2193
  // Bash-clean delivery: the model reaches for `Bash` when it cannot call a
2179
2194
  // scripted tool, and a genuine command output is trusted where a `deny`
2180
2195
  // reason is read as adversarial interception.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
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",