@yaag/runtime 0.10.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.
@@ -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,
@@ -64,6 +67,7 @@ class ResumeTransport implements AgentTransport {
64
67
  readonly #frames = new FrameQueue();
65
68
  readonly #buffered: Frame[] = [];
66
69
  #askCursor = 0;
70
+ #compactionCursor = 0;
67
71
  #mode: "replay" | "activating" | "live" = "replay";
68
72
  #live: AgentTransport | null = null;
69
73
  #activation: Promise<void> | null = null;
@@ -112,6 +116,27 @@ class ResumeTransport implements AgentTransport {
112
116
  if (this.#mode === "live") this.#live?.finishAsk(completion);
113
117
  }
114
118
 
119
+ /**
120
+ * A compaction the Cassette does not hold activates live, exactly as a
121
+ * diverged Ask does: the recorded prefix cannot answer it (ADR-0043).
122
+ */
123
+ beginCompaction(marker: CompactionMarker): CompactionPlayback | undefined {
124
+ if (this.#mode === "live") return this.#live?.beginCompaction(marker);
125
+ if (this.#mode === "activating") return undefined;
126
+ const mismatch = replayMismatch.compaction(this.#agent, this.#compactionCursor, marker);
127
+ if (!mismatch) {
128
+ this.#compactionCursor += 1;
129
+ return this.#replay.beginCompaction(marker);
130
+ }
131
+ this.#mode = "activating";
132
+ this.#activation = this.#activate(null);
133
+ return undefined;
134
+ }
135
+
136
+ finishCompaction(result: CompactionResult | undefined): void {
137
+ if (this.#mode === "live") this.#live?.finishCompaction(result);
138
+ }
139
+
115
140
  recordedExtractionPolicy(index: number): string | undefined {
116
141
  return this.#mode === "replay" ? this.#agent.asks[index]?.extractionPolicy : undefined;
117
142
  }
@@ -121,7 +146,7 @@ class ResumeTransport implements AgentTransport {
121
146
  return this.#closed;
122
147
  }
123
148
 
124
- async #activate(marker: AskMarker): Promise<void> {
149
+ async #activate(marker: AskMarker | null): Promise<void> {
125
150
  // End the old source before attaching the new one, but keep the outward
126
151
  // queue open so Connection remains attached across the handoff.
127
152
  this.#replay.finish();
@@ -131,7 +156,7 @@ class ResumeTransport implements AgentTransport {
131
156
  try {
132
157
  const live = await this.#liveFactory.open(this.#continuation);
133
158
  this.#live = live;
134
- live.beginAsk(marker);
159
+ if (marker !== null) live.beginAsk(marker);
135
160
  for (const frame of this.#buffered.splice(0)) live.send(frame);
136
161
  this.#mode = "live";
137
162
  void this.#forward(live.frames());
package/src/errors.ts CHANGED
@@ -44,6 +44,10 @@ export type YaagErrorCode =
44
44
  | "ASK_TIMEOUT" // timeoutMs elapsed
45
45
  | "ASK_LIMIT" // soft Ask budget exceeded after grace
46
46
  | "ASK_STALLED" // no frame arrived within the silence budget; escalated, then kill
47
+ | "COMPACT_DURING_ASK" // compact() was called while an Ask was in flight
48
+ | "COMPACT_FAILED" // pi refused or could not summarize the context
49
+ | "FORK_DURING_ASK" // fork() was called while the source Ask was in flight
50
+ | "FORK_REFUSED" // the fork source died mid-Ask, or holds no session file
47
51
  | "ASK_INVALID_OUTPUT" // settled text could not be extracted or satisfy outputSchema
48
52
  | "ARGS_INVALID" // arguments failed schema validation before the Run started
49
53
  | "CONFIG_INVALID" // a config layer is missing, unparsable, or violates the schema
package/src/events.ts CHANGED
@@ -22,7 +22,7 @@ export type RunOutcome = "completed" | "failed" | "stopped" | "paused" | "interr
22
22
 
23
23
  /**
24
24
  * How an Agent came to be. An absent value on an event means "spawn"; "fork"
25
- * arrives with the forking spec.
25
+ * marks an Agent created from a copy of another Agent's session (ADR-0044).
26
26
  */
27
27
  export type SpawnOrigin = "spawn" | "fork";
28
28
 
@@ -169,6 +169,31 @@ export type LifecycleEventBody =
169
169
  /** Cumulative assistant-completion cost observed so far. */
170
170
  readonly cost: number;
171
171
  }
172
+ | {
173
+ /**
174
+ * One compaction of an Agent's context (ADR-0043).
175
+ *
176
+ * It carries the summary call's own spend, so a Run Summary does not
177
+ * silently lose it. `agent_exit` stays authoritative for the Agent's
178
+ * total: pi's `get_session_stats` already includes compaction spend.
179
+ * The custom instructions never ride the wire (ticket 09); only the
180
+ * `custom` flag says that the program supplied some.
181
+ */
182
+ readonly type: "agent_compaction";
183
+ readonly agent: string;
184
+ /** Monotonic per Agent, from 0. */
185
+ readonly index: number;
186
+ /** Context tokens before the summary replaced the transcript; null when unknown. */
187
+ readonly tokensBefore: number | null;
188
+ /** Estimated context tokens after the summary; null when unknown. */
189
+ readonly tokensAfter: number | null;
190
+ /** The summary call's own token breakdown; null when unknown. */
191
+ readonly tokens: TokenBreakdown | null;
192
+ /** The summary call's own cost; null when unknown. */
193
+ readonly cost: number | null;
194
+ /** The program supplied custom compaction instructions. */
195
+ readonly custom: boolean;
196
+ }
172
197
  | {
173
198
  readonly type: "agent_exit";
174
199
  readonly agent: string;
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  CassetteAgent,
6
6
  CassetteArtifact,
7
7
  CassetteAsk,
8
+ CassetteCompaction,
8
9
  CassetteGit,
9
10
  CassetteRun,
10
11
  CassetteSink,
@@ -79,6 +80,7 @@ export type {
79
80
  AgentInfo,
80
81
  AgentState,
81
82
  AskingAgentInfo,
83
+ CompactionInfo,
82
84
  EndedRunSummary,
83
85
  ExitedAgentInfo,
84
86
  IdleAgentInfo,
@@ -96,6 +98,9 @@ export type {
96
98
  AskMarker,
97
99
  AskMarkerContext,
98
100
  AskPlayback,
101
+ CompactionMarker,
102
+ CompactionPlayback,
103
+ CompactionResult,
99
104
  DiscoveredSkill,
100
105
  Frame,
101
106
  ReapPath,
@@ -110,6 +115,7 @@ export type {
110
115
  export { readSystemPromptSidecar, reap, worktreeTransport } from "./transport/index.ts";
111
116
  export type {
112
117
  AskOptions,
118
+ ForkOptions,
113
119
  Handle,
114
120
  ResolvedSpawnOptions,
115
121
  SpawnOptions,
@@ -16,6 +16,7 @@ export type {
16
16
  AgentInfo,
17
17
  AgentState,
18
18
  AskingAgentInfo,
19
+ CompactionInfo,
19
20
  ExitedAgentInfo,
20
21
  IdleAgentInfo,
21
22
  ModelFallbackInfo,
@@ -7,6 +7,15 @@ export type { AgentActivity, SpawnOrigin } from "../events.ts";
7
7
  export type { ModelFallbackInfo } from "./summary-fallbacks.ts";
8
8
  export type { NodeInfo } from "./summary-nodes.ts";
9
9
 
10
+ /** What one compaction of an Agent's context reported (ADR-0043). */
11
+ export interface CompactionInfo {
12
+ readonly tokensBefore: number | null;
13
+ readonly tokensAfter: number | null;
14
+ /** The summary call's own cost, already included in `agent_exit` accounting. */
15
+ readonly cost: number | null;
16
+ readonly at: number | null;
17
+ }
18
+
10
19
  /** The observer-facing lifecycle state of an Agent. */
11
20
  export type AgentState = "idle" | "asking" | "exited";
12
21
 
@@ -34,6 +43,10 @@ interface AgentInfoBase {
34
43
  /** Timestamp ordering only cumulative live accounting, never lifecycle state. */
35
44
  readonly usageUpdatedAt: number | null;
36
45
  readonly askStartedAt: number | null;
46
+ /** Compactions of this Agent's context so far (ADR-0043). */
47
+ readonly compactions: number;
48
+ /** What the newest compaction reported, or null when the Agent compacted none. */
49
+ readonly lastCompaction: CompactionInfo | null;
37
50
  /** This Agent's bounded Nested Node table, in first-seen order (spec §3). */
38
51
  readonly nodes: readonly NodeInfo[];
39
52
  /** Exited Nested Nodes dropped to keep the table bounded. */
@@ -103,6 +116,8 @@ export function placeholderAgent(): IdleAgentInfo {
103
116
  sessionFile: null,
104
117
  parent: null,
105
118
  origin: "spawn",
119
+ compactions: 0,
120
+ lastCompaction: null,
106
121
  state: "idle",
107
122
  askIndex: null,
108
123
  promptGist: null,
@@ -264,6 +279,29 @@ export function setUsage(
264
279
  return { ...agent, tokens: observation.tokens, cost: observation.cost, usageUpdatedAt: at };
265
280
  }
266
281
 
282
+ /**
283
+ * Counts one compaction and keeps what it reported.
284
+ *
285
+ * It folds no cost: the summary call's spend reaches the Summary through
286
+ * `agent_usage`, and `agent_exit` stays authoritative (ADR-0043). A terminal
287
+ * Agent still counts a late compaction, because the count is a fact about the
288
+ * conversation rather than a lifecycle state.
289
+ */
290
+ export function compactAgent(
291
+ current: AgentRecord | undefined,
292
+ observation: CompactionInfo,
293
+ at: number | null,
294
+ ): AgentRecord {
295
+ const agent = current ?? placeholderAgent();
296
+ const stale =
297
+ at !== null && agent.lastCompaction?.at !== null && at < (agent.lastCompaction?.at ?? at);
298
+ return {
299
+ ...agent,
300
+ compactions: agent.compactions + 1,
301
+ lastCompaction: stale ? agent.lastCompaction : { ...observation, at },
302
+ };
303
+ }
304
+
267
305
  /**
268
306
  * Folds authoritative shutdown accounting into the terminal Agent state.
269
307
  *
@@ -3,6 +3,7 @@ import type { TokenBreakdown } from "../transport/index.ts";
3
3
  import type { AgentInfo } from "./summary-agent.ts";
4
4
  import {
5
5
  type AgentRecord,
6
+ compactAgent,
6
7
  endAsk,
7
8
  exitAgent,
8
9
  placeholderAgent,
@@ -22,6 +23,7 @@ export type {
22
23
  AgentInfo,
23
24
  AgentState,
24
25
  AskingAgentInfo,
26
+ CompactionInfo,
25
27
  ExitedAgentInfo,
26
28
  IdleAgentInfo,
27
29
  ModelFallbackInfo,
@@ -51,6 +53,8 @@ interface RunSummaryBase {
51
53
  readonly worstFrameGapMs: number;
52
54
  /** Run-wide Model Resolution fallbacks, pruned per-Agent entries included. */
53
55
  readonly modelFallbacks: number;
56
+ /** Compactions across every Agent of this Run (ADR-0043). */
57
+ readonly compactions: number;
54
58
  }
55
59
 
56
60
  /** A Run that has not settled; it has neither outcome nor compatibility result. */
@@ -109,6 +113,7 @@ export function initialSummary(): RunningRunSummary {
109
113
  durationMs: 0,
110
114
  worstFrameGapMs: 0,
111
115
  modelFallbacks: 0,
116
+ compactions: 0,
112
117
  ok: null,
113
118
  };
114
119
  }
@@ -160,6 +165,21 @@ export function applyEvent(
160
165
  event.agent,
161
166
  applyAgentModel(summary.agents[event.agent] ?? placeholderAgent(), event),
162
167
  );
168
+ case "agent_compaction":
169
+ return withAgent(
170
+ { ...summary, compactions: summary.compactions + 1 },
171
+ event.agent,
172
+ compactAgent(
173
+ summary.agents[event.agent],
174
+ {
175
+ tokensBefore: event.tokensBefore,
176
+ tokensAfter: event.tokensAfter,
177
+ cost: event.cost,
178
+ at,
179
+ },
180
+ at,
181
+ ),
182
+ );
163
183
  case "agent_usage":
164
184
  return withAgent(summary, event.agent, setUsage(summary.agents[event.agent], event, at));
165
185
  case "ask_end":
@@ -6,7 +6,13 @@ import {
6
6
  import type { AvailableModel } from "../model/index.ts";
7
7
  import { FrameQueue } from "./frame-queue.ts";
8
8
  import { parseFrame } from "./jsonl.ts";
9
- import type { AgentStats, AgentTransport, AskMarker, Frame } from "./transport.ts";
9
+ import type {
10
+ AgentStats,
11
+ AgentTransport,
12
+ AskMarker,
13
+ CompactionMarker,
14
+ Frame,
15
+ } from "./transport.ts";
10
16
 
11
17
  /**
12
18
  * Scripted frame playback for one prompt sent through a FakeTransport.
@@ -50,6 +56,10 @@ export interface FakeTransportOptions extends FakePromptScript {
50
56
  readonly steerError?: string;
51
57
  /** Makes an abort RPC response fail without embedding a limit decision in playback. */
52
58
  readonly abortError?: string;
59
+ /** Payload of a `compact` command response; omission answers a bare success. */
60
+ readonly compaction?: Record<string, unknown>;
61
+ /** Makes a `compact` command response fail, as pi does when it cannot summarize. */
62
+ readonly compactError?: string;
53
63
  /** Makes the private report_result schema command fail. */
54
64
  readonly schemaCommandError?: string;
55
65
  /** The snapshot answered to `get_available_models`; defaults to this fake's own model. */
@@ -87,6 +97,7 @@ export class FakeTransport implements AgentTransport {
87
97
  /** Everything the runtime wrote, in order. */
88
98
  readonly sent: Frame[] = [];
89
99
  readonly asks: AskMarker[] = [];
100
+ readonly compactions: CompactionMarker[] = [];
90
101
  readonly #queue = new FrameQueue();
91
102
  readonly #options: FakeTransportOptions;
92
103
  #closed = false;
@@ -113,6 +124,7 @@ export class FakeTransport implements AgentTransport {
113
124
  if (frame.type === "abort") void this.#answerAbort(frame);
114
125
  if (frame.type === "get_last_assistant_text") this.#answerLastText(frame);
115
126
  if (frame.type === "get_state") this.#answerState(frame);
127
+ if (frame.type === "compact") this.#answerCompact(frame);
116
128
  if (frame.type === "get_available_models") this.#answerAvailableModels(frame);
117
129
  if (frame.type === "set_model") this.#answerSetModel(frame);
118
130
  if (frame.type === "set_thinking_level")
@@ -133,6 +145,15 @@ export class FakeTransport implements AgentTransport {
133
145
  // This live test transport does not persist Ask outcomes.
134
146
  }
135
147
 
148
+ beginCompaction(marker: CompactionMarker): undefined {
149
+ this.compactions.push(marker);
150
+ return undefined;
151
+ }
152
+
153
+ finishCompaction(): void {
154
+ // This live test transport does not persist compaction outcomes.
155
+ }
156
+
136
157
  /** True once the runtime shut this Agent down. */
137
158
  get closed(): boolean {
138
159
  return this.#closed;
@@ -295,6 +316,19 @@ export class FakeTransport implements AgentTransport {
295
316
  });
296
317
  }
297
318
 
319
+ #answerCompact(frame: Frame): void {
320
+ const error = this.#options.compactError;
321
+ this.#queue.push({
322
+ type: "response",
323
+ command: "compact",
324
+ id: frame.id,
325
+ success: error === undefined,
326
+ ...(error === undefined
327
+ ? { data: this.#options.compaction ?? { summary: "summary" } }
328
+ : { error }),
329
+ });
330
+ }
331
+
298
332
  #answerState(frame: Frame): void {
299
333
  if (this.#options.stateSilent === true) return;
300
334
  this.#queue.push({
@@ -17,7 +17,12 @@ export { FrameGapTracker } from "./frame-gap.ts";
17
17
  export { FrameQueue } from "./frame-queue.ts";
18
18
  export { decodeFrames } from "./jsonl.ts";
19
19
  export { liveTransport } from "./live-transport.ts";
20
- export { type AgentProgress, piCommand, readAgentProgress } from "./pi-state.ts";
20
+ export {
21
+ type AgentProgress,
22
+ piCommand,
23
+ readAgentProgress,
24
+ readCompaction,
25
+ } from "./pi-state.ts";
21
26
  export { type ReapPath, type ReapTarget, reap } from "./reap.ts";
22
27
  export { type DiscoveredSkill, liveSkillProbe, type SkillProbeFactory } from "./skill-probe.ts";
23
28
  export { skillRestrictionTransport } from "./skill-restriction-transport.ts";
@@ -34,6 +39,9 @@ export type {
34
39
  AskMarker,
35
40
  AskMarkerContext,
36
41
  AskPlayback,
42
+ CompactionMarker,
43
+ CompactionPlayback,
44
+ CompactionResult,
37
45
  Frame,
38
46
  OpenOptions,
39
47
  TokenBreakdown,
@@ -140,6 +140,15 @@ class LiveTransport implements AgentTransport {
140
140
  // Live transport has no Cassette outcome to complete.
141
141
  }
142
142
 
143
+ beginCompaction(): undefined {
144
+ // Nothing to do live; a recording implementation groups frames by this.
145
+ return undefined;
146
+ }
147
+
148
+ finishCompaction(): void {
149
+ // Live transport has no Cassette compaction to complete.
150
+ }
151
+
143
152
  close(): Promise<AgentStats> {
144
153
  this.#closing ??= this.#shutdown();
145
154
  return this.#closing;
@@ -1,6 +1,12 @@
1
1
  import { REPORT_RESULT_EXTENSION_PATH, REPORT_RESULT_TOOL_NAME } from "../ask-contract/index.ts";
2
2
  import { SYSTEM_PROMPT_RECORDER_EXTENSION_PATH } from "./system-prompt-recorder.ts";
3
- import type { AgentStats, Frame, OpenOptions, TokenBreakdown } from "./transport.ts";
3
+ import type {
4
+ AgentStats,
5
+ CompactionResult,
6
+ Frame,
7
+ OpenOptions,
8
+ TokenBreakdown,
9
+ } from "./transport.ts";
4
10
 
5
11
  /** What the Agent reported about its own work, read from a `get_state` probe. */
6
12
  export interface AgentProgress {
@@ -23,7 +29,10 @@ export function piCommand(options: OpenOptions, toolProbeExtensionPath?: string)
23
29
  }
24
30
  appendCapabilities(cmd, options, toolProbeExtensionPath);
25
31
  if (options.sessionDir) cmd.push("--session-dir", options.sessionDir);
32
+ // `--session` and `--fork` are mutually exclusive in pi, and a resume is the
33
+ // stronger claim: it names the very session this Agent must continue.
26
34
  if (options.sessionFile) cmd.push("--session", options.sessionFile);
35
+ else if (options.forkSession) cmd.push("--fork", options.forkSession);
27
36
  // No --approve: Agents inherit the user's saved project-trust decision (ADR-0009).
28
37
  return cmd;
29
38
  }
@@ -154,6 +163,47 @@ export function readStats(response: Frame): AgentStats {
154
163
  return { tokens: readBreakdown("tokens" in data ? data.tokens : null), cost };
155
164
  }
156
165
 
166
+ /**
167
+ * What a `compact` response reports (ADR-0043).
168
+ *
169
+ * A missing field becomes null rather than 0, so "unknown" and "free" stay
170
+ * distinguishable: a custom compaction handler may answer without usage.
171
+ */
172
+ export function readCompaction(response: Frame): CompactionResult {
173
+ const data: unknown = response.data;
174
+ if (typeof data !== "object" || data === null) {
175
+ return { tokensBefore: null, tokensAfter: null, tokens: null, cost: null };
176
+ }
177
+ const record: Record<string, unknown> = { ...data };
178
+ const usage: unknown = record.usage;
179
+ const usageRecord: Record<string, unknown> =
180
+ typeof usage === "object" && usage !== null ? { ...usage } : {};
181
+ const cost: unknown = usageRecord.cost;
182
+ return {
183
+ tokensBefore: typeof record.tokensBefore === "number" ? record.tokensBefore : null,
184
+ tokensAfter:
185
+ typeof record.estimatedTokensAfter === "number" ? record.estimatedTokensAfter : null,
186
+ tokens: readCompactionTokens(usageRecord),
187
+ cost:
188
+ typeof cost === "object" &&
189
+ cost !== null &&
190
+ typeof (cost as { total?: unknown }).total === "number"
191
+ ? (cost as { total: number }).total
192
+ : null,
193
+ };
194
+ }
195
+
196
+ /** The summary call's own token breakdown, or null when pi reported none. */
197
+ function readCompactionTokens(usage: Record<string, unknown>): TokenBreakdown | null {
198
+ return readBreakdown({
199
+ input: usage.input,
200
+ output: usage.output,
201
+ cacheRead: usage.cacheRead,
202
+ cacheWrite: usage.cacheWrite,
203
+ total: usage.totalTokens,
204
+ });
205
+ }
206
+
157
207
  function readBreakdown(tokens: unknown): TokenBreakdown | null {
158
208
  if (typeof tokens !== "object" || tokens === null) return null;
159
209
  const record: Record<string, unknown> = { ...tokens };
@@ -31,6 +31,23 @@ export interface AgentStats {
31
31
  readonly cost: number | null;
32
32
  }
33
33
 
34
+ /**
35
+ * What one compaction of an Agent's context reported (ADR-0043).
36
+ *
37
+ * Every field is null when pi reported nothing for it: a custom compaction
38
+ * handler may answer without usage, and "unknown" must stay distinct from 0.
39
+ */
40
+ export interface CompactionResult {
41
+ /** Context tokens before the summary replaced the transcript. */
42
+ readonly tokensBefore: number | null;
43
+ /** Estimated context tokens after the summary replaced the transcript. */
44
+ readonly tokensAfter: number | null;
45
+ /** Token breakdown of the summary call itself. */
46
+ readonly tokens: TokenBreakdown | null;
47
+ /** Cost of the summary call itself. */
48
+ readonly cost: number | null;
49
+ }
50
+
34
51
  /** Recorded inputs used to explain an Ask-hash mismatch without changing identity. */
35
52
  export interface AskMarkerContext {
36
53
  readonly prompt: string;
@@ -93,6 +110,27 @@ export interface AskCompletion {
93
110
  readonly recovered?: true;
94
111
  }
95
112
 
113
+ /**
114
+ * Opaque marker naming one compaction of an Agent's context.
115
+ *
116
+ * A compaction sits between two Asks, so it cannot ride the Agent-level frame
117
+ * streams: a Cassette replay switches those cursors to an Ask and never
118
+ * switches back. The marker gives the exchange its own recorded streams.
119
+ */
120
+ export interface CompactionMarker {
121
+ /** Monotonic per Agent, from 0. */
122
+ readonly index: number;
123
+ /** sha256 of the settled-Ask count and the custom instructions, when given. */
124
+ readonly hash: string;
125
+ /** Settled Asks of this Agent at the compaction point. */
126
+ readonly afterAsks: number;
127
+ }
128
+
129
+ /** Presence identifies Cassette playback of one compaction. */
130
+ export interface CompactionPlayback {
131
+ readonly result: CompactionResult;
132
+ }
133
+
96
134
  /** Presence identifies Cassette playback, including recorded successful Asks. */
97
135
  export interface AskPlayback {
98
136
  /** Limit outcome recorded for this Ask, if it rejected with ASK_LIMIT. */
@@ -143,6 +181,15 @@ export interface AgentTransport {
143
181
  /** Reports surfaced live outcomes; replay ignores completion. */
144
182
  finishAsk(completion: AskCompletion): void;
145
183
 
184
+ /**
185
+ * Begins one compaction. Live transports return undefined; replay transports
186
+ * return the recorded result.
187
+ */
188
+ beginCompaction(marker: CompactionMarker): CompactionPlayback | undefined;
189
+
190
+ /** Ends the compaction exchange, so later frames belong to the Agent again. */
191
+ finishCompaction(result: CompactionResult | undefined): void;
192
+
146
193
  /**
147
194
  * The extraction policy recorded for the Ask at `index`, when a Cassette
148
195
  * backs it. An Ask recorded under an older policy keeps that policy, so its
@@ -200,6 +247,22 @@ export interface OpenOptions {
200
247
  readonly sessionDir?: string;
201
248
  /** Resumes an existing pi session, translated to `--session <path>`. */
202
249
  readonly sessionFile?: string;
250
+ /** How the Agent came to be; absent means an ordinary spawn. Spawn identity. */
251
+ readonly origin?: "fork";
252
+ /** Name of the Agent this one was forked from. Spawn identity; never argv. */
253
+ readonly forkOf?: string;
254
+ /** Settled Asks of the fork source at the fork point. Spawn identity. */
255
+ readonly forkAsks?: number;
256
+ /**
257
+ * The fork source's session file, translated to `pi --fork <path>`.
258
+ * Never spawn identity: the path is machine-specific.
259
+ */
260
+ readonly forkSession?: string;
261
+ /**
262
+ * The fork source's worktree, so the fork branches from it instead of from a
263
+ * clean base tree. Never spawn identity.
264
+ */
265
+ readonly worktreeFrom?: WorktreeResolution;
203
266
  }
204
267
 
205
268
  /** Nondeterministic worktree identity resolved below the transport seam. */
@@ -28,9 +28,17 @@ export function worktreeTransport(inner: TransportFactory): TransportFactory {
28
28
  observeStartup?: TransportStartupObserver,
29
29
  ): Promise<AgentTransport> {
30
30
  if (options.worktree !== true) return inner.open(options, observeStartup);
31
- const root = await repositoryRoot(options.cwd, options.name);
32
- await requireClean(root, options.name);
33
- const resolution = await addWorktree({ root, name: options.name });
31
+ const from = options.worktreeFrom;
32
+ const root = await repositoryRoot(from?.cwd ?? options.cwd, options.name);
33
+ // A fork branches from what its source already committed, so the source's
34
+ // dirty files stay behind and no clean check applies (ADR-0044).
35
+ if (from === undefined) await requireClean(root, options.name);
36
+ const resolution = await addWorktree({
37
+ root,
38
+ name: options.name,
39
+ namingRoot: await namingRoot(root),
40
+ startPoint: from?.branch ?? "HEAD",
41
+ });
34
42
  const { worktree: _worktree, ...innerOptions } = options;
35
43
  let reported = false;
36
44
  const transport = await inner.open(
@@ -69,18 +77,43 @@ async function requireClean(cwd: string, name: string): Promise<void> {
69
77
  }
70
78
  }
71
79
 
72
- async function addWorktree(options: { readonly root: string; readonly name: string }): Promise<{
80
+ /**
81
+ * The repository the worktree directory is named after.
82
+ *
83
+ * For a worktree it is the main repository, not the worktree itself, so a fork
84
+ * of a Worktree Agent does not nest `repo-worktrees/a1-worktrees/a2`.
85
+ */
86
+ async function namingRoot(root: string): Promise<string> {
87
+ const result = await git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
88
+ const common = result.stdout.trim();
89
+ if (result.code !== 0 || common === "" || !common.endsWith("/.git")) return root;
90
+ return common.slice(0, -"/.git".length);
91
+ }
92
+
93
+ async function addWorktree(options: {
94
+ readonly root: string;
95
+ readonly name: string;
96
+ readonly namingRoot: string;
97
+ readonly startPoint: string;
98
+ }): Promise<{
73
99
  readonly cwd: string;
74
100
  readonly branch: string;
75
101
  }> {
76
- const parent = join(dirname(options.root), `${basename(options.root)}-worktrees`);
102
+ const parent = join(dirname(options.namingRoot), `${basename(options.namingRoot)}-worktrees`);
77
103
  await mkdir(parent, { recursive: true });
78
104
  for (let suffix = 1; ; suffix += 1) {
79
105
  const leaf = suffix === 1 ? options.name : `${options.name}-${suffix}`;
80
106
  const branch = `yaag/${leaf}`;
81
107
  const cwd = join(parent, leaf);
82
108
  if (await candidateTaken(options.root, branch, cwd)) continue;
83
- const result = await git(options.root, ["worktree", "add", "-b", branch, cwd, "HEAD"]);
109
+ const result = await git(options.root, [
110
+ "worktree",
111
+ "add",
112
+ "-b",
113
+ branch,
114
+ cwd,
115
+ options.startPoint,
116
+ ]);
84
117
  if (result.code === 0) return { cwd, branch };
85
118
  if (isCollision(result)) continue;
86
119
  throw new Error(`git worktree add failed: ${result.stderr.trim() || result.stdout.trim()}`);