@basein/runner 0.3.25 → 0.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.d.ts +13 -0
  7. package/dist/bin/investigate.js +72 -2
  8. package/dist/bin/scenario-edit.d.ts +5 -0
  9. package/dist/bin/scenario-edit.js +17 -1
  10. package/dist/config/adapters/claude-code.d.ts +51 -7
  11. package/dist/config/adapters/claude-code.js +105 -18
  12. package/dist/config/generate.d.ts +61 -13
  13. package/dist/config/generate.js +108 -20
  14. package/dist/config/resolve.d.ts +5 -0
  15. package/dist/config/resolve.js +7 -0
  16. package/dist/control/correlation.js +0 -0
  17. package/dist/control/daemon.d.ts +28 -0
  18. package/dist/control/daemon.js +62 -0
  19. package/dist/control/paths.d.ts +19 -1
  20. package/dist/control/paths.js +20 -2
  21. package/dist/control/pin-guard.js +8 -2
  22. package/dist/control/recorder-main.js +17 -5
  23. package/dist/control/server.d.ts +75 -11
  24. package/dist/control/server.js +440 -77
  25. package/dist/control/transcript.d.ts +64 -3
  26. package/dist/control/transcript.js +318 -37
  27. package/dist/jsonrpc/framing.d.ts +18 -3
  28. package/dist/jsonrpc/framing.js +57 -4
  29. package/dist/proxy/intercept.d.ts +4 -0
  30. package/dist/proxy/intercept.js +4 -0
  31. package/dist/proxy/relay.d.ts +13 -0
  32. package/dist/proxy/relay.js +12 -1
  33. package/dist/proxy/session.d.ts +8 -1
  34. package/dist/proxy/session.js +67 -10
  35. package/dist/record/recorder.d.ts +6 -1
  36. package/dist/record/redact.d.ts +18 -4
  37. package/dist/record/redact.js +538 -79
  38. package/dist/record/remote-recorder.d.ts +26 -5
  39. package/dist/record/remote-recorder.js +174 -18
  40. package/dist/record/truncate.js +3 -1
  41. package/dist/replay/bash-channel.d.ts +7 -1
  42. package/dist/replay/bash-channel.js +9 -3
  43. package/dist/replay/builtins.d.ts +14 -0
  44. package/dist/replay/builtins.js +460 -95
  45. package/dist/replay/bundle.d.ts +15 -11
  46. package/dist/replay/bundle.js +167 -33
  47. package/dist/replay/controller.d.ts +92 -1
  48. package/dist/replay/controller.js +246 -47
  49. package/dist/replay/derive.js +46 -4
  50. package/dist/replay/handover.d.ts +13 -0
  51. package/dist/replay/handover.js +18 -0
  52. package/dist/replay/plan.d.ts +41 -0
  53. package/dist/replay/plan.js +171 -3
  54. package/dist/replay/pricing.d.ts +61 -11
  55. package/dist/replay/pricing.js +135 -42
  56. package/dist/replay/reads-only.d.ts +48 -0
  57. package/dist/replay/reads-only.js +161 -0
  58. package/dist/replay/safety.js +43 -5
  59. package/dist/upstream/http-client.d.ts +6 -3
  60. package/dist/upstream/http-client.js +54 -13
  61. package/dist/upstream/stdio-client.d.ts +7 -0
  62. package/dist/upstream/stdio-client.js +1 -1
  63. package/dist/util/atomic-write.d.ts +9 -1
  64. package/dist/util/atomic-write.js +9 -6
  65. package/dist/util/service-health.d.ts +7 -0
  66. package/dist/util/service-health.js +10 -3
  67. package/package.json +1 -1
  68. package/production.md +2 -2
  69. package/scripts/install.ps1 +14 -2
