pi-lean-dimension 0.3.2 → 0.4.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.
Files changed (31) hide show
  1. package/README.md +18 -3
  2. package/node_modules/pi-lean-portal/README.md +21 -6
  3. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +24 -0
  4. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  5. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  6. package/node_modules/pi-lean-portal/browser-toggle.ts +23 -26
  7. package/node_modules/pi-lean-portal/package.json +2 -2
  8. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +22 -3
  9. package/node_modules/pi-lean-search/index.ts +11 -1
  10. package/node_modules/pi-lean-search/package.json +2 -2
  11. package/node_modules/pi-tool-masking/LICENSE +21 -661
  12. package/node_modules/pi-tool-masking/README.md +104 -14
  13. package/node_modules/pi-tool-masking/index.ts +767 -43
  14. package/node_modules/pi-tool-masking/package.json +6 -4
  15. package/node_modules/playwright/lib/common/index.js +3 -6
  16. package/node_modules/playwright/lib/common/index.js.txt +2 -2
  17. package/node_modules/playwright/lib/transform/esmLoader.js +3 -6
  18. package/node_modules/playwright/lib/transform/esmLoader.js.txt +2 -2
  19. package/node_modules/playwright/package.json +2 -2
  20. package/node_modules/playwright-core/lib/coreBundle.js +1 -1
  21. package/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-By56iMx7.js → codeMirrorModule-rXmQmLUY.js} +1 -1
  22. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-B-dXF5JN.js +181 -0
  23. package/node_modules/playwright-core/lib/vite/traceViewer/assets/{xtermModule-COQkjINf.js → xtermModule-BuZfJS5v.js} +1 -1
  24. package/node_modules/playwright-core/lib/vite/traceViewer/{index.Dl36UVQT.js → index.KZ4wOW1K.js} +1 -1
  25. package/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  26. package/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.D962mr9b.js → uiMode.Dzuouizj.js} +2 -2
  27. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  28. package/node_modules/playwright-core/package.json +1 -1
  29. package/node_modules/playwright-core/types/structs.d.ts +8 -3
  30. package/package.json +3 -3
  31. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-B34OrIms.js +0 -181
@@ -1,14 +1,18 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
1
4
  import type {
2
5
  ExtensionAPI,
3
6
  ExtensionContext,
7
+ SessionEntry,
4
8
  } from "@earendil-works/pi-coding-agent";
5
9
 
6
10
  // ---------------------------------------------------------------------------
7
- // §5 Public API — types
11
+ // Public API — types
8
12
  // ---------------------------------------------------------------------------
9
13
 
