@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 +719 -0
- package/README.md +57 -0
- package/package.json +52 -0
- package/src/collapse.ts +20 -0
- package/src/diagnostics.ts +26 -0
- package/src/events.ts +79 -0
- package/src/extension.ts +44 -0
- package/src/index.ts +41 -0
- package/src/migrations.ts +142 -0
- package/src/once.ts +25 -0
- package/src/persistence.ts +166 -0
- package/src/pix-command.ts +188 -0
- package/src/registry.ts +38 -0
- package/src/runtime.test.ts +244 -0
- package/src/runtime.ts +462 -0
- package/src/schema.ts +191 -0
- package/src/sections/collapse.ts +35 -0
- package/src/sections/gate.ts +71 -0
- package/src/sections/index.ts +34 -0
- package/src/sections/optimizer.ts +37 -0
- package/src/sections/pretty.ts +53 -0
- package/src/testing.ts +28 -0
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.
|