@yaag/runtime 0.1.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.
Files changed (71) hide show
  1. package/package.json +25 -0
  2. package/src/agent-names.ts +20 -0
  3. package/src/agent-usage.ts +72 -0
  4. package/src/agent.ts +130 -0
  5. package/src/args-validation.ts +11 -0
  6. package/src/ask-activity.ts +84 -0
  7. package/src/ask-contract-identity.ts +96 -0
  8. package/src/ask-exchange-events.ts +60 -0
  9. package/src/ask-exchange-options.ts +32 -0
  10. package/src/ask-exchange.ts +291 -0
  11. package/src/ask-hash.ts +86 -0
  12. package/src/ask-limit.ts +189 -0
  13. package/src/ask-output-steering.ts +69 -0
  14. package/src/ask-output-tail.ts +166 -0
  15. package/src/ask-output.ts +109 -0
  16. package/src/ask-settlement.ts +37 -0
  17. package/src/ask-turn.ts +70 -0
  18. package/src/cassette-loader.ts +131 -0
  19. package/src/cassette-publish.ts +55 -0
  20. package/src/cassette-replay.ts +178 -0
  21. package/src/cassette-schema.ts +152 -0
  22. package/src/cassette.ts +275 -0
  23. package/src/checkpoint-dir.ts +89 -0
  24. package/src/connection.ts +123 -0
  25. package/src/define-agent.ts +83 -0
  26. package/src/define-run.ts +69 -0
  27. package/src/errors.ts +115 -0
  28. package/src/events.ts +143 -0
  29. package/src/extension-package.ts +88 -0
  30. package/src/extension-paths.ts +66 -0
  31. package/src/extension-source.ts +60 -0
  32. package/src/fake-transport.ts +240 -0
  33. package/src/frame-gap.ts +41 -0
  34. package/src/frame-queue.ts +52 -0
  35. package/src/git-facts.ts +32 -0
  36. package/src/idle-watch.ts +154 -0
  37. package/src/index.ts +96 -0
  38. package/src/jsonl.ts +42 -0
  39. package/src/live-transport.ts +210 -0
  40. package/src/node-decoder-subagent.ts +67 -0
  41. package/src/node-decoder-workflow.ts +74 -0
  42. package/src/node-decoder.ts +23 -0
  43. package/src/node-decoders.ts +9 -0
  44. package/src/node-details.ts +70 -0
  45. package/src/node-path.ts +36 -0
  46. package/src/node-tracker.ts +143 -0
  47. package/src/pi-state.ts +108 -0
  48. package/src/prompt-gist.ts +13 -0
  49. package/src/prompt.ts +80 -0
  50. package/src/reap.ts +59 -0
  51. package/src/recording-transport.ts +97 -0
  52. package/src/replay-divergence.ts +155 -0
  53. package/src/replay-transport.ts +72 -0
  54. package/src/resume-preconditions.ts +59 -0
  55. package/src/resume-transport.ts +165 -0
  56. package/src/run-checkpoint.ts +93 -0
  57. package/src/run-context.ts +19 -0
  58. package/src/run.ts +274 -0
  59. package/src/skill-probe.ts +247 -0
  60. package/src/skill-restriction-transport.ts +76 -0
  61. package/src/spawn.ts +241 -0
  62. package/src/summary-agent.ts +310 -0
  63. package/src/summary-nodes.ts +77 -0
  64. package/src/summary.ts +213 -0
  65. package/src/tool-probe-extension.ts +17 -0
  66. package/src/tool-probe.ts +141 -0
  67. package/src/transport.ts +178 -0
  68. package/src/types.ts +130 -0
  69. package/src/validation-errors.ts +70 -0
  70. package/src/wire-constants.ts +24 -0
  71. package/src/worktree-transport.ts +125 -0
