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
---
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>
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,
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>
</div>
</section>
@@ -98,7 +98,7 @@
<a href="#keys">Config matrix</a>
<a href="#path-access">External path access</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="#completion-tools">Completion tools</a>
</aside>
@@ -113,8 +113,8 @@
</ul>
<p>
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:
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.
@@ -334,7 +334,7 @@
<td>Restart web/API and session daemon on that machine</td>
</tr>
<tr>
<td>Agent CLI command</td>
<td>Companion CLI command</td>
<td><code>agent.command</code></td>
<td><code>PI_WEB_AGENT_COMMAND</code></td>
<td>Global/session daemon</td>
@@ -342,12 +342,12 @@
<td>Restart session daemon on that machine; affects doctor/status/update checks</td>
</tr>
<tr>
<td>Agent state directory</td>
<td>Agent profile state directory</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>Global/session daemon</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>
<td>Agent can spawn sessions</td>
@@ -455,7 +455,7 @@
<td>Restart web/API; advanced state override</td>
</tr>
<tr>
<td>Agent session storage directory</td>
<td>Agent profile session storage directory</td>
<td></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>
@@ -463,7 +463,7 @@
<td>Restart session daemon; env-only session storage override</td>
</tr>
<tr>
<td>Agent config directory</td>
<td>Agent profile state directory</td>
<td></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>
@@ -537,46 +537,63 @@
</section>
<section id="agent-runtime">
<h2>Agent runtime</h2>
<h2>Pi-compatible agent profile and companion CLI</h2>
<p>
<code>agent.command</code> controls which Pi-compatible CLI PI WEB checks in doctor/status/update flows.
It defaults to <code>pi</code>. 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.
<code>agent.command</code> selects the Pi-compatible companion CLI used by <code>pi-web doctor</code> and,
when it can be generated safely, package-managed update commands. It defaults to <code>pi</code>. This
setting does <strong>not</strong> replace the embedded runtime: every session continues to use PI WEB's
bundled Pi SDK.
</p>
<p>
<code>agent.dir</code> controls which compatible agent state directory PI WEB reads for auth providers,
model settings, settings, and session metadata. It defaults to <code>~/.pi/agent</code> only for the default
<code>pi</code> command. Set it explicitly when you want an isolated Pi profile or when
<code>agent.command</code> points at another compatible CLI.
<code>agent.dir</code> 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
<code>~/.pi/agent</code> 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.
</p>
<div class="code-card">
<pre><code>{
"agent": {
"command": "my-pi-fork",
"dir": "/opt/my-pi-fork/agent"
"command": "pi-lab",
"dir": "/opt/pi-profiles/lab"
}
}</code></pre>
</div>
<p>
For example, a fork profile can set <code>agent.command</code> to <code>my-pi-fork</code> and
<code>agent.dir</code> to <code>/opt/my-pi-fork/agent</code>.
An alternate command always requires an explicit state directory. The command must be a safe bare
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>
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>
overrides session storage separately from <code>agent.dir</code>. Existing Pi Coding Agent env names
(<code>PI_CODING_AGENT_DIR</code> and <code>PI_CODING_AGENT_SESSION_DIR</code>) remain supported only for
Pi compatibility; alternate commands should use the explicit PI WEB variables or config keys.
companion CLI, <code>PI_WEB_AGENT_DIR</code> sets the profile state directory, and
<code>PI_WEB_AGENT_SESSION_DIR</code> overrides session storage separately from <code>agent.dir</code>. The
legacy <code>PI_CODING_AGENT_DIR</code> and <code>PI_CODING_AGENT_SESSION_DIR</code> names apply only to a
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>
Session directory overrides are environment-only; use <code>PI_WEB_AGENT_SESSION_DIR</code> unless you are
intentionally using the legacy Pi-compatible <code>PI_CODING_AGENT_SESSION_DIR</code> 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. <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>
<div class="callout warning">
In <strong>Settings → Session daemon</strong>, 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.
</div>
</section>
@@ -592,24 +609,35 @@
<h3><code>subsessions</code></h3>
<p>
Boolean. Beta. Controls whether agents receive the tracked-subsession tools:
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, and
<code>read_subsession</code>. Defaults to <code>false</code> and also requires <code>spawnSessions</code>
to be enabled.
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>,
<code>read_subsession</code>, and <code>yield_to_subsessions</code>. Defaults to <code>false</code> and also
requires <code>spawnSessions</code> to be enabled.
</p>
<p>
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 <code>spawn_subsession</code> 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 <code>spawn_subsession</code> 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 <code>spawn_session</code> tool instead.
</p>
<p>
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.
<code>list_subsessions</code>, <code>check_subsession</code>, and <code>read_subsession</code> 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
<code>yield_to_subsessions</code> 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.
</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>
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 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>.
@@ -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`.
- **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.
@@ -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 |
| 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 |
| 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 |
| 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 |
| Companion CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks |
| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins |
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
| Plugin enablement/settings | `plugins.<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 |
| 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 |
| 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 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 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 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 |
## 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.
### 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
{
"agent": {
"command": "my-pi-fork",
"dir": "/opt/my-pi-fork/agent"
"command": "pi-lab",
"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
`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.
+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(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", () => {
+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) }),
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),
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),
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 }) }),
@@ -64,6 +64,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.clearQueue(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)),
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
+2 -2
View File
@@ -54,13 +54,13 @@ export class AuthDialog extends LitElement {
case "method": return html`
<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?.("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>
`;
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`
<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); }}>
${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>
+92 -8
View File
@@ -1,5 +1,6 @@
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 { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
@@ -12,19 +13,61 @@ describe("chatQueuedMessageSections", () => {
expect(sections).toEqual([
{
source: "client",
heading: "Queued until session starts",
detail: "Will send once the backend session is ready",
messages: [{ kind: "followUp", text: "queued before start" }],
},
{
source: "server",
heading: "Queued messages",
detail: "1 pending · Stop clears the queue",
detail: "1 pending",
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", () => {
it("uses one full date and model label without a model prefix", () => {
const timestamp = "2026-07-10T19:15:30.000Z";
@@ -103,10 +146,17 @@ interface GroupBodyRenderCall {
startIndex: number;
}
type RenderQueuedMessages = (this: ChatView) => TemplateResult;
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
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 {
const method: unknown = Reflect.get(view, "renderMessageGroup");
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
@@ -125,6 +175,10 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
return calls;
}
function isRenderQueuedMessages(value: unknown): value is RenderQueuedMessages {
return typeof value === "function";
}
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
return typeof value === "function";
}
@@ -134,13 +188,30 @@ function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBo
}
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
const strings = templateStrings(template);
const values = templateValues(template);
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value;
let handler: TemplateEventHandler | undefined;
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) {
const candidate = values[index];
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 {
@@ -221,3 +292,16 @@ function isTemplateResult(value: unknown): value is TemplateResult {
function isStringArray(value: unknown): value is 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,
};
}
+16 -4
View File
@@ -39,6 +39,7 @@ function clampNumber(value: number, min: number, max: number): number {
}
export interface QueuedMessageSection {
source: "client" | "server";
heading: string;
detail: string;
messages: QueuedSessionMessage[];
@@ -46,8 +47,8 @@ export interface QueuedMessageSection {
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
return [
clientQueued.length === 0 ? undefined : { 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 },
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 : { source: "server", heading: "Queued messages", detail: `${String(serverQueued.length)} pending`, messages: serverQueued },
].filter((section): section is QueuedMessageSection => section !== undefined);
}
@@ -89,6 +90,8 @@ export class ChatView extends LitElement {
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity;
@property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true;
@@ -123,6 +126,9 @@ export class ChatView extends LitElement {
private readonly onPageHide = () => {
this.saveScrollPosition();
};
private readonly handleClearServerQueue = (): void => {
this.onClearServerQueue?.();
};
override connectedCallback(): void {
super.connectedCallback();
@@ -261,11 +267,17 @@ export class ChatView extends LitElement {
}
private renderQueuedMessageList(section: QueuedMessageSection) {
const canClear = section.source === "server" && this.canClearServerQueue && this.onClearServerQueue !== undefined;
return html`
<aside class="queued-messages" aria-live="polite">
<div class="queued-header">
<strong>${section.heading}</strong>
<small>${section.detail}</small>
<div class="queued-heading">
<strong>${section.heading}</strong>
<small>${section.detail}</small>
</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`
<div class="queued-message">
@@ -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);
}
private canClearServerQueue(): boolean {
const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsClearQueue);
}
private canCleanupSessions(): boolean {
const runtime = this.selectedMachineRuntime();
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);
}
// Stable handler identities for <prompt-editor>. Inlined arrow closures would
// be a fresh reference on every render, forcing Lit to re-commit the bindings
// each time the app re-renders; bound class fields keep them constant.
// Stable handler identities for child components. Inlined arrow closures
// would be a fresh reference on every render, forcing Lit to re-commit the
// 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 => {
this.sendPrompt(text, streamingBehavior, attachments, delivery);
};
@@ -1865,6 +1870,10 @@ export class PiWebApp extends LitElement {
void this.sessions.stopActiveWork();
};
private readonly handleClearServerQueue = (): void => {
void this.sessions.clearServerQueue();
};
private readonly handleSelectModel = (): void => {
void this.openModelDialog();
};
@@ -1873,6 +1882,12 @@ export class PiWebApp extends LitElement {
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() {
if (!this.appShell.isMobileNavigationLayout) return null;
return html`
@@ -1931,7 +1946,7 @@ export class PiWebApp extends LitElement {
${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${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>
<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}
@@ -87,7 +87,7 @@ describe("settings-dialog session daemon machine targeting", () => {
await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
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 () => {
+1 -1
View File
@@ -437,7 +437,7 @@ export class SettingsDialog extends LitElement {
if (config.agent !== undefined) {
const profileSupport = this.agentProfileSettingsSupport(target);
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;
}
}
@@ -19,14 +19,14 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [
"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",
"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",
"Config file",
"Companion CLI command",
"agent-lab",
"Agent state directory",
"Profile state directory",
"/srv/agent-lab",
"Allow agents to start sessions",
]);
@@ -44,7 +44,7 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [
"Failed to save session-daemon config.",
"Session daemon settings saved.",
"Agent profile restart required on local (local gateway)",
"Pi-compatible agent profile restart required on local (local gateway)",
"Config file",
]);
});
@@ -69,7 +69,7 @@ export class SettingsSessiondPanel extends LitElement {
<code>${config.path}</code>
</div>
<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">
<span class="field-heading">
<span>Companion CLI command</span>
@@ -89,7 +89,7 @@ export class SettingsSessiondPanel extends LitElement {
</label>
<label class="field">
<span class="field-heading">
<span>Agent state directory</span>
<span>Profile state directory</span>
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
@@ -102,7 +102,7 @@ export class SettingsSessiondPanel extends LitElement {
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
@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>
<footer class="form-actions">
<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") {
notices.push({
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.`,
});
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
notices.push({
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.",
});
}
@@ -274,5 +274,5 @@ function errorMessage(error: unknown): 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(target, undefined)).toEqual({
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, {
ok: true,
@@ -56,7 +56,7 @@ describe("selected-machine settings target helpers", () => {
expect(isAgentProfileSettingsSupported(unsupported)).toBe(false);
expect(unsupported).toEqual({
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) {
return {
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" };
return {
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: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-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.queued-header strong { color: var(--pi-warning); }
.queued-header small { color: var(--pi-muted); }
.queued-header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px; }
.queued-heading { min-width: 0; flex: 1 1 180px; display: grid; gap: 2px; }
.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:first-of-type { padding-top: 0; border-top: 0; }
.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() {
const session = this.getState().selectedSession;
if (!session) return;
@@ -768,11 +782,15 @@ export class SessionController {
}
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 selected = state.selectedSession;
return target.selectionSeq === this.selectionSeq
&& selectedMachineId(state) === target.machineId
&& selected?.id === target.session.id
return selectionSeq === this.selectionSeq
&& selectedMachineId(state) === machineId
&& selected?.id === sessionId
&& selected.archived !== true
&& !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" });
});
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 () => {
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 }>();
@@ -28,6 +28,17 @@ describe("machine-scoped session proxy routes", () => {
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 () => {
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",
"check_subsession",
"read_subsession",
"yield_to_subsessions",
]);
});
@@ -222,6 +222,84 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
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 () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -10,10 +10,15 @@ const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
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 child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const created = [parent.runtime, child.runtime];
const children = childIds.map((childId) => fakeRuntime(childId, {
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;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
@@ -41,7 +46,7 @@ describe("PiSessionService", () => {
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs,
});
return { parent, child, service };
return { parent, child, children, service };
}
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
@@ -796,6 +801,41 @@ describe("PiSessionService", () => {
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 () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
await service.start("/workspace");
+24 -1
View File
@@ -896,6 +896,16 @@ export class PiSessionService implements SessionRouteService {
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
* 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 finalText = finalAssistantText(historyMessages(session));
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);
}
@@ -1347,6 +1361,15 @@ export class PiSessionService implements SessionRouteService {
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> {
const active = this.activeForLookup(ref);
if (active === undefined) return;
+68 -1
View File
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
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 { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.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 () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
@@ -255,12 +304,14 @@ describe("session routes", () => {
class CapturingRouteSessionService implements SessionRouteService {
readonly calls: unknown[] = [];
readonly reloadCalls: SessionRouteLookup[] = [];
readonly clearQueueCalls: SessionRouteLookup[] = [];
messagesResponse: unknown[] | MessagePage = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
reloadError: Error | undefined;
clearQueueError: Error | undefined;
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
this.cleanupPreviewCalls.push(request);
@@ -294,6 +345,22 @@ class CapturingRouteSessionService implements SessionRouteService {
list(): never { throw unusedRouteMethod("list"); }
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> {
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) => {
try {
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.
*
* Keep this surface neutral: implementations may be backed by the native Pi SDK,
* an out-of-process agent bridge, or another daemon. Pi-specific lifecycle hooks
* such as auth-change handling and daemon shutdown stay on the concrete service.
* Keep transport concerns separate from the bundled Pi SDK implementation so
* routes remain testable. Pi-specific lifecycle hooks such as auth-change
* handling and daemon shutdown stay on the concrete service.
*/
export interface SessionRouteService {
list(cwd: string): Promise<ClientSession[]>;
start(cwd: string): Promise<ClientSession>;
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
setModel(ref: SessionRouteLookup, provider: string, modelId: string): 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}`);
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 {
@@ -52,27 +62,40 @@ describe("createSubsessionToolDefinitions", () => {
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({
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.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete.");
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: 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));
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({});
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.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output.");
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.");
for (const definition of [definitions.list, definitions.check, definitions.read]) {
expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i);
}
expect(definitions.list.description).toBe("List tracked child statuses. Never yields or changes control flow; do not poll.");
expect(definitions.list.promptSnippet).toBe("list_subsessions: inspect child statuses; never yields");
expect(definitions.check.description).toBe("Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.");
expect(definitions.check.promptSnippet).toBe("check_subsession: inspect child status and available output; never yields");
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 () => {
@@ -105,12 +128,51 @@ describe("createSubsessionToolDefinitions", () => {
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" },
] });
expect(firstText(result.content)).toContain("child-1 [working]");
expect(result.terminate).toBeUndefined();
});
it("list_subsessions reports an empty state", async () => {
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
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.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 () => {
@@ -121,7 +183,33 @@ describe("createSubsessionToolDefinitions", () => {
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
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 () => {
@@ -136,15 +224,34 @@ describe("createSubsessionToolDefinitions", () => {
const read = vi.fn(() => Promise.resolve({
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" }] }],
total: 5, matched: 1, start: 2, hasMore: false,
total: 5, matched: 2, start: 2, hasMore: true,
}));
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(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
expect(firstText(result.content)).toContain("the answer");
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: 2 });
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 () => {
@@ -165,6 +272,7 @@ describe("createSubsessionToolDefinitions", () => {
expect(text).toContain("command"); // raw args surfaced in text, not only details
expect(text).toContain("ls -la");
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 () => {
@@ -178,6 +286,7 @@ describe("createSubsessionToolDefinitions", () => {
const text = firstText(result.content);
expect(text).toContain("4 matched"); // not "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 () => {
+68 -26
View File
@@ -67,50 +67,51 @@ export interface SubsessionToolDeps {
const SpawnSubsessionParams = Type.Object({
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({
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 YieldToSubsessionsParams = Type.Object({});
const CheckSubsessionParams = Type.Object({
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({
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(
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(
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({
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({
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({
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({
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({
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}`;
}
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 {
const header = `#${String(entry.index)} ${entry.role}`;
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 in this window (${String(result.matched)} matched outside it)`)
: `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
// (they all sit at index >= before): the agent paged too far back and should
// raise `before` or omit it, not page back further.
@@ -162,11 +167,12 @@ function renderTranscript(result: SubsessionReadResult): string {
: (result.matched === 0
? "(no messages matched the filters)"
: `(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
* 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>({
name: "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.",
promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.",
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: tracked parallel work; continue, then join with yield_to_subsessions",
parameters: SpawnSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
@@ -193,7 +199,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
...(ctx.model === undefined ? {} : { model: ctx.model }),
});
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,
};
},
@@ -202,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "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).",
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
description: "List tracked child statuses. Never yields or changes control flow; do not poll.",
promptSnippet: "list_subsessions: inspect child statuses; never yields",
parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
@@ -219,16 +225,19 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
name: "check_subsession",
label: "Check subsession",
description: "Return a tracked subsession's current status, message count, and most recent assistant output.",
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
description: "Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.",
promptSnippet: "check_subsession: inspect child status and available output; never yields",
parameters: CheckSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
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 {
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
content: [{ type: "text", text }],
details: result,
};
},
@@ -237,20 +246,53 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
name: "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.",
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
description: "Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.",
promptSnippet: "read_subsession: inspect an available child transcript; never yields",
parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const { sessionId, ...query } = params;
const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
const text = result.status === "working"
? workingInspectionGuidance(result.sessionId)
: renderTranscript(result);
return {
content: [{ type: "text", text: renderTranscript(result) }],
content: [{ type: "text", text }],
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",
sessionsCleanup: "sessions.cleanup",
sessionsReload: "sessions.reload",
sessionsClearQueue: "sessions.clearQueue",
sessionsPersistedState: "sessions.persistedState",
promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions",
@@ -67,9 +68,9 @@ export interface PiWebUploadsConfig {
}
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;
/** 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;
}
@@ -94,7 +95,7 @@ export interface PiWebConfigValues {
* while the capability stabilizes. Requires spawnSessions to be enabled.
*/
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;
}
+20
View File
@@ -30,6 +30,26 @@ describe("PI WEB capabilities", () => {
})).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", () => {
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();
+3
View File
@@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments,
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
@@ -24,6 +25,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.sessionsBulkMutations,
PI_WEB_CAPABILITIES.sessionsCleanup,
PI_WEB_CAPABILITIES.sessionsReload,
PI_WEB_CAPABILITIES.sessionsClearQueue,
PI_WEB_CAPABILITIES.sessionsPersistedState,
PI_WEB_CAPABILITIES.promptAttachments,
] as const satisfies readonly PiWebCapability[];
@@ -33,6 +35,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsClearQueue]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[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: "GET", path: "/sessions/:sessionId/commands" },
{ method: "POST", path: "/sessions/:sessionId/prompt" },
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
{ method: "POST", path: "/sessions/:sessionId/attachments" },
{ method: "POST", path: "/sessions/:sessionId/shell" },
{ method: "POST", path: "/sessions/:sessionId/commands/run" },