docs: clarify Pi-compatible agent profiles

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:30:09 +02:00
38 changed files with 1282 additions and 160 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a capability-aware Clear queue action that removes queued session messages, including prompts held during compaction, without stopping active work.
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Add configurable agent runtime settings so PI WEB can use alternate Pi-compatible commands and isolated agent state/session directories, with web-side status and plugin views re-reading saved config. Add selectable Pi-compatible agent profiles and companion CLIs for isolated auth, models, settings, sessions, Pi packages, plugins, diagnostics, and safe update commands. Settings shows when a session-daemon restart is required, and mixed-version remote saves fail instead of reporting false success. The embedded runtime remains the bundled Pi SDK.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add explicit tracked-subsession yielding with no-poll wake-up guidance, remaining-child status, and clear boundaries around child output.
+73 -45
View File
@@ -81,7 +81,7 @@
<p> <p>
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address, PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access, trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access,
manual upload defaults, upload limits, agent runtime selection, and session-daemon tools. manual upload defaults, upload limits, Pi-compatible agent profiles and companion CLIs, and session-daemon tools.
</p> </p>
</div> </div>
</section> </section>
@@ -98,7 +98,7 @@
<a href="#keys">Config matrix</a> <a href="#keys">Config matrix</a>
<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 runtime</a> <a href="#agent-runtime">Agent profile and companion CLI</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>
@@ -113,8 +113,8 @@
</ul> </ul>
<p> <p>
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected
machine for config that affects work running there: agent runtime selection, session daemon tools, machine for config that affects work running there: the Pi-compatible agent profile and companion CLI,
PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers
that do not advertise selected-machine settings support report those settings as unavailable instead of that do not advertise selected-machine settings support report those settings as unavailable instead of
silently falling back to the gateway. silently falling back to the gateway.
@@ -334,7 +334,7 @@
<td>Restart web/API and session daemon on that machine</td> <td>Restart web/API and session daemon on that machine</td>
</tr> </tr>
<tr> <tr>
<td>Agent CLI command</td> <td>Companion CLI command</td>
<td><code>agent.command</code></td> <td><code>agent.command</code></td>
<td><code>PI_WEB_AGENT_COMMAND</code></td> <td><code>PI_WEB_AGENT_COMMAND</code></td>
<td>Global/session daemon</td> <td>Global/session daemon</td>
@@ -342,12 +342,12 @@
<td>Restart session daemon on that machine; affects doctor/status/update checks</td> <td>Restart session daemon on that machine; affects doctor/status/update checks</td>
</tr> </tr>
<tr> <tr>
<td>Agent state directory</td> <td>Agent profile state directory</td>
<td><code>agent.dir</code></td> <td><code>agent.dir</code></td>
<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, and sessions</td> <td>Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins</td>
</tr> </tr>
<tr> <tr>
<td>Agent can spawn sessions</td> <td>Agent can spawn sessions</td>
@@ -455,7 +455,7 @@
<td>Restart web/API; advanced state override</td> <td>Restart web/API; advanced state override</td>
</tr> </tr>
<tr> <tr>
<td>Agent session storage directory</td> <td>Agent profile session storage directory</td>
<td></td> <td></td>
<td><code>PI_WEB_AGENT_SESSION_DIR</code> (<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility)</td> <td><code>PI_WEB_AGENT_SESSION_DIR</code> (<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility)</td>
<td>Session daemon env</td> <td>Session daemon env</td>
@@ -463,7 +463,7 @@
<td>Restart session daemon; env-only session storage override</td> <td>Restart session daemon; env-only session storage override</td>
</tr> </tr>
<tr> <tr>
<td>Agent config directory</td> <td>Agent profile state directory</td>
<td></td> <td></td>
<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>Web/API + session daemon env</td> <td>Web/API + session daemon env</td>
@@ -537,46 +537,63 @@
</section> </section>
<section id="agent-runtime"> <section id="agent-runtime">
<h2>Agent runtime</h2> <h2>Pi-compatible agent profile and companion CLI</h2>
<p> <p>
<code>agent.command</code> controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. <code>agent.command</code> selects the Pi-compatible companion CLI used by <code>pi-web doctor</code> and,
It defaults to <code>pi</code>. Set it only when diagnostics and package-managed update checks should target when it can be generated safely, package-managed update commands. It defaults to <code>pi</code>. This
another compatible command; the embedded session runtime still uses PI WEB's SDK integration. setting does <strong>not</strong> replace the embedded runtime: every session continues to use PI WEB's
bundled Pi SDK.
</p> </p>
<p> <p>
<code>agent.dir</code> controls which compatible agent state directory PI WEB reads for auth providers, <code>agent.dir</code> selects the Pi-compatible state profile used for auth providers, models, settings,
model settings, settings, and session metadata. It defaults to <code>~/.pi/agent</code> only for the default sessions, Pi packages, and Pi-package-backed PI WEB plugin discovery. It defaults to
<code>pi</code> command. Set it explicitly when you want an isolated Pi profile or when <code>~/.pi/agent</code> only for a canonical Pi companion command. The directory must use the data layout
<code>agent.command</code> points at another compatible CLI. supported by the bundled Pi SDK; PI WEB does not load or convert incompatible fork formats, migrate
profile data, or repartition PI WEB-managed archives when the profile changes.
</p> </p>
<div class="code-card"> <div class="code-card">
<pre><code>{ <pre><code>{
"agent": { "agent": {
"command": "my-pi-fork", "command": "pi-lab",
"dir": "/opt/my-pi-fork/agent" "dir": "/opt/pi-profiles/lab"
} }
}</code></pre> }</code></pre>
</div> </div>
<p> <p>
For example, a fork profile can set <code>agent.command</code> to <code>my-pi-fork</code> and An alternate command always requires an explicit state directory. The command must be a safe bare
<code>agent.dir</code> to <code>/opt/my-pi-fork/agent</code>. executable name such as <code>pi-lab</code> or a host-absolute executable path such as
<code>/opt/pi/bin/pi</code>; relative paths, shell expressions, and launcher strings are rejected. The
state directory must be host-absolute or start with <code>~</code>. In a federated save, the gateway
transports Unix and Windows absolute paths without reinterpreting them, and the target machine validates
and returns the persisted profile.
</p> </p>
<p> <p>
Environment variables take precedence over the config file. <code>PI_WEB_AGENT_COMMAND</code> selects the Environment variables take precedence over the config file. <code>PI_WEB_AGENT_COMMAND</code> selects the
command, <code>PI_WEB_AGENT_DIR</code> sets the state directory, and <code>PI_WEB_AGENT_SESSION_DIR</code> companion CLI, <code>PI_WEB_AGENT_DIR</code> sets the profile state directory, and
overrides session storage separately from <code>agent.dir</code>. Existing Pi Coding Agent env names <code>PI_WEB_AGENT_SESSION_DIR</code> overrides session storage separately from <code>agent.dir</code>. The
(<code>PI_CODING_AGENT_DIR</code> and <code>PI_CODING_AGENT_SESSION_DIR</code>) remain supported only for legacy <code>PI_CODING_AGENT_DIR</code> and <code>PI_CODING_AGENT_SESSION_DIR</code> names apply only to a
Pi compatibility; alternate commands should use the explicit PI WEB variables or config keys. canonical Pi companion command; PI WEB never derives ambient environment-variable names from an arbitrary
command. Use the explicit <code>PI_WEB_AGENT_*</code> names for alternate commands.
<code>PI_WEB_AGENT_DIR</code> is an unconditional override, while a legacy
<code>PI_CODING_AGENT_DIR</code> override stops applying when Settings selects an alternate command so the
command and directory can transition together.
</p> </p>
<p> <p>
Session directory overrides are environment-only; use <code>PI_WEB_AGENT_SESSION_DIR</code> unless you are The session daemon resolves the persisted desired values plus its environment once at startup. That
intentionally using the legacy Pi-compatible <code>PI_CODING_AGENT_SESSION_DIR</code> name. 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,
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.
</p> </p>
<div class="callout warning"> <div class="callout warning">
In <strong>Settings → Session daemon</strong>, agent settings are saved on the selected machine. Restart If the session daemon cannot report a valid active profile, profile-dependent package and plugin
the session daemon on that machine after changing them. The web/API process can display the new config operations report unavailable instead of falling back to independently resolved config. A package-managed
immediately, and status/plugin discovery may re-read it on later requests, but active session runtime update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi
ownership is intentionally long-lived. 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.
</div> </div>
</section> </section>
@@ -592,24 +609,35 @@
<h3><code>subsessions</code></h3> <h3><code>subsessions</code></h3>
<p> <p>
Boolean. Beta. Controls whether agents receive the tracked-subsession tools: Boolean. Beta. Controls whether agents receive the tracked-subsession tools:
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, and <code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>,
<code>read_subsession</code>. Defaults to <code>false</code> and also requires <code>spawnSessions</code> <code>read_subsession</code>, and <code>yield_to_subsessions</code>. Defaults to <code>false</code> and also
to be enabled. requires <code>spawnSessions</code> to be enabled.
</p> </p>
<p> <p>
Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child Tracked subsessions are join-oriented. Calling <code>spawn_subsession</code> returns immediately, so the
stops working, and inspect their status and transcripts. Calling <code>spawn_subsession</code> returns parent can continue independent work while the child runs. Work whose result the parent does not need to
immediately. The parent can continue independent work while treating every child whose result it needs join belongs in the fire-and-forget <code>spawn_session</code> tool instead.
as pending. Before producing work that depends on those results, the parent reaches a join point and
yields until every required child has sent a completion notice.
</p> </p>
<p> <p>
A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current At a join point, after finishing its independent work, the parent calls
turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves <code>yield_to_subsessions</code> alone as the final action in its tool batch. Pi ends a tool batch early
one pending child; after processing it, the parent yields again if another required child is pending. only when every result in that batch is terminating. If any tracked child is still working, the action
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> provide ends the current agent run so the parent becomes idle. If none are working, it does not end the run and
on-demand status and transcript inspection for deliberate progress checks or recovery. Completion clearly reports that there is nothing to wait for.
notifications, rather than polling these tools, are the normal synchronization mechanism. </p>
<p>
A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other
tracked children still working, so the parent can continue work or call
<code>yield_to_subsessions</code> again at the next join point. Further notices arrive automatically; do
not poll.
</p>
<p>
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> never yield
or change control flow. They are for deliberate inspection or recovery, not completion polling. While a
child works, agent-facing <code>check_subsession</code> and <code>read_subsession</code> withhold partial
output and direct the parent to continue independent work or yield at the join point. Output becomes
available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled
marker and the child output or transcript always comes last.
</p> </p>
<p> <p>
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
+22 -18
View File
@@ -1,6 +1,6 @@
# PI WEB configuration reference # PI WEB configuration reference
PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, agent runtime selection, and session-daemon tools. PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, Pi-compatible agent profiles and companion CLIs, and session-daemon tools.
This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>. This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>.
@@ -11,7 +11,7 @@ PI WEB uses two config files:
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`. - **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings. - **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: agent runtime selection, session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway. Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: the Pi-compatible agent profile and companion CLI, session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages. Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages.
@@ -112,8 +112,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed | | External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
| 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 |
| Agent 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 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, and sessions | | 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 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 |
@@ -128,8 +128,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API | | Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API |
| Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override | | Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override |
| Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override | | Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override |
| Agent session storage directory | — | `PI_WEB_AGENT_SESSION_DIR` (`PI_CODING_AGENT_SESSION_DIR` for Pi compatibility) | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override | | Agent profile session storage directory | — | `PI_WEB_AGENT_SESSION_DIR` (`PI_CODING_AGENT_SESSION_DIR` for Pi compatibility) | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override |
| Agent config directory | — | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Web/API + session daemon env | Not supported locally | Restart services | | Agent profile state directory | — | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Web/API + session daemon env | Not supported locally | Restart services |
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes | | Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes |
## Key details ## Key details
@@ -183,38 +183,42 @@ For machine federation, Settings saves the global upload default on the selected
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload. The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.
### Agent runtime selection ### Pi-compatible agent profile and companion CLI
`agent.command` controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows. It defaults to `pi`. Set it only when diagnostics and package-managed update checks should target another compatible command; the embedded session runtime still uses PI WEB's SDK integration. `agent.command` selects the Pi-compatible companion CLI used by `pi-web doctor` and, when it can be generated safely, package-managed update commands. It defaults to `pi`. This setting does **not** replace the embedded runtime: every session continues to use PI WEB's bundled Pi SDK.
`agent.dir` controls which compatible agent state directory PI WEB reads for auth providers, model settings, settings, and session metadata. It defaults to `~/.pi/agent` only for the default `pi` command. Set it explicitly when you want an isolated Pi profile or when `agent.command` points at another compatible CLI. `agent.dir` selects the Pi-compatible state profile used for auth providers, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugin discovery. It defaults to `~/.pi/agent` only for a canonical Pi companion command. The directory must use the data layout supported by the bundled Pi SDK; PI WEB does not load or convert incompatible fork formats, migrate profile data, or repartition PI WEB-managed archives when the profile changes.
```json ```json
{ {
"agent": { "agent": {
"command": "my-pi-fork", "command": "pi-lab",
"dir": "/opt/my-pi-fork/agent" "dir": "/opt/pi-profiles/lab"
} }
} }
``` ```
For example, a fork profile can set `agent.command` to `my-pi-fork` and `agent.dir` to `/opt/my-pi-fork/agent`. An alternate command always requires an explicit state directory. The command must be a safe bare executable name such as `pi-lab` or a host-absolute executable path such as `/opt/pi/bin/pi`; relative paths, shell expressions, and launcher strings are rejected. The state directory must be host-absolute or start with `~`. In a federated save, the gateway transports Unix and Windows absolute paths without reinterpreting them, and the target machine validates and returns the persisted profile.
Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the command, `PI_WEB_AGENT_DIR` sets the state directory, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. Existing Pi Coding Agent env names (`PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`) remain supported only for Pi compatibility; alternate commands should use the explicit PI WEB variables or config keys. 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.
Session directory overrides are environment-only; use `PI_WEB_AGENT_SESSION_DIR` unless you are intentionally using the legacy Pi-compatible `PI_CODING_AGENT_SESSION_DIR` name. 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.
In **Settings → Session daemon**, agent settings are saved on the selected machine. Restart the session daemon on that machine after changing them. The web/API process can display the new config immediately, and status/plugin discovery may re-read it on later requests, but active session runtime ownership is intentionally long-lived. 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.
### Session daemon tools ### Session daemon tools
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, and `read_subsession`. It defaults to `false` and also requires `spawnSessions` to be enabled. `subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, `read_subsession`, and `yield_to_subsessions`. It defaults to `false` and also requires `spawnSessions` to be enabled.
Tracked subsessions let an agent delegate work to child sessions, receive a notification when each child stops working, and inspect their status and transcripts. Calling `spawn_subsession` returns immediately. The parent can continue independent work while treating every child whose result it needs as pending. Before producing work that depends on those results, the parent reaches a join point and yields until every required child has sent a completion notice. Tracked subsessions are join-oriented. Calling `spawn_subsession` returns immediately, so the parent can continue independent work while the child runs. Work whose result the parent does not need to join belongs in the fire-and-forget `spawn_session` tool instead.
A completion notice wakes an idle parent. If the parent is busy, the notice queues until the current turn ends rather than interrupting in-flight work. For multiple required children, each notice resolves one pending child; after processing it, the parent yields again if another required child is pending. `list_subsessions`, `check_subsession`, and `read_subsession` provide on-demand status and transcript inspection for deliberate progress checks or recovery. Completion notifications, rather than polling these tools, are the normal synchronization mechanism. At a join point, after finishing its independent work, the parent calls `yield_to_subsessions` alone as the final action in its tool batch. Pi ends a tool batch early only when every result in that batch is terminating. If any tracked child is still working, the action ends the current agent run so the parent becomes idle. If none are working, it does not end the run and clearly reports that there is nothing to wait for.
A completion notice wakes an idle parent or queues behind in-flight work. Each notice lists any other tracked children still working, so the parent can continue work or call `yield_to_subsessions` again at the next join point. Further notices arrive automatically; do not poll.
`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. In notices and inspection results, PI WEB guidance precedes a labeled marker and the child output or transcript always comes last.
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
+30
View File
@@ -250,6 +250,36 @@ describe("session API compatibility", () => {
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt"); expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" }); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" });
}); });
it("clears a session queue through an encoded machine route and parses the returned status", async () => {
const fetchMock = stubJsonFetch({
sessionId: "s /?",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
tokens: { input: 3, output: 2, cacheRead: 1, cacheWrite: 0, total: 6 },
cost: 0.25,
ignored: "not part of SessionStatus",
});
await expect(sessionsApi.clearQueue({ id: "s /?", cwd: "/repo with spaces" }, "remote /?")).resolves.toEqual({
sessionId: "s /?",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 3, output: 2, cacheRead: 1, cacheWrite: 0, total: 6 },
cost: 0.25,
});
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/queue/clear");
expect(init?.method).toBe("POST");
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" });
});
}); });
describe("machine-scoped file suggestion API", () => { describe("machine-scoped file suggestion API", () => {
+1
View File
@@ -209,6 +209,7 @@ export const sessionsApi = {
deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }), deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage), messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage),
status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus), status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse), models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }), setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }), cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
@@ -64,6 +64,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)), ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)), ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)), ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.clearQueue(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)),
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)), ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)), ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
+2 -2
View File
@@ -54,13 +54,13 @@ export class AuthDialog extends LitElement {
case "method": return html` case "method": return html`
<div class="options"> <div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button> <button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the configured agent auth.json</small></button> <button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the active Pi-compatible profile's auth.json</small></button>
</div> </div>
`; `;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`; case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
case "apiKey": return html` case "apiKey": return html`
<div class="form"> <div class="form">
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored in the configured agent <code>auth.json</code>.</p> <p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored in the active Pi-compatible profile's <code>auth.json</code>.</p>
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}> <input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null} ${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div> <div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
+91 -7
View File
@@ -1,5 +1,6 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { QueuedSessionMessage, SessionStatus } from "../api";
import type { ChatLine } from "./shared"; import type { ChatLine } from "./shared";
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView"; import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
@@ -12,19 +13,61 @@ describe("chatQueuedMessageSections", () => {
expect(sections).toEqual([ expect(sections).toEqual([
{ {
source: "client",
heading: "Queued until session starts", heading: "Queued until session starts",
detail: "Will send once the backend session is ready", detail: "Will send once the backend session is ready",
messages: [{ kind: "followUp", text: "queued before start" }], messages: [{ kind: "followUp", text: "queued before start" }],
}, },
{ {
source: "server",
heading: "Queued messages", heading: "Queued messages",
detail: "1 pending · Stop clears the queue", detail: "1 pending",
messages: [{ kind: "steer", text: "server queued" }], messages: [{ kind: "steer", text: "server queued" }],
}, },
]); ]);
}); });
}); });
describe("ChatView queued-message clear action", () => {
// Direct handler extraction keeps this node-environment test focused on the
// Clear queue template wiring without introducing a component-wide DOM shim.
it("renders an accessible server-queue action and invokes its callback", () => {
const view = new ChatView();
const onClearServerQueue = vi.fn();
view.status = queuedStatus([{ kind: "steer", text: "server queued" }]);
view.canClearServerQueue = true;
view.onClearServerQueue = onClearServerQueue;
const rendered = renderQueuedMessages(view);
const markup = templateStaticMarkup(rendered);
expect(markup).toContain('type="button"');
expect(markup).toContain('title="Clear queued messages without stopping active work"');
expect(markup).toContain(">Clear queue</button>");
templateEventHandler(rendered, "Clear queue")(new Event("click"));
expect(onClearServerQueue).toHaveBeenCalledOnce();
});
it("hides the action when the selected runtime does not support clearing", () => {
const view = new ChatView();
view.status = queuedStatus([{ kind: "followUp", text: "server queued" }]);
view.canClearServerQueue = false;
view.onClearServerQueue = vi.fn();
expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue");
});
it("does not expose the server action for the separate client pending-start queue", () => {
const view = new ChatView();
view.status = queuedStatus([]);
view.clientQueuedMessages = [{ kind: "followUp", text: "waiting for session start" }];
view.canClearServerQueue = true;
view.onClearServerQueue = vi.fn();
expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue");
});
});
describe("chatMessageMetadataLabel", () => { describe("chatMessageMetadataLabel", () => {
it("uses one full date and model label without a model prefix", () => { it("uses one full date and model label without a model prefix", () => {
const timestamp = "2026-07-10T19:15:30.000Z"; const timestamp = "2026-07-10T19:15:30.000Z";
@@ -103,10 +146,17 @@ interface GroupBodyRenderCall {
startIndex: number; startIndex: number;
} }
type RenderQueuedMessages = (this: ChatView) => TemplateResult;
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult; type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult; type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
type TemplateEventHandler = (event: Event) => void; type TemplateEventHandler = (event: Event) => void;
function renderQueuedMessages(view: ChatView): TemplateResult {
const method: unknown = Reflect.get(view, "renderQueuedMessages");
if (!isRenderQueuedMessages(method)) throw new Error("ChatView.renderQueuedMessages is not callable");
return method.call(view);
}
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult { function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
const method: unknown = Reflect.get(view, "renderMessageGroup"); const method: unknown = Reflect.get(view, "renderMessageGroup");
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable"); if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
@@ -125,6 +175,10 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
return calls; return calls;
} }
function isRenderQueuedMessages(value: unknown): value is RenderQueuedMessages {
return typeof value === "function";
}
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup { function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
return typeof value === "function"; return typeof value === "function";
} }
@@ -134,13 +188,30 @@ function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBo
} }
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
const strings = templateStrings(template); let handler: TemplateEventHandler | undefined;
const values = templateValues(template); visit(template);
if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`);
return handler;
function visit(value: unknown): void {
if (handler !== undefined) return;
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
const strings = templateStrings(value);
const values = templateValues(value);
for (let index = 0; index < values.length; index += 1) { for (let index = 0; index < values.length; index += 1) {
const value = values[index]; const candidate = values[index];
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value; const isNearMarker = strings[index]?.includes(marker) === true || strings[index + 1]?.includes(marker) === true;
if (isNearMarker && isTemplateEventHandler(candidate)) {
handler = candidate;
return;
}
visit(candidate);
}
} }
throw new Error(`Expected template event handler after ${marker}`);
} }
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
@@ -221,3 +292,16 @@ function isTemplateResult(value: unknown): value is TemplateResult {
function isStringArray(value: unknown): value is string[] { function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
} }
function queuedStatus(queuedMessages: QueuedSessionMessage[]): SessionStatus {
return {
sessionId: "session-1",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: queuedMessages.length,
queuedMessages,
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
+14 -2
View File
@@ -39,6 +39,7 @@ function clampNumber(value: number, min: number, max: number): number {
} }
export interface QueuedMessageSection { export interface QueuedMessageSection {
source: "client" | "server";
heading: string; heading: string;
detail: string; detail: string;
messages: QueuedSessionMessage[]; messages: QueuedSessionMessage[];
@@ -46,8 +47,8 @@ export interface QueuedMessageSection {
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] { export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
return [ return [
clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued }, clientQueued.length === 0 ? undefined : { source: "client", heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued }, serverQueued.length === 0 ? undefined : { source: "server", heading: "Queued messages", detail: `${String(serverQueued.length)} pending`, messages: serverQueued },
].filter((section): section is QueuedMessageSection => section !== undefined); ].filter((section): section is QueuedMessageSection => section !== undefined);
} }
@@ -89,6 +90,8 @@ export class ChatView extends LitElement {
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = []; @property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus; @property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity; @property({ attribute: false }) activity?: SessionActivity;
@property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onLoadMore?: () => void; @property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement; @query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true; @state() private pinnedToBottom = true;
@@ -123,6 +126,9 @@ export class ChatView extends LitElement {
private readonly onPageHide = () => { private readonly onPageHide = () => {
this.saveScrollPosition(); this.saveScrollPosition();
}; };
private readonly handleClearServerQueue = (): void => {
this.onClearServerQueue?.();
};
override connectedCallback(): void { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
@@ -261,12 +267,18 @@ export class ChatView extends LitElement {
} }
private renderQueuedMessageList(section: QueuedMessageSection) { private renderQueuedMessageList(section: QueuedMessageSection) {
const canClear = section.source === "server" && this.canClearServerQueue && this.onClearServerQueue !== undefined;
return html` return html`
<aside class="queued-messages" aria-live="polite"> <aside class="queued-messages" aria-live="polite">
<div class="queued-header"> <div class="queued-header">
<div class="queued-heading">
<strong>${section.heading}</strong> <strong>${section.heading}</strong>
<small>${section.detail}</small> <small>${section.detail}</small>
</div> </div>
${canClear ? html`
<button type="button" class="queued-clear-button" title="Clear queued messages without stopping active work" @click=${this.handleClearServerQueue}>Clear queue</button>
` : null}
</div>
${section.messages.map((message, index) => html` ${section.messages.map((message, index) => html`
<div class="queued-message"> <div class="queued-message">
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span> <span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
@@ -0,0 +1,151 @@
import type { TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { MachineRuntime, SessionInfo, SessionStatus } from "../api";
import { initialAppState, type AppState } from "../appState";
import { SessionController } from "../controllers/sessionController";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { PiWebApp } from "./PiWebApp";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("PiWebApp queued-message clear wiring", () => {
it("passes a stable supported-runtime callback through to SessionController", () => {
const app = createApp();
const state = stateWithRuntime(runtimeWithCapabilities([PI_WEB_CAPABILITIES.sessionsClearQueue]));
setAppState(app, state);
const controller = appSessionController(app);
const clearServerQueue = vi.spyOn(controller, "clearServerQueue").mockResolvedValue(undefined);
const firstRender = renderChatView(app, state);
const secondRender = renderChatView(app, state);
const firstCallback = templateCallbackAfterMarker(firstRender, ".onClearServerQueue=");
const secondCallback = templateCallbackAfterMarker(secondRender, ".onClearServerQueue=");
expect(templateValueAfterMarker(firstRender, ".canClearServerQueue=")).toBe(true);
expect(secondCallback).toBe(firstCallback);
firstCallback();
expect(clearServerQueue).toHaveBeenCalledOnce();
});
it("passes false when runtime discovery is unavailable, unhealthy, or lacks the capability", () => {
const app = createApp();
const runtimes: (MachineRuntime | undefined)[] = [
undefined,
{ ...runtimeWithCapabilities([PI_WEB_CAPABILITIES.sessionsClearQueue]), ok: false },
runtimeWithCapabilities([PI_WEB_CAPABILITIES.sessionsReload]),
];
for (const runtime of runtimes) {
const state = stateWithRuntime(runtime);
setAppState(app, state);
expect(templateValueAfterMarker(renderChatView(app, state), ".canClearServerQueue=")).toBe(false);
}
});
});
type RenderChatView = (this: PiWebApp, state: AppState, session: SessionInfo) => TemplateResult;
type ClearServerQueueCallback = () => void;
function createApp(): PiWebApp {
const storage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
return new PiWebApp();
}
function stateWithRuntime(runtime: MachineRuntime | undefined): AppState {
const session: SessionInfo = {
id: "session-1",
cwd: "/repo",
path: "/repo/session-1.jsonl",
created: "2026-07-14T00:00:00.000Z",
modified: "2026-07-14T00:00:00.000Z",
messageCount: 1,
firstMessage: "hello",
};
return {
...initialAppState(),
selectedSession: session,
status: queuedStatus(),
machineRuntimes: runtime === undefined ? {} : { local: runtime },
};
}
function runtimeWithCapabilities(capabilities: NonNullable<MachineRuntime["capabilities"]>): MachineRuntime {
return { machineId: "local", ok: true, checkedAt: "2026-07-14T00:00:00.000Z", capabilities };
}
function queuedStatus(): SessionStatus {
return {
sessionId: "session-1",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 1,
queuedMessages: [{ kind: "followUp", text: "queued" }],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
function setAppState(app: PiWebApp, state: AppState): void {
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state");
}
function appSessionController(app: PiWebApp): SessionController {
const controller: unknown = Reflect.get(app, "sessions");
if (!(controller instanceof SessionController)) throw new Error("PiWebApp SessionController was unavailable");
return controller;
}
function renderChatView(app: PiWebApp, state: AppState): TemplateResult {
const method: unknown = Reflect.get(app, "renderChatView");
if (!isRenderChatView(method)) throw new Error("PiWebApp.renderChatView is not callable");
const session = state.selectedSession;
if (session === undefined) throw new Error("Expected a selected session");
return method.call(app, state, session);
}
function isRenderChatView(value: unknown): value is RenderChatView {
return typeof value === "function";
}
function templateCallbackAfterMarker(template: TemplateResult, marker: string): ClearServerQueueCallback {
const value = templateValueAfterMarker(template, marker);
if (!isClearServerQueueCallback(value)) throw new Error(`Expected callback after ${marker}`);
return value;
}
function isClearServerQueueCallback(value: unknown): value is ClearServerQueueCallback {
return typeof value === "function";
}
function templateValueAfterMarker(template: TemplateResult, marker: string): unknown {
const strings = templateStrings(template);
const values = templateValues(template);
const index = strings.findIndex((part) => part.includes(marker));
if (index < 0) throw new Error(`Expected template marker ${marker}`);
return values[index];
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
+19 -4
View File
@@ -1023,6 +1023,11 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload); return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
} }
private canClearServerQueue(): boolean {
const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsClearQueue);
}
private canCleanupSessions(): boolean { private canCleanupSessions(): boolean {
const runtime = this.selectedMachineRuntime(); const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup); return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
@@ -1854,9 +1859,9 @@ export class PiWebApp extends LitElement {
void this.sessions.send(text, streamingBehavior, attachments, delivery); void this.sessions.send(text, streamingBehavior, attachments, delivery);
} }
// Stable handler identities for <prompt-editor>. Inlined arrow closures would // Stable handler identities for child components. Inlined arrow closures
// be a fresh reference on every render, forcing Lit to re-commit the bindings // would be a fresh reference on every render, forcing Lit to re-commit the
// each time the app re-renders; bound class fields keep them constant. // bindings each time the app re-renders; bound class fields keep them constant.
private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => { private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => {
this.sendPrompt(text, streamingBehavior, attachments, delivery); this.sendPrompt(text, streamingBehavior, attachments, delivery);
}; };
@@ -1865,6 +1870,10 @@ export class PiWebApp extends LitElement {
void this.sessions.stopActiveWork(); void this.sessions.stopActiveWork();
}; };
private readonly handleClearServerQueue = (): void => {
void this.sessions.clearServerQueue();
};
private readonly handleSelectModel = (): void => { private readonly handleSelectModel = (): void => {
void this.openModelDialog(); void this.openModelDialog();
}; };
@@ -1873,6 +1882,12 @@ export class PiWebApp extends LitElement {
void this.openThinkingDialog(); void this.openThinkingDialog();
}; };
private renderChatView(state: AppState, session: SessionInfo) {
return html`
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
`;
}
private renderContextBar() { private renderContextBar() {
if (!this.appShell.isMobileNavigationLayout) return null; if (!this.appShell.isMobileNavigationLayout) return null;
return html` return html`
@@ -1931,7 +1946,7 @@ export class PiWebApp extends LitElement {
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div> <div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html` ${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[state.selectedSession.id] ?? []} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> ${this.renderChatView(state, state.selectedSession)}
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor> <prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor>
<status-bar .status=${state.status}></status-bar> <status-bar .status=${state.status}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null} ${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
@@ -87,7 +87,7 @@ describe("settings-dialog session daemon machine targeting", () => {
await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } }); await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
expect(saveSpy).not.toHaveBeenCalled(); expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again."); expect(getDialogProperty(dialog, "sessiondError")).toBe("Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
}); });
it("saves a remote agent profile when granular support is advertised", async () => { it("saves a remote agent profile when granular support is advertised", async () => {
+1 -1
View File
@@ -437,7 +437,7 @@ export class SettingsDialog extends LitElement {
if (config.agent !== undefined) { if (config.agent !== undefined) {
const profileSupport = this.agentProfileSettingsSupport(target); const profileSupport = this.agentProfileSettingsSupport(target);
if (!isAgentProfileSettingsSupported(profileSupport)) { if (!isAgentProfileSettingsSupported(profileSupport)) {
this.sessiondError = profileSupport.message ?? `Agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`; this.sessiondError = profileSupport.message ?? `Pi-compatible agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`;
return; return;
} }
} }
@@ -19,14 +19,14 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [ expectTextOrder(rendered, [
"Session daemon", "Session daemon",
"These settings affect the long-lived session runtime on Lab Mac (remote machine).", "Select the Pi-compatible agent profile and companion CLI for Lab Mac (remote machine).",
"Reload", "Reload",
"Agent profile restart required on Lab Mac (remote machine)", "Pi-compatible agent profile restart required on Lab Mac (remote machine)",
"Run <code>pi-web restart</code> on that machine", "Run <code>pi-web restart</code> on that machine",
"Config file", "Config file",
"Companion CLI command", "Companion CLI command",
"agent-lab", "agent-lab",
"Agent state directory", "Profile state directory",
"/srv/agent-lab", "/srv/agent-lab",
"Allow agents to start sessions", "Allow agents to start sessions",
]); ]);
@@ -44,7 +44,7 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [ expectTextOrder(rendered, [
"Failed to save session-daemon config.", "Failed to save session-daemon config.",
"Session daemon settings saved.", "Session daemon settings saved.",
"Agent profile restart required on local (local gateway)", "Pi-compatible agent profile restart required on local (local gateway)",
"Config file", "Config file",
]); ]);
}); });
@@ -69,7 +69,7 @@ export class SettingsSessiondPanel extends LitElement {
<code>${config.path}</code> <code>${config.path}</code>
</div> </div>
<form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}> <form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}>
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Agent profile editing is unavailable for this machine."}</div>`} ${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Pi-compatible agent profile editing is unavailable for this machine."}</div>`}
<label class="field"> <label class="field">
<span class="field-heading"> <span class="field-heading">
<span>Companion CLI command</span> <span>Companion CLI command</span>
@@ -89,7 +89,7 @@ export class SettingsSessiondPanel extends LitElement {
</label> </label>
<label class="field"> <label class="field">
<span class="field-heading"> <span class="field-heading">
<span>Agent state directory</span> <span>Profile state directory</span>
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null} ${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span> </span>
<input <input
@@ -102,7 +102,7 @@ export class SettingsSessiondPanel extends LitElement {
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked} ?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
@input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }} @input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }}
> >
<small>Choose the compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small> <small>Choose the Pi-compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
</label> </label>
<footer class="form-actions"> <footer class="form-actions">
<button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button> <button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button>
@@ -167,13 +167,13 @@ export class SettingsSessiondPanel extends LitElement {
if (activation === "restart-required") { if (activation === "restart-required") {
notices.push({ notices.push({
type: "warning", type: "warning",
title: `Agent profile restart required on ${this.targetLabel}`, title: `Pi-compatible agent profile restart required on ${this.targetLabel}`,
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`, content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
}); });
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") { } else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
notices.push({ notices.push({
type: "info", type: "info",
title: `Active agent profile unavailable on ${this.targetLabel}`, title: `Active Pi-compatible agent profile unavailable on ${this.targetLabel}`,
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.", content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
}); });
} }
@@ -274,5 +274,5 @@ function errorMessage(error: unknown): string {
} }
function sessiondDescription(targetLabel: string): string { function sessiondDescription(targetLabel: string): string {
return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`; return `Select the Pi-compatible agent profile and companion CLI for ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
} }
@@ -45,7 +45,7 @@ describe("selected-machine settings target helpers", () => {
expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" }); expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
expect(agentProfileSettingsSupport(target, undefined)).toEqual({ expect(agentProfileSettingsSupport(target, undefined)).toEqual({
state: "unknown", state: "unknown",
message: "Agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.", message: "Pi-compatible agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.",
}); });
expect(agentProfileSettingsSupport(target, { expect(agentProfileSettingsSupport(target, {
ok: true, ok: true,
@@ -56,7 +56,7 @@ describe("selected-machine settings target helpers", () => {
expect(isAgentProfileSettingsSupported(unsupported)).toBe(false); expect(isAgentProfileSettingsSupported(unsupported)).toBe(false);
expect(unsupported).toEqual({ expect(unsupported).toEqual({
state: "unsupported", state: "unsupported",
message: "Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.", message: "Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.",
}); });
}); });
@@ -37,13 +37,13 @@ export function agentProfileSettingsSupport(target: SettingsMachineTarget, runti
if (runtime?.ok !== true) { if (runtime?.ok !== true) {
return { return {
state: "unknown", state: "unknown",
message: `Agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`, message: `Pi-compatible agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`,
}; };
} }
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" }; if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" };
return { return {
state: "unsupported", state: "unsupported",
message: `Agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`, message: `Pi-compatible agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`,
}; };
} }
+6 -3
View File
@@ -305,9 +305,12 @@ export const chatStyles = css`
.history-load-button:hover, .history-load-button:focus { border-color: var(--pi-accent); color: var(--pi-text-bright); } .history-load-button:hover, .history-load-button:focus { border-color: var(--pi-accent); color: var(--pi-text-bright); }
.history-load-button:disabled { cursor: default; opacity: .55; } .history-load-button:disabled { cursor: default; opacity: .55; }
.queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); overflow: hidden; } .queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); overflow: hidden; }
.queued-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } .queued-header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px; }
.queued-header strong { color: var(--pi-warning); } .queued-heading { min-width: 0; flex: 1 1 180px; display: grid; gap: 2px; }
.queued-header small { color: var(--pi-muted); } .queued-heading strong { color: var(--pi-warning); }
.queued-heading small { color: var(--pi-muted); }
.queued-clear-button { flex: 0 0 auto; border: 1px solid var(--pi-warning-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-warning); padding: 5px 10px; font: 12px system-ui, sans-serif; white-space: nowrap; cursor: pointer; }
.queued-clear-button:hover, .queued-clear-button:focus { border-color: var(--pi-warning); color: var(--pi-text-bright); }
.queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); } .queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); }
.queued-message:first-of-type { padding-top: 0; border-top: 0; } .queued-message:first-of-type { padding-top: 0; border-top: 0; }
.queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { SessionController } from "./sessionController";
import { defaultApi, deferred, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
function machine(id: string): NonNullable<AppState["selectedMachine"]> {
return { id, name: id, kind: "remote", createdAt: "now", updatedAt: "now" };
}
describe("SessionController server queue clearing", () => {
it("applies the returned status to the selected session without changing client-side queued sends", async () => {
const queuedStatus: SessionStatus = {
...status(oldSession.id),
isStreaming: true,
pendingMessageCount: 2,
queuedMessages: [
{ kind: "steer", text: "adjust course" },
{ kind: "followUp", text: "then summarize" },
],
};
const clearedStatus: SessionStatus = { ...queuedStatus, pendingMessageCount: 0, queuedMessages: [] };
const clientQueuedSends = [{ kind: "followUp" as const, text: "waiting for session creation" }];
const clearCalls: { sessionId: string; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedMachine: machine("remote-a"),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: queuedStatus,
sessionStatuses: { [oldSession.id]: queuedStatus },
clientQueuedSessionMessages: { [oldSession.id]: clientQueuedSends },
};
const api: typeof defaultApi = {
...defaultApi,
clearQueue: (session, machineId) => {
clearCalls.push({ sessionId: sessionLookupId(session), machineId: machineId ?? "local" });
return Promise.resolve(clearedStatus);
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.clearServerQueue();
expect(clearCalls).toEqual([{ sessionId: oldSession.id, machineId: "remote-a" }]);
expect(state.status).toEqual(clearedStatus);
expect(state.sessionStatuses[oldSession.id]).toEqual(clearedStatus);
expect(state.clientQueuedSessionMessages[oldSession.id]).toBe(clientQueuedSends);
});
it("does not apply a response after another session is selected", async () => {
const request = deferred<SessionStatus>();
const oldStatus: SessionStatus = { ...status(oldSession.id), pendingMessageCount: 1, queuedMessages: [{ kind: "followUp", text: "old queue" }] };
const replacementStatus: SessionStatus = { ...status(replacementSession.id), pendingMessageCount: 3, queuedMessages: [{ kind: "steer", text: "new queue" }] };
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession, replacementSession],
status: oldStatus,
sessionStatuses: { [oldSession.id]: oldStatus, [replacementSession.id]: replacementStatus },
};
const api: typeof defaultApi = { ...defaultApi, clearQueue: () => request.promise };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const clearing = controller.clearServerQueue();
state = { ...state, selectedSession: replacementSession, status: replacementStatus };
request.resolve({ ...oldStatus, pendingMessageCount: 0, queuedMessages: [] });
await clearing;
expect(state.status).toBe(replacementStatus);
expect(state.sessionStatuses[oldSession.id]).toBe(oldStatus);
expect(state.sessionStatuses[replacementSession.id]).toBe(replacementStatus);
});
it("does not apply a response after the selected machine changes", async () => {
const request = deferred<SessionStatus>();
const machineBStatus: SessionStatus = { ...status(oldSession.id), pendingMessageCount: 4, queuedMessages: [{ kind: "followUp", text: "machine B queue" }] };
let state: AppState = {
...initialAppState(),
selectedMachine: machine("remote-a"),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: status(oldSession.id),
sessionStatuses: { [oldSession.id]: status(oldSession.id) },
};
const api: typeof defaultApi = { ...defaultApi, clearQueue: () => request.promise };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const clearing = controller.clearServerQueue();
state = {
...state,
selectedMachine: machine("remote-b"),
status: machineBStatus,
sessionStatuses: { [oldSession.id]: machineBStatus },
};
request.resolve(status(oldSession.id));
await clearing;
expect(state.status).toBe(machineBStatus);
expect(state.sessionStatuses[oldSession.id]).toBe(machineBStatus);
});
it("reports queue-clear failures through the application error state", async () => {
const queuedStatus: SessionStatus = { ...status(oldSession.id), pendingMessageCount: 1, queuedMessages: [{ kind: "steer", text: "keep me" }] };
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: queuedStatus,
sessionStatuses: { [oldSession.id]: queuedStatus },
};
const api: typeof defaultApi = { ...defaultApi, clearQueue: () => Promise.reject(new Error("queue clear failed")) };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.clearServerQueue();
expect(state.error).toBe("Error: queue clear failed");
expect(state.status).toBe(queuedStatus);
});
it("does not send a server clear for a client-pending session or discard its queued sends", async () => {
const pendingSession = { ...oldSession, id: "pending-session", clientPendingStart: true as const, machineId: "local" };
const clientQueuedSends = [{ kind: "followUp" as const, text: "send after creation" }];
let clearCalls = 0;
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: pendingSession,
sessions: [pendingSession],
clientQueuedSessionMessages: { [pendingSession.id]: clientQueuedSends },
};
const api: typeof defaultApi = {
...defaultApi,
clearQueue: () => {
clearCalls += 1;
return Promise.resolve(status(pendingSession.id));
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.clearServerQueue();
expect(clearCalls).toBe(0);
expect(state.clientQueuedSessionMessages[pendingSession.id]).toBe(clientQueuedSends);
});
});
@@ -723,6 +723,20 @@ export class SessionController {
} }
} }
async clearServerQueue() {
const state = this.getState();
const session = state.selectedSession;
if (session === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) return;
const machineId = selectedMachineId(state);
const selectionSeq = this.selectionSeq;
try {
const status = await this.api.clearQueue(session, machineId);
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.applyStatus(status);
} catch (error) {
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
}
}
async stopActiveWork() { async stopActiveWork() {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session) return;
@@ -768,11 +782,15 @@ export class SessionController {
} }
private isCurrentRefreshTarget(target: SelectedSessionRefreshTarget): boolean { private isCurrentRefreshTarget(target: SelectedSessionRefreshTarget): boolean {
return this.isCurrentSessionSelection(target.session.id, target.machineId, target.selectionSeq);
}
private isCurrentSessionSelection(sessionId: string, machineId: string, selectionSeq: number): boolean {
const state = this.getState(); const state = this.getState();
const selected = state.selectedSession; const selected = state.selectedSession;
return target.selectionSeq === this.selectionSeq return selectionSeq === this.selectionSeq
&& selectedMachineId(state) === target.machineId && selectedMachineId(state) === machineId
&& selected?.id === target.session.id && selected?.id === sessionId
&& selected.archived !== true && selected.archived !== true
&& !isClientPendingStartSessionInfo(selected); && !isClientPendingStartSessionInfo(selected);
} }
+18
View File
@@ -186,6 +186,24 @@ describe("buildApp remote machine proxy routes", () => {
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" }); expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
}); });
it("proxies remote session queue clearing through the allowlisted route", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const status = { sessionId: "s1", pendingMessageCount: 0, queuedMessages: [] };
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify(status)]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/queue/clear`, payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(status);
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/queue/clear", { cwd: "/repo" });
});
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => { it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>(); const remote = addResponse.json<{ id: string }>();
@@ -28,6 +28,17 @@ describe("machine-scoped session proxy routes", () => {
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]); expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]);
}); });
it("forwards queue-clear mutations and their status through the session daemon", async () => {
const status = { sessionId: "session-1", pendingMessageCount: 0, queuedMessages: [] };
daemon.respondWith({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(status) });
const response = await app.inject({ method: "POST", url: "/api/machines/local/sessions/session-1/queue/clear", payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(status);
expect(daemon.requests).toEqual([{ method: "POST", path: "/sessions/session-1/queue/clear", body: { cwd: "/repo" } }]);
});
it("strips the machine prefix before forwarding auth requests", async () => { it("strips the machine prefix before forwarding auth requests", async () => {
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } }); const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
@@ -46,6 +46,7 @@ describe("delegation tool capability boundary", () => {
"list_subsessions", "list_subsessions",
"check_subsession", "check_subsession",
"read_subsession", "read_subsession",
"yield_to_subsessions",
]); ]);
}); });
@@ -222,6 +222,84 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose(); await service.dispose();
}); });
it("clears runtime and compaction queues without interrupting active work", async () => {
const steeringMessages = ["adjust this turn"];
const followUpMessages = ["then do this"];
const transcript = [{ role: "user", content: "keep this history" }];
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("clear-queue-session", {
messages: transcript,
isStreaming: true,
isCompacting: true,
pendingMessageCount: 2,
getSteeringMessages: () => steeringMessages,
getFollowUpMessages: () => followUpMessages,
});
const clearRuntimeQueue = vi.fn(() => {
const cleared = { steering: [...steeringMessages], followUp: [...followUpMessages] };
steeringMessages.length = 0;
followUpMessages.length = 0;
fake.session.pendingMessageCount = 0;
return cleared;
});
fake.session.clearQueue = clearRuntimeQueue;
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("clear-queue-session"), "queued during compaction", "followUp");
await expect(service.status(sessionRef("clear-queue-session"))).resolves.toMatchObject({
isStreaming: true,
isCompacting: true,
pendingMessageCount: 3,
queuedMessages: [
{ kind: "steer", text: "adjust this turn" },
{ kind: "followUp", text: "then do this" },
{ kind: "followUp", text: "queued during compaction" },
],
});
const status = await service.clearQueue(sessionRef("clear-queue-session"));
expect(clearRuntimeQueue).toHaveBeenCalledOnce();
expect(status).toMatchObject({
isStreaming: true,
isCompacting: true,
pendingMessageCount: 0,
queuedMessages: [],
messageCount: 1,
});
expect(fake.session.messages).toBe(transcript);
expect(fake.calls.prompt).toEqual([]);
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
const publishedStatuses = hub.sessionEvents.filter(({ event }) => event.type === "status.update");
expect(publishedStatuses.at(-1)?.event).toEqual({ type: "status.update", status });
await service.dispose();
});
it("clears an already-empty queue idempotently", async () => {
const fake = fakeRuntime("clear-empty-queue-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
heartbeatIntervalMs: 60_000,
});
const firstStatus = await service.clearQueue(sessionRef("clear-empty-queue-session"));
const secondStatus = await service.clearQueue(sessionRef("clear-empty-queue-session"));
expect(fake.calls.clearQueue).toBe(2);
expect(fake.calls.abort).toBe(0);
expect(firstStatus).toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
expect(secondStatus).toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
await service.dispose();
});
it("clears queued messages when aborting active work", async () => { it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session"); const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -10,10 +10,15 @@ const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => { describe("PiSessionService", () => {
describe("spawnSubsession", () => { describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); const children = childIds.map((childId) => fakeRuntime(childId, {
const created = [parent.runtime, child.runtime]; sessionFile: `/tmp/${childId}.jsonl`,
sessionManager: fakeSessionManager("/workspace-feature"),
}));
const child = children[0];
if (child === undefined) throw new Error("At least one child fixture is required");
const created = [parent.runtime, ...children.map(({ runtime }) => runtime)];
let index = 0; let index = 0;
const createAgentRuntime: RuntimeCreator = async () => { const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve(); await Promise.resolve();
@@ -41,7 +46,7 @@ describe("PiSessionService", () => {
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs, heartbeatIntervalMs,
}); });
return { parent, child, service }; return { parent, child, children, service };
} }
it("records the parent, delivers the prompt, and lists the tracked child", async () => { it("records the parent, delivers the prompt, and lists the tracked child", async () => {
@@ -796,6 +801,41 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("reports other working children in each completion notice", async () => {
const { parent, children, service } = subsessionService(
{ allowed: true, cwd: "/workspace-feature" },
60_000,
["child-1", "child-2"],
);
const [first, second] = children;
if (first === undefined || second === undefined) throw new Error("Expected two child fixtures");
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "first", cwd: "/workspace-feature" });
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "second", cwd: "/workspace-feature" });
first.session.isStreaming = true;
first.emit({ type: "agent_start" });
second.session.isStreaming = true;
second.emit({ type: "agent_start" });
first.session.isStreaming = false;
first.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[0]?.message.content).toBe(
"Subsession child-1 stopped working (idle).\nStill working: child-2. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.\n\n--- SUBSESSION OUTPUT: child-1 ---\n(no output)",
);
second.session.isStreaming = false;
second.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage[1]?.message.content).toBe(
"Subsession child-2 stopped working (idle).\nNo other tracked subsessions are working.\n\n--- SUBSESSION OUTPUT: child-2 ---\n(no output)",
);
await service.dispose();
});
it("notifies via the heartbeat when the child settles without a further event", async () => { it("notifies via the heartbeat when the child settles without a further event", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10); const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
await service.start("/workspace"); await service.start("/workspace");
+24 -1
View File
@@ -896,6 +896,16 @@ export class PiSessionService implements SessionRouteService {
return "idle"; return "idle";
} }
private workingSubsessionIds(parentSessionId: string): string[] {
const childIds = this.subsessionChildren.get(parentSessionId);
if (childIds === undefined) return [];
return [...childIds].filter((childId) => {
const link = this.subsessionLinks.get(childId);
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
return active !== undefined && this.hasActiveWork(active.runtime.session);
});
}
/** /**
* Drive parent notifications from a tracked child's status. Arms a pending * Drive parent notifications from a tracked child's status. Arms a pending
* notification while the child is working, and when it stops fires a single * notification while the child is working, and when it stops fires a single
@@ -915,7 +925,11 @@ export class PiSessionService implements SessionRouteService {
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle"; const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
const finalText = finalAssistantText(historyMessages(session)); const finalText = finalAssistantText(historyMessages(session));
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText); const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`; const workingIds = this.workingSubsessionIds(link.parentSessionId);
const next = workingIds.length === 0
? "No other tracked subsessions are working."
: `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n--- SUBSESSION OUTPUT: ${childId} ---\n${preview}`;
void this.notifyParentOfSubsession(link.parentSessionId, childId, text); void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
} }
@@ -1347,6 +1361,15 @@ export class PiSessionService implements SessionRouteService {
this.unregisterSubsession(session.sessionId); this.unregisterSubsession(session.sessionId);
} }
async clearQueue(ref: PiSessionLookup): Promise<ClientSessionStatus> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.clearCompactionPromptQueue(session.sessionId);
clearSessionQueue(session);
this.publishStatus(session);
return this.statusFromSession(session);
}
async abort(ref: PiSessionLookup): Promise<void> { async abort(ref: PiSessionLookup): Promise<void> {
const active = this.activeForLookup(ref); const active = this.activeForLookup(ref);
if (active === undefined) return; if (active === undefined) return;
+68 -1
View File
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket"; import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js"; import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
@@ -168,6 +168,55 @@ describe("session routes", () => {
} }
}); });
it("clears a session queue with workspace context and returns fresh status", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear", payload: { cwd: requestCwd } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
sessionId: "session-1",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
expect(routeService.clearQueueCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("maps archived queue-clear failures to a mutation error without requiring a body", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
routeService.clearQueueError = new Error("Archived sessions are read-only. Restore the session to continue.");
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear" });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Archived sessions are read-only. Restore the session to continue." });
expect(routeService.clearQueueCalls).toEqual(["session-1"]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("normalizes cleanup requests for preview and execute routes", async () => { it("normalizes cleanup requests for preview and execute routes", async () => {
const routeApp = Fastify({ logger: false }); const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket); await routeApp.register(fastifyWebsocket);
@@ -255,12 +304,14 @@ describe("session routes", () => {
class CapturingRouteSessionService implements SessionRouteService { class CapturingRouteSessionService implements SessionRouteService {
readonly calls: unknown[] = []; readonly calls: unknown[] = [];
readonly reloadCalls: SessionRouteLookup[] = []; readonly reloadCalls: SessionRouteLookup[] = [];
readonly clearQueueCalls: SessionRouteLookup[] = [];
messagesResponse: unknown[] | MessagePage = []; messagesResponse: unknown[] | MessagePage = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = []; readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = []; readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = []; readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = []; readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
reloadError: Error | undefined; reloadError: Error | undefined;
clearQueueError: Error | undefined;
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> { cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
this.cleanupPreviewCalls.push(request); this.cleanupPreviewCalls.push(request);
@@ -294,6 +345,22 @@ class CapturingRouteSessionService implements SessionRouteService {
list(): never { throw unusedRouteMethod("list"); } list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); } start(): never { throw unusedRouteMethod("start"); }
clearQueue(lookup: SessionRouteLookup): Promise<SessionStatus> {
this.clearQueueCalls.push(lookup);
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
return Promise.resolve({
sessionId: sessionIdFromLookup(lookup),
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
}
messages(): Promise<unknown[] | MessagePage> { messages(): Promise<unknown[] | MessagePage> {
return Promise.resolve(this.messagesResponse); return Promise.resolve(this.messagesResponse);
} }
+8
View File
@@ -173,6 +173,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
} }
}); });
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/queue/clear`, async (request, reply) => {
try {
return await sessions.clearQueue(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: AttachmentsRequestBody | undefined }>(`${prefix}/sessions/:sessionId/attachments`, async (request, reply) => { app.post<{ Params: { sessionId: string }; Body: AttachmentsRequestBody | undefined }>(`${prefix}/sessions/:sessionId/attachments`, async (request, reply) => {
try { try {
const body = optionalRecord(request.body); const body = optionalRecord(request.body);
+4 -3
View File
@@ -25,15 +25,16 @@ export type SessionRouteLookup = string | SessionRouteRef;
/** /**
* Route-facing session contract for PI WEB's HTTP/WebSocket API. * Route-facing session contract for PI WEB's HTTP/WebSocket API.
* *
* Keep this surface neutral: implementations may be backed by the native Pi SDK, * Keep transport concerns separate from the bundled Pi SDK implementation so
* an out-of-process agent bridge, or another daemon. Pi-specific lifecycle hooks * routes remain testable. Pi-specific lifecycle hooks such as auth-change
* such as auth-change handling and daemon shutdown stay on the concrete service. * handling and daemon shutdown stay on the concrete service.
*/ */
export interface SessionRouteService { export interface SessionRouteService {
list(cwd: string): Promise<ClientSession[]>; list(cwd: string): Promise<ClientSession[]>;
start(cwd: string): Promise<ClientSession>; start(cwd: string): Promise<ClientSession>;
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>; messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>; status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>; availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>; setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus>; cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus>;
@@ -0,0 +1,163 @@
import type { Api, AssistantMessage, Message, Model } from "@earendil-works/pi-ai";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { runAgentLoop, type AgentEvent, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { createSubsessionToolDefinitions, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
const model: Model<Api> = {
id: "fake-model",
name: "Fake Model",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000,
maxTokens: 100,
};
function extensionContext(): ExtensionContext {
const sessionManager = {
getSessionId: () => "parent-1",
getSessionFile: () => "/sessions/parent-1.jsonl",
};
// The wrapped yield definition only reads the two session-manager methods above.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal integration boundary for a Pi tool definition.
return { sessionManager } as unknown as ExtensionContext;
}
function wrapDefinition(definition: ToolDefinition, ctx: ExtensionContext): AgentTool {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
...(definition.executionMode === undefined ? {} : { executionMode: definition.executionMode }),
execute: (toolCallId, params, signal, onUpdate) => definition.execute(toolCallId, params, signal, onUpdate, ctx),
};
}
function isLlmMessage(agentMessage: AgentMessage): agentMessage is Message {
return agentMessage.role === "user" || agentMessage.role === "assistant" || agentMessage.role === "toolResult";
}
function message(stopReason: "stop" | "toolUse", content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp: 0,
};
}
function streamSequence(messages: AssistantMessage[]): StreamFn {
let index = 0;
return vi.fn(() => {
const next = messages[index];
index += 1;
if (next === undefined) throw new Error("unexpected provider invocation");
if (next.stopReason !== "stop" && next.stopReason !== "toolUse" && next.stopReason !== "length") {
throw new Error(`unsupported fake stop reason ${next.stopReason}`);
}
const stream = createAssistantMessageEventStream();
stream.push({ type: "done", reason: next.stopReason, message: next });
stream.end(next);
return stream;
});
}
async function runYieldBatch(subsessions: SubsessionSummary[], includeSentinel = false) {
const list = vi.fn(() => Promise.resolve(subsessions));
const deps: SubsessionToolDeps = {
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
list,
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
};
const yieldDefinition = createSubsessionToolDefinitions("/workspace", deps)
.find(({ name }) => name === "yield_to_subsessions");
if (yieldDefinition === undefined) throw new Error("missing yield_to_subsessions");
const sentinel = vi.fn(() => Promise.resolve({ content: [{ type: "text" as const, text: "sentinel complete" }], details: {} }));
const sentinelTool: AgentTool = {
name: "sentinel",
label: "Sentinel",
description: "Return normally.",
parameters: Type.Object({}),
execute: sentinel,
};
const firstContent: AssistantMessage["content"] = [
{ type: "toolCall", id: "yield-call", name: "yield_to_subsessions", arguments: {} },
...(includeSentinel
? [{ type: "toolCall" as const, id: "sentinel-call", name: "sentinel", arguments: {} }]
: []),
];
const streamFn = streamSequence([
message("toolUse", firstContent),
message("stop", [{ type: "text", text: "normal follow-up" }]),
]);
const events: AgentEvent[] = [];
const messages = await runAgentLoop(
[{ role: "user", content: "join now", timestamp: 0 }],
{
systemPrompt: "",
messages: [],
tools: [wrapDefinition(yieldDefinition, extensionContext()), sentinelTool],
},
{ model, convertToLlm: (agentMessages) => agentMessages.filter(isLlmMessage) },
(event) => { events.push(event); },
undefined,
streamFn,
);
return { events, list, messages, sentinel, streamFn };
}
describe("yield_to_subsessions Pi agent-loop integration", () => {
it("ends the run after one provider call when invoked alone with a working child", async () => {
const result = await runYieldBatch([
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
]);
expect(result.streamFn).toHaveBeenCalledTimes(1);
expect(result.list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
expect(result.events.slice(-2).map(({ type }) => type)).toEqual(["turn_end", "agent_end"]);
expect(result.messages.at(-1)).toMatchObject({ role: "toolResult", toolName: "yield_to_subsessions" });
});
it("makes a normal follow-up provider call when no child is working", async () => {
const result = await runYieldBatch([]);
expect(result.streamFn).toHaveBeenCalledTimes(2);
expect(result.events.filter(({ type }) => type === "turn_start")).toHaveLength(2);
expect(result.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "normal follow-up" }],
});
});
it("does not terminate a mixed batch with a non-terminating sibling tool", async () => {
const result = await runYieldBatch([
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
], true);
expect(result.sentinel).toHaveBeenCalledTimes(1);
expect(result.streamFn).toHaveBeenCalledTimes(2);
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
});
});
+127 -18
View File
@@ -25,7 +25,17 @@ function tools(deps: Partial<SubsessionToolDeps>) {
if (tool === undefined) throw new Error(`missing tool ${name}`); if (tool === undefined) throw new Error(`missing tool ${name}`);
return tool; return tool;
}; };
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), check: find("check_subsession"), read: find("read_subsession") }; return {
spawn: find("spawn_subsession"),
list: find("list_subsessions"),
check: find("check_subsession"),
read: find("read_subsession"),
yield: find("yield_to_subsessions"),
};
}
function workingGuidance(sessionId: string): string {
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
} }
function firstText(content: readonly (TextContent | ImageContent)[]): string { function firstText(content: readonly (TextContent | ImageContent)[]): string {
@@ -52,27 +62,40 @@ describe("createSubsessionToolDefinitions", () => {
expect(firstText(result.content)).toContain("Started tracked subsession child-1"); expect(firstText(result.content)).toContain("Started tracked subsession child-1");
}); });
it("guides the parent to join all required subsessions without polling", async () => { it("guides the parent to continue independent work and use the explicit join action", async () => {
const { spawn: spawnTool } = tools({ const { spawn: spawnTool } = tools({
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })), spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })),
}); });
expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion."); expect(spawnTool.description).toBe("Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.");
expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete."); expect(spawnTool.promptSnippet).toBe("spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions");
const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined)); const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion."); expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Continue independent work, then join with yield_to_subsessions; do not poll.");
}); });
it("keeps subsession inspection tool descriptions capability-oriented", () => { it("distinguishes status inspection from yielding in tool metadata", () => {
const definitions = tools({}); const definitions = tools({});
expect(definitions.list.description).toBe("List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown)."); expect(definitions.list.description).toBe("List tracked child statuses. Never yields or changes control flow; do not poll.");
expect(definitions.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output."); expect(definitions.list.promptSnippet).toBe("list_subsessions: inspect child statuses; never yields");
expect(definitions.read.description).toBe("Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries."); expect(definitions.check.description).toBe("Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.");
for (const definition of [definitions.list, definitions.check, definitions.read]) { expect(definitions.check.promptSnippet).toBe("check_subsession: inspect child status and available output; never yields");
expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i); expect(definitions.read.description).toBe("Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.");
} expect(definitions.read.promptSnippet).toBe("read_subsession: inspect an available child transcript; never yields");
});
it("registers the parameterless yield action with terminal-batch guidance", () => {
const { yield: yieldTool } = tools({});
expect(yieldTool.parameters).toMatchObject({ type: "object", properties: {} });
expect(yieldTool.description).toBe("At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.");
expect(yieldTool.promptSnippet).toBe("yield_to_subsessions: end the run at a join point; call alone and last");
expect(yieldTool.promptGuidelines).toEqual([
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
"Call alone and last; a mixed tool batch may continue the run.",
"Completion notices wake you; do not poll inspection tools.",
]);
}); });
it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => { it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => {
@@ -105,12 +128,51 @@ describe("createSubsessionToolDefinitions", () => {
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" }, { sessionId: "child-2", cwd: "/repos/a", status: "idle" },
] }); ] });
expect(firstText(result.content)).toContain("child-1 [working]"); expect(firstText(result.content)).toContain("child-1 [working]");
expect(result.terminate).toBeUndefined();
}); });
it("list_subsessions reports an empty state", async () => { it("list_subsessions reports an empty state", async () => {
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) }); const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined)); const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined));
expect(result.content[0]).toMatchObject({ type: "text", text: "No tracked subsessions." }); expect(result.content[0]).toMatchObject({ type: "text", text: "No tracked subsessions." });
expect(result.terminate).toBeUndefined();
});
it("yield_to_subsessions terminates when tracked children are working", async () => {
const subsessions = [
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" as const },
{ sessionId: "child-3", cwd: "/repos/a", status: "working" as const },
];
const list = vi.fn(() => Promise.resolve(subsessions));
const { yield: yieldTool } = tools({ list });
const result = await yieldTool.execute("call-yield", {}, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
expect(result.details).toEqual({ subsessions });
expect(firstText(result.content)).toBe("Working: child-1, child-3. Ending this run; completion notices will wake you.");
expect(result.terminate).toBe(true);
});
it.each([
{ label: "an empty list", subsessions: [] },
{
label: "only non-working children",
subsessions: [
{ sessionId: "child-idle", cwd: "/repos/a", status: "idle" as const },
{ sessionId: "child-error", cwd: "/repos/a", status: "error" as const },
{ sessionId: "child-unknown", cwd: "/repos/a", status: "unknown" as const },
],
},
])("yield_to_subsessions remains active with $label", async ({ subsessions }) => {
const { yield: yieldTool } = tools({ list: vi.fn(() => Promise.resolve(subsessions)) });
const result = await yieldTool.execute("call-no-yield", {}, undefined, undefined, ctxFor("parent-1", undefined));
expect(result.details).toEqual({ subsessions });
expect(firstText(result.content)).toBe("No tracked subsessions are working; continuing.");
expect(result.terminate).toBeUndefined();
}); });
it("check_subsession scopes by parent and returns the final result", async () => { it("check_subsession scopes by parent and returns the final result", async () => {
@@ -121,7 +183,33 @@ describe("createSubsessionToolDefinitions", () => {
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl"); expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" }); expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
expect(firstText(result.content)).toContain("all done"); expect(firstText(result.content)).toBe("Subsession child-1 [idle].\n\n--- SUBSESSION OUTPUT: child-1 ---\nall done");
expect(result.terminate).toBeUndefined();
});
it("check_subsession withholds partial working output without yielding", async () => {
const partial = { sessionId: "child-1", cwd: "/repos/a", status: "working" as const, finalText: "SECRET PARTIAL OUTPUT", messageCount: 4 };
const check = vi.fn(() => Promise.resolve(partial));
const { check: checkTool } = tools({ check });
const result = await checkTool.execute("call-working-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
expect(firstText(result.content)).not.toContain("SECRET PARTIAL OUTPUT");
expect(result.details).toEqual(partial);
expect(result.terminate).toBeUndefined();
});
it("check_subsession preserves non-working error output without yielding", async () => {
const { check: checkTool } = tools({
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "error" as const, finalText: "child failed", messageCount: 3 })),
});
const result = await checkTool.execute("call-error-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(firstText(result.content)).toBe("Subsession child-1 [error].\n\n--- SUBSESSION OUTPUT: child-1 ---\nchild failed");
expect(result.terminate).toBeUndefined();
}); });
it("check_subsession propagates scope errors so the agent loop reports them", async () => { it("check_subsession propagates scope errors so the agent loop reports them", async () => {
@@ -136,15 +224,34 @@ describe("createSubsessionToolDefinitions", () => {
const read = vi.fn(() => Promise.resolve({ const read = vi.fn(() => Promise.resolve({
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }], entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }],
total: 5, matched: 1, start: 2, hasMore: false, total: 5, matched: 2, start: 2, hasMore: true,
})); }));
const { read: readTool } = tools({ read }); const { read: readTool } = tools({ read });
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl")); const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200, limit: 1 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl"); expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200, limit: 1 }, "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 }); expect(result.details).toMatchObject({ sessionId: "child-1", matched: 2 });
expect(firstText(result.content)).toContain("the answer"); expect(firstText(result.content)).toBe("Subsession child-1 [idle] — messages 22 of 5 (2 matched). Earlier matching messages exist before index 2.\n\n--- SUBSESSION TRANSCRIPT: child-1 ---\n#2 assistant\nthe answer");
expect(result.terminate).toBeUndefined();
});
it("read_subsession withholds partial working transcripts without yielding", async () => {
const partial = {
sessionId: "child-1", cwd: "/repos/a", status: "working" as const,
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "SECRET TRANSCRIPT ENTRY" }] }],
total: 3, matched: 1, start: 2, hasMore: false,
};
const read = vi.fn(() => Promise.resolve(partial));
const { read: readTool } = tools({ read });
const result = await readTool.execute("call-working-read", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", {}, "/sessions/parent-1.jsonl");
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
expect(firstText(result.content)).not.toContain("SECRET TRANSCRIPT ENTRY");
expect(result.details).toEqual(partial);
expect(result.terminate).toBeUndefined();
}); });
it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => { it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => {
@@ -165,6 +272,7 @@ describe("createSubsessionToolDefinitions", () => {
expect(text).toContain("command"); // raw args surfaced in text, not only details expect(text).toContain("command"); // raw args surfaced in text, not only details
expect(text).toContain("ls -la"); expect(text).toContain("ls -la");
expect(text).toContain("[+43 chars truncated"); // 50 - 7 expect(text).toContain("[+43 chars truncated"); // 50 - 7
expect(result.terminate).toBeUndefined();
}); });
it("read_subsession distinguishes an empty page-window from a zero-match result", async () => { it("read_subsession distinguishes an empty page-window from a zero-match result", async () => {
@@ -178,6 +286,7 @@ describe("createSubsessionToolDefinitions", () => {
const text = firstText(result.content); const text = firstText(result.content);
expect(text).toContain("4 matched"); // not "nothing matched" expect(text).toContain("4 matched"); // not "nothing matched"
expect(text).not.toContain("nothing matched"); expect(text).not.toContain("nothing matched");
expect(result.terminate).toBeUndefined();
}); });
it("read_subsession propagates scope errors so the agent loop reports them", async () => { it("read_subsession propagates scope errors so the agent loop reports them", async () => {
+68 -26
View File
@@ -67,50 +67,51 @@ export interface SubsessionToolDeps {
const SpawnSubsessionParams = Type.Object({ const SpawnSubsessionParams = Type.Object({
prompt: Type.String({ prompt: Type.String({
description: "The first instruction to send to the new tracked subsession.", description: "Initial instruction for the tracked child.",
}), }),
cwd: Type.Optional(Type.String({ cwd: Type.Optional(Type.String({
description: "Working directory for the subsession. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.", description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.",
})), })),
}); });
const ListSubsessionsParams = Type.Object({}); const ListSubsessionsParams = Type.Object({});
const YieldToSubsessionsParams = Type.Object({});
const CheckSubsessionParams = Type.Object({ const CheckSubsessionParams = Type.Object({
sessionId: Type.String({ sessionId: Type.String({
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.", description: "Tracked child id from spawn_subsession or list_subsessions.",
}), }),
}); });
const ReadSubsessionParams = Type.Object({ const ReadSubsessionParams = Type.Object({
sessionId: Type.String({ sessionId: Type.String({
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.", description: "Tracked child id from spawn_subsession or list_subsessions.",
}), }),
roles: Type.Optional(Type.Array( roles: Type.Optional(Type.Array(
Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]), Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]),
{ description: "Message roles to include. Omit for all roles." }, { description: "Roles to include; omit for all." },
)), )),
include: Type.Optional(Type.Array( include: Type.Optional(Type.Array(
Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]), Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]),
{ description: "Content kinds to keep within messages. Omit for all kinds." }, { description: "Content kinds to include; omit for all." },
)), )),
search: Type.Optional(Type.String({ search: Type.Optional(Type.String({
description: "Case-insensitive substring; keep only messages whose text or tool name matches. Always searches full message content, even when maxChars is set.", description: "Case-insensitive text or tool-name substring; searches full content before maxChars truncation.",
})), })),
maxChars: Type.Optional(Type.Integer({ maxChars: Type.Optional(Type.Integer({
minimum: 0, minimum: 0,
description: "Truncate each text/thinking/tool-result value to this many characters; clipped parts are marked '[+N chars truncated]'. Omit for full, untruncated text (there is no default, so truncation only happens when you ask for it).", description: "Maximum characters per text, thinking, or tool-result value; omit for no truncation.",
})), })),
includeToolArgs: Type.Optional(Type.Boolean({ includeToolArgs: Type.Optional(Type.Boolean({
description: "Include raw tool-call arguments (can be large). A compact one-line summary of each call is always shown regardless.", description: "Include raw tool-call arguments; summaries are always included.",
})), })),
before: Type.Optional(Type.Integer({ before: Type.Optional(Type.Integer({
minimum: 0, minimum: 0,
description: "Return only messages before this transcript index; page backward by passing the previous response's 'start'.", description: "Return messages before this index; use the previous start to page backward.",
})), })),
limit: Type.Optional(Type.Integer({ limit: Type.Optional(Type.Integer({
minimum: 1, minimum: 1,
description: "Maximum number of most-recent matching messages to return within the window (returned in chronological order). Defaults to 50.", description: "Maximum recent matches, in chronological order. Defaults to 50.",
})), })),
}); });
@@ -118,6 +119,10 @@ function statusLine(summary: SubsessionSummary): string {
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`; return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
} }
function workingInspectionGuidance(sessionId: string): string {
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
}
function renderEntry(entry: TranscriptEntry): string { function renderEntry(entry: TranscriptEntry): string {
const header = `#${String(entry.index)} ${entry.role}`; const header = `#${String(entry.index)} ${entry.role}`;
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n"); const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
@@ -153,7 +158,7 @@ function renderTranscript(result: SubsessionReadResult): string {
? "no messages matched your filters" ? "no messages matched your filters"
: `no messages in this window (${String(result.matched)} matched outside it)`) : `no messages in this window (${String(result.matched)} matched outside it)`)
: `messages ${String(result.start)}${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`; : `messages ${String(result.start)}${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`;
const more = result.hasMore ? `\n\nEarlier matching messages exist before index ${String(result.start)}.` : ""; const more = result.hasMore ? ` Earlier matching messages exist before index ${String(result.start)}.` : "";
// Empty entries with matches means the `before` cursor excluded every match // Empty entries with matches means the `before` cursor excluded every match
// (they all sit at index >= before): the agent paged too far back and should // (they all sit at index >= before): the agent paged too far back and should
// raise `before` or omit it, not page back further. // raise `before` or omit it, not page back further.
@@ -162,11 +167,12 @@ function renderTranscript(result: SubsessionReadResult): string {
: (result.matched === 0 : (result.matched === 0
? "(no messages matched the filters)" ? "(no messages matched the filters)"
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`); : `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`);
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`; return `Subsession ${result.sessionId} [${result.status}] — ${range}.${more}\n\n--- SUBSESSION TRANSCRIPT: ${result.sessionId} ---\n${body}`;
} }
/** /**
* Tools that let an agent spawn *tracked* child sessions and inspect them. * Tools that let an agent spawn *tracked* child sessions, inspect them, and
* explicitly yield at a join point.
* *
* Unlike `spawn_session` (fire-and-forget peers), a subsession records its * Unlike `spawn_session` (fire-and-forget peers), a subsession records its
* parent in its session header, the parent is notified when it stops working, * parent in its session header, the parent is notified when it stops working,
@@ -178,8 +184,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({ const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
name: "spawn_subsession", name: "spawn_subsession",
label: "Spawn subsession", label: "Spawn subsession",
description: "Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.", description: "Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.",
promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.", promptSnippet: "spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions",
parameters: SpawnSubsessionParams, parameters: SpawnSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
@@ -193,7 +199,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
...(ctx.model === undefined ? {} : { model: ctx.model }), ...(ctx.model === undefined ? {} : { model: ctx.model }),
}); });
return { return {
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.` }], content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Continue independent work, then join with yield_to_subsessions; do not poll.` }],
details: result, details: result,
}; };
}, },
@@ -202,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({ const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "list_subsessions", name: "list_subsessions",
label: "List subsessions", label: "List subsessions",
description: "List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).", description: "List tracked child statuses. Never yields or changes control flow; do not poll.",
promptSnippet: "list_subsessions: see the tracked child sessions you spawned", promptSnippet: "list_subsessions: inspect child statuses; never yields",
parameters: ListSubsessionsParams, parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
@@ -219,16 +225,19 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({ const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
name: "check_subsession", name: "check_subsession",
label: "Check subsession", label: "Check subsession",
description: "Return a tracked subsession's current status, message count, and most recent assistant output.", description: "Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.",
promptSnippet: "check_subsession: glance at a subsession's status and latest output", promptSnippet: "check_subsession: inspect child status and available output; never yields",
parameters: CheckSubsessionParams, parameters: CheckSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile); const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
const body = result.finalText === "" ? "(no output yet)" : result.finalText; const body = result.finalText === "" ? "(no output yet)" : result.finalText;
const text = result.status === "working"
? workingInspectionGuidance(result.sessionId)
: `Subsession ${result.sessionId} [${result.status}].\n\n--- SUBSESSION OUTPUT: ${result.sessionId} ---\n${body}`;
return { return {
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }], content: [{ type: "text", text }],
details: result, details: result,
}; };
}, },
@@ -237,20 +246,53 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({ const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
name: "read_subsession", name: "read_subsession",
label: "Read subsession", label: "Read subsession",
description: "Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.", description: "Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.",
promptSnippet: "read_subsession: read through a subsession's transcript with filters", promptSnippet: "read_subsession: inspect an available child transcript; never yields",
parameters: ReadSubsessionParams, parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const { sessionId, ...query } = params; const { sessionId, ...query } = params;
const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile); const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
const text = result.status === "working"
? workingInspectionGuidance(result.sessionId)
: renderTranscript(result);
return { return {
content: [{ type: "text", text: renderTranscript(result) }], content: [{ type: "text", text }],
details: result, details: result,
}; };
}, },
}); });
return [spawnTool, listTool, checkTool, readTool]; const yieldTool = defineTool<typeof YieldToSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "yield_to_subsessions",
label: "Yield to subsessions",
description: "At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.",
promptSnippet: "yield_to_subsessions: end the run at a join point; call alone and last",
promptGuidelines: [
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
"Call alone and last; a mixed tool batch may continue the run.",
"Completion notices wake you; do not poll inspection tools.",
],
parameters: YieldToSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const subsessions = await deps.list(parentSessionId, parentSessionFile);
const working = subsessions.filter(({ status }) => status === "working");
if (working.length === 0) {
return {
content: [{ type: "text", text: "No tracked subsessions are working; continuing." }],
details: { subsessions },
};
}
return {
content: [{ type: "text", text: `Working: ${working.map(({ sessionId }) => sessionId).join(", ")}. Ending this run; completion notices will wake you.` }],
details: { subsessions },
terminate: true,
};
},
});
return [spawnTool, listTool, checkTool, readTool, yieldTool];
} }
+4 -3
View File
@@ -6,6 +6,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsBulkMutations: "sessions.bulkMutations", sessionsBulkMutations: "sessions.bulkMutations",
sessionsCleanup: "sessions.cleanup", sessionsCleanup: "sessions.cleanup",
sessionsReload: "sessions.reload", sessionsReload: "sessions.reload",
sessionsClearQueue: "sessions.clearQueue",
sessionsPersistedState: "sessions.persistedState", sessionsPersistedState: "sessions.persistedState",
promptAttachments: "prompt.attachments", promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions", workspaceFileSuggestions: "workspace.fileSuggestions",
@@ -67,9 +68,9 @@ export interface PiWebUploadsConfig {
} }
export interface PiWebAgentConfig { export interface PiWebAgentConfig {
/** Agent CLI command used for diagnostics and package-managed updates. */ /** Pi-compatible companion CLI used for diagnostics and safe package-managed updates. */
command?: string; command?: string;
/** Agent config/state directory containing auth.json, models.json, settings.json, and sessions/. */ /** Pi-compatible profile directory containing auth.json, models.json, settings.json, and sessions/. */
dir?: string; dir?: string;
} }
@@ -94,7 +95,7 @@ export interface PiWebConfigValues {
* while the capability stabilizes. Requires spawnSessions to be enabled. * while the capability stabilizes. Requires spawnSessions to be enabled.
*/ */
subsessions?: boolean; subsessions?: boolean;
/** Agent runtime command/state used by PI WEB and the session daemon (Pi by default). */ /** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
agent?: PiWebAgentConfig; agent?: PiWebAgentConfig;
} }
+20
View File
@@ -30,6 +30,26 @@ describe("PI WEB capabilities", () => {
})).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState); })).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
}); });
it("requires web and session daemon support for server-side queue clearing", () => {
const clearQueue = PI_WEB_CAPABILITIES.sessionsClearQueue;
expect(WEB_RUNTIME_CAPABILITIES).toContain(clearQueue);
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(clearQueue);
expect(parseKnownPiWebCapabilities([clearQueue, "future.capability"])).toEqual([clearQueue]);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [clearQueue] },
sessiond: { available: true, capabilities: [] },
})).not.toContain(clearQueue);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [] },
sessiond: { available: true, capabilities: [clearQueue] },
})).not.toContain(clearQueue);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [clearQueue] },
sessiond: { available: true, capabilities: [clearQueue] },
})).toContain(clearQueue);
});
it("keeps only known string capabilities when parsing runtime data", () => { it("keeps only known string capabilities when parsing runtime data", () => {
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined(); expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
+3
View File
@@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions, PI_WEB_CAPABILITIES.workspaceFileSuggestions,
@@ -24,6 +25,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState, PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[]; ] as const satisfies readonly PiWebCapability[];
@@ -33,6 +35,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsClearQueue]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
+1
View File
@@ -59,6 +59,7 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" }, { method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
{ method: "GET", path: "/sessions/:sessionId/commands" }, { method: "GET", path: "/sessions/:sessionId/commands" },
{ method: "POST", path: "/sessions/:sessionId/prompt" }, { method: "POST", path: "/sessions/:sessionId/prompt" },
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
{ method: "POST", path: "/sessions/:sessionId/attachments" }, { method: "POST", path: "/sessions/:sessionId/attachments" },
{ method: "POST", path: "/sessions/:sessionId/shell" }, { method: "POST", path: "/sessions/:sessionId/shell" },
{ method: "POST", path: "/sessions/:sessionId/commands/run" }, { method: "POST", path: "/sessions/:sessionId/commands/run" },