@@ -0,0 +1,165 @@
1
+ import type { Cassette, CassetteAgent } from "./cassette.ts";
2
+ import { CassetteReplay } from "./cassette-replay.ts";
3
+ import type { AskLimitOutcome, AskStalledOutcome } from "./errors.ts";
4
+ import { FrameQueue } from "./frame-queue.ts";
5
+ import { replayMismatch } from "./replay-divergence.ts";
6
+ import { checkResumePreconditions } from "./resume-preconditions.ts";
7
+ import type {
8
+ AgentStats,
9
+ AgentTransport,
10
+ AskMarker,
11
+ AskPlayback,
12
+ Frame,
13
+ OpenOptions,
14
+ TransportFactory,
15
+ TransportStartupObserver,
16
+ } from "./transport.ts";
17
+
18
+ /**
19
+ * Creates a Cassette-prefix transport that continues with live pi at divergence.
20
+ *
21
+ * Matched Agents validate their recorded continuation point before replaying.
22
+ * Spawn mismatches intentionally bypass those checks and start a fresh Agent.
23
+ */
24
+ export function resumeTransport(cassette: Cassette, live: TransportFactory): TransportFactory {
25
+ let spawnCursor = 0;
26
+ return {
27
+ async open(
28
+ options: OpenOptions,
29
+ observeStartup?: TransportStartupObserver,
30
+ ): Promise<AgentTransport> {
31
+ const agent = cassette.agents[spawnCursor++] ?? null;
32
+ const mismatch = replayMismatch.spawn(agent, options);
33
+ if (mismatch) return live.open(options, observeStartup);
34
+ await checkResumePreconditions(agent);
35
+ const worktree = worktreeResolution(agent);
36
+ observeStartup?.({
37
+ ...(agent.sessionFile === undefined ? {} : { sessionFile: agent.sessionFile }),
38
+ ...(worktree === undefined ? {} : { worktree }),
39
+ });
40
+ return new ResumeTransport(agent, live, options);
41
+ },
42
+ };
43
+ }
44
+
45
+ class ResumeTransport implements AgentTransport {
46
+ readonly model: string;
47
+ readonly #agent: CassetteAgent;
48
+ readonly #liveFactory: TransportFactory;
49
+ readonly #continuation: OpenOptions;
50
+ readonly #replay: CassetteReplay;
51
+ readonly #frames = new FrameQueue();
52
+ readonly #buffered: Frame[] = [];
53
+ #askCursor = 0;
54
+ #mode: "replay" | "activating" | "live" = "replay";
55
+ #live: AgentTransport | null = null;
56
+ #activation: Promise<void> | null = null;
57
+ #activationFailed = false;
58
+ #closed: Promise<AgentStats> | null = null;
59
+
60
+ constructor(agent: CassetteAgent, liveFactory: TransportFactory, options: OpenOptions) {
61
+ this.#agent = agent;
62
+ this.#liveFactory = liveFactory;
63
+ this.#continuation = continuationOptions(agent, options);
64
+ this.#replay = new CassetteReplay(agent);
65
+ this.model = agent.model;
66
+ void this.#forward(this.#replay.frames());
67
+ }
68
+
69
+ send(frame: Frame): void {
70
+ if (this.#mode === "replay") {
71
+ this.#replay.send(frame);
72
+ return;
73
+ }
74
+ if (this.#mode === "activating") {
75
+ this.#buffered.push(frame);
76
+ return;
77
+ }
78
+ this.#live?.send(frame);
79
+ }
80
+
81
+ frames(): AsyncIterable<Frame> {
82
+ return this.#frames.frames();
83
+ }
84
+
85
+ beginAsk(marker: AskMarker): AskPlayback | undefined {
86
+ if (this.#mode === "live") return this.#live?.beginAsk(marker);
87
+ if (this.#mode === "activating") return undefined;
88
+ const mismatch = replayMismatch.ask(this.#agent, this.#askCursor, marker);
89
+ if (!mismatch) {
90
+ this.#askCursor += 1;
91
+ return this.#replay.beginAsk(marker);
92
+ }
93
+ this.#mode = "activating";
94
+ this.#activation = this.#activate(marker);
95
+ return undefined;
96
+ }
97
+
98
+ finishAsk(
99
+ outcome?: AskLimitOutcome,
100
+ stalled?: AskStalledOutcome,
101
+ invalidOutput?: import("./transport.ts").AskInvalidOutputPlayback,
102
+ ): void {
103
+ if (this.#mode === "live") this.#live?.finishAsk(outcome, stalled, invalidOutput);
104
+ }
105
+
106
+ close(): Promise<AgentStats> {
107
+ this.#closed ??= this.#shutdown();
108
+ return this.#closed;
109
+ }
110
+
111
+ async #activate(marker: AskMarker): Promise<void> {
112
+ // End the old source before attaching the new one, but keep the outward
113
+ // queue open so Connection remains attached across the handoff.
114
+ this.#replay.finish();
115
+ try {
116
+ const live = await this.#liveFactory.open(this.#continuation);
117
+ this.#live = live;
118
+ live.beginAsk(marker);
119
+ for (const frame of this.#buffered.splice(0)) live.send(frame);
120
+ this.#mode = "live";
121
+ void this.#forward(live.frames());
122
+ } catch {
123
+ this.#activationFailed = true;
124
+ this.#frames.end();
125
+ }
126
+ }
127
+
128
+ async #shutdown(): Promise<AgentStats> {
129
+ if (this.#mode === "replay") {
130
+ this.#replay.finish();
131
+ this.#frames.end();
132
+ return this.#replay.stats;
133
+ }
134
+ await this.#activation;
135
+ this.#frames.end();
136
+ if (this.#live) return this.#live.close();
137
+ return this.#activationFailed ? { tokens: null, cost: null } : this.#replay.stats;
138
+ }
139
+
140
+ async #forward(source: AsyncIterable<Frame>): Promise<void> {
141
+ for await (const frame of source) this.#frames.push(frame);
142
+ }
143
+ }
144
+
145
+ function continuationOptions(agent: CassetteAgent, original: OpenOptions): OpenOptions {
146
+ const { worktree: _worktree, ...spawn } = agent.spawn;
147
+ const worktree = worktreeResolution(agent);
148
+ return {
149
+ ...spawn,
150
+ ...(original.resolvedExtensionPaths === undefined
151
+ ? {}
152
+ : { resolvedExtensionPaths: original.resolvedExtensionPaths }),
153
+ ...(worktree === undefined ? {} : { cwd: worktree.cwd }),
154
+ ...(agent.sessionFile === undefined ? {} : { sessionFile: agent.sessionFile }),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * The resolution counts only when the recorded spawn actually requested a
160
+ * worktree: stray metadata on an ordinary spawn must not relocate the Agent
161
+ * or lend it a worktree identity.
162
+ */
163
+ function worktreeResolution(agent: CassetteAgent): CassetteAgent["worktree"] {
164
+ return agent.spawn.worktree === true ? agent.worktree : undefined;
165
+ }
@@ -0,0 +1,93 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { CassetteCollector, CassetteRun } from "./cassette.ts";
4
+ import { publishCassette } from "./cassette-publish.ts";
5
+ import { checkpointFileName } from "./checkpoint-dir.ts";
6
+ import type { RunOutcome } from "./events.ts";
7
+
8
+ /**
9
+ * One Run's publication request: what settled, what it collected, and where the
10
+ * artifact may go. `record` wins over `checkpointDir`; a Run that neither
11
+ * recorded nor stopped publishes nothing.
12
+ */
13
+ export interface RunCheckpointOptions {
14
+ /** Terminal outcome of the Run; only `stopped` publishes without `record`. */
15
+ readonly outcome: RunOutcome;
16
+ readonly collector: CassetteCollector;
17
+ /** Explicit `--record` destination; overrides the default checkpoint directory. */
18
+ readonly record: string | undefined;
19
+ /** Already-resolved default directory a stopped Run publishes into. */
20
+ readonly checkpointDir: string;
21
+ /** Program entry point recorded as invocation identity; omitted when unknown. */
22
+ readonly programFile: string | undefined;
23
+ readonly args: unknown;
24
+ }
25
+
26
+ /**
27
+ * Publishes the collected Cassette when the Run must be restorable: always under
28
+ * `--record`, and additionally into the checkpoint directory when the Run
29
+ * stopped (ADR-0021/0024). Returns the error that must become the Run's outcome,
30
+ * or null when there was nothing to publish or a primary program error owns it.
31
+ */
32
+ export async function publishRunCheckpoint(options: RunCheckpointOptions): Promise<unknown | null> {
33
+ const destination = checkpointDestination(options);
34
+ if (destination === null) return null;
35
+ try {
36
+ if (options.record === undefined) {
37
+ // Only the default directory is created; an explicit --record path keeps
38
+ // today's rule that its directory must already exist.
39
+ await mkdir(options.checkpointDir, { recursive: true, mode: 0o700 });
40
+ }
41
+ await publishCassette(destination, options.collector.cassette(await runBlock(options)));
42
+ return null;
43
+ } catch (error) {
44
+ // A secondary publication failure must not displace a primary program error.
45
+ if (options.outcome === "failed") {
46
+ writeRecordingDiagnostic(error);
47
+ return null;
48
+ }
49
+ if (options.outcome === "stopped") {
50
+ return new Error(`run stopped but is not restorable: ${String(error)}`, { cause: error });
51
+ }
52
+ // A requested checkpoint silently going missing is worse than rejecting an
53
+ // otherwise successful Run (ADR-0021).
54
+ return error;
55
+ }
56
+ }
57
+
58
+ function checkpointDestination(options: RunCheckpointOptions): string | null {
59
+ if (options.record !== undefined) return options.record;
60
+ if (options.outcome !== "stopped") return null;
61
+ return join(options.checkpointDir, checkpointFileName());
62
+ }
63
+
64
+ async function runBlock(options: RunCheckpointOptions): Promise<CassetteRun> {
65
+ const { outcome, programFile } = options;
66
+ if (programFile === undefined) return { outcome };
67
+ const programHash = await hashProgram(programFile);
68
+ return {
69
+ outcome,
70
+ programFile,
71
+ args: options.args,
72
+ ...(programHash === null ? {} : { programHash }),
73
+ };
74
+ }
75
+
76
+ /** Advisory only: an unreadable program file omits the hash and never fails the Run. */
77
+ async function hashProgram(programFile: string): Promise<string | null> {
78
+ try {
79
+ const bytes = await Bun.file(programFile).arrayBuffer();
80
+ return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** Reports a secondary Cassette publication failure without displacing the Run's primary error. */
87
+ export function writeRecordingDiagnostic(error: unknown): void {
88
+ try {
89
+ process.stderr.write(`[yaag] ${String(error)}\n`);
90
+ } catch {
91
+ // stderr diagnostics are best-effort: a broken stderr must not mask the Run's error either.
92
+ }
93
+ }
@@ -0,0 +1,19 @@
1
+ import type { AgentDefinition } from "./define-agent.ts";
2
+ import type { Handle, SpawnOptions, SpawnOverrides } from "./types.ts";
3
+
4
+ /** What an Orchestration Program is handed. */
5
+ export interface RunContext<Args = unknown> {
6
+ /** Program arguments, validated when the program declares a schema (ADR-0010). */
7
+ readonly args: Args;
8
+ /**
9
+ * Starts an Agent and resolves once it is confirmed up.
10
+ *
11
+ * Waits on a `get_state` round-trip, so a bad model fails here, before any
12
+ * tokens are spent. Killed automatically when the Run ends. Worktrees survive
13
+ * the Run and are never managed after creation. Definitions own policy;
14
+ * spawn overrides own topology. Runtime callers are validated even when they
15
+ * bypass TypeScript.
16
+ */
17
+ spawn(options?: SpawnOptions): Promise<Handle>;
18
+ spawn(definition: AgentDefinition, overrides?: SpawnOverrides): Promise<Handle>;
19
+ }
package/src/run.ts ADDED
@@ -0,0 +1,274 @@
1
+ import type { TSchema } from "typebox";
2
+ import type { Agent } from "./agent.ts";
3
+ import { validateArgs } from "./args-validation.ts";
4
+ import { type Cassette, CassetteCollector } from "./cassette.ts";
5
+ import { loadCassette } from "./cassette-loader.ts";
6
+ import { cleanStaleCheckpointTemp, resolveCheckpointDirectory } from "./checkpoint-dir.ts";
7
+ import { type OrchestrationProgram, programDefinition } from "./define-run.ts";
8
+ import { isYaagError, YaagError } from "./errors.ts";
9
+ import type { EventSink, LifecycleEvent, RunOutcome, StampedEventSink } from "./events.ts";
10
+ import { liveTransport } from "./live-transport.ts";
11
+ import { recordingTransport } from "./recording-transport.ts";
12
+ import { replayTransport } from "./replay-transport.ts";
13
+ import { resumeTransport } from "./resume-transport.ts";
14
+ import { publishRunCheckpoint, writeRecordingDiagnostic } from "./run-checkpoint.ts";
15
+ import type { RunContext } from "./run-context.ts";
16
+ import { liveSkillProbe, type SkillProbeFactory } from "./skill-probe.ts";
17
+ import { skillRestrictionTransport } from "./skill-restriction-transport.ts";
18
+ import { makeSpawn } from "./spawn.ts";
19
+ import { applyEvent, initialSummary, type RunSummary } from "./summary.ts";
20
+ import type { TransportFactory } from "./transport.ts";
21
+ import { worktreeTransport } from "./worktree-transport.ts";
22
+
23
+ export interface RunOptions {
24
+ /** Where Agents come from. Defaults to real `pi --mode rpc` processes. */
25
+ readonly transport?: TransportFactory;
26
+ readonly events?: StampedEventSink;
27
+ /** Delivered as `ctx.args`; validated when the program declares an args schema (ADR-0010). */
28
+ readonly args?: unknown;
29
+ /** Session storage directory for every Agent of this Run (ticket 06). */
30
+ readonly sessionDir?: string;
31
+ /**
32
+ * Absolute Orchestration Program source path used to resolve portable relative
33
+ * extension declarations. This is Run context, never an Agent working directory.
34
+ */
35
+ readonly programFile?: string;
36
+ /** Resolves pi's enabled skills for live restriction requests. */
37
+ readonly skillProbe?: SkillProbeFactory;
38
+ /** Write every public transport-seam frame once the Run settles. */
39
+ readonly record?: string;
40
+ /** Re-run against this versioned Cassette instead of opening a live transport. */
41
+ readonly replay?: string;
42
+ /** Replay a matching Cassette prefix, then continue live from its session. */
43
+ readonly resume?: string;
44
+ /** Ends the Run early; every Agent is still reaped and costed. */
45
+ readonly signal?: AbortSignal;
46
+ /** Overrides the default checkpoint directory a stopped Run publishes into (ADR-0021). */
47
+ readonly checkpointDir?: string;
48
+ }
49
+
50
+ /**
51
+ * Executes one Orchestration Program and resolves with its return value.
52
+ *
53
+ * Every Agent is dead by the time this settles — on success, on failure, and on
54
+ * abort (ADR-0002) — and every Agent's cost has been emitted. Rejects with
55
+ * ARGS_INVALID before emitting an event when declared argument schema validation fails.
56
+ */
57
+ export async function executeRun<Args, Result>(
58
+ program: OrchestrationProgram<Args, Result>,
59
+ options: RunOptions = {},
60
+ ): Promise<Result> {
61
+ if (options.record !== undefined && options.replay !== undefined) {
62
+ throw new YaagError("OPTIONS_CONFLICT", "cannot use record and replay together");
63
+ }
64
+ if (options.resume !== undefined && options.replay !== undefined) {
65
+ throw new YaagError("OPTIONS_CONFLICT", "cannot use resume and replay together");
66
+ }
67
+ const checkpointDir = options.checkpointDir ?? resolveCheckpointDirectory();
68
+ try {
69
+ // Best-effort, and emits no event, so the ARGS_INVALID "no events before
70
+ // rejection" rule still holds.
71
+ await cleanStaleCheckpointTemp(checkpointDir);
72
+ } catch (error) {
73
+ writeRecordingDiagnostic(error);
74
+ }
75
+ const replay = options.replay === undefined ? null : await loadCassette(options.replay);
76
+ const resume = options.resume === undefined ? null : await loadCassette(options.resume);
77
+ const definition = programDefinition(program);
78
+ // Collection is universal so every Run can checkpoint. Only pause, stop and
79
+ // --record flush (ADR-0021). That memory is potentially unbounded over
80
+ // arbitrarily many Asks — yaag has no Run token budget (ADR-0012/0017), and
81
+ // this ADR does not pretend one exists.
82
+ const collector = new CassetteCollector();
83
+ const args: unknown = Object.hasOwn(options, "args") ? options.args : {};
84
+ try {
85
+ assertArgs<Args>(definition.args, args);
86
+ } catch (error) {
87
+ await publishRunCheckpoint({
88
+ outcome: "failed",
89
+ collector,
90
+ record: options.record,
91
+ checkpointDir,
92
+ programFile: options.programFile,
93
+ args,
94
+ });
95
+ throw error;
96
+ }
97
+
98
+ let summary: RunSummary = initialSummary();
99
+ const sink: StampedEventSink = options.events ?? (() => {});
100
+ // Every event the Run emits also feeds the fold, so its own final accounting
101
+ // is derived exactly the way an outside observer would derive it. `at` is
102
+ // stamped here, once, so no individual emitter touches the wall clock.
103
+ const emit: EventSink = (body) => {
104
+ const event: LifecycleEvent = { ...body, at: Date.now() };
105
+ summary = applyEvent(summary, event);
106
+ sink(event);
107
+ };
108
+ const factory = selectFactory({ options, collector, replay, resume });
109
+ const agents: Agent[] = [];
110
+ const startedAt = Date.now();
111
+ const spawnGate = makeSpawn({
112
+ factory,
113
+ agents,
114
+ emit,
115
+ sessionDir: options.sessionDir,
116
+ programFile: options.programFile,
117
+ });
118
+
119
+ const ctx: RunContext<Args> = { args, spawn: spawnGate.spawn };
120
+ emit({ type: "run_start", program: definition.name ?? "program" });
121
+
122
+ // The program's settlement is held rather than rethrown from a `finally`, so
123
+ // publication can displace it when the checkpoint fails (ADR-0021).
124
+ let outcome: RunOutcome = "failed";
125
+ let settled: Settlement<Result>;
126
+ try {
127
+ const value = await Promise.race([definition.run(ctx), stopped(options.signal)]);
128
+ outcome = "completed";
129
+ settled = { ok: true, value };
130
+ } catch (error) {
131
+ outcome = stopOutcome(error);
132
+ settled = { ok: false, error };
133
+ }
134
+ spawnGate.close();
135
+ await reapAll({ agents, emit });
136
+ // run_end is the acknowledgement: nothing may report an outcome before the
137
+ // checkpoint's destination-directory fsync has landed (ADR-0021).
138
+ const failure = await publishRunCheckpoint({
139
+ outcome,
140
+ collector,
141
+ record: options.record,
142
+ checkpointDir,
143
+ programFile: options.programFile,
144
+ args,
145
+ });
146
+ emitRunEnd({
147
+ emit,
148
+ outcome: failure === null ? outcome : "failed",
149
+ durationMs: Date.now() - startedAt,
150
+ summary,
151
+ });
152
+ if (failure !== null) throw failure;
153
+ if (!settled.ok) throw settled.error;
154
+ return settled.value;
155
+ }
156
+
157
+ /** What the Orchestration Program body produced, before the Run acknowledges it. */
158
+ type Settlement<Result> =
159
+ | { readonly ok: true; readonly value: Result }
160
+ | { readonly ok: false; readonly error: unknown };
161
+
162
+ /** The stop signal is the only rejection that is not a Run failure (ADR-0022). */
163
+ function stopOutcome(error: unknown): RunOutcome {
164
+ return isYaagError(error) && error.code === "RUN_STOPPED" ? "stopped" : "failed";
165
+ }
166
+
167
+ /**
168
+ * Narrows the Run's incoming args to the program's declared Args at the one
169
+ * boundary where untyped input enters typed code. Schema programs are proven
170
+ * here by `validateArgs` (rejects ARGS_INVALID); schemaless programs declared
171
+ * Args themselves in `defineRun`, so the narrowing restates the program's own
172
+ * contract rather than inventing one.
173
+ */
174
+ function assertArgs<Args>(schema: TSchema | undefined, value: unknown): asserts value is Args {
175
+ if (schema !== undefined) validateArgs(schema, value);
176
+ }
177
+
178
+ /**
179
+ * Inputs that select the Run's transport composition. The collector receives
180
+ * every frame from every Run. Replay and resume hold the loaded Cassettes, or
181
+ * null when the Run does not use them.
182
+ */
183
+ export interface FactorySelection {
184
+ readonly options: RunOptions;
185
+ readonly collector: CassetteCollector;
186
+ readonly replay: Cassette | null;
187
+ readonly resume: Cassette | null;
188
+ }
189
+
190
+ /**
191
+ * Composes the Run's transport. Strict replay deliberately bypasses the
192
+ * worktree wrapper; resume retains its worktree-wrapped live side for unmatched
193
+ * spawns (ADR-0013). The live order is worktree → skill restriction → live, so
194
+ * skill discovery occurs in the resolved worktree cwd.
195
+ *
196
+ * The recorder is the outermost wrapper of every composition, replay included:
197
+ * it observes only, and never changes delegation (ADR-0013/0021).
198
+ */
199
+ export function selectFactory(selection: FactorySelection): TransportFactory {
200
+ const base =
201
+ selection.replay !== null ? replayTransport(selection.replay) : selectLiveFactory(selection);
202
+ return recordingTransport(base, selection.collector);
203
+ }
204
+
205
+ function selectLiveFactory(selection: FactorySelection): TransportFactory {
206
+ const restricted = skillRestrictionTransport(
207
+ selection.options.transport ?? liveTransport,
208
+ selection.options.skillProbe ?? liveSkillProbe,
209
+ );
210
+ const live = worktreeTransport(restricted);
211
+ return selection.resume === null ? live : resumeTransport(selection.resume, live);
212
+ }
213
+
214
+ /** Rejects when the Run is stopped; never resolves. */
215
+ function stopped(signal: AbortSignal | undefined): Promise<never> {
216
+ return new Promise<never>((_resolve, reject) => {
217
+ if (!signal) return;
218
+ if (signal.aborted) reject(new YaagError("RUN_STOPPED", "run stopped"));
219
+ signal.addEventListener("abort", () => reject(new YaagError("RUN_STOPPED", "run stopped")), {
220
+ once: true,
221
+ });
222
+ });
223
+ }
224
+
225
+ interface ReapOptions {
226
+ readonly agents: readonly Agent[];
227
+ readonly emit: EventSink;
228
+ }
229
+
230
+ /** Reaping never throws: a failed shutdown must not mask the Run's own error. */
231
+ async function reapAll({ agents, emit }: ReapOptions): Promise<void> {
232
+ await Promise.all(
233
+ agents.map(async (agent) => {
234
+ try {
235
+ await agent.close();
236
+ } catch {
237
+ // close() threw, so the Agent never emitted its own exit. Emit one, or
238
+ // the fold — and every observer — would lose the Agent entirely.
239
+ emit({
240
+ type: "agent_exit",
241
+ agent: agent.name,
242
+ tokens: null,
243
+ cost: null,
244
+ incomplete: true,
245
+ ...(agent.branch === undefined
246
+ ? {}
247
+ : { worktree: { cwd: agent.cwd, branch: agent.branch } }),
248
+ });
249
+ }
250
+ }),
251
+ );
252
+ }
253
+
254
+ interface RunEndOptions {
255
+ readonly emit: EventSink;
256
+ readonly outcome: RunOutcome;
257
+ readonly durationMs: number;
258
+ /** Settled fold: every Agent is reaped and the checkpoint is published by now. */
259
+ readonly summary: RunSummary;
260
+ }
261
+
262
+ /** Emits the Run's acknowledgement, the last Lifecycle Event of every Run. */
263
+ function emitRunEnd({ emit, outcome, durationMs, summary }: RunEndOptions): void {
264
+ emit({
265
+ type: "run_end",
266
+ ok: outcome === "completed",
267
+ outcome,
268
+ durationMs,
269
+ cost: summary.cost,
270
+ tokens: summary.tokens,
271
+ incomplete: summary.incomplete,
272
+ worstFrameGapMs: summary.worstFrameGapMs,
273
+ });
274
+ }