specpi 0.19.0 → 0.20.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0 - 2026-09-09
4
+
5
+ - Add opt-in `structural_search` with pinned ast-grep 0.45.3, protected explicit source selection, bounded output and subprocess cleanup, and exact-call approval in Strict mode. Enable with `specpi update --structural-search=on`, then restart Pi.
6
+ - Add `browser_accessibility` with axe-core 4.13.0 for the current browser state, fixed WCAG profiles, bounded violations and incomplete findings, and existing browser cancellation. Update the managed browser runtime to provision the scanner.
7
+ - Stage, smoke, verify and roll back structural runtime changes with the installer; preserve enablement and modified runtimes, qualify native hosts in CI, and keep downloaded binaries out of the npm artifact. An unparseable owned configuration is reported with its path instead of aborting `plan`, and only an explicit `--structural-search` selection rewrites it.
8
+ - Fix structural-search merge findings: preserve non-binary runtime changes using full-tree ownership checks, restore prior runtimes before failed cleanup, bound serialized enablement configuration, and dismiss expired/cancelled Strict approvals.
9
+
10
+ ## 0.19.1 - 2026-09-08
11
+
12
+ - Allow Pi's exact public Copilot catalog identification headers during delegation, fixing Copilot → Anthropic → Copilot switches that were incorrectly rejected as runtime provider overrides. Reconstruct headers independently in the child; retain runtime-auth, extension-provider and other header restrictions. Restart Pi after updating the harness.
13
+
3
14
  ## 0.19.0 - 2026-09-07
4
15
 
5
16
  - Add session-owned background commands with exact interactive approvals, shared Command Guard admission, bounded logs, and best-effort process cleanup.
package/README.md CHANGED
@@ -32,6 +32,8 @@ SpecPi extends Pi with chat, review tools, and focused delegation. Its principle
32
32
  - **Focused delegation.** One agent makes changes. Up to two read-only subagents help investigate and review. [Research and design](https://tannermidd.github.io/SpecPi/single-agent/)
33
33
  - **Background tasks.** Start an approved dev server, test suite, or watch build, inspect bounded output, and stop it without blocking other work.
34
34
  - **Review as you work.** Track changed files, inspect diffs, check pages in a browser, and review risky commands.
35
+ - **Structural search.** Opt into bounded ast-grep patterns over selected code with `specpi update --structural-search=on`. [Usage and limits](docs/structural-search.md)
36
+ - **Accessibility checks.** Scan the current browser state for automated WCAG and optional best-practice findings. [Browser verification](docs/browser-testing.md)
35
37
  - **Improvements you choose.** Record recurring problems, select one with `/harness-improvement`, and test the change before calling it done.
36
38
 
37
39
  Problem collection is off by default. Its records stay on your machine. Pi connects to your chosen model provider.
@@ -49,20 +51,12 @@ specpi doctor
49
51
 
50
52
  Preview the changes, confirm the install, then check the setup. Restart Pi when finished.
51
53
 
52
- To pin this release, use `npm install --global specpi@0.19.0`.
54
+ To pin this release, use `npm install --global specpi@0.20.0`.
53
55
 
54
56
  Delegation is enabled at startup. Use `/delegate off` to turn it off.
55
57
 
56
58
  [Setup, updates & removal](https://tannermidd.github.io/SpecPi/wiki/#getting-started) · [Delegation settings](docs/delegation/README.md)
57
59
 
58
- ## Background tasks
59
-
60
- Ask Pi to start a long-running command with `background_start`, then use `background_list`, `background_logs`, and `background_stop` to observe and clean up tasks. `background_start` accepts `command`, optional `cwd`/`label`, and `timeoutSeconds` (1–28,800; default 1,800). It requires interactive approval even with Guard off; Guard denials and locks still apply. Headless starts are denied.
61
-
62
- Commands use `/bin/sh` on POSIX or system `cmd.exe` on Windows, not Pi's configured Bash. They inherit the process environment except `NODE_OPTIONS` and `NODE_PATH`, which are cleared to keep the supervisor's startup predictable. No PTY or interactive stdin is provided. Four active tasks are allowed; output is capped at 256 KiB per task, reads at 64 KiB, and completed records at 32. Log offsets are absolute bytes in the UTF-8 stream including stdout/stderr markers, before terminal-control escaping; responses report the next cursor and lost bytes.
63
-
64
- Stop tasks when finished. Session replacement, reload, tree navigation, and shutdown attempt bounded cleanup. `cleanup: confirmed` means the owned root/group termination was observed, not that escaped descendants are contained; unconfirmed cleanup retains its slot and may need manual process inspection. Spawn success is not service readiness. Output is memory-only in the extension, but returned text may enter Pi conversation/provider retention. See the [security boundary](SECURITY_MODEL.md#background-task-execution).
65
-
66
60
  ## SpecPi Chat for VS Code
67
61
 
68
62
  Chat beside your code, attach files, and switch conversations.
@@ -71,7 +65,7 @@ Chat beside your code, attach files, and switch conversations.
71
65
 
72
66
  In VS Code, find **SpecPi Chat** by **tannermidd** in Extensions and install it. Open **SpecPi** in the Activity Bar, then **Connect Pi**.
73
67
 
74
- Chat 0.4.1 shows a compact delegation strip only while workers run or settle, and opens workspace image links in the image viewer. SpecPi 0.19.0 includes the delegation fix for switching models in the same chat. Update both packages, reload VS Code for the Chat update, and use **Restart Pi** to load the harness update. Later model switches do not require a restart. New Chat sessions start with Guard off; use `/guard guard` or `/guard strict` to enable it.
68
+ Chat 0.4.4 shows a compact delegation strip only while workers run or settle, and opens workspace image links in the image viewer. SpecPi 0.19.1 fixes delegation when switching GitHub Copilot models in the same chat, including Luna Opus Luna. Update the harness and use **Restart Pi** to load the fix; this patch does not require a Chat update. Later model switches do not require a restart. New Chat sessions start with Guard off; use `/guard guard` or `/guard strict` to enable it.
75
69
 
76
70
  ## Go further
77
71
 
package/SECURITY_MODEL.md CHANGED
@@ -35,7 +35,7 @@ not a new provider-disclosure boundary.
35
35
 
36
36
  Delegation is a native extension discovered through the ordinary Pi package and SpecPi lifecycle. Start `pi` normally; delegation is enabled by default at the first session start of each Pi process, including TUI, RPC, print and JSON modes, after settings, host and Guard checks. Startup launches no workers or model inference, but Pi-owned authentication/OAuth preparation may occur. The model-facing schema is present only while dispatch is enabled. It adds no launcher, separate host process, service or trust override. Parent Pi retains normal resource discovery, trust decisions and proxy policy. Delegation checks required public SDK capabilities, not exact version identifiers. Missing session/runtime/settings/thinking APIs prevent activation; session construction and every request still enforce the tool, model and resource policy. API presence is not proof of every future SDK behavior or a passing integration receipt. Normal installation's minimum Pi version and 0.84.4 bootstrap pin are separate contracts.
37
37
 
38
- Each worker is an SDK `createAgentSession` instance with in-memory session storage. Pi owns its model/tool loop. A fresh Pi `ModelRuntime` uses standard authentication, environment and `models.json` resolution, restoring Pi's persisted model catalogs with catalog network refresh disabled. Child transport and thinking budgets come from configured global settings; project settings are not loaded. Parent model/thinking are explicit with Pi's supported-level clamping. SpecPi does not extract credentials, copy authentication state or inspect private runtime fields. Preflight rejects runtime-only authentication, selected extension-registered provider overrides, model-specific headers, startup proxy configuration and mismatched safe model descriptors because those routes cannot be faithfully reconstructed. These rejections leave parent configuration unchanged.
38
+ Each worker is an SDK `createAgentSession` instance with in-memory session storage. Pi owns its model/tool loop. A fresh Pi `ModelRuntime` uses standard authentication, environment and `models.json` resolution, restoring Pi's persisted model catalogs with catalog network refresh disabled. Child transport and thinking budgets come from configured global settings; project settings are not loaded. Parent model/thinking are explicit with Pi's supported-level clamping. SpecPi does not extract credentials, copy authentication state or inspect private runtime fields. Preflight rejects runtime-only authentication, selected extension-registered provider overrides, unsupported model-specific headers, startup proxy configuration and mismatched safe model descriptors because those routes cannot be faithfully reconstructed. The sole model-header exception is the exact public Copilot catalog set: `User-Agent: GitHubCopilotChat/0.35.0`, `Editor-Version: vscode/1.107.0`, `Editor-Plugin-Version: copilot-chat/0.35.0`, and `Copilot-Integration-Id: vscode-chat`. Both parent and independently reconstructed child must have that entire set, with no extra keys, inherited headers or accessors; header presence must also match. No parent header object is copied or serialized. Changed catalog values require review before admission. These rejections leave parent configuration unchanged.
39
39
 
40
40
  This is not full parent inference parity: parent request hooks, ephemeral runtime settings and session affinity are not automatically inherited. Keep delegation disabled if those inherited controls are required for every request. Children load no ambient extensions, skills, AGENTS files or parent transcript. Their only tools are selected-source list/read/literal-search; shell, write, arbitrary plugin, live-web and nested delegation tools are absent. Trusted parent extensions remain privileged in the same process. This is an application boundary, not an OS sandbox against malicious extensions.
41
41
 
@@ -183,6 +183,8 @@ Completion Challenge activation and structured results are bounded to the curren
183
183
 
184
184
  ## Browser isolation
185
185
 
186
+ The fixed `browser_accessibility` operation loads pinned axe-core/Playwright packages from the private browser runtime and scans an already open page. It exposes no model-supplied JavaScript, custom rules or exclusions. Results retain bounded rule guidance and target examples, not raw HTML/check payloads; selectors and URL paths can still contain private data and normal tool results may persist in Pi history. Findings include incomplete checks, profile/tags, viewport and scan timestamps. Navigation invalidates the result; dynamic DOM changes are not an atomic snapshot. Cancellation uses the existing browser teardown path. No automatic accessibility artifact or new external scanning service is introduced. The historical browser capability validator retains its original contract; installer/doctor request a separate additional accessibility smoke within the same browser launch.
187
+
186
188
  SpecPi launches managed Chromium in a fresh Playwright context. It does not attach to a personal browser profile or load the user's cookies, saved passwords, or extensions. Browser pages still execute untrusted content and can reach URLs available to the host.
187
189
 
188
190
  Snapshots, screenshots, page text, downloads, console output, and visual baselines may contain sensitive information. Default artifacts remain in SpecPi's private state directory. Explicit output publication is bounded and atomic, and existing artifacts or baselines are not replaced without explicit overwrite authorization.
@@ -193,6 +195,14 @@ Keyboard, native-selection, and condition-wait tools accept bounded declarative
193
195
 
194
196
  The browser is not an operating-system or network sandbox. Use a container or VM for hostile applications and dedicated test accounts instead of personal authenticated sessions.
195
197
 
198
+ ## Structural search
199
+
200
+ Structural search is disabled until explicitly selected. The owned `specpi/tool-integrations.json` file stores nonsecret enablement; plan does not mutate it, updates back it up and roll it back with managed state, and uninstall preserves it. Unparseable content does not abort the non-mutating plan and does not silently enable anything: install and update stop and name the file unless an explicit `--structural-search` selection rewrites it, and the prior bytes go to the operation backup first. A configuration that is a link or not a bounded regular file stays a hard failure that no selection rewrites. The extension itself treats any unreadable configuration as disabled. Runtime acquisition is private, pinned, script-free and transactional. Configuration output is checked against the same 16 KiB bound as reads before mutation, with compact JSON fallback. Doctor checks source/runtime integrity before its offline fixture; unverified or modified prior runtime directories are preserved rather than deleted. Runtime ownership now binds the acquired tree's contents, paths, directories, permissions and link targets; the tree fingerprint does not traverse links, and the marker is checked separately. Legacy binary-only markers remain unverified. Retirement rechecks the tree before deletion. Rollback quarantines the new runtime and restores the prior directory before attempting recursive cleanup, reporting any retained quarantine. No global executable search or PATH injection is used.
201
+
202
+ The parent captures explicit selected source with the existing snapshot path/private-store/link/descriptor/digest checks. A parent-only byte callback rechecks the original digest and clears its buffer after parsing. Existing workers gain no tool or byte API route. Files are capped at 1 MiB, selection at 8 MiB/200 files, pattern at 4 KiB, returned evidence at 24 KiB, raw stdout at 2 MiB and stderr at 8 KiB per call. Source bytes go to a reviewed native parser over stdin; only a neutral configuration is written to private scratch. Files containing secrets under ordinary source names are not automatically detectable.
203
+
204
+ Native execution uses a fixed argument array, `shell: false`, an absolute private binary, explicit neutral ast-grep configuration and minimal environment. No rewrite, rule import, custom grammar or arbitrary executable is exposed. Guard admission is source-bound and repeated before parsing; Strict uses exact-call approval, Off does not remove tool restrictions, and Locked/ambiguous state denies. Session/policy changes cancel queued work and invalidate pending authorization; approval dialogs receive the operation signal and remaining timeout. Pi 0.84.4 releases cancelled RPC approvals locally but does not send remote dismissal events; clients expire them using the forwarded timeout, and late replies cannot authorize cancelled work. A parser that does not confirm termination blocks further starts until cleanup and reload. These controls are not OS containment: trusted same-user native code and extensions retain host privileges.
205
+
196
206
  ## Website and automation
197
207
 
198
208
  The GitHub Pages workflow publishes the checked-in `site/` directory only after the shared browser test job succeeds for that checked-out revision. The loopback rendered-site server rejects traversal and symlink escapes. Tests block third-party requests, use a synthetic clipboard, and upload only public-site failure screenshots with three-day retention; no visual baselines are created automatically. Required browser commands fail rather than skip when prerequisites are unavailable. Strict no-emit browser type checking uses local pinned development declarations; third-party declaration bodies and other extension implementations are outside that scoped gate.
@@ -211,7 +221,7 @@ Chat allows up to 90 seconds for an initial read-only RPC readiness probe before
211
221
 
212
222
  The webview loads only packaged scripts/styles under a strict content security policy, forbids network resources, and renders model/tool/file text without interpreting embedded HTML. Images use validated data URLs under `img-src data:`; remote image URLs are never fetched by the webview. HTTP(S) links, including external image links, require a human click and pass through VS Code's external-link API. Clicked code references use a separate host action: paths are parsed, checked against the selected workspace, canonicalized, and restricted to regular non-sensitive files before VS Code opens the file and reveals a clamped line/range. Clicked workspace Markdown image references, and ordinary file links or inline references ending in PNG, JPEG, GIF or WebP extensions, use the same path boundary and the image validator before a preview is returned. The extension suffix chooses the viewer, not whether the file is safe or valid. Arbitrary command and URI handlers are not executed. Raw paths preserve literal percent signs; Markdown link destinations and explicit local file URLs are decoded once before filesystem validation.
213
223
 
214
- Text/code context requires an explicit file/selection action, is size-bounded, rejects binary and known sensitive paths, and checks canonical workspace containment. Workspace file suggestions enumerate a bounded set of names; choosing a suggestion invokes the same attachment validation before reading contents. Dropped workspace URIs are validated independently of their browser representation. Native image selection, clipboard images, and dropped image files are explicit attachment actions and may supply images from outside the selected workspace; filesystem image sources still require ordinary files and pass sensitive-path checks. Pi-state names such as `auth`, `trust`, `sessions`, `missions`, and `history` are reserved inside `.pi`, Chat-owned storage, and the configured Pi agent directory (including its canonical target), not arbitrary source directories. Global credential/key patterns remain blocked, including `auth.json`. Relative `PI_CODING_AGENT_DIR` overrides retain conservative name blocking because they resolve in the child workspace; use an absolute path or `~/…` to obtain directory-scoped filtering. These filename checks and format validators are not content-based secret detectors. Manually typed messages, ordinary files, and images may contain sensitive content, and the extension does not promise image metadata removal. Prompts and attachments are sent to the provider selected in Pi when the user sends them.
224
+ Text/code context comes from an explicit attachment action or the enabled editor-selection offer when the user sends a message. The offer carries positions and document version, not source text; send-time collection preserves the exact selected characters and rejects a changed document. Context is size-bounded, rejects binary and known sensitive paths, and checks canonical workspace containment. Workspace file suggestions enumerate a bounded set of names; choosing a suggestion invokes the same attachment validation before reading contents. Root `.gitignore` filtering uses the bounded 64 KiB UTF-8 attachment reader with canonical containment, private-path, hard-link, and identity checks, and refuses symlinks and special files before opening. An unsafe or unavailable ignore file stops the operation; it does not silently disable filtering. Glob matching uses dynamic programming with bounded work rather than backtracking glob regexes. Malformed character classes invalidate only their own rule. Folder attachments contain directory metadata only, capped at 200 displayed entries / 16 KiB and 1,000 scanned entries across the snapshot (including hidden entries), with an explicit truncation notice. These output/work bounds do not guarantee filesystem I/O latency. Dropped workspace URIs are validated independently of their browser representation. Native image selection, clipboard images, and dropped image files are explicit attachment actions and may supply images from outside the selected workspace; filesystem image sources still require ordinary files and pass sensitive-path checks. Pi-state names such as `auth`, `trust`, `sessions`, `missions`, and `history` are reserved inside `.pi`, Chat-owned storage, and the configured Pi agent directory (including its canonical target), not arbitrary source directories. Global credential/key patterns remain blocked, including `auth.json`. Relative `PI_CODING_AGENT_DIR` overrides retain conservative name blocking because they resolve in the child workspace; use an absolute path or `~/…` to obtain directory-scoped filtering. These filename checks and format validators are not content-based secret detectors. Manually typed messages, ordinary files, and images may contain sensitive content, and the extension does not promise image metadata removal. Prompts and attachments are sent to the provider selected in Pi when the user sends them.
215
225
 
216
226
  Image content is checked for supported PNG, JPEG, GIF, or WebP structure, declared MIME agreement, dimensions, and bounded base64 size. Each image is limited to 5 MiB, 16,384 pixels per side, and 40 million pixels. A prompt permits eight mixed attachments and 20 MiB of image bytes. The display retains at most 32 images / 20 MiB and reports omitted content; this is separate from Pi's persisted history. RPC records and buffered writes have independent 64 MiB caps. Recognized oversized history responses with a matching pending request are drained without accumulating the remaining bytes, then rejected without terminating Pi. The sidebar reports omitted history and remains usable; other oversized protocol records terminate the connection. A large historical transcript can exceed the RPC cap even when its original prompts were individually valid. Media caches remain in memory, are referenced by opaque identifiers while streaming, and are released as content is no longer retained. No remote image fetch, image processing service, or new image dependency is introduced. Pi/provider image limits and behavior remain upstream contracts.
217
227
 
package/THIRD_PARTY.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Third-party components
2
2
 
3
+ Structural search privately installs `@ast-grep/cli` **0.45.3**, its matching platform-specific native binary package, and `detect-libc` **2.1.2**, under the exact `structural-runtime/package-lock.json` graph. These packages use MIT licenses. Installation uses `npm ci --ignore-scripts`; SpecPi resolves the pinned platform binary directly and does not execute the CLI's postinstall copier, invoke a global `sg`, download a fallback or modify PATH. Source: [ast-grep](https://github.com/ast-grep/ast-grep). Windows x64, macOS x64/arm64 and Linux glibc x64 are the implemented platform mappings; CI and offline fixture smokes qualify the corresponding hosts.
4
+
5
+ Browser accessibility adds `@axe-core/playwright` **4.13.0** and `axe-core` **4.13.0** to the existing locked browser runtime, retaining Playwright **1.62.1**. Both Deque packages are MPL-2.0, remain unmodified, and retain their installed license/source notices. SpecPi's npm artifact ships dependency manifests/locks, not vendored scanner code; npm acquires the packages during confirmed browser setup. Source: [axe-core](https://github.com/dequelabs/axe-core), [Playwright integration](https://github.com/dequelabs/axe-core-npm). The fixed scanner runs in the existing isolated Chromium context, with no new external accessibility service.
6
+
3
7
  SpecPi Chat is a separate VS Code extension built with the public VS Code Webview View API and Pi `0.84.4`'s documented RPC protocol. VS Code and the installed Pi runtime retain their upstream licenses; neither is bundled in the VSIX. The extension and its ZIP/VSIX packager use Node built-ins and first-party source only, with no added runtime or development dependency. Its tests reuse the repository's pinned Playwright and Pi packages. Provider authentication, requests, billing, and normal Pi background behavior remain owned by the configured Pi runtime.
4
8
 
5
9
  Chat's provider Limits row recognizes the public RPC `setStatus` presentation contracts of `@llblab/pi-codex-usage` **0.9.3** (`aa-codex-usage`) and `@sreetej510/pi-usage` **0.9.9** (`provider-usage`). Public source was checked at commits `1a97ee5560c5d7dd3b99ce34e2dcfe456592f8a0` and `9dee3b590f736c723011e3bc3717b3f0d6e8072b`, respectively. Tests reproduce their status/notification contracts through synthetic local Pi RPC, not live accounts or upstream provider requests. Text retains each plugin's quota semantics rather than converting or aggregating measurements. Neither plugin is bundled in the VSIX; this compatibility change does not install pi-usage or change SpecPi's existing package pins. Plugins retain ownership of authentication, caching, refresh/backoff, and explicit reset-consumption confirmation.
@@ -9,6 +9,8 @@
9
9
  "version": "0.1.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
+ "@axe-core/playwright": "4.13.0",
13
+ "axe-core": "4.13.0",
12
14
  "pixelmatch": "7.2.0",
13
15
  "playwright": "1.62.1",
14
16
  "pngjs": "7.0.0"
@@ -17,6 +19,27 @@
17
19
  "node": ">=22.19.0"
18
20
  }
19
21
  },
22
+ "node_modules/@axe-core/playwright": {
23
+ "version": "4.13.0",
24
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
25
+ "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
26
+ "license": "MPL-2.0",
27
+ "dependencies": {
28
+ "axe-core": "~4.13.0"
29
+ },
30
+ "peerDependencies": {
31
+ "playwright-core": ">= 1.0.0"
32
+ }
33
+ },
34
+ "node_modules/axe-core": {
35
+ "version": "4.13.0",
36
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
37
+ "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
38
+ "license": "MPL-2.0",
39
+ "engines": {
40
+ "node": ">=4"
41
+ }
42
+ },
20
43
  "node_modules/fsevents": {
21
44
  "version": "2.3.2",
22
45
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -5,6 +5,8 @@
5
5
  "description": "Pinned local browser runtime managed by the SpecPi installer",
6
6
  "license": "MIT",
7
7
  "dependencies": {
8
+ "@axe-core/playwright": "4.13.0",
9
+ "axe-core": "4.13.0",
8
10
  "pixelmatch": "7.2.0",
9
11
  "playwright": "1.62.1",
10
12
  "pngjs": "7.0.0"
@@ -1,5 +1,17 @@
1
1
  # Browser application testing
2
2
 
3
+ ## Accessibility
4
+
5
+ `browser_accessibility` scans the already open isolated page using `@axe-core/playwright` and axe-core 4.13.0. Open the page, perform the relevant interaction, wait for the intended visible state, scan, repair and repeat the same scan. It does not launch a blank browser. Continue keyboard, focus and functional checks; automated results are not certification.
6
+
7
+ Optional input: `include` (CSS selector matching exactly one region), `profile` (`wcag22aa`, the default, or `best-practice` to add those checks), `maxFindings` (default 50, maximum 100 per category) and `timeoutMs` (default 15000, 1000–30000 including queueing). The WCAG profile uses `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` and `wcag22aa`. There are no custom scripts/checks, rule suppressions or exclusion lists. Invalid or ambiguous scope fails without broadening the scan.
8
+
9
+ Results separate violations and incomplete checks, identify profile/tags, scan ID/timestamps, sanitized location and viewport, and return total counts plus explicit truncation within 24 KiB. Navigation invalidates the scan. Dynamic content is not an atomic snapshot; inaccessible frames and closed shadow roots can limit coverage. No raw HTML/check payloads or automatic scan files are retained, but selectors/paths can contain sensitive data and normal results may remain in Pi history. Cancellation uses the existing browser teardown/recovery path.
10
+
11
+ The managed browser lock includes the scanner. Run `specpi update` without browser/package skip flags to provision it. Missing scanner packages affect accessibility availability; existing screenshots and browser interactions can still work. Installer/doctor extend their offline browser smoke with a known unnamed button and a corrected control, without changing the historical wishlist validator's default smoke. Real Chromium tests exercise label, contrast, ARIA and repaired dynamic states at desktop/tablet/mobile sizes.
12
+
13
+ The offline fixtures also verify unnamed controls in a same-origin iframe and an open shadow root. The closed-root control is not reported and remains an explicit coverage limitation; cross-origin frame coverage is not qualified by these fixtures. Queue expiry and cancellation discard the browser, and reopening restores scanning. These seeded-page results do not establish coverage of arbitrary applications or replace keyboard and assistive-technology review.
14
+
3
15
  ## Agent workflow
4
16
 
5
17
  1. `browser_open` opens an HTTP(S) page in isolated Chromium; `browser_snapshot` exposes bounded rendered text and namespaced control references.
@@ -47,7 +59,7 @@ npm run check:pi-package
47
59
 
48
60
  ### Scoped types
49
61
 
50
- `tsconfig.browser.json` checks exactly `extensions/browser/index.ts`, `diagnostics.ts`, `interactions.ts`, `lifecycle.ts`, and the `core.d.mts` boundary, with strict checking and no emit. It uses real pinned Pi, TypeBox, Playwright and Node declaration packages from development dependencies. Browser runtime imports remain lazy; Playwright imports in the extension are type-only. The `.mjs` image/runtime helper has narrow declarations backed by existing helper/runtime tests and an export-inventory regression; its JavaScript implementation is **not** fully type-checked. `skipLibCheck` skips third-party declarations, not first-party browser implementation errors. Other extensions remain syntax-checked, not advertised as type-checked. A negative fixture proves invalid key and Playwright API argument types fail without generated JavaScript.
62
+ `tsconfig.browser.json` checks browser `index.ts`, `accessibility.ts`, `diagnostics.ts`, `interactions.ts` and `lifecycle.ts`, plus the background and structural registration files and their narrow declaration boundaries, with strict checking and no emit. It uses real pinned Pi, TypeBox, Playwright and Node declarations. Browser runtime imports remain lazy; Playwright imports in the extension are type-only. The `.mjs` implementations have focused runtime tests and are **not** fully type-checked. `skipLibCheck` skips third-party declarations, not the selected first-party implementations. Other extensions remain syntax-checked, not advertised as type-checked. A negative fixture proves invalid key and Playwright API argument types fail without generated JavaScript.
51
63
 
52
64
  ### Rendered site
53
65
 
@@ -74,6 +74,6 @@ Cancellation revokes access and requests SDK abort. Slots remain held until SDK-
74
74
 
75
75
  Activation checks required public SDK capabilities, not an exact version allowlist. The installer retains its separate Pi 0.84.4 bootstrap pin. Restart after updating the runtime.
76
76
 
77
- Children use fresh Pi `ModelRuntime` instances with standard authentication/environment/models resolution and global transport/thinking settings. Project settings are not loaded. Parent model/thinking are explicit and checked through Pi's public APIs. Runtime-only authentication, selected extension-provider overrides, model headers, startup proxy configuration and mismatched model descriptors are rejected rather than silently approximated.
77
+ Children use fresh Pi `ModelRuntime` instances with standard authentication/environment/models resolution and global transport/thinking settings. Project settings are not loaded. Parent model/thinking are explicit and checked through Pi's public APIs. Runtime-only authentication, selected extension-provider overrides, unsupported model headers, startup proxy configuration and mismatched model descriptors are rejected rather than silently approximated. Pi's exact public Copilot client-identification headers are supported when independently reconstructed in the child; all other model headers remain unsupported. The reviewed values and matching rules are listed in [the security model](../../SECURITY_MODEL.md).
78
78
 
79
79
  Parent hooks, ephemeral settings and session affinity are **not inherited**. Keep delegation off if they are required on every request. Stable provider identity cannot certify an unchanged remote service. Trusted extensions still share Pi's privileged process; this is a trusted-local-filesystem contract, not an OS sandbox. See [SECURITY_MODEL.md](../../SECURITY_MODEL.md) for the authoritative boundaries and [THIRD_PARTY.md](../../THIRD_PARTY.md) for dependency compatibility.
@@ -17,8 +17,10 @@ fresh Pi `ModelRuntime`. The parent model and thinking level are passed explicit
17
17
  subject to Pi's clamping. Standard Pi authentication, environment and `models.json`
18
18
  resolution apply. Child transport/thinking budgets come from configured global settings;
19
19
  project settings are not loaded. Runtime-only authentication, selected extension-provider
20
- overrides, model-specific headers, startup proxy configuration and safe model-descriptor
21
- mismatches fail preflight. Parent request hooks,
20
+ overrides, unsupported model-specific headers, startup proxy configuration and safe model-descriptor
21
+ mismatches fail preflight. The exact public Copilot client-identification header set is
22
+ permitted only when independently reconstructed in the child, as specified in the
23
+ [security model](../../SECURITY_MODEL.md). Parent request hooks,
22
24
  ephemeral runtime settings, session affinity and ambient resources are not inherited.
23
25
 
24
26
  Command Guard is optional. Absent and Off states permit activation; an installed
@@ -0,0 +1,40 @@
1
+ # Structural search
2
+
3
+ `structural_search` is an opt-in, read-only ast-grep 0.45.3 tool. It matches code structures across formatting changes. It does not resolve types/references, edit code or replace literal search for comments, strings and filenames.
4
+
5
+ Enable it through the installer, then restart Pi:
6
+
7
+ ```sh
8
+ specpi plan --structural-search=on
9
+ specpi update --structural-search=on
10
+ ```
11
+
12
+ For a fresh installation use `install`. `--yes` confirms installation but does not opt into structural search by itself. Omission preserves the existing choice. `--skip-package-install` and `--skip-tool-install` skip acquisition without disabling an existing runtime. Direct `pi install npm:specpi` does not provision the runtime; use the SpecPi installer for setup.
13
+
14
+ ```json
15
+ {
16
+ "language": "typescript",
17
+ "pattern": "pi.registerTool($OPTIONS)",
18
+ "paths": ["extensions/browser/index.ts"],
19
+ "maxResults": 50,
20
+ "timeoutMs": 10000
21
+ }
22
+ ```
23
+
24
+ JavaScript (`.js`, `.mjs`, `.cjs`), JSX, TypeScript (`.ts`, `.mts`, `.cts`), TSX and Python are supported. Select explicit relative files under the active working root, using ordinary discovery tools first. Directories, globs, custom grammars/configuration and rewriting are unavailable. A pattern with multiple independent statements may be rejected; syntax recovery can also accept malformed patterns, so inspect matches and retain project-native checks.
25
+
26
+ Limits are 200 files, 8 MiB of selected source in aggregate, 1 MiB per file, 6 KiB encoded path selection, 4 KiB pattern, 100 returned matches, 512 bytes per snippet and 24 KiB per result. Selection metadata must fit half the result budget; narrow long path lists when necessary. The default deadline is 10 seconds, configurable from 1–30 seconds including queueing and approval. There is one active parser and at most one queued request. Raw parser output is capped at 2 MiB, diagnostics at 8 KiB per call. Cleanup may take one additional second; unconfirmed cleanup blocks further parser starts until resolved and the extension is reloaded.
27
+
28
+ Results include source digests, searched/requested counts, ranges, engine version and explicit truncation. Lines and UTF-8 byte columns are one-based; end positions are exclusive. Columns are calculated from original source bytes, not the CLI's character-oriented display columns. A partial, timed-out, cancelled or unavailable result is not proof that other matches do not exist. Captured on-disk bytes exclude unsaved editor buffers and are not an atomic filesystem snapshot.
29
+
30
+ The parser receives selected source over stdin, a fixed argument list, a neutral configuration and a minimal environment. It runs from private temporary scratch containing configuration only. Source and results are not written to that scratch. The existing selected-source boundary rejects private namespaces, credentials, path escapes, links and hardlinks. It cannot detect secrets embedded in ordinary source. Returned snippets can persist in normal Pi conversation history.
31
+
32
+ Guard and Off permit these bounded reads; Strict asks for the exact call. The approval receives the operation cancellation signal and remaining timeout. Terminal prompts dismiss on cancellation; Pi 0.84.4 releases cancelled RPC approvals server-side but sends no remote dismissal event, so clients may display them until the forwarded timeout expires. Late answers cannot authorize the cancelled call. Locked or ambiguous Guard state denies. If Guard is absent, the same source/input limits remain. Policy, root and enablement are rechecked before parsing. A denial must not be retried through a different tool. Existing read-only workers do not gain structural-search access.
33
+
34
+ Enablement lives in `<agentDir>/specpi/tool-integrations.json`. The installer preserves unrelated fields, backs up the owned file before changes and rolls it back on failure. Serialized configuration must also fit the 16 KiB reader limit: the installer uses compact JSON when needed and rejects output that still exceeds the limit before changing configuration or runtimes. If that file becomes unparseable, `plan` reports it and continues without mutating anything, while `install` and `update` stop and name the file; repair or remove it, or pass `--structural-search=on` or `--structural-search=off` to rewrite it from the backed-up original. A configuration that is a link or not a bounded regular file remains a hard failure that no selection rewrites. The private runtime has an exact npm lockfile, script-free acquisition and binary/full-tree checksums; no global PATH entry is added. Doctor checks integrity and runs an offline fixture smoke. The initial native platforms are Windows x64, macOS x64/arm64 and Linux glibc x64; other combinations report unavailable.
35
+
36
+ Disable with `specpi update --structural-search=off`, then restart Pi. Intact owned runtimes are removed transactionally. Modified/unverified runtimes are preserved with a warning; replacement preserves them outside the active runtime path. The tree check covers file contents, paths, directories, permissions and link targets without traversing links, and is repeated before retired-tree deletion. Older ownership markers without a tree checksum are unverified and preserved. Rollback quarantines the new runtime and restores the prior directory before recursive cleanup; a cleanup failure reports the retained quarantine path. Uninstall preserves enablement configuration and user evidence. Trusted native executables still run with the user's permissions; these controls are not an OS sandbox.
37
+
38
+ Development verification uses `npm run setup:structural` followed by `npm run test:structural`. This installs only the locked native test runtime in `.specpi-test/structural-runtime/`. Installer tests use disposable Pi directories and skip external acquisition.
39
+
40
+ Six synthetic lookup trials on Windows x64 / Node 24.18.0 on September 9, 2026 covered call sites, constructors, filesystem calls, JSX/TSX components and Python calls. Each fixture contained two valid structures with different formatting plus one comment/string decoy. All 12 expected matches were found with no false positives; each response was 669–707 bytes and took 32–48 ms in that single run. A simple fixed-string `rg` query per fixture found six valid matches, missed six formatting variants and returned six decoys (37–85 bytes, 24–33 ms per query). This demonstrates the intended syntax-sensitive use case, not superiority over carefully constructed regex/manual inspection or measured agent productivity. Literal search remains faster and smaller in these trials; structural search remains opt-in.
@@ -0,0 +1,155 @@
1
+ import { createRequire } from "node:module";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import type { Page } from "playwright";
6
+ import { StringEnum } from "@earendil-works/pi-ai";
7
+ import { Type } from "typebox";
8
+ import { sanitizeDiagnostic, sanitizeUrl } from "./diagnostics.ts";
9
+
10
+ export const ACCESSIBILITY_VERSION = "4.13.0";
11
+ export const MAX_ACCESSIBILITY_BYTES = 24 * 1024;
12
+ export class AccessibilityError extends Error {}
13
+ const tags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
14
+ export const AccessibilityParams = Type.Object(
15
+ {
16
+ include: Type.Optional(Type.String({ minLength: 1, maxLength: 1000 })),
17
+ profile: Type.Optional(StringEnum(["wcag22aa", "best-practice"] as const)),
18
+ maxFindings: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
19
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, maximum: 30000 })),
20
+ },
21
+ { additionalProperties: false },
22
+ );
23
+ type Finding = {
24
+ id: string;
25
+ impact?: string | null;
26
+ help: string;
27
+ helpUrl: string;
28
+ nodes: Array<{ target: unknown }>;
29
+ };
30
+ type Analysis = {
31
+ testEngine: { version: string };
32
+ violations: Finding[];
33
+ incomplete: Finding[];
34
+ passes: unknown[];
35
+ inapplicable: unknown[];
36
+ };
37
+ type Options = { include?: string; profile?: "wcag22aa" | "best-practice"; maxFindings?: number };
38
+ type Builder = { withTags(tags: string[]): Builder; include(selector: string): Builder; analyze(): Promise<Analysis> };
39
+
40
+ export function reduceAccessibility(raw: Analysis, maxFindings = 50) {
41
+ const severity = ["critical", "serious", "moderate", "minor", "unknown"];
42
+ // Rank an unrecognized impact as "unknown" so ordering matches the value that is reported.
43
+ const rank = (impact?: string | null) => {
44
+ const index = severity.indexOf(impact ?? "unknown");
45
+
46
+ return index === -1 ? severity.indexOf("unknown") : index;
47
+ };
48
+
49
+ const reduce = (findings: Finding[]) =>
50
+ [...findings]
51
+ .sort((a, b) => rank(a.impact) - rank(b.impact) || a.id.localeCompare(b.id))
52
+ .slice(0, maxFindings)
53
+ .map((finding) => ({
54
+ rule: sanitizeDiagnostic(finding.id, 100),
55
+ impact: severity.includes(finding.impact ?? "") ? finding.impact : "unknown",
56
+ guidance: sanitizeDiagnostic(finding.help, 300),
57
+ helpUrl: sanitizeUrl(finding.helpUrl),
58
+ affectedNodes: finding.nodes.length,
59
+ targets: finding.nodes.slice(0, 3).map((node) => sanitizeDiagnostic(JSON.stringify(node.target), 200)),
60
+ }));
61
+ const result = {
62
+ violations: reduce(raw.violations),
63
+ incomplete: reduce(raw.incomplete),
64
+ totals: {
65
+ violations: raw.violations.length,
66
+ incomplete: raw.incomplete.length,
67
+ passes: raw.passes.length,
68
+ inapplicable: raw.inapplicable.length,
69
+ },
70
+ truncated: false,
71
+ };
72
+ // Reserve space for page/state metadata. Keep both categories represented.
73
+ while (Buffer.byteLength(JSON.stringify(result)) > MAX_ACCESSIBILITY_BYTES - 4096) {
74
+ const list = result.violations.length >= result.incomplete.length ? result.violations : result.incomplete;
75
+ if (!list.length) {
76
+ break;
77
+ }
78
+
79
+ list.pop();
80
+ }
81
+
82
+ result.truncated =
83
+ result.violations.length < raw.violations.length || result.incomplete.length < raw.incomplete.length;
84
+
85
+ return result;
86
+ }
87
+
88
+ export async function scanAccessibility(page: Page, runtimeDir: string, options: Options = {}) {
89
+ let BuilderClass: new (options: { page: Page }) => Builder;
90
+ try {
91
+ const require = createRequire(path.join(runtimeDir, "package.json"));
92
+ if (
93
+ require("axe-core/package.json").version !== ACCESSIBILITY_VERSION ||
94
+ JSON.parse(
95
+ fs.readFileSync(
96
+ path.join(runtimeDir, "node_modules", "@axe-core", "playwright", "package.json"),
97
+ "utf8",
98
+ ),
99
+ ).version !== ACCESSIBILITY_VERSION
100
+ ) {
101
+ throw new Error("Unexpected scanner version");
102
+ }
103
+
104
+ BuilderClass = require("@axe-core/playwright").default;
105
+ } catch {
106
+ throw new AccessibilityError(
107
+ "Accessibility scanner unavailable. Run specpi update without --skip-browser-install.",
108
+ );
109
+ }
110
+
111
+ const scanId = crypto.randomUUID();
112
+ let navigated = false;
113
+ const onNavigation = () => {
114
+ navigated = true;
115
+ };
116
+
117
+ page.on("framenavigated", onNavigation);
118
+ const startedAt = new Date().toISOString();
119
+ try {
120
+ if (options.include && (await page.locator(`css=${options.include}`).count()) !== 1) {
121
+ throw new AccessibilityError("Accessibility scope must match exactly one region.");
122
+ }
123
+
124
+ const effectiveTags = options.profile === "best-practice" ? [...tags, "best-practice"] : [...tags];
125
+ const builder = new BuilderClass({ page }).withTags(effectiveTags);
126
+ if (options.include) {
127
+ builder.include(options.include);
128
+ }
129
+
130
+ const result = await builder.analyze();
131
+ if (navigated || page.isClosed()) {
132
+ throw new AccessibilityError(
133
+ "Accessibility scan became stale during navigation. Scan the current state again.",
134
+ );
135
+ }
136
+
137
+ return {
138
+ status: "complete",
139
+ scanId,
140
+ scannerVersion: result.testEngine.version,
141
+ url: sanitizeUrl(page.url()),
142
+ viewport: page.viewportSize(),
143
+ scope: options.include ? sanitizeDiagnostic(options.include, 500) : "page",
144
+ profile: options.profile ?? "wcag22aa",
145
+ tags: effectiveTags,
146
+ startedAt,
147
+ finishedAt: new Date().toISOString(),
148
+ ...reduceAccessibility(result, options.maxFindings),
149
+ limitations:
150
+ "Automated checks of the observed DOM state only; dynamic changes, inaccessible frames and closed shadow roots may limit coverage. Incomplete checks need review. A clean result is not accessibility certification.",
151
+ };
152
+ } finally {
153
+ page.off("framenavigated", onNavigation);
154
+ }
155
+ }
@@ -6,6 +6,7 @@ import type { Browser, BrowserContext, Page } from "playwright";
6
6
  import { Check } from "typebox/value";
7
7
  import { BrowserDiagnostics, DIAGNOSTIC_CATEGORIES } from "./diagnostics.ts";
8
8
  import { BrowserCleanupError, settleBrowserCleanup } from "./lifecycle.ts";
9
+ import { AccessibilityError, AccessibilityParams, scanAccessibility } from "./accessibility.ts";
9
10
  import {
10
11
  PressParams,
11
12
  SelectionParams,
@@ -417,9 +418,12 @@ export default function browserExtension(pi: ExtensionAPI) {
417
418
  throw new Error("Invalid browser tool parameters.");
418
419
  }
419
420
 
420
- const bounded = ["browser_press", "browser_select_option", "browser_wait_for"].includes(
421
- definition.name,
422
- );
421
+ const bounded = [
422
+ "browser_press",
423
+ "browser_select_option",
424
+ "browser_wait_for",
425
+ "browser_accessibility",
426
+ ].includes(definition.name);
423
427
  const controller = new AbortController();
424
428
  const originalSignal = args[2];
425
429
  const signal = AbortSignal.any([
@@ -430,7 +434,10 @@ export default function browserExtension(pi: ExtensionAPI) {
430
434
  const timer = bounded
431
435
  ? setTimeout(
432
436
  () => controller.abort(),
433
- interactionTimeout((args[1] as { timeoutMs?: number }).timeoutMs),
437
+ interactionTimeout(
438
+ (args[1] as { timeoutMs?: number }).timeoutMs ??
439
+ (definition.name === "browser_accessibility" ? 15000 : undefined),
440
+ ),
434
441
  )
435
442
  : undefined;
436
443
  try {
@@ -506,6 +513,35 @@ export default function browserExtension(pi: ExtensionAPI) {
506
513
  }
507
514
  }
508
515
 
516
+ register({
517
+ name: "browser_accessibility",
518
+ label: "Browser Accessibility",
519
+ description:
520
+ "Check the current isolated browser page for automated accessibility violations and incomplete checks. Open and interact with the page first; this does not certify accessibility.",
521
+ parameters: AccessibilityParams,
522
+ async execute(_id, params, signal) {
523
+ if (!state.page || state.page.isClosed()) {
524
+ throw new Error("Open a page with browser_open before running accessibility checks.");
525
+ }
526
+
527
+ try {
528
+ const result = await cancellable(() => scanAccessibility(state.page!, runtimeDir, params), signal);
529
+
530
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
531
+ } catch (error) {
532
+ if (error instanceof BrowserCleanupError || signal?.aborted) {
533
+ throw error;
534
+ }
535
+
536
+ if (error instanceof AccessibilityError) {
537
+ throw error;
538
+ }
539
+
540
+ throw new Error("Accessibility analysis failed. Check the page, scope and runtime, then retry.");
541
+ }
542
+ },
543
+ });
544
+
509
545
  register({
510
546
  name: "browser_open",
511
547
  label: "Browser Open",
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { createRequire } from "node:module";
5
6
  import { comparePngBuffers, loadBrowserRuntime } from "./core.mjs";
6
7
 
7
8
  const runtimeDir = process.argv[2];
@@ -40,7 +41,34 @@ try {
40
41
  throw new Error("Changed browser visual comparison was not detected.");
41
42
  }
42
43
 
44
+ if (process.argv.includes("--accessibility")) {
45
+ const require = createRequire(path.join(path.resolve(runtimeDir), "package.json"));
46
+ const AxeBuilder = require("@axe-core/playwright").default;
47
+ if (require("axe-core/package.json").version !== "4.13.0") {
48
+ throw new Error("Accessibility runtime version mismatch.");
49
+ }
50
+
51
+ await page.setContent(
52
+ '<!doctype html><html lang="en"><title>Fixture</title><main><button></button></main></html>',
53
+ );
54
+ const broken = await new AxeBuilder({ page }).withTags(["wcag2a"]).analyze();
55
+ if (!broken.violations.some((finding) => finding.id === "button-name")) {
56
+ throw new Error("Accessibility smoke missed an unnamed button.");
57
+ }
58
+
59
+ await page.setContent(
60
+ '<!doctype html><html lang="en"><title>Fixture</title><main><button>Save</button></main></html>',
61
+ );
62
+ const fixed = await new AxeBuilder({ page }).withTags(["wcag2a"]).analyze();
63
+ if (fixed.violations.some((finding) => finding.id === "button-name")) {
64
+ throw new Error("Accessibility smoke did not recognize the repair.");
65
+ }
66
+ }
67
+
43
68
  console.log(`Browser smoke passed: ${baseline.length} byte PNG; exact and changed visual comparisons verified`);
69
+ if (process.argv.includes("--accessibility")) {
70
+ console.log("ACCESSIBILITY_SMOKE=passed");
71
+ }
44
72
  } finally {
45
73
  await browser?.close().catch(() => {});
46
74
  await fs.rm(tempDir, { recursive: true, force: true });
@@ -185,7 +185,20 @@ export default function registerCommandGuard(
185
185
  state.onModeChanged = () => pi.events?.emit("specpi:guard-policy-changed", { reason: "guard policy changed" });
186
186
  let backgroundSubscription: (() => void) | undefined;
187
187
  let guardStateSubscription: (() => void) | undefined;
188
+ let structuralSubscription: (() => void) | undefined;
188
189
  const subscribeGuardState = () => {
190
+ structuralSubscription ??= pi.events?.on?.("specpi:structural-admission", (request: any) => {
191
+ request?.reply?.({
192
+ mode: state.mode,
193
+ generation: state.generation,
194
+ action:
195
+ !state.ready || state.startupFailed || state.mode === "locked"
196
+ ? "deny"
197
+ : state.mode === "strict"
198
+ ? "ask"
199
+ : "allow",
200
+ });
201
+ });
189
202
  if (!backgroundSubscription) {
190
203
  backgroundSubscription = pi.events?.on?.("specpi:background-admission", (request: any) => {
191
204
  if (typeof request?.reply !== "function") {
@@ -240,6 +253,23 @@ export default function registerCommandGuard(
240
253
 
241
254
  subscribeGuardState();
242
255
  const backgroundSource = fileURLToPath(new URL("../background-tasks/index.ts", import.meta.url));
256
+ const structuralSource = fileURLToPath(new URL("../structural-search/index.ts", import.meta.url));
257
+ const ownsStructuralTool = (name: string): boolean => {
258
+ if (name !== "structural_search") {
259
+ return false;
260
+ }
261
+
262
+ const matches = pi.getAllTools?.().filter((tool) => tool.name === name) ?? [];
263
+ const source = matches.length === 1 ? matches[0].sourceInfo?.path : undefined;
264
+ if (typeof source !== "string" || !path.isAbsolute(source)) {
265
+ return false;
266
+ }
267
+
268
+ return process.platform === "win32"
269
+ ? path.resolve(source).toLowerCase() === path.resolve(structuralSource).toLowerCase()
270
+ : path.resolve(source) === path.resolve(structuralSource);
271
+ };
272
+
243
273
  const ownsBackgroundTool = (name: string): boolean => {
244
274
  if (!["background_start", "background_list", "background_logs", "background_stop"].includes(name)) {
245
275
  return false;
@@ -352,6 +382,8 @@ export default function registerCommandGuard(
352
382
  reset();
353
383
  backgroundSubscription?.();
354
384
  backgroundSubscription = undefined;
385
+ structuralSubscription?.();
386
+ structuralSubscription = undefined;
355
387
  if (typeof guardStateSubscription === "function") {
356
388
  guardStateSubscription();
357
389
  guardStateSubscription = undefined;
@@ -513,7 +545,7 @@ export default function registerCommandGuard(
513
545
  // Check Pi's current registration provenance, not a tool name or cached handshake.
514
546
  // Genuine background tools validate again in execute; cleanup remains usable under a lock.
515
547
  // A missing or replaced registration retains normal Strict/locked enforcement.
516
- if (ownsBackgroundTool(name)) {
548
+ if (ownsBackgroundTool(name) || ownsStructuralTool(name)) {
517
549
  return;
518
550
  }
519
551