@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/src/runtime.ts ADDED
@@ -0,0 +1,462 @@
1
+ /**
2
+ * runtime.ts — the process singleton and PixRuntime implementation.
3
+ *
4
+ * One runtime per JavaScript process, stored under a globalThis symbol so
5
+ * duplicate compatible npm copies share one write queue. Reads are synchronous
6
+ * after a lazy load; writes are serialized and atomic.
7
+ */
8
+
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import { DiagnosticSink } from "./diagnostics.ts";
11
+ import {
12
+ type ConfigChange,
13
+ type ConfigChangeOrigin,
14
+ type ConfigListener,
15
+ type ConfigSnapshot,
16
+ EventBus,
17
+ makeSnapshot,
18
+ type SubscribeOptions,
19
+ } from "./events.ts";
20
+ import { importOptimizerSidecar, migrate } from "./migrations.ts";
21
+ import {
22
+ ConfigWriteError,
23
+ FileStorage,
24
+ parseRawDocument,
25
+ type StorageAdapter,
26
+ serializeRawDocument,
27
+ WriteQueue,
28
+ } from "./persistence.ts";
29
+ import { SectionRegistry } from "./registry.ts";
30
+ import {
31
+ CONFIG_FORMAT_VERSION,
32
+ type ConfigDiagnostic,
33
+ clone,
34
+ type DeepPartial,
35
+ deepMerge,
36
+ diffPaths,
37
+ isObj,
38
+ type ParseContext,
39
+ type RawDocument,
40
+ type SectionHandle,
41
+ stripDefaults,
42
+ } from "./schema.ts";
43
+
44
+ export interface InitOptions {
45
+ origin?: ConfigChangeOrigin;
46
+ source?: string;
47
+ }
48
+
49
+ export interface UpdateOptions {
50
+ origin?: ConfigChangeOrigin;
51
+ source?: string;
52
+ }
53
+
54
+ export interface ReloadOptions {
55
+ origin?: ConfigChangeOrigin;
56
+ source?: string;
57
+ }
58
+
59
+ export interface RuntimeAdapters {
60
+ agentDir?: string;
61
+ storage?: StorageAdapter;
62
+ registry?: SectionRegistry;
63
+ }
64
+
65
+ export interface PixRuntime {
66
+ readonly path: string;
67
+ readonly ready: boolean;
68
+ init(options?: InitOptions): Promise<ConfigSnapshot>;
69
+ flush(): Promise<void>;
70
+ shutdown(): Promise<void>;
71
+ snapshot(): ConfigSnapshot;
72
+ get<K extends string, T>(section: SectionHandle<K, T>): Readonly<T>;
73
+ update<K extends string, T>(
74
+ section: SectionHandle<K, T>,
75
+ updater: DeepPartial<T> | ((current: Readonly<T>) => T),
76
+ options?: UpdateOptions,
77
+ ): Promise<ConfigChange | undefined>;
78
+ reset<K extends string, T>(
79
+ section: SectionHandle<K, T>,
80
+ paths?: readonly string[],
81
+ options?: UpdateOptions,
82
+ ): Promise<ConfigChange | undefined>;
83
+ reload(options?: ReloadOptions): Promise<ConfigChange | undefined>;
84
+ subscribe(listener: ConfigListener, options?: SubscribeOptions): () => void;
85
+ diagnostics(): readonly ConfigDiagnostic[];
86
+ }
87
+
88
+ class RuntimeImpl implements PixRuntime {
89
+ private readonly storage: StorageAdapter;
90
+ private readonly registry: SectionRegistry;
91
+ private readonly queue = new WriteQueue();
92
+ private readonly bus = new EventBus();
93
+ private readonly sink = new DiagnosticSink();
94
+
95
+ private revision = 0;
96
+ private current: ConfigSnapshot | null = null;
97
+ private readonly agentDir: string;
98
+ private initPromise: Promise<ConfigSnapshot> | null = null;
99
+ private readOnly = false;
100
+
101
+ constructor(adapters: RuntimeAdapters = {}) {
102
+ this.agentDir = adapters.agentDir ?? getAgentDir();
103
+ this.storage = adapters.storage ?? new FileStorage(this.agentDir);
104
+ this.registry = adapters.registry ?? new SectionRegistry();
105
+ }
106
+
107
+ get path(): string {
108
+ return this.storage.path;
109
+ }
110
+
111
+ get ready(): boolean {
112
+ return this.current !== null;
113
+ }
114
+
115
+ private parseContext(): ParseContext {
116
+ return { diagnostic: (d) => this.sink.push(d) };
117
+ }
118
+
119
+ /** Resolve every section from a raw document into a values map. */
120
+ private resolve(doc: RawDocument): Map<string, unknown> {
121
+ const ctx = this.parseContext();
122
+ const values = new Map<string, unknown>();
123
+ for (const section of this.registry.all()) {
124
+ values.set(section.key, section.parse(doc[section.key], ctx));
125
+ }
126
+ return values;
127
+ }
128
+
129
+ private publish(values: Map<string, unknown>): ConfigSnapshot {
130
+ this.revision += 1;
131
+ this.current = makeSnapshot(this.revision, values);
132
+ return this.current;
133
+ }
134
+
135
+ /** Synchronous lazy load — read-only, never migrates or writes. */
136
+ private lazyLoad(): ConfigSnapshot {
137
+ if (this.current) return this.current;
138
+ let doc: RawDocument = {};
139
+ try {
140
+ doc = parseRawDocument(this.storage.readRaw());
141
+ } catch (err) {
142
+ this.sink.push({
143
+ code: "READ_FAILED",
144
+ severity: "warning",
145
+ message: "config read failed; using defaults",
146
+ cause: err,
147
+ });
148
+ }
149
+ return this.publish(this.resolve(doc));
150
+ }
151
+
152
+ snapshot(): ConfigSnapshot {
153
+ return this.current ?? this.lazyLoad();
154
+ }
155
+
156
+ get<K extends string, T>(section: SectionHandle<K, T>): Readonly<T> {
157
+ return this.snapshot().get(section);
158
+ }
159
+
160
+ diagnostics(): readonly ConfigDiagnostic[] {
161
+ return this.sink.all();
162
+ }
163
+
164
+ subscribe(listener: ConfigListener, options: SubscribeOptions = {}): () => void {
165
+ const unsub = this.bus.subscribe({ listener, paths: options.paths });
166
+ if (options.immediate) {
167
+ listener({
168
+ revision: this.revision,
169
+ origin: "init",
170
+ changed: [],
171
+ current: this.snapshot(),
172
+ persisted: false,
173
+ });
174
+ }
175
+ return unsub;
176
+ }
177
+
178
+ /** Single-flight initialization: migrate, import sidecars, publish. */
179
+ init(options: InitOptions = {}): Promise<ConfigSnapshot> {
180
+ if (this.initPromise) return this.initPromise;
181
+ this.initPromise = this.queue.run(async () => {
182
+ const hadLazy = this.current !== null;
183
+ const previous = this.current;
184
+ this.storage.ensureDir();
185
+ const doc = parseRawDocument(this.storage.readRaw());
186
+ const ctx = this.parseContext();
187
+
188
+ const migrated = migrate(doc, ctx);
189
+ this.readOnly = migrated.document.$version !== CONFIG_FORMAT_VERSION;
190
+
191
+ let working = migrated.document;
192
+ let needsWrite = migrated.changed;
193
+ let archive: (() => void) | undefined;
194
+
195
+ if (!this.readOnly) {
196
+ const sidecar = importOptimizerSidecar(working, this.agentDir, ctx);
197
+ if (sidecar.changed) needsWrite = true;
198
+ archive = sidecar.archive;
199
+ }
200
+
201
+ if (needsWrite && !this.readOnly) {
202
+ try {
203
+ this.persist(working);
204
+ archive?.();
205
+ } catch (err) {
206
+ this.sink.push({
207
+ code: "WRITE_FAILED",
208
+ severity: "error",
209
+ message: "initial migration write failed",
210
+ cause: err,
211
+ });
212
+ }
213
+ } else if (this.readOnly) {
214
+ working = doc; // do not normalize a future-version file
215
+ }
216
+
217
+ const values = this.resolve(working);
218
+ const snapshot = this.publish(values);
219
+
220
+ if (previous) {
221
+ const changed = this.changedPaths(previous, snapshot);
222
+ if (changed.length > 0) {
223
+ this.dispatch({
224
+ revision: this.revision,
225
+ origin: hadLazy ? "migration" : (options.origin ?? "init"),
226
+ source: options.source,
227
+ changed,
228
+ previous,
229
+ current: snapshot,
230
+ persisted: needsWrite,
231
+ });
232
+ }
233
+ } else {
234
+ this.dispatch({
235
+ revision: this.revision,
236
+ origin: options.origin ?? "init",
237
+ source: options.source,
238
+ changed: [],
239
+ current: snapshot,
240
+ persisted: needsWrite,
241
+ });
242
+ }
243
+ return snapshot;
244
+ });
245
+ return this.initPromise;
246
+ }
247
+
248
+ /** Serialize the current resolved values into a sparse raw document. */
249
+ private buildRawFromValues(values: Map<string, unknown>, base: RawDocument): RawDocument {
250
+ const doc: RawDocument = { ...base, $version: CONFIG_FORMAT_VERSION };
251
+ for (const section of this.registry.all()) {
252
+ const value = values.get(section.key);
253
+ const serialized = section.serialize
254
+ ? section.serialize(value, section.defaults)
255
+ : this.defaultStrip(value, section.defaults);
256
+ if (serialized === undefined) delete doc[section.key];
257
+ else doc[section.key] = serialized;
258
+ }
259
+ return doc;
260
+ }
261
+
262
+ private defaultStrip(value: unknown, defaults: unknown): unknown {
263
+ if (!isObj(value) || !isObj(defaults)) {
264
+ return JSON.stringify(value) === JSON.stringify(defaults) ? undefined : value;
265
+ }
266
+ const copy = { ...value };
267
+ return stripDefaults(copy, defaults) > 0 ? copy : undefined;
268
+ }
269
+
270
+ private persist(doc: RawDocument): void {
271
+ this.storage.writeAtomic(serializeRawDocument(doc));
272
+ }
273
+
274
+ private changedPaths(prev: ConfigSnapshot, next: ConfigSnapshot): string[] {
275
+ const out: string[] = [];
276
+ for (const section of this.registry.all()) {
277
+ const handle = { key: section.key, defaults: section.defaults, __section: section };
278
+ out.push(...diffPaths(section.key, prev.get(handle as never), next.get(handle as never)));
279
+ }
280
+ return out;
281
+ }
282
+
283
+ private dispatch(change: ConfigChange): void {
284
+ this.bus.emit(change, (err) =>
285
+ this.sink.push({
286
+ code: "LISTENER_FAILED",
287
+ severity: "warning",
288
+ message: "config listener threw",
289
+ cause: err,
290
+ }),
291
+ );
292
+ }
293
+
294
+ update<K extends string, T>(
295
+ section: SectionHandle<K, T>,
296
+ updater: DeepPartial<T> | ((current: Readonly<T>) => T),
297
+ options: UpdateOptions = {},
298
+ ): Promise<ConfigChange | undefined> {
299
+ return this.queue.run(async () => this.commit(section, updater, options, "api"));
300
+ }
301
+
302
+ reset<K extends string, T>(
303
+ section: SectionHandle<K, T>,
304
+ paths?: readonly string[],
305
+ options: UpdateOptions = {},
306
+ ): Promise<ConfigChange | undefined> {
307
+ return this.queue.run(async () => {
308
+ // Full reset to defaults, or per-path restore. Always a function
309
+ // updater so defaults *replace* rather than deep-merge onto current.
310
+ const updater = (current: Readonly<T>): T => {
311
+ if (!paths || paths.length === 0) return clone(section.defaults) as T;
312
+ const next = clone(current) as Record<string, unknown>;
313
+ const defs = section.defaults as Record<string, unknown>;
314
+ for (const p of paths) {
315
+ const key = p.startsWith(`${section.key}.`) ? p.slice(section.key.length + 1) : p;
316
+ if (key in defs) next[key] = clone(defs[key]);
317
+ }
318
+ return next as T;
319
+ };
320
+ return this.commit(section, updater, options, "api");
321
+ });
322
+ }
323
+
324
+ /** Core transaction: read latest on-disk, apply update, persist, publish. */
325
+ private commit<K extends string, T>(
326
+ section: SectionHandle<K, T>,
327
+ updater: DeepPartial<T> | ((current: Readonly<T>) => T),
328
+ options: UpdateOptions,
329
+ defaultOrigin: ConfigChangeOrigin,
330
+ ): ConfigChange | undefined {
331
+ if (this.readOnly) {
332
+ this.sink.push({
333
+ code: "UNSUPPORTED_CONFIG_VERSION",
334
+ severity: "error",
335
+ path: section.key,
336
+ message: "config is read-only (newer $version)",
337
+ });
338
+ return undefined;
339
+ }
340
+
341
+ const previous = this.snapshot();
342
+ const base = parseRawDocument(this.storage.readRaw());
343
+ const ctx = this.parseContext();
344
+
345
+ // Re-resolve all sections from the latest on-disk doc so unknown fields
346
+ // and sibling sections survive.
347
+ const values = new Map<string, unknown>();
348
+ for (const s of this.registry.all()) values.set(s.key, s.parse(base[s.key], ctx));
349
+
350
+ const currentValue = values.get(section.key) as Readonly<T>;
351
+ const nextValue =
352
+ typeof updater === "function"
353
+ ? (updater as (c: Readonly<T>) => T)(currentValue)
354
+ : deepMerge(currentValue as T, updater);
355
+
356
+ if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
357
+ values.set(section.key, nextValue);
358
+
359
+ const doc = this.buildRawFromValues(values, base);
360
+ try {
361
+ this.persist(doc);
362
+ } catch (err) {
363
+ this.sink.push({
364
+ code: "WRITE_FAILED",
365
+ severity: "error",
366
+ path: section.key,
367
+ message: "config write failed; snapshot unchanged",
368
+ cause: err,
369
+ });
370
+ if (err instanceof ConfigWriteError) return undefined;
371
+ return undefined;
372
+ }
373
+
374
+ const snapshot = this.publish(values);
375
+ const change: ConfigChange = {
376
+ revision: this.revision,
377
+ origin: options.origin ?? defaultOrigin,
378
+ source: options.source,
379
+ changed: this.changedPaths(previous, snapshot),
380
+ previous,
381
+ current: snapshot,
382
+ persisted: true,
383
+ };
384
+ if (change.changed.length === 0) return undefined;
385
+ this.dispatch(change);
386
+ return change;
387
+ }
388
+
389
+ reload(options: ReloadOptions = {}): Promise<ConfigChange | undefined> {
390
+ return this.queue.run(async () => {
391
+ const previous = this.snapshot();
392
+ const doc = parseRawDocument(this.storage.readRaw());
393
+ const values = this.resolve(doc);
394
+ const snapshot = this.publish(values);
395
+ const changed = this.changedPaths(previous, snapshot);
396
+ if (changed.length === 0) return undefined;
397
+ const change: ConfigChange = {
398
+ revision: this.revision,
399
+ origin: options.origin ?? "reload",
400
+ source: options.source,
401
+ changed,
402
+ previous,
403
+ current: snapshot,
404
+ persisted: false,
405
+ };
406
+ this.dispatch(change);
407
+ return change;
408
+ });
409
+ }
410
+
411
+ flush(): Promise<void> {
412
+ // All writes are synchronous inside queued tasks; draining the queue
413
+ // guarantees pending transactions have settled.
414
+ return this.queue.run(async () => undefined);
415
+ }
416
+
417
+ async shutdown(): Promise<void> {
418
+ await this.flush();
419
+ this.bus.clear();
420
+ }
421
+ }
422
+
423
+ // ── Process singleton ────────────────────────────────────────────────────────
424
+
425
+ const SINGLETON_KEY = Symbol.for("@xynogen/pix-runtime");
426
+
427
+ interface Global {
428
+ [SINGLETON_KEY]?: PixRuntime;
429
+ }
430
+
431
+ export function pixRuntime(): PixRuntime {
432
+ const g = globalThis as Global;
433
+ if (!g[SINGLETON_KEY]) g[SINGLETON_KEY] = new RuntimeImpl();
434
+ return g[SINGLETON_KEY];
435
+ }
436
+
437
+ /** Test/entry hook: create an isolated runtime with injected adapters. */
438
+ export function createRuntime(adapters: RuntimeAdapters = {}): PixRuntime {
439
+ return new RuntimeImpl(adapters);
440
+ }
441
+
442
+ // ── Convenience delegates ────────────────────────────────────────────────────
443
+
444
+ export function config<K extends string, T>(section: SectionHandle<K, T>): Readonly<T> {
445
+ return pixRuntime().get(section);
446
+ }
447
+
448
+ export function updateConfig<K extends string, T>(
449
+ section: SectionHandle<K, T>,
450
+ patch: DeepPartial<T> | ((current: Readonly<T>) => T),
451
+ options?: UpdateOptions,
452
+ ): Promise<ConfigChange | undefined> {
453
+ return pixRuntime().update(section, patch, options);
454
+ }
455
+
456
+ export function onConfigChange(listener: ConfigListener, options?: SubscribeOptions): () => void {
457
+ return pixRuntime().subscribe(listener, options);
458
+ }
459
+
460
+ export function reloadConfig(options?: ReloadOptions): Promise<ConfigChange | undefined> {
461
+ return pixRuntime().reload(options);
462
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * schema.ts — shared types, validation helpers, and immutability utilities for
3
+ * the pix-runtime config layer.
4
+ *
5
+ * The runtime models config as typed *sections*. A section owns its defaults,
6
+ * a pure `parse(raw)`, and an optional `serialize(value, defaults)`. Parsing
7
+ * never throws: invalid fields fall back individually and push a diagnostic.
8
+ */
9
+
10
+ export const CONFIG_FORMAT_VERSION = 1 as const;
11
+
12
+ // ── Raw document ─────────────────────────────────────────────────────────────
13
+
14
+ /** The on-disk JSON shape: sparse, `$version` plus known/unknown sections. */
15
+ export type RawDocument = Record<string, unknown>;
16
+
17
+ // ── Diagnostics ──────────────────────────────────────────────────────────────
18
+
19
+ export type ConfigDiagnosticCode =
20
+ | "PARSE_ERROR"
21
+ | "INVALID_VALUE"
22
+ | "READ_FAILED"
23
+ | "WRITE_FAILED"
24
+ | "MIGRATION_FAILED"
25
+ | "UNSUPPORTED_CONFIG_VERSION"
26
+ | "LISTENER_FAILED";
27
+
28
+ export interface ConfigDiagnostic {
29
+ code: ConfigDiagnosticCode;
30
+ severity: "warning" | "error";
31
+ path?: string;
32
+ message: string;
33
+ cause?: unknown;
34
+ at: number;
35
+ }
36
+
37
+ /** Collects diagnostics while parsing/migrating without throwing. */
38
+ export interface ParseContext {
39
+ diagnostic(d: Omit<ConfigDiagnostic, "at">): void;
40
+ }
41
+
42
+ // ── Section definitions ──────────────────────────────────────────────────────
43
+
44
+ export interface ConfigSection<T> {
45
+ key: string;
46
+ defaults: Readonly<T>;
47
+ parse(raw: unknown, ctx: ParseContext): T;
48
+ /**
49
+ * Serialize a resolved value into a sparse raw object relative to defaults.
50
+ * When omitted, the runtime deep-strips values equal to defaults.
51
+ */
52
+ serialize?(value: T, defaults: T): unknown;
53
+ }
54
+
55
+ /** Opaque, typed handle to a registered section. */
56
+ export interface SectionHandle<K extends string, T> {
57
+ readonly key: K;
58
+ readonly defaults: Readonly<T>;
59
+ /** Internal reference to the full definition. */
60
+ readonly __section: ConfigSection<T>;
61
+ }
62
+
63
+ export function defineSection<const K extends string, T>(
64
+ definition: ConfigSection<T> & { key: K },
65
+ ): SectionHandle<K, T> {
66
+ return {
67
+ key: definition.key,
68
+ defaults: definition.defaults,
69
+ __section: definition,
70
+ };
71
+ }
72
+
73
+ // ── Value coercion helpers (pure, never throw) ───────────────────────────────
74
+
75
+ export function isObj(v: unknown): v is Record<string, unknown> {
76
+ return v !== null && typeof v === "object" && !Array.isArray(v);
77
+ }
78
+
79
+ export function boolOr(v: unknown, fallback: boolean): boolean {
80
+ return typeof v === "boolean" ? v : fallback;
81
+ }
82
+
83
+ export function posNumOr(v: unknown, fallback: number): number {
84
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : fallback;
85
+ }
86
+
87
+ export function enumOr<T extends string>(v: unknown, allowed: readonly T[], fallback: T): T {
88
+ return typeof v === "string" && (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
89
+ }
90
+
91
+ export function strArr(v: unknown): string[] {
92
+ if (!Array.isArray(v)) return [];
93
+ return v.filter((x): x is string => typeof x === "string");
94
+ }
95
+
96
+ // ── Sparse serialization ─────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Recursively remove entries equal to their default. Returns the number of
100
+ * remaining keys so callers can drop empty sections. Mutates `section`.
101
+ */
102
+ export function stripDefaults(
103
+ section: Record<string, unknown>,
104
+ defaults: Record<string, unknown>,
105
+ ): number {
106
+ for (const [key, defaultValue] of Object.entries(defaults)) {
107
+ const value = section[key];
108
+ if (isObj(value) && isObj(defaultValue)) {
109
+ const nested = { ...value };
110
+ if (stripDefaults(nested, defaultValue) > 0) section[key] = nested;
111
+ else delete section[key];
112
+ } else if (JSON.stringify(value) === JSON.stringify(defaultValue)) {
113
+ delete section[key];
114
+ }
115
+ }
116
+ return Object.keys(section).length;
117
+ }
118
+
119
+ // ── Immutability ─────────────────────────────────────────────────────────────
120
+
121
+ /** Deep-freeze a value in place and return it typed as readonly. */
122
+ export function deepFreeze<T>(value: T): T {
123
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
124
+ Object.freeze(value);
125
+ for (const key of Object.keys(value as Record<string, unknown>)) {
126
+ deepFreeze((value as Record<string, unknown>)[key]);
127
+ }
128
+ }
129
+ return value;
130
+ }
131
+
132
+ /** Deep clone a JSON-compatible value via the native structured clone. */
133
+ export function clone<T>(value: T): T {
134
+ return structuredClone(value);
135
+ }
136
+
137
+ // ── Deep merge (objects merge, arrays/primitives replace) ────────────────────
138
+
139
+ export type DeepPartial<T> = T extends (infer U)[]
140
+ ? U[]
141
+ : T extends object
142
+ ? { [K in keyof T]?: DeepPartial<T[K]> }
143
+ : T;
144
+
145
+ /**
146
+ * Merge `patch` onto `base`. Objects merge recursively; arrays replace;
147
+ * `undefined` values are rejected (skipped). Returns a new object.
148
+ */
149
+ export function deepMerge<T>(base: T, patch: DeepPartial<T>): T {
150
+ if (!isObj(base) || !isObj(patch)) return (patch as unknown as T) ?? base;
151
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
152
+ for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
153
+ if (value === undefined) continue;
154
+ const prev = out[key];
155
+ if (isObj(prev) && isObj(value)) {
156
+ out[key] = deepMerge<Record<string, unknown>>(
157
+ prev,
158
+ value as DeepPartial<Record<string, unknown>>,
159
+ );
160
+ } else {
161
+ out[key] = value;
162
+ }
163
+ }
164
+ return out as T;
165
+ }
166
+
167
+ // ── Changed-path detection ───────────────────────────────────────────────────
168
+
169
+ /** Return dotted JSON paths that differ between two resolved section values. */
170
+ export function diffPaths(prefix: string, a: unknown, b: unknown): string[] {
171
+ if (JSON.stringify(a) === JSON.stringify(b)) return [];
172
+ if (!isObj(a) || !isObj(b)) return [prefix];
173
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
174
+ const out: string[] = [];
175
+ for (const key of keys) {
176
+ out.push(...diffPaths(prefix ? `${prefix}.${key}` : key, a[key], b[key]));
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** Match a changed path against subscription filters (supports `section.*`). */
182
+ export function pathMatches(changed: string, filters: readonly string[]): boolean {
183
+ return filters.some((f) => {
184
+ if (f === changed) return true;
185
+ if (f.endsWith(".*")) {
186
+ const base = f.slice(0, -2);
187
+ return changed === base || changed.startsWith(`${base}.`);
188
+ }
189
+ return false;
190
+ });
191
+ }
@@ -0,0 +1,35 @@
1
+ import { boolOr, defineSection, isObj, posNumOr } from "../schema.ts";
2
+
3
+ export interface CollapseConfig {
4
+ /** Master toggle. `false` = never collapse any tool. Default `true`. */
5
+ enabled: boolean;
6
+ /** Seconds before a tool card collapses. Default `10`. */
7
+ delaySec: number;
8
+ /** Per-tool overrides. Missing key follows `enabled`. */
9
+ tools: Partial<Record<string, boolean>>;
10
+ }
11
+
12
+ const DEFAULTS: Readonly<CollapseConfig> = {
13
+ enabled: true,
14
+ delaySec: 10,
15
+ tools: {},
16
+ };
17
+
18
+ export const collapseSection = defineSection<"collapse", CollapseConfig>({
19
+ key: "collapse",
20
+ defaults: DEFAULTS,
21
+ parse(raw) {
22
+ if (!isObj(raw)) return { ...DEFAULTS, tools: {} };
23
+ const tools: Partial<Record<string, boolean>> = {};
24
+ if (isObj(raw.tools)) {
25
+ for (const [k, v] of Object.entries(raw.tools)) {
26
+ if (typeof v === "boolean") tools[k] = v;
27
+ }
28
+ }
29
+ return {
30
+ enabled: boolOr(raw.enabled, DEFAULTS.enabled),
31
+ delaySec: posNumOr(raw.delaySec, DEFAULTS.delaySec),
32
+ tools,
33
+ };
34
+ },
35
+ });