pi-lean-dimension 0.3.3 → 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.
package/README.md CHANGED
@@ -21,34 +21,27 @@ npx playwright install chromium firefox
21
21
  That's it — the tools are registered and start enabled by default. Control
22
22
  them with `/web on|off|learn` (`on` = browser tools, `learn` = browser tools
23
23
  plus guide-saving via `web-learn`, `off` = everything off). The state persists
24
- per session. To set a different default for **new** sessions, add this to your
25
- Pi settings (`~/.pi/agent/settings.json` or `.pi/settings.json`):
24
+ per session. To set a different default for **new** sessions, add a
25
+ `toolsetDefaults` block to your Pi settings (`~/.pi/agent/settings.json` or
26
+ `.pi/settings.json`):
26
27
 
27
28
  ```json
28
- { "browserToggle": { "defaultEnabled": false } }
29
+ {
30
+ "toolsetDefaults": {
31
+ "toolset-state:pi-lean-dimension.web": { "enabled": false },
32
+ "toolset-state:pi-lean-dimension.web-learn": { "enabled": false },
33
+ "toolset-state:pi-lean-dimension.search": { "enabled": false }
34
+ }
35
+ }
29
36
  ```
30
37
 
31
- > ⚠️ **Deprecated — not yet replaced.** `browserToggle.defaultEnabled` is
32
- > still read today and works as shown. It is *scheduled* to be replaced by
33
- > the `pi-tool-masking` library's `toolsetDefaults` block in an upcoming
34
- > release — **that block is not read yet**, so keep the legacy key until the
35
- > replacement ships. To be ready, you can add the future block alongside it
36
- > (it's harmless today; it'll take effect automatically once the offload
37
- > lands):
38
- >
39
- > ```json
40
- > {
41
- > "browserToggle": { "defaultEnabled": false },
42
- > "toolsetDefaults": {
43
- > "toolset-state:pi-lean-dimension.web": { "enabled": false },
44
- > "toolset-state:pi-lean-dimension.web-learn": { "enabled": false },
45
- > "toolset-state:pi-lean-dimension.search": { "enabled": false }
46
- > }
47
- > }
48
- > ```
49
- >
50
- > (omit a `toolsetDefaults` key to use the toolset's packaged default; the
51
- > `search` key only applies when `pi-lean-search` is installed.)
38
+ `toolsetDefaults` is read by the [`pi-tool-masking`](https://github.com/coreyryanhanson/pi-tool-masking/) library on restore, before
39
+ the toolset's packaged default. Omit a key to use the packaged default; the
40
+ `search` key only applies when `pi-lean-search` is installed.
41
+
42
+ > The legacy `browserToggle.defaultEnabled` key is **removed in 0.4.0** and
43
+ > no longer read. If you pinned it, move the value into the matching
44
+ > `toolsetDefaults` entry above.
52
45
 
53
46
  ---
54
47
 
@@ -115,17 +115,10 @@ toggles guide-saving mode, and manages browser profiles.
115
115
  ### Persistence
116
116
 
117
117
  The toggle state is stored in the conversation's branch history. A fresh
118
- conversation starts with the default from `settings.json` (`browserToggle.defaultEnabled`,
119
- which defaults to `true`).
120
-
121
- > ⚠️ `browserToggle.defaultEnabled` is still read today but is scheduled to be
122
- > replaced by the `pi-tool-masking` library's `toolsetDefaults` block in an
123
- > upcoming release — **that block is not read yet**, so keep the legacy key
124
- > until the replacement ships. See
125
- > [Configuration (`settings.json`)](#configuration-settingsjson) for the
126
- > future shape and how to pre-add it harmlessly. A migration warning fires on
127
- > session start once the legacy key is present and the web default hasn't yet
128
- > been mirrored into `toolsetDefaults`.
118
+ conversation starts with the default from `settings.json` — the
119
+ `pi-tool-masking` library's `toolsetDefaults` block (see
120
+ [Configuration (`settings.json`)](#configuration-settingsjson)), falling back
121
+ to the toolset's packaged default (`true` for `web`).
129
122
 
130
123
  ---
131
124
 
@@ -615,48 +608,30 @@ a `profile` parameter:
615
608
  }
616
609
  ```
617
610
 
618
- ### `browserToggle.defaultEnabled` *(deprecated — not yet replaced)*
611
+ ### `toolsetDefaults` *(settings-based toolset defaults)*
619
612
 
620
- Whether browser tools are enabled on fresh conversations. **This key is still
621
- read today and works as shown.** It is *scheduled* to be replaced by the
622
- `pi-tool-masking` library's `toolsetDefaults` block in an upcoming release —
623
- **that block is not read yet**, so keep this key until the replacement ships.
613
+ Whether browser tools are enabled on fresh conversations. Read by the
614
+ `pi-tool-masking` library at restore time, between the chat-branch tier and
615
+ the toolset's packaged default:
624
616
 
625
617
  ```jsonc
626
618
  {
627
- "browserToggle": {
628
- "defaultEnabled": true
619
+ "toolsetDefaults": {
620
+ "toolset-state:pi-lean-dimension.web": { "enabled": true },
621
+ "toolset-state:pi-lean-dimension.web-learn": { "enabled": false },
622
+ "toolset-state:pi-lean-dimension.search": { "enabled": true }
629
623
  }
630
624
  }
631
625
  ```
632
626
 
633
- > ⚠️ **Preparing for the offload.** Settings-based toolset defaults are being
634
- > offloaded to the `pi-tool-masking` library, which will read a new
635
- > `toolsetDefaults` block keyed by persist key. **The new block is not read
636
- > by the current release** — it's harmless to add now and will take effect
637
- > automatically once the offload ships, at which point the legacy key stops
638
- > being read. To pre-stage the migration, set both:
639
- >
640
- > ```jsonc
641
- > {
642
- > "browserToggle": { "defaultEnabled": true },
643
- > "toolsetDefaults": {
644
- > "toolset-state:pi-lean-dimension.web": { "enabled": true },
645
- > "toolset-state:pi-lean-dimension.web-learn": { "enabled": false },
646
- > "toolset-state:pi-lean-dimension.search": { "enabled": true }
647
- > }
648
- > }
649
- > ```
650
- >
651
- > - Keys are the toolsets' `persistKey` values (`toolset-state:<id>`).
652
- > - Omit a `toolsetDefaults` key to use the toolset's packaged default (`web`
653
- > and `search` default `true`; `web-learn` defaults `false`).
654
- > - The `search` key only applies when `pi-lean-search` is installed.
655
- >
656
- > Once the offload ships and the legacy read is removed, delete the
657
- > `browserToggle` block. Until then, a migration warning fires on session
658
- > start when the legacy key is present and the web default hasn't yet been
659
- > mirrored into `toolsetDefaults`; mirroring it suppresses the warning.
627
+ - Keys are the toolsets' `persistKey` values (`toolset-state:<id>`).
628
+ - Omit a `toolsetDefaults` key to use the toolset's packaged default (`web`
629
+ and `search` default `true`; `web-learn` defaults `false`).
630
+ - The `search` key only applies when `pi-lean-search` is installed.
631
+
632
+ > The legacy `browserToggle.defaultEnabled` key is **removed in 0.4.0** — it
633
+ > is no longer read. If you pinned it, move the value into the matching
634
+ > `toolsetDefaults` entry above.
660
635
 
661
636
  ### `browser.maxStorageStateSize`
662
637
 
@@ -9,24 +9,12 @@ import {
9
9
  getDefaultResolutionMode,
10
10
  } from "pi-tool-masking";
11
11
  import type { ToolsetSpec } from "pi-tool-masking";
12
- import { readMergedSettings } from "./core/shared/settings-reader.js";
13
-
14
- // Focus-mode guard helper. The library's published `DefaultResolutionMode`
15
- // type is `"exclusion" | "inclusion"` (the allowlist mode is unpublished /
16
- // ships in pi-tool-masking 1.2.0), so the string cast is load-bearing: an
17
- // allowlist-capable consumer sharing the `globalThis` module state writes
18
- // `"allowlist"` into it, and this consumer reads that value back at runtime
19
- // even though its own bundled type doesn't name the mode. On published
20
- // versions no caller ever writes `"allowlist"`, so this is a no-op for
21
- // ordinary users — it only activates when an allowlist-capable
22
- // pi-tool-masking consumer is in play.
23
- //
24
- // Cleanup at the ^1.2.0 bump: once `DefaultResolutionMode` names
25
- // `"allowlist"`, drop the `as string` cast here and in pi-lean-search's
26
- // co-activation mirror — the type system can then check the comparison
27
- // directly and the cast becomes a suppressor of a check it should perform.
12
+
13
+ // Focus-mode guard: refuse actuating subcommands while the library holds the
14
+ // line — inclusion mode or allowlist focus (an upstream pi-tool-masking
15
+ // consumer).
28
16
  function isFocusHolding(): boolean {
29
- const mode = getDefaultResolutionMode() as string;
17
+ const mode = getDefaultResolutionMode();
30
18
  return mode === "inclusion" || mode === "allowlist";
31
19
  }
32
20
 
@@ -154,86 +142,13 @@ function renderBrowserGlyph(
154
142
  }
155
143
  }
156
144
 
157
- // ---- Legacy settings migration warning ---------------------------
158
- //
159
- // The portal currently seeds the web toolset's fresh-session default from
160
- // `browserToggle.defaultEnabled` in settings.json. An upcoming pi-tool-masking
161
- // release takes over settings-based toolset defaults and reads a new
162
- // `toolsetDefaults` block instead. Warn users who pinned the legacy key so
163
- // they can migrate before the offload lands and the legacy read is removed.
164
- //
165
- // ponytail: warn-only — we still honor the legacy key for backward compat
166
- // until pi-tool-masking owns the settings tier; then delete this helper and
167
- // the `browserToggle` read below. Ceiling: a silent default shift for users
168
- // who ignore the warning; upgrade path is the pi-tool-masking bump.
169
- const TOOLSET_DEFAULTS_MIGRATION_MSG =
170
- "⚠️ pi-lean-portal: settings-based toolset defaults are moving to the " +
171
- "pi-tool-masking library. The `browserToggle.defaultEnabled` key in your " +
172
- "settings.json will stop being read in an upcoming release. It still works " +
173
- "today — KEEP it until the replacement ships, and add the new " +
174
- "`toolsetDefaults` block alongside it so your config is ready (the new " +
175
- "block is not read yet, but is harmless now and will take effect " +
176
- "automatically once the offload lands):\n\n" +
177
- "{\n" +
178
- ' "browserToggle": { "defaultEnabled": true },\n' +
179
- ' "toolsetDefaults": {\n' +
180
- ' "toolset-state:pi-lean-dimension.web": { "enabled": true },\n' +
181
- ' "toolset-state:pi-lean-dimension.web-learn": { "enabled": true },\n' +
182
- ' "toolset-state:pi-lean-dimension.search": { "enabled": true }\n' +
183
- " }\n" +
184
- "}\n" +
185
- "(omit a `toolsetDefaults` key to use the toolset's packaged default; the " +
186
- "`search` key only applies when pi-lean-search is installed). Mirror your " +
187
- "web default into `toolset-state:pi-lean-dimension.web` to silence this " +
188
- "warning.";
189
-
190
- function hasLegacyToolsetDefault(merged: Record<string, unknown>): boolean {
191
- const seg = merged["browserToggle"];
192
- if (!seg || typeof seg !== "object" || Array.isArray(seg)) return false;
193
- return (
194
- typeof (seg as Record<string, unknown>)["defaultEnabled"] === "boolean"
195
- );
196
- }
197
-
198
- // The new `toolsetDefaults` block the pi-tool-masking offload will read.
199
- // When the user has already migrated the web toolset's default into it,
200
- // the warning is redundant even if the legacy `browserToggle.defaultEnabled`
201
- // key is still sitting on disk — suppress so a migrated settings file stays
202
- // quiet. Only the web persist key gates this: it's the one the legacy key
203
- // controlled, so its presence means the user acted on the migration.
204
- const WEB_TOOLSET_PERSIST_KEY = "toolset-state:pi-lean-dimension.web";
205
- function hasMigratedToolsetDefault(merged: Record<string, unknown>): boolean {
206
- const td = merged["toolsetDefaults"];
207
- if (!td || typeof td !== "object" || Array.isArray(td)) return false;
208
- const entry = (td as Record<string, unknown>)[WEB_TOOLSET_PERSIST_KEY];
209
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
210
- return typeof (entry as Record<string, unknown>)["enabled"] === "boolean";
211
- }
212
-
213
145
  // ---- Toggle initializer ------------------------------------------
214
146
 
215
147
  export default function initBrowserToggle(pi: ExtensionAPI) {
216
- const merged = readMergedSettings();
217
- // Warn only when the legacy key is pinned AND the user hasn't already
218
- // migrated the web default into `toolsetDefaults`. Once the migrated
219
- // entry exists, pi-tool-masking will read it directly and the legacy
220
- // key is dead weight — no need to nag.
221
- const legacyDefaultPinned =
222
- hasLegacyToolsetDefault(merged) && !hasMigratedToolsetDefault(merged);
223
- const browserToggleSegment = (merged as Record<string, unknown>)[
224
- "browserToggle"
225
- ] as Record<string, unknown> | undefined;
226
- const webDefault =
227
- browserToggleSegment &&
228
- typeof browserToggleSegment["defaultEnabled"] === "boolean"
229
- ? (browserToggleSegment["defaultEnabled"] as boolean)
230
- : true;
231
-
232
- const webSpec: ToolsetSpec = {
233
- ...PORTAL_WEB_SPEC,
234
- defaultEnabled: webDefault,
235
- };
236
- const webToolset = defineToolset(pi, webSpec);
148
+ // Settings-based toolset defaults (`toolsetDefaults` tier) are read by
149
+ // pi-tool-masking itself inside defineToolset/restore — pass the packaged
150
+ // spec straight through.
151
+ const webToolset = defineToolset(pi, PORTAL_WEB_SPEC);
237
152
  const learnToolset = defineToolset(pi, PORTAL_LEARN_SPEC);
238
153
 
239
154
  // ── Keep cached state in sync with library events ─────────
@@ -267,8 +182,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
267
182
  // unguarded, matching the focus controller's treatment of its own
268
183
  // read-only commands.
269
184
  if (["on", "off", "learn"].includes(cmd) && isFocusHolding()) {
270
- const inInclusion =
271
- (getDefaultResolutionMode() as string) === "inclusion";
185
+ const inInclusion = getDefaultResolutionMode() === "inclusion";
272
186
  ctx.ui.notify(
273
187
  inInclusion
274
188
  ? "Another plugin has active inclusion mode — this toolset can't be toggled while inclusion is holding the line. Deactivate it there first."
@@ -348,11 +262,6 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
348
262
  restoreProfile(pi, ctx);
349
263
  _lastCtx = ctx;
350
264
  syncCachedState();
351
- // One-time-per-session migration warning when the legacy
352
- // `browserToggle.defaultEnabled` pin is still on disk.
353
- if (legacyDefaultPinned) {
354
- ctx.ui.notify(TOOLSET_DEFAULTS_MIGRATION_MSG, "warning");
355
- }
356
265
  });
357
266
 
358
267
  pi.on("session_tree", async (_event, ctx) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-portal",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension. Interactive web browsing for Pi — a /web toggle removes the tools from context when switched off, Playwright Chromium/Firefox deliver accessibility-tree snapshots, persistent profiles, cookies, and domain-matched guides; custom/stealth backends (Camoufox) plug in when a site blocks the shipped browsers.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "node-html-parser": "^6.1.0",
59
- "pi-tool-masking": "^1.1.0",
59
+ "pi-tool-masking": "^1.2.0",
60
60
  "playwright": "^1.60.0",
61
61
  "turndown": "^7.2.0"
62
62
  },
@@ -80,9 +80,7 @@ describe("readSearxngUrl", () => {
80
80
  vi.mocked(existsSync).mockImplementation(
81
81
  (path) => typeof path === "string" && path.includes(".pi"),
82
82
  );
83
- vi.mocked(readFileSync).mockReturnValue(
84
- JSON.stringify({ theme: "dark", browserToggle: {} }),
85
- );
83
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ theme: "dark" }));
86
84
  expect(readSearxngUrl()).toBeUndefined();
87
85
  });
88
86
 
@@ -482,8 +480,7 @@ describe("pi-lean-dimension.web co-activation mirror", () => {
482
480
  // Allowlist focus (an upstream pi-tool-masking consumer) holds the line —
483
481
  // the mirror must not co-activate, so a stale library `doRestore` emitting
484
482
  // a web `changed` during resume can't disable search or write a {enabled}
485
- // entry. Set the shared module state directly (published type doesn't name
486
- // "allowlist").
483
+ // entry. Set the shared module state directly.
487
484
  it("skips co-activation while allowlist focus is active", async () => {
488
485
  const { pi, events } = mockSearchPi(["web-search"]);
489
486
  searchExtension(pi);
@@ -208,20 +208,10 @@ export default function (pi: ExtensionAPI) {
208
208
  // co-activation. The focus set is authoritative, so a web `changed` event
209
209
  // — including one a stale library `doRestore` emits during resume — must
210
210
  // not disable search or write a focus-indistinguishable {enabled} entry.
211
- // The published `DefaultResolutionMode` type doesn't name `"allowlist"`
212
- // (it ships in pi-tool-masking 1.2.0), so the string cast is load-bearing:
213
- // an allowlist-capable consumer sharing the `globalThis` module state
214
- // writes `"allowlist"` into it and we read it back here. No-op for
215
- // ordinary users on published versions, where nothing ever writes
216
- // `"allowlist"`.
217
- //
218
- // Cleanup at the ^1.2.0 bump: drop the `as string` cast once
219
- // `DefaultResolutionMode` names `"allowlist"` — same as the matching cast
220
- // in pi-lean-portal's browser-toggle focus guard.
221
211
  pi.events.on(TOOLSET_EVENTS.changed, (data: unknown) => {
222
212
  const event = data as ToolsetChangedEvent;
223
213
  if (event.id === "pi-lean-dimension.web") {
224
- if ((getDefaultResolutionMode() as string) === "allowlist") return;
214
+ if (getDefaultResolutionMode() === "allowlist") return;
225
215
  if (event.enabled) {
226
216
  searchToolset.enable(pi);
227
217
  } else {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-search",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension. SearXNG search tool for Pi — pairs with pi-lean-portal's /web toggle; search-only installs are valid, or add it to a portal install for the full web-tools suite.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -38,7 +38,7 @@
38
38
  "test": "vitest run"
39
39
  },
40
40
  "dependencies": {
41
- "pi-tool-masking": "^1.1.0"
41
+ "pi-tool-masking": "^1.2.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@earendil-works/pi-ai": "*",
@@ -16,7 +16,7 @@ Without `pi-tool-masking`, every pi extension that toggles tools reimplements th
16
16
  4. On `session_start` / `session_tree`, walk the branch for persisted entries and re-apply state.
17
17
  5. Emit events so side-effect owners (status bars, pickers) can re-render.
18
18
 
19
- `pi-tool-masking` does all of that in one call: `defineToolset(pi, spec)`. It also adds dependency cascading (enabling a toolset auto-enables its dependencies) and reverse cascading (disabling a toolset auto-disables dependents).
19
+ `pi-tool-masking` does all of that in one call: `defineToolset(pi, spec)`. It also adds dependency cascading (enabling a toolset auto-enables its dependencies) and reverse cascading (disabling a toolset auto-disables dependents), plus a settings tier for per-scope defaults and allowlist mode for reliable focus across reloads.
20
20
 
21
21
  ---
22
22
 
@@ -89,19 +89,24 @@ Register a toolset and receive a `Toolset` handle (`enable`, `disable`, `isEnabl
89
89
 
90
90
  **Idempotent re-registration:** calling `defineToolset` with the same `spec.id` and an unchanged spec returns the existing toolset. This is safe across `/reload`.
91
91
 
92
- ### `setDefaultResolutionMode(pi, mode)`
92
+ ### `setDefaultResolutionMode(pi, mode, allowlist?)`
93
93
 
94
- Switch how toolsets with no persisted state resolve on restore. Two modes:
94
+ Switch how toolsets with no persisted state resolve on restore. Three modes:
95
95
 
96
96
  | Mode | Behavior on restore (no persisted entry) |
97
97
  |---|---|
98
98
  | `"exclusion"` (default) | Toolsets default **on** if `defaultEnabled` is true, **off** otherwise |
99
- | `"inclusion"` | All unknown toolsets default **off** — useful for focus-mode "only these tools" workflows |
99
+ | `"inclusion"` (@deprecated since 1.2.0) | All unknown toolsets default **off** — a weaker, unbounded floor. Use `"allowlist"` instead for focus-style "only these tools" suppression |
100
+ | `"allowlist"` | Only the listed toolset ids are **on**, everything else **off** — a finite, branch-persisted set whose complement is computed at restore, resilient to toolsets installed later. Pass the array as the third argument: `setDefaultResolutionMode(pi, "allowlist", ["my-plugin.web"])` |
100
101
 
101
102
  ### `getDefaultResolutionMode()`
102
103
 
103
104
  Read the current default resolution mode. Returns `"exclusion"` until a session restore loads the persisted mode.
104
105
 
106
+ ### `getActiveAllowlist()`
107
+
108
+ Read the live allowlist array from module state. Returns the `string[]` when the active mode is `"allowlist"`, otherwise `undefined`. Parameterless (like `getDefaultResolutionMode`) — the downstream call site receives `ExtensionAPI`, which doesn't expose `sessionManager`, so the branch can't be read there. The branch remains the source of truth; module state is the live mirror, snapped from the last mode branch entry by `doRestore` (and by `setDefaultResolutionMode` when it appends the entry). A consumer consults this to keep toolsets registered *after* focus was entered off.
109
+
105
110
  ### `getRegisteredToolsets()`
106
111
 
107
112
  Return a read-only snapshot of every registered toolset (`{ spec, toolset }`). No `pi` argument needed — pure registry read.
@@ -121,10 +126,59 @@ Return a read-only snapshot of every registered toolset (`{ spec, toolset }`). N
121
126
  | `Toolset` | Handle returned by `defineToolset` |
122
127
  | `ToolsetChangedEvent` | Shape of events emitted by `TOOLSET_EVENTS` |
123
128
  | `RegistryEntry` | `{ spec: ToolsetSpec; toolset: Toolset }` — a single registered toolset |
124
- | `DefaultResolutionMode` | `"exclusion" \| "inclusion"` |
129
+ | `DefaultResolutionMode` | `"exclusion" \| "inclusion" \| "allowlist"` |
130
+ | `MalformedSettingsError` | Thrown by `writeToolsetDefaults` / `clearToolsetDefaults` when settings.json is corrupt or non-object (never silently overwritten). Catch with `instanceof`. |
125
131
 
126
132
  ---
127
133
 
134
+ ## Toolset defaults (settings tier)
135
+
136
+ A toolset's fresh-session default is no longer locked to its packaged `spec.defaultEnabled`. Users can pin `{ enabled: boolean }` under a reserved `toolsetDefaults` key in pi-core settings, keyed by the toolset's full `persistKey` — without toggling (which writes a session-scoped chat-branch entry). The library reads both files itself inside `doRestore`, fresh on each `/reload`, so downstream consumers no longer need to reinvent a settings reader to inject values into `spec.defaultEnabled` before `defineToolset`.
137
+
138
+ Restore resolves each toolset's default in this order (first hit wins):
139
+
140
+ 1. **Chat-branch entry** — the last `appendEntry(persistKey, …)` on this branch. A `null` tombstone (see [`clearToolsetEntry`](#tombstone-helpers)) falls through to tier 2.
141
+ 2. **Settings pin** — `toolsetDefaults[persistKey].enabled`, merged global → project (project wins per entry). Mode-agnostic.
142
+ 3. **Packaged default** — `spec.defaultEnabled ?? true`, filtered by resolution mode for unpinned toolsets only.
143
+
144
+ Settings pins are honored in all three modes, mirroring how chat-branch entries are honored — only unpinned toolsets consult mode for the floor.
145
+
146
+ ### `readMergedToolsetDefaults()`
147
+
148
+ Read and merge `toolsetDefaults` from global (`~/.pi/agent/settings.json`, or `$PI_CODING_AGENT_DIR/settings.json`) and project (`<cwd>/.pi/settings.json`) settings. Project overrides global per entry. Missing/unreadable/malformed files contribute `{}`. Never throws. Returns `Record<persistKey, { enabled: boolean }>`. Read once per loop and pass the snapshot to `getEffectiveDefault` to avoid re-reading disk per toolset.
149
+
150
+ ### `readToolsetDefaults(scope)`
151
+
152
+ Read one scope's raw `toolsetDefaults` block (no merge). `scope` is `"global"` or `"project"`. Same never-throw policy. Use for `defaults show`-style commands that need per-scope attribution.
153
+
154
+ ### `writeToolsetDefaults(entries, scope)`
155
+
156
+ Merge a batch of `{ [persistKey]: { enabled } }` entries into one scope's `toolsetDefaults`, preserving every other top-level key and every existing entry not in `entries`. A write where every entry already matches its on-disk value is a no-op (no reformat, no mtime bump). Returns the settings file path. **Throws `MalformedSettingsError`** if the file exists but parses to a non-object or is unparsable — a corrupt file is never silently overwritten.
157
+
158
+ ### `clearToolsetDefaults(scope)`
159
+
160
+ Remove the `toolsetDefaults` wrapper key entirely from one scope, preserving every other top-level key. Returns the path removed from, or `null` if the key was already absent (or the file missing). No per-entry clear by design — write an `entries` map without the unwanted keys via `writeToolsetDefaults`. Same `MalformedSettingsError` guard.
161
+
162
+ ### `getEffectiveDefault(spec, snapshot?)`
163
+
164
+ Resolve a toolset's effective fresh-session default: settings tier (2) then packaged `spec.defaultEnabled ?? true` (3). **Ignores resolution mode** — callers needing mode-aware behavior must consult `getDefaultResolutionMode()` themselves. Pass an explicit `snapshot` (from `readMergedToolsetDefaults()`) when looping over multiple toolsets; omit it for a one-off (it performs its own read).
165
+
166
+ ## Tombstone helpers
167
+
168
+ Within pi-core's append-only `SessionManager`, a toolset's chat-branch entry can't be deleted — but a `null` tombstone appended after the last entry makes `doRestore` fall through to the settings tier so settings re-assert. Tombstones are dedup'd (no-op when the last entry is already cleared) and never written for never-toggled toolsets. A later manual toggle appends after the tombstone and supersedes it.
169
+
170
+ ### `clearToolsetEntry(pi, persistKey, branch)`
171
+
172
+ Append a `null` tombstone for one toolset's branch entry (dedup'd). `branch` is the caller's `ctx.sessionManager.getBranch()` snapshot — `ExtensionAPI` exposes `appendEntry` but not `sessionManager`, so the dedup read comes from the caller.
173
+
174
+ ### `clearAllToolsetEntries(pi, branch)`
175
+
176
+ Tombstone every registered toolset's branch entry (dedup'd per toolset). Covers exactly the toolsets in the global registry.
177
+
178
+ ### `applyToolsetEnabled(pi, spec, enabled)`
179
+
180
+ Apply a toolset's enabled state via `setActiveTools` and emit `TOOLSET_EVENTS.changed` **without** writing a branch entry — the live-apply half of a settings restore (pull a toolset to its settings/packaged default without persisting a chat-branch pin).
181
+
128
182
  ## ToolsetSpec fields
129
183
 
130
184
  ```ts
@@ -206,15 +260,18 @@ pi.events.on(TOOLSET_EVENTS.restored, (event) => {
206
260
  });
207
261
  ```
208
262
 
209
- ### Focus mode (inclusion resolution)
263
+ ### Focus mode (allowlist resolution)
210
264
 
211
265
  ```ts
212
266
  import { setDefaultResolutionMode, getRegisteredToolsets } from "pi-tool-masking";
213
267
 
214
- // Enter focus: set inclusion mode so unknown toolsets stay off
215
- setDefaultResolutionMode(pi, "inclusion");
268
+ // Enter focus: allowlist mode keeps only the listed toolsets on — restore
269
+ // applies it on the next /reload, and the loop below applies it live.
270
+ // "inclusion" (deprecated) cannot guarantee this: a toolset installed after
271
+ // focus leaks on, because the set of "on" toolsets was never recorded.
272
+ setDefaultResolutionMode(pi, "allowlist", ["my-plugin.web"]);
216
273
 
217
- // Enable only the allowlisted toolsets
274
+ // Apply live: enable only the allowlisted toolsets
218
275
  const allowlist = new Set(["my-plugin.web"]);
219
276
  for (const entry of getRegisteredToolsets()) {
220
277
  if (allowlist.has(entry.spec.id)) {
@@ -282,7 +339,9 @@ ids with a stable namespace (<product-family>.<subset>, e.g. "foo.web").
282
339
 
283
340
  - **Registration:** `defineToolset` stores the spec and handle in a global registry (shared across module instances, so multiple extensions see the same toolsets).
284
341
  - **Persistence:** each toolset writes `{ enabled }` entries under its `persistKey` on the session branch. On `session_start` or `session_tree`, the library re-reads the branch and applies the last persisted state.
285
- - **Default resolution:** a single `toolset-resolution-mode` entry on the branch controls whether unknown toolsets default on or off. This is set by `setDefaultResolutionMode` and persists across reloads.
342
+ - **Default resolution:** a `toolset-resolution-mode` entry on the branch controls how toolsets with no persisted state resolve on restore — `exclusion` (on/off by `defaultEnabled`), `inclusion` (deprecated unbounded floor), or `allowlist` (a finite branch-persisted array whose complement is computed at restore). Set by `setDefaultResolutionMode`, persists across reloads.
343
+ - **Defaults tiers:** each toolset's restore default resolves chat-branch entry → `toolsetDefaults` settings pin → packaged `spec.defaultEnabled`, filtered by resolution mode for unpinned toolsets only. Settings pins are read fresh from disk on each restore.
344
+ - **Null-tombstone-aware restore:** a `null` last branch entry (written by `clearToolsetEntry`) falls through to the settings tier instead of any stale prior entry; mode resolution is likewise null-tombstone-aware (`branchMode ?? "exclusion"`). Tombstones aren't sticky — a later toggle supersedes them.
286
345
  - **Events:** a live toggle emits only when state actually changes (no-op toggles are suppressed); restore always emits, so side-effect owners stay in sync across reloads and tree navigations.
287
346
 
288
347
  ---