@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,178 @@
1
+ import type { CassetteAgent } from "./cassette.ts";
2
+ import { FrameQueue } from "./frame-queue.ts";
3
+ import type { AgentStats, AskMarker, AskPlayback, Frame } from "./transport.ts";
4
+
5
+ /**
6
+ * Replays one Cassette Agent's frame streams at the transport seam.
7
+ *
8
+ * It owns frame ordering and command-id remapping, but deliberately leaves
9
+ * mismatch policy to its caller. `finish()` ends playback for a live handoff.
10
+ */
11
+ export class CassetteReplay {
12
+ readonly model: string;
13
+ readonly stats: AgentStats;
14
+ readonly #agent: CassetteAgent;
15
+ readonly #queue = new FrameQueue();
16
+ #sentCursor = 0;
17
+ #receivedCursor = 0;
18
+ #sent: readonly Frame[] = [];
19
+ #received: readonly Frame[] = [];
20
+ #ids = new Map<string, string>();
21
+
22
+ constructor(agent: CassetteAgent) {
23
+ this.#agent = agent;
24
+ this.model = agent.model;
25
+ this.stats = agent.stats;
26
+ this.#sent = agent.sentFrames;
27
+ this.#received = agent.receivedFrames;
28
+ for (const frame of this.#received) this.#queue.push(frame);
29
+ }
30
+
31
+ /** Returns the recorded Agent whose Ask cursors this instance plays. */
32
+ get agent(): CassetteAgent {
33
+ return this.#agent;
34
+ }
35
+
36
+ /** Sends a frame through the recorded conversation, synthesizing internal responses. */
37
+ send(frame: Frame): void {
38
+ if (frame.type === "get_last_assistant_text") this.#skipCorrectionHistory();
39
+ const expected = this.#sent[this.#sentCursor];
40
+ if (expected && expected.type === frame.type) {
41
+ this.#sentCursor += 1;
42
+ this.#rememberId(expected, frame);
43
+ this.#releaseFor(frame.type);
44
+ this.#consumeControls();
45
+ return;
46
+ }
47
+ if (frame.type === "get_state") {
48
+ this.#respond(frame, { model: modelState(this.model) });
49
+ return;
50
+ }
51
+ if (frame.type === "get_session_stats") this.#respond(frame, this.stats);
52
+ }
53
+
54
+ /** Returns a single ordered stream of recorded incoming frames. */
55
+ frames(): AsyncIterable<Frame> {
56
+ return this.#queue.frames();
57
+ }
58
+
59
+ /** Moves playback to an Ask that its caller has already identity-checked. */
60
+ beginAsk(marker: AskMarker): AskPlayback {
61
+ const ask = this.#agent.asks[marker.index];
62
+ if (!ask) throw new Error("unreachable replay Ask cursor");
63
+ this.#sentCursor = 0;
64
+ this.#receivedCursor = 0;
65
+ this.#sent = ask.sentFrames;
66
+ this.#received = ask.receivedFrames;
67
+ const awaitsAbortSettlement = abortSettlementFollowsFinalText(ask);
68
+ return {
69
+ ...(ask.limit === undefined ? {} : { limit: ask.limit }),
70
+ ...(ask.stalled === undefined ? {} : { stalled: ask.stalled }),
71
+ ...(ask.outcome === undefined ? {} : { outcome: ask.outcome }),
72
+ ...(awaitsAbortSettlement ? { awaitsAbortSettlement: true as const } : {}),
73
+ };
74
+ }
75
+
76
+ /** Ends the stream cleanly, allowing a caller to switch to another source. */
77
+ finish(): void {
78
+ this.#queue.end();
79
+ }
80
+
81
+ #consumeControls(): void {
82
+ for (;;) {
83
+ const expected = this.#sent[this.#sentCursor];
84
+ if (expected === undefined || !this.#isRecordedControl(expected)) return;
85
+ this.#sentCursor += 1;
86
+ this.#releaseFor(expected.type);
87
+ }
88
+ }
89
+
90
+ /** Any non-first prompt within an Ask is a recorded correction (ADR-0027 amendment). */
91
+ #isRecordedControl(frame: Frame): boolean {
92
+ if (frame.type === "steer" || frame.type === "abort") return true;
93
+ return frame.type === "prompt" && this.#sentCursor > 0;
94
+ }
95
+
96
+ #skipCorrectionHistory(): void {
97
+ while (this.#isIntermediateTextQuery()) {
98
+ const skipped = this.#sent[this.#sentCursor];
99
+ if (typeof skipped?.id === "string") this.#ids.set(skipped.id, `cassette-${skipped.id}`);
100
+ this.#sentCursor += 1;
101
+ this.#releaseFor("get_last_assistant_text", true);
102
+ this.#consumeControls();
103
+ }
104
+ }
105
+
106
+ #isIntermediateTextQuery(): boolean {
107
+ return (
108
+ this.#sent[this.#sentCursor]?.type === "get_last_assistant_text" &&
109
+ this.#sent
110
+ .slice(this.#sentCursor + 1)
111
+ .some((frame) => frame.type === "get_last_assistant_text")
112
+ );
113
+ }
114
+
115
+ #releaseFor(command: string, skippedText = false): void {
116
+ for (; this.#receivedCursor < this.#received.length; this.#receivedCursor += 1) {
117
+ const frame = this.#received[this.#receivedCursor];
118
+ if (!frame) return;
119
+ if (frame.type === "response" && typeof frame.command === "string") {
120
+ if (
121
+ frame.command !== command &&
122
+ !(skippedText && frame.command === "get_last_assistant_text") &&
123
+ this.#hasFutureCommand(frame.command)
124
+ ) {
125
+ return;
126
+ }
127
+ }
128
+ this.#queue.push(this.#remapResponse(frame));
129
+ }
130
+ }
131
+
132
+ #hasFutureCommand(command: string): boolean {
133
+ return this.#sent.slice(this.#sentCursor).some((frame) => frame.type === command);
134
+ }
135
+
136
+ #rememberId(expected: Frame, actual: Frame): void {
137
+ if (typeof expected.id === "string" && typeof actual.id === "string") {
138
+ this.#ids.set(expected.id, actual.id);
139
+ }
140
+ }
141
+
142
+ #remapResponse(frame: Frame): Frame {
143
+ if (frame.type !== "response" || typeof frame.id !== "string") return frame;
144
+ const id = this.#ids.get(frame.id);
145
+ return id === undefined ? frame : { ...frame, id };
146
+ }
147
+
148
+ #respond(frame: Frame, data: Record<string, unknown> | AgentStats): void {
149
+ if (typeof frame.id !== "string") return;
150
+ this.#queue.push({ type: "response", command: frame.type, id: frame.id, success: true, data });
151
+ }
152
+ }
153
+
154
+ function abortSettlementFollowsFinalText(agent: CassetteAgent["asks"][number]): boolean {
155
+ const finalText = agent.sentFrames.findLastIndex(
156
+ (frame) => frame.type === "get_last_assistant_text",
157
+ );
158
+ if (
159
+ finalText === -1 ||
160
+ !agent.sentFrames.slice(finalText + 1).some((frame) => frame.type === "abort")
161
+ ) {
162
+ return false;
163
+ }
164
+ const abortResponse = agent.receivedFrames.findIndex(
165
+ (frame) => frame.type === "response" && frame.command === "abort",
166
+ );
167
+ return (
168
+ abortResponse !== -1 &&
169
+ agent.receivedFrames.slice(abortResponse + 1).some((frame) => frame.type === "agent_settled")
170
+ );
171
+ }
172
+
173
+ function modelState(model: string): Record<string, string> {
174
+ const slash = model.indexOf("/");
175
+ return slash === -1
176
+ ? { id: model }
177
+ : { provider: model.slice(0, slash), id: model.slice(slash + 1) };
178
+ }
@@ -0,0 +1,152 @@
1
+ import { type Static, Type } from "typebox";
2
+ import type { CanonicalJsonObject } from "./ask-contract-identity.ts";
3
+ import type { CassetteArtifact } from "./cassette.ts";
4
+ import type { Frame } from "./transport.ts";
5
+
6
+ /**
7
+ * Structure-only mirror of the hand-written Cassette interfaces; `cassette.ts`
8
+ * stays the public type authority. Semantic invariants — version gate, Ask
9
+ * positions, contract co-presence, canonical JSON — are loader post-checks.
10
+ * No node sets `additionalProperties`: unknown fields are tolerated so a newer
11
+ * artifact still loads.
12
+ */
13
+
14
+ const FrameSchema = Type.Unsafe<Frame>(Type.Object({ type: Type.String() }));
15
+
16
+ const ThinkingSchema = Type.Union(
17
+ (["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const).map((level) =>
18
+ Type.Literal(level),
19
+ ),
20
+ );
21
+
22
+ const StringArray = Type.Array(Type.String());
23
+
24
+ const spawnPolicyFields = {
25
+ model: Type.Optional(Type.String()),
26
+ systemPrompt: Type.Optional(Type.String()),
27
+ thinking: Type.Optional(ThinkingSchema),
28
+ appendSystemPrompt: Type.Optional(Type.String()),
29
+ tools: Type.Optional(StringArray),
30
+ disallowedTools: Type.Optional(StringArray),
31
+ skills: Type.Optional(StringArray),
32
+ disallowedSkills: Type.Optional(StringArray),
33
+ };
34
+
35
+ const CassetteSpawnSchema = Type.Object({
36
+ name: Type.String(),
37
+ cwd: Type.String(),
38
+ ...spawnPolicyFields,
39
+ worktree: Type.Optional(Type.Literal(true)),
40
+ });
41
+
42
+ const ContextSpawnSchema = Type.Object({
43
+ cwd: Type.Optional(Type.String()),
44
+ ...spawnPolicyFields,
45
+ worktree: Type.Optional(Type.Boolean()),
46
+ });
47
+
48
+ const contractFields = {
49
+ outputSchema: Type.Optional(Type.Unsafe<CanonicalJsonObject>(Type.Object({}))),
50
+ maxSteers: Type.Optional(Type.Integer({ minimum: 0 })),
51
+ extractionPolicy: Type.Optional(Type.String()),
52
+ };
53
+
54
+ const ContextAskSchema = Type.Object({
55
+ maxTurns: Type.Optional(Type.Number()),
56
+ maxToolCalls: Type.Optional(Type.Number()),
57
+ maxDurationMs: Type.Optional(Type.Number()),
58
+ idleMs: Type.Optional(Type.Number()),
59
+ wrapUpPrompt: Type.Optional(Type.String()),
60
+ ...contractFields,
61
+ });
62
+
63
+ const AskMarkerContextSchema = Type.Object({
64
+ prompt: Type.String(),
65
+ spawn: ContextSpawnSchema,
66
+ ask: ContextAskSchema,
67
+ });
68
+
69
+ const CassetteAskSchema = Type.Object({
70
+ // Plain number: the loader's Ask-position invariant owns every index error.
71
+ index: Type.Number(),
72
+ hash: Type.String(),
73
+ ...contractFields,
74
+ definitionName: Type.Optional(Type.String()),
75
+ context: Type.Optional(AskMarkerContextSchema),
76
+ sentFrames: Type.Array(FrameSchema),
77
+ receivedFrames: Type.Array(FrameSchema),
78
+ limit: Type.Optional(
79
+ Type.Object({
80
+ kind: Type.Union([
81
+ Type.Literal("turns"),
82
+ Type.Literal("toolCalls"),
83
+ Type.Literal("durationMs"),
84
+ ]),
85
+ count: Type.Integer({ minimum: 0 }),
86
+ }),
87
+ ),
88
+ stalled: Type.Optional(
89
+ Type.Object({ idleMs: Type.Number({ exclusiveMinimum: 0 }), destructive: Type.Boolean() }),
90
+ ),
91
+ outcome: Type.Optional(
92
+ Type.Object({
93
+ kind: Type.Literal("invalid_output"),
94
+ steeringEfforts: Type.Integer({ minimum: 0 }),
95
+ }),
96
+ ),
97
+ });
98
+
99
+ const TokenBreakdownSchema = Type.Object({
100
+ input: Type.Number(),
101
+ output: Type.Number(),
102
+ cacheRead: Type.Number(),
103
+ cacheWrite: Type.Number(),
104
+ total: Type.Number(),
105
+ });
106
+
107
+ const CassetteAgentSchema = Type.Object({
108
+ spawn: CassetteSpawnSchema,
109
+ model: Type.String(),
110
+ sessionFile: Type.Optional(Type.String()),
111
+ worktree: Type.Optional(Type.Object({ cwd: Type.String(), branch: Type.String() })),
112
+ git: Type.Optional(Type.Object({ branch: Type.String(), head: Type.String() })),
113
+ sentFrames: Type.Array(FrameSchema),
114
+ receivedFrames: Type.Array(FrameSchema),
115
+ asks: Type.Array(CassetteAskSchema),
116
+ stats: Type.Object({
117
+ tokens: Type.Union([Type.Null(), TokenBreakdownSchema]),
118
+ cost: Type.Union([Type.Null(), Type.Number()]),
119
+ }),
120
+ });
121
+
122
+ const CassetteRunSchema = Type.Object({
123
+ outcome: Type.Union(
124
+ (["completed", "failed", "stopped", "paused"] as const).map((outcome) => Type.Literal(outcome)),
125
+ ),
126
+ programFile: Type.Optional(Type.String()),
127
+ args: Type.Optional(Type.Unknown()),
128
+ programHash: Type.Optional(Type.String()),
129
+ });
130
+
131
+ /**
132
+ * Structure gate for one Cassette artifact read from disk. `run` stays optional
133
+ * here because version 1 predates it; the loader's presence invariant rejects a
134
+ * version 2 artifact without one, so field-path errors stay precise (no union
135
+ * of whole-artifact schemas).
136
+ */
137
+ export const CassetteSchema = Type.Object({
138
+ v: Type.Union([Type.Literal(1), Type.Literal(2)]),
139
+ run: Type.Optional(CassetteRunSchema),
140
+ agents: Type.Array(CassetteAgentSchema),
141
+ });
142
+
143
+ /**
144
+ * Compile-time soundness: every schema-accepted value IS a Cassette, so the
145
+ * loader's cast cannot drift. Completeness (schema not over-strict) is pinned
146
+ * by the committed real-pi fixture round-trip tests.
147
+ */
148
+ type AssertAssignable<T extends Base, Base> = T;
149
+ export type CassetteSchemaIsSound = AssertAssignable<
150
+ Static<typeof CassetteSchema>,
151
+ CassetteArtifact
152
+ >;
@@ -0,0 +1,275 @@
1
+ import type { CanonicalJsonObject } from "./ask-contract-identity.ts";
2
+ import type { AskLimitOutcome, AskStalledOutcome } from "./errors.ts";
3
+ import type { RunOutcome } from "./events.ts";
4
+ import type {
5
+ AgentStats,
6
+ AskInvalidOutputPlayback,
7
+ AskMarker,
8
+ AskMarkerContext,
9
+ Frame,
10
+ OpenOptions,
11
+ WorktreeResolution,
12
+ } from "./transport.ts";
13
+ import type { ThinkingLevel } from "./types.ts";
14
+
15
+ /** The Cassette format this runtime writes; the loader also reads version 1. */
16
+ export const CASSETTE_VERSION = 2 as const;
17
+
18
+ /** Run-level settlement record; the ADR-0021 v2 block. */
19
+ export interface CassetteRun {
20
+ /** The Run's terminal outcome. `paused` arrives with the pause slice. */
21
+ readonly outcome: RunOutcome;
22
+ /** Invocation identity: what a resume is about to re-execute. */
23
+ readonly programFile?: string;
24
+ readonly args?: unknown;
25
+ /** Advisory content hash of the program file; SHA-256 hex. */
26
+ readonly programHash?: string;
27
+ }
28
+
29
+ /** Versioned JSON artifact containing the frames exchanged during one Run. */
30
+ export interface Cassette {
31
+ readonly v: typeof CASSETTE_VERSION;
32
+ readonly run: CassetteRun;
33
+ readonly agents: readonly CassetteAgent[];
34
+ }
35
+
36
+ /** On-disk shape the loader accepts: v1 carries no run block, v2 carries one. */
37
+ export interface CassetteArtifact {
38
+ readonly v: 1 | typeof CASSETTE_VERSION;
39
+ readonly run?: CassetteRun;
40
+ readonly agents: readonly CassetteAgent[];
41
+ }
42
+
43
+ /** Git state observed at Agent spawn, when its cwd is a work tree. */
44
+ export interface CassetteGit {
45
+ readonly branch: string;
46
+ readonly head: string;
47
+ }
48
+
49
+ /** One Agent's independently ordered frame streams. */
50
+ export interface CassetteAgent {
51
+ readonly spawn: CassetteSpawn;
52
+ readonly model: string;
53
+ readonly sessionFile?: string;
54
+ /** Nondeterministic worktree location, deliberately outside spawn matching. */
55
+ readonly worktree?: WorktreeResolution;
56
+ readonly git?: CassetteGit;
57
+ readonly sentFrames: readonly Frame[];
58
+ readonly receivedFrames: readonly Frame[];
59
+ readonly asks: readonly CassetteAsk[];
60
+ readonly stats: AgentStats;
61
+ }
62
+
63
+ /** Behavioural identity supplied when opening a recorded Agent. */
64
+ export interface CassetteSpawn {
65
+ readonly name: string;
66
+ readonly cwd: string;
67
+ readonly model?: string;
68
+ readonly systemPrompt?: string;
69
+ /** pi thinking level, present only when explicitly requested. */
70
+ readonly thinking?: ThinkingLevel;
71
+ /** Text appended to pi's system prompt, present even when empty. */
72
+ readonly appendSystemPrompt?: string;
73
+ /** Tool allowlist, including an explicit empty list that disables all tools. */
74
+ readonly tools?: readonly string[];
75
+ /** Tool denylist, applied after the allowlist. */
76
+ readonly disallowedTools?: readonly string[];
77
+ /** Skill-name allowlist; resolved filesystem paths never enter a Cassette. */
78
+ readonly skills?: readonly string[];
79
+ /** Skill-name denylist; resolved filesystem paths never enter a Cassette. */
80
+ readonly disallowedSkills?: readonly string[];
81
+ /** Deterministic request, present only when the Agent requested a worktree. */
82
+ readonly worktree?: true;
83
+ }
84
+
85
+ /** The frames attributed to one Ask marker. */
86
+ export interface CassetteAsk {
87
+ readonly index: number;
88
+ readonly hash: string;
89
+ /** Canonical schema behavioral identity, never recorded model output. */
90
+ readonly outputSchema?: CanonicalJsonObject;
91
+ /** Explicit correction bound; omission remains distinct from the runtime default. */
92
+ readonly maxSteers?: number;
93
+ /** Extraction behavior identity, never recorded model output. */
94
+ readonly extractionPolicy?: string;
95
+ /** Definition identity for replay divergence diagnostics, never Ask identity. */
96
+ readonly definitionName?: string;
97
+ /** Recorded hash inputs for definition-specific divergence diagnostics. */
98
+ readonly context?: AskMarkerContext;
99
+ readonly sentFrames: readonly Frame[];
100
+ readonly receivedFrames: readonly Frame[];
101
+ /** Present only when the Ask surfaced ASK_LIMIT. */
102
+ readonly limit?: AskLimitOutcome;
103
+ /** Present only when the Ask surfaced ASK_STALLED. */
104
+ readonly stalled?: AskStalledOutcome;
105
+ /** Present only when the Ask surfaced ASK_INVALID_OUTPUT. */
106
+ readonly outcome?: AskInvalidOutputPlayback;
107
+ }
108
+
109
+ /** Sink consumed by recordingTransport without exposing its mutable state. */
110
+ export interface CassetteSink {
111
+ reserve(options: OpenOptions): CassetteRecorder;
112
+ cassette(run: CassetteRun): Cassette;
113
+ }
114
+
115
+ /** Per-Agent recorder returned when a factory open begins. */
116
+ export interface CassetteOpened {
117
+ readonly model: string;
118
+ readonly sessionFile?: string;
119
+ readonly worktree?: WorktreeResolution;
120
+ readonly git?: CassetteGit;
121
+ }
122
+
123
+ export interface CassetteRecorder {
124
+ opened(metadata: CassetteOpened): void;
125
+ failed(): void;
126
+ sent(frame: Frame): void;
127
+ received(frame: Frame): void;
128
+ beginAsk(marker: AskMarker): void;
129
+ finishAsk(
130
+ outcome?: AskLimitOutcome,
131
+ stalled?: AskStalledOutcome,
132
+ invalidOutput?: AskInvalidOutputPlayback,
133
+ ): void;
134
+ closed(stats: AgentStats): void;
135
+ }
136
+
137
+ interface MutableAsk {
138
+ readonly index: number;
139
+ readonly hash: string;
140
+ readonly outputSchema?: CanonicalJsonObject;
141
+ readonly maxSteers?: number;
142
+ readonly extractionPolicy?: string;
143
+ readonly definitionName?: string;
144
+ readonly context?: AskMarkerContext;
145
+ readonly sentFrames: Frame[];
146
+ readonly receivedFrames: Frame[];
147
+ limit?: AskLimitOutcome;
148
+ stalled?: AskStalledOutcome;
149
+ outcome?: AskInvalidOutputPlayback;
150
+ }
151
+
152
+ interface MutableAgent {
153
+ readonly spawn: CassetteSpawn;
154
+ readonly model: string;
155
+ readonly sessionFile?: string;
156
+ readonly worktree?: WorktreeResolution;
157
+ readonly git?: CassetteGit;
158
+ readonly sentFrames: Frame[];
159
+ readonly receivedFrames: Frame[];
160
+ readonly asks: MutableAsk[];
161
+ stats: AgentStats;
162
+ active: MutableAsk | null;
163
+ }
164
+
165
+ /** Collects a Cassette in memory; `executeRun` serializes it at Run settlement. */
166
+ export class CassetteCollector implements CassetteSink {
167
+ readonly #agents: (MutableAgent | null)[] = [];
168
+
169
+ reserve(options: OpenOptions): CassetteRecorder {
170
+ const slot = this.#agents.length;
171
+ const spawn = spawnIdentity(options);
172
+ let agent: MutableAgent | null = null;
173
+ this.#agents.push(null);
174
+ return {
175
+ opened: ({ model, sessionFile, worktree, git }): void => {
176
+ agent = {
177
+ spawn,
178
+ model,
179
+ ...(sessionFile === undefined ? {} : { sessionFile }),
180
+ ...(worktree === undefined ? {} : { worktree }),
181
+ ...(git === undefined ? {} : { git }),
182
+ sentFrames: [],
183
+ receivedFrames: [],
184
+ asks: [],
185
+ stats: { tokens: null, cost: null },
186
+ active: null,
187
+ };
188
+ this.#agents[slot] = agent;
189
+ },
190
+ failed: (): void => {
191
+ this.#agents[slot] = null;
192
+ },
193
+ sent: (frame): void => append(agent, "sentFrames", frame),
194
+ received: (frame): void => append(agent, "receivedFrames", frame),
195
+ beginAsk: (marker): void => {
196
+ if (!agent) return;
197
+ const ask = { ...marker, sentFrames: [], receivedFrames: [] };
198
+ agent.asks.push(ask);
199
+ agent.active = ask;
200
+ },
201
+ finishAsk: (outcome, stalled, invalidOutput): void => {
202
+ if (!agent?.active) return;
203
+ if (outcome !== undefined) agent.active.limit = outcome;
204
+ if (stalled !== undefined) agent.active.stalled = stalled;
205
+ if (invalidOutput !== undefined) agent.active.outcome = invalidOutput;
206
+ },
207
+ closed: (stats): void => {
208
+ if (agent) agent.stats = stats;
209
+ },
210
+ };
211
+ }
212
+
213
+ cassette(run: CassetteRun): Cassette {
214
+ return {
215
+ v: CASSETTE_VERSION,
216
+ run,
217
+ agents: this.#agents
218
+ .filter((agent): agent is MutableAgent => agent !== null)
219
+ .map(
220
+ ({
221
+ spawn,
222
+ model,
223
+ sessionFile,
224
+ worktree,
225
+ git,
226
+ sentFrames,
227
+ receivedFrames,
228
+ asks,
229
+ stats,
230
+ }) => ({
231
+ spawn,
232
+ model,
233
+ ...(sessionFile === undefined ? {} : { sessionFile }),
234
+ ...(worktree === undefined ? {} : { worktree }),
235
+ ...(git === undefined ? {} : { git }),
236
+ sentFrames,
237
+ receivedFrames,
238
+ asks,
239
+ stats,
240
+ }),
241
+ ),
242
+ };
243
+ }
244
+ }
245
+
246
+ function spawnIdentity(options: OpenOptions): CassetteSpawn {
247
+ return {
248
+ name: options.name,
249
+ cwd: options.cwd,
250
+ ...(options.model === undefined ? {} : { model: options.model }),
251
+ ...(options.systemPrompt === undefined ? {} : { systemPrompt: options.systemPrompt }),
252
+ ...(options.thinking === undefined ? {} : { thinking: options.thinking }),
253
+ ...(options.appendSystemPrompt === undefined
254
+ ? {}
255
+ : { appendSystemPrompt: options.appendSystemPrompt }),
256
+ ...(options.tools === undefined ? {} : { tools: [...options.tools] }),
257
+ ...(options.disallowedTools === undefined
258
+ ? {}
259
+ : { disallowedTools: [...options.disallowedTools] }),
260
+ ...(options.skills === undefined ? {} : { skills: [...options.skills] }),
261
+ ...(options.disallowedSkills === undefined
262
+ ? {}
263
+ : { disallowedSkills: [...options.disallowedSkills] }),
264
+ ...(options.worktree === true ? { worktree: true } : {}),
265
+ };
266
+ }
267
+
268
+ function append(
269
+ agent: MutableAgent | null,
270
+ direction: "sentFrames" | "receivedFrames",
271
+ frame: Frame,
272
+ ): void {
273
+ if (!agent) return;
274
+ (agent.active ?? agent)[direction].push(frame);
275
+ }
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdir, stat, unlink } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { CASSETTE_TEMP_PREFIX } from "./cassette-publish.ts";
6
+
7
+ /** A temp file left by an interrupted publication is stale after one day (ADR-0021). */
8
+ export const CHECKPOINT_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
9
+
10
+ const CHECKPOINT_SUFFIX = join("yaag", "checkpoints");
11
+
12
+ /**
13
+ * Resolves where a stopped Run publishes its checkpoint when `--record` set no
14
+ * path: `YAAG_CHECKPOINT_DIR`, then an absolute `XDG_STATE_HOME`, then
15
+ * `$HOME/.local/state`. A relative `XDG_STATE_HOME` is ignored (XDG spec).
16
+ */
17
+ export function resolveCheckpointDirectory(env: NodeJS.ProcessEnv = process.env): string {
18
+ const override = env["YAAG_CHECKPOINT_DIR"];
19
+ if (override !== undefined && override !== "") return override;
20
+ const xdg = env["XDG_STATE_HOME"];
21
+ if (xdg !== undefined && isAbsolute(xdg)) return join(xdg, CHECKPOINT_SUFFIX);
22
+ const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
23
+ return join(home, ".local", "state", CHECKPOINT_SUFFIX);
24
+ }
25
+
26
+ /**
27
+ * Names one checkpoint file. There is no Run id below the transport seam yet, so
28
+ * the name is a millisecond timestamp, the process id, and a full random UUID.
29
+ * The UUID is what makes the name collision-safe: two Runs in one process can
30
+ * settle inside the same millisecond, and a rename onto an existing name would
31
+ * destroy the earlier checkpoint (ADR-0021). The UUID keeps all 122 random bits;
32
+ * a truncated nonce would make that silent overwrite reachable.
33
+ */
34
+ export function checkpointFileName(
35
+ now: Date = new Date(),
36
+ pid: number = process.pid,
37
+ nonce: string = randomUUID(),
38
+ ): string {
39
+ const stamp = now.toISOString().replaceAll(":", "-").replaceAll(".", "-");
40
+ return `${stamp}-${pid}-${nonce}.cassette.json`;
41
+ }
42
+
43
+ /** Bounds for {@link cleanStaleCheckpointTemp}; both default when omitted. */
44
+ export interface CleanStaleOptions {
45
+ /** Epoch milliseconds treated as "now"; defaults to `Date.now()`. */
46
+ readonly now?: number;
47
+ /** Age above which a temp file is removed; defaults to {@link CHECKPOINT_TEMP_MAX_AGE_MS}. */
48
+ readonly maxAgeMs?: number;
49
+ }
50
+
51
+ /**
52
+ * Removes publication temp files older than `maxAgeMs`. A missing directory
53
+ * resolves silently; a `readdir` failure rejects, so the caller owns the
54
+ * diagnostic. This module never writes to stderr.
55
+ */
56
+ export async function cleanStaleCheckpointTemp(
57
+ directory: string,
58
+ options: CleanStaleOptions = {},
59
+ ): Promise<void> {
60
+ const now = options.now ?? Date.now();
61
+ const maxAgeMs = options.maxAgeMs ?? CHECKPOINT_TEMP_MAX_AGE_MS;
62
+ let entries: readonly string[];
63
+ try {
64
+ entries = await readdir(directory);
65
+ } catch (error) {
66
+ if (isMissing(error)) return;
67
+ throw error;
68
+ }
69
+ await Promise.all(
70
+ entries
71
+ .filter((entry) => entry.startsWith(CASSETTE_TEMP_PREFIX))
72
+ .map((entry) => discardWhenStale(join(directory, entry), now, maxAgeMs)),
73
+ );
74
+ }
75
+
76
+ async function discardWhenStale(path: string, now: number, maxAgeMs: number): Promise<void> {
77
+ try {
78
+ const info = await stat(path);
79
+ if (now - info.mtimeMs > maxAgeMs) await unlink(path);
80
+ } catch {
81
+ // A concurrent Run may have just renamed the temp file away; cleanup is best-effort.
82
+ }
83
+ }
84
+
85
+ function isMissing(error: unknown): boolean {
86
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
87
+ const { code } = error;
88
+ return typeof code === "string" && code === "ENOENT";
89
+ }