@xynogen/pix-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.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # pix-runtime
2
+
3
+ Pix's small shared runtime layer. It owns the process-wide config contract:
4
+ `~/.pi/agent/pix.json` as a single, sparse, versioned user config file, plus the
5
+ lifecycle that keeps it coherent.
6
+
7
+ It is **not** an aggregator, renderer, model-data package, or service locator.
8
+ See `DESIGN.md` for the full contract.
9
+
10
+ ## What it does
11
+
12
+ - Versioned, sparse `pix.json` (`$version: 1`) — defaults resolve in code.
13
+ - Typed sections: `collapse`, `pretty`, `optimizer`, `gate`.
14
+ - Atomic writes behind a serialized in-process queue and a short-lived
15
+ cross-process lock. A failed write leaves the old file intact.
16
+ - Immutable, deeply frozen config snapshots with a monotonic revision.
17
+ - Typed, path-filtered change events.
18
+ - One-time migration of legacy unversioned config and the `optimizer.json`
19
+ sidecar.
20
+ - The `/pix` shared-settings command.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pi install npm:@xynogen/pix-runtime
26
+ ```
27
+
28
+ Standalone-installable: importing an accessor lazily creates the singleton even
29
+ without the extension factory. Installed via `pix-core` it registers `/pix` and
30
+ session hooks once.
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import { config, updateConfig, onConfigChange } from "@xynogen/pix-runtime/config";
36
+ import { prettySection } from "@xynogen/pix-runtime/sections";
37
+
38
+ const icons = config(prettySection).icons; // synchronous read
39
+ await updateConfig(prettySection, { icons: "ascii" });
40
+ const off = onConfigChange((c) => render(), { paths: ["pretty.icons"] });
41
+ ```
42
+
43
+ Collapse policy helpers:
44
+
45
+ ```ts
46
+ import { shouldCollapse, collapseDelayMs } from "@xynogen/pix-runtime/collapse";
47
+ ```
48
+
49
+ ## Testing
50
+
51
+ ```ts
52
+ import { createIsolatedRuntime } from "@xynogen/pix-runtime/testing";
53
+
54
+ const { runtime, cleanup } = createIsolatedRuntime();
55
+ // ... exercise runtime against a temp agent dir ...
56
+ cleanup();
57
+ ```
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@xynogen/pix-runtime",
3
+ "version": "0.1.0",
4
+ "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "test": "bun test"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "DESIGN.md",
14
+ "LICENSE"
15
+ ],
16
+ "pi": {
17
+ "extensions": [
18
+ "src/extension.ts"
19
+ ]
20
+ },
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./config": "./src/runtime.ts",
24
+ "./sections": "./src/sections/index.ts",
25
+ "./collapse": "./src/collapse.ts",
26
+ "./testing": "./src/testing.ts"
27
+ },
28
+ "keywords": [
29
+ "pi",
30
+ "pi-package",
31
+ "pi-extension",
32
+ "config",
33
+ "runtime"
34
+ ],
35
+ "author": "xynogen",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/xynogen/pix-mono.git",
40
+ "directory": "packages/pix-runtime"
41
+ },
42
+ "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-runtime#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/xynogen/pix-mono/issues"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*"
51
+ }
52
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * collapse.ts — pure collapse *policy* only. UI timers/state machines live in
3
+ * the renderer (pix-pretty), which consumes these helpers.
4
+ */
5
+
6
+ import { pixRuntime } from "./runtime.ts";
7
+ import { collapseSection } from "./sections/collapse.ts";
8
+
9
+ /** Should a tool's output card auto-collapse? Per-tool override wins. */
10
+ export function shouldCollapse(toolName: string): boolean {
11
+ const c = pixRuntime().get(collapseSection);
12
+ const perTool = c.tools[toolName];
13
+ if (typeof perTool === "boolean") return perTool;
14
+ return c.enabled;
15
+ }
16
+
17
+ /** Collapse delay in milliseconds. */
18
+ export function collapseDelayMs(): number {
19
+ return pixRuntime().get(collapseSection).delaySec * 1000;
20
+ }
@@ -0,0 +1,26 @@
1
+ import type { ConfigDiagnostic } from "./schema.ts";
2
+
3
+ /** Bounded ring buffer of config diagnostics surfaced through `/pix`. */
4
+ export class DiagnosticSink {
5
+ private readonly items: ConfigDiagnostic[] = [];
6
+
7
+ constructor(private readonly limit = 100) {}
8
+
9
+ push(d: Omit<ConfigDiagnostic, "at">): void {
10
+ this.items.push({ ...d, at: Date.now() });
11
+ if (this.items.length > this.limit) this.items.splice(0, this.items.length - this.limit);
12
+ }
13
+
14
+ all(): readonly ConfigDiagnostic[] {
15
+ return [...this.items];
16
+ }
17
+
18
+ /** Diagnostics newer than a timestamp — used for session-start aggregation. */
19
+ since(ts: number): readonly ConfigDiagnostic[] {
20
+ return this.items.filter((d) => d.at >= ts);
21
+ }
22
+
23
+ clear(): void {
24
+ this.items.length = 0;
25
+ }
26
+ }
package/src/events.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { CONFIG_FORMAT_VERSION, deepFreeze, pathMatches, type SectionHandle } from "./schema.ts";
2
+
3
+ // ── Snapshot ─────────────────────────────────────────────────────────────────
4
+
5
+ export interface ConfigSnapshot {
6
+ readonly revision: number;
7
+ readonly formatVersion: typeof CONFIG_FORMAT_VERSION;
8
+ readonly loadedAt: number;
9
+ get<K extends string, T>(section: SectionHandle<K, T>): Readonly<T>;
10
+ }
11
+
12
+ /** Immutable snapshot backed by a frozen map of resolved section values. */
13
+ export function makeSnapshot(revision: number, values: Map<string, unknown>): ConfigSnapshot {
14
+ const frozen = new Map<string, unknown>();
15
+ for (const [key, value] of values) frozen.set(key, deepFreeze(value));
16
+ const loadedAt = Date.now();
17
+ return {
18
+ revision,
19
+ formatVersion: CONFIG_FORMAT_VERSION,
20
+ loadedAt,
21
+ get<K extends string, T>(section: SectionHandle<K, T>): Readonly<T> {
22
+ return (frozen.get(section.key) ?? section.defaults) as Readonly<T>;
23
+ },
24
+ };
25
+ }
26
+
27
+ // ── Change events ────────────────────────────────────────────────────────────
28
+
29
+ export type ConfigChangeOrigin = "init" | "command" | "api" | "reload" | "migration";
30
+
31
+ export interface ConfigChange {
32
+ readonly revision: number;
33
+ readonly origin: ConfigChangeOrigin;
34
+ readonly source?: string;
35
+ readonly changed: readonly string[];
36
+ readonly previous?: ConfigSnapshot;
37
+ readonly current: ConfigSnapshot;
38
+ readonly persisted: boolean;
39
+ }
40
+
41
+ export type ConfigListener = (change: ConfigChange) => void;
42
+
43
+ export interface SubscribeOptions {
44
+ paths?: readonly string[];
45
+ immediate?: boolean;
46
+ }
47
+
48
+ interface Registration {
49
+ listener: ConfigListener;
50
+ paths?: readonly string[];
51
+ }
52
+
53
+ /** In-process listener registry with path filtering and safe dispatch. */
54
+ export class EventBus {
55
+ private readonly regs = new Set<Registration>();
56
+
57
+ subscribe(reg: Registration): () => void {
58
+ this.regs.add(reg);
59
+ return () => this.regs.delete(reg);
60
+ }
61
+
62
+ /** Dispatch in registration order over a copied list (unsubscribe-safe). */
63
+ emit(change: ConfigChange, onError: (err: unknown) => void): void {
64
+ for (const reg of [...this.regs]) {
65
+ if (reg.paths && !change.changed.some((c) => pathMatches(c, reg.paths as string[]))) {
66
+ continue;
67
+ }
68
+ try {
69
+ reg.listener(change);
70
+ } catch (err) {
71
+ onError(err);
72
+ }
73
+ }
74
+ }
75
+
76
+ clear(): void {
77
+ this.regs.clear();
78
+ }
79
+ }
@@ -0,0 +1,44 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { once } from "./once.ts";
3
+ import { registerPixCommand } from "./pix-command.ts";
4
+ import { pixRuntime } from "./runtime.ts";
5
+
6
+ /**
7
+ * Runtime extension entry. Idempotent per `pi` instance: registers `/pix` and
8
+ * session lifecycle hooks, and drives init/reload/flush of the config singleton.
9
+ *
10
+ * Standalone-installable: importing a runtime accessor lazily creates the
11
+ * singleton even if this factory never runs, so the factory only owns the
12
+ * command and lifecycle wiring.
13
+ */
14
+ export default function registerRuntime(pi: ExtensionAPI): void {
15
+ once(pi, "pix-runtime", () => {
16
+ const runtime = pixRuntime();
17
+
18
+ registerPixCommand(pi, runtime);
19
+
20
+ let initialized = false;
21
+ pi.on("session_start", async () => {
22
+ if (initialized) {
23
+ await runtime.reload({ origin: "reload", source: "session_start" });
24
+ } else {
25
+ initialized = true;
26
+ await runtime.init({ origin: "init", source: "session_start" });
27
+ }
28
+ surfaceDiagnostics(pi, runtime);
29
+ });
30
+
31
+ pi.on("session_shutdown", async () => {
32
+ await runtime.flush();
33
+ });
34
+ });
35
+ }
36
+
37
+ /** Aggregate error-severity diagnostics into at most one notification. */
38
+ function surfaceDiagnostics(pi: ExtensionAPI, runtime: ReturnType<typeof pixRuntime>): void {
39
+ const errors = runtime.diagnostics().filter((d) => d.severity === "error");
40
+ if (errors.length === 0) return;
41
+ const ui = (pi as unknown as { ui?: { notify?(m: string, t?: string): void } }).ui;
42
+ const msg = `pix config: ${errors.length} issue(s) — see ${runtime.path}`;
43
+ ui?.notify?.(msg, "warning");
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export { collapseDelayMs, shouldCollapse } from "./collapse.ts";
2
+ export type {
3
+ ConfigChange,
4
+ ConfigChangeOrigin,
5
+ ConfigListener,
6
+ ConfigSnapshot,
7
+ SubscribeOptions,
8
+ } from "./events.ts";
9
+ export { default } from "./extension.ts";
10
+ export {
11
+ config,
12
+ createRuntime,
13
+ type InitOptions,
14
+ onConfigChange,
15
+ type PixRuntime,
16
+ pixRuntime,
17
+ type ReloadOptions,
18
+ type RuntimeAdapters,
19
+ reloadConfig,
20
+ type UpdateOptions,
21
+ updateConfig,
22
+ } from "./runtime.ts";
23
+ export type {
24
+ ConfigDiagnostic,
25
+ ConfigDiagnosticCode,
26
+ DeepPartial,
27
+ SectionHandle,
28
+ } from "./schema.ts";
29
+ export { CONFIG_FORMAT_VERSION, defineSection } from "./schema.ts";
30
+ export {
31
+ builtinSections,
32
+ type CollapseConfig,
33
+ collapseSection,
34
+ type GateConfig,
35
+ type GateRuleConfig,
36
+ gateSection,
37
+ type OptimizerConfig,
38
+ optimizerSection,
39
+ type PrettyConfig,
40
+ prettySection,
41
+ } from "./sections/index.ts";
@@ -0,0 +1,142 @@
1
+ /**
2
+ * migrations.ts — ordered, idempotent, pure migrations over a RawDocument plus
3
+ * the legacy `optimizer.json` sidecar importer.
4
+ *
5
+ * ponytail: the DESIGN.md staged optimizer mirror (keep optimizer.json as a
6
+ * read/write mirror for a full release train) is simplified here to a one-time
7
+ * import + archive. That is safe for a greenfield 0.1.0 where no published
8
+ * consumer delegates to runtime yet. Upgrade path: reinstate the mirror window
9
+ * in section 8.2 before any consumer ships runtime-backed optimizer writes.
10
+ */
11
+
12
+ import { existsSync, readFileSync, renameSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { CONFIG_FORMAT_VERSION, isObj, type ParseContext, type RawDocument } from "./schema.ts";
15
+
16
+ const LEGACY_PRETTY_COLOR_KEYS = ["theme", "syntaxTheme", "diffColors"] as const;
17
+ const LEGACY_DIFF_COLOR_KEYS = [
18
+ "bgAdd",
19
+ "bgDel",
20
+ "bgAddHighlight",
21
+ "bgDelHighlight",
22
+ "bgGutterAdd",
23
+ "bgGutterDel",
24
+ "fgAdd",
25
+ "fgDel",
26
+ ] as const;
27
+
28
+ const OPTIMIZER_KEYS = ["caveman", "rtk", "toon", "ponytail"] as const;
29
+
30
+ export interface MigrationResult {
31
+ document: RawDocument;
32
+ changed: boolean;
33
+ }
34
+
35
+ /** Detected source format of a raw document. */
36
+ export function detectVersion(doc: RawDocument): number | "unversioned" {
37
+ const v = doc.$version;
38
+ return typeof v === "number" && Number.isFinite(v) ? v : "unversioned";
39
+ }
40
+
41
+ /**
42
+ * Migrate a raw document up to the current format version. Pure: returns a new
43
+ * document and whether anything changed. Unknown keys are preserved.
44
+ */
45
+ export function migrate(input: RawDocument, ctx: ParseContext): MigrationResult {
46
+ const version = detectVersion(input);
47
+
48
+ if (typeof version === "number" && version > CONFIG_FORMAT_VERSION) {
49
+ // Future version: caller enters read-only mode. Do not mutate.
50
+ ctx.diagnostic({
51
+ code: "UNSUPPORTED_CONFIG_VERSION",
52
+ severity: "error",
53
+ message: `config $version ${version} is newer than supported ${CONFIG_FORMAT_VERSION}`,
54
+ });
55
+ return { document: input, changed: false };
56
+ }
57
+
58
+ const doc: RawDocument = structuredClone(input);
59
+ let changed = false;
60
+
61
+ // Remove legacy color keys — active themes own all colors now.
62
+ if (isObj(doc.pretty)) {
63
+ const pretty = { ...doc.pretty };
64
+ for (const key of LEGACY_PRETTY_COLOR_KEYS) {
65
+ if (key in pretty) {
66
+ delete pretty[key];
67
+ changed = true;
68
+ }
69
+ }
70
+ if (isObj(pretty.diff)) {
71
+ const diff = { ...pretty.diff };
72
+ for (const key of LEGACY_DIFF_COLOR_KEYS) {
73
+ if (key in diff) {
74
+ delete diff[key];
75
+ changed = true;
76
+ }
77
+ }
78
+ pretty.diff = diff;
79
+ }
80
+ doc.pretty = pretty;
81
+ }
82
+
83
+ if (doc.$version !== CONFIG_FORMAT_VERSION) {
84
+ doc.$version = CONFIG_FORMAT_VERSION;
85
+ changed = true;
86
+ }
87
+
88
+ return { document: doc, changed };
89
+ }
90
+
91
+ /**
92
+ * Import `optimizer.json` sidecar into `doc.optimizer` exactly once. Canonical
93
+ * `pix.json.optimizer.<key>` wins conflicts. Returns whether the document
94
+ * changed and whether the sidecar should be archived after a successful write.
95
+ */
96
+ export function importOptimizerSidecar(
97
+ doc: RawDocument,
98
+ agentDir: string,
99
+ ctx: ParseContext,
100
+ ): { changed: boolean; archive?: () => void } {
101
+ const sidecarPath = join(agentDir, "optimizer.json");
102
+ if (!existsSync(sidecarPath)) return { changed: false };
103
+
104
+ let raw: Record<string, unknown>;
105
+ try {
106
+ const parsed = JSON.parse(readFileSync(sidecarPath, "utf-8")) as unknown;
107
+ if (!isObj(parsed)) throw new Error("not an object");
108
+ raw = parsed;
109
+ } catch (err) {
110
+ ctx.diagnostic({
111
+ code: "MIGRATION_FAILED",
112
+ severity: "warning",
113
+ path: "optimizer.json",
114
+ message: "malformed optimizer sidecar left untouched",
115
+ cause: err,
116
+ });
117
+ return { changed: false };
118
+ }
119
+
120
+ const existing = isObj(doc.optimizer) ? { ...doc.optimizer } : {};
121
+ let changed = false;
122
+ for (const key of OPTIMIZER_KEYS) {
123
+ const value = raw[key];
124
+ if (typeof value === "string" && !(key in existing)) {
125
+ existing[key] = value;
126
+ changed = true;
127
+ }
128
+ }
129
+ if (changed) doc.optimizer = existing;
130
+
131
+ const archive = () => {
132
+ let target = `${sidecarPath}.migrated-v1`;
133
+ if (existsSync(target)) target = `${target}.${Date.now()}`;
134
+ try {
135
+ renameSync(sidecarPath, target);
136
+ } catch {
137
+ // A competing process may have moved it — ENOENT is benign.
138
+ }
139
+ };
140
+
141
+ return { changed, archive };
142
+ }
package/src/once.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Per-instance idempotency guard for extension activation.
3
+ *
4
+ * pix-core (the meta-package) invokes this package's factory, and a standalone
5
+ * install makes Pi invoke it again — sometimes against the SAME `pi`. We must
6
+ * dedupe that. But Pi rebuilds the extension runtime on /new, /resume, /fork,
7
+ * and /reload, handing the factory a BRAND-NEW `pi`; that must re-register.
8
+ *
9
+ * Keying the registry on the `pi` instance satisfies both: same instance =>
10
+ * skip, new instance => run. The registry lives on globalThis because jiti
11
+ * (`moduleCache: false`) re-evaluates this module on every load pass.
12
+ */
13
+ export function once(pi: object, key: string, fn: () => void): void {
14
+ const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
15
+ if (!g.__pixOnce) g.__pixOnce = new WeakMap<object, Set<string>>();
16
+ const registry = g.__pixOnce;
17
+ let loaded = registry.get(pi);
18
+ if (!loaded) {
19
+ loaded = new Set<string>();
20
+ registry.set(pi, loaded);
21
+ }
22
+ if (loaded.has(key)) return;
23
+ loaded.add(key);
24
+ fn();
25
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * persistence.ts — read, sparse serialize, atomic write, and a serialized
3
+ * in-process write queue with a short-lived cross-process lock.
4
+ *
5
+ * A failed lock/write/rename leaves the old file intact and throws a typed
6
+ * error; the caller keeps the previous snapshot.
7
+ */
8
+
9
+ import {
10
+ closeSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ openSync,
14
+ readFileSync,
15
+ renameSync,
16
+ rmSync,
17
+ statSync,
18
+ writeFileSync,
19
+ writeSync,
20
+ } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import type { RawDocument } from "./schema.ts";
23
+
24
+ export class ConfigWriteError extends Error {
25
+ constructor(
26
+ message: string,
27
+ readonly cause?: unknown,
28
+ ) {
29
+ super(message);
30
+ this.name = "ConfigWriteError";
31
+ }
32
+ }
33
+
34
+ export class ConfigLockError extends ConfigWriteError {
35
+ constructor(message: string, cause?: unknown) {
36
+ super(message, cause);
37
+ this.name = "ConfigLockError";
38
+ }
39
+ }
40
+
41
+ /** Filesystem adapter — tests inject an in-memory or temp-dir implementation. */
42
+ export interface StorageAdapter {
43
+ readonly path: string;
44
+ readRaw(): string | undefined;
45
+ /** Atomically replace the config file contents. */
46
+ writeAtomic(contents: string): void;
47
+ ensureDir(): void;
48
+ }
49
+
50
+ const LOCK_STALE_MS = 30_000;
51
+ const LOCK_RETRY_MS = 25;
52
+ const LOCK_MAX_RETRIES = 200; // ~5s budget
53
+
54
+ /** Node/Bun filesystem storage rooted at `<agentDir>/pix.json`. */
55
+ export class FileStorage implements StorageAdapter {
56
+ readonly path: string;
57
+ private readonly lockPath: string;
58
+
59
+ constructor(agentDir: string) {
60
+ this.path = join(agentDir, "pix.json");
61
+ this.lockPath = `${this.path}.lock`;
62
+ }
63
+
64
+ ensureDir(): void {
65
+ mkdirSync(dirname(this.path), { recursive: true });
66
+ }
67
+
68
+ readRaw(): string | undefined {
69
+ try {
70
+ if (!existsSync(this.path)) return undefined;
71
+ return readFileSync(this.path, "utf-8");
72
+ } catch (err) {
73
+ throw new ConfigWriteError(`read failed: ${this.path}`, err);
74
+ }
75
+ }
76
+
77
+ private acquireLock(): void {
78
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
79
+ try {
80
+ const fd = openSync(this.lockPath, "wx", 0o600);
81
+ writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
82
+ closeSync(fd);
83
+ return;
84
+ } catch {
85
+ // Reclaim a stale lock only when older than the threshold.
86
+ try {
87
+ const age = Date.now() - statSync(this.lockPath).mtimeMs;
88
+ if (age > LOCK_STALE_MS) {
89
+ rmSync(this.lockPath, { force: true });
90
+ continue;
91
+ }
92
+ } catch {
93
+ // Lock vanished between open and stat — retry immediately.
94
+ continue;
95
+ }
96
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
97
+ }
98
+ }
99
+ throw new ConfigLockError(`could not acquire ${this.lockPath}`);
100
+ }
101
+
102
+ private releaseLock(): void {
103
+ try {
104
+ rmSync(this.lockPath, { force: true });
105
+ } catch {
106
+ /* best effort */
107
+ }
108
+ }
109
+
110
+ writeAtomic(contents: string): void {
111
+ this.ensureDir();
112
+ this.acquireLock();
113
+ const tmp = `${this.path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
114
+ try {
115
+ writeFileSync(tmp, contents, { mode: 0o600 });
116
+ renameSync(tmp, this.path);
117
+ } catch (err) {
118
+ try {
119
+ rmSync(tmp, { force: true });
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ throw new ConfigWriteError(`write failed: ${this.path}`, err);
124
+ } finally {
125
+ this.releaseLock();
126
+ }
127
+ }
128
+ }
129
+
130
+ // ── In-process serialized write queue ────────────────────────────────────────
131
+
132
+ /**
133
+ * Serializes async transactions so concurrent updates in one process never
134
+ * interleave reads and writes. Each task runs after the previous settles.
135
+ */
136
+ export class WriteQueue {
137
+ private tail: Promise<unknown> = Promise.resolve();
138
+
139
+ run<T>(task: () => Promise<T>): Promise<T> {
140
+ const next = this.tail.then(task, task);
141
+ // Keep the chain alive even if a task rejects.
142
+ this.tail = next.then(
143
+ () => undefined,
144
+ () => undefined,
145
+ );
146
+ return next;
147
+ }
148
+ }
149
+
150
+ // ── Raw document read/parse ──────────────────────────────────────────────────
151
+
152
+ export function parseRawDocument(text: string | undefined): RawDocument {
153
+ if (!text) return {};
154
+ try {
155
+ const parsed = JSON.parse(text) as unknown;
156
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
157
+ ? (parsed as RawDocument)
158
+ : {};
159
+ } catch {
160
+ return {};
161
+ }
162
+ }
163
+
164
+ export function serializeRawDocument(doc: RawDocument): string {
165
+ return `${JSON.stringify(doc, null, 2)}\n`;
166
+ }