@@ -104,13 +104,16 @@ export declare function assembleBundle(entries: readonly BundleEntry[], maxChars
104
104
  */
105
105
  export declare function wentWell(entries: readonly BundleEntry[], opts: Pick<AssembleOptions, "handover" | "subTask">): boolean;
106
106
  /**
107
- * JSON text with every string longer than 40 characters cut to its first 40
108
- * and "…". Keys, numbers and short strings stay as they are, and an escape is
109
- * never split. Works on JSON a parser would refuse — a tool's `NaN`, a result
110
- * cut short — because it walks the text's string literals from the start, so
111
- * it always knows which quote opens one: the text between two strings, a
112
- * list of numbers say, is never taken for one. An unterminated string is left
113
- * as it is.
107
+ * JSON text with every string of prose longer than 40 characters cut to its
108
+ * first 40 and "…". Keys, numbers, short strings and strings with no space in
109
+ * them ({@link BRIEF_ID_CHARS}: a link, a path, an id) stay as they are. A
110
+ * character is never split: an escape counts as one, `\n` and a Unicode
111
+ * escape (a backslash, a `u` and four hex digits) alike, and so does a pair of
112
+ * surrogates, written out (an emoji) or as two such escapes.
113
+ * Works on JSON a parser would refuse — a tool's `NaN`, a result cut short —
114
+ * because it walks the text's string literals from the start, so it always
115
+ * knows which quote opens one: the text between two strings, a list of numbers
116
+ * say, is never taken for one. An unterminated string is left as it is.
114
117
  */
115
118
  export declare function cutLongText(json: string): string;
116
119
  /**
@@ -120,10 +123,11 @@ export declare function cutLongText(json: string): string;
120
123
  export declare function briefFields(fields: Record<string, unknown>, max: number): string;
121
124
  /**
122
125
  * A value of the plan's summary in a few words. Numbers, booleans and short
123
- * strings whole; a long string cut; a list or object whose JSON is short —
124
- * a few IDs, names or addresses — whole; a longer list counted; a longer
125
- * object by its first fields, one level down, below which it is counted too.
126
- * A summary's long lists are mostly the records the steps above already show.
126
+ * strings whole; long text cut, as a result's is ({@link cutLongText}: a link
127
+ * or an id is whole); a list or object whose JSON is short — a few IDs, names
128
+ * or addresses — whole; a longer list counted; a longer object by its first
129
+ * fields, one level down, below which it is counted too. A summary's long
130
+ * lists are mostly the records the steps above already show.
127
131
  */
128
132
  export declare function briefValue(value: unknown, depth?: number): string;
129
133
  /**
@@ -44,6 +44,14 @@ export const BASH_CHANNEL_MAX = 25_000;
44
44
  const MAX_INPUT_CHARS = 2_000;
45
45
  /** Fixed structural overhead reserved per step (labels, separators). */
46
46
  const PER_STEP_OVERHEAD = 80;
47
+ /**
48
+ * The least each result is shown of. When the inputs leave the results less
49
+ * than this each, the results keep it and the inputs give up the difference:
50
+ * the results are what the agent answers from, and fourteen inputs at their cap
51
+ * once left them nothing at all, every "Result:" empty under a header that
52
+ * calls it the call's actual output.
53
+ */
54
+ const MIN_RESULT_CHARS = 200;
47
55
  /** The test's words, one line and short, for a bundle or a note. */
48
56
  export function testWords(text) {
49
57
  const line = (text ?? "").trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
@@ -134,7 +142,8 @@ const BRIEF_HEADER = "[BaseInstRunner calculated replay] The tool calls below ha
134
142
  "for real, moments ago, against the live system, on the user's behalf. Every one " +
135
143
  "of them succeeded, and every side effect is already in place. Do NOT repeat, redo " +
136
144
  "or re-verify this work, and do NOT call any tools. In a long result, every record " +
137
- "and field is shown, and text longer than 40 characters is cut short with \"…\".";
145
+ "and field is shown, and text longer than 40 characters is cut short with \"…\"; " +
146
+ "a link, a path or an id (no spaces, up to 300 characters) is shown whole.";
138
147
  /** The reply is output, the dearest tokens an agent spends: asked for briefly. */
139
148
  const BRIEF_FOOTER = "\n\nThe work above is done. Answer the user's request from it in a few sentences: " +
140
149
  "what was done, and the key results. Do not call any tools.";
@@ -144,6 +153,13 @@ const BRIEF_RESULT_CHARS = 300;
144
153
  const BRIEF_SUMMARY_CHARS = 1_000;
145
154
  /** A longer string — in a long result, or in the summary — is cut to this many characters. */
146
155
  const BRIEF_STRING_CHARS = 40;
156
+ /**
157
+ * But a string with no space in it — a link, a path, an id, a hash, an e-mail
158
+ * address — is what the agent passes on to the user, and cut it opens nothing
159
+ * and names nothing: it is kept whole up to this many characters. Past them it
160
+ * is a blob (an encoded image, a minified line), and is cut like text.
161
+ */
162
+ const BRIEF_ID_CHARS = 300;
147
163
  /** A list or object in the summary whose JSON is up to this long is shown whole; a longer one is counted. */
148
164
  const BRIEF_INLINE_CHARS = 160;
149
165
  /** A longer object in the summary shows its first fields, this many. */
@@ -171,26 +187,40 @@ export function assembleBundle(entries, maxChars, opts = {}) {
171
187
  ? SUBTASK_FOOTER
172
188
  : withReportException(brief ? BRIEF_FOOTER : anyRecorded ? MIXED_FOOTER : LIVE_FOOTER, opts.reportTool);
173
189
  const summary = brief ? briefSummary(opts.brief?.summary) : "";
174
- const fixedCost = header.length +
175
- footer.length +
176
- summary.length +
177
- entries.reduce((sum, e) => sum +
178
- PER_STEP_OVERHEAD +
179
- e.toolName.length +
180
- e.input.length +
181
- // A left-out step's line, which has no response to take from.
182
- (e.notNeeded ? PER_STEP_OVERHEAD + (testWords(e.notNeeded.text)?.length ?? 0) : 0), 0);
183
- const responseBudget = Math.max(0, maxChars - fixedCost);
184
- const perResponse = n > 0 ? Math.floor(responseBudget / n) : responseBudget;
190
+ const labels = entries.reduce((sum, e) => sum +
191
+ PER_STEP_OVERHEAD +
192
+ e.toolName.length +
193
+ // A left-out step's line, which has no response to take from.
194
+ (e.notNeeded ? PER_STEP_OVERHEAD + (testWords(e.notNeeded.text)?.length ?? 0) : 0), 0);
195
+ const inputs = entries.reduce((sum, e) => sum + e.input.length, 0);
196
+ // What the frame and the labels leave is shared by the inputs and the
197
+ // results. The inputs go in whole and the results split what they leave —
198
+ // unless that is less than a floor each (or nothing at all, when the inputs
199
+ // alone pass the limit): then each result keeps the floor, and the inputs,
200
+ // cut to a share of the rest, say how much of themselves is left out.
201
+ const free = Math.max(0, maxChars - (header.length + footer.length + summary.length + labels));
202
+ let perResponse = n > 0 ? Math.floor(Math.max(0, free - inputs) / n) : Math.max(0, free - inputs);
203
+ let inputCap = Number.POSITIVE_INFINITY;
204
+ const floor = Math.min(MIN_RESULT_CHARS, Math.floor(free / (2 * Math.max(1, n))));
205
+ if (n > 0 && perResponse < floor) {
206
+ perResponse = floor;
207
+ inputCap = Math.floor((free - n * floor) / entries.length);
208
+ }
209
+ const shownEntries = Number.isFinite(inputCap)
210
+ ? entries.map((e) => ({ ...e, input: truncate(e.input, inputCap) }))
211
+ : entries;
185
212
  const show = (e) => {
186
213
  const result = resultText(e.response);
214
+ // A built-in's result is shown as its own text, whole within its share.
215
+ const own = brief ? builtinText(e.toolName, e.response) : undefined;
216
+ const text = own ?? result.text;
187
217
  return {
188
- text: brief ? briefResult(result.text, perResponse) : truncate(result.text, perResponse),
218
+ text: brief && own === undefined ? briefResult(text, perResponse) : truncate(text, perResponse),
189
219
  marks: resultMarks(e.recorded, result.error),
190
220
  };
191
221
  };
192
222
  const sections = [];
193
- for (const run of consecutiveRuns(entries)) {
223
+ for (const run of consecutiveRuns(shownEntries)) {
194
224
  if (run.length === 1) {
195
225
  const { entry: e, index: i } = run[0];
196
226
  if (e.notNeeded) {
@@ -209,7 +239,14 @@ export function assembleBundle(entries, maxChars, opts = {}) {
209
239
  }
210
240
  sections.push(runSection(run, show));
211
241
  }
212
- return truncate(header + sections.join("") + summary + footer, maxChars);
242
+ // The labels are an estimate, and a bundle that still passes the limit loses
243
+ // its middle, never its footer: the last thing the agent is told. Where even
244
+ // that does not fit the limit, the end goes, as it always did.
245
+ const whole = header + sections.join("") + summary + footer;
246
+ if (whole.length <= maxChars)
247
+ return whole;
248
+ const keepFooter = truncate(header + sections.join("") + summary, Math.max(0, maxChars - footer.length)) + footer;
249
+ return keepFooter.length <= maxChars ? keepFooter : truncate(whole, maxChars);
213
250
  }
214
251
  /**
215
252
  * Whether a run went entirely well and the agent answers now — the one case a
@@ -239,14 +276,67 @@ function briefResult(text, budget) {
239
276
  return truncate(cutLongText(text), budget);
240
277
  return truncate(text, budget);
241
278
  }
279
+ /** The runner's built-ins whose results {@link builtinText} lays out as text. */
280
+ const BUILTIN_RESULT_TOOLS = new Set(["Bash", "Read", "Glob", "Grep"]);
281
+ /**
282
+ * A built-in tool's result as the text it is, or undefined when it is not one
283
+ * the brief layout can show so: what a command printed, a file's lines, a
284
+ * search's lines or file names.
285
+ *
286
+ * The runner's own tools answer in the hook's shape — `{stdout, stderr, …}`,
287
+ * `{type, file: {content, …}}`, `{mode, content | filenames, …}`, `{filenames,
288
+ * …}` — and not in MCP's `CallToolResult`, so {@link resultText} leaves them as
289
+ * JSON, and the brief layout took all of it for one long string: a command's
290
+ * output of 470 characters ending "TOTAL DIFFERENCE 100.00" was cut to its
291
+ * first 40, a Glob's file names every one, under a header that says to call no
292
+ * tools. The same words from an MCP tool were shown whole.
293
+ */
294
+ function builtinText(toolName, response) {
295
+ if (!BUILTIN_RESULT_TOOLS.has(toolName))
296
+ return undefined;
297
+ let parsed;
298
+ try {
299
+ parsed = JSON.parse(response);
300
+ }
301
+ catch {
302
+ return undefined;
303
+ }
304
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
305
+ return undefined;
306
+ const r = parsed;
307
+ const lines = (v) => Array.isArray(v) && v.every((x) => typeof x === "string") ? v.join("\n") : undefined;
308
+ let body;
309
+ if (toolName === "Bash") {
310
+ if (typeof r.stdout !== "string" || r.interrupted === true || r.isImage === true)
311
+ return undefined;
312
+ const stderr = typeof r.stderr === "string" && r.stderr !== "" ? `stderr: ${r.stderr}` : "";
313
+ body = stderr && r.stdout !== "" && !r.stdout.endsWith("\n") ? `${r.stdout}\n${stderr}` : r.stdout + stderr;
314
+ }
315
+ else if (toolName === "Read") {
316
+ const file = r.file;
317
+ body = r.type === "text" && typeof file?.content === "string" ? file.content : undefined;
318
+ }
319
+ else {
320
+ body = typeof r.content === "string" && toolName === "Grep" ? r.content : lines(r.filenames);
321
+ }
322
+ if (body === undefined)
323
+ return undefined;
324
+ if (body === "")
325
+ return toolName === "Bash" ? "(no output)" : toolName === "Read" ? "(empty file)" : "(nothing found)";
326
+ // A list the tool cut where it made it: its text alone would read as the whole answer.
327
+ return r.truncated === true ? `${body}\n(cut short by the tool: there may be more than is shown)` : body;
328
+ }
242
329
  /**
243
- * JSON text with every string longer than 40 characters cut to its first 40
244
- * and "…". Keys, numbers and short strings stay as they are, and an escape is
245
- * never split. Works on JSON a parser would refuse — a tool's `NaN`, a result
246
- * cut short — because it walks the text's string literals from the start, so
247
- * it always knows which quote opens one: the text between two strings, a
248
- * list of numbers say, is never taken for one. An unterminated string is left
249
- * as it is.
330
+ * JSON text with every string of prose longer than 40 characters cut to its
331
+ * first 40 and "…". Keys, numbers, short strings and strings with no space in
332
+ * them ({@link BRIEF_ID_CHARS}: a link, a path, an id) stay as they are. A
333
+ * character is never split: an escape counts as one, `\n` and a Unicode
334
+ * escape (a backslash, a `u` and four hex digits) alike, and so does a pair of
335
+ * surrogates, written out (an emoji) or as two such escapes.
336
+ * Works on JSON a parser would refuse — a tool's `NaN`, a result cut short —
337
+ * because it walks the text's string literals from the start, so it always
338
+ * knows which quote opens one: the text between two strings, a list of numbers
339
+ * say, is never taken for one. An unterminated string is left as it is.
250
340
  */
251
341
  export function cutLongText(json) {
252
342
  let out = "";
@@ -256,24 +346,68 @@ export function cutLongText(json) {
256
346
  if (open < 0)
257
347
  return out + json.slice(at);
258
348
  out += json.slice(at, open + 1);
259
- // Walk the string a unit at a time — a character, or an escape with what
260
- // it escapes — to its closing quote, noting where the 40th unit ends.
349
+ // Walk the string a character at a time to its closing quote, noting where
350
+ // the 40th ends and whether the text has a space in it.
261
351
  let end = open + 1;
262
352
  let units = 0;
263
353
  let cutAt = -1;
354
+ let spaced = false;
264
355
  while (end < json.length && json[end] !== '"') {
265
- end += json[end] === "\\" ? 2 : 1;
356
+ const code = json.charCodeAt(end);
357
+ if (code === 92 || (code >= 0xd800 && code <= 0xdbff)) {
358
+ // An escape, or the first half of a surrogate pair: it may take more
359
+ // than one character of the text.
360
+ const unit = characterAt(json, end);
361
+ end += unit.length;
362
+ spaced ||= unit.space;
363
+ }
364
+ else {
365
+ // Nearly every character, and a result may be megabytes of them: one
366
+ // of the text, a space only when it says so.
367
+ spaced ||= code === 32 || ((code < 32 || code > 126) && /\s/.test(json[end]));
368
+ end += 1;
369
+ }
266
370
  units += 1;
267
371
  if (units === BRIEF_STRING_CHARS)
268
372
  cutAt = end;
269
373
  }
270
374
  if (end >= json.length)
271
375
  return out + json.slice(open + 1);
272
- out += units > BRIEF_STRING_CHARS ? `${json.slice(open + 1, cutAt)}…"` : json.slice(open + 1, end + 1);
376
+ const cut = units > (spaced ? BRIEF_STRING_CHARS : BRIEF_ID_CHARS);
377
+ out += cut ? `${json.slice(open + 1, cutAt)}…"` : json.slice(open + 1, end + 1);
273
378
  at = end + 1;
274
379
  }
275
380
  return out;
276
381
  }
382
+ /**
383
+ * The character of a JSON string's text that starts at `i`: how many
384
+ * characters of the text it takes, and whether it is a space (a `\t`, or a
385
+ * Unicode escape of a space, is one as much as the character itself).
386
+ */
387
+ function characterAt(text, i) {
388
+ const c = text[i];
389
+ if (c === "\\") {
390
+ const unit = unicodeEscape(text, i);
391
+ if (unit === undefined)
392
+ return { length: 2, space: /^[nrtf]$/.test(text[i + 1] ?? "") };
393
+ // Two escapes that are the halves of one character are one character.
394
+ const low = unit >= 0xd800 && unit <= 0xdbff ? unicodeEscape(text, i + 6) : undefined;
395
+ if (low !== undefined && low >= 0xdc00 && low <= 0xdfff)
396
+ return { length: 12, space: false };
397
+ return { length: 6, space: /\s/.test(String.fromCharCode(unit)) };
398
+ }
399
+ const code = c.charCodeAt(0);
400
+ const next = text.charCodeAt(i + 1);
401
+ if (code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff)
402
+ return { length: 2, space: false };
403
+ return { length: 1, space: /\s/.test(c) };
404
+ }
405
+ /** The code unit of a `\uXXXX` escape at `i`, or undefined when there is none there. */
406
+ function unicodeEscape(text, i) {
407
+ return text[i] === "\\" && text[i + 1] === "u" && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))
408
+ ? parseInt(text.slice(i + 2, i + 6), 16)
409
+ : undefined;
410
+ }
277
411
  /**
278
412
  * The plan's summary (its response model) in a few words, or "" when it has
279
413
  * nothing to add: none at all, or only the note that it was left out — which
@@ -304,15 +438,15 @@ export function briefFields(fields, max) {
304
438
  }
305
439
  /**
306
440
  * A value of the plan's summary in a few words. Numbers, booleans and short
307
- * strings whole; a long string cut; a list or object whose JSON is short —
308
- * a few IDs, names or addresses — whole; a longer list counted; a longer
309
- * object by its first fields, one level down, below which it is counted too.
310
- * A summary's long lists are mostly the records the steps above already show.
441
+ * strings whole; long text cut, as a result's is ({@link cutLongText}: a link
442
+ * or an id is whole); a list or object whose JSON is short — a few IDs, names
443
+ * or addresses — whole; a longer list counted; a longer object by its first
444
+ * fields, one level down, below which it is counted too. A summary's long
445
+ * lists are mostly the records the steps above already show.
311
446
  */
312
447
  export function briefValue(value, depth = 0) {
313
- if (typeof value === "string") {
314
- return JSON.stringify(value.length > BRIEF_STRING_CHARS ? `${value.slice(0, BRIEF_STRING_CHARS)}…` : value);
315
- }
448
+ if (typeof value === "string")
449
+ return cutLongText(JSON.stringify(value));
316
450
  if (value === null || typeof value !== "object")
317
451
  return String(value);
318
452
  let whole;
@@ -14,6 +14,7 @@
14
14
  import type { ExecutionReport } from "../record/recorder.js";
15
15
  import { type ReplayMode, type StepReach } from "./coverage.js";
16
16
  import { deriveParameters, type AgentDay, type DeriveContext, type DeriveResult } from "./derive.js";
17
+ import { type HandoverStopReason } from "./handover.js";
17
18
  import { ProxyWorkQueue } from "./executor.js";
18
19
  import { ScenarioReplayPlan } from "./plan.js";
19
20
  import { type HostSinks } from "./safety.js";
@@ -91,6 +92,13 @@ export interface PreToolOptions {
91
92
  * route's deadline guard spoke first. No further step is started.
92
93
  */
93
94
  signal?: AbortSignal;
95
+ /**
96
+ * The call only looks and changes nothing (replay/reads-only.ts), as the
97
+ * control server judged it with what the tool's server says of it. A direct
98
+ * plan the model has not asked for yet lets such a call through and waits
99
+ * for `run_scenario` (B2 is11.md); nothing runs in its place.
100
+ */
101
+ readsOnly?: boolean;
94
102
  }
95
103
  /**
96
104
  * What the caller knows about how a pinned call ended, beyond its error text
@@ -310,6 +318,39 @@ export interface IntentMatchOptions {
310
318
  maxRequestsPerTurn: number;
311
319
  }
312
320
  export declare const DEFAULT_INTENT_MATCH: IntentMatchOptions;
321
+ /**
322
+ * Where the stopped step stood when a plan handed over, in the words the
323
+ * service reads (its recordings/repo.ts `handoverFamily`): did the step
324
+ * happen. Its tool ran and only its output could not be read: `ran`. It was
325
+ * sent and nothing came back: `uncertain`. Stopped in front of it — the plan's
326
+ * time ran out (`budget`), its caller left (`cancelled`), its test could not
327
+ * say whether this request needs it (`test_failed`) — it was never sent:
328
+ * `not_run`. Otherwise the stop's own reason (`not_run`, `failed`,
329
+ * `concurrent`, `host_rejected`, `host_edited`), and a plain failure, which
330
+ * carries none, reads `failed`.
331
+ *
332
+ * The service continues a hand-over only with work that was recorded where the
333
+ * step had not happened (`not_run`, `failed`): the agent's work there starts
334
+ * with that very step. Continued after a step that ran, or may have, the plan
335
+ * made of it ran the step a second time. The three stops in front of a step
336
+ * went out under their own names, which the service reads as "may have run":
337
+ * a plan stopped by its time budget was never continued.
338
+ */
339
+ export declare function stopOf(h: {
340
+ ranThisStep?: boolean;
341
+ inFlight?: unknown;
342
+ stopReason?: HandoverStopReason;
343
+ }): string;
344
+ /**
345
+ * Whether a run of the plan sent at least one of its calls: a step did its
346
+ * work, its tool answered with an error, its output could not be read after
347
+ * the tool ran, or it was sent and has not been seen through (a pin still out,
348
+ * a stop that may have happened). A step its test left out, one whose logic
349
+ * broke before its call, one that could not be sent and a recorded stand-in
350
+ * sent nothing — a plan that only did those has nothing a second run could
351
+ * repeat.
352
+ */
353
+ export declare function sentACall(state: ReplayState): boolean;
313
354
  /** The turn handed back to the model part-way (fallbk.md). */
314
355
  export interface Handover {
315
356
  kind: FallbackKind;
@@ -317,6 +358,8 @@ export interface Handover {
317
358
  stepIndex: number;
318
359
  toolName?: string;
319
360
  error?: string;
361
+ /** For a `step_failed`: where the stopped step stood ({@link stopOf}). */
362
+ stop?: string;
320
363
  /** What the model is told. Always built, even when delivery fails. */
321
364
  note: string;
322
365
  /** Set once the note reached the model, so it is never delivered twice. */
@@ -478,12 +521,28 @@ export interface ReplayState {
478
521
  running?: Promise<RunArmedResult>;
479
522
  /** The model called `run_scenario` for this plan (its `PreToolUse` was seen). */
480
523
  directRequested?: boolean;
524
+ /**
525
+ * Calls that only look, made while this direct plan waited for
526
+ * `run_scenario` (B2 is11.md): each ran as the model wrote it, and the plan
527
+ * stayed announced. The first is told the plan still waits, once.
528
+ */
529
+ readsWaited?: number;
481
530
  /**
482
531
  * The direct run's caller went away mid-run — Esc on `run_scenario`, or its
483
532
  * client timing out. The turn it cut short is no measurement of the task, so
484
533
  * the execution report says `measured: false`.
485
534
  */
486
535
  callerCancelled?: boolean;
536
+ /**
537
+ * The plan ran to its end while its caller was gone, so its results reached
538
+ * nobody and no hand-over note exists: this says so, once, to the model
539
+ * ({@link ReplayController.takeNote}), so that it does not run the steps
540
+ * again.
541
+ */
542
+ unseen?: {
543
+ note: string;
544
+ delivered: boolean;
545
+ };
487
546
  /**
488
547
  * The derivation is the service's to answer (no key here), so waiting on it
489
548
  * is waiting on the service, and obeys its breaker (util/service-health.ts).
@@ -618,12 +677,36 @@ export type DeclineCode = "replay_disabled" | "not_ready" | "known_bad_first_ste
618
677
  * arms on the model's first call after the hand-over, whatever that is.)
619
678
  */
620
679
  | "not_taken"
680
+ /**
681
+ * The plan was cancelled by its caller (Esc on `run_scenario`, or a timeout)
682
+ * a few minutes ago, after at least one step had run, and the same plan came
683
+ * back: not armed again, so the steps whose side effects are in place do not
684
+ * run a second time. The agent does the task, told what ran. Set by the
685
+ * control server.
686
+ */
687
+ | "after_cancel"
621
688
  /**
622
689
  * A continuation whose own code for working out its values (`paramsLogic`)
623
690
  * failed on this request, so none of its steps could be computed. Set by
624
691
  * the control server.
625
692
  */
626
- | "params_failed";
693
+ | "params_failed"
694
+ /**
695
+ * A direct plan announced to the model, which made a call that may change
696
+ * something before it called `run_scenario`: the plan retired running
697
+ * nothing, because run after the model's own change it could do the same
698
+ * work twice. Calls that only look before it do not count (B2 is11.md).
699
+ * Set by the control server (runners after 0.3.26; up to it, such a plan
700
+ * was retired at any first call, and reported no reason).
701
+ */
702
+ | "changed_first"
703
+ /**
704
+ * A direct plan whose `run_scenario` call never reached the runner: the
705
+ * person refused it, a rule denied it, or it failed on the way. Refused
706
+ * means refused: the plan retired running nothing. Set by the control
707
+ * server (runners after 0.3.26; up to it, reported with no reason).
708
+ */
709
+ | "run_refused";
627
710
  /**
628
711
  * What a continuation continues (the service's fallbk.md Part 3): the plan
629
712
  * that handed over, the step it stopped in front of, and the recording of the
@@ -774,6 +857,12 @@ export declare class ReplayController {
774
857
  * against the plan; the outcome is `fell_back` when a step ran and `failed`
775
858
  * when none did, as for every hand-over. The note is the caller's to
776
859
  * deliver ({@link takeNote}). Returns false when there was no live plan.
860
+ *
861
+ * A step the adapter was given and has not reported back on may be running:
862
+ * the plan stops after it, as for a call beside a pin ({@link concurrentCall}),
863
+ * and the hand-over says the step may have happened — never "did not run",
864
+ * which the service would continue with work that starts by running it
865
+ * again. Its result is still read when it lands.
777
866
  */
778
867
  hostHandOver(state: ReplayState, why: "own_call" | "closed" | "late"): boolean;
779
868
  /**
@@ -814,6 +903,8 @@ export declare class ReplayController {
814
903
  * Returns it once and marks it delivered; undefined when there is none.
815
904
  */
816
905
  takeNote(state: ReplayState | undefined): string | undefined;
906
+ /** Whether a note about this plan is still to be told to the model: a hand-over's, or the unseen results'. */
907
+ hasUndeliveredNote(state: ReplayState | undefined): boolean;
817
908
  /**
818
909
  * Deliver text to the model in place of the tool call it was about to make:
819
910
  * as genuine command output for `Bash`, as the denial reason otherwise — the