@yaag/runtime 0.9.0 → 0.11.0

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.
@@ -1,28 +1,13 @@
1
- import { resolve } from "node:path";
2
1
  import type { ConfigExtension } from "../config/index.ts";
3
2
  import { YaagError } from "../errors.ts";
4
3
  import type { EventSink } from "../events.ts";
5
- import {
6
- ModelErrorHistory,
7
- type ModelSelection,
8
- normalizeModelResolution,
9
- resolveModel,
10
- resolveRecordedModel,
11
- } from "../model/index.ts";
12
4
  import type { RunContext } from "../run/index.ts";
13
- import type { AgentTransport, TransportFactory, TransportStartup } from "../transport/index.ts";
14
- import type {
15
- AskOptions,
16
- Handle,
17
- ResolvedSpawnOptions,
18
- SpawnOptions,
19
- SpawnOverrides,
20
- } from "../types.ts";
21
- import { Agent } from "./agent.ts";
22
- import { uniqueAgentName } from "./agent-names.ts";
5
+ import type { TransportFactory } from "../transport/index.ts";
6
+ import type { Handle, SpawnOptions, SpawnOverrides } from "../types.ts";
7
+ import type { Agent } from "./agent.ts";
23
8
  import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "./define-agent.ts";
24
- import { resolveSpawnExtensions } from "./spawn-extensions.ts";
25
- import { openRequest, withSelection } from "./spawn-request.ts";
9
+ import { type ForkSpawner, forkRequest } from "./fork.ts";
10
+ import { type OpenAgentContext, type OpenAgentRequest, openAgent } from "./spawn-open.ts";
26
11
 
27
12
  /** Dependencies for one Run's Agent-spawn gate. */
