@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/runtime",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,80 @@
1
+ import { agentError } from "../errors.ts";
2
+ import type { EventSink } from "../events.ts";
3
+ import type { AgentTransport, CompactionResult, Connection } from "../transport/index.ts";
4
+ import { readCompaction } from "../transport/index.ts";
5
+ import type { AgentUsage } from "./agent-usage.ts";
6
+
7
+ /** One compaction of one Agent's context (ADR-0043). */
8
+ export interface CompactionExchange {
9
+ readonly agent: string;
10
+ readonly connection: Connection;
11
+ readonly transport: AgentTransport;
12
+ /** Monotonic per Agent, from 0. */
13
+ readonly index: number;
14
+ /** Settled Asks of this Agent at the compaction point. */
15
+ readonly afterAsks: number;
16
+ /** pi `customInstructions`; the text never rides the wire (ticket 09). */
17
+ readonly instructions?: string;
18
+ readonly emit: EventSink;
19
+ readonly usage: AgentUsage;
20
+ }
21
+
22
+ /**
23
+ * Compacts one Agent's context and reports what the summary call cost.
24
+ *
25
+ * Playback answers from the Cassette and emits the same event, but folds no
26
+ * usage: a replayed Run never reports live accounting.
27
+ */
28
+ export async function compactAgent(exchange: CompactionExchange): Promise<CompactionResult> {
29
+ const playback = exchange.transport.beginCompaction({
30
+ index: exchange.index,
31
+ hash: compactionHash(exchange.afterAsks, exchange.instructions),
32
+ afterAsks: exchange.afterAsks,
33
+ });
34
+ let result: CompactionResult | undefined;
35
+ try {
36
+ result = playback === undefined ? await live(exchange) : playback.result;
37
+ } finally {
38
+ exchange.transport.finishCompaction(result);
39
+ }
40
+ if (playback === undefined && result.tokens !== null && result.cost !== null) {
41
+ exchange.usage.add({ tokens: result.tokens, cost: result.cost });
42
+ }
43
+ exchange.emit({
44
+ type: "agent_compaction",
45
+ agent: exchange.agent,
46
+ index: exchange.index,
47
+ tokensBefore: result.tokensBefore,
48
+ tokensAfter: result.tokensAfter,
49
+ tokens: result.tokens,
50
+ cost: result.cost,
51
+ custom: exchange.instructions !== undefined,
52
+ });
53
+ return result;
54
+ }
55
+
56
+ /** Runs the pi `compact` command and parses what it reported. */
57
+ async function live(exchange: CompactionExchange): Promise<CompactionResult> {
58
+ const response = await exchange.connection.command({
59
+ type: "compact",
60
+ ...(exchange.instructions === undefined ? {} : { customInstructions: exchange.instructions }),
61
+ });
62
+ if (!response.success) {
63
+ throw agentError(
64
+ exchange.agent,
65
+ "COMPACT_FAILED",
66
+ `compaction failed: ${response.error ?? "no reason reported"}`,
67
+ );
68
+ }
69
+ return readCompaction({ type: "response", data: response.data });
70
+ }
71
+
72
+ /**
73
+ * Identity of one compaction: where it sits in the Agent's conversation, and
74
+ * whether the program steered it. The instruction text is hashed, never stored.
75
+ */
76
+ export function compactionHash(afterAsks: number, instructions: string | undefined): string {
77
+ const hasher = new Bun.CryptoHasher("sha256");
78
+ hasher.update(JSON.stringify({ afterAsks, instructions: instructions ?? null }));
79
+ return hasher.digest("hex");
80
+ }
@@ -23,6 +23,17 @@ export class AgentUsage {
23
23
  observe(frame: Frame): void {
24
24
  const usage = usageFromFrame(frame);
25
25
  if (usage === null) return;
26
+ this.add(usage);
27
+ }
28
+
29
+ /**
30
+ * Folds one completion yaag observed outside the frame stream, and reports.
31
+ *
32
+ * A compaction's summary call is the only such completion today: pi answers
33
+ * it on the `compact` response instead of an assistant `message_end`, so the
34
+ * live accounting would lose that spend (ADR-0043).
35
+ */
36
+ add(usage: AgentUsageSnapshot): void {
26
37
  this.#tokens = {
27
38
  input: this.#tokens.input + usage.tokens.input,
28
39
  output: this.#tokens.output + usage.tokens.output,
@@ -5,10 +5,23 @@ import { ReportResultTool } from "../ask-contract/index.ts";
5
5
  import { agentError } from "../errors.ts";
6
6
  import type { EventSink } from "../events.ts";
7
7
  import type { ModelErrorHistory, ModelResolution } from "../model/index.ts";
8
- import type { AgentStats, AgentTransport } from "../transport/index.ts";
8
+ import type {
9
+ AgentStats,
10
+ AgentTransport,
11
+ CompactionResult,
12
+ WorktreeResolution,
13
+ } from "../transport/index.ts";
9
14
  import { Connection } from "../transport/index.ts";
10
- import type { AskOptions, Handle, ResolvedSpawnOptions, StructuredAskOptions } from "../types.ts";
15
+ import type {
16
+ AskOptions,
17
+ ForkOptions,
18
+ Handle,
19
+ ResolvedSpawnOptions,
20
+ StructuredAskOptions,
21
+ } from "../types.ts";
22
+ import { compactAgent } from "./agent-compaction.ts";
11
23
  import { AgentUsage } from "./agent-usage.ts";
24
+ import type { ForkSpawner } from "./fork.ts";
12
25
 
13
26
  /** The mid-Ask Model Resolution one Agent's spawn handed it (ADR-0038). */
14
27
  export interface AgentModelFallback {
@@ -32,6 +45,10 @@ export interface AgentOptions {
32
45
  * Absent when the spawn named no candidate: there is nothing to fall back from.
33
46
  */
34
47
  readonly modelFallback?: AgentModelFallback;
48
+ /** pi's session file for this Agent; a fork copies it. Absent during playback. */
49
+ readonly sessionFile?: string;
50
+ /** Opens a fork of this Agent through the Run's spawn gate (ADR-0044). */
51
+ readonly fork?: ForkSpawner;
35
52
  /** Definition-owned defaults merged below explicit per-Ask options. */
36
53
  readonly askDefaults?: AskOptions;
37
54
  /** Definition identity recorded on its Asks, outside replay identity. */
@@ -64,9 +81,13 @@ export class Agent implements Handle {
64
81
  #candidate: string;
65
82
  readonly #usage: AgentUsage;
66
83
  readonly #reportResultTool = new ReportResultTool();
84
+ readonly #sessionFile: string | undefined;
85
+ readonly #fork: ForkSpawner | undefined;
67
86
  #busy = false;
68
87
  #incomplete = false;
69
88
  #askIndex = 0;
89
+ #settledAsks = 0;
90
+ #compactionIndex = 0;
70
91
  #closing: Promise<AgentStats> | null = null;
71
92
 
72
93
  constructor(options: AgentOptions) {
@@ -81,6 +102,8 @@ export class Agent implements Handle {
81
102
  this.#spawnOptions = options.spawnOptions;
82
103
  this.#askDefaults = options.askDefaults ?? {};
83
104
  this.#definitionName = options.definitionName;
105
+ this.#sessionFile = options.sessionFile;
106
+ this.#fork = options.fork;
84
107
  this.#askLimitGraceMs = options.askLimitGraceMs;
85
108
  this.#idleAbortSettleMs = options.idleAbortSettleMs;
86
109
  this.#stallProbeSettleMs = options.stallProbeSettleMs;
@@ -144,9 +167,78 @@ export class Agent implements Handle {
144
167
  });
145
168
  } finally {
146
169
  this.#busy = false;
170
+ // A failed Ask settles the conversation as much as a successful one: the
171
+ // fork point is the boundary, not the outcome.
172
+ this.#settledAsks += 1;
147
173
  }
148
174
  }
149
175
 
176
+ /** Replaces this Agent's context with a summary of it (ADR-0043). */
177
+ compact(instructions?: string): Promise<CompactionResult> {
178
+ if (this.#connection.dead)
179
+ return Promise.reject(agentError(this.name, "AGENT_DIED", "agent is no longer running"));
180
+ if (this.#busy) {
181
+ return Promise.reject(
182
+ agentError(this.name, "COMPACT_DURING_ASK", "an Ask is in flight — compact between Asks"),
183
+ );
184
+ }
185
+ return compactAgent({
186
+ agent: this.name,
187
+ connection: this.#connection,
188
+ transport: this.#transport,
189
+ index: this.#compactionIndex++,
190
+ afterAsks: this.#settledAsks,
191
+ ...(instructions === undefined ? {} : { instructions }),
192
+ emit: this.#emit,
193
+ usage: this.#usage,
194
+ });
195
+ }
196
+
197
+ /** Spawns a new Agent from a copy of this Agent's session (ADR-0044). */
198
+ fork(overrides?: ForkOptions): Promise<Handle> {
199
+ const spawner = this.#fork;
200
+ if (spawner === undefined) {
201
+ return Promise.reject(
202
+ agentError(this.name, "FORK_REFUSED", "this Agent's Run cannot open a fork"),
203
+ );
204
+ }
205
+ if (this.#busy) {
206
+ return Promise.reject(
207
+ agentError(this.name, "FORK_DURING_ASK", "an Ask is in flight — fork between Asks"),
208
+ );
209
+ }
210
+ if (this.#incomplete) {
211
+ return Promise.reject(
212
+ agentError(
213
+ this.name,
214
+ "FORK_REFUSED",
215
+ "the Agent died mid-Ask, so the tail of its session is undefined",
216
+ ),
217
+ );
218
+ }
219
+ const sessionFile = this.#sessionFile;
220
+ if (sessionFile === undefined) {
221
+ return Promise.reject(
222
+ agentError(this.name, "FORK_REFUSED", "the Agent holds no session file to fork"),
223
+ );
224
+ }
225
+ return spawner(
226
+ {
227
+ name: this.name,
228
+ spawnOptions: this.#spawnOptions,
229
+ sessionFile,
230
+ worktree: this.#worktree(),
231
+ settledAsks: this.#settledAsks,
232
+ handle: this,
233
+ },
234
+ overrides,
235
+ );
236
+ }
237
+
238
+ #worktree(): WorktreeResolution | undefined {
239
+ return this.branch === undefined ? undefined : { cwd: this.cwd, branch: this.branch };
240
+ }
241
+
150
242
  /** True when the Agent was killed mid-Ask, so its cost is a floor (ADR-0012). */
151
243
  get incomplete(): boolean {
152
244
  return this.#incomplete;
@@ -0,0 +1,74 @@
1
+ import type { WorktreeResolution } from "../transport/index.ts";
2
+ import type { ForkOptions, Handle, ResolvedSpawnOptions, SpawnOptions } from "../types.ts";
3
+
4
+ /** What one Agent hands the spawn gate when it is forked (ADR-0044). */
5
+ export interface ForkSource {
6
+ readonly name: string;
7
+ /** The options the source settled on; the verbatim inheritance base. */
8
+ readonly spawnOptions: ResolvedSpawnOptions;
9
+ /** pi's session file for the source; a fork without one is refused. */
10
+ readonly sessionFile: string | undefined;
11
+ readonly worktree: WorktreeResolution | undefined;
12
+ /** Settled Asks of the source at the fork point. */
13
+ readonly settledAsks: number;
14
+ /** The source Handle itself, which becomes the fork's default Parent Link. */
15
+ readonly handle: Handle;
16
+ }
17
+
18
+ /** Opens one fork. The spawn gate owns it, so a fork walks the normal spawn path. */
19
+ export type ForkSpawner = (source: ForkSource, overrides?: ForkOptions) => Promise<Handle>;
20
+
21
+ /** Spawn identity and launch facts that only a fork carries. */
22
+ export interface ForkIdentity {
23
+ readonly forkOf: string;
24
+ readonly forkAsks: number;
25
+ /** The session pi copies with `--fork`; machine-specific, never identity. */
26
+ readonly forkSession: string;
27
+ readonly worktreeFrom?: WorktreeResolution;
28
+ }
29
+
30
+ /** One fork request: what to spawn, whose child it is, and whether to compact it. */
31
+ export interface ForkRequest {
32
+ /** Inherited options with the overrides applied; never carries `name`. */
33
+ readonly spawnOptions: SpawnOptions;
34
+ /** The raw Parent Link, defaulting to the fork source. */
35
+ readonly parent: unknown;
36
+ readonly fork: ForkIdentity;
37
+ readonly compact: boolean | string | undefined;
38
+ }
39
+
40
+ /**
41
+ * Merges a fork source with explicit overrides into one spawn request.
42
+ *
43
+ * Inheritance is verbatim, with two deliberate exceptions: `name` is dropped,
44
+ * so the child gets a fresh auto-name unless the overrides give it one, and
45
+ * `compact` never reaches the spawn options, because it is a fork verb rather
46
+ * than a property of the Agent.
47
+ */
48
+ export function forkRequest(source: ForkSource, overrides?: ForkOptions): ForkRequest {
49
+ if (source.sessionFile === undefined) {
50
+ throw new Error("a fork source must hold a session file");
51
+ }
52
+ const { name: _name, ...inherited } = source.spawnOptions;
53
+ const { compact, parent, ...explicit } = overrides ?? {};
54
+ return {
55
+ spawnOptions: { ...inherited, ...stripUndefined(explicit) },
56
+ parent: parent ?? source.handle,
57
+ fork: {
58
+ forkOf: source.name,
59
+ forkAsks: source.settledAsks,
60
+ forkSession: source.sessionFile,
61
+ ...(source.worktree === undefined ? {} : { worktreeFrom: source.worktree }),
62
+ },
63
+ compact,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Drops explicitly undefined overrides, so `fork({ model: undefined })` keeps
69
+ * the inherited value instead of erasing it.
70
+ */
71
+ function stripUndefined(options: Omit<ForkOptions, "compact" | "parent">): SpawnOptions {
72
+ const entries = Object.entries(options).filter(([, value]) => value !== undefined);
73
+ return Object.fromEntries(entries) as SpawnOptions;
74
+ }
@@ -10,4 +10,5 @@ export {
10
10
  defineAgent,
11
11
  isAgentDefinition,
12
12
  } from "./define-agent.ts";
13
+ export { type ForkSource, type ForkSpawner, forkRequest } from "./fork.ts";
13
14
  export { makeSpawn } from "./spawn.ts";
@@ -0,0 +1,249 @@
1
+ import { resolve } from "node:path";
2
+ import { YaagError } from "../errors.ts";
3
+ import {
4
+ ModelErrorHistory,
5
+ type ModelSelection,
6
+ normalizeModelResolution,
7
+ resolveModel,
8
+ resolveRecordedModel,
9
+ } from "../model/index.ts";
10
+ import type {
11
+ AgentTransport,
12
+ TransportFactory,
13
+ TransportStartup,
14
+ WorktreeResolution,
15
+ } from "../transport/index.ts";
16
+ import type { AskOptions, Handle, ResolvedSpawnOptions, SpawnOptions } from "../types.ts";
17
+ import { Agent } from "./agent.ts";
18
+ import { uniqueAgentName } from "./agent-names.ts";
19
+ import type { ForkIdentity, ForkSpawner } from "./fork.ts";
20
+ import type { SpawnDependencies } from "./spawn.ts";
21
+ import { resolveSpawnExtensions } from "./spawn-extensions.ts";
22
+ import { resolveParent } from "./spawn-parent.ts";
23
+ import { openRequest, withSelection } from "./spawn-request.ts";
24
+
25
+ /** One request the open path serves: a plain spawn, or a fork of another Agent. */
26
+ export interface OpenAgentRequest {
27
+ /** Never carries `parent`: a Handle must not reach the recorded options. */
28
+ readonly spawnOptions: SpawnOptions;
29
+ /** The raw Parent Link option, validated by `resolveParent`. */
30
+ readonly parent: unknown;
31
+ readonly askDefaults: AskOptions | undefined;
32
+ /** Definition identity stays separate from a topology-overridden Agent name. */
33
+ readonly definitionName: string | undefined;
34
+ /** Present only for a fork; adds `origin: "fork"` and the `--fork` source. */
35
+ readonly fork?: ForkIdentity;
36
+ }
37
+
38
+ /** What the open path needs beyond the Run's spawn dependencies. */
39
+ export interface OpenAgentContext extends SpawnDependencies {
40
+ /** Agent names already allocated in this Run. */
41
+ readonly taken: Set<string>;
42
+ /** How an opened Agent forks itself later. */
43
+ readonly forkSpawner: ForkSpawner;
44
+ }
45
+
46
+ /**
47
+ * Opens one Agent: resolves extensions and a model, starts its transport, and
48
+ * emits `agent_spawn`.
49
+ *
50
+ * A fork walks this very path, so the Tool Contract probe, Model Resolution,
51
+ * the worktree wrapper and the Cassette identity cannot drift between a spawn
52
+ * and a fork (ADR-0044).
53
+ */
54
+ export async function openAgent(
55
+ deps: OpenAgentContext,
56
+ request: OpenAgentRequest,
57
+ ): Promise<Handle> {
58
+ const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
59
+ const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, deps.taken);
60
+ // Resolved before any transport work, so a bad Parent Link costs nothing.
61
+ const parent = resolveParent(request.parent, deps.agents);
62
+ const resolution = normalizeModelResolution(request.spawnOptions);
63
+ // One history per Agent: its spawn loop and every mid-Ask fallback loop
64
+ // append to it, so a resolver sees every candidate that already failed.
65
+ const history = new ModelErrorHistory();
66
+ try {
67
+ // Extension paths do not vary per candidate, so a bad path fails once, generically.
68
+ const extensions = await resolveSpawnExtensions({
69
+ configExtensions: deps.configExtensions,
70
+ declared: request.spawnOptions.extensions,
71
+ useConfigExtensions: request.spawnOptions.configExtensions !== false,
72
+ ...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
73
+ projectRoot: cwd,
74
+ }).catch((error: unknown) => {
75
+ if (error instanceof YaagError) throw error;
76
+ throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
77
+ });
78
+ // One local for both open sites, so the launch arguments and the recorded
79
+ // identity cannot drift apart.
80
+ const extensionFields =
81
+ extensions === undefined
82
+ ? {}
83
+ : {
84
+ resolvedExtensionPaths: extensions.resolvedPaths,
85
+ declaredExtensions: extensions.declared,
86
+ };
87
+ const forkFields =
88
+ request.fork === undefined
89
+ ? {}
90
+ : {
91
+ origin: "fork" as const,
92
+ forkOf: request.fork.forkOf,
93
+ forkAsks: request.fork.forkAsks,
94
+ forkSession: request.fork.forkSession,
95
+ ...(request.fork.worktreeFrom === undefined
96
+ ? {}
97
+ : { worktreeFrom: request.fork.worktreeFrom }),
98
+ };
99
+ const attempt = async (
100
+ selection: ModelSelection,
101
+ ): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
102
+ const settled = withSelection(request.spawnOptions, selection);
103
+ return {
104
+ opened: await openTransport({
105
+ factory: deps.factory,
106
+ name,
107
+ cwd,
108
+ spawnOptions: settled,
109
+ ...extensionFields,
110
+ ...forkFields,
111
+ ...(parent === undefined ? {} : { parent }),
112
+ sessionDir: deps.sessionDir,
113
+ }),
114
+ spawnOptions: settled,
115
+ };
116
+ };
117
+ // The peek must stay in the same synchronous block as the first open: a
118
+ // Cassette-backed factory claims Agents in open order (ADR-0013).
119
+ const recorded = deps.factory.recordedSpawn?.(
120
+ openRequest({
121
+ name,
122
+ cwd,
123
+ spawnOptions: request.spawnOptions,
124
+ ...extensionFields,
125
+ ...forkFields,
126
+ ...(parent === undefined ? {} : { parent }),
127
+ sessionDir: deps.sessionDir,
128
+ }),
129
+ );
130
+ // A Cassette-backed spawn adopts the recorded resolved selection and skips
131
+ // the loop, so a replayed Run emits no spawn-time fallback (ADR-0039).
132
+ const adopted =
133
+ recorded === undefined ? undefined : resolveRecordedModel({ resolution, recorded });
134
+ // The adopted outcome carries the attempts the recording already spent, so
135
+ // a later mid-Ask fallback re-resolves from that attempt index (ADR-0039).
136
+ for (const skipped of adopted?.skipped ?? []) {
137
+ history.record(skipped.reason, skipped.failedModel);
138
+ }
139
+ const { opened, spawnOptions } =
140
+ adopted === undefined
141
+ ? await resolveModel({
142
+ resolution,
143
+ agent: name,
144
+ history,
145
+ onFallback: (fallback) => {
146
+ deps.emit({ type: "model_fallback", agent: name, ...fallback });
147
+ },
148
+ attempt,
149
+ })
150
+ : await attempt(adopted.selection);
151
+ const resolvedCwd = opened.startup.worktree?.cwd ?? cwd;
152
+ const branch = opened.startup.worktree?.branch;
153
+ const sessionFile = opened.startup.sessionFile;
154
+ const agent = new Agent({
155
+ name,
156
+ cwd: resolvedCwd,
157
+ branch,
158
+ transport: opened.transport,
159
+ emit: deps.emit,
160
+ spawnOptions,
161
+ // An Agent that named no candidate inherits pi's default model, so a
162
+ // failing Ask has nothing to fall back from and stays an Ask failure.
163
+ ...(spawnOptions.model === undefined
164
+ ? {}
165
+ : { modelFallback: { resolution, history, candidate: spawnOptions.model } }),
166
+ ...(sessionFile === undefined ? {} : { sessionFile }),
167
+ fork: deps.forkSpawner,
168
+ ...(request.askDefaults === undefined ? {} : { askDefaults: request.askDefaults }),
169
+ ...(request.definitionName === undefined ? {} : { definitionName: request.definitionName }),
170
+ });
171
+ deps.agents.push(agent);
172
+ deps.emit({
173
+ type: "agent_spawn",
174
+ agent: name,
175
+ model: agent.model,
176
+ cwd: resolvedCwd,
177
+ ...(branch === undefined ? {} : { branch }),
178
+ ...(sessionFile === undefined ? {} : { sessionFile }),
179
+ ...(parent === undefined ? {} : { parent }),
180
+ ...(request.fork === undefined ? {} : { origin: "fork" as const }),
181
+ });
182
+ return agent;
183
+ } catch (error) {
184
+ if (request.definitionName !== undefined && isSpawnFailure(error)) {
185
+ throw new YaagError(
186
+ "SPAWN_FAILED",
187
+ `definition "${request.definitionName}": ${error.message}`,
188
+ name,
189
+ );
190
+ }
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ function isSpawnFailure(error: unknown): error is YaagError {
196
+ return error instanceof YaagError && error.code === "SPAWN_FAILED";
197
+ }
198
+
199
+ interface OpenTransportOptions {
200
+ readonly factory: TransportFactory;
201
+ readonly name: string;
202
+ readonly cwd: string;
203
+ readonly spawnOptions: ResolvedSpawnOptions;
204
+ readonly resolvedExtensionPaths?: readonly string[];
205
+ readonly declaredExtensions?: readonly string[];
206
+ readonly parent?: string;
207
+ readonly origin?: "fork";
208
+ readonly forkOf?: string;
209
+ readonly forkAsks?: number;
210
+ readonly forkSession?: string;
211
+ readonly worktreeFrom?: WorktreeResolution;
212
+ readonly sessionDir: string | undefined;
213
+ }
214
+
215
+ interface OpenedTransport {
216
+ readonly transport: AgentTransport;
217
+ readonly startup: TransportStartup;
218
+ }
219
+
220
+ async function openTransport(options: OpenTransportOptions): Promise<OpenedTransport> {
221
+ const startup: TransportStartup = {};
222
+ try {
223
+ const transport = await options.factory.open(
224
+ openRequest({
225
+ name: options.name,
226
+ cwd: options.cwd,
227
+ spawnOptions: options.spawnOptions,
228
+ ...(options.resolvedExtensionPaths === undefined
229
+ ? {}
230
+ : { resolvedExtensionPaths: options.resolvedExtensionPaths }),
231
+ ...(options.declaredExtensions === undefined
232
+ ? {}
233
+ : { declaredExtensions: options.declaredExtensions }),
234
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
235
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
236
+ ...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
237
+ ...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
238
+ ...(options.forkSession === undefined ? {} : { forkSession: options.forkSession }),
239
+ ...(options.worktreeFrom === undefined ? {} : { worktreeFrom: options.worktreeFrom }),
240
+ sessionDir: options.sessionDir,
241
+ }),
242
+ (report) => Object.assign(startup, report),
243
+ );
244
+ return { transport, startup };
245
+ } catch (error) {
246
+ if (error instanceof YaagError) throw error;
247
+ throw new YaagError("SPAWN_FAILED", `agent "${options.name}": ${String(error)}`, options.name);
248
+ }
249
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ModelSelection } from "../model/index.ts";
2
- import type { OpenOptions } from "../transport/index.ts";
2
+ import type { OpenOptions, WorktreeResolution } from "../transport/index.ts";
3
3
  import type { ResolvedSpawnOptions, SpawnOptions } from "../types.ts";
4
4
 
5
5
  /** Everything one spawn needs to build its open request, before a model settles. */
@@ -11,6 +11,12 @@ export interface OpenRequestOptions {
11
11
  readonly declaredExtensions?: readonly string[];
12
12
  /** Resolved parent Agent name (Parent Link); spawn identity only, never argv. */
13
13
  readonly parent?: string;
14
+ /** Fork facts (ADR-0044); the first three are spawn identity, the rest are not. */
15
+ readonly origin?: "fork";
16
+ readonly forkOf?: string;
17
+ readonly forkAsks?: number;
18
+ readonly forkSession?: string;
19
+ readonly worktreeFrom?: WorktreeResolution;
14
20
  readonly sessionDir: string | undefined;
15
21
  }
16
22
 
@@ -54,6 +60,11 @@ export function openRequest(options: OpenRequestOptions): OpenOptions {
54
60
  ? {}
55
61
  : { declaredExtensions: options.declaredExtensions }),
56
62
  ...(options.parent === undefined ? {} : { parent: options.parent }),
63
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
64
+ ...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
65
+ ...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
66
+ ...(options.forkSession === undefined ? {} : { forkSession: options.forkSession }),
67
+ ...(options.worktreeFrom === undefined ? {} : { worktreeFrom: options.worktreeFrom }),
57
68
  ...(spawnOptions.worktree === true ? { worktree: true as const } : {}),
58
69
  ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
59
70
  };