@yaag/extension 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.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @yaag/extension
2
+
3
+ The yaag pi extension: run Orchestration Programs from a pi session.
4
+
5
+ It is loaded by pi through jiti, in **Node**, and bridges to the `yaag` CLI,
6
+ which runs on **Bun** (ADR-0005, ADR-0015).
7
+
8
+ ## Prerequisites
9
+
10
+ **Bun is required.** The extension itself runs in pi's Node process, but every
11
+ Orchestration Program runs on Bun. Bun must be on `PATH`, or at `~/.bun/bin/bun`:
12
+
13
+ ```bash
14
+ curl -fsSL https://bun.sh/install | bash
15
+ ```
16
+
17
+ A missing Bun is reported once, when the extension loads, naming what to
18
+ install — never as an `ENOENT` at the first tool call.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pi install ./packages/extension # from the monorepo root
24
+ ```
25
+
26
+ Pi records the local path without copying, so the `workspace:*` dependency on
27
+ the CLI resolves through the monorepo's `node_modules`. No publish, no bundle,
28
+ no version bump.
29
+
30
+ For a throwaway session instead: `pi -e ./packages/extension`.
31
+
32
+ ## What it provides
33
+
34
+ ### `yaag_run`
35
+
36
+ | Parameter | Type | Meaning |
37
+ |---|---|---|
38
+ | `file` | `string` | Path to the Orchestration Program file |
39
+ | `args` | `string?` | The program's arguments, as a JSON object string |
40
+ | `background` | `boolean?` | Start the Run in the background and return its Run id |
41
+ | `record` | `string?` | Write this Run's Cassette artifact to this path |
42
+ | `resume` | `string?` | Replay a matching Cassette prefix, then continue live |
43
+
44
+ Blocking by default: the call returns when the Run ends, and its content is the
45
+ Run's return value. With `background: true` it returns at once with a short Run
46
+ id (`r1`, `r2`, …) and the result arrives later as a follow-up message that
47
+ triggers a turn. Background Runs may overlap; each has its own id and stop handle.
48
+
49
+ The model sees only the Run's value (or the id). The user additionally sees the
50
+ Run Summary — program, Agents, Asks settled, cost — refreshed after every
51
+ Lifecycle Event. Blocking completion and `yaag_stop` report token usage on pi's
52
+ nested-usage channel. Natural background completion is the exception: pi custom
53
+ follow-ups have no usage field, so their summaries remain available for rendering
54
+ without changing Host Session token or cost totals.
55
+
56
+ ### `yaag_describe`
57
+
58
+ | Parameter | Type | Meaning |
59
+ |---|---|---|
60
+ | `file` | `string` | Path to the Orchestration Program file |
61
+
62
+ Returns the program's declared `{ name, description, args }` contract verbatim.
63
+ Use it to discover a known program's arguments before calling `yaag_run`; it does
64
+ not execute the Run or enumerate programs.
65
+
66
+ ### `yaag_setup_workspace`
67
+
68
+ | Parameter | Type | Meaning |
69
+ |---|---|---|
70
+ | `dir` | `string?` | Workspace root; omitted uses pi's current working directory |
71
+
72
+ Use this when an Orchestration Program author sees missing `@yaag/runtime` editor/type
73
+ resolution in their workspace. `dir` is optional and defaults to pi's current working
74
+ directory. Normally run setup once for that workspace, and rerun it when an upgrade
75
+ needs to refresh generated declarations. Its content is the CLI's verbatim per-artifact
76
+ written/skipped report. Setup is not a Run: it has no Run id, summary, progress, usage,
77
+ cassette, or follow-up behavior.
78
+
79
+ ### `yaag_stop`
80
+
81
+ | Parameter | Type | Meaning |
82
+ |---|---|---|
83
+ | `id` | `string` | The Run id to stop, as returned by `yaag_run` |
84
+
85
+ Reaps the Run's Agents and reports what it got through and what it spent:
86
+ program, Agents seen, Asks settled, cost and tokens. Costs are reported as
87
+ "at least" when the Summary is incomplete.
88
+
89
+ ### `/yaag-status`
90
+
91
+ Shows the resolved `bun` executable and CLI entry point.
92
+
93
+ ### `/yaag-setup-workspace [dir]`
94
+
95
+ Creates or refreshes editor type support for Orchestration Programs under `.yaag/`.
96
+ The directory argument is optional and defaults to pi's current working directory.
97
+ It displays the CLI's per-artifact written/skipped report. This is the user-facing
98
+ counterpart of `yaag_setup_workspace`, not a Run: it has no Run id, summary,
99
+ progress, usage, cassette, or follow-up behavior.
100
+
101
+ ## Where Orchestration Programs live
102
+
103
+ By **convention**, in a `.yaag/` directory in your project — but nothing enforces
104
+ that location, and `file` accepts any path. Run `yaag_setup_workspace` once to create
105
+ `.yaag/tsconfig.json` and refresh `.yaag/types/`. The CLI vendors its runtime and
106
+ TypeBox declarations there, so editors and type checkers work without installing
107
+ workspace dependencies; execution separately uses the runtime shipped with the CLI.
108
+ Setup creates no program scaffold: authored programs remain yours, separate from its
109
+ generated declarations. In this monorepo the convention is
110
+ [`examples/`](../../examples). A program is a user-authored TypeScript file whose
111
+ default export is `defineRun({ run })`. Use file tools to find a known program, then use
112
+ `yaag_describe({ file })` to discover its declared contract and arguments.
113
+
114
+ ## A worked example
115
+
116
+ `.yaag/review.ts`:
117
+
118
+ ```ts
119
+ import { defineRun, prompt } from "@yaag/runtime";
120
+
121
+ export default defineRun({
122
+ name: "review",
123
+ description: "Review the working tree from one angle.",
124
+ run: async (ctx) => {
125
+ const agent = await ctx.spawn({
126
+ name: "reviewer",
127
+ tools: ["read", "grep"],
128
+ disallowedTools: ["yaag_run"],
129
+ skills: ["review"],
130
+ disallowedSkills: [],
131
+ });
132
+ return agent.ask(
133
+ prompt`
134
+ Audit this repo for issues.
135
+ Report the top three.
136
+ `,
137
+ {
138
+ maxTurns: 4,
139
+ maxToolCalls: 12,
140
+ maxDurationMs: 60_000,
141
+ wrapUpPrompt: "Give the findings now.",
142
+ },
143
+ );
144
+ },
145
+ });
146
+ ```
147
+
148
+ ## Program author controls
149
+
150
+ `prompt\`…\`` dedents static prompt text while preserving interpolated values.
151
+ `tools` / `disallowedTools` and `skills` / `disallowedSkills` are spawn-level
152
+ restrictions: a skill is a portable name, never a `SKILL.md` path. Tool allowlists
153
+ are applied before denylists; explicit empty allowlists disable flag-controllable
154
+ items.
155
+
156
+ `maxTurns`, `maxToolCalls`, and `maxDurationMs` are per-Ask soft limits. They
157
+ steer an Agent to wrap up, then abort only after grace; an `ASK_LIMIT` rejection
158
+ is recoverable and the Handle can be asked again. `wrapUpPrompt` replaces the
159
+ default steering message. This is intentionally different from `timeoutMs`, the
160
+ destructive fallback that rejects with `ASK_TIMEOUT` and closes the Agent.
161
+
162
+ The tool call:
163
+
164
+ ```json
165
+ { "file": ".yaag/review.ts", "args": "{\"focus\":\"security\"}" }
166
+ ```
167
+
168
+ For this blocking call, what comes back:
169
+
170
+ - **content** — the Run's return value, the reviewer's report (this is what the
171
+ model reads)
172
+ - **details** — the Run Summary, rendered for the user
173
+ - **usage** — the token breakdown: input, output, cache read, cache write, total
174
+
175
+ The background variant:
176
+
177
+ ```json
178
+ { "file": ".yaag/review.ts", "background": true }
179
+ ```
180
+
181
+ returns `Run r1 started in the background.` immediately; progress keeps
182
+ streaming into the fold, and when the Run ends a follow-up message arrives with
183
+ its value and final Run Summary in non-model-visible `details`. Pi's custom
184
+ follow-up API has no nested-usage field, so natural completion does not alter
185
+ Host Session token or cost totals. `yaag_stop({ "id": "r1" })` ends it early
186
+ and reports its nested usage through the stop tool result.
187
+
188
+ ## Both entry points, one execution path
189
+
190
+ ```sh
191
+ bun apps/yaag/src/cli.ts run .yaag/review.ts
192
+ ```
193
+
194
+ and `yaag_run` are the same execution path. The extension spawns exactly this
195
+ CLI as a child — `bun cli.ts run <file> --events-fd 3 --args <json>` — and reads
196
+ structured Lifecycle Events from descriptor 3 while the CLI's own stderr format
197
+ stays unchanged. **What you debug in a terminal is what the session runs.**
198
+
199
+ ## How stopping works
200
+
201
+ One ladder, taken by every route: pressing Esc during a blocking Run, calling
202
+ `yaag_stop` on a background Run, or closing the session.
203
+
204
+ 1. **stdin EOF** — the extension closes the child's stdin; the CLI unwinds
205
+ through the runtime's own Agent reap ladder.
206
+ 2. **SIGTERM** — if the CLI is still there.
207
+ 3. **group SIGKILL** — only if the CLI ignored both.
208
+
209
+ It is the runtime's Agent reap ladder (ADR-0008), one process level up.
210
+
211
+ ## Docs
212
+
213
+ Provided tools: `yaag_run`, `yaag_describe`, `yaag_setup_workspace`, `yaag_stop`.
214
+ Provided commands: `/yaag-status`, `/yaag-setup-workspace [dir]`.
215
+
216
+ - [`../../docs/architecture.md`](../../docs/architecture.md) — the diagrams
217
+ - [`../../docs/adr/`](../../docs/adr) — why it is built this way
218
+ - [ADR-0018: foreign workspaces use CLI aliases and vendored types](../../docs/adr/0018-foreign-workspaces-use-cli-aliases-and-vendored-types.md) — workspace setup and foreign-program support
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@yaag/extension",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "files": [
8
+ "src",
9
+ "!src/**/*.test.ts"
10
+ ],
11
+ "type": "module",
12
+ "keywords": [
13
+ "pi-package"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "for file in src/*.test.ts; do bun test \"$file\" || exit 1; done",
23
+ "test:e2e": "bun test e2e"
24
+ },
25
+ "dependencies": {
26
+ "@earendil-works/pi-tui": "^0.84.0",
27
+ "@yaag/runtime": "0.0.0",
28
+ "@yaag/tui": "0.0.0",
29
+ "@yaag/cli": "0.0.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": "*",
33
+ "typebox": "*"
34
+ },
35
+ "devDependencies": {
36
+ "@earendil-works/pi-coding-agent": "^0.84.0",
37
+ "@types/node": "^22.0.0",
38
+ "typebox": "1.3.7"
39
+ }
40
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The single combined inline widget for every live background Run (spec §1).
3
+ *
4
+ * One pi widget key holds every Run, so a Run starting or ending costs no
5
+ * extra scrollback. The frame is a `string[]`, which pi never routes input to,
6
+ * so the widget consumes no key by construction.
7
+ */
8
+ import { renderRunsWidget, type WidgetRun } from "@yaag/tui";
9
+ import type { RunRegistry } from "./run-registry.ts";
10
+ import type { RunTreeStore } from "./run-trees.ts";
11
+
12
+ /** The one pi widget key; every Run shares it. */
13
+ export const WIDGET_KEY = "yaag-runs";
14
+
15
+ /** The only pi capability the widget needs. */
16
+ export interface WidgetSurface {
17
+ setWidget(key: string, lines: string[] | undefined): void;
18
+ }
19
+
20
+ /**
21
+ * What the combined widget needs: the Run registry it reads live Runs from,
22
+ * the projection store it reads their `TreeState` from, and the render budget.
23
+ */
24
+ export interface BackgroundWidgetOptions {
25
+ readonly registry: RunRegistry;
26
+ readonly store: RunTreeStore;
27
+ readonly now?: () => number;
28
+ readonly width?: number;
29
+ readonly maxLines?: number;
30
+ }
31
+
32
+ /** The widget controller: bind it once, then refresh it on every occurrence. */
33
+ export interface BackgroundWidget {
34
+ /**
35
+ * Binds the pi UI surface, once the Host Session has one, and draws the
36
+ * current frame. A `WidgetSurface.setWidget()` that throws propagates here.
37
+ */
38
+ bind(surface: WidgetSurface): void;
39
+ /**
40
+ * Redraws the frame; drops the redraw before `bind`, and skips an unchanged
41
+ * frame. A bound `WidgetSurface.setWidget()` that throws propagates here.
42
+ */
43
+ refresh(): void;
44
+ /**
45
+ * Unsubscribes from the store and clears the widget. A bound
46
+ * `WidgetSurface.setWidget()` that throws propagates here.
47
+ */
48
+ dispose(): void;
49
+ }
50
+
51
+ /**
52
+ * Builds the combined widget over this session's Run registry and projections.
53
+ *
54
+ * An unbound controller drops every refresh, and a Run without a projection is
55
+ * skipped. Construction itself never throws, but `bind()`, `refresh()`, and
56
+ * `dispose()` call the caller's `WidgetSurface.setWidget()` and propagate
57
+ * whatever it throws; the store subscription also propagates it into the
58
+ * `ingest()` that caused the redraw.
59
+ */
60
+ export function createBackgroundWidget(options: BackgroundWidgetOptions): BackgroundWidget {
61
+ const now = options.now ?? Date.now;
62
+ const width = options.width ?? 100;
63
+ let surface: WidgetSurface | undefined;
64
+ let drawn: readonly string[] | undefined;
65
+
66
+ const entries = (): readonly WidgetRun[] => {
67
+ const runs: WidgetRun[] = [];
68
+ for (const run of options.registry.live) {
69
+ if (options.store.kind(run.id) !== "background") continue;
70
+ const state = options.store.get(run.id);
71
+ if (state !== undefined) runs.push({ id: run.id, state });
72
+ }
73
+ return runs;
74
+ };
75
+
76
+ const refresh = (): void => {
77
+ if (surface === undefined) return;
78
+ const lines = renderRunsWidget(entries(), {
79
+ now: now(),
80
+ width,
81
+ ...(options.maxLines === undefined ? {} : { maxLines: options.maxLines }),
82
+ });
83
+ if (drawn !== undefined && sameLines(drawn, lines)) return;
84
+ drawn = lines;
85
+ surface.setWidget(WIDGET_KEY, lines.length === 0 ? undefined : [...lines]);
86
+ };
87
+
88
+ const unsubscribe = options.store.subscribe(() => refresh());
89
+
90
+ return {
91
+ bind(bound: WidgetSurface): void {
92
+ surface = bound;
93
+ drawn = undefined;
94
+ refresh();
95
+ },
96
+ refresh,
97
+ dispose(): void {
98
+ unsubscribe();
99
+ surface?.setWidget(WIDGET_KEY, undefined);
100
+ surface = undefined;
101
+ },
102
+ };
103
+ }
104
+
105
+ function sameLines(left: readonly string[], right: readonly string[]): boolean {
106
+ return left.length === right.length && left.every((line, index) => line === right[index]);
107
+ }
@@ -0,0 +1,97 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable, Writable } from "node:stream";
3
+ import { type ReapTarget, reap } from "@yaag/runtime";
4
+ import { StderrTail } from "./stderr-buffer.ts";
5
+
6
+ /** The process-level output of one Bun CLI child. */
7
+ export interface CliChildOutcome {
8
+ readonly code: number | null;
9
+ readonly stdout: string;
10
+ readonly stderr: string;
11
+ }
12
+
13
+ export interface CliChildOptions {
14
+ readonly bun: string;
15
+ readonly cli: string;
16
+ readonly argv: readonly string[];
17
+ /** Run reserves fd 3 for Lifecycle Events; ordinary CLI calls do not. */
18
+ readonly events?: boolean;
19
+ }
20
+
21
+ /** A detached Bun CLI child and the common reap ladder used to stop it. */
22
+ export interface CliChild {
23
+ readonly outcome: Promise<CliChildOutcome>;
24
+ readonly events: Readable | undefined;
25
+ stop(): void;
26
+ }
27
+
28
+ /**
29
+ * Starts the resolved Bun CLI in its own process group.
30
+ *
31
+ * The returned `stop` closes stdin, then escalates through SIGTERM and group
32
+ * SIGKILL via `reap()`. It never interprets CLI argv, stdout, or fd 3.
33
+ */
34
+ export function startCliChild(options: CliChildOptions): CliChild {
35
+ const child = spawn(options.bun, [options.cli, ...options.argv], {
36
+ stdio: options.events === true ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
37
+ detached: true,
38
+ });
39
+ const stderr = new StderrTail();
40
+ const exited = observeExit(child);
41
+ const target: ReapTarget = {
42
+ exited,
43
+ endStdin: () => endStdin(child.stdin),
44
+ terminate: () => void child.kill("SIGTERM"),
45
+ destroyGroup: () => destroyGroup(child),
46
+ };
47
+ const outcome = Promise.all([
48
+ text(child.stdout),
49
+ drain(child.stderr, (chunk) => stderr.write(chunk)),
50
+ exited,
51
+ ]).then(([stdout]) => ({ code: child.exitCode, stdout, stderr: stderr.text() }));
52
+ const eventStream = options.events === true ? child.stdio[3] : undefined;
53
+ return {
54
+ outcome,
55
+ events: isReadable(eventStream) ? eventStream : undefined,
56
+ stop: () => void reap(target),
57
+ };
58
+ }
59
+
60
+ function endStdin(stdin: Writable | null): void {
61
+ stdin?.end();
62
+ }
63
+
64
+ /** Last resort: the whole group, including processes the CLI may have spawned. */
65
+ function destroyGroup(child: ReturnType<typeof spawn>): void {
66
+ try {
67
+ if (child.pid !== undefined) process.kill(-child.pid, "SIGKILL");
68
+ else child.kill("SIGKILL");
69
+ } catch {
70
+ child.kill("SIGKILL");
71
+ }
72
+ }
73
+
74
+ function isReadable(stream: unknown): stream is Readable {
75
+ return typeof stream === "object" && stream !== null && "on" in stream;
76
+ }
77
+
78
+ async function text(stream: Readable | null): Promise<string> {
79
+ if (stream === null) return "";
80
+ let out = "";
81
+ stream.setEncoding("utf8");
82
+ for await (const chunk of stream) out += String(chunk);
83
+ return out;
84
+ }
85
+
86
+ async function drain(stream: Readable | null, onChunk: (chunk: string) => void): Promise<void> {
87
+ if (stream === null) return;
88
+ stream.setEncoding("utf8");
89
+ for await (const chunk of stream) onChunk(String(chunk));
90
+ }
91
+
92
+ function observeExit(child: ReturnType<typeof spawn>): Promise<void> {
93
+ return new Promise((resolve, reject) => {
94
+ child.on("close", () => resolve());
95
+ child.on("error", reject);
96
+ });
97
+ }
@@ -0,0 +1,69 @@
1
+ import { resolve } from "node:path";
2
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { startCliChild } from "./cli-child.ts";
5
+ import { statusReport } from "./status.ts";
6
+
7
+ const parameters = Type.Object({
8
+ file: Type.String({ description: "Path to the Orchestration Program file" }),
9
+ });
10
+
11
+ export interface DescribeToolOptions {
12
+ readonly bun: string | null;
13
+ readonly cli: string;
14
+ }
15
+
16
+ const DESCRIPTION = [
17
+ "Return an Orchestration Program's declared name, description, and args schema.",
18
+ "",
19
+ "For program authors: use the prompt`…` helper for clean prompts. ctx.spawn restrictions",
20
+ "are spawn-level: tools/disallowedTools, and skills/disallowedSkills are names, not paths.",
21
+ "handle.ask supports maxTurns, maxToolCalls, maxDurationMs, and wrapUpPrompt as recoverable",
22
+ "ASK_LIMIT controls. These differ from timeoutMs, the destructive Agent-killing fallback.",
23
+ "",
24
+ "Describing imports the program and executes its module top level. Programs should keep",
25
+ "module top level side-effect free; use this only for reviewed, user-authored files.",
26
+ ].join("\n");
27
+
28
+ /**
29
+ * Creates `yaag_describe`, which returns the CLI's program contract verbatim.
30
+ *
31
+ * Throws an actionable missing-Bun, CLI failure, or cancellation error. It has
32
+ * no Run registry, Lifecycle Event, progress, summary, or follow-up dependency.
33
+ */
34
+ export function createDescribeTool(
35
+ options: DescribeToolOptions,
36
+ ): ToolDefinition<typeof parameters, undefined> {
37
+ const { bun, cli } = options;
38
+ return {
39
+ name: "yaag_describe",
40
+ label: "Describe Program",
41
+ description: DESCRIPTION,
42
+ parameters,
43
+ async execute(_id, params, signal) {
44
+ if (bun === null) throw new Error(statusReport(null, cli));
45
+ const child = startCliChild({ bun, cli, argv: ["describe", resolve(params.file)] });
46
+ signal?.addEventListener("abort", child.stop, { once: true });
47
+ if (wasAborted(signal)) child.stop();
48
+ try {
49
+ const outcome = await child.outcome;
50
+ if (wasAborted(signal)) throw new Error("yaag_describe: cancelled");
51
+ if (outcome.code !== 0) throw new Error(failure(outcome.code, outcome.stderr));
52
+ return { content: [{ type: "text", text: outcome.stdout }], details: undefined };
53
+ } finally {
54
+ signal?.removeEventListener("abort", child.stop);
55
+ }
56
+ },
57
+ };
58
+ }
59
+
60
+ function wasAborted(signal: AbortSignal | undefined): boolean {
61
+ return signal?.aborted === true;
62
+ }
63
+
64
+ function failure(code: number | null, stderr: string): string {
65
+ const tail = stderr.trimEnd();
66
+ const what =
67
+ code === null ? "the description was killed" : `the description failed (exit ${code})`;
68
+ return tail === "" ? `yaag_describe: ${what}` : `yaag_describe: ${what}\n${tail}`;
69
+ }
@@ -0,0 +1,47 @@
1
+ import type { Readable } from "node:stream";
2
+ import type { LifecycleEvent } from "@yaag/runtime";
3
+
4
+ /**
5
+ * Reads Lifecycle Events from the CLI's dedicated descriptor: one JSON object
6
+ * per line, wrapped in the `{ v: 1, ... }` envelope (ADR-0016).
7
+ *
8
+ * Resolves when the stream ends. Never rejects on content: a line that is not
9
+ * JSON, or carries a wire version this host does not know, is skipped — a
10
+ * malformed record must not take down the Run that produced it.
11
+ */
12
+ export async function readEvents(
13
+ stream: Readable,
14
+ onEvent: (event: LifecycleEvent) => void,
15
+ ): Promise<void> {
16
+ let pending = "";
17
+ stream.setEncoding("utf8");
18
+ for await (const chunk of stream) {
19
+ pending += String(chunk);
20
+ const lines = pending.split("\n");
21
+ pending = lines.pop() ?? "";
22
+ for (const line of lines) emit(line, onEvent);
23
+ }
24
+ emit(pending, onEvent);
25
+ }
26
+
27
+ function emit(line: string, onEvent: (event: LifecycleEvent) => void): void {
28
+ const event = parseEnvelope(line);
29
+ if (event !== null) onEvent(event);
30
+ }
31
+
32
+ function parseEnvelope(line: string): LifecycleEvent | null {
33
+ if (line.trim() === "") return null;
34
+ let value: unknown;
35
+ try {
36
+ value = JSON.parse(line);
37
+ } catch {
38
+ return null;
39
+ }
40
+ if (typeof value !== "object" || value === null) return null;
41
+ if (!("v" in value) || value.v !== 1) return null;
42
+ if (!("type" in value) || typeof value.type !== "string") return null;
43
+ const { v: _version, ...event } = value as { v: number } & Record<string, unknown>;
44
+ // Narrowed as far as the wire allows: the envelope is versioned, and the
45
+ // Summary fold ignores any type it does not recognise.
46
+ return event as unknown as LifecycleEvent;
47
+ }