@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/DESIGN.md ADDED
@@ -0,0 +1,719 @@
1
+ # `@xynogen/pix-runtime` design
2
+
3
+ Status: proposed
4
+ Target first release: `0.1.0`
5
+
6
+ ## 1. Purpose
7
+
8
+ `pix-runtime` is Pix's small shared runtime layer. It owns the process-wide Pix
9
+ configuration contract and the lifecycle needed to keep that contract coherent.
10
+ It is **not** an aggregator, renderer, model-data package, service locator, or
11
+ state database.
12
+
13
+ Its first responsibility is to make `~/.pi/agent/pix.json` the single, sparse,
14
+ versioned user configuration file for the Pix distro. It will own:
15
+
16
+ - the config schema and defaults;
17
+ - validation and normalization;
18
+ - one-time migrations, including `optimizer.json`;
19
+ - atomic persistence and serialized writes;
20
+ - an immutable in-process config snapshot;
21
+ - typed change events with changed paths and origin;
22
+ - lifecycle initialization and shutdown;
23
+ - the `/pix` shared-settings command;
24
+ - narrow, pure runtime helpers such as collapse policy.
25
+
26
+ Package boundaries after adoption:
27
+
28
+ - `pix-core`: dependency aggregation and extension registration only;
29
+ - `pix-runtime`: shared configuration and lifecycle;
30
+ - `pix-data`: model catalogs, scores, and caches only;
31
+ - `pix-pretty`: rendering only; consumes runtime settings;
32
+ - feature packages: own behavior and UI, and consume built-in typed runtime
33
+ sections where shared persistence is appropriate.
34
+
35
+ ## 2. Non-goals
36
+
37
+ The runtime must not become a general dependency-injection container.
38
+ Specifically, v1 does not own:
39
+
40
+ - themes, ANSI values, render caches, or TUI components;
41
+ - model catalogs or network cache refreshes;
42
+ - session transcript state, todos, tool results, or credentials;
43
+ - arbitrary package services;
44
+ - project-local Pix configuration;
45
+ - automatic filesystem watching;
46
+ - hidden background work or package activation.
47
+
48
+ There is one runtime instance per JavaScript process, not one per conversation.
49
+ The singleton is stored under `globalThis[Symbol.for("@xynogen/pix-runtime")]`, not
50
+ only in module scope, so duplicated compatible npm copies do not create separate
51
+ write queues. A second incompatible runtime major must fail closed with a clear
52
+ diagnostic. Session-specific state remains in the relevant extension or Pi
53
+ session log.
54
+
55
+ ## 3. Dependency direction
56
+
57
+ ```text
58
+ pix-core ───────────────► pix-runtime
59
+ │ ▲
60
+ ├─► pix-data │
61
+ ├─► pix-pretty ───────────┤
62
+ └─► feature packages ─────┘
63
+
64
+ pix-runtime ──peer──► Pi host
65
+ pix-runtime ──X─────► pix-core / pix-data / pix-pretty / feature packages
66
+ ```
67
+
68
+ `pix-runtime` must have no dependency on another `@xynogen/pix-*` package. It
69
+ may peer-depend on the Pi host for `getAgentDir()` and extension types. Keeping
70
+ this direction acyclic prevents the runtime from turning into another bundle.
71
+
72
+ `pix-core` registers `pix-runtime` first, before all consumers. Direct installs
73
+ remain supported: importing a runtime accessor lazily creates the singleton and
74
+ loads the built-in sections even if the extension factory was not run.
75
+ The factory is still required for `/pix`, session lifecycle hooks, and user
76
+ notifications.
77
+
78
+ ## 4. Storage contract
79
+
80
+ ### 4.1 Canonical path
81
+
82
+ Always resolve the file through Pi's agent directory:
83
+
84
+ ```ts
85
+ join(getAgentDir(), "pix.json")
86
+ ```
87
+
88
+ This respects `PI_CODING_AGENT_DIR`; it must not reconstruct the path from
89
+ `HOME`. Tests inject an explicit `agentDir` adapter rather than mutating the
90
+ real user directory.
91
+
92
+ ### 4.2 File shape
93
+
94
+ The file is a sparse document with a format version:
95
+
96
+ ```jsonc
97
+ {
98
+ "$version": 1,
99
+ "pretty": {
100
+ "icons": "ascii",
101
+ "diff": { "splitMinWidth": 170 }
102
+ },
103
+ "optimizer": {
104
+ "caveman": "lite",
105
+ "rtk": "on"
106
+ }
107
+ }
108
+ ```
109
+
110
+ Top-level sections in v1:
111
+
112
+ ```ts
113
+ interface PixConfigV1 {
114
+ $version: 1;
115
+ collapse: {
116
+ enabled: boolean; // default true
117
+ delaySec: number; // default 10
118
+ tools: Partial<Record<string, boolean>>;
119
+ };
120
+ pretty: {
121
+ icons: "nerd" | "unicode" | "ascii"; // default nerd
122
+ lsStyle: "grid" | "tree"; // default grid
123
+ maxPreviewLines: number; // default 80
124
+ maxRenderLines: number; // default 150
125
+ maxHighlightChars: number; // default 80000
126
+ cacheLimit: number; // default 128
127
+ diff: {
128
+ splitMinWidth: number; // default 150
129
+ splitMinCodeWidth: number; // default 60
130
+ };
131
+ };
132
+ optimizer: {
133
+ caveman: "off" | "lite" | "full" | "ultra" | "micro"; // default off
134
+ rtk: "off" | "on"; // default on
135
+ toon: "off" | "on"; // default on
136
+ ponytail: "off" | "lite" | "full" | "ultra"; // default off
137
+ };
138
+ gate: {
139
+ disableDefaults: boolean; // default false
140
+ autoApprove: string[]; // default []
141
+ extraRules: Array<{
142
+ pattern: string;
143
+ flags?: string;
144
+ severity?: "risky" | "dangerous" | "critical";
145
+ reason?: string;
146
+ }>;
147
+ };
148
+ }
149
+ ```
150
+
151
+ The **resolved snapshot** always contains every field. The **persisted file**
152
+ contains `$version` plus sparse known values. Runtime tracks provenance for
153
+ known paths (`explicit` versus `inherited`): an explicitly written value is
154
+ retained even when it currently equals the default, while untouched inherited
155
+ defaults are omitted. This prevents a future default change from silently
156
+ changing an explicit user choice. `reset()` clears explicit provenance and
157
+ restores inheritance.
158
+
159
+ Unknown top-level sections and unknown keys inside known sections are preserved
160
+ on read/write so a newer or standalone package is not erased by an older
161
+ runtime. Runtime therefore keeps a private raw-document shadow beside the typed
162
+ snapshot; the presence of known paths in that shadow is their explicit-value
163
+ provenance. Section serialization merges known fields into that shadow rather
164
+ than reconstructing the whole file from typed values. Unknown data is not
165
+ exposed through typed selectors in v1.
166
+
167
+ `$version` is metadata and is always persisted after the first successful
168
+ migration/write. An entirely default v1 config is therefore:
169
+
170
+ ```json
171
+ { "$version": 1 }
172
+ ```
173
+
174
+ ### 4.3 Atomic writes
175
+
176
+ All writes use one serialized in-process queue and a short-lived cross-process
177
+ lock (`pix.json.lock`, acquired with exclusive creation). The lock contains PID
178
+ and timestamp metadata, uses bounded retry/backoff, and may be reclaimed only
179
+ when it is older than the configured stale threshold and its owning process is
180
+ confirmed absent where the platform supports that check. Lock timeout returns a
181
+ typed error rather than writing concurrently.
182
+
183
+ Inside the lock, every transaction:
184
+
185
+ 1. reads the latest on-disk document (never trusts only the cached shadow);
186
+ 2. migrates and validates it;
187
+ 3. applies the typed update;
188
+ 4. merges known fields while preserving unknown raw fields;
189
+ 5. omits inherited defaults recursively while retaining explicitly set values;
190
+ 6. writes `<pix.json>.tmp-<pid>-<nonce>` in the same directory;
191
+ 7. flushes and closes the temporary file;
192
+ 8. renames it over `pix.json` atomically and best-effort flushes the directory;
193
+ 9. releases the cross-process lock in `finally`;
194
+ 10. updates the in-memory snapshot;
195
+ 11. emits one change event.
196
+
197
+ Use mode `0o600` for a newly created file and temporary file. No caller writes
198
+ `pix.json` directly. A failed lock/write/rename leaves the old file intact, does
199
+ not change the snapshot, and returns a typed error. The runtime never silently
200
+ reports success. This protects both multiple sessions in one process and
201
+ multiple Pi processes sharing the same agent directory.
202
+
203
+ ## 5. Schema registry
204
+
205
+ Runtime models configuration as typed sections rather than exposing one
206
+ stringly typed mega-object. Built-in sections (`collapse`, `pretty`,
207
+ `optimizer`, `gate`) live in runtime so the core config works immediately.
208
+ V1 keeps the registry internal: there is no stable dynamic registration API
209
+ until a real optional-package use case proves its lifecycle and ownership
210
+ semantics. Unknown raw sections are still preserved, so this does not block
211
+ forward compatibility.
212
+
213
+ ```ts
214
+ export interface ConfigSection<T> {
215
+ key: string;
216
+ defaults: Readonly<T>;
217
+ parse(raw: unknown, ctx: ParseContext): T;
218
+ serialize?(value: T, defaults: T): unknown;
219
+ settings?: readonly SettingDescriptor<T>[];
220
+ }
221
+
222
+ export function defineSection<const K extends string, T>(
223
+ definition: ConfigSection<T> & { key: K },
224
+ ): SectionHandle<K, T>;
225
+
226
+ ```
227
+
228
+ Registry rules:
229
+
230
+ - duplicate built-in keys fail at module initialization;
231
+ - parsers are pure and synchronous;
232
+ - invalid fields fall back individually and produce diagnostics rather than
233
+ invalidating the whole file;
234
+ - `gate.extraRules` validates both regex pattern and flags during parsing, so
235
+ malformed expressions become `INVALID_VALUE` diagnostics and never throw at
236
+ tool-call time;
237
+ - a future public `registerSection()` API requires a separate design/release.
238
+
239
+ Built-in handles are exported from `@xynogen/pix-runtime/sections`:
240
+
241
+ ```ts
242
+ collapseSection
243
+ prettySection
244
+ optimizerSection
245
+ gateSection
246
+ ```
247
+
248
+ This avoids stringly typed access in consumers.
249
+
250
+ ## 6. Public API
251
+
252
+ ### 6.1 Runtime access
253
+
254
+ ```ts
255
+ export interface PixRuntime {
256
+ readonly path: string;
257
+ readonly ready: boolean;
258
+
259
+ init(options?: InitOptions): Promise<ConfigSnapshot>;
260
+ flush(): Promise<void>;
261
+ shutdown(): Promise<void>;
262
+
263
+ snapshot(): ConfigSnapshot;
264
+ get<K extends string, T>(section: SectionHandle<K, T>): Readonly<T>;
265
+
266
+ update<K extends string, T>(
267
+ section: SectionHandle<K, T>,
268
+ updater: DeepPartial<T> | ((current: Readonly<T>) => T),
269
+ options?: UpdateOptions,
270
+ ): Promise<ConfigChange | undefined>;
271
+
272
+ reset<K extends string, T>(
273
+ section: SectionHandle<K, T>,
274
+ paths?: readonly ConfigPath<T>[],
275
+ options?: UpdateOptions,
276
+ ): Promise<ConfigChange | undefined>;
277
+
278
+ reload(options?: ReloadOptions): Promise<ConfigChange | undefined>;
279
+ subscribe(listener: ConfigListener, options?: SubscribeOptions): () => void;
280
+ diagnostics(): readonly ConfigDiagnostic[];
281
+ }
282
+
283
+ export function pixRuntime(): PixRuntime;
284
+ ```
285
+
286
+ Convenience functions may delegate to the singleton:
287
+
288
+ ```ts
289
+ config(section)
290
+ updateConfig(section, patch, options?)
291
+ onConfigChange(listener, options?)
292
+ reloadConfig(options?)
293
+ ```
294
+
295
+ There is deliberately no untyped `savePixConfig(Record<string, unknown>)` in
296
+ the stable API. Patch semantics are explicit: objects merge recursively, arrays
297
+ replace, `undefined` is rejected, and `null` is accepted only when the section
298
+ schema permits it. `reset()` is the only way to restore defaults/delete known
299
+ persisted paths. A functional updater receives the latest section parsed from
300
+ disk inside the transaction lock, not a stale cached value.
301
+
302
+ ### 6.2 Snapshots and immutability
303
+
304
+ Every successful commit creates a deeply frozen snapshot with a monotonically
305
+ increasing in-process revision. Reads are synchronous after lazy initialization:
306
+
307
+ ```ts
308
+ interface ConfigSnapshot {
309
+ readonly revision: number;
310
+ readonly formatVersion: 1;
311
+ readonly loadedAt: number;
312
+ get<K extends string, T>(section: SectionHandle<K, T>): Readonly<T>;
313
+ }
314
+ ```
315
+
316
+ `runtime.get(section)` and `snapshot.get(section)` return deeply frozen section
317
+ values. The latter lets change listeners compare previous/current values
318
+ without reaching back into mutable runtime state. Consumers must not retain and
319
+ mutate them. Updates are immutable and typed.
320
+
321
+ ### 6.3 Change events
322
+
323
+ ```ts
324
+ type ConfigChangeOrigin =
325
+ | "init"
326
+ | "command"
327
+ | "api"
328
+ | "reload"
329
+ | "migration";
330
+
331
+ interface ConfigChange {
332
+ readonly revision: number;
333
+ readonly origin: ConfigChangeOrigin;
334
+ readonly source?: string; // e.g. "pix-pretty:/pix" or "pix-optimizer:/optimizer"
335
+ readonly changed: readonly string[]; // JSON paths, e.g. "pretty.icons"
336
+ readonly previous?: ConfigSnapshot; // absent only for init/immediate delivery
337
+ readonly current: ConfigSnapshot;
338
+ readonly persisted: boolean;
339
+ }
340
+ ```
341
+
342
+ Mutation/reload events fire only after success and only when the resolved
343
+ config actually changes. A no-op `update()`/`reset()` returns `undefined`.
344
+ Initialization emits one `origin: "init"` event with no `previous` snapshot;
345
+ `{ immediate: true }` delivers that same shape for the current snapshot to the
346
+ new listener only. Listener failures are isolated and reported as diagnostics;
347
+ they do not roll back a committed write. Dispatch occurs in registration order
348
+ from a copied listener list so unsubscribe during dispatch is safe.
349
+
350
+ Filtering is built in:
351
+
352
+ ```ts
353
+ runtime.subscribe(listener, { paths: ["pretty.icons", "optimizer.*"], immediate: true });
354
+ ```
355
+
356
+ This is an in-process event bus only. External edits become visible when
357
+ `reload()` is explicitly called or when a future Pi config-reload lifecycle
358
+ hook invokes it. V1 intentionally avoids a permanent filesystem watcher.
359
+ Listener-triggered updates are appended to the write queue after current event
360
+ dispatch, preventing recursive commits and preserving event order.
361
+
362
+ ## 7. Initialization and lifecycle
363
+
364
+ The extension entry point is idempotent:
365
+
366
+ ```ts
367
+ export default function registerRuntime(pi: ExtensionAPI): void;
368
+ ```
369
+
370
+ A process-global compatible-runtime record contains a registration token that
371
+ prevents duplicate command/lifecycle registration when runtime is installed
372
+ directly and also imported through `pix-core` (even if package copies receive
373
+ different wrapper objects for the Pi host). On first registration it:
374
+
375
+ 1. installs built-in section definitions;
376
+ 2. creates the process singleton using `getAgentDir()`;
377
+ 3. registers `/pix`;
378
+ 4. registers one `session_start` hook that calls `init()` the first time and
379
+ `reload()` on later sessions, then surfaces aggregated migration/parse
380
+ diagnostics through the UI;
381
+ 5. registers one `session_shutdown` hook that calls `flush()`.
382
+
383
+ A session shutdown does not destroy process-wide subscriptions: Pi may start
384
+ another session in the same process. Explicit `shutdown()` is reserved for
385
+ process teardown and tests; it flushes writes, releases runtime resources, and
386
+ clears listeners.
387
+
388
+ `init()` itself is single-flight. Concurrent calls share one promise. It:
389
+
390
+ 1. creates the agent directory if needed;
391
+ 2. acquires the in-process write queue;
392
+ 3. reads `pix.json` without modifying it yet;
393
+ 4. detects the source format;
394
+ 5. runs ordered migrations;
395
+ 6. parses all built-in sections;
396
+ 7. imports legacy sidecars;
397
+ 8. atomically persists the canonical sparse document if migration changed it
398
+ and the current release stage allows that migration;
399
+ 9. freezes and publishes the first snapshot;
400
+ 10. emits one consolidated event: `init` when no lazy snapshot existed, or
401
+ `migration` when a prior lazy snapshot changed. If a lazy snapshot is still
402
+ identical, initialization emits nothing; `{ immediate: true }`
403
+ subscriptions already received that snapshot.
404
+
405
+ Consumers may call `get()` before `session_start`; this performs a synchronous,
406
+ read-only lazy load so module-level constants continue to work during the
407
+ transition. It does not migrate or write, but it may read a valid legacy optimizer sidecar
408
+ as a temporary overlay so optimizer controls do not flicker to defaults before
409
+ initialization. Later `init()` reparses under the normal transaction lock and
410
+ publishes any difference. Packages should migrate
411
+ module-level config constants to functions or subscriptions because a constant
412
+ cannot react to `/pix` changes.
413
+
414
+ Runtime resolves only JSON values plus schema defaults. Legacy package-specific
415
+ environment variables are not folded into the persisted snapshot because doing
416
+ so would make sparse serialization and change events ambiguous. During the
417
+ compatibility window, each consumer may apply its existing environment override
418
+ on top of `runtime.get(section)` using its current precedence; those variables
419
+ should be documented as external overrides and deprecated separately.
420
+
421
+ ## 8. Migration design
422
+
423
+ Migrations are ordered, idempotent, and pure over a `RawDocument`:
424
+
425
+ ```ts
426
+ interface Migration {
427
+ from: number | "unversioned";
428
+ to: number;
429
+ migrate(document: RawDocument, context: MigrationContext): MigrationResult;
430
+ }
431
+ ```
432
+
433
+ ### 8.1 Unversioned `pix.json` → v1
434
+
435
+ - retain valid `collapse`, `pretty`, `optimizer`, and `gate` values;
436
+ - remove `pretty.theme`, `pretty.syntaxTheme`, `pretty.diffColors`, and legacy
437
+ diff color fields because active Pi themes own all colors;
438
+ - preserve unknown keys;
439
+ - normalize default-valued entries out of the persisted document;
440
+ - set `$version: 1`.
441
+
442
+ ### 8.2 `optimizer.json` → `pix.json.optimizer`
443
+
444
+ Import `join(getAgentDir(), "optimizer.json")` exactly once:
445
+
446
+ - validate each optimizer value independently;
447
+ - when both files contain a valid value, existing `pix.json.optimizer.<key>`
448
+ wins;
449
+ - otherwise import the valid sidecar value;
450
+ - write canonical `pix.json` first;
451
+ - only after that rename the sidecar to `optimizer.json.migrated-v1`;
452
+ - if the archive name exists, append a timestamp;
453
+ - never delete the sidecar before the canonical write succeeds;
454
+ - malformed sidecars remain untouched and generate a visible diagnostic;
455
+ - future starts skip archived files, making migration idempotent.
456
+
457
+ Migration is deliberately staged because already-published `pix-data` versions
458
+ delete `raw.optimizer` whenever they save:
459
+
460
+ 1. the Phase-A `pix-data` compatibility release removes that deletion and makes
461
+ every config write delegate to runtime;
462
+ 2. every first-party package still depending on `pix-data` raises its
463
+ `pix-data` floor to that compatibility version in the same release train,
464
+ preventing npm from resolving a known pre-delegate version for those
465
+ packages; package manifests and `bun.lock` are audited before tagging;
466
+ 3. the first optimizer-runtime release imports into `pix.json` but keeps
467
+ `optimizer.json` as a compatibility mirror/read fallback for one full
468
+ release train; optimizer updates write canonical config first, then update
469
+ the mirror;
470
+ 4. if an old writer erases `pix.json.optimizer`, runtime restores it from the
471
+ mirror on the next locked initialization/reload. The reverse compatibility
472
+ direction is not fully enforceable for arbitrary third-party/stale installs,
473
+ so the mirror remains the authoritative rollback source during this window;
474
+ 5. only after the compatibility window and published dependency-floor audit
475
+ does runtime rename the mirror to `optimizer.json.migrated-v1` (timestamping
476
+ conflicts). A competing process that already moved it makes `ENOENT` benign.
477
+
478
+ The archived sidecar then provides a transparent rollback/audit path. A later
479
+ major release may remove archived files, but v1 does not. This temporary mirror
480
+ is the sole exception to the eventual one-file storage rule and exists only to
481
+ avoid user-state loss during mixed-version upgrades.
482
+
483
+ ### 8.3 Old runtimes and forward compatibility
484
+
485
+ If `$version` is greater than the maximum supported version, runtime enters
486
+ read-only compatibility mode:
487
+
488
+ - known sections may be read best-effort;
489
+ - no automatic normalization or migration occurs;
490
+ - all updates fail with `UNSUPPORTED_CONFIG_VERSION`;
491
+ - `/pix` displays the problem and the path instead of overwriting the file.
492
+
493
+ This is safer than letting an older package erase newer settings.
494
+
495
+ ## 9. `/pix` and package-owned controls
496
+
497
+ `/pix` is registered by runtime because it edits the shared document. Its rows
498
+ come from section `settings` descriptors. This keeps persistence centralized
499
+ without making runtime know feature behavior.
500
+
501
+ A descriptor contains a path, label, parser/formatter, allowed values, and
502
+ optional visibility. These descriptors are internal to built-in runtime
503
+ sections in v1. Runtime supplies the generic overlay and calls
504
+ `runtime.update()`.
505
+
506
+ Ownership rule:
507
+
508
+ - `/pix` shows distro-wide settings such as icons, layout, collapse, and gate;
509
+ - `/optimizer` remains the optimizer's complete control surface;
510
+ - optimizer consumes runtime's built-in `optimizerSection` but does **not**
511
+ register controls in `/pix`;
512
+ - `/optimizer` writes `optimizerSection` through runtime and subscribes to
513
+ `optimizer.*` changes;
514
+ - config ownership and UI ownership are separate concepts.
515
+
516
+ The `/pix` headless summary marks non-default values and reports the canonical
517
+ path. It never dumps unknown sections or secrets.
518
+
519
+ ## 10. Consumer migration
520
+
521
+ ### Phase A — introduce runtime without behavior changes
522
+
523
+ 1. Create `pix-runtime` with built-in sections, tests, and extension entry.
524
+ 2. Add it as the first dependency/member in `pix-core`.
525
+ 3. Keep temporary compatibility exports in `pix-data/pix-config` and
526
+ `pix-data/collapse`, implemented as deprecated delegates to runtime. This
527
+ release explicitly removes `delete raw.optimizer` and direct filesystem
528
+ writes.
529
+ 4. Make runtime read legacy unversioned `pix.json` but postpone sidecar import
530
+ until optimizer has runtime support in the same release train.
531
+ 5. Ship this compatibility release before enabling optimizer migration. This is
532
+ essential because historical `pix-data.savePixConfig()` deletes the
533
+ `optimizer` key and could otherwise destroy newly migrated state in a mixed
534
+ install.
535
+ 6. Update `AGENTS.md` now—not in final cleanup—to recognize runtime as a
536
+ sanctioned shared layer and document the new dependency direction.
537
+ 7. Keep `0.1.x` private/experimental while stabilizing the contract, publish
538
+ `1.0.0` before broad Phase-B adoption, and then avoid 0.x caret-range churn
539
+ across roughly twenty consumers.
540
+
541
+ ### Phase B — move consumers
542
+
543
+ - `pix-pretty`: use `prettySection`; subscribe to `pretty.icons`; convert
544
+ module-level numeric constants to getters or captured values refreshed on
545
+ config events.
546
+ - runtime owns only pure `shouldCollapse()` and `collapseDelayMs()` policy;
547
+ move the UI timer/state machine (`tickCollapse`, `CollapseState`) to
548
+ `@xynogen/pix-pretty/collapse`, which consumes runtime policy. Tool packages
549
+ already use pix-pretty for rendering and should not get UI state from runtime.
550
+ - `pix-gate`: read `gateSection` at tool-call time or rebuild compiled rules on
551
+ `gate.*` changes so `/pix` updates are live.
552
+ - `pix-data`: remove config and command ownership; retain only data/cache APIs.
553
+ - tests: construct isolated runtimes with injected filesystem/path/log adapters,
554
+ avoiding singleton and real-home leakage.
555
+
556
+ ### Phase C — unify optimizer state
557
+
558
+ 1. Add `pix-runtime` dependency to `pix-optimizer`.
559
+ 2. Replace `loadOptValue`/`saveOptValue` with `runtime.get/update` on
560
+ `optimizerSection`.
561
+ 3. Preserve session-log entries for branch-local restoration only: initialize
562
+ from global config, then let a valid current-branch session entry override
563
+ the live value without rewriting global config. An explicit `/optimizer`
564
+ change writes both the branch entry and global preference.
565
+ 4. Enable canonical sidecar import plus the temporary compatibility mirror;
566
+ archive `optimizer.json` only after the audited compatibility window.
567
+ 5. Keep `/optimizer` as the only optimizer UI.
568
+
569
+ ### Phase D — remove compatibility layer
570
+
571
+ After all first-party consumers have shipped runtime-based versions:
572
+
573
+ - remove `pix-config.ts`, `/pix`, and config docs from `pix-data`;
574
+ - move collapse implementation and tests to runtime;
575
+ - remove deprecated exports in the next planned breaking release of `pix-data`;
576
+ - remove compatibility wording from `AGENTS.md`; runtime was already added as a
577
+ sanctioned layer in Phase A.
578
+
579
+ ## 11. Testing requirements
580
+
581
+ The runtime release is blocked unless tests cover:
582
+
583
+ ### Schema and normalization
584
+
585
+ - every built-in default resolves correctly;
586
+ - invalid fields fall back individually and produce path diagnostics;
587
+ - inherited defaults are removed recursively while explicit default-valued
588
+ choices survive;
589
+ - unknown fields survive updates;
590
+ - snapshots are deeply immutable;
591
+ - duplicate built-in section registration is rejected;
592
+ - malformed gate regex patterns/flags become diagnostics.
593
+
594
+ ### Persistence
595
+
596
+ - writes are atomic and leave no temp file after success;
597
+ - simulated write/rename failures preserve the old file and snapshot;
598
+ - concurrent updates in one process and in two simulated processes are
599
+ serialized without lost fields;
600
+ - functional updaters receive the latest locked on-disk value;
601
+ - no-op updates neither write nor emit;
602
+ - custom `PI_CODING_AGENT_DIR` is respected;
603
+ - new files use restrictive permissions where supported.
604
+
605
+ ### Migration
606
+
607
+ - unversioned config migrates to v1;
608
+ - legacy color keys are removed;
609
+ - optimizer sidecar imports valid values and serves as a temporary read/write
610
+ compatibility mirror;
611
+ - canonical optimizer values win conflicts;
612
+ - malformed sidecars remain untouched;
613
+ - sidecar archives only after successful canonical write and the audited
614
+ compatibility window;
615
+ - rerunning migration is idempotent, and concurrent archive `ENOENT` is benign;
616
+ - future versions enter read-only mode.
617
+
618
+ ### Events and lifecycle
619
+
620
+ - initialization is single-flight and idempotent;
621
+ - events contain correct optional-previous/current snapshots and changed paths;
622
+ - path filters and immediate subscriptions work;
623
+ - listener errors are isolated;
624
+ - session flush and explicit shutdown drain pending writes without breaking
625
+ subscriptions needed by a later session;
626
+ - direct package use works without `pix-core` registration.
627
+
628
+ ### Integration
629
+
630
+ - `/pix` updates icon mode live;
631
+ - `/optimizer` updates only optimizer config and remains its sole UI;
632
+ - gate rules rebuild after a gate config change;
633
+ - collapse policy changes affect newly rendered cards;
634
+ - installing `pix-data` alone no longer registers `/pix` after compatibility
635
+ removal.
636
+
637
+ ## 12. Error and diagnostic model
638
+
639
+ No config failure should crash Pi, but failures must be visible and
640
+ inspectable. Runtime keeps bounded diagnostics and exposes them to `/pix`:
641
+
642
+ ```ts
643
+ interface ConfigDiagnostic {
644
+ code:
645
+ | "PARSE_ERROR"
646
+ | "INVALID_VALUE"
647
+ | "READ_FAILED"
648
+ | "WRITE_FAILED"
649
+ | "MIGRATION_FAILED"
650
+ | "UNSUPPORTED_CONFIG_VERSION"
651
+ | "LISTENER_FAILED";
652
+ severity: "warning" | "error";
653
+ path?: string;
654
+ message: string;
655
+ cause?: unknown;
656
+ at: number;
657
+ }
658
+ ```
659
+
660
+ At session start, aggregate diagnostics into at most one notification to avoid
661
+ noise. `/pix` provides the detailed paths. Never include config values in error
662
+ messages unless they are known non-sensitive enum/number values.
663
+
664
+ ## 13. Recommended source layout
665
+
666
+ ```text
667
+ packages/pix-runtime/
668
+ package.json
669
+ README.md
670
+ DESIGN.md
671
+ src/
672
+ index.ts # extension entry + stable public exports
673
+ runtime.ts # singleton and PixRuntime implementation
674
+ registry.ts # internal built-in section registry
675
+ schema.ts # shared types and validation helpers
676
+ sections/
677
+ index.ts
678
+ collapse.ts
679
+ pretty.ts
680
+ optimizer.ts
681
+ gate.ts
682
+ persistence.ts # read, sparse serialize, atomic write, queue
683
+ migrations.ts # versioned migrations and sidecar importer
684
+ events.ts # listener registry and path filtering
685
+ collapse.ts # pure collapse policy only
686
+ diagnostics.ts
687
+ pix-command.ts
688
+ testing.ts # createIsolatedRuntime(adapters)
689
+ ```
690
+
691
+ Suggested exports:
692
+
693
+ ```jsonc
694
+ {
695
+ ".": "./src/index.ts",
696
+ "./config": "./src/runtime.ts",
697
+ "./sections": "./src/sections/index.ts",
698
+ "./collapse": "./src/collapse.ts",
699
+ "./testing": "./src/testing.ts"
700
+ }
701
+ ```
702
+
703
+ ## 14. Acceptance criteria
704
+
705
+ `pix-runtime` is ready to release when:
706
+
707
+ 1. all config paths use `getAgentDir()` and honor `PI_CODING_AGENT_DIR`;
708
+ 2. `pix.json` is versioned, sparse, validated, atomically written, and guarded
709
+ against concurrent writers;
710
+ 3. unknown fields survive older-runtime updates and future versions fail closed;
711
+ 4. optimizer state migrates once with an archived rollback file;
712
+ 5. failed writes cannot corrupt or falsely update the live snapshot;
713
+ 6. typed, filtered change events update icons, gate rules, and optimizer state;
714
+ 7. `/pix` and `/optimizer` retain separate ownership;
715
+ 8. `pix-data` can become a pure model-data package;
716
+ 9. `pix-core` remains an ordered aggregator with runtime first;
717
+ 10. standalone packages work without pix-core through lazy runtime access;
718
+ 11. the full test, typecheck, lint, dependency, migration, and publish dry-run
719
+ suites pass.