28
13
  export interface SpawnDependencies {
@@ -50,125 +35,30 @@ export interface SpawnGate {
50
35
  export function makeSpawn(deps: SpawnDependencies): SpawnGate {
51
36
  const taken = new Set<string>();
52
37
  let closed = false;
38
+ // Declared before use: every Agent this gate opens is handed the same fork
39
+ // spawner, so a fork is gated and named exactly like a spawn.
40
+ const forkSpawner: ForkSpawner = async (source, overrides) => {
41
+ if (closed) throw new YaagError("RUN_CLOSED", "run has already settled");
42
+ const request = forkRequest(source, overrides);
43
+ const handle = await openAgent(context, {
44
+ spawnOptions: request.spawnOptions,
45
+ parent: request.parent,
46
+ askDefaults: undefined,
47
+ definitionName: undefined,
48
+ fork: request.fork,
49
+ });
50
+ if (request.compact !== undefined && request.compact !== false) {
51
+ await handle.compact(typeof request.compact === "string" ? request.compact : undefined);
52
+ }
53
+ return handle;
54
+ };
55
+ const context: OpenAgentContext = { ...deps, taken, forkSpawner };
53
56
  const spawn: RunContext["spawn"] = async (
54
57
  definitionOrOptions: SpawnOptions | AgentDefinition = {},
55
58
  overrides?: SpawnOverrides,
56
59
  ): Promise<Handle> => {
57
60
  if (closed) throw new YaagError("RUN_CLOSED", "run has already settled");
58
- const request = resolveRequest(definitionOrOptions, overrides);
59
- const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
60
- const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, taken);
61
- const resolution = normalizeModelResolution(request.spawnOptions);
62
- // One history per Agent: its spawn loop and every mid-Ask fallback loop
63
- // append to it, so a resolver sees every candidate that already failed.
64
- const history = new ModelErrorHistory();
65
- try {
66
- // Extension paths do not vary per candidate, so a bad path fails once, generically.
67
- const extensions = await resolveSpawnExtensions({
68
- configExtensions: deps.configExtensions,
69
- declared: request.spawnOptions.extensions,
70
- useConfigExtensions: request.spawnOptions.configExtensions !== false,
71
- ...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
72
- projectRoot: cwd,
73
- }).catch((error: unknown) => {
74
- if (error instanceof YaagError) throw error;
75
- throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
76
- });
77
- // One local for both open sites, so the launch arguments and the recorded
78
- // identity cannot drift apart.
79
- const extensionFields =
80
- extensions === undefined
81
- ? {}
82
- : {
83
- resolvedExtensionPaths: extensions.resolvedPaths,
84
- declaredExtensions: extensions.declared,
85
- };
86
- const attempt = async (
87
- selection: ModelSelection,
88
- ): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
89
- const settled = withSelection(request.spawnOptions, selection);
90
- return {
91
- opened: await openTransport({
92
- factory: deps.factory,
93
- name,
94
- cwd,
95
- spawnOptions: settled,
96
- ...extensionFields,
97
- sessionDir: deps.sessionDir,
98
- }),
99
- spawnOptions: settled,
100
- };
101
- };
102
- // The peek must stay in the same synchronous block as the first open: a
103
- // Cassette-backed factory claims Agents in open order (ADR-0013).
104
- const recorded = deps.factory.recordedSpawn?.(
105
- openRequest({
106
- name,
107
- cwd,
108
- spawnOptions: request.spawnOptions,
109
- ...extensionFields,
110
- sessionDir: deps.sessionDir,
111
- }),
112
- );
113
- // A Cassette-backed spawn adopts the recorded resolved selection and skips
114
- // the loop, so a replayed Run emits no spawn-time fallback (ADR-0039).
115
- const adopted =
116
- recorded === undefined ? undefined : resolveRecordedModel({ resolution, recorded });
117
- // The adopted outcome carries the attempts the recording already spent, so
118
- // a later mid-Ask fallback re-resolves from that attempt index (ADR-0039).
119
- for (const skipped of adopted?.skipped ?? []) {
120
- history.record(skipped.reason, skipped.failedModel);
121
- }
122
- const { opened, spawnOptions } =
123
- adopted === undefined
124
- ? await resolveModel({
125
- resolution,
126
- agent: name,
127
- history,
128
- onFallback: (fallback) => {
129
- deps.emit({ type: "model_fallback", agent: name, ...fallback });
130
- },
131
- attempt,
132
- })
133
- : await attempt(adopted.selection);
134
- const resolvedCwd = opened.startup.worktree?.cwd ?? cwd;
135
- const branch = opened.startup.worktree?.branch;
136
- const sessionFile = opened.startup.sessionFile;
137
- const agent = new Agent({
138
- name,
139
- cwd: resolvedCwd,
140
- branch,
141
- transport: opened.transport,
142
- emit: deps.emit,
143
- spawnOptions,
144
- // An Agent that named no candidate inherits pi's default model, so a
145
- // failing Ask has nothing to fall back from and stays an Ask failure.
146
- ...(spawnOptions.model === undefined
147
- ? {}
148
- : { modelFallback: { resolution, history, candidate: spawnOptions.model } }),
149
- ...(request.askDefaults === undefined ? {} : { askDefaults: request.askDefaults }),
150
- ...(request.definitionName === undefined ? {} : { definitionName: request.definitionName }),
151
- });
152
- deps.agents.push(agent);
153
- deps.emit({
154
- type: "agent_spawn",
155
- agent: name,
156
- model: agent.model,
157
- cwd: resolvedCwd,
158
- ...(branch === undefined ? {} : { branch }),
159
- ...(sessionFile === undefined ? {} : { sessionFile }),
160
- });
161
- return agent;
162
- } catch (error) {
163
- if (request.definitionName !== undefined && isSpawnFailure(error)) {
164
- throw new YaagError(
165
- "SPAWN_FAILED",
166
- `definition "${request.definitionName}": ${error.message}`,
167
- name,
168
- );
169
- }
170
- throw error;
171
- }
61
+ return openAgent(context, resolveRequest(definitionOrOptions, overrides));
172
62
  };
173
63
  return {
174
64
  spawn,
@@ -178,23 +68,26 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
178
68
  };
179
69
  }
180
70
 
181
- type MutableSpawnOverrides = { -readonly [Key in keyof SpawnOverrides]: SpawnOverrides[Key] };
71
+ /** Topology overrides after validation; `parent` stays raw for `resolveParent`. */
72
+ type SpawnTopology = Omit<SpawnOverrides, "parent">;
73
+
74
+ type MutableSpawnOverrides = { -readonly [Key in keyof SpawnTopology]: SpawnTopology[Key] };
182
75
 