10
14
  export interface ToolsetSpec {
11
- /** Stable id, e.g. "portal.web". Used in persist keys and event payloads. */
15
+ /** Stable id, e.g. "my-plugin.web". Used in persist keys and event payloads. */
12
16
  id: string;
13
17
  /** Human-readable name for the group. Optional — presenters fall back to id. */
14
18
  label?: string;
@@ -16,7 +20,7 @@ export interface ToolsetSpec {
16
20
  description?: string;
17
21
  /** Tool names this toolset governs. */
18
22
  names: Set<string>;
19
- /** Primary persistence key the toolset writes, e.g. "toolset-state:portal.web". */
23
+ /** Primary persistence key the toolset writes, e.g. "toolset-state:my-plugin.web". */
20
24
  persistKey: string;
21
25
  /** Fresh-session fallback when no branch entry exists. */
22
26
  defaultEnabled?: boolean;
@@ -33,17 +37,27 @@ export interface Toolset {
33
37
  }
34
38
 
35
39
  export interface ToolsetChangedEvent {
36
- /** Toolset id (e.g. "portal.web"). Always set. */
40
+ /** Toolset id (e.g. "my-plugin.web"). Always set. */
37
41
  id: string;
38
42
  enabled: boolean;
39
43
  /** Present only when emitMemberEvents is on and this is a per-member fanout event. */
40
44
  member?: string;
41
45
  }
42
46
 
43
- export type DefaultResolutionMode = "exclusion" | "inclusion";
47
+ /**
48
+ * How toolsets with no persisted branch entry resolve on restore.
49
+ *
50
+ * @deprecated The `"inclusion"` member is deprecated since 1.2.0 — use
51
+ * `"allowlist"` for focus-style suppression (a finite, branch-persisted set
52
+ * whose complement is computed at restore), or `"exclusion"` for the
53
+ * default-on floor. `"inclusion"` still works (with a one-time runtime
54
+ * warning) through the deprecation window; it is scheduled for removal in a
55
+ * near-term 1.x minor.
56
+ */
57
+ export type DefaultResolutionMode = "exclusion" | "inclusion" | "allowlist";
44
58
 
45
59
  // ---------------------------------------------------------------------------
46
- // §6 Change notification — event names
60
+ // Change notification — event names
47
61
  // ---------------------------------------------------------------------------
48
62
 
49
63
  export const TOOLSET_EVENTS = {
@@ -52,7 +66,7 @@ export const TOOLSET_EVENTS = {
52
66
  } as const;
53
67
 
54
68
  // ---------------------------------------------------------------------------
55
- // Registry on globalThis (§6.1)
69
+ // Registry on globalThis
56
70
  // ---------------------------------------------------------------------------
57
71
 
58
72
  const REGISTRY_KEY = "__piToolMaskingRegistry";
@@ -81,9 +95,35 @@ function getRegistry(): Registry {
81
95
 
82
96
  const MODULE_KEY = "__piToolMaskingModuleState";
83
97
  const MODE_PERSIST_KEY = "toolset-resolution-mode";
98
+ const DEPRECATION_WARNED_KEY = "__piToolMaskingDeprecationWarned";
99
+
100
+ const INCLUSION_DEPRECATED_MESSAGE =
101
+ '[pi-tool-masking] "inclusion" resolution mode is deprecated since 1.2.0 ' +
102
+ 'and will be removed in a coming 1.x minor; use "allowlist" for focus suppression.';
103
+
104
+ // Once-per-process-per-site dedup for the inclusion deprecation warning.
105
+ // Keyed by trigger site (`"setDefaultResolutionMode"` / `"doRestore"`), not
106
+ // per call — "you're on the deprecated path" needs saying once per entry
107
+ // point. Lives on globalThis (like the registry and module state) so a
108
+ // /reload, which re-evals modules in the same process, does not re-warn.
109
+ function warnInclusionDeprecation(
110
+ site: "setDefaultResolutionMode" | "doRestore",
111
+ ): void {
112
+ if (!(DEPRECATION_WARNED_KEY in globalThis)) {
113
+ (globalThis as any)[DEPRECATION_WARNED_KEY] = new Set<string>();
114
+ }
115
+ const warned = (globalThis as any)[DEPRECATION_WARNED_KEY] as Set<string>;
116
+ if (warned.has(site)) return;
117
+ warned.add(site);
118
+ console.warn(INCLUSION_DEPRECATED_MESSAGE);
119
+ }
84
120
 
85
121
  interface ModuleState {
86
122
  defaultResolutionMode: DefaultResolutionMode;
123
+ // explicit `| undefined`: exactOptionalPropertyTypes forbids assigning
124
+ // `undefined` to a bare optional property (TS2412), and both doRestore and
125
+ // setDefaultResolutionMode assign undefined for non-allowlist modes.
126
+ activeAllowlist?: string[] | undefined;
87
127
  }
88
128
 
89
129
  function getModuleState(): ModuleState {
@@ -96,7 +136,7 @@ function getModuleState(): ModuleState {
96
136
  }
97
137
 
98
138
  // ---------------------------------------------------------------------------
99
- // deepEqual for spec comparison (§6.1 idempotent re-registration)
139
+ // deepEqual for spec comparison (idempotent re-registration)
100
140
  // ---------------------------------------------------------------------------
101
141
 
102
142
  function deepEqual(a: unknown, b: unknown): boolean {
@@ -132,7 +172,7 @@ function deepEqual(a: unknown, b: unknown): boolean {
132
172
  // ---------------------------------------------------------------------------
133
173
 
134
174
  function ensureRestoreHandler(pi: ExtensionAPI): void {
135
- // Dedup by event-object identity (§6). The runner passes the same event
175
+ // Dedup by event-object identity. The runner passes the same event
136
176
  // reference to every extension's handler in one emit() call, so the first
137
177
  // handler wins and the rest skip. Each /reload constructs a fresh event
138
178
  // object, so restore re-runs with the fresh pi.
@@ -142,57 +182,179 @@ function ensureRestoreHandler(pi: ExtensionAPI): void {
142
182
 
143
183
  const registry = getRegistry();
144
184
 
145
- // Re-read durable resolution mode before per-toolset fallback (§4.5).
185
+ // Re-read durable resolution mode before per-toolset fallback.
146
186
  // setDefaultResolutionMode persists this bit; a fresh process defaults
147
187
  // to "exclusion" until the persisted entry is replayed here. Mode
148
188
  // entries are from a prior session (not written during this restore), so
149
- // a single read here is sufficient.
189
+ // a single read here is sufficient. Null-tombstone-aware: read the LAST
190
+ // mode entry regardless of `data` (a `null` tombstone is the most recent
191
+ // mode fact and must beat a stale prior entry, falling through to
192
+ // "exclusion"); there is no settings fallback for mode — no mode
193
+ // settings tier exists, so mode resolution is `branchMode ?? "exclusion"`.
150
194
  const modeEntries = ctx.sessionManager
151
195
  .getBranch()
152
- .filter((b: any) => b.customType === MODE_PERSIST_KEY && b.data?.mode);
153
- if (modeEntries.length > 0) {
154
- getModuleState().defaultResolutionMode = (
155
- modeEntries[modeEntries.length - 1] as any
156
- ).data.mode;
196
+ .filter((b: any) => b.customType === MODE_PERSIST_KEY);
197
+ const lastModeEntry = modeEntries[modeEntries.length - 1] as any;
198
+ const branchMode = lastModeEntry?.data?.mode;
199
+ const branchAllowlist = lastModeEntry?.data?.allowlist;
200
+ // Fail closed: a branch entry claiming "allowlist" with no usable array
201
+ // is corruption (write-time validation prevents it, but branch files are
202
+ // hand-editable). Recover to an EMPTY allowlist, not to "exclusion":
203
+ // (1) Respect the branch's mode claim. mode="allowlist" is a state
204
+ // someone chose; silently rewriting it to "exclusion" (which
205
+ // means "everything on" under default fallback) is a mode change
206
+ // nobody made and fails OPEN — the wrong default for a masking
207
+ // library. Empty allowlist = "nothing is on", the safe recovery.
208
+ // (2) Keep mode + array consistent. mode=allowlist with
209
+ // activeAllowlist=undefined is contradictory
210
+ // (getDefaultResolutionMode() === "allowlist" while
211
+ // getActiveAllowlist() === undefined). mode=allowlist with
212
+ // activeAllowlist=[] is consistent and means "no member is allowed
213
+ // on" — the same semantic as a populated allowlist whose members
214
+ // are all unregistered.
215
+ // Asymmetric with `setDefaultResolutionMode`: write-time rejects an
216
+ // empty array as a likely mistake (e.g. deleting the last member and
217
+ // forgetting to switch modes); restore-time recovers a missing/non-array
218
+ // to [] as the safe recovery. Write-time validates intent; restore-time
219
+ // picks the safe recovery.
220
+ const allowArr = Array.isArray(branchAllowlist) ? branchAllowlist : [];
221
+ const mode: DefaultResolutionMode =
222
+ branchMode === "inclusion" || branchMode === "exclusion"
223
+ ? branchMode
224
+ : branchMode === "allowlist"
225
+ ? "allowlist"
226
+ : "exclusion";
227
+ const ms = getModuleState();
228
+ ms.defaultResolutionMode = mode;
229
+ // Deprecation: resolving a branch mode entry to "inclusion" is the
230
+ // deprecated path (fires on /reload of a session that last set
231
+ // inclusion). warnInclusionDeprecation dedups once per process per
232
+ // trigger site.
233
+ if (mode === "inclusion") warnInclusionDeprecation("doRestore");
234
+ // Mirror the allowlist into module state so the parameterless
235
+ // `getActiveAllowlist()` can read it — branch is the source of truth,
236
+ // module state is the live mirror (same pattern as
237
+ // `defaultResolutionMode`). `setDefaultResolutionMode` writes this same
238
+ // field when it appends the mode entry. Non-allowlist modes → undefined.
239
+ ms.activeAllowlist = mode === "allowlist" ? allowArr : undefined;
240
+
241
+ // Allowlist short-circuit: the allowlist is a finite array of
242
+ // toolset ids stored in the branch mode entry; the suppression (the
243
+ // complement) is COMPUTED here over all registered toolsets, not stored.
244
+ // While the last mode entry is "allowlist", this set-level override is
245
+ // authoritative: per-toolset branch entries and settings pins are
246
+ // bypassed, and toolsets registered after the mode entry are off.
247
+ // Atomic two-phase restore: phase 1 computes the desired active-tools
248
+ // set and applies it in ONE `setActiveTools` call (no per-toolset emit
249
+ // during the loop — a companion mirror on `changed` cannot fire
250
+ // mid-restore and `appendEntry` against an in-progress state); phase 2
251
+ // emits `restored` for every registered toolset AFTER state is final.
252
+ if (mode === "allowlist") {
253
+ // `allowArr` computed above — fail-closed `[]` recovery already applied.
254
+ const allow = new Set<string>(allowArr);
255
+ const registered = new Set(pi.getAllTools().map((t) => t.name));
256
+
257
+ // Phase 1: desired set = current − (suppressed toolset members) +
258
+ // (allowlist members). The suppress set is the complement of the
259
+ // allowlist among registered toolset tools only; everything else in
260
+ // the current set (tools not owned by any registered toolset) is kept
261
+ // as-is — `setActiveTools` is a full replacement, so we must not
262
+ // rebuild the set from only allowlist members. This mirrors the
263
+ // per-toolset restore (`_applyRestoreToolset` only adds/removes
264
+ // `spec.names`), applied set-wide.
265
+ const current = new Set(pi.getActiveTools());
266
+ const suppress = new Set<string>();
267
+ for (const [, entry] of registry) {
268
+ if (!allow.has(entry.spec.id)) {
269
+ for (const n of entry.spec.names) suppress.add(n);
270
+ }
271
+ }
272
+ const desired = new Set<string>();
273
+ for (const n of current) {
274
+ if (!suppress.has(n)) desired.add(n);
275
+ }
276
+ for (const [, entry] of registry) {
277
+ if (allow.has(entry.spec.id)) {
278
+ for (const n of entry.spec.names) desired.add(n);
279
+ }
280
+ }
281
+ // The `registered` filter is partially redundant: names carried over
282
+ // from `current` are already active (hence registered); it only
283
+ // matters for spec names that aren't registered tools. Kept for
284
+ // parity with `_applyRestoreToolset`'s per-name filtering.
285
+ pi.setActiveTools([...desired].filter((n) => registered.has(n)));
286
+
287
+ // Phase 2: notify AFTER state is final. `restored` for every
288
+ // registered toolset — the whole pass is a branch replay of the
289
+ // authoritative allowlist entry, not a live toggle (see the JSDoc on
290
+ // `getActiveAllowlist` for the event-type divergence by mode).
291
+ for (const [, entry] of registry) {
292
+ _emitToolsetEvents(
293
+ entry.spec,
294
+ pi,
295
+ TOOLSET_EVENTS.restored,
296
+ allow.has(entry.spec.id),
297
+ );
298
+ }
299
+ return;
157
300
  }
158
- const mode = getModuleState().defaultResolutionMode;
301
+
302
+ // Read settings.json toolset defaults once per restore pass.
303
+ // settings.json is stable mid-restore (unlike the branch, which
304
+ // companion mirroring can mutate), so a single read suffices.
305
+ const settingsDefaults = readMergedToolsetDefaults();
159
306
 
160
307
  // ponytail: restore applies each toolset's entry independently and does
161
- // NOT re-run the requires cascade. Safe because §7.1 guarantees persisted
162
- // state is always consistent — the live-toggling cascade (§4.4) makes an
308
+ // NOT re-run the requires cascade. Safe because persisted state is always
309
+ // consistent — the live-toggling cascade makes an
163
310
  // incoherent persisted combo unreachable. Re-adding cascade here would
164
311
  // double-toggle and break restore independence.
165
312
  //
166
313
  // Re-read the branch per toolset (not once before the loop): a companion
167
- // mirror (§10.1) fires synchronously inside `_applyRestoreToolset` and may
168
- // `appendEntry` for a toolset later in iteration order (e.g. portal.web's
314
+ // mirror fires synchronously inside `_applyRestoreToolset` and may
315
+ // `appendEntry` for a toolset later in iteration order (e.g. my-plugin.web's
169
316
  // default-false restore makes search.web disable itself). Snapshotting the
170
317
  // branch once would hide that write from the later toolset, so it would
171
318
  // fall back to its packaged default and desync from the companion — the
172
- // §6 "search's own restore reads the branch and finds the entry the mirror
319
+ // "search's own restore reads the branch and finds the entry the mirror
173
320
  // just wrote" guarantee.
174
321
  for (const [, entry] of registry) {
175
322
  const { spec } = entry;
176
323
 
177
324
  // Find persisted entry for this toolset (last-writer-wins).
178
325
  // Fresh read per toolset so companion-mirror writes during this
179
- // pass are visible to later toolsets.
326
+ // pass are visible to later toolsets. The `b.data != null` filter
327
+ // is dropped: a null (tombstoned) last entry means "cleared →
328
+ // fall through to settings → mode floor → packaged" and must beat
329
+ // a stale prior entry instead of being invisible.
180
330
  const branchNow = ctx.sessionManager.getBranch();
181
331
  const persistEntries = branchNow.filter(
182
- (b: any) => b.customType === spec.persistKey && b.data != null,
332
+ (b: any) => b.customType === spec.persistKey,
183
333
  );
184
-
185
- if (persistEntries.length > 0) {
186
- const lastEntry = persistEntries[persistEntries.length - 1];
187
- const enabled = (lastEntry as any).data?.enabled;
188
- if (typeof enabled === "boolean") {
189
- _applyRestoreToolset(spec, pi, enabled, true);
190
- }
334
+ const lastEntry = persistEntries[persistEntries.length - 1];
335
+ const enabled = (lastEntry as any)?.data?.enabled;
336
+ if (typeof enabled === "boolean") {
337
+ _applyRestoreToolset(spec, pi, enabled, true);
191
338
  } else {
192
- // No entry — resolve default based on mode (§4.5)
339
+ // No usable branch entry — fall through to settings tier (2),
340
+ // then mode floor, then packaged `defaultEnabled` (3). A pinned
341
+ // settings entry is explicit user intent and participates in
342
+ // BOTH modes — mirroring how the chat-branch tier (also user
343
+ // intent) is honored in inclusion via the if-branch. Only
344
+ // unpinned toolsets consult mode for the floor (exclusion →
345
+ // `defaultEnabled ?? true`, inclusion → false).
346
+ // `readMergedToolsetDefaults()` returns the on-disk shape
347
+ // Record<persistKey, { enabled: boolean }>
348
+ // so the pin is the wrapped object; unwrap with `?.enabled`.
349
+ const settingsEnabled = settingsDefaults[spec.persistKey]?.enabled;
193
350
  const fallback = spec.defaultEnabled ?? true;
194
- const enabled = mode === "inclusion" ? false : fallback;
195
- _applyRestoreToolset(spec, pi, enabled, false);
351
+ const resolved =
352
+ typeof settingsEnabled === "boolean"
353
+ ? settingsEnabled
354
+ : mode === "inclusion"
355
+ ? false
356
+ : fallback;
357
+ _applyRestoreToolset(spec, pi, resolved, false);
196
358
  }
197
359
  }
198
360
  };
@@ -215,7 +377,7 @@ function _emitToolsetEvents(
215
377
 
216
378
  if (spec.emitMemberEvents) {
217
379
  for (const name of spec.names) {
218
- // Only emit for names that are actually registered tools (§6)
380
+ // Only emit for names that are actually registered tools
219
381
  if (!pi.getAllTools().some((t) => t.name === name)) continue;
220
382
  pi.events.emit(eventType, {
221
383
  id: spec.id,
@@ -287,7 +449,7 @@ function _applyRestoreToolset(
287
449
  pi.setActiveTools(filtered);
288
450
  }
289
451
 
290
- // Always emit regardless of state (always-emit invariant, §6)
452
+ // Always emit regardless of state (always-emit invariant)
291
453
  const eventType = isPersistedEntry
292
454
  ? TOOLSET_EVENTS.restored
293
455
  : TOOLSET_EVENTS.changed;
@@ -295,7 +457,7 @@ function _applyRestoreToolset(
295
457
  }
296
458
 
297
459
  // ---------------------------------------------------------------------------
298
- // Enable cascade + cycle detection (§4.4, §9)
460
+ // Enable cascade + cycle detection
299
461
  // ---------------------------------------------------------------------------
300
462
 
301
463
  function _enableToolset(
@@ -328,7 +490,7 @@ function _enableToolset(
328
490
  }
329
491
 
330
492
  // ---------------------------------------------------------------------------
331
- // Disable reverse-cascade (§4.4, §9)
493
+ // Disable reverse-cascade
332
494
  // ---------------------------------------------------------------------------
333
495
 
334
496
  function _disableDependents(
@@ -387,7 +549,7 @@ class ToolsetImpl implements Toolset {
387
549
  }
388
550
 
389
551
  // ---------------------------------------------------------------------------
390
- // §5 Public API — functions
552
+ // Public API — functions
391
553
  // ---------------------------------------------------------------------------
392
554
 
393
555
  export function defineToolset(pi: ExtensionAPI, spec: ToolsetSpec): Toolset {
@@ -426,6 +588,39 @@ export function defineToolset(pi: ExtensionAPI, spec: ToolsetSpec): Toolset {
426
588
  }
427
589
  }
428
590
 
591
+ // Name-overlap guard: no two toolsets may claim the same tool name. Every
592
+ // downstream failure mode (isEnabled lying, restore order-dependence, enable
593
+ // no-op, skipped dependents, focus leaks, mis-attribution, double-counts)
594
+ // requires two toolsets claiming one name; with that unreachable, toolsets
595
+ // own disjoint name sets. Gather every collision in this registration into
596
+ // one error so the author sees the full scope in one pass. `getAllTools()` is
597
+ // deferred to the throw branch so a clean registration never pays for it.
598
+ const collisions: { name: string; owner: string }[] = [];
599
+ for (const [id, entry] of registry) {
600
+ if (id === spec.id) continue;
601
+ for (const name of spec.names) {
602
+ if (entry.spec.names.has(name)) collisions.push({ name, owner: id });
603
+ }
604
+ }
605
+ if (collisions.length > 0) {
606
+ const allTools = pi.getAllTools();
607
+ const lines = collisions.map(({ name, owner }) => {
608
+ const tool = allTools.find((t) => t.name === name);
609
+ const where = tool
610
+ ? ` (registered from ${tool.sourceInfo.path}, source: ${tool.sourceInfo.source})`
611
+ : "";
612
+ return ` - tool "${name}" already claimed by toolset "${owner}"${where}`;
613
+ });
614
+ throw new Error(
615
+ `[pi-tool-masking] name overlap: toolset "${spec.id}" claims tools ` +
616
+ `already owned by another toolset:\n` +
617
+ lines.join("\n") +
618
+ "\n" +
619
+ `Each tool may belong to only one toolset. Naming convention: prefix ` +
620
+ `toolset ids with a stable namespace (<product-family>.<subset>, e.g. "my-plugin.web").`,
621
+ );
622
+ }
623
+
429
624
  const toolset = new ToolsetImpl(spec);
430
625
  registry.set(spec.id, { spec, toolset });
431
626
 
@@ -434,23 +629,100 @@ export function defineToolset(pi: ExtensionAPI, spec: ToolsetSpec): Toolset {
434
629
  return toolset;
435
630
  }
436
631
 
632
+ /**
633
+ * Set how toolsets with no persisted entry resolve on restore.
634
+ *
635
+ * - `"exclusion"` (default): toolsets fall back to `defaultEnabled ?? true`.
636
+ * - `"allowlist"` (ids): only the listed toolset ids are on, everything else
637
+ * off — the finite, branch-persisted focus constraint, resilient to
638
+ * toolsets installed after the mode was set. Requires a non-empty array.
639
+ * - `"inclusion"`: all unknown toolsets default off.
640
+ *
641
+ * @deprecated The `"inclusion"` mode is deprecated since 1.2.0 — use
642
+ * `"allowlist"` for focus-style suppression (or `"exclusion"` for the
643
+ * default-on floor). Setting `"inclusion"` emits a one-time runtime warning;
644
+ * it is scheduled for removal in a near-term 1.x minor.
645
+ */
437
646
  export function setDefaultResolutionMode(
438
647
  pi: ExtensionAPI,
439
648
  mode: DefaultResolutionMode,
649
+ allowlist?: string[],
440
650
  ): void {
441
- if (mode !== "exclusion" && mode !== "inclusion") {
651
+ if (mode === "inclusion")
652
+ warnInclusionDeprecation("setDefaultResolutionMode");
653
+ if (mode !== "exclusion" && mode !== "inclusion" && mode !== "allowlist") {
654
+ throw new Error(
655
+ `[pi-tool-masking] Invalid defaultResolutionMode: "${mode}". Must be "exclusion", "inclusion", or "allowlist".`,
656
+ );
657
+ }
658
+ // Write-time validation (asymmetric with restore): an allowlist mode with
659
+ // no/empty array is a likely mistake (deleting the last member and
660
+ // forgetting to switch modes); restore instead recovers a corrupt
661
+ // missing/non-array allowlist to `[]` (fail closed). Write-time validates
662
+ // intent; restore-time picks the safe recovery. Forward references are
663
+ // legal — ids need not be registered yet.
664
+ if (
665
+ mode === "allowlist" &&
666
+ (!allowlist || !Array.isArray(allowlist) || allowlist.length === 0)
667
+ ) {
442
668
  throw new Error(
443
- `[pi-tool-masking] Invalid defaultResolutionMode: "${mode}". Must be "exclusion" or "inclusion".`,
669
+ `[pi-tool-masking] defaultResolutionMode "allowlist" requires a non-empty allowlist array of toolset ids.`,
444
670
  );
445
671
  }
446
- getModuleState().defaultResolutionMode = mode;
447
- pi.appendEntry(MODE_PERSIST_KEY, { mode });
672
+ const ms = getModuleState();
673
+ ms.defaultResolutionMode = mode;
674
+ // Mirror into module state so `getActiveAllowlist()` stays consistent with
675
+ // the branch without a `sessionManager` dependency. Existing exclusion /
676
+ // inclusion entries persist `{ mode }` only (unchanged shape);
677
+ // `.activeAllowlist` is undefined for non-allowlist modes. Copy the
678
+ // caller's array — the branch snapshots on append, but module state holds
679
+ // the live reference; a caller mutating the array post-call would
680
+ // otherwise drift the mirror from the branch.
681
+ ms.activeAllowlist =
682
+ mode === "allowlist" && allowlist ? [...allowlist] : undefined;
683
+ pi.appendEntry(
684
+ MODE_PERSIST_KEY,
685
+ mode === "allowlist" ? { mode, allowlist } : { mode },
686
+ );
448
687
  }
449
688
 
450
689
  export function getDefaultResolutionMode(): DefaultResolutionMode {
451
690
  return getModuleState().defaultResolutionMode;
452
691
  }
453
692
 
693
+ /**
694
+ * Read the live allowlist array from module state (mirrored from the last
695
+ * mode branch entry by `doRestore`'s mode-resolution block and by
696
+ * `setDefaultResolutionMode` when it appends the entry). Returns the
697
+ * `allowlist` array when the active mode is `"allowlist"`, otherwise
698
+ * `undefined`.
699
+ *
700
+ * Parameterless (like `getDefaultResolutionMode`) — the consumer call site
701
+ * receives `pi: ExtensionAPI`, which does not expose `sessionManager`, so a
702
+ * `pi`-arg signature would not compile there. The branch remains the source
703
+ * of truth; module state is the live mirror.
704
+ *
705
+ * **Event-type divergence by mode (restore contract):** under
706
+ * exclusion/inclusion, the per-toolset restore loop emits `restored` for
707
+ * toolsets with a branch entry and `changed` for default-fallback toolsets
708
+ * (no branch entry). Under allowlist, restore emits `restored` for **every**
709
+ * registered toolset — the whole pass is a branch replay of the
710
+ * authoritative allowlist entry, not a live toggle. A consumer expecting
711
+ * `changed` for fallback toolsets during restore gets `restored` instead
712
+ * while allowlist is active.
713
+ *
714
+ * **`requires` cascade:** not re-run during allowlist restore (same
715
+ * independence invariant as per-toolset restore). A caller that passes an
716
+ * allowlist missing a dependency gets that dep off — pass the forward
717
+ * closure.
718
+ */
719
+ export function getActiveAllowlist(): string[] | undefined {
720
+ // Copy on read: module state is the internal mirror; handing out the live
721
+ // reference would let a consumer mutate it and corrupt the mirror.
722
+ const allowlist = getModuleState().activeAllowlist;
723
+ return allowlist ? [...allowlist] : undefined;
724
+ }
725
+
454
726
  /**
455
727
  * Enumerate every registered toolset in the global registry.
456
728
  * Returns a read-only snapshot — callers cannot mutate the live registry
@@ -462,3 +734,455 @@ export function getDefaultResolutionMode(): DefaultResolutionMode {
462
734
  export function getRegisteredToolsets(): readonly RegistryEntry[] {
463
735
  return [...getRegistry().values()];
464
736
  }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Tombstone helpers + apply-without-persist
740
+ // ---------------------------------------------------------------------------
741
+
742
+ /**
743
+ * Tombstone a toolset's chat-branch entry: append `null` for `persistKey`
744
+ * so `doRestore` falls through to the settings tier. Owns the
745
+ * tombstone-write convention.
746
+ *
747
+ * Dedup: appends `null` only if the key has a prior entry whose last entry
748
+ * is not already cleared. A toolset that was never toggled has no branch
749
+ * entry — no tombstone is written. Consecutive restores with no intervening
750
+ * toggle write zero tombstones.
751
+ *
752
+ * `branch` is the caller's branch snapshot
753
+ * (`ctx.sessionManager.getBranch()`): `ExtensionAPI` exposes `appendEntry`
754
+ * but not `sessionManager`, so the dedup read comes from the caller.
755
+ *
756
+ * ponytail: dedup caps growth at one tombstone per toggle/restore cycle,
757
+ * but many cycles in one session still stack entries. Upgrade path: a
758
+ * pi-core "compact toolset entries" op, out of scope.
759
+ */
760
+ export function clearToolsetEntry(
761
+ pi: ExtensionAPI,
762
+ persistKey: string,
763
+ branch: readonly SessionEntry[],
764
+ ): void {
765
+ let last: SessionEntry | undefined;
766
+ for (let i = branch.length - 1; i >= 0; i--) {
767
+ if ((branch[i] as any).customType === persistKey) {
768
+ last = branch[i];
769
+ break;
770
+ }
771
+ }
772
+ const data = (last as any)?.data;
773
+ // No prior entry, a tombstoned last entry (data null), or a last entry
774
+ // without an `enabled` field → already effectively cleared, skip.
775
+ if (data == null || data?.enabled == null) {
776
+ return;
777
+ }
778
+ pi.appendEntry(persistKey, null);
779
+ }
780
+
781
+ /**
782
+ * Tombstone every registered toolset's chat-branch entry (dedup'd per
783
+ * toolset — never-toggled toolsets get no tombstone). Covers exactly the
784
+ * toolsets in the global registry. `branch` is the caller's
785
+ * `ctx.sessionManager.getBranch()` snapshot (see `clearToolsetEntry`).
786
+ */
787
+ export function clearAllToolsetEntries(
788
+ pi: ExtensionAPI,
789
+ branch: readonly SessionEntry[],
790
+ ): void {
791
+ for (const [, entry] of getRegistry()) {
792
+ clearToolsetEntry(pi, entry.spec.persistKey, branch);
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Apply a toolset's enabled state via `setActiveTools` and emit
798
+ * `TOOLSET_EVENTS.changed` — **without** writing a branch entry. The
799
+ * live-apply half of a settings restore: pull the toolset to its
800
+ * settings/packaged default without persisting a chat-branch pin.
801
+ */
802
+ export function applyToolsetEnabled(
803
+ pi: ExtensionAPI,
804
+ spec: ToolsetSpec,
805
+ enabled: boolean,
806
+ ): void {
807
+ _applyRestoreToolset(spec, pi, enabled, false);
808
+ }
809
+
810
+ // ---------------------------------------------------------------------------
811
+ // Settings.json reader — toolsetDefaults tier
812
+ // ---------------------------------------------------------------------------
813
+
814
+ /** On-disk settings shape: `toolsetDefaults[persistKey] = { enabled }`. */
815
+ type ToolsetDefaultsMap = Record<string, { enabled: boolean }>;
816
+
817
+ function settingsPath(scope: "global" | "project"): string {
818
+ const agentDir =
819
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
820
+ return scope === "global"
821
+ ? join(agentDir, "settings.json")
822
+ : join(process.cwd(), ".pi", "settings.json");
823
+ }
824
+
825
+ /**
826
+ * Read one scope's settings.json as a parsed object, or `{}` on any
827
+ * read/parse failure (never-throw policy — a malformed file contributes
828
+ * `{}` to the merge; only mutators throw `MalformedSettingsError`).
829
+ */
830
+ function readSettingsJsonSafe(scope: "global" | "project"): unknown {
831
+ const path = settingsPath(scope);
832
+ try {
833
+ if (!existsSync(path)) return {};
834
+ return JSON.parse(readFileSync(path, "utf-8"));
835
+ } catch {
836
+ return {};
837
+ }
838
+ }
839
+
840
+ let _settingsOverride: ToolsetDefaultsMap | null = null;
841
+
842
+ /**
843
+ * Inject a snapshot of toolset defaults for tests. Pass `null` to restore
844
+ * the disk-read path. Production code never calls this.
845
+ *
846
+ * @internal
847
+ */
848
+ export function setSettingsOverrideForTests(
849
+ defaults: ToolsetDefaultsMap | null,
850
+ ): void {
851
+ _settingsOverride = defaults;
852
+ }
853
+
854
+ /**
855
+ * Extract toolset defaults from a settings.json object.
856
+ *
857
+ * Reads `json.toolsetDefaults` — a map of
858
+ * `{ [persistKey]: { enabled: boolean } }`. Entries whose value isn't a
859
+ * `{ enabled }` shape are dropped silently. Returns the on-disk shape
860
+ * verbatim (no flattening — call sites unwrap via `?.enabled`).
861
+ *
862
+ * @internal
863
+ */
864
+ export function parseToolsetDefaults(json: unknown): ToolsetDefaultsMap {
865
+ if (!json || typeof json !== "object" || Array.isArray(json)) return {};
866
+ const td = (json as Record<string, unknown>)["toolsetDefaults"];
867
+ if (!td || typeof td !== "object" || Array.isArray(td)) return {};
868
+ const result: ToolsetDefaultsMap = {};
869
+ for (const [key, val] of Object.entries(td as Record<string, unknown>)) {
870
+ // ponytail: only `enabled` is read; extra fields (`label`, etc.) ignored.
871
+ // Add a schema validator if downstreams depend on more fields.
872
+ const valObj = val as Record<string, unknown>;
873
+ if (valObj && typeof valObj["enabled"] === "boolean") {
874
+ result[key] = { enabled: valObj["enabled"] as boolean };
875
+ }
876
+ }
877
+ return result;
878
+ }
879
+
880
+ /**
881
+ * Shallow-merge global and project toolset defaults.
882
+ *
883
+ * Project wins on key collision: `{ ...global_, ...project }`. Per-entry
884
+ * only — no deep merge of the `{ enabled }` values.
885
+ *
886
+ * @internal
887
+ */
888
+ export function mergeToolsetDefaults(
889
+ global_: ToolsetDefaultsMap,
890
+ project: ToolsetDefaultsMap,
891
+ ): ToolsetDefaultsMap {
892
+ return { ...global_, ...project };
893
+ }
894
+
895
+ /**
896
+ * Read and merge toolset defaults from settings.json (global + project).
897
+ *
898
+ * Reads `json.toolsetDefaults` from:
899
+ * `<agentDir>/settings.json` (global)
900
+ * `<cwd>/.pi/settings.json` (project)
901
+ *
902
+ * Where `agentDir = PI_CODING_AGENT_DIR ?? ~/.pi/agent`.
903
+ * Missing/unreadable/malformed files contribute `{}`. Never throws.
904
+ *
905
+ * Returns the on-disk shape `Record<persistKey, { enabled: boolean }>`
906
+ * (not flattened) — call sites unwrap with `?.enabled`. Project overrides
907
+ * global per entry.
908
+ *
909
+ * When `setSettingsOverrideForTests` has set an override, returns that
910
+ * override verbatim instead of reading disk.
911
+ *
912
+ * @public — exported for snapshot-in usage (read once per loop
913
+ * and pass to `getEffectiveDefault`).
914
+ *
915
+ * ponytail: hardcodes the two pi-core settings paths (global
916
+ * `~/.pi/agent/settings.json`, project `<cwd>/.pi/settings.json`) with no
917
+ * configuration knob — if pi-core moves its settings paths or format this
918
+ * breaks. Upgrade path: a pi-core settings-path registry, if one ever appears.
919
+ */
920
+ export function readMergedToolsetDefaults(): ToolsetDefaultsMap {
921
+ if (_settingsOverride !== null) return { ..._settingsOverride };
922
+ return mergeToolsetDefaults(
923
+ parseToolsetDefaults(readSettingsJsonSafe("global")),
924
+ parseToolsetDefaults(readSettingsJsonSafe("project")),
925
+ );
926
+ }
927
+
928
+ /**
929
+ * Read toolset defaults from one settings.json scope (global or project).
930
+ *
931
+ * Returns the raw `toolsetDefaults` block parsed from that scope's file,
932
+ * without merging. Missing/unreadable/malformed files return `{}`.
933
+ *
934
+ * When `setSettingsOverrideForTests` has set an override, returns that
935
+ * override for both scopes (test mode approximation — attribution tests
936
+ * must use the writer seam + disk round-trip).
937
+ *
938
+ * @public — exported for a `defaults show`-style command that needs
939
+ * per-scope attribution.
940
+ */
941
+ export function readToolsetDefaults(
942
+ scope: "global" | "project",
943
+ ): ToolsetDefaultsMap {
944
+ if (_settingsOverride !== null) return { ..._settingsOverride };
945
+ return parseToolsetDefaults(readSettingsJsonSafe(scope));
946
+ }
947
+
948
+ /**
949
+ * Resolve a toolset's effective fresh-session default: settings tier (2)
950
+ * then packaged `spec.defaultEnabled` (3). **Ignores resolution mode** —
951
+ * callers that need mode-aware behavior must consult
952
+ * `getDefaultResolutionMode()` themselves and act accordingly.
953
+ *
954
+ * Pass an explicit `snapshot` (the merged settings map from
955
+ * `readMergedToolsetDefaults()`) when calling from a loop over multiple
956
+ * toolsets — read the snapshot once before the loop and pass it in to
957
+ * avoid re-reading disk per toolset. When `snapshot` is omitted the
958
+ * function performs its own one-off `readMergedToolsetDefaults()` call.
959
+ *
960
+ * `snapshot` shares the on-disk shape
961
+ * `Record<persistKey, { enabled: boolean }>`; the pin is unwrapped via
962
+ * `?.enabled`. A missing or malformed entry falls through to
963
+ * `spec.defaultEnabled ?? true`.
964
+ *
965
+ * @public — exported for call sites that need the settings-aware default
966
+ * without re-implementing the reader (e.g. focus teardown and
967
+ * post-install actuation).
968
+ */
969
+ export function getEffectiveDefault(
970
+ spec: ToolsetSpec,
971
+ snapshot?: ToolsetDefaultsMap,
972
+ ): boolean {
973
+ const map = snapshot ?? readMergedToolsetDefaults();
974
+ const settingsEnabled = map[spec.persistKey]?.enabled;
975
+ return typeof settingsEnabled === "boolean"
976
+ ? settingsEnabled
977
+ : (spec.defaultEnabled ?? true);
978
+ }
979
+
980
+ // ---------------------------------------------------------------------------
981
+ // Settings.json writer — toolsetDefaults tier
982
+ // ---------------------------------------------------------------------------
983
+
984
+ let _settingsWriterOverride: {
985
+ global: ToolsetDefaultsMap;
986
+ project: ToolsetDefaultsMap;
987
+ } | null = null;
988
+
989
+ /**
990
+ * Capture toolset-defaults writes in-memory instead of hitting disk. Pass
991
+ * `null` to restore the disk-write path. Independent of
992
+ * `setSettingsOverrideForTests` — both seams must be cleared (`null`) for a
993
+ * true round-trip that hits disk on both read and write.
994
+ *
995
+ * @internal
996
+ */
997
+ export function setSettingsWriterOverrideForTests(
998
+ state: {
999
+ global: ToolsetDefaultsMap;
1000
+ project: ToolsetDefaultsMap;
1001
+ } | null,
1002
+ ): void {
1003
+ _settingsWriterOverride = state;
1004
+ }
1005
+
1006
+ /**
1007
+ * Read one scope's settings.json (with the malformed-file guard), run
1008
+ * `mutator` against the parsed object, and write it back iff `mutator`
1009
+ * returns `true`. Returns the same boolean so callers whose result *is*
1010
+ * "did it write" (e.g. `clearToolsetDefaults`) can use it directly.
1011
+ *
1012
+ * `mutator` returns `false` to skip the write (used by clear when the
1013
+ * key is absent — no needless reformat of a hand-edited file). The
1014
+ * malformed-file guard throws before `mutator` runs, so a corrupt file is
1015
+ * never handed to a mutator and never overwritten.
1016
+ *
1017
+ * ponytail: read-modify-write is not atomic — concurrent Pi sessions
1018
+ * writing the same global settings.json can lose writes. An advisory
1019
+ * file lock or write-to-temp+rename would close this; revisit if
1020
+ * cross-session write contention becomes observable.
1021
+ */
1022
+ function mutateSettingsJson(
1023
+ scope: "global" | "project",
1024
+ mutator: (existing: Record<string, unknown>) => boolean,
1025
+ ): boolean {
1026
+ const path = settingsPath(scope);
1027
+
1028
+ let existing: Record<string, unknown>;
1029
+ try {
1030
+ if (existsSync(path)) {
1031
+ const raw = readFileSync(path, "utf-8");
1032
+ const parsed = JSON.parse(raw);
1033
+ if (
1034
+ typeof parsed !== "object" ||
1035
+ parsed === null ||
1036
+ Array.isArray(parsed)
1037
+ ) {
1038
+ throw new MalformedSettingsError(
1039
+ `[pi-tool-masking] Refusing to overwrite non-object settings.json at ` +
1040
+ `${path}. The file contains ${
1041
+ Array.isArray(parsed)
1042
+ ? "a JSON array"
1043
+ : typeof parsed === "object"
1044
+ ? "null"
1045
+ : typeof parsed
1046
+ }. Fix or remove it before writing.`,
1047
+ );
1048
+ }
1049
+ existing = parsed as Record<string, unknown>;
1050
+ } else {
1051
+ existing = {};
1052
+ }
1053
+ } catch (err: unknown) {
1054
+ if (err instanceof MalformedSettingsError) throw err;
1055
+ if (err instanceof SyntaxError) {
1056
+ throw new MalformedSettingsError(
1057
+ `[pi-tool-masking] Refusing to overwrite malformed settings.json at ` +
1058
+ `${path}. Fix or remove it before writing. Parse error: ${err.message}`,
1059
+ );
1060
+ }
1061
+ throw err;
1062
+ }
1063
+
1064
+ const write = mutator(existing);
1065
+
1066
+ if (write) {
1067
+ mkdirSync(dirname(path), { recursive: true });
1068
+ writeFileSync(path, JSON.stringify(existing, null, 2) + "\n");
1069
+ }
1070
+ return write;
1071
+ }
1072
+
1073
+ /**
1074
+ * Write a batch of toolset default entries to one settings scope.
1075
+ *
1076
+ * `entries` is the on-disk shape `{ [persistKey]: { enabled: boolean } }`
1077
+ * — the same shape `readMergedToolsetDefaults()` returns. Each entry
1078
+ * becomes `toolsetDefaults[persistKey] = { enabled }` in the chosen
1079
+ * scope's settings file, preserving every other top-level key.
1080
+ *
1081
+ * Merge semantics: shallow per-entry within `toolsetDefaults`. Existing
1082
+ * entries for persistKeys NOT in `entries` are preserved; entries in
1083
+ * `entries` overwrite any same-key existing entry. A write where every
1084
+ * entry already matches its on-disk value is a no-op: the file is left
1085
+ * untouched (no reformat, no mtime bump).
1086
+ *
1087
+ * **Malformed-file guard:**
1088
+ * - File missing → write fresh (nothing to lose).
1089
+ * - File parses to a non-object (array, string, null) → **throw**
1090
+ * (data-loss guard — would destroy unparsable user config).
1091
+ * - `JSON.parse` throws → **throw** (same reason).
1092
+ *
1093
+ * Returns the path of the settings file the entries were merged into
1094
+ * (whether or not any entry actually changed — the file is the write
1095
+ * destination either way).
1096
+ *
1097
+ * @public
1098
+ */
1099
+ export function writeToolsetDefaults(
1100
+ entries: ToolsetDefaultsMap,
1101
+ scope: "global" | "project",
1102
+ ): string {
1103
+ // Seam path: merge into memory
1104
+ if (_settingsWriterOverride !== null) {
1105
+ for (const [key, val] of Object.entries(entries)) {
1106
+ _settingsWriterOverride[scope][key] = { enabled: val.enabled };
1107
+ }
1108
+ return settingsPath(scope);
1109
+ }
1110
+
1111
+ mutateSettingsJson(scope, (existing) => {
1112
+ // Non-object `toolsetDefaults` (array/string/null) recovers to `{}` —
1113
+ // same recovery as the reader (`parseToolsetDefaults`). A bare array
1114
+ // would swallow string-keyed writes (JSON.stringify drops them) and a
1115
+ // string would throw a confusing TypeError on `td[key] = ...`.
1116
+ const raw = existing.toolsetDefaults;
1117
+ const td: Record<string, unknown> =
1118
+ raw && typeof raw === "object" && !Array.isArray(raw)
1119
+ ? (raw as Record<string, unknown>)
1120
+ : {};
1121
+ let changed = false;
1122
+ for (const [key, val] of Object.entries(entries)) {
1123
+ if (
1124
+ (td[key] as { enabled?: unknown } | undefined)?.enabled !== val.enabled
1125
+ ) {
1126
+ td[key] = { enabled: val.enabled };
1127
+ changed = true;
1128
+ }
1129
+ }
1130
+ if (!changed) return false;
1131
+ existing.toolsetDefaults = td;
1132
+ return true;
1133
+ });
1134
+ return settingsPath(scope);
1135
+ }
1136
+
1137
+ /**
1138
+ * Remove the `toolsetDefaults` wrapper key entirely from one scope's
1139
+ * settings file, preserving every other top-level key. After this, every
1140
+ * toolset in that scope falls back to tier 3 (packaged default, or
1141
+ * `spec.defaultEnabled ?? true`).
1142
+ *
1143
+ * Returns the path of the settings file the key was removed from, or
1144
+ * `null` if the key was already absent (or the file was missing). No
1145
+ * per-entry clear path by design — callers who want that write an
1146
+ * `entries` map without the unwanted keys via `writeToolsetDefaults`.
1147
+ *
1148
+ * **Malformed-file guard:** same as `writeToolsetDefaults` — throws on
1149
+ * non-object or unparsable JSON rather than overwriting user config.
1150
+ *
1151
+ * @public
1152
+ */
1153
+ export function clearToolsetDefaults(
1154
+ scope: "global" | "project",
1155
+ ): string | null {
1156
+ // Seam path: clear all keys in memory
1157
+ if (_settingsWriterOverride !== null) {
1158
+ const state = _settingsWriterOverride[scope];
1159
+ const keys = Object.keys(state);
1160
+ for (const key of keys) {
1161
+ delete state[key];
1162
+ }
1163
+ return keys.length > 0 ? settingsPath(scope) : null;
1164
+ }
1165
+
1166
+ return mutateSettingsJson(scope, (existing) => {
1167
+ if (!("toolsetDefaults" in existing)) return false; // no write, no reformat
1168
+ delete existing.toolsetDefaults;
1169
+ return true;
1170
+ })
1171
+ ? settingsPath(scope)
1172
+ : null;
1173
+ }
1174
+
1175
+ /**
1176
+ * Error thrown when refusing to overwrite a malformed or non-object
1177
+ * settings.json to prevent data-loss of user pi-core config.
1178
+ *
1179
+ * @public — exported for downstream consumers to distinguish malformed-file
1180
+ * errors from generic I/O errors without string-matching `message`.
1181
+ * Catch with `instanceof MalformedSettingsError`.
1182
+ */
1183
+ export class MalformedSettingsError extends Error {
1184
+ constructor(message: string) {
1185
+ super(message);
1186
+ this.name = "MalformedSettingsError";
1187
+ }
1188
+ }