Merge pull request #91 from jmfederico/agent/global-provider-policy

fix: freeze extension providers to the global startup baseline
This commit is contained in:
Federico Jaramillo Martinez
2026-07-23 12:01:01 +02:00
committed by GitHub
20 changed files with 1085 additions and 207 deletions
+7
View File
@@ -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.
+5 -7
View File
@@ -36,7 +36,7 @@ Requirements:
- Node.js 22.19.0 or newer - Node.js 22.19.0 or newer
- npm - 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 - git and the development tools your agents need
Install and start PI WEB as per-user services: 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) 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: [PI WEB plugin guide and API](https://pi-web.dev/plugins)
Read more: [Plugin API](https://pi-web.dev/plugins)
## Configuration ## Configuration
+51 -6
View File
@@ -99,6 +99,7 @@
<a href="#path-access">External path access</a> <a href="#path-access">External path access</a>
<a href="#manual-uploads">Manual uploads</a> <a href="#manual-uploads">Manual uploads</a>
<a href="#agent-runtime">Agent profile and companion CLI</a> <a href="#agent-runtime">Agent profile and companion CLI</a>
<a href="#pi-extension-provider-baseline">Pi extension providers</a>
<a href="#session-tools">Session tools</a> <a href="#session-tools">Session tools</a>
<a href="#completion-tools">Completion tools</a> <a href="#completion-tools">Completion tools</a>
</aside> </aside>
@@ -175,7 +176,7 @@
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li> <li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li> <li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li> <li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
<li>Pi package install/remove/update: not a PI WEB config key; after a mutation, type <code>/reload</code> 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.</li> <li>Pi package install/remove/update: not a PI WEB config key; after a mutation, type <code>/reload</code> 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 <code>pi-web-sessiond.service</code>; <code>/reload</code> cannot change the startup provider baseline. See <a href="#pi-extension-provider-baseline">Pi extension provider baseline</a>.</li>
<li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li> <li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li>
</ul> </ul>
</section> </section>
@@ -347,7 +348,7 @@
<td><code>PI_WEB_AGENT_DIR</code> (<code>PI_CODING_AGENT_DIR</code> for Pi compatibility)</td> <td><code>PI_WEB_AGENT_DIR</code> (<code>PI_CODING_AGENT_DIR</code> for Pi compatibility)</td>
<td>Global/session daemon</td> <td>Global/session daemon</td>
<td>Not supported locally</td> <td>Not supported locally</td>
<td>Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins</td> <td>Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugins</td>
</tr> </tr>
<tr> <tr>
<td>Agent can spawn sessions</td> <td>Agent can spawn sessions</td>
@@ -583,12 +584,12 @@
secret-free active profile stays fixed for the daemon lifetime. <strong>Settings → Session daemon</strong> secret-free active profile stays fixed for the daemon lifetime. <strong>Settings → Session daemon</strong>
saves command and directory together as desired configuration and shows whether the profile is active, 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, 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 Pi-package-backed PI WEB plugin discovery, status/install detection, and update planning continue to use
daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying
newly saved values. the newly saved values.
</p> </p>
<div class="callout warning"> <div class="callout warning">
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 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 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 companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised
@@ -597,6 +598,50 @@
</div> </div>
</section> </section>
<section id="pi-extension-provider-baseline">
<h2>Pi extension provider baseline</h2>
<p>
This policy applies to <strong>Pi runtime extensions</strong>, not PI WEB browser plugins. Pi extensions
are runtime modules loaded by the session daemon and can call <code>pi.registerProvider(...)</code>;
PI WEB plugins are browser-side UI modules and never run in the session daemon.
</p>
<p>
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
(<code>agent.dir</code>), 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
(<code>pi.registerProvider("id", config)</code>) and native-provider registrations
(<code>pi.registerProvider(provider)</code>), alongside Pi built-ins, environment credentials, and
providers from the active agent directory's <code>models.json</code>.
</p>
<p>
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 <code>session_start</code>, and <code>/reload</code>. The
captured provider stays unchanged while non-provider Pi extension features continue to load and reload
normally.
</p>
<p>
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.
</p>
<p>
Configure providers before the daemon starts: use the active agent directory's
<code>models.json</code>, or install the Pi extension globally in that agent profile. Project Pi extensions
and project-level <code>models.json</code> files cannot add providers to PI WEB's shared baseline.
</p>
<div class="callout warning">
<strong>Restart required:</strong> after updating PI WEB—or after installing, removing, or updating a
global Pi extension that registers providers—manually restart <code>pi-web-sessiond.service</code>
(<code>systemctl --user restart pi-web-sessiond</code>). Restarting only the web/API service and running
<code>/reload</code> do not rebuild the baseline.
</div>
</section>
<section id="session-tools"> <section id="session-tools">
<h2>Session daemon tools</h2> <h2>Session daemon tools</h2>
<h3><code>spawnSessions</code></h3> <h3><code>spawnSessions</code></h3>
+16 -4
View File
@@ -43,7 +43,7 @@ Process restarts depend on the key:
- `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `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. - `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. - `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. - `shortcuts`: saved settings apply in the browser after config refresh/save.
## Global config example ## 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 | | 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 | | 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 | | 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 | | 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 | | 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.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | | Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.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. 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 ### Session daemon tools
+1 -1
View File
@@ -38,7 +38,7 @@
"downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web", "downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web",
"codeRepository": "https://github.com/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.", "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" "license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE"
} }
</script> </script>
+1 -1
View File
@@ -107,7 +107,7 @@
<h2>Requirements</h2> <h2>Requirements</h2>
<ul> <ul>
<li><strong>Node.js 22.19.0 or newer</strong> and npm.</li> <li><strong>Node.js 22.19.0 or newer</strong> and npm.</li>
<li><strong>Pi Coding Agent <code>&gt;=0.80.8 &lt;0.81</code></strong> installed/configured so the <code>pi</code> command works for your user.</li> <li><strong>Pi Coding Agent <code>&gt;=0.81.1 &lt;0.82</code></strong> installed/configured so the <code>pi</code> command works for your user.</li>
<li>A shell login environment that exposes Node, npm, Pi, git, and any tools your agents need.</li> <li>A shell login environment that exposes Node, npm, Pi, git, and any tools your agents need.</li>
<li>For the automatic installer: a supported per-user service manager.</li> <li>For the automatic installer: a supported per-user service manager.</li>
</ul> </ul>
+28 -17
View File
@@ -90,7 +90,7 @@
<aside class="toc" aria-label="Plugin page contents"> <aside class="toc" aria-label="Plugin page contents">
<strong>On this page</strong> <strong>On this page</strong>
<a href="#extend">What can be extended</a> <a href="#extend">What can be extended</a>
<a href="#packages-vs-plugins">Pi packages vs PI WEB plugins</a> <a href="#packages-vs-plugins">Pi packages, extensions, and plugins</a>
<a href="#ask-ai">What to ask AI to build</a> <a href="#ask-ai">What to ask AI to build</a>
<a href="#example">Canonical example</a> <a href="#example">Canonical example</a>
<a href="#built-in-plugins">Built-in plugins</a> <a href="#built-in-plugins">Built-in plugins</a>
@@ -127,16 +127,22 @@
</section> </section>
<section id="packages-vs-plugins"> <section id="packages-vs-plugins">
<h2>Pi packages vs PI WEB plugins</h2> <h2>Pi packages, Pi extensions, and PI WEB plugins</h2>
<p> <p>
<strong>Pi packages</strong> are packages managed by Pi (<code>pi install</code>, <code>pi remove</code>, <strong>Pi packages</strong> are distribution bundles managed by Pi (<code>pi install</code>,
<code>pi update</code>). A Pi package can provide extensions, skills, prompt templates, themes, <code>pi remove</code>, <code>pi update</code>). A Pi package can provide Pi extensions, skills, prompt
context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin. templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not
include a PI WEB plugin.
</p>
<p>
<strong>Pi extensions</strong> are runtime modules loaded by the session daemon. They can register Pi
tools, hooks, commands, and model providers. They are not PI WEB plugins.
</p> </p>
<p> <p>
<strong>PI WEB plugins</strong> are browser-side UI modules discovered from bundled, local, dev, and <strong>PI WEB plugins</strong> 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, installed Pi-package sources. They cannot register model providers or server-side hooks. Enabling or
removing, or updating a Pi package is a Pi package-manager task. 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.
</p> </p>
<p> <p>
Use <strong>Settings → Pi packages</strong> to view configured Pi packages or install/remove/update a Use <strong>Settings → Pi packages</strong> 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 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 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 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 unsupported or unavailable instead of silently falling back to the gateway.
updating a Pi package, type <code>/reload</code> in each idle PI WEB session on the target machine to </p>
refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system <p>
prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed After installing, removing, or updating a Pi package, type <code>/reload</code> in each idle PI WEB
PI WEB browser plugins. A routine session daemon restart is not required. 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
<a href="config#pi-extension-provider-baseline">Pi extension provider baseline</a>.
</p> </p>
</section> </section>
@@ -329,17 +339,18 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<section id="manage-plugins"> <section id="manage-plugins">
<h2>Manage PI WEB plugins</h2> <h2>Manage PI WEB plugins</h2>
<p> <p>
Open <strong>Settings → PI WEB plugins</strong> to review discovered bundled, local, dev, and Pi package Open <strong>Settings → PI WEB plugins</strong> to review discovered bundled, local, dev, and
plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin Pi-package-supplied PI WEB plugins for the selected PI WEB machine. When the local machine is selected,
list; when a remote machine is selected, the list comes from that remote PI WEB server and includes 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 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 the browser imports it. Core app contributions such as the command palette, base workspace tools, and
themes are not managed through this plugin list. themes are not managed through this plugin list.
</p> </p>
<p> <p>
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may 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 <strong>Settings → Pi packages</strong>. In a federated setup, provide PI WEB plugins or other Pi resources, use <strong>Settings → Pi packages</strong>. In a federated
both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement
still writes the PI WEB <code>plugins</code> config key rather than changing Pi package-manager settings. still writes the PI WEB <code>plugins</code> config key rather than changing Pi package-manager settings.
</p> </p>
<div class="code-card"> <div class="code-card">
+10 -6
View File
@@ -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. 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 <source>`, and does not ask for an install location. 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 <source>`, 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. 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 ## Trust model
@@ -169,9 +173,9 @@ If a remote plugin constructs absolute asset URLs, it should use the `pluginId`
## Manage PI WEB plugins ## 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: Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
+116 -131
View File
@@ -42,9 +42,9 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.80.8", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.80.8", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-coding-agent": "^0.80.8", "@earendil-works/pi-coding-agent": "^0.81.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@@ -61,9 +61,9 @@
"node": ">=22.19.0" "node": ">=22.19.0"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81", "@earendil-works/pi-agent-core": ">=0.81.1 <0.82",
"@earendil-works/pi-ai": ">=0.80.8 <0.81", "@earendil-works/pi-ai": ">=0.81.1 <0.82",
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82"
} }
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
@@ -167,9 +167,9 @@
} }
}, },
"node_modules/@aws-sdk/core": { "node_modules/@aws-sdk/core": {
"version": "3.975.3", "version": "3.976.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz",
"integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -187,13 +187,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-env": { "node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.59", "version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz",
"integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -204,13 +204,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http": { "node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.61", "version": "3.972.62",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz",
"integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6", "@smithy/fetch-http-handler": "^5.6.6",
@@ -223,13 +223,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": {
"version": "4.9.7", "version": "4.9.9",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz",
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -238,20 +238,20 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-ini": { "node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.4", "version": "3.973.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz",
"integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-env": "^3.972.59", "@aws-sdk/credential-provider-env": "^3.972.60",
"@aws-sdk/credential-provider-http": "^3.972.61", "@aws-sdk/credential-provider-http": "^3.972.62",
"@aws-sdk/credential-provider-login": "^3.972.66", "@aws-sdk/credential-provider-login": "^3.972.67",
"@aws-sdk/credential-provider-process": "^3.972.59", "@aws-sdk/credential-provider-process": "^3.972.60",
"@aws-sdk/credential-provider-sso": "^3.973.3", "@aws-sdk/credential-provider-sso": "^3.973.4",
"@aws-sdk/credential-provider-web-identity": "^3.972.65", "@aws-sdk/credential-provider-web-identity": "^3.972.66",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9", "@smithy/credential-provider-imds": "^4.4.9",
@@ -263,14 +263,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-login": { "node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.66", "version": "3.972.67",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz",
"integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -281,18 +281,18 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-node": { "node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.70", "version": "3.972.71",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz",
"integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.59", "@aws-sdk/credential-provider-env": "^3.972.60",
"@aws-sdk/credential-provider-http": "^3.972.61", "@aws-sdk/credential-provider-http": "^3.972.62",
"@aws-sdk/credential-provider-ini": "^3.973.4", "@aws-sdk/credential-provider-ini": "^3.973.5",
"@aws-sdk/credential-provider-process": "^3.972.59", "@aws-sdk/credential-provider-process": "^3.972.60",
"@aws-sdk/credential-provider-sso": "^3.973.3", "@aws-sdk/credential-provider-sso": "^3.973.4",
"@aws-sdk/credential-provider-web-identity": "^3.972.65", "@aws-sdk/credential-provider-web-identity": "^3.972.66",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9", "@smithy/credential-provider-imds": "^4.4.9",
@@ -304,13 +304,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-process": { "node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.59", "version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz",
"integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -321,15 +321,15 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso": { "node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.3", "version": "3.973.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz",
"integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/token-providers": "3.1088.0", "@aws-sdk/token-providers": "3.1092.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -340,14 +340,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1088.0", "version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz",
"integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -358,14 +358,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-web-identity": { "node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.65", "version": "3.972.66",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz",
"integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.33", "@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@@ -408,13 +408,13 @@
} }
}, },
"node_modules/@aws-sdk/middleware-websocket": { "node_modules/@aws-sdk/middleware-websocket": {
"version": "3.972.41", "version": "3.972.42",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.41.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.42.tgz",
"integrity": "sha512-LSbGvvYmjc4Br9BPYI2dTLnIclmrSiQbahkP4D6nRGVEv4qsCZ8csVuKBPVEEFCVD+EEngGh8ROls6XpumtwMg==", "integrity": "sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6", "@smithy/fetch-http-handler": "^5.6.6",
@@ -427,13 +427,13 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients": { "node_modules/@aws-sdk/nested-clients": {
"version": "3.997.33", "version": "3.997.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz",
"integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.975.3", "@aws-sdk/core": "^3.976.0",
"@aws-sdk/signature-v4-multi-region": "^3.996.41", "@aws-sdk/signature-v4-multi-region": "^3.996.41",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4", "@smithy/core": "^3.29.4",
@@ -447,13 +447,13 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": {
"version": "4.9.7", "version": "4.9.9",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz",
"integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "integrity": "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -984,13 +984,13 @@
} }
}, },
"node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"integrity": "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==", "integrity": "sha512-yqbh68CyhqxMov/jUogFJfMqlu2Gd37GAki+tr59YCmAPHfomiCA5ESzusXtpGzABeiZFC/OrRdQ4GwCCOMIHA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-ai": "^0.81.1",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
"yaml": "2.9.0" "yaml": "2.9.0"
@@ -1007,9 +1007,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-ai": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"integrity": "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==", "integrity": "sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1040,16 +1040,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-coding-agent": { "node_modules/@earendil-works/pi-coding-agent": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
"integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==", "integrity": "sha512-r6ovAsZOgAqbC/aU6s+/dPnv/sGZBuWyZNvi3pXjpbuX5wvp3XvGkQI7/VLvX2o9XpmpFaPUxKNym1WfkN/P8A==",
"dev": true, "dev": true,
"hasShrinkwrap": true, "hasShrinkwrap": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-tui": "^0.80.10", "@earendil-works/pi-tui": "^0.81.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -1539,12 +1539,12 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-ai": "^0.81.1",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
"yaml": "2.9.0" "yaml": "2.9.0"
@@ -1554,8 +1554,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1579,8 +1579,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.80.10", "version": "0.81.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1695,9 +1695,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1715,9 +1712,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1735,9 +1729,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1755,9 +1746,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1775,9 +1763,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2146,9 +2131,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5345,9 +5330,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@smithy/core": { "node_modules/@smithy/core": {
"version": "3.29.5", "version": "3.29.7",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz",
"integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", "integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -5359,13 +5344,13 @@
} }
}, },
"node_modules/@smithy/credential-provider-imds": { "node_modules/@smithy/credential-provider-imds": {
"version": "4.4.10", "version": "4.4.12",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz",
"integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", "integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -5374,13 +5359,13 @@
} }
}, },
"node_modules/@smithy/fetch-http-handler": { "node_modules/@smithy/fetch-http-handler": {
"version": "5.6.7", "version": "5.6.9",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.9.tgz",
"integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", "integrity": "sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -5417,13 +5402,13 @@
} }
}, },
"node_modules/@smithy/signature-v4": { "node_modules/@smithy/signature-v4": {
"version": "5.6.6", "version": "5.6.8",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz",
"integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", "integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.29.5", "@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
+6 -6
View File
@@ -83,9 +83,9 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.80.8", "@earendil-works/pi-agent-core": "^0.81.1",
"@earendil-works/pi-ai": "^0.80.8", "@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-coding-agent": "^0.80.8", "@earendil-works/pi-coding-agent": "^0.81.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@@ -114,9 +114,9 @@
"homepage": "https://pi-web.dev/", "homepage": "https://pi-web.dev/",
"packageManager": "[email protected]", "packageManager": "[email protected]",
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.80.8 <0.81", "@earendil-works/pi-agent-core": ">=0.81.1 <0.82",
"@earendil-works/pi-ai": ">=0.80.8 <0.81", "@earendil-works/pi-ai": ">=0.81.1 <0.82",
"@earendil-works/pi-coding-agent": ">=0.80.8 <0.81" "@earendil-works/pi-coding-agent": ">=0.81.1 <0.82"
}, },
"keywords": [ "keywords": [
"pi-package", "pi-package",
+5
View File
@@ -7,6 +7,7 @@ import { WorkspaceActivityService } from "./activity/workspaceActivityService.js
import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js"; import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js";
import { SessionEventHub } from "./realtime/sessionEventHub.js"; import { SessionEventHub } from "./realtime/sessionEventHub.js";
import { AuthService } from "./sessions/authService.js"; import { AuthService } from "./sessions/authService.js";
import { bootstrapAndFreezeGlobalExtensionProviders } from "./sessions/globalProviderPolicy.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js"; import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js"; import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
@@ -50,6 +51,10 @@ await runSessionDaemonStartup({
await unreadStore.load(); await unreadStore.load();
const workspaceActivity = new WorkspaceActivityService(eventHub); const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = await AuthService.create({ agentDir: activeAgentProfile.dir, logger: app.log }); 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 const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined; : undefined;
+7 -1
View File
@@ -3,13 +3,19 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { ModelRuntime } from "@earendil-works/pi-coding-agent"; import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai"; 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 type { OAuthFlowState } from "../../shared/apiTypes.js";
import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js"; import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = []; 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 () => { afterEach(async () => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
@@ -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<string, unknown>;
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<string, unknown> {
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<string, unknown>, 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<string> {
const dir = await mkdtemp(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function writeAgentExtension(agentDir: string, source: string): Promise<void> {
await mkdir(join(agentDir, "extensions"), { recursive: true });
await writeFile(join(agentDir, "extensions", "global-probe.js"), source);
}
async function agentDirWithExtension(source: string): Promise<string> {
const agentDir = await tempDir("pi-web-policy-agent-");
await writeAgentExtension(agentDir, source);
return agentDir;
}
/** Write a project extension into `<cwd>/.pi/extensions/` and return the cwd. */
async function projectWithExtension(source: string): Promise<string> {
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<PolicyHarness> {
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<void> {
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);
});
});
@@ -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<string, unknown>;
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<string> {
const path = await mkdtemp(join(tmpdir(), prefix));
tempDirs.push(path);
return path;
}
async function agentDirWithExtension(source: string): Promise<string> {
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<string, unknown>, 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<ReturnType<typeof createTestModelRuntime>>): 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",
});
});
});
+129
View File
@@ -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<string, unknown>, message: string): void;
info(details: Record<string, unknown>, message: string): void;
warn(details: Record<string, unknown>, message: string): void;
}
type ProviderMutationOperation = "registerNativeProvider" | "registerProvider" | "unregisterProvider";
type ProviderMutationMethods = Pick<ModelRuntime, ProviderMutationOperation>;
const LOG_CONTEXT = "global-provider-bootstrap";
async function loadGlobalExtensionServices(runtime: ModelRuntime, agentDir: string): Promise<AgentSessionServices> {
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<ProviderMutationOperation, Set<string>> = {
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<void> {
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",
);
}
@@ -4,12 +4,22 @@ import { join } from "node:path";
import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai"; import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core"; import type { StreamFn } from "@earendil-works/pi-agent-core";
import { ModelRuntime } from "@earendil-works/pi-coding-agent"; 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 { 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"; 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"; 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", () => { describe("PiSessionService prompt, queue, and auth warnings", () => {
it("sends prompts to an injected runtime without touching the SDK runtime", async () => { it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
const fake = fakeRuntime("prompt-session"); const fake = fakeRuntime("prompt-session");
@@ -78,7 +88,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose(); 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 model = testModel();
const streamCalls: unknown[] = []; const streamCalls: unknown[] = [];
const streamFn: StreamFn = (streamModel, context, options) => { const streamFn: StreamFn = (streamModel, context, options) => {
@@ -99,7 +109,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return stream; return stream;
}; };
const hub = new CapturingSessionEventHub(); 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, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR, agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime, modelRuntime: testModelRuntime,
@@ -208,7 +208,7 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
setSessionName: (name: string) => { session.sessionName = name; }, setSessionName: (name: string) => { session.sessionName = name; },
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }), compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
getUserMessagesForForking: () => [], 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, ...patch,
}; };
const runtime: PiSessionRuntime = { const runtime: PiSessionRuntime = {
+8 -8
View File
@@ -345,13 +345,13 @@ export interface PiAgentSession {
setSessionName(name: string): void; setSessionName(name: string): void;
/** /**
* Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core` * Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core`
* `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this * `Agent`), exposing only `streamFunction` — the resolved-auth/headers/retry "call
* model" function pi's own compaction/branch-summarization code uses internally. * this model" function pi's own compaction/branch-summarization code uses
* Lets callers (e.g. session title generation) issue one-off model calls without * internally. Lets callers (e.g. session title generation) issue one-off model
* depending on pi-ai's deprecated `/compat` provider registry or leaking the full * calls without depending on pi-ai's deprecated `/compat` provider registry or
* `Agent`/`AgentSession` surface. * leaking the full `Agent`/`AgentSession` surface.
*/ */
agent: { streamFn: StreamFn }; agent: { streamFunction: StreamFn };
} }
export interface PiSessionRuntime { export interface PiSessionRuntime {
@@ -587,7 +587,7 @@ function createDefaultRuntimeFactory(
subsessions?: SubsessionToolDeps, subsessions?: SubsessionToolDeps,
): PiWebCreateAgentSessionRuntimeFactory { ): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { 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 const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions); const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
@@ -2696,7 +2696,7 @@ export class PiSessionService implements SessionRouteService {
const model = session.model; const model = session.model;
if (model === undefined) return; 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)); this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
}).catch(() => { }).catch(() => {
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage)); this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
@@ -1,6 +1,6 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; 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 { afterEach, describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import { import {
@@ -22,6 +22,9 @@ import {
} from "./sessionUnreadStore.js"; } from "./sessionUnreadStore.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; 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[] = []; const tempRoots: string[] = [];
afterEach(async () => { afterEach(async () => {
@@ -50,11 +53,11 @@ describe("PiSessionService daemon-owned unread state", () => {
const secondSnapshot = await service.unreadCatalog(); const secondSnapshot = await service.unreadCatalog();
const current = secondSnapshot.sessions[0]; 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]); expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
const staleSnapshot = await service.acknowledgeUnread("session-1", { const staleSnapshot = await service.acknowledgeUnread("session-1", {
cwd: "/workspace", cwd: WORKSPACE_CWD,
catalogId: "catalog-test", catalogId: "catalog-test",
throughCompletionOrder: 1, throughCompletionOrder: 1,
}); });
@@ -62,7 +65,7 @@ describe("PiSessionService daemon-owned unread state", () => {
expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]); expect(unreadEvents(hub).map((event) => event.catalogRevision)).toEqual([1, 2]);
const acknowledged = await service.acknowledgeUnread("session-1", { const acknowledged = await service.acknowledgeUnread("session-1", {
cwd: "/workspace", cwd: WORKSPACE_CWD,
catalogId: "catalog-test", catalogId: "catalog-test",
throughCompletionOrder: current?.completionOrder ?? 0, throughCompletionOrder: current?.completionOrder ?? 0,
}); });
@@ -100,7 +103,7 @@ describe("PiSessionService daemon-owned unread state", () => {
finishBash?.(); finishBash?.();
await Promise.resolve(); 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 { } finally {
await service.dispose(); await service.dispose();
} }
@@ -258,13 +261,13 @@ describe("PiSessionService daemon-owned unread state", () => {
initial.session.isStreaming = false; initial.session.isStreaming = false;
expect((await service.unreadCatalog()).sessions).toEqual([]); expect((await service.unreadCatalog()).sessions).toEqual([]);
completeStoreWork(unreadStore, "session-1", "/workspace"); completeStoreWork(unreadStore, "session-1", WORKSPACE_CWD);
const beforeReload = (await service.unreadCatalog()).sessions[0]; const beforeReload = (await service.unreadCatalog()).sessions[0];
await service.reload(sessionRef("session-1")); await service.reload(sessionRef("session-1"));
const afterReload = (await service.unreadCatalog()).sessions[0]; const afterReload = (await service.unreadCatalog()).sessions[0];
expect(beforeReload).toBeDefined(); 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); expect(afterReload?.completionOrder).toBeGreaterThan(beforeReload?.completionOrder ?? 0);
} finally { } finally {
await service.dispose(); 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 () => { it("clears stale unread when a runtime rebind changes logical session identity", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "session-old", "/workspace"); completeStoreWork(unreadStore, "session-old", WORKSPACE_CWD);
const original = fakeRuntime("session-old"); const original = fakeRuntime("session-old");
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined; let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
original.runtime.setRebindSession = (callback) => { rebindSession = callback; }; 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 () => { it("cleans unread state through archive, restore, delete, and cwd reconciliation", async () => {
const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" }); const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
for (const sessionId of ["archive-me", "restore-me", "delete-me", "orphan"]) { for (const sessionId of ["archive-me", "restore-me", "delete-me", "orphan"]) {
completeStoreWork(unreadStore, sessionId, "/workspace"); completeStoreWork(unreadStore, sessionId, WORKSPACE_CWD);
} }
const archived = new Map([ const archived = new Map([
["restore-me", { sessionId: "restore-me", cwd: "/workspace", archivedAt: "2026-07-01T00:00:00.000Z", archivePath: "/archive/restore-me.jsonl" }], ["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")); await service.deleteArchived(sessionRef("delete-me"));
expect((await service.unreadCatalog()).sessions.map((summary) => summary.sessionId)).toEqual(["orphan"]); 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([]); expect((await service.unreadCatalog()).sessions).toEqual([]);
} finally { } finally {
await service.dispose(); await service.dispose();
@@ -404,7 +407,7 @@ describe("PiSessionService daemon-owned unread state", () => {
completeRuntimeWork(child); completeRuntimeWork(child);
expect((await service.unreadCatalog()).sessions).toContainEqual(expect.objectContaining({ expect((await service.unreadCatalog()).sessions).toContainEqual(expect.objectContaining({
sessionId: "child-1", sessionId: "child-1",
cwd: "/workspace-feature", cwd: FEATURE_CWD,
})); }));
} finally { } finally {
await service.dispose(); 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"); 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" }); const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "child-1", "/workspace-feature"); completeStoreWork(unreadStore, "child-1", FEATURE_CWD);
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
const parentManager = fakeSessionManager("/workspace", { const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ getEntries: () => [{
@@ -452,7 +455,7 @@ describe("PiSessionService daemon-owned unread state", () => {
]); ]);
expect((await service.unreadCatalog()).sessions).toEqual([]); 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 { } finally {
await service.dispose(); 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"); 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" }); const unreadStore = new SessionUnreadStore({ createCatalogId: () => "catalog-test" });
completeStoreWork(unreadStore, "child-1", "/workspace-feature"); completeStoreWork(unreadStore, "child-1", FEATURE_CWD);
const parentManager = fakeSessionManager("/workspace", { const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ getEntries: () => [{
type: "custom", type: "custom",
@@ -464,7 +464,8 @@ describe("FileSessionUnreadPersistence", () => {
nextCompletionOrder: 1, nextCompletionOrder: 1,
sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 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([]); expect((await readdir(join(root, "state"))).filter((name) => name.endsWith(".tmp"))).toEqual([]);
const reloaded = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" }); const reloaded = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" });