183
- interface SpawnRequest {
184
- readonly spawnOptions: SpawnOptions;
185
- readonly askDefaults: AskOptions | undefined;
186
- /** Definition identity stays separate from a topology-overridden Agent name. */
187
- readonly definitionName: string | undefined;
76
+ /** One validated override object: checked topology plus the unchecked Parent Link. */
77
+ interface ValidatedOverrides {
78
+ readonly topology: SpawnTopology;
79
+ readonly parent: unknown;
188
80
  }
189
81
 
190
82
  function resolveRequest(
191
83
  definitionOrOptions: SpawnOptions | AgentDefinition,
192
84
  overrides: SpawnOverrides | undefined,
193
- ): SpawnRequest {
85
+ ): OpenAgentRequest {
194
86
  if (!isAgentDefinition(definitionOrOptions)) {
195
- return { spawnOptions: definitionOrOptions, askDefaults: undefined, definitionName: undefined };
87
+ const { parent, ...spawnOptions } = definitionOrOptions;
88
+ return { spawnOptions, parent, askDefaults: undefined, definitionName: undefined };
196
89
  }
197
- const topology = validateOverrides(overrides);
90
+ const { topology, parent } = validateOverrides(overrides);
198
91
  const config = agentDefinitionConfig(definitionOrOptions);
199
92
  return {
200
93
  spawnOptions: {
@@ -218,27 +111,32 @@ function resolveRequest(
218
111
  ? { systemPrompt: config.prompt }
219
112
  : { appendSystemPrompt: config.prompt }),
220
113
  },
114
+ parent,
221
115
  askDefaults: config.askDefaults,
222
116
  definitionName: config.name,
223
117
  };
224
118
  }
225
119
 
226
- function validateOverrides(overrides: unknown): SpawnOverrides {
227
- if (overrides === undefined) return {};
120
+ function validateOverrides(overrides: unknown): ValidatedOverrides {
121
+ if (overrides === undefined) return { topology: {}, parent: undefined };
228
122
  if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
229
123
  throw new TypeError(
230
124
  "spawn overrides must be an object: definitions own policy and spawn overrides own topology",
231
125
  );
232
126
  }
233
127
  for (const key of Object.keys(overrides)) {
234
- if (key !== "name" && key !== "cwd" && key !== "worktree") {
128
+ if (key !== "name" && key !== "cwd" && key !== "worktree" && key !== "parent") {
235
129
  throw new TypeError(
236
130
  `spawn override "${key}" is not allowed: definitions own policy and spawn overrides own topology`,
237
131
  );
238
132
  }
239
133
  }
240
134
  const topology: MutableSpawnOverrides = {};
135
+ // `parent` is never narrowed here: `resolveParent` owns that check, so the
136
+ // error text stays in one place and no unchecked value becomes a Handle.
137
+ let parent: unknown;
241
138
  for (const [key, value] of Object.entries(overrides)) {
139
+ if (key === "parent") parent = value;
242
140
  if (key === "name") {
243
141
  if (value !== undefined && typeof value !== "string") {
244
142
  throw new TypeError('spawn override "name" must be a string when present');
@@ -258,49 +156,5 @@ function validateOverrides(overrides: unknown): SpawnOverrides {
258
156
  topology.worktree = value;
259
157
  }
260
158
  }
261
- return topology;
262
- }
263
-
264
- function isSpawnFailure(error: unknown): error is YaagError {
265
- return error instanceof YaagError && error.code === "SPAWN_FAILED";
266
- }
267
-
268
- interface OpenTransportOptions {
269
- readonly factory: TransportFactory;
270
- readonly name: string;
271
- readonly cwd: string;
272
- readonly spawnOptions: ResolvedSpawnOptions;
273
- readonly resolvedExtensionPaths?: readonly string[];
274
- readonly declaredExtensions?: readonly string[];
275
- readonly sessionDir: string | undefined;
276
- }
277
-
278
- interface OpenedTransport {
279
- readonly transport: AgentTransport;
280
- readonly startup: TransportStartup;
281
- }
282
-
283
- async function openTransport(options: OpenTransportOptions): Promise<OpenedTransport> {
284
- const startup: TransportStartup = {};
285
- try {
286
- const transport = await options.factory.open(
287
- openRequest({
288
- name: options.name,
289
- cwd: options.cwd,
290
- spawnOptions: options.spawnOptions,
291
- ...(options.resolvedExtensionPaths === undefined
292
- ? {}
293
- : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
294
- ...(options.declaredExtensions === undefined
295
- ? {}
296
- : { declaredExtensions: options.declaredExtensions }),
297
- sessionDir: options.sessionDir,
298
- }),
299
- (report) => Object.assign(startup, report),
300
- );
301
- return { transport, startup };
302
- } catch (error) {
303
- if (error instanceof YaagError) throw error;
304
- throw new YaagError("SPAWN_FAILED", `agent "${options.name}": ${String(error)}`, options.name);
305
- }
159
+ return { topology, parent };
306
160
  }
@@ -1,6 +1,13 @@
1
1
  import type { ReportedResult } from "../ask-contract/index.ts";
2
2
  import { isReportResultCommandFrame, ReportResultCall } from "../ask-contract/index.ts";
3
- import type { AgentStats, AskMarker, AskPlayback, Frame } from "../transport/index.ts";
3
+ import type {
4
+ AgentStats,
5
+ AskMarker,
6
+ AskPlayback,
7
+ CompactionMarker,
8
+ CompactionPlayback,
9
+ Frame,
10
+ } from "../transport/index.ts";
4
11
  import { FrameQueue } from "../transport/index.ts";
5
12
  import type { CassetteAgent } from "./cassette.ts";
6
13
 
@@ -92,6 +99,31 @@ export class CassetteReplay {
92
99
  };
93
100
  }
94
101
 
102
+ /**
103
+ * Moves playback to a compaction its caller has already identity-checked.
104
+ *
105
+ * A compaction lives between two Asks, and `beginAsk` never switches the
106
+ * cursors back to the Agent-level streams, so the recorded compaction owns
107
+ * its own streams (ADR-0043).
108
+ */
109
+ beginCompaction(marker: CompactionMarker): CompactionPlayback {
110
+ const compaction = this.#agent.compactions?.[marker.index];
111
+ if (!compaction) throw new Error("unreachable replay compaction cursor");
112
+ this.#sentCursor = 0;
113
+ this.#receivedCursor = 0;
114
+ this.#sent = compaction.sentFrames;
115
+ this.#received = compaction.receivedFrames;
116
+ this.#openAttempt(0);
117
+ return {
118
+ result: compaction.result ?? {
119
+ tokensBefore: null,
120
+ tokensAfter: null,
121
+ tokens: null,
122
+ cost: null,
123
+ },
124
+ };
125
+ }
126
+
95
127
  /** Ends the stream cleanly, allowing a caller to switch to another source. */
96
128
  finish(): void {
97
129
  this.#queue.end();
@@ -38,6 +38,10 @@ const CassetteSpawnSchema = Type.Object({
38
38
  ...spawnPolicyFields,
39
39
  worktree: Type.Optional(Type.Literal(true)),
40
40
  declaredExtensions: Type.Optional(StringArray),
41
+ parent: Type.Optional(Type.String()),
42
+ origin: Type.Optional(Type.Literal("fork")),
43
+ forkOf: Type.Optional(Type.String()),
44
+ forkAsks: Type.Optional(Type.Number()),
41
45
  });
42
46
 
43
47
  const ContextSpawnSchema = Type.Object({
@@ -118,6 +122,22 @@ const TokenBreakdownSchema = Type.Object({
118
122
  total: Type.Number(),
119
123
  });
120
124
 
125
+ const CompactionResultSchema = Type.Object({
126
+ tokensBefore: Type.Union([Type.Null(), Type.Number()]),
127
+ tokensAfter: Type.Union([Type.Null(), Type.Number()]),
128
+ tokens: Type.Union([Type.Null(), TokenBreakdownSchema]),
129
+ cost: Type.Union([Type.Null(), Type.Number()]),
130
+ });
131
+
132
+ const CassetteCompactionSchema = Type.Object({
133
+ index: Type.Number(),
134
+ hash: Type.String(),
135
+ afterAsks: Type.Number(),
136
+ result: Type.Optional(CompactionResultSchema),
137
+ sentFrames: Type.Array(FrameSchema),
138
+ receivedFrames: Type.Array(FrameSchema),
139
+ });
140
+
121
141
  const CassetteAgentSchema = Type.Object({
122
142
  spawn: CassetteSpawnSchema,
123
143
  model: Type.String(),
@@ -127,6 +147,7 @@ const CassetteAgentSchema = Type.Object({
127
147
  sentFrames: Type.Array(FrameSchema),
128
148
  receivedFrames: Type.Array(FrameSchema),
129
149
  asks: Type.Array(CassetteAskSchema),
150
+ compactions: Type.Optional(Type.Array(CassetteCompactionSchema)),
130
151
  stats: Type.Object({
131
152
  tokens: Type.Union([Type.Null(), TokenBreakdownSchema]),
132
153
  cost: Type.Union([Type.Null(), Type.Number()]),
@@ -7,6 +7,8 @@ import type {
7
7
  AskInvalidOutputPlayback,
8
8
  AskMarker,
9
9
  AskMarkerContext,
10
+ CompactionMarker,
11
+ CompactionResult,
10
12
  Frame,
11
13
  OpenOptions,
12
14
  WorktreeResolution,
@@ -60,9 +62,23 @@ export interface CassetteAgent {
60
62
  readonly sentFrames: readonly Frame[];
61
63
  readonly receivedFrames: readonly Frame[];
62
64
  readonly asks: readonly CassetteAsk[];
65
+ /** Compactions between this Agent's Asks; absent for an Agent that compacted none. */
66
+ readonly compactions?: readonly CassetteCompaction[];
63
67
  readonly stats: AgentStats;
64
68
  }
65
69
 
70
+ /** The frames attributed to one compaction marker (ADR-0043). */
71
+ export interface CassetteCompaction {
72
+ readonly index: number;
73
+ readonly hash: string;
74
+ /** Settled Asks of this Agent at the compaction point. */
75
+ readonly afterAsks: number;
76
+ /** What the recorded compaction reported; absent when the exchange failed. */
77
+ readonly result?: CompactionResult;
78
+ readonly sentFrames: readonly Frame[];
79
+ readonly receivedFrames: readonly Frame[];
80
+ }
81
+
66
82
  /** Behavioural identity supplied when opening a recorded Agent. */
67
83
  export interface CassetteSpawn {
68
84
  readonly name: string;
@@ -88,6 +104,17 @@ export interface CassetteSpawn {
88
104
  * across machines (ADR-0040). Absent when the Agent ran no extension.
89
105
  */
90
106
  readonly declaredExtensions?: readonly string[];
107
+ /**
108
+ * Resolved name of the Agent named as this Agent's parent (Parent Link).
109
+ * Part of spawn identity; absent for a root Agent and for older Cassettes.
110
+ */
111
+ readonly parent?: string;
112
+ /** How the Agent came to be; absent means an ordinary spawn. Spawn identity. */
113
+ readonly origin?: "fork";
114
+ /** Name of the Agent this one was forked from. Spawn identity. */
115
+ readonly forkOf?: string;
116
+ /** Settled Asks of the fork source at the fork point. Spawn identity. */
117
+ readonly forkAsks?: number;
91
118
  }
92
119
 
93
120
  /** The frames attributed to one Ask marker. */
@@ -137,6 +164,8 @@ export interface CassetteRecorder {
137
164
  received(frame: Frame): void;
138
165
  beginAsk(marker: AskMarker): void;
139
166
  finishAsk(completion: AskCompletion): void;
167
+ beginCompaction(marker: CompactionMarker): void;
168
+ finishCompaction(result: CompactionResult | undefined): void;
140
169
  closed(stats: AgentStats): void;
141
170
  }
142
171
 
@@ -156,6 +185,15 @@ interface MutableAsk {
156
185
  recovered?: true;
157
186
  }
158
187
 
188
+ interface MutableCompaction {
189
+ readonly index: number;
190
+ readonly hash: string;
191
+ readonly afterAsks: number;
192
+ readonly sentFrames: Frame[];
193
+ readonly receivedFrames: Frame[];
194
+ result?: CompactionResult;
195
+ }
196
+
159
197
  interface MutableAgent {
160
198
  readonly spawn: CassetteSpawn;
161
199
  readonly model: string;
@@ -165,8 +203,10 @@ interface MutableAgent {
165
203
  readonly sentFrames: Frame[];
166
204
  readonly receivedFrames: Frame[];
167
205
  readonly asks: MutableAsk[];
206
+ readonly compactions: MutableCompaction[];
168
207
  stats: AgentStats;
169
- active: MutableAsk | null;
208
+ /** The slot later frames belong to: one Ask, one compaction, or the Agent. */
209
+ active: MutableAsk | MutableCompaction | null;
170
210
  }
171
211
 
172
212
  /** Collects a Cassette in memory; `executeRun` serializes it at Run settlement. */
@@ -189,6 +229,7 @@ export class CassetteCollector implements CassetteSink {
189
229
  sentFrames: [],
190
230
  receivedFrames: [],
191
231
  asks: [],
232
+ compactions: [],
192
233
  stats: { tokens: null, cost: null },
193
234
  active: null,
194
235
  };
@@ -206,11 +247,24 @@ export class CassetteCollector implements CassetteSink {
206
247
  agent.active = ask;
207
248
  },
208
249
  finishAsk: (completion): void => {
209
- if (!agent?.active) return;
210
- if (completion.limit !== undefined) agent.active.limit = completion.limit;
211
- if (completion.stalled !== undefined) agent.active.stalled = completion.stalled;
212
- if (completion.invalidOutput !== undefined) agent.active.outcome = completion.invalidOutput;
213
- if (completion.recovered === true) agent.active.recovered = true;
250
+ const ask = activeAsk(agent);
251
+ if (!ask) return;
252
+ if (completion.limit !== undefined) ask.limit = completion.limit;
253
+ if (completion.stalled !== undefined) ask.stalled = completion.stalled;
254
+ if (completion.invalidOutput !== undefined) ask.outcome = completion.invalidOutput;
255
+ if (completion.recovered === true) ask.recovered = true;
256
+ },
257
+ beginCompaction: (marker): void => {
258
+ if (!agent) return;
259
+ const compaction = { ...marker, sentFrames: [], receivedFrames: [] };
260
+ agent.compactions.push(compaction);
261
+ agent.active = compaction;
262
+ },
263
+ finishCompaction: (result): void => {
264
+ const compaction = agent?.compactions.at(-1);
265
+ if (compaction !== undefined && result !== undefined) compaction.result = result;
266
+ // Later frames belong to the Agent again, not to the compaction.
267
+ if (agent) agent.active = null;
214
268
  },
215
269
  closed: (stats): void => {
216
270
  if (agent) agent.stats = stats;
@@ -234,6 +288,7 @@ export class CassetteCollector implements CassetteSink {
234
288
  sentFrames,
235
289
  receivedFrames,
236
290
  asks,
291
+ compactions,
237
292
  stats,
238
293
  }) => ({
239
294
  spawn,
@@ -244,6 +299,9 @@ export class CassetteCollector implements CassetteSink {
244
299
  sentFrames,
245
300
  receivedFrames,
246
301
  asks,
302
+ // Absent for an Agent that compacted nothing, so every Cassette
303
+ // recorded before ADR-0043 keeps its byte-identical shape.
304
+ ...(compactions.length === 0 ? {} : { compactions }),
247
305
  stats,
248
306
  }),
249
307
  ),
@@ -272,10 +330,21 @@ function spawnIdentity(options: OpenOptions): CassetteSpawn {
272
330
  ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
273
331
  ? {}
274
332
  : { declaredExtensions: [...options.declaredExtensions] }),
333
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
334
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
335
+ ...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
336
+ ...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
275
337
  ...(options.worktree === true ? { worktree: true } : {}),
276
338
  };
277
339
  }
278
340
 
341
+ /** The Ask that later frames belong to, or nothing when a compaction owns them. */
342
+ function activeAsk(agent: MutableAgent | null): MutableAsk | null {
343
+ const active = agent?.active;
344
+ if (!active || "afterAsks" in active) return null;
345
+ return active;
346
+ }
347
+
279
348
  function append(
280
349
  agent: MutableAgent | null,
281
350
  direction: "sentFrames" | "receivedFrames",
@@ -10,6 +10,7 @@ export {
10
10
  type CassetteArtifact,
11
11
  type CassetteAsk,
12
12
  CassetteCollector,
13
+ type CassetteCompaction,
13
14
  type CassetteGit,
14
15
  type CassetteRun,
15
16
  type CassetteSink,
@@ -4,6 +4,9 @@ import type {
4
4
  AskCompletion,
5
5
  AskMarker,
6
6
  AskPlayback,
7
+ CompactionMarker,
8
+ CompactionPlayback,
9
+ CompactionResult,
7
10
  Frame,
8
11
  OpenOptions,
9
12
  RecordedSpawnSelection,
@@ -87,6 +90,16 @@ class RecordingTransport implements AgentTransport {
87
90
  this.#inner.finishAsk(completion);
88
91
  }
89
92
 
93
+ beginCompaction(marker: CompactionMarker): CompactionPlayback | undefined {
94
+ this.#recorder.beginCompaction(marker);
95
+ return this.#inner.beginCompaction(marker);
96
+ }
97
+
98
+ finishCompaction(result: CompactionResult | undefined): void {
99
+ this.#recorder.finishCompaction(result);
100
+ this.#inner.finishCompaction(result);
101
+ }
102
+
90
103
  recordedExtractionPolicy(index: number): string | undefined {
91
104
  return this.#inner.recordedExtractionPolicy?.(index);
92
105
  }