Archived
Merge pull request #91 from jmfederico/agent/global-provider-policy
fix: freeze extension providers to the global startup baseline
This commit is contained in:
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>>=0.80.8 <0.81</code></strong> installed/configured so the <code>pi</code> command works for your user.</li>
|
<li><strong>Pi Coding Agent <code>>=0.81.1 <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
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Generated
+116
-131
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
Reference in New Issue
Block a user