diff --git a/.changeset/global-provider-policy.md b/.changeset/global-provider-policy.md
new file mode 100644
index 0000000..17c6325
--- /dev/null
+++ b/.changeset/global-provider-policy.md
@@ -0,0 +1,7 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Require Pi Coding Agent `>=0.81.1 <0.82` and build an immutable provider baseline at session-daemon startup. Globally installed Pi extensions can register both config-form and native providers during startup bootstrap; every later Pi extension registration or unregistration—including global replay, project same-ID replacement, lifecycle callbacks, and `/reload`—is ignored. PI WEB browser plugins are a separate browser-only system and are unaffected. Non-provider Pi extension features still work, and ignored calls are de-duplicated in session-daemon logs by operation/provider ID without logging provider configuration or credentials or creating session warnings/notifications.
+
+After updating PI WEB, or after installing, removing, or updating a globally installed Pi extension that registers providers, manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the provider baseline.
diff --git a/README.md b/README.md
index ddfdff1..0127950 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ Requirements:
- Node.js 22.19.0 or newer
- npm
-- Pi Coding Agent `>=0.80.8 <0.81`, configured for your user
+- Pi Coding Agent `>=0.81.1 <0.82`, configured for your user
- git and the development tools your agents need
Install and start PI WEB as per-user services:
@@ -108,15 +108,13 @@ When a remote machine is selected, Settings tabs label their target. Pi packages
Read more: [Fleet and machines guide](https://pi-web.dev/machines)
-## Plugins
+## PI WEB plugins
-PI WEB supports trusted browser-side PI WEB plugins that can add actions, workspace panels, and workspace metadata.
+PI WEB supports trusted browser-side plugins that can add actions, workspace panels, and workspace metadata. Use **Settings → PI WEB plugins** to enable or disable them on the selected machine.
-Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine and labels where installs, updates, or removals will run. Use **Settings → PI WEB plugins** to enable or disable discovered browser plugins on the selected machine.
+Pi packages are a separate Pi package-manager concept. A Pi package may include a PI WEB browser plugin, but installing a package and enabling its browser plugin are different operations.
-After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session on that machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins.
-
-Read more: [Plugin API](https://pi-web.dev/plugins)
+Read more: [PI WEB plugin guide and API](https://pi-web.dev/plugins)
## Configuration
diff --git a/docs/config.html b/docs/config.html
index a837f2a..f814d69 100644
--- a/docs/config.html
+++ b/docs/config.html
@@ -99,6 +99,7 @@
External path accessManual uploadsAgent profile and companion CLI
+ Pi extension providersSession toolsCompletion tools
@@ -175,7 +176,7 @@
pathAccess: applies on the next request; existing file views may need a browser refresh.
uploads.defaultFolder: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
plugins: reload the browser tab after changing PI WEB plugin enablement.
-
Pi package install/remove/update: not a PI WEB config key; after a mutation, type /reload in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.
+
Pi package install/remove/update: not a PI WEB config key; after a mutation, type /reload in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for PI WEB browser plugin changes. If a global Pi extension adds, removes, or changes a provider, manually restart pi-web-sessiond.service; /reload cannot change the startup provider baseline. See Pi extension provider baseline.
shortcuts: saved settings apply in the browser after config refresh/save.
@@ -347,7 +348,7 @@
PI_WEB_AGENT_DIR (PI_CODING_AGENT_DIR for Pi compatibility)
Global/session daemon
Not supported locally
-
Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins
+
Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugins
Agent can spawn sessions
@@ -583,12 +584,12 @@
secret-free active profile stays fixed for the daemon lifetime. Settings → Session daemon
saves command and directory together as desired configuration and shows whether the profile is active,
needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations,
- package-backed plugin discovery, status/install detection, and update planning continue to use the
- daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the
- newly saved values.
+ Pi-package-backed PI WEB plugin discovery, status/install detection, and update planning continue to use
+ the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying
+ the newly saved values.
- If the session daemon cannot report a valid active profile, profile-dependent package and plugin
+ If the session daemon cannot report a valid active profile, profile-dependent Pi package and PI WEB plugin
operations report unavailable instead of falling back to independently resolved config. A package-managed
update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi
companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised
@@ -597,6 +598,50 @@
+
+
Pi extension provider baseline
+
+ This policy applies to Pi runtime extensions, not PI WEB browser plugins. Pi extensions
+ are runtime modules loaded by the session daemon and can call pi.registerProvider(...);
+ PI WEB plugins are browser-side UI modules and never run in the session daemon.
+
+
+ PI WEB shares one model runtime across all sessions. When the session daemon starts, before any project
+ resources load, it initializes global Pi extensions from the active agent profile
+ (agent.dir), including extensions supplied by globally configured Pi packages. Provider
+ registrations made by synchronous or awaited asynchronous extension factories during this bootstrap join
+ the shared baseline. PI WEB captures both config-form registrations
+ (pi.registerProvider("id", config)) and native-provider registrations
+ (pi.registerProvider(provider)), alongside Pi built-ins, environment credentials, and
+ providers from the active agent directory's models.json.
+
+
+ After startup capture, every later Pi extension provider registration, native registration, and
+ unregistration is a no-op, regardless of source or provider ID. This includes global extensions replayed
+ while sessions load, project extensions attempting to add or replace a provider, same-ID replacement or
+ unregistration, lifecycle callbacks such as session_start, and /reload. The
+ captured provider stays unchanged while non-provider Pi extension features continue to load and reload
+ normally.
+
+
+ Ignored mutations are written to the session-daemon log once per operation and provider ID. The log entry
+ contains no provider configuration or credentials, and PI WEB does not show a session warning or
+ notification. This prevents accidental provider, configuration, or credential contamination between
+ projects; it is not a security boundary because Pi extensions remain trusted daemon code.
+
+
+ Configure providers before the daemon starts: use the active agent directory's
+ models.json, or install the Pi extension globally in that agent profile. Project Pi extensions
+ and project-level models.json files cannot add providers to PI WEB's shared baseline.
+
+
+ Restart required: after updating PI WEB—or after installing, removing, or updating a
+ global Pi extension that registers providers—manually restart pi-web-sessiond.service
+ (systemctl --user restart pi-web-sessiond). Restarting only the web/API service and running
+ /reload do not rebuild the baseline.
+
+
+
Session daemon tools
spawnSessions
diff --git a/docs/config.md b/docs/config.md
index 61b70b1..90b9d85 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -43,7 +43,7 @@ Process restarts depend on the key:
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
-- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.
+- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for PI WEB browser plugin changes. If a global Pi extension adds, removes, or changes a provider, manually restart `pi-web-sessiond.service`; `/reload` cannot change the startup provider baseline. See [Pi extension provider baseline](#pi-extension-provider-baseline).
- `shortcuts`: saved settings apply in the browser after config refresh/save.
## Global config example
@@ -113,7 +113,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
| Companion CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks |
-| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins |
+| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugins |
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
| Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
@@ -202,9 +202,21 @@ An alternate command always requires an explicit state directory. The command mu
Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the companion CLI, `PI_WEB_AGENT_DIR` sets the profile state directory, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. The legacy `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` names apply only to a canonical Pi companion command; PI WEB never derives ambient environment-variable names from an arbitrary command. Use the explicit `PI_WEB_AGENT_*` names for alternate commands. `PI_WEB_AGENT_DIR` is an unconditional override, while a legacy `PI_CODING_AGENT_DIR` override stops applying when Settings selects an alternate command so the command and directory can transition together.
-The session daemon resolves the persisted desired values plus its environment once at startup. That secret-free active profile stays fixed for the daemon lifetime. **Settings → Session daemon** saves command and directory together as desired configuration and shows whether the profile is active, needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations, package-backed plugin discovery, status/install detection, and update planning continue to use the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the newly saved values.
+The session daemon resolves the persisted desired values plus its environment once at startup. That secret-free active profile stays fixed for the daemon lifetime. **Settings → Session daemon** saves command and directory together as desired configuration and shows whether the profile is active, needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations, Pi-package-backed PI WEB plugin discovery, status/install detection, and update planning continue to use the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the newly saved values.
-If the session daemon cannot report a valid active profile, profile-dependent package and plugin operations report unavailable instead of falling back to independently resolved config. A package-managed update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised support, and the gateway rejects a remote save if the target does not return the requested profile. Restart the session daemon on the selected machine to establish the next active profile.
+If the session daemon cannot report a valid active profile, profile-dependent Pi package and PI WEB plugin operations report unavailable instead of falling back to independently resolved config. A package-managed update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised support, and the gateway rejects a remote save if the target does not return the requested profile. Restart the session daemon on the selected machine to establish the next active profile.
+
+### Pi extension provider baseline
+
+This policy applies to **Pi runtime extensions**, not PI WEB browser plugins. Pi extensions are runtime modules loaded by the session daemon and can call `pi.registerProvider(...)`; PI WEB plugins are browser-side UI modules and never run in the session daemon.
+
+PI WEB shares one model runtime across all sessions. When the session daemon starts, before any project resources load, it initializes global Pi extensions from the active agent profile (`agent.dir`), including extensions supplied by globally configured Pi packages. Provider registrations made by synchronous or awaited asynchronous extension factories during this bootstrap join the shared baseline. PI WEB captures both config-form registrations (`pi.registerProvider("id", config)`) and native-provider registrations (`pi.registerProvider(provider)`), alongside Pi built-ins, environment credentials, and providers from the active agent directory's `models.json`.
+
+After startup capture, every later Pi extension provider registration, native registration, and unregistration is a no-op, regardless of source or provider ID. This includes global extensions replayed while sessions load, project extensions attempting to add or replace a provider, same-ID replacement or unregistration, lifecycle callbacks such as `session_start`, and `/reload`. The captured provider stays unchanged while non-provider Pi extension features continue to load and reload normally.
+
+Ignored mutations are written to the session-daemon log once per operation and provider ID. The log entry contains no provider configuration or credentials, and PI WEB does not show a session warning or notification. This prevents accidental provider, configuration, or credential contamination between projects; it is not a security boundary because Pi extensions remain trusted daemon code.
+
+Configure providers before the daemon starts: use the active agent directory's `models.json`, or install the Pi extension globally in that agent profile. Project Pi extensions and project-level `models.json` files cannot add providers to PI WEB's shared baseline. After updating PI WEB—or after installing, removing, or updating a global Pi extension that registers providers—manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the baseline.
### Session daemon tools
diff --git a/docs/index.html b/docs/index.html
index b79e41e..5debb5f 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -38,7 +38,7 @@
"downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web",
"codeRepository": "https://github.com/jmfederico/pi-web",
"description": "PI WEB is a web UI for Pi Coding Agent that keeps persistent agent sessions running in real workspaces on your machine or server.",
- "softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent >=0.80.8 <0.81",
+ "softwareRequirements": "Node.js 22.19.0 or newer and Pi Coding Agent >=0.81.1 <0.82",
"license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE"
}
diff --git a/docs/install.html b/docs/install.html
index c4985fc..9373721 100644
--- a/docs/install.html
+++ b/docs/install.html
@@ -107,7 +107,7 @@
Requirements
Node.js 22.19.0 or newer and npm.
-
Pi Coding Agent >=0.80.8 <0.81 installed/configured so the pi command works for your user.
+
Pi Coding Agent >=0.81.1 <0.82 installed/configured so the pi command works for your user.
A shell login environment that exposes Node, npm, Pi, git, and any tools your agents need.
For the automatic installer: a supported per-user service manager.
- Pi packages are packages managed by Pi (pi install, pi remove,
- pi update). A Pi package can provide extensions, skills, prompt templates, themes,
- context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
+ Pi packages are distribution bundles managed by Pi (pi install,
+ pi remove, pi update). A Pi package can provide Pi extensions, skills, prompt
+ templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not
+ include a PI WEB plugin.
+
+
+ Pi extensions are runtime modules loaded by the session daemon. They can register Pi
+ tools, hooks, commands, and model providers. They are not PI WEB plugins.
PI WEB plugins are browser-side UI modules discovered from bundled, local, dev, and
- installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing,
- removing, or updating a Pi package is a Pi package-manager task.
+ installed Pi-package sources. They cannot register model providers or server-side hooks. Enabling or
+ disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a
+ separate Pi package-manager task.
Use Settings → Pi packages to view configured Pi packages or install/remove/update a
@@ -156,11 +162,15 @@
before the browser imports them. In a federated setup, this plugin enablement surface targets the
currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB
server does not advertise selected-machine settings support, PI WEB reports the plugin settings as
- unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or
- updating a Pi package, type /reload in each idle PI WEB session on the target machine to
- refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system
- prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed
- PI WEB browser plugins. A routine session daemon restart is not required.
+ unsupported or unavailable instead of silently falling back to the gateway.
+
+
+ After installing, removing, or updating a Pi package, type /reload in each idle PI WEB
+ session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt
+ templates, themes, and context/system prompt files. Reload the browser page separately for newly
+ discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate
+ daemon-start policy; see
+ Pi extension provider baseline.
@@ -329,17 +339,18 @@ After editing, check the manifest endpoint and browser-console failure cases.
Manage PI WEB plugins
- Open Settings → PI WEB plugins to review discovered bundled, local, dev, and Pi package
- plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin
- list; when a remote machine is selected, the list comes from that remote PI WEB server and includes
+ Open Settings → PI WEB plugins to review discovered bundled, local, dev, and
+ Pi-package-supplied PI WEB plugins for the selected PI WEB machine. When the local machine is selected,
+ this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB
+ server and includes
disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before
the browser imports it. Core app contributions such as the command palette, base workspace tools, and
themes are not managed through this plugin list.
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may
- provide plugins or other Pi resources, use Settings → Pi packages. In a federated setup,
- both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement
+ provide PI WEB plugins or other Pi resources, use Settings → Pi packages. In a federated
+ setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement
still writes the PI WEB plugins config key rather than changing Pi package-manager settings.
diff --git a/docs/plugins.md b/docs/plugins.md
index cbcfcd2..cb4a988 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -13,17 +13,21 @@ Plugins can currently:
They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed.
-## Pi packages vs PI WEB plugins
+## Pi packages, Pi extensions, and PI WEB plugins
-**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
+**Pi packages** are distribution bundles managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide Pi extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
-**PI WEB plugins** are browser-side PI WEB UI modules discovered from bundled, local, dev, and installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a Pi package-manager task.
+**Pi extensions** are runtime modules loaded by the session daemon. They can register Pi tools, hooks, commands, and model providers. They are not PI WEB plugins.
+
+**PI WEB plugins** are browser-side UI modules discovered from bundled, local, dev, and installed Pi-package sources. They cannot register model providers or server-side hooks. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a separate Pi package-manager task.
Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install `, and does not ask for an install location.
When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes, PI WEB reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway.
-Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.
+Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway.
+
+After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate daemon-start policy; see [Pi extension provider baseline](https://pi-web.dev/config#pi-extension-provider-baseline).
## Trust model
@@ -169,9 +173,9 @@ If a remote plugin constructs absolute asset URLs, it should use the `pluginId`
## Manage PI WEB plugins
-Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
+Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi-package-supplied PI WEB plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
-This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB `plugins` config key rather than changing Pi package-manager settings.
+This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide PI WEB plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB `plugins` config key rather than changing Pi package-manager settings.
Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
diff --git a/package-lock.json b/package-lock.json
index 2a2e89c..c8b6487 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,9 +42,9 @@
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
- "@earendil-works/pi-agent-core": "^0.80.8",
- "@earendil-works/pi-ai": "^0.80.8",
- "@earendil-works/pi-coding-agent": "^0.80.8",
+ "@earendil-works/pi-agent-core": "^0.81.1",
+ "@earendil-works/pi-ai": "^0.81.1",
+ "@earendil-works/pi-coding-agent": "^0.81.1",
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.3",
"@types/ws": "^8.18.1",
@@ -61,9 +61,9 @@
"node": ">=22.19.0"
},
"peerDependencies": {
- "@earendil-works/pi-agent-core": ">=0.80.8 <0.81",
- "@earendil-works/pi-ai": ">=0.80.8 <0.81",
- "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81"
+ "@earendil-works/pi-agent-core": ">=0.81.1 <0.82",
+ "@earendil-works/pi-ai": ">=0.81.1 <0.82",
+ "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82"
}
},
"node_modules/@anthropic-ai/sdk": {
@@ -167,9 +167,9 @@
}
},
"node_modules/@aws-sdk/core": {
- "version": "3.975.3",
- "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz",
- "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==",
+ "version": "3.976.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz",
+ "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -187,13 +187,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.972.59",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz",
- "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==",
+ "version": "3.972.60",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz",
+ "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
+ "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
@@ -204,13 +204,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.972.61",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz",
- "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==",
+ "version": "3.972.62",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz",
+ "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
+ "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
@@ -223,13 +223,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": {
- "version": "4.9.7",
- "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz",
- "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==",
+ "version": "4.9.9",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz",
+ "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.29.5",
+ "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@@ -238,20 +238,20 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.973.4",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz",
- "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==",
+ "version": "3.973.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz",
+ "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
- "@aws-sdk/credential-provider-env": "^3.972.59",
- "@aws-sdk/credential-provider-http": "^3.972.61",
- "@aws-sdk/credential-provider-login": "^3.972.66",
- "@aws-sdk/credential-provider-process": "^3.972.59",
- "@aws-sdk/credential-provider-sso": "^3.973.3",
- "@aws-sdk/credential-provider-web-identity": "^3.972.65",
- "@aws-sdk/nested-clients": "^3.997.33",
+ "@aws-sdk/core": "^3.976.0",
+ "@aws-sdk/credential-provider-env": "^3.972.60",
+ "@aws-sdk/credential-provider-http": "^3.972.62",
+ "@aws-sdk/credential-provider-login": "^3.972.67",
+ "@aws-sdk/credential-provider-process": "^3.972.60",
+ "@aws-sdk/credential-provider-sso": "^3.973.4",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.66",
+ "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9",
@@ -263,14 +263,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.66",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz",
- "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==",
+ "version": "3.972.67",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz",
+ "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
- "@aws-sdk/nested-clients": "^3.997.33",
+ "@aws-sdk/core": "^3.976.0",
+ "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
@@ -281,18 +281,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.972.70",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz",
- "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==",
+ "version": "3.972.71",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz",
+ "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/credential-provider-env": "^3.972.59",
- "@aws-sdk/credential-provider-http": "^3.972.61",
- "@aws-sdk/credential-provider-ini": "^3.973.4",
- "@aws-sdk/credential-provider-process": "^3.972.59",
- "@aws-sdk/credential-provider-sso": "^3.973.3",
- "@aws-sdk/credential-provider-web-identity": "^3.972.65",
+ "@aws-sdk/credential-provider-env": "^3.972.60",
+ "@aws-sdk/credential-provider-http": "^3.972.62",
+ "@aws-sdk/credential-provider-ini": "^3.973.5",
+ "@aws-sdk/credential-provider-process": "^3.972.60",
+ "@aws-sdk/credential-provider-sso": "^3.973.4",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.66",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9",
@@ -304,13 +304,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.972.59",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz",
- "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==",
+ "version": "3.972.60",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz",
+ "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
+ "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
@@ -321,15 +321,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.973.3",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz",
- "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==",
+ "version": "3.973.4",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz",
+ "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
- "@aws-sdk/nested-clients": "^3.997.33",
- "@aws-sdk/token-providers": "3.1088.0",
+ "@aws-sdk/core": "^3.976.0",
+ "@aws-sdk/nested-clients": "^3.997.34",
+ "@aws-sdk/token-providers": "3.1092.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
@@ -340,14 +340,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
- "version": "3.1088.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz",
- "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==",
+ "version": "3.1092.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz",
+ "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
- "@aws-sdk/nested-clients": "^3.997.33",
+ "@aws-sdk/core": "^3.976.0",
+ "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
@@ -358,14 +358,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.972.65",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz",
- "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==",
+ "version": "3.972.66",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz",
+ "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
- "@aws-sdk/nested-clients": "^3.997.33",
+ "@aws-sdk/core": "^3.976.0",
+ "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
@@ -408,13 +408,13 @@
}
},
"node_modules/@aws-sdk/middleware-websocket": {
- "version": "3.972.41",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz",
- "integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==",
+ "version": "3.972.42",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.42.tgz",
+ "integrity": "sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
+ "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
@@ -427,13 +427,13 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
- "version": "3.997.33",
- "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz",
- "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==",
+ "version": "3.997.34",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz",
+ "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.975.3",
+ "@aws-sdk/core": "^3.976.0",
"@aws-sdk/signature-v4-multi-region": "^3.996.41",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
@@ -447,13 +447,13 @@
}
},
"node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": {
- "version": "4.9.7",
- "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz",
- "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==",
+ "version": "4.9.9",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz",
+ "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.29.5",
+ "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@@ -984,13 +984,13 @@
}
},
"node_modules/@earendil-works/pi-agent-core": {
- "version": "0.80.10",
- "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
- "integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==",
+ "version": "0.81.1",
+ "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
+ "integrity": "sha512-yqbh68CyhqxMov/jUogFJfMqlu2Gd37GAki+tr59YCmAPHfomiCA5ESzusXtpGzABeiZFC/OrRdQ4GwCCOMIHA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@earendil-works/pi-ai": "^0.80.10",
+ "@earendil-works/pi-ai": "^0.81.1",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -1007,9 +1007,9 @@
"license": "MIT"
},
"node_modules/@earendil-works/pi-ai": {
- "version": "0.80.10",
- "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
- "integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==",
+ "version": "0.81.1",
+ "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
+ "integrity": "sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1040,16 +1040,16 @@
"license": "MIT"
},
"node_modules/@earendil-works/pi-coding-agent": {
- "version": "0.80.10",
- "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz",
- "integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==",
+ "version": "0.81.1",
+ "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
+ "integrity": "sha512-r6ovAsZOgAqbC/aU6s+/dPnv/sGZBuWyZNvi3pXjpbuX5wvp3XvGkQI7/VLvX2o9XpmpFaPUxKNym1WfkN/P8A==",
"dev": true,
"hasShrinkwrap": true,
"license": "MIT",
"dependencies": {
- "@earendil-works/pi-agent-core": "^0.80.10",
- "@earendil-works/pi-ai": "^0.80.10",
- "@earendil-works/pi-tui": "^0.80.10",
+ "@earendil-works/pi-agent-core": "^0.81.1",
+ "@earendil-works/pi-ai": "^0.81.1",
+ "@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@@ -1539,12 +1539,12 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
- "version": "0.80.10",
- "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz",
+ "version": "0.81.1",
+ "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"dev": true,
"license": "MIT",
"dependencies": {
- "@earendil-works/pi-ai": "^0.80.10",
+ "@earendil-works/pi-ai": "^0.81.1",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -1554,8 +1554,8 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
- "version": "0.80.10",
- "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz",
+ "version": "0.81.1",
+ "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1579,8 +1579,8 @@
}
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
- "version": "0.80.10",
- "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz",
+ "version": "0.81.1",
+ "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1695,9 +1695,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1715,9 +1712,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1735,9 +1729,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1755,9 +1746,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1775,9 +1763,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2146,9 +2131,9 @@
"license": "MIT"
},
"node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5345,9 +5330,9 @@
"license": "MIT"
},
"node_modules/@smithy/core": {
- "version": "3.29.5",
- "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz",
- "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==",
+ "version": "3.29.7",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz",
+ "integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -5359,13 +5344,13 @@
}
},
"node_modules/@smithy/credential-provider-imds": {
- "version": "4.4.10",
- "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz",
- "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==",
+ "version": "4.4.12",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz",
+ "integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.29.5",
+ "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@@ -5374,13 +5359,13 @@
}
},
"node_modules/@smithy/fetch-http-handler": {
- "version": "5.6.7",
- "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz",
- "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==",
+ "version": "5.6.9",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.9.tgz",
+ "integrity": "sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.29.5",
+ "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@@ -5417,13 +5402,13 @@
}
},
"node_modules/@smithy/signature-v4": {
- "version": "5.6.6",
- "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz",
- "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==",
+ "version": "5.6.8",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz",
+ "integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.29.5",
+ "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
diff --git a/package.json b/package.json
index 27b7500..b4ac1aa 100644
--- a/package.json
+++ b/package.json
@@ -83,9 +83,9 @@
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
- "@earendil-works/pi-agent-core": "^0.80.8",
- "@earendil-works/pi-ai": "^0.80.8",
- "@earendil-works/pi-coding-agent": "^0.80.8",
+ "@earendil-works/pi-agent-core": "^0.81.1",
+ "@earendil-works/pi-ai": "^0.81.1",
+ "@earendil-works/pi-coding-agent": "^0.81.1",
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.3",
"@types/ws": "^8.18.1",
@@ -114,9 +114,9 @@
"homepage": "https://pi-web.dev/",
"packageManager": "npm@11.11.0",
"peerDependencies": {
- "@earendil-works/pi-agent-core": ">=0.80.8 <0.81",
- "@earendil-works/pi-ai": ">=0.80.8 <0.81",
- "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81"
+ "@earendil-works/pi-agent-core": ">=0.81.1 <0.82",
+ "@earendil-works/pi-ai": ">=0.81.1 <0.82",
+ "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82"
},
"keywords": [
"pi-package",
diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts
index 32d64f1..8703dba 100644
--- a/src/server/sessiond.ts
+++ b/src/server/sessiond.ts
@@ -7,6 +7,7 @@ import { WorkspaceActivityService } from "./activity/workspaceActivityService.js
import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js";
import { SessionEventHub } from "./realtime/sessionEventHub.js";
import { AuthService } from "./sessions/authService.js";
+import { bootstrapAndFreezeGlobalExtensionProviders } from "./sessions/globalProviderPolicy.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
@@ -50,6 +51,10 @@ await runSessionDaemonStartup({
await unreadStore.load();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log });
+ // Capture providers registered by global extensions while the runtime is
+ // still mutable, then freeze every later extension-provider mutation before
+ // any real session can load project resources.
+ await bootstrapAndFreezeGlobalExtensionProviders(auth.runtime, activeAgentProfile.dir, app.log);
const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts
index e427e6c..8a07d0a 100644
--- a/src/server/sessions/authService.test.ts
+++ b/src/server/sessions/authService.test.ts
@@ -3,13 +3,19 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OAuthFlowState } from "../../shared/apiTypes.js";
import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = [];
+beforeEach(() => {
+ // Pi 0.81 uses PI_OFFLINE for refreshes after runtime creation. Auth tests
+ // exercise local credential behavior and must never fetch provider catalogs.
+ vi.stubEnv("PI_OFFLINE", "1");
+});
+
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
diff --git a/src/server/sessions/globalProviderPolicy.acceptance.test.ts b/src/server/sessions/globalProviderPolicy.acceptance.test.ts
new file mode 100644
index 0000000..ecd2ee8
--- /dev/null
+++ b/src/server/sessions/globalProviderPolicy.acceptance.test.ts
@@ -0,0 +1,451 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ModelRuntime } from "@earendil-works/pi-coding-agent";
+import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
+import {
+ bootstrapAndFreezeGlobalExtensionProviders,
+ type GlobalProviderBootstrapLogger,
+} from "./globalProviderPolicy.js";
+import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
+import { PiSessionService, type PiSessionRef } from "./piSessionService.js";
+import {
+ CapturingSessionEventHub,
+ createTestModelRuntime,
+ TEST_MODEL_ID,
+ TEST_MODEL_PROVIDER,
+} from "./piSessionService.testSupport.js";
+
+/**
+ * Acceptance coverage for the exact sessiond lifecycle: global extensions are
+ * loaded once against the shared ModelRuntime, provider mutations are frozen,
+ * and real sessions subsequently load both global and project extensions
+ * through Pi's public session factories.
+ *
+ * These tests are also a tripwire for the instance-method shadowing used to
+ * freeze `registerProvider`, native registration, and unregistration. If Pi
+ * changes how real extension calls reach ModelRuntime, these scenarios fail.
+ */
+
+interface LogEntry {
+ level: "error" | "info" | "warn";
+ details: Record;
+ message: string;
+}
+
+interface PolicyHarness {
+ service: PiSessionService;
+ runtime: ModelRuntime;
+ agentDir: string;
+ logEntries: LogEntry[];
+}
+
+const tempDirs: string[] = [];
+const services: PiSessionService[] = [];
+
+const IGNORED_MUTATION_MESSAGE = "ignored provider mutation after global bootstrap";
+
+function modelId(providerId: string, variant: string): string {
+ return `${providerId}-${variant}-model`;
+}
+
+function providerBaseUrl(providerId: string, variant: string): string {
+ return `https://${providerId}-${variant}.example.com`;
+}
+
+function providerConfig(providerId: string, variant = "baseline"): Record {
+ return {
+ name: `${providerId} ${variant}`,
+ baseUrl: providerBaseUrl(providerId, variant),
+ apiKey: `sk-${providerId}-${variant}-secret`,
+ api: "openai-completions",
+ models: [{
+ id: modelId(providerId, variant),
+ name: `${providerId} ${variant} model`,
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 1_000,
+ maxTokens: 100,
+ }],
+ };
+}
+
+function providerRegistrationSource(providerId: string, variant = "baseline"): string {
+ return `pi.registerProvider(${JSON.stringify(providerId)}, ${JSON.stringify(providerConfig(providerId, variant))});`;
+}
+
+function nativeProviderRegistrationSource(providerId: string, variant = "baseline"): string {
+ const baseUrl = providerBaseUrl(providerId, variant);
+ const model = {
+ id: modelId(providerId, variant),
+ name: `${providerId} ${variant} model`,
+ api: "openai-completions",
+ provider: providerId,
+ baseUrl,
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 2_000,
+ maxTokens: 200,
+ };
+ return `pi.registerProvider({
+ id: ${JSON.stringify(providerId)},
+ name: ${JSON.stringify(`${providerId} ${variant}`)},
+ baseUrl: ${JSON.stringify(baseUrl)},
+ auth: {
+ apiKey: {
+ name: ${JSON.stringify(`${providerId} API key`)},
+ async resolve() {
+ return {
+ auth: { apiKey: ${JSON.stringify(`sk-${providerId}-${variant}-secret`)} },
+ source: "acceptance fixture"
+ };
+ }
+ }
+ },
+ getModels() { return [${JSON.stringify(model)}]; },
+ stream() { throw new Error("stream should not be called in this acceptance test"); },
+ streamSimple() { throw new Error("streamSimple should not be called in this acceptance test"); }
+ });`;
+}
+
+function globalProvidersSource(): string {
+ return `
+ export default function (pi) {
+ ${providerRegistrationSource("global-config")}
+ ${nativeProviderRegistrationSource("global-native")}
+ }
+ `;
+}
+
+function capturingLogger(): { entries: LogEntry[]; logger: GlobalProviderBootstrapLogger } {
+ const entries: LogEntry[] = [];
+ const record = (level: LogEntry["level"], details: Record, message: string): void => {
+ entries.push({ level, details, message });
+ };
+ return {
+ entries,
+ logger: {
+ error: (details, message) => { record("error", details, message); },
+ info: (details, message) => { record("info", details, message); },
+ warn: (details, message) => { record("warn", details, message); },
+ },
+ };
+}
+
+function ignoredMutationEntries(entries: readonly LogEntry[]): LogEntry[] {
+ return entries.filter((entry) => entry.message === IGNORED_MUTATION_MESSAGE);
+}
+
+function expectIgnoredMutations(
+ entries: readonly LogEntry[],
+ expected: readonly { operation: string; providerId: string }[],
+): void {
+ const ignored = ignoredMutationEntries(entries);
+ expect(ignored).toHaveLength(expected.length);
+ expect(ignored.map((entry) => entry.details)).toEqual(expect.arrayContaining(
+ expected.map(({ operation, providerId }) => ({
+ context: "global-provider-bootstrap",
+ operation,
+ providerId,
+ })),
+ ));
+ expect(ignored.every((entry) => entry.level === "info")).toBe(true);
+ const operationProviderKeys = ignored.map((entry) => `${String(entry.details["operation"])}:${String(entry.details["providerId"])}`);
+ expect(new Set(operationProviderKeys).size).toBe(ignored.length);
+}
+
+afterEach(async () => {
+ vi.unstubAllEnvs();
+ await Promise.all(services.splice(0).map(async (service) => service.dispose()));
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+async function tempDir(prefix: string): Promise {
+ const dir = await mkdtemp(join(tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+async function writeAgentExtension(agentDir: string, source: string): Promise {
+ await mkdir(join(agentDir, "extensions"), { recursive: true });
+ await writeFile(join(agentDir, "extensions", "global-probe.js"), source);
+}
+
+async function agentDirWithExtension(source: string): Promise {
+ const agentDir = await tempDir("pi-web-policy-agent-");
+ await writeAgentExtension(agentDir, source);
+ return agentDir;
+}
+
+/** Write a project extension into `/.pi/extensions/` and return the cwd. */
+async function projectWithExtension(source: string): Promise {
+ const cwd = await tempDir("pi-web-policy-project-");
+ await mkdir(join(cwd, ".pi", "extensions"), { recursive: true });
+ await writeFile(join(cwd, ".pi", "extensions", "probe.js"), source);
+ return cwd;
+}
+
+async function policyHarness(options: { runtime?: ModelRuntime; agentDir?: string } = {}): Promise {
+ const agentDir = options.agentDir ?? await tempDir("pi-web-policy-agent-");
+ // Isolate Pi's per-user resource discovery (~/.agents/skills et al.) so the
+ // harness sees only extensions written into its explicit agent/project dirs.
+ vi.stubEnv("HOME", await tempDir("pi-web-policy-home-"));
+ const runtime = options.runtime ?? await createTestModelRuntime();
+ const { entries, logger } = capturingLogger();
+
+ await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, logger);
+
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ agentDir,
+ modelRuntime: runtime,
+ sessionManager: createPiSessionManagerGateway({ agentDir, env: {}, sessionDirEnvKeys: [] }),
+ heartbeatIntervalMs: 60_000,
+ logger,
+ });
+ services.push(service);
+ return { service, runtime, agentDir, logEntries: entries };
+}
+
+async function expectNoProviderMutationFeedback(service: PiSessionService, ref: PiSessionRef): Promise {
+ const status = await service.status(ref);
+ expect(status.warnings ?? []).toEqual([]);
+ expect(service.notificationInbox(ref).notifications).toEqual([]);
+}
+
+/** Parse the session-start marker file without type assertions. */
+function parseToolMarker(raw: string): { activeTools: string[]; allTools: string[] } {
+ const value: unknown = JSON.parse(raw);
+ if (typeof value !== "object" || value === null || !("activeTools" in value) || !("allTools" in value)) {
+ throw new Error(`Unexpected marker content: ${raw}`);
+ }
+ const { activeTools, allTools } = value;
+ if (!Array.isArray(activeTools) || !Array.isArray(allTools)) throw new Error(`Unexpected marker content: ${raw}`);
+ return { activeTools: activeTools.map(String), allTools: allTools.map(String) };
+}
+
+describe("immutable global provider bootstrap acceptance", () => {
+ it("loads global config and native providers once, then treats normal session replay as a no-op", async () => {
+ const agentDir = await agentDirWithExtension(globalProvidersSource());
+ const { service, runtime, logEntries } = await policyHarness({ agentDir });
+ const baselineConfig = runtime.getRegisteredProviderConfig("global-config");
+ const baselineNative = runtime.getRegisteredNativeProvider("global-native");
+
+ expect(baselineConfig).toMatchObject({ baseUrl: providerBaseUrl("global-config", "baseline") });
+ expect(baselineNative).toMatchObject({
+ id: "global-native",
+ baseUrl: providerBaseUrl("global-native", "baseline"),
+ });
+ expect(logEntries).toContainEqual({
+ level: "info",
+ details: { context: "global-provider-bootstrap", providerIds: ["global-config", "global-native"] },
+ message: "global extension provider baseline bootstrapped and frozen",
+ });
+
+ const cwd = await tempDir("pi-web-policy-project-");
+ const session = await service.start(cwd);
+ const ref = { id: session.id, cwd };
+
+ expect(runtime.getRegisteredProviderIds()).toEqual(["global-config", "global-native"]);
+ expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig);
+ expect(runtime.getRegisteredNativeProvider("global-native")).toBe(baselineNative);
+ expect(runtime.getModel("global-config", modelId("global-config", "baseline"))).toMatchObject({
+ provider: "global-config",
+ baseUrl: providerBaseUrl("global-config", "baseline"),
+ });
+ expect(runtime.getModel("global-native", modelId("global-native", "baseline"))).toMatchObject({
+ provider: "global-native",
+ baseUrl: providerBaseUrl("global-native", "baseline"),
+ });
+ const available = await service.availableModels(ref);
+ expect(available).toEqual(expect.arrayContaining([
+ expect.objectContaining({ provider: "global-config", id: modelId("global-config", "baseline") }),
+ expect.objectContaining({ provider: "global-native", id: modelId("global-native", "baseline") }),
+ ]));
+ expectIgnoredMutations(logEntries, [
+ { operation: "registerProvider", providerId: "global-config" },
+ { operation: "registerNativeProvider", providerId: "global-native" },
+ ]);
+ await expectNoProviderMutationFeedback(service, ref);
+ });
+
+ it("blocks real project add, replacement, and unregister calls without disabling other extension features", async () => {
+ const agentDir = await agentDirWithExtension(globalProvidersSource());
+ const { service, runtime, logEntries } = await policyHarness({ agentDir });
+ const baselineConfig = runtime.getRegisteredProviderConfig("global-config");
+ const baselineNative = runtime.getRegisteredNativeProvider("global-native");
+ const markerPath = join(await tempDir("pi-web-policy-marker-"), "session-start.json");
+ const cwd = await projectWithExtension(`
+ import { writeFileSync } from "node:fs";
+ export default function (pi) {
+ ${providerRegistrationSource("project-config", "project-secret")}
+ ${providerRegistrationSource("global-config", "project-secret")}
+ ${nativeProviderRegistrationSource("project-native", "project-secret")}
+ ${nativeProviderRegistrationSource("global-native", "project-secret")}
+ pi.registerTool({
+ name: "project_probe_tool",
+ label: "Project Probe Tool",
+ description: "non-provider acceptance probe",
+ parameters: { type: "object", properties: {} },
+ async execute() { return { content: [{ type: "text", text: "project probe ok" }] }; }
+ });
+ pi.registerCommand("project-probe", {
+ description: "non-provider acceptance probe",
+ async handler() {}
+ });
+ pi.on("session_start", () => {
+ ${providerRegistrationSource("project-config", "late-secret")}
+ pi.unregisterProvider("global-config");
+ pi.unregisterProvider("global-config");
+ pi.unregisterProvider("global-native");
+ pi.unregisterProvider("global-native");
+ writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({
+ activeTools: pi.getActiveTools(),
+ allTools: pi.getAllTools().map((tool) => tool.name)
+ }));
+ });
+ }
+ `);
+
+ const session = await service.start(cwd);
+ const ref = { id: session.id, cwd };
+
+ expect(runtime.getRegisteredProviderIds()).toEqual(["global-config", "global-native"]);
+ expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig);
+ expect(runtime.getRegisteredNativeProvider("global-native")).toBe(baselineNative);
+ expect(runtime.getRegisteredProviderConfig("project-config")).toBeUndefined();
+ expect(runtime.getRegisteredNativeProvider("project-native")).toBeUndefined();
+ expect(runtime.getModel("global-config", modelId("global-config", "baseline"))).toBeDefined();
+ expect(runtime.getModel("global-config", modelId("global-config", "project-secret"))).toBeUndefined();
+ expect(runtime.getModel("global-native", modelId("global-native", "baseline"))).toBeDefined();
+ expect(runtime.getModel("global-native", modelId("global-native", "project-secret"))).toBeUndefined();
+ expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBeDefined();
+
+ expect(await service.commands(ref)).toContainEqual({
+ name: "project-probe",
+ description: "non-provider acceptance probe",
+ source: "extension",
+ });
+ const marker = parseToolMarker(await readFile(markerPath, "utf-8"));
+ expect(marker.activeTools).toContain("project_probe_tool");
+ expect(marker.allTools).toContain("project_probe_tool");
+
+ expectIgnoredMutations(logEntries, [
+ { operation: "registerProvider", providerId: "global-config" },
+ { operation: "registerProvider", providerId: "project-config" },
+ { operation: "registerNativeProvider", providerId: "global-native" },
+ { operation: "registerNativeProvider", providerId: "project-native" },
+ { operation: "unregisterProvider", providerId: "global-config" },
+ { operation: "unregisterProvider", providerId: "global-native" },
+ ]);
+ expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("secret");
+ expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("example.com");
+ await expectNoProviderMutationFeedback(service, ref);
+ });
+
+ it("keeps a tensorX-style startup provider while ignoring its session_start refresh", async () => {
+ const providerId = "tensorx-style";
+ const agentDir = await agentDirWithExtension(`
+ export default function (pi) {
+ ${providerRegistrationSource(providerId, "startup")}
+ pi.on("session_start", () => {
+ ${providerRegistrationSource(providerId, "late-refresh-secret")}
+ });
+ }
+ `);
+ const { service, runtime, logEntries } = await policyHarness({ agentDir });
+ const baseline = runtime.getRegisteredProviderConfig(providerId);
+ const cwd = await tempDir("pi-web-policy-project-");
+
+ const session = await service.start(cwd);
+ const ref = { id: session.id, cwd };
+
+ expect(runtime.getRegisteredProviderConfig(providerId)).toBe(baseline);
+ expect(runtime.getRegisteredProviderConfig(providerId)).toMatchObject({
+ baseUrl: providerBaseUrl(providerId, "startup"),
+ });
+ expect(runtime.getModel(providerId, modelId(providerId, "startup"))).toBeDefined();
+ expect(runtime.getModel(providerId, modelId(providerId, "late-refresh-secret"))).toBeUndefined();
+ expectIgnoredMutations(logEntries, [
+ { operation: "registerProvider", providerId },
+ ]);
+ expect(JSON.stringify(ignoredMutationEntries(logEntries))).not.toContain("late-refresh-secret");
+ await expectNoProviderMutationFeedback(service, ref);
+ });
+
+ it("requires a fresh daemon bootstrap for global extension changes instead of applying them on reload", async () => {
+ const providerId = "reload-global";
+ const variantEnv = "PI_WEB_ACCEPTANCE_PROVIDER_VARIANT";
+ vi.stubEnv(variantEnv, "first");
+ const agentDir = await agentDirWithExtension(`
+ export default function (pi) {
+ const variant = process.env[${JSON.stringify(variantEnv)}] ?? "missing";
+ pi.registerProvider(${JSON.stringify(providerId)}, {
+ name: "reload global " + variant,
+ baseUrl: "https://reload-" + variant + ".example.com",
+ apiKey: "sk-reload-" + variant,
+ api: "openai-completions",
+ models: [{
+ id: "model-" + variant,
+ name: "Reload " + variant,
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 1000,
+ maxTokens: 100
+ }]
+ });
+ }
+ `);
+ const firstDaemon = await policyHarness({ agentDir });
+ const firstBaseline = firstDaemon.runtime.getRegisteredProviderConfig(providerId);
+ const cwd = await tempDir("pi-web-policy-project-");
+ const session = await firstDaemon.service.start(cwd);
+ const ref = { id: session.id, cwd };
+
+ vi.stubEnv(variantEnv, "second");
+ await expect(firstDaemon.service.runCommand(ref, "/reload")).resolves.toMatchObject({ type: "done" });
+
+ expect(firstDaemon.runtime.getRegisteredProviderConfig(providerId)).toBe(firstBaseline);
+ expect(firstDaemon.runtime.getRegisteredProviderConfig(providerId)).toMatchObject({
+ baseUrl: "https://reload-first.example.com",
+ });
+ expect(firstDaemon.runtime.getModel(providerId, "model-first")).toBeDefined();
+ expect(firstDaemon.runtime.getModel(providerId, "model-second")).toBeUndefined();
+
+ const secondDaemon = await policyHarness({ agentDir });
+ expect(secondDaemon.runtime.getRegisteredProviderConfig(providerId)).toMatchObject({
+ baseUrl: "https://reload-second.example.com",
+ });
+ expect(secondDaemon.runtime.getModel(providerId, "model-first")).toBeUndefined();
+ expect(secondDaemon.runtime.getModel(providerId, "model-second")).toBeDefined();
+ });
+
+ it("leaves project-level models.json behavior unchanged", async () => {
+ const agentDir = await tempDir("pi-web-policy-agent-");
+ await writeFile(join(agentDir, "models.json"), JSON.stringify({
+ providers: { "global-acme": providerConfig("global-acme") },
+ }));
+ const runtime = await ModelRuntime.create({
+ credentials: new InMemoryCredentialStore(),
+ modelsPath: join(agentDir, "models.json"),
+ allowModelNetwork: false,
+ });
+ const { service } = await policyHarness({ runtime, agentDir });
+ const cwd = await tempDir("pi-web-policy-project-");
+ await mkdir(join(cwd, ".pi"), { recursive: true });
+ await writeFile(join(cwd, ".pi", "models.json"), JSON.stringify({
+ providers: { "project-acme": providerConfig("project-acme") },
+ }));
+
+ const session = await service.start(cwd);
+ const ref = { id: session.id, cwd };
+
+ expect(runtime.getModel("global-acme", modelId("global-acme", "baseline"))).toBeDefined();
+ expect(runtime.getModel("project-acme", modelId("project-acme", "baseline"))).toBeUndefined();
+ expect(runtime.getRegisteredProviderIds()).toEqual([]);
+ await expectNoProviderMutationFeedback(service, ref);
+ });
+});
diff --git a/src/server/sessions/globalProviderPolicy.test.ts b/src/server/sessions/globalProviderPolicy.test.ts
new file mode 100644
index 0000000..0545f9b
--- /dev/null
+++ b/src/server/sessions/globalProviderPolicy.test.ts
@@ -0,0 +1,211 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import type { Provider } from "@earendil-works/pi-ai";
+import {
+ bootstrapAndFreezeGlobalExtensionProviders,
+ type GlobalProviderBootstrapLogger,
+} from "./globalProviderPolicy.js";
+import {
+ createTestModelRuntime,
+ TEST_MODEL_ID,
+ TEST_MODEL_PROVIDER,
+} from "./piSessionService.testSupport.js";
+
+interface LogEntry {
+ level: "error" | "info" | "warn";
+ details: Record;
+ message: string;
+}
+
+const tempDirs: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })));
+});
+
+async function tempDir(prefix: string): Promise {
+ const path = await mkdtemp(join(tmpdir(), prefix));
+ tempDirs.push(path);
+ return path;
+}
+
+async function agentDirWithExtension(source: string): Promise {
+ const agentDir = await tempDir("pi-web-global-provider-unit-");
+ await mkdir(join(agentDir, "extensions"), { recursive: true });
+ await writeFile(join(agentDir, "extensions", "provider.js"), source);
+ return agentDir;
+}
+
+function capturingLogger(): { entries: LogEntry[]; logger: GlobalProviderBootstrapLogger } {
+ const entries: LogEntry[] = [];
+ const record = (level: LogEntry["level"], details: Record, message: string): void => {
+ entries.push({ level, details, message });
+ };
+ return {
+ entries,
+ logger: {
+ error: (details, message) => { record("error", details, message); },
+ info: (details, message) => { record("info", details, message); },
+ warn: (details, message) => { record("warn", details, message); },
+ },
+ };
+}
+
+function nativeProvider(providerId: string, name = providerId): Provider {
+ return {
+ id: providerId,
+ name,
+ auth: {
+ apiKey: {
+ name: `${providerId} API key`,
+ resolve: () => Promise.resolve(undefined),
+ },
+ },
+ getModels: () => [],
+ stream: () => { throw new Error("stream should not be called in this test"); },
+ streamSimple: () => { throw new Error("streamSimple should not be called in this test"); },
+ };
+}
+
+function registerProjectConfigProvider(runtime: Awaited>): void {
+ runtime.registerProvider("project-config", {
+ name: "Project Config",
+ baseUrl: "https://project-secret.example.com",
+ apiKey: "project-secret-api-key",
+ api: "openai-completions",
+ models: [{
+ id: "project-model",
+ name: "Project Model",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 8_192,
+ maxTokens: 1_024,
+ }],
+ });
+}
+
+describe("bootstrapAndFreezeGlobalExtensionProviders", () => {
+ it("captures the global baseline before making every later provider mutation a no-op", async () => {
+ const agentDir = await agentDirWithExtension(`
+ export default function (pi) {
+ pi.registerProvider("global-config", {
+ name: "Global Config",
+ baseUrl: "https://global.example.com",
+ apiKey: "$GLOBAL_PROVIDER_KEY",
+ api: "openai-completions",
+ models: [{
+ id: "global-model",
+ name: "Global Model",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 8192,
+ maxTokens: 1024
+ }]
+ });
+ }
+ `);
+ const runtime = await createTestModelRuntime();
+ const builtInModel = runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
+ expect(builtInModel).toBeDefined();
+ const { entries, logger } = capturingLogger();
+
+ await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, logger);
+
+ const baselineConfig = runtime.getRegisteredProviderConfig("global-config");
+ expect(baselineConfig).toMatchObject({ baseUrl: "https://global.example.com" });
+ expect(runtime.getModel("global-config", "global-model")).toBeDefined();
+ expect(entries).toContainEqual({
+ level: "info",
+ details: { context: "global-provider-bootstrap", providerIds: ["global-config"] },
+ message: "global extension provider baseline bootstrapped and frozen",
+ });
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ runtime.registerProvider("global-config", {
+ baseUrl: "https://replacement-secret.example.com",
+ headers: { Authorization: "replacement-secret-token" },
+ });
+ runtime.registerNativeProvider(nativeProvider("global-config", "native-secret-name"));
+ runtime.unregisterProvider("global-config");
+ registerProjectConfigProvider(runtime);
+ runtime.registerNativeProvider(nativeProvider("project-native", "project-native-secret-name"));
+ runtime.unregisterProvider("project-only");
+ }
+
+ expect(runtime.getRegisteredProviderIds()).toEqual(["global-config"]);
+ expect(runtime.getRegisteredProviderConfig("global-config")).toBe(baselineConfig);
+ expect(runtime.getRegisteredNativeProvider("global-config")).toBeUndefined();
+ expect(runtime.getRegisteredProviderConfig("project-config")).toBeUndefined();
+ expect(runtime.getRegisteredNativeProvider("project-native")).toBeUndefined();
+ expect(runtime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID)).toBe(builtInModel);
+
+ const ignoredMutations = entries
+ .filter((entry) => entry.message === "ignored provider mutation after global bootstrap")
+ .map((entry) => entry.details);
+ expect(ignoredMutations).toEqual([
+ { context: "global-provider-bootstrap", operation: "registerProvider", providerId: "global-config" },
+ { context: "global-provider-bootstrap", operation: "registerNativeProvider", providerId: "global-config" },
+ { context: "global-provider-bootstrap", operation: "unregisterProvider", providerId: "global-config" },
+ { context: "global-provider-bootstrap", operation: "registerProvider", providerId: "project-config" },
+ { context: "global-provider-bootstrap", operation: "registerNativeProvider", providerId: "project-native" },
+ { context: "global-provider-bootstrap", operation: "unregisterProvider", providerId: "project-only" },
+ ]);
+ expect(JSON.stringify(ignoredMutations)).not.toContain("secret");
+ });
+
+ it("keeps ignored mutations as no-ops when structured logging fails", async () => {
+ const agentDir = await tempDir("pi-web-global-provider-unit-");
+ const runtime = await createTestModelRuntime();
+ const { logger } = capturingLogger();
+ const loggingError = new Error("provider mutation logger failed");
+ const throwingLogger: GlobalProviderBootstrapLogger = {
+ ...logger,
+ info(details, message) {
+ if (message === "ignored provider mutation after global bootstrap") throw loggingError;
+ logger.info(details, message);
+ },
+ };
+
+ await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, throwingLogger);
+
+ expect(() => { registerProjectConfigProvider(runtime); }).not.toThrow();
+ expect(() => { runtime.registerNativeProvider(nativeProvider("project-native")); }).not.toThrow();
+ expect(() => { runtime.unregisterProvider("project-only"); }).not.toThrow();
+ expect(runtime.getRegisteredProviderIds()).toEqual([]);
+ });
+
+ it("logs non-fatal Pi bootstrap diagnostics and still freezes the runtime", async () => {
+ const agentDir = await agentDirWithExtension(`
+ export default function (pi) {
+ pi.registerProvider("broken-provider", { streamSimple() {} });
+ }
+ `);
+ const runtime = await createTestModelRuntime();
+ const { entries, logger } = capturingLogger();
+
+ await bootstrapAndFreezeGlobalExtensionProviders(runtime, agentDir, logger);
+
+ const diagnosticEntry = entries.find((entry) => entry.details["diagnosticType"] === "error");
+ expect(diagnosticEntry?.level).toBe("error");
+ expect(diagnosticEntry?.details["context"]).toBe("global-provider-bootstrap");
+ expect(diagnosticEntry?.message).toBe("global extension provider bootstrap diagnostic");
+ expect(diagnosticEntry?.details["diagnostic"])
+ .toEqual(expect.stringContaining('"api" is required when registering streamSimple'));
+
+ runtime.registerProvider("after-diagnostic", {});
+ expect(runtime.getRegisteredProviderIds()).toEqual([]);
+ expect(entries).toContainEqual({
+ level: "info",
+ details: {
+ context: "global-provider-bootstrap",
+ operation: "registerProvider",
+ providerId: "after-diagnostic",
+ },
+ message: "ignored provider mutation after global bootstrap",
+ });
+ });
+});
diff --git a/src/server/sessions/globalProviderPolicy.ts b/src/server/sessions/globalProviderPolicy.ts
new file mode 100644
index 0000000..c999526
--- /dev/null
+++ b/src/server/sessions/globalProviderPolicy.ts
@@ -0,0 +1,129 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ createAgentSessionServices,
+ type AgentSessionRuntimeDiagnostic,
+ type AgentSessionServices,
+ type ModelRuntime,
+} from "@earendil-works/pi-coding-agent";
+
+/** Structured logging boundary supplied by the session daemon. */
+export interface GlobalProviderBootstrapLogger {
+ error(details: Record, message: string): void;
+ info(details: Record, message: string): void;
+ warn(details: Record, message: string): void;
+}
+
+type ProviderMutationOperation = "registerNativeProvider" | "registerProvider" | "unregisterProvider";
+type ProviderMutationMethods = Pick;
+
+const LOG_CONTEXT = "global-provider-bootstrap";
+
+async function loadGlobalExtensionServices(runtime: ModelRuntime, agentDir: string): Promise {
+ const scratchCwd = await mkdtemp(join(tmpdir(), "pi-web-global-ext-"));
+ try {
+ return await createAgentSessionServices({ cwd: scratchCwd, agentDir, modelRuntime: runtime });
+ } finally {
+ await rm(scratchCwd, { recursive: true, force: true });
+ }
+}
+
+function logBootstrapDiagnostic(
+ logger: GlobalProviderBootstrapLogger,
+ diagnostic: AgentSessionRuntimeDiagnostic,
+): void {
+ const details = {
+ context: LOG_CONTEXT,
+ diagnosticType: diagnostic.type,
+ diagnostic: diagnostic.message,
+ };
+ if (diagnostic.type === "error") {
+ logger.error(details, "global extension provider bootstrap diagnostic");
+ } else if (diagnostic.type === "warning") {
+ logger.warn(details, "global extension provider bootstrap diagnostic");
+ } else {
+ logger.info(details, "global extension provider bootstrap diagnostic");
+ }
+}
+
+function freezeProviderMutations(runtime: ModelRuntime, logger: GlobalProviderBootstrapLogger): void {
+ const originalMethods: ProviderMutationMethods = {
+ registerNativeProvider: runtime.registerNativeProvider.bind(runtime),
+ registerProvider: runtime.registerProvider.bind(runtime),
+ unregisterProvider: runtime.unregisterProvider.bind(runtime),
+ };
+ const loggedProviderIds: Record> = {
+ registerNativeProvider: new Set(),
+ registerProvider: new Set(),
+ unregisterProvider: new Set(),
+ };
+ const logIgnoredMutation = (operation: ProviderMutationOperation, providerId: string): void => {
+ const loggedIds = loggedProviderIds[operation];
+ if (loggedIds.has(providerId)) return;
+ loggedIds.add(providerId);
+ try {
+ logger.info(
+ { context: LOG_CONTEXT, operation, providerId },
+ "ignored provider mutation after global bootstrap",
+ );
+ } catch {
+ // Logging must not turn an ignored mutation into an extension failure.
+ }
+ };
+ const frozenMethods: ProviderMutationMethods = {
+ registerProvider(providerId) {
+ logIgnoredMutation("registerProvider", providerId);
+ },
+ registerNativeProvider(provider) {
+ logIgnoredMutation("registerNativeProvider", provider.id);
+ },
+ unregisterProvider(providerId) {
+ logIgnoredMutation("unregisterProvider", providerId);
+ },
+ };
+
+ try {
+ Object.assign(runtime, frozenMethods);
+ } catch (error: unknown) {
+ Object.assign(runtime, originalMethods);
+ throw error;
+ }
+}
+
+/**
+ * Load global extensions once against the shared model runtime, then make its
+ * extension-provider baseline immutable for the rest of the daemon lifetime.
+ * All sessions share this runtime, so accepting project-dependent mutations
+ * would leak provider configuration across workspaces. This is an accidental
+ * contamination guard, not a sandbox for otherwise trusted extensions.
+ *
+ * The temporary cwd is guaranteed to be empty, so Pi discovers agent-dir
+ * extensions without loading project resources. Documented initialization-time
+ * config and native registrations therefore reach the runtime through Pi's
+ * public service factory. Pi exposes no provider-freeze hook, so the daemon
+ * deliberately shadows the three public instance mutation methods afterward;
+ * every registration replay or later call is then a logged no-op.
+ */
+export async function bootstrapAndFreezeGlobalExtensionProviders(
+ runtime: ModelRuntime,
+ agentDir: string,
+ logger: GlobalProviderBootstrapLogger,
+): Promise {
+ const services = await loadGlobalExtensionServices(runtime, agentDir);
+ const providerIds = Object.freeze([...runtime.getRegisteredProviderIds()].sort());
+
+ freezeProviderMutations(runtime, logger);
+
+ for (const diagnostic of services.diagnostics) logBootstrapDiagnostic(logger, diagnostic);
+ for (const extensionError of services.resourceLoader.getExtensions().errors) {
+ logger.error(
+ { context: LOG_CONTEXT, error: extensionError.error },
+ "global extension failed during provider bootstrap",
+ );
+ }
+ logger.info(
+ { context: LOG_CONTEXT, providerIds },
+ "global extension provider baseline bootstrapped and frozen",
+ );
+}
diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts
index 84f22fa..781acce 100644
--- a/src/server/sessions/piSessionService.promptQueue.test.ts
+++ b/src/server/sessions/piSessionService.promptQueue.test.ts
@@ -4,12 +4,22 @@ import { join } from "node:path";
import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
+beforeEach(() => {
+ // Pi 0.81 uses PI_OFFLINE for refreshes after runtime creation. These tests
+ // exercise local model/auth behavior and must never fetch provider catalogs.
+ vi.stubEnv("PI_OFFLINE", "1");
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
describe("PiSessionService prompt, queue, and auth warnings", () => {
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
const fake = fakeRuntime("prompt-session");
@@ -78,7 +88,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
- it("generates a session name for the first prompt via the session's agent.streamFn", async () => {
+ it("generates a session name for the first prompt via the session's agent.streamFunction", async () => {
const model = testModel();
const streamCalls: unknown[] = [];
const streamFn: StreamFn = (streamModel, context, options) => {
@@ -99,7 +109,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return stream;
};
const hub = new CapturingSessionEventHub();
- const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
+ const fake = fakeRuntime("name-session", { model, agent: { streamFunction: streamFn } });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts
index c25556b..c6c7635 100644
--- a/src/server/sessions/piSessionService.testSupport.ts
+++ b/src/server/sessions/piSessionService.testSupport.ts
@@ -208,7 +208,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial
setSessionName: (name: string) => { session.sessionName = name; },
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
getUserMessagesForForking: () => [],
- agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } },
+ agent: { streamFunction: () => { throw new Error("streamFunction should not be called in this test"); } },
...patch,
};
const runtime: PiSessionRuntime = {
diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts
index c3d87ae..720d949 100644
--- a/src/server/sessions/piSessionService.ts
+++ b/src/server/sessions/piSessionService.ts
@@ -345,13 +345,13 @@ export interface PiAgentSession {
setSessionName(name: string): void;
/**
* Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core`
- * `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this
- * model" function pi's own compaction/branch-summarization code uses internally.
- * Lets callers (e.g. session title generation) issue one-off model calls without
- * depending on pi-ai's deprecated `/compat` provider registry or leaking the full
- * `Agent`/`AgentSession` surface.
+ * `Agent`), exposing only `streamFunction` — the resolved-auth/headers/retry "call
+ * this model" function pi's own compaction/branch-summarization code uses
+ * internally. Lets callers (e.g. session title generation) issue one-off model
+ * calls without depending on pi-ai's deprecated `/compat` provider registry or
+ * leaking the full `Agent`/`AgentSession` surface.
*/
- agent: { streamFn: StreamFn };
+ agent: { streamFunction: StreamFn };
}
export interface PiSessionRuntime {
@@ -587,7 +587,7 @@ function createDefaultRuntimeFactory(
subsessions?: SubsessionToolDeps,
): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
- const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
+ const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
@@ -2696,7 +2696,7 @@ export class PiSessionService implements SessionRouteService {
const model = session.model;
if (model === undefined) return;
- void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
+ void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => {
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
}).catch(() => {
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
diff --git a/src/server/sessions/piSessionService.unread.test.ts b/src/server/sessions/piSessionService.unread.test.ts
index 3d21df9..c5a432a 100644
--- a/src/server/sessions/piSessionService.unread.test.ts
+++ b/src/server/sessions/piSessionService.unread.test.ts
@@ -1,6 +1,6 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import {
@@ -22,6 +22,9 @@ import {
} from "./sessionUnreadStore.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
+// Unread identities are persisted after platform-native cwd canonicalization.
+const WORKSPACE_CWD = resolve("/workspace");
+const FEATURE_CWD = resolve("/workspace-feature");
const tempRoots: string[] = [];
afterEach(async () => {
@@ -50,11 +53,11 @@ describe("PiSessionService daemon-owned unread state", () => {
const secondSnapshot = await service.unreadCatalog();
const current = secondSnapshot.sessions[0];
- expect(current).toMatchObject({ sessionId: "session-1", cwd: "/workspace", completionOrder: 2 });
+ expect(current).toMatchObject({ sessionId: "session-1", cwd: WORKSPACE_CWD, completionOrder: 2 });
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
const staleSnapshot = await service.acknowledgeUnread("session-1", {
- cwd: "/workspace",
+ cwd: WORKSPACE_CWD,
catalogId: "catalog-test",
throughCompletionOrder: 1,
});
@@ -62,7 +65,7 @@ describe("PiSessionService daemon-owned unread state", () => {
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
const acknowledged = await service.acknowledgeUnread("session-1", {
- cwd: "/workspace",
+ cwd: WORKSPACE_CWD,
catalogId: "catalog-test",
throughCompletionOrder: current?.completionOrder ?? 0,
});
@@ -100,7 +103,7 @@ describe("PiSessionService daemon-owned unread state", () => {
finishBash?.();
await Promise.resolve();
- expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "session-1", cwd: "/workspace", completionOrder: 1 }]);
+ expect((await service.unreadCatalog()).sessions).toMatchObject([{ sessionId: "session-1", cwd: WORKSPACE_CWD, completionOrder: 1 }]);
} finally {
await service.dispose();
}
@@ -258,13 +261,13 @@ describe("PiSessionService daemon-owned unread state", () => {
initial.session.isStreaming = false;
expect((await service.unreadCatalog()).sessions).toEqual([]);
- completeStoreWork(unreadStore, "session-1", "/workspace");
+ completeStoreWork(unreadStore, "session-1", WORKSPACE_CWD);
const beforeReload = (await service.unreadCatalog()).sessions[0];
await service.reload(sessionRef("session-1"));
const afterReload = (await service.unreadCatalog()).sessions[0];
expect(beforeReload).toBeDefined();
- expect(afterReload).toMatchObject({ sessionId: "session-1", cwd: "/workspace" });
+ expect(afterReload).toMatchObject({ sessionId: "session-1", cwd: WORKSPACE_CWD });
expect(afterReload?.completionOrder).toBeGreaterThan(beforeReload?.completionOrder ?? 0);
} finally {
await service.dispose();
@@ -273,7 +276,7 @@ describe("PiSessionService daemon-owned unread state", () => {
it("clears stale unread when a runtime rebind changes logical session identity", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
- completeStoreWork(unreadStore, "session-old", "/workspace");
+ completeStoreWork(unreadStore, "session-old", WORKSPACE_CWD);
const original = fakeRuntime("session-old");
let rebindSession: ((session: PiAgentSession) => Promise) | undefined;
original.runtime.setRebindSession = (callback) => { rebindSession = callback; };
@@ -302,7 +305,7 @@ describe("PiSessionService daemon-owned unread state", () => {
it("cleans unread state through archive, restore, delete, and cwd reconciliation", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
for (const sessionId of ["archive-me", "restore-me", "delete-me", "orphan"]) {
- completeStoreWork(unreadStore, sessionId, "/workspace");
+ completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD);
}
const archived = new Map([
["restore-me", { sessionId: "restore-me", cwd: "/workspace", archivedAt: "2026-07-01T00:00:00.000Z", archivePath: "/archive/restore-me.jsonl" }],
@@ -348,7 +351,7 @@ describe("PiSessionService daemon-owned unread state", () => {
await service.deleteArchived(sessionRef("delete-me"));
expect((await service.unreadCatalog()).sessions.map((summary) => summary.sessionId)).toEqual(["orphan"]);
- await service.list("/workspace");
+ await service.list(WORKSPACE_CWD);
expect((await service.unreadCatalog()).sessions).toEqual([]);
} finally {
await service.dispose();
@@ -404,7 +407,7 @@ describe("PiSessionService daemon-owned unread state", () => {
completeRuntimeWork(child);
expect((await service.unreadCatalog()).sessions).toContainEqual(expect.objectContaining({
sessionId: "child-1",
- cwd: "/workspace-feature",
+ cwd: FEATURE_CWD,
}));
} finally {
await service.dispose();
@@ -420,7 +423,7 @@ describe("PiSessionService daemon-owned unread state", () => {
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
- completeStoreWork(unreadStore, "child-1", "/workspace-feature");
+ completeStoreWork(unreadStore, "child-1", FEATURE_CWD);
const hub = new CapturingSessionEventHub();
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{
@@ -452,7 +455,7 @@ describe("PiSessionService daemon-owned unread state", () => {
]);
expect((await service.unreadCatalog()).sessions).toEqual([]);
- expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "child-1", cwd: "/workspace-feature", unread: null });
+ expect(unreadEvents(hub).at(-1)).toMatchObject({ sessionId: "child-1", cwd: FEATURE_CWD, unread: null });
} finally {
await service.dispose();
}
@@ -466,7 +469,7 @@ describe("PiSessionService daemon-owned unread state", () => {
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
- completeStoreWork(unreadStore, "child-1", "/workspace-feature");
+ completeStoreWork(unreadStore, "child-1", FEATURE_CWD);
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{
type: "custom",
diff --git a/src/server/sessions/sessionUnreadStore.test.ts b/src/server/sessions/sessionUnreadStore.test.ts
index 7bf84b9..a6ec905 100644
--- a/src/server/sessions/sessionUnreadStore.test.ts
+++ b/src/server/sessions/sessionUnreadStore.test.ts
@@ -464,7 +464,8 @@ describe("FileSessionUnreadPersistence", () => {
nextCompletionOrder: 1,
sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }],
});
- expect((await stat(filePath)).mode & 0o777).toBe(0o600);
+ // Windows reports synthesized POSIX mode bits and ignores the requested file mode.
+ if (process.platform !== "win32") expect((await stat(filePath)).mode & 0o777).toBe(0o600);
expect((await readdir(join(root, "state"))).filter((name) => name.endsWith(".tmp"))).toEqual([]);
const reloaded = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" });