Merge branch 'main' into cleanup/plugin-api-scope

This commit is contained in:
Federico Jaramillo Martinez
2026-06-24 08:49:49 +02:00
102 changed files with 7292 additions and 1406 deletions
+5 -1
View File
@@ -25,7 +25,11 @@ Create a changeset for changes that affect users, operators, package consumers,
- Dependency/runtime requirement changes
- Release-process changes that future maintainers need to see
A changeset is usually not needed for purely internal refactors, tests, lint-only changes, build cleanup, or agent-only project skills unless the user wants them recorded. When in doubt, ask briefly or create a patch changeset with a clear note.
A changeset is usually not needed for purely internal refactors, tests, lint-only changes, or build cleanup unless the user wants them recorded.
A changeset is also not needed for changes that are not part of what a pi-web release ships to users. The release is the published npm package, and its contents are an allowlist defined by the `files` field in `package.json` (plus `package.json` itself). Anything outside that allowlist never reaches package consumers, so it cannot be a user-visible release change. This includes repo-only material such as agent skills under `.agents/` and `skills/`, internal docs, CI config, and developer tooling. If you are unsure whether a path ships, check it against `package.json` `files` (or run `npm pack --dry-run`); when a change lives entirely outside the published files, skip the changeset unless the user explicitly wants it recorded.
When in doubt, ask briefly or create a patch changeset with a clear note.
## How to create a changeset
@@ -97,6 +97,15 @@ If there is no GitHub Actions publish workflow, stop and explain that one must b
- Update the newly generated `CHANGELOG.md` heading to match the computed CalVer version if Changesets used a different heading. This manual changelog heading edit is acceptable during release prep; normal development should still use changeset fragments instead.
- Review the generated `CHANGELOG.md` section. It should be suitable for GitHub Release notes.
- Do not use plain `npm version <new-version>` because it creates a local git tag as a side effect; releases should be controlled via GitHub.
- **Sync the lockfile to the final version.** `npm run release:version` (Changesets) updates `package.json` but does not reliably rewrite `package-lock.json`, and the CalVer-enforcing `npm version --no-git-tag-version` only touches the lock when it actually runs. Either path can leave the committed `package-lock.json` behind at the previous version, which then resurfaces as an unexpected diff after the next `npm install`. After the version is finalized, always resync the lockfile without touching `node_modules`:
```bash
npm install --package-lock-only
```
- Confirm the lockfile now matches `package.json` before continuing:
```bash
node -e "const v=require('./package.json').version, l=require('./package-lock.json'); if (l.version!==v || l.packages[''].version!==v) { console.error('lockfile version mismatch:', l.version, l.packages[''].version, 'expected', v); process.exit(1); } console.log('lockfile in sync at', v);"
```
- If the lockfile mismatch persists, stop and resolve it before committing; do not ship a release whose `package-lock.json` version disagrees with `package.json`.
5. **Run checks before creating the release**
- Run the repository's normal verification commands, for example:
@@ -113,6 +122,7 @@ If there is no GitHub Actions publish workflow, stop and explain that one must b
- `package-lock.json`
- `CHANGELOG.md`
- consumed/deleted `.changeset/*.md` fragments
- Before staging, confirm `package-lock.json` is actually in the diff and carries the new version. If `git status --short` does not show `package-lock.json` as modified while `package.json` changed version, the lockfile sync in step 4 was missed — go back and run `npm install --package-lock-only`. Never commit a release where `package.json` advanced but `package-lock.json` did not.
- Use:
```bash
git add package.json package-lock.json CHANGELOG.md .changeset
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Show a per-session sending indicator while messages with image attachments are uploading. Previously the composer cleared instantly while the upload, server-side image resizing, and first-session open happened in the background, so it looked like nothing was happening. The chat activity dock now shows "Sending your message…" for the originating session (including the folder-mode upload step), and that session shows the activity dot in the session list so progress is visible even after switching away. The indicator is scoped per session, so it no longer leaks onto other sessions or machines, and the upload itself continues in the background regardless of navigation.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Improve user/assistant message distinction in the dark theme. Previously the user and assistant message backgrounds were nearly identical (contrast ratio ~1.06), making it hard to tell speakers apart. Each message now has a colored left accent stripe by role (brand accent for user, neutral for assistant) with matching header labels, applied across all themes. The dark theme's user-message background was also lightened and decoupled from the generic hover color, and the user border brightened, so user turns stand out clearly.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Declutter the chat composer bar with icon-based actions. The Send, Queue, Steer, and Stop buttons are now compact icons, the Attach button moved into the message box, and the thinking level is shown as a small gauge whose bars reflect the levels available for the current model. This leaves more room on narrow/mobile layouts while keeping the model selector readable. All controls retain accessible labels and tooltips. Thinking levels are now sourced from pi directly, so an unfamiliar level from a newer pi version is still selectable and displayed gracefully instead of causing an error.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": minor
---
Add image attachments to the chat composer. You can now paste (Ctrl/Cmd+V), drag-and-drop, or use the new Attach button to add PNG, JPEG, GIF, and WebP images to a message, with thumbnail previews and multi-image support. Attachments are delivered to the session using pi's native image format (images are auto-resized to pi's inline limits for full compatibility), and image content now renders inline in the transcript. A per-message delivery toggle also lets you instead save attachments into the workspace `.pi-web/paste` folder and reference them so the agent reads them with its own tools. The accepted HTTP upload size is now configurable via `PI_WEB_MAX_UPLOAD_BYTES` or the `maxUploadBytes` config value.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Run the suggested Linux restart commands inside a detached transient systemd user service (`systemd-run --user`) instead of directly. The restart now completes even when the launching PI WEB terminal is killed by restarting the session daemon, and its output can be inspected with `journalctl --user -u pi-web-restart`.
-9
View File
@@ -1,9 +0,0 @@
---
"@jmfederico/pi-web": minor
---
Add a **Reload** action to the session three-dot menu that re-reads the session from disk. The session daemon keeps an in-memory `SessionManager` per session and never re-reads the session file, so when the same session is also driven by another process (for example the `pi` CLI), new on-disk entries were invisible to the web UI and the tail of the conversation appeared truncated. Reloading closes the active session, re-opens it from disk, discards the cached transcript, and re-fetches the history.
Reload is also available from the command palette as **Reload Session**, so it can be triggered from the keyboard and assigned a custom shortcut. Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it (both the menu item and the palette action are disabled otherwise).
Note: this changes a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Make the Updates panel actionable: every suggested command now has both a Copy and a Run button (Run executes it in a workspace terminal), a single recommended all-in-one command is shown at the top so users do not have to choose, and the remaining commands are grouped as clearly optional additional commands.
+8
View File
@@ -10,3 +10,11 @@ When working on this project, assume the session runtime owner is long-lived and
If you make changes that affect `src/server/sessiond.ts`, session runtime ownership, the session daemon protocol, or any code path only loaded by the session daemon, inform the user that a manual restart of the session daemon is needed.
Changes to the web/API/UI side generally only require the `pi-web-ui-dev.service` autoreload/restart path.
## Configuration conventions
- `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API.
- Global user/machine config lives at `$PI_WEB_CONFIG` or `~/.config/pi-web/config.json`.
- Project-local PI WEB core config should use one commit-able file: `<project>/.pi-web/config.json`.
- Core features should add keys to these config files, not create one project file per feature.
- Plugins may own separate project config files, such as `.pi-web/tasks.json`.
+42
View File
@@ -1,5 +1,47 @@
# @jmfederico/pi-web
## 1.202606.5
### Patch Changes
- c2e2a29: Add a dedicated PI WEB configuration reference covering config-file precedence, project-local config, external path access allowlists, session daemon tools, plugins, shortcuts, upload limits, and environment variables. Custom `pi-web install --config` paths are now passed to the session daemon service as well as the web service, and the session daemon now honors config-file `maxUploadBytes` values.
- 4f4c6fa: Fix remote session reloads so they proxy through the web/API instead of returning the app shell as JSON.
- 62c2234: Prevent live skill-loading cards from duplicating when the finalized transcript groups multiple skill reads.
- 27bc924: Persist the Settings → Session daemon tracked subsessions toggle so it remains enabled after restart.
- d931101: Fix dead-key/IME input in the terminal (e.g. typing `~` on a Swedish keyboard). The character previously stuck in the top-left corner and was never sent to the shell. The terminal panel now includes the xterm composition-view styles and no longer forces the helper textarea's position with `!important`, so dead-key composition is placed at the cursor and committed correctly.
- 6933d3a: Keep mobile navigation on the selected session when remote workspace loading finishes out of order.
- 2bb6e48: Normalize allowed external path suggestions on Windows so configured absolute paths use platform separators consistently.
- 9cc20d6: Allow configured external filesystem roots to be listed, read, configured from the global settings UI, and completed from absolute `@` path suggestions while keeping absolute paths denied by default, advertise workspace-scoped file suggestion support as a remote-machine capability, and use `fzf` when available to improve file/path completion filtering.
- 355ebe8: Add tracked subsessions (beta, off by default): agents can spawn child sessions they stay attached to. The new `spawn_subsession` tool starts a child session linked to its parent (recorded in the session tree), notifies the parent when the child stops working, and lets the parent inspect children via `list_subsessions`, `check_subsession` (a quick glance at a child's status and latest output), and `read_subsession` (read through a child's transcript with role/content filters, full-content substring search, optional per-value `maxChars` truncation that flags clipped parts, and pagination). The completion notice is delivered as a system-authored message (not attributed to the human), and still wakes an idle parent while queueing behind any in-flight work. Unlike the fire-and-forget `spawn_session`, subsessions are observable by their spawner.
The capability is gated behind a beta flag so it can ship without being exposed in releases: enable it with the `PI_WEB_SUBSESSIONS` env var, the `subsessions` config key, or the "Allow agents to start tracked subsessions" toggle in Settings → Session daemon. It also requires `spawnSessions` to be enabled. Requires a manual session daemon restart to take effect.
## 1.202606.4
### Patch Changes
- 53b00c4: Show a per-session sending indicator while messages with image attachments are uploading. Previously the composer cleared instantly while the upload, server-side image resizing, and first-session open happened in the background, so it looked like nothing was happening. The chat activity dock now shows "Sending your message…" for the originating session (including the folder-mode upload step), and that session shows the activity dot in the session list so progress is visible even after switching away. The indicator is scoped per session, so it no longer leaks onto other sessions or machines, and the upload itself continues in the background regardless of navigation.
- cfb7493: Improve user/assistant message distinction in the dark theme. Previously the user and assistant message backgrounds were nearly identical (contrast ratio ~1.06), making it hard to tell speakers apart. The dark theme's user-message background was lightened and decoupled from the generic hover color, and the user border brightened, so user turns stand out clearly.
- dd23b3e: Fix a duplicate session appearing in the list when starting a new session. The `session.created` broadcast (added with the spawn_session tool) could race ahead of the start request's HTTP response in the same tab, leaving two badges with the same id — one with archive/reload actions and one with delete. The optimistic insert now replaces any entry the broadcast added, so the locally cached session (with its delete action and draft support) always wins.
- 3930505: Fix the "Catching up…" badge sometimes staying visible after a session goes idle. The stream catch-up mode was tracked by two fields that could drift — a private guard and the public badge flag — and the socket reconnect path updated one without the other, so the terminating idle status no longer cleared the badge. Both facets now route through a single source of truth, and any idle status for the selected session reliably dismisses the badge.
- 411e61a: Declutter the chat composer bar with icon-based actions. The Send, Queue, Steer, and Stop buttons are now compact icons, the Attach button moved into the message box, and the thinking level is shown as a small gauge whose bars reflect the levels available for the current model. This leaves more room on narrow/mobile layouts while keeping the model selector readable. All controls retain accessible labels and tooltips. Thinking levels are now sourced from pi directly, so an unfamiliar level from a newer pi version is still selectable and displayed gracefully instead of causing an error.
- d17050e: Add image attachments to the chat composer. You can now paste (Ctrl/Cmd+V), drag-and-drop, or use the new Attach button to add PNG, JPEG, GIF, and WebP images to a message, with thumbnail previews and multi-image support. Attachments are delivered to the session using pi's native image format (images are auto-resized to pi's inline limits for full compatibility), and image content now renders inline in the transcript. A per-message delivery toggle also lets you instead save attachments into the workspace `.pi-web/attachments` folder and reference them so the agent reads them with its own tools. The accepted HTTP upload size is now configurable via `PI_WEB_MAX_UPLOAD_BYTES` or the `maxUploadBytes` config value.
- 3c6b4a4: Run the suggested Linux restart commands inside a detached transient systemd user service (`systemd-run --user`) instead of directly. The restart now completes even when the launching PI WEB terminal is killed by restarting the session daemon, and its output can be inspected with `journalctl --user -u pi-web-restart`.
- 61f0b79: Move reload to the end of the session action menu.
- 82db15f: Add a **Reload** action to the session three-dot menu that re-reads the session from disk. The session daemon keeps an in-memory `SessionManager` per session and never re-reads the session file, so when the same session is also driven by another process (for example the `pi` CLI), new on-disk entries were invisible to the web UI and the tail of the conversation appeared truncated. Reloading closes the active session, re-opens it from disk, discards the cached transcript, and re-fetches the history.
Reload is also available from the command palette as **Reload Session**, so it can be triggered from the keyboard and assigned a custom shortcut. Reload refuses to run while the session has work in progress and on archived (read-only) sessions, and is gated behind a new `sessions.reload` runtime capability so it only appears for machines whose Pi-Web runtime supports it (both the menu item and the palette action are disabled otherwise).
Note: this changes a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect.
- 95c1512: Let agents start new sessions with a `spawn_session` tool. An agent can dispatch a fresh, independent session with an initial prompt — useful for ralph-style loops (an agent kicks off the next iteration when done) and for chaining long plans across sessions. Spawned sessions are normal sessions a human can open and interact with, and they now appear in the session list the moment they are created (in the matching workspace) without a manual reload.
To keep every spawned session visible and controllable, an agent may only spawn into a workspace — any worktree, including one it just created — of the same registered project as the spawning session. The capability is on by default and can be toggled under Settings → Session daemon (or via the `spawnSessions` config key / `PI_WEB_SPAWN_SESSIONS` environment variable); changes take effect after the session daemon restarts.
Note: this adds a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect.
- 3c6b4a4: Make the Updates panel actionable: every suggested command now has both a Copy and a Run button (Run executes it in a workspace terminal), a single recommended all-in-one command is shown at the top so users do not have to choose, and the remaining commands are grouped as clearly optional additional commands.
## 1.202606.3
### Patch Changes
+37 -14
View File
@@ -1,4 +1,4 @@
# PI WEB
# PI WEB — web UI for Pi Coding Agent
[![CI](https://github.com/jmfederico/pi-web/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jmfederico/pi-web/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@jmfederico/pi-web)](https://www.npmjs.com/package/@jmfederico/pi-web)
@@ -10,11 +10,16 @@ Website: <https://pi-web.dev/>
![PI WEB](docs/assets/pi-web-banner.png)
**Run AI coding agents on your own machine or server, keep them alive in real workspaces, and control everything from a browser.**
**Run Pi Coding Agent from a web UI, keep sessions alive in real workspaces, and supervise them from any device.**
PI WEB is a web control plane for [Pi Coding Agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent). Add your repositories once, open project workspaces and git worktrees, start agent sessions inside them, and come back later without losing the work. Your browser becomes the cockpit; your server becomes the persistent development environment. Start on your laptop, check in from your phone, and continue from an iPad or another machine whenever that is the device you have at hand.
PI WEB is a web UI for [Pi Coding Agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) that keeps agent sessions running on your own machine or server. Add your repositories once, open project workspaces and git worktrees, start sessions inside them, and come back later without losing the work. Your browser becomes the cockpit; your server becomes the persistent development environment. Start on your laptop, check in from your phone, and continue from an iPad or another machine whenever that is the device you have at hand.
![PI WEB demo](docs/assets/pi-web-demo.gif)
![PI WEB desktop screenshot showing an agent-created pi-web.dev screenshot selected in the file preview](docs/assets/pi-web-desktop.png)
<p align="center">
<img src="docs/assets/pi-web-tablet.png" alt="PI WEB tablet screenshot" width="58%" />
<img src="docs/assets/pi-web-mobile.png" alt="PI WEB mobile chat screenshot" width="28%" />
</p>
With PI WEB you can:
@@ -32,6 +37,10 @@ Agentic development works best when agents are not trapped inside a single local
PI WEB connects those two worlds. The work stays in the server-side environment while you move between devices: laptop for deep focus, phone for a quick check-in, tablet for review, desktop when you are back at a desk. It is not trying to recreate the old desktop IDE in a browser; it is a control surface for persistent, parallel, human-in-the-loop agent work.
### Is PI WEB a Pi web UI?
Yes. PI WEB is a Pi web UI for running and supervising Pi Coding Agent sessions from a browser. Unlike simple session viewers, PI WEB is built around persistent server-side workspaces, long-running session daemons, git worktrees, remote machines, and multi-device supervision.
## Core model
PI WEB organizes work into four levels:
@@ -69,7 +78,7 @@ This maps naturally to real development work:
## Architecture
PI WEB uses a split-process architecture so agent runtimes are not owned by the browser-facing dev server.
PI WEB uses a split-process architecture so agent runtimes are not owned by the browser-facing dev server. Under the hood, it acts as a browser-based control plane for sessions, workspaces, files, terminals, and trusted remote machines.
```text
Browser UI
@@ -104,7 +113,7 @@ PI WEB keeps its own state intentionally small:
## Machine federation
The Machines section lets one PI WEB instance act as a gateway to other PI WEB runtimes. Register a remote machine from **Actions → Add Machine** with the remote PI WEB base URL, for example a URL reachable over NetBird, Tailscale, WireGuard, an SSH tunnel, or a trusted reverse proxy. The browser continues talking to the local PI WEB origin; project, workspace, file, git, session, activity, and terminal HTTP/WebSocket traffic is proxied server-to-server. See the [Fleet guide](https://pi-web.dev/machines.html) for setup, trust model, and troubleshooting details.
The Machines section lets one PI WEB instance act as a gateway to other PI WEB runtimes. Register a remote machine from **Actions → Add Machine** with the remote PI WEB base URL, for example a URL reachable over NetBird, Tailscale, WireGuard, an SSH tunnel, or a trusted reverse proxy. The browser continues talking to the local PI WEB origin; project, workspace, file, git, session, activity, and terminal HTTP/WebSocket traffic is proxied server-to-server. See the [Fleet guide](https://pi-web.dev/machines) for setup, trust model, and troubleshooting details.
Remote model-provider credentials and OAuth state stay on the target machine. API-key provider configuration can be proxied, but OAuth login should be completed by opening the remote PI WEB directly. Register remote machines only when you trust the endpoint and the network path: adding a machine gives this PI WEB server permission to contact that URL with the optional bearer token you configured.
@@ -118,7 +127,7 @@ A useful prompt for AI agents:
```text
Build a PI WEB plugin for this project. Goal: <describe the UI behavior>.
Before coding, read https://pi-web.dev/plugins.html and https://pi-web.dev/plugins.md.
Before coding, read https://pi-web.dev/plugins and https://pi-web.dev/plugins.md.
Create it under ~/.pi-web/plugins/<plugin-id> using the documented PI WEB v1 plugin API.
Validate with /pi-web-plugins/manifest.json and explain reload/debug steps.
Do not modify PI WEB itself.
@@ -250,10 +259,16 @@ npm publish --access public
PI WEB uses a single-line CalVer-inspired npm version: `MAJOR.YYYYMM.SEQUENCE`, for example `1.202605.1`. The major number signals breaking-change eras; the middle number is the release month; the final number increments for additional releases in that month. Older major eras may be deprecated rather than maintained in parallel.
PI WEB declares `@earendil-works/pi-coding-agent` as a peer dependency (`>=0.74.0 <1`) and a development dependency for local builds. This keeps published installs flexible: npm 7+ installs the peer automatically, and users can upgrade the Pi package within the compatible range without PI WEB pinning a separate copy.
PI WEB declares `@earendil-works/pi-coding-agent` as a peer dependency (`>=0.78.0 <1`) and a development dependency for local builds. This keeps published installs flexible: npm 7+ installs the peer automatically, and users can upgrade the Pi package within the compatible range without PI WEB pinning a separate copy.
The web server defaults to `127.0.0.1:8504`. Set `PI_WEB_HOST=0.0.0.0` only when you intentionally want to bind directly on all interfaces.
## Configuration
Global PI WEB config lives at `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`. Project-local core config lives at `<project>/.pi-web/config.json`.
See the full [Configuration reference](docs/config.md) for config-file precedence, project-local config, external path access, session daemon settings, plugins, shortcuts, upload limits, and environment variables.
The web server defaults to `127.0.0.1:8504`. Set `PI_WEB_HOST=0.0.0.0` only when you intentionally want to bind directly on all interfaces behind a trusted network, firewall, or authenticated proxy.
The session daemon defaults to a private Unix socket at:
@@ -261,18 +276,26 @@ The session daemon defaults to a private Unix socket at:
~/.pi-web/sessiond.sock
```
Environment variables:
Common config keys:
- `PI_WEB_PORT` / `PORT` — web server port. Defaults to `8504`.
- `PI_WEB_HOST` — web server bind host. Defaults to `127.0.0.1`.
- `PI_WEB_DATA_DIR` — PI WEB data directory. Defaults to `~/.pi-web`.
- `host` / `port` — web/API bind address. Environment overrides: `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`.
- `pathAccess.allowedPaths` — external filesystem roots that PI WEB may list/read through the file explorer and absolute `@` path completions. Absolute paths are denied by default.
- `maxUploadBytes` — maximum accepted request body size. Defaults to 64 MB. Environment override: `PI_WEB_MAX_UPLOAD_BYTES`.
- `spawnSessions` — enable the `spawn_session` tool. Defaults to `true`. Environment override: `PI_WEB_SPAWN_SESSIONS`.
- `subsessions` — beta tracked-subsession tools (`spawn_subsession`, `list_subsessions`, `check_subsession`, `read_subsession`). Defaults to `false`, requires `spawnSessions`, and requires a session daemon restart after changes. Environment override: `PI_WEB_SUBSESSIONS`.
- `plugins` — plugin enablement/settings. Reload the browser after changing plugin enablement.
- `shortcuts` — keyboard shortcut overrides; use `null` to disable an action shortcut.
Operational environment variables:
- `PI_WEB_CONFIG` — path to the global config JSON file.
- `PI_WEB_DATA_DIR` — PI WEB-managed data directory. Defaults to `~/.pi-web`.
- `PI_WEB_SESSIOND_SOCKET` — Unix socket path used by both the daemon and web process when `PI_WEB_SESSIOND_URL` is not set. Defaults to `$PI_WEB_DATA_DIR/sessiond.sock`.
- `PI_WEB_SESSIOND_PORT` — optional TCP port for the daemon. If unset, the daemon listens on the Unix socket instead.
- `PI_WEB_SESSIOND_HOST` — daemon TCP bind host when `PI_WEB_SESSIOND_PORT` is set. Defaults to `127.0.0.1`.
- `PI_WEB_SESSIOND_URL` — daemon URL used by the web process when connecting over TCP, for example `http://127.0.0.1:3001`. If you set `PI_WEB_SESSIOND_PORT`, set this for the web process too.
- `PI_WEB_PROJECTS_FILE` — optional override for the projects storage JSON file. Defaults to `$PI_WEB_DATA_DIR/projects.json`.
- `PI_WEB_MACHINES_FILE` — optional override for the remote machine registry JSON file. Defaults to `$PI_WEB_DATA_DIR/machines.json`.
- `PI_WEB_MAX_UPLOAD_BYTES` — maximum accepted HTTP request body size in bytes (covers pasted/attached images). Defaults to 64 MB. Also configurable as `maxUploadBytes` in `config.json`.
- `PI_CODING_AGENT_SESSION_DIR` — Pi session storage directory. PI WEB follows the same session-location priority as Pi for web sessions: this environment variable, then `sessionDir` in Pi settings for the selected workspace, then Pi's default session directory.
- `PI_CODING_AGENT_DIR` — Pi agent config directory. PI WEB uses this for Pi auth, settings, resources, and default session storage, matching Pi's own configuration layout.
+20 -10
View File
@@ -6,8 +6,16 @@
<title>Page not found — PI WEB</title>
<meta name="description" content="The PI WEB page you requested does not exist." />
<meta name="robots" content="noindex" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="Page not found — PI WEB" />
<meta property="og:image" content="/assets/pi-web-banner.png" />
<meta property="og:url" content="https://pi-web.dev/404" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Page not found — PI WEB" />
<meta name="twitter:description" content="The PI WEB page you requested does not exist." />
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
@@ -29,11 +37,12 @@
<a class="brand" href="/" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="/remote-first.html">Remote-first</a>
<a href="/machines.html">Fleet</a>
<a href="/install.html">Install</a>
<a href="/plugins.html">Plugins</a>
<a href="/faq.html">FAQ</a>
<a href="/remote-first">Remote-first</a>
<a href="/machines">Fleet</a>
<a href="/install">Install</a>
<a href="/config">Config</a>
<a href="/plugins">Plugins</a>
<a href="/faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
@@ -80,10 +89,11 @@
<div class="container footer-inner">
<span>PI WEB · remote control for persistent Pi Coding Agent sessions.</span>
<div class="footer-links">
<a href="/machines.html">Fleet</a>
<a href="/install.html">Install</a>
<a href="/plugins.html">Plugins</a>
<a href="/faq.html">FAQ</a>
<a href="/machines">Fleet</a>
<a href="/install">Install</a>
<a href="/config">Config</a>
<a href="/plugins">Plugins</a>
<a href="/faq">FAQ</a>
<a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
# Canonical SEO redirects supported by Cloudflare Workers static assets.
# Host and scheme redirects need Cloudflare Redirect Rules or custom Worker routing.
/index.html / 301
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+481
View File
@@ -0,0 +1,481 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Configure PI WEB — config files, paths, and session tools</title>
<meta
name="description"
content="Configure PI WEB config files, external path access, session daemon tools, plugins, shortcuts, uploads, and runtime environment variables."
/>
<link rel="canonical" href="https://pi-web.dev/config" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="Configure PI WEB" />
<meta
property="og:description"
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables."
/>
<meta property="og:url" content="https://pi-web.dev/config" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Configure PI WEB" />
<meta
name="twitter:description"
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
const theme = window.localStorage.getItem("pi-web-theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="site-header">
<nav class="container nav" aria-label="Main navigation">
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config" aria-current="page">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
/>
</svg>
<span>GitHub</span>
</a>
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
<span data-theme-icon aria-hidden="true"></span>
<span data-theme-label>Theme</span>
</button>
</div>
</div>
</nav>
</header>
<main>
<section class="page-hero">
<div class="container">
<p class="eyebrow"><span class="pulse"></span> Configuration reference</p>
<h1>Configure PI WEB where your agents work.</h1>
<p>
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload
limits, and session-daemon tools.
</p>
</div>
</section>
<section class="section compact">
<div class="container doc-layout">
<aside class="toc" aria-label="Config page contents">
<strong>On this page</strong>
<a href="#files">Config files</a>
<a href="#precedence">Precedence and reloads</a>
<a href="#global-config">Global config</a>
<a href="#project-config">Project config</a>
<a href="#keys">Config matrix</a>
<a href="#path-access">External path access</a>
<a href="#session-tools">Session tools</a>
<a href="#completion-tools">Completion tools</a>
</aside>
<div class="doc-content">
<section id="files">
<h2>Config files</h2>
<p>PI WEB uses a global config file for machine-local settings and a project-local file for repository settings.</p>
<ul>
<li><strong>Global config:</strong> <code>$PI_WEB_CONFIG</code>, or <code>$XDG_CONFIG_HOME/pi-web/config.json</code>, or <code>~/.config/pi-web/config.json</code>.</li>
<li><strong>Project config:</strong> <code>&lt;project&gt;/.pi-web/config.json</code> for commit-able project settings.</li>
</ul>
<p>
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's
config by opening that machine directly or changing files on that machine.
</p>
<p>
If you installed services with a custom config path, rerun
<code>pi-web install --config /path/to/config.json</code> after changing that path or after upgrading from
a version that only applied the custom path to the web service. This regenerates service files so the
web/API and session daemon use the same <code>PI_WEB_CONFIG</code>.
</p>
</section>
<section id="precedence">
<h2>Precedence and reloads</h2>
<p>Runtime values are resolved in this order:</p>
<div class="code-card">
<pre><code>defaults → config file → environment overrides</code></pre>
</div>
<p>
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_SPAWN_SESSIONS</code>,
and <code>PI_WEB_SUBSESSIONS</code>.
</p>
<ul>
<li><code>host</code> / <code>port</code>: restart the web/API service or process.</li>
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon.</li>
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon.</li>
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing plugin enablement.</li>
<li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li>
</ul>
</section>
<section id="global-config">
<h2>Global config example</h2>
<p>
<code>pi-web install</code> creates the initial file. You can also save settings from
<strong>Settings → General</strong>, <strong>Settings → Plugins</strong>, <strong>Settings → Keyboard</strong>,
and <strong>Settings → Session daemon</strong>.
</p>
<div class="code-card">
<div class="copy-row">
<strong>Example config.json</strong>
<button class="copy-button" data-copy="#global-config-example">Copy</button>
</div>
<pre id="global-config-example"><code>{
"host": "127.0.0.1",
"port": 8504,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
},
"maxUploadBytes": 67108864,
"spawnSessions": true,
"subsessions": false,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
"info": { "enabled": false }
},
"shortcuts": {
"core:view.chat": "mod+1",
"core:session.stop": null
}
}</code></pre>
</div>
</section>
<section id="project-config">
<h2>Project-local config</h2>
<p>
Project-local config lives at <code>&lt;project&gt;/.pi-web/config.json</code>. Use it for settings that should
follow a repository. When a project config defines <code>pathAccess</code>, PI WEB merges it after the
global path list.
</p>
<div class="code-card">
<div class="copy-row">
<strong>.pi-web/config.json</strong>
<button class="copy-button" data-copy="#project-config-example">Copy</button>
</div>
<pre id="project-config-example"><code>{
"version": 1,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
}
}</code></pre>
</div>
<p>
Project-local <code>pathAccess.allowedPaths</code> entries must still be host-absolute or
<code>~</code>-prefixed; relative roots are not supported. Plugins may own separate project files, such as
<code>.pi-web/tasks.json</code> for the built-in Workspace Tasks plugin.
</p>
</section>
<section id="keys">
<h2>Config matrix</h2>
<p>
Use this table as the quick reference for where a setting can live, which environment variable overrides
it, and whether project-local config overrides or merges with global config. Rows with JSON key
<code></code> are runtime-only environment variables, not config-file keys.
</p>
<div class="table-scroll" role="region" aria-label="PI WEB configuration matrix" tabindex="0">
<table class="config-matrix">
<thead>
<tr>
<th scope="col">Config</th>
<th scope="col">JSON key</th>
<th scope="col">Env var</th>
<th scope="col">Scope</th>
<th scope="col">Project-local behavior</th>
<th scope="col">Applies / restart</th>
</tr>
</thead>
<tbody>
<tr class="table-section"><th scope="rowgroup" colspan="6">Config-file keys</th></tr>
<tr>
<td>Web/API bind host</td>
<td><code>host</code></td>
<td><code>PI_WEB_HOST</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart web/API</td>
</tr>
<tr>
<td>Web/API port</td>
<td><code>port</code></td>
<td><code>PI_WEB_PORT</code>, <code>PORT</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart web/API</td>
</tr>
<tr>
<td>Dev-server allowed hosts</td>
<td><code>allowedHosts</code></td>
<td><code>PI_WEB_ALLOWED_HOSTS</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart dev web/UI</td>
</tr>
<tr>
<td>External filesystem roots</td>
<td><code>pathAccess.allowedPaths</code></td>
<td></td>
<td>Global + project</td>
<td><strong>Merges:</strong> global roots first, then project roots; duplicates removed</td>
<td>Next file request; refresh existing views if needed</td>
</tr>
<tr>
<td>Upload/body limit</td>
<td><code>maxUploadBytes</code></td>
<td><code>PI_WEB_MAX_UPLOAD_BYTES</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart web/API and session daemon</td>
</tr>
<tr>
<td>Agent can spawn sessions</td>
<td><code>spawnSessions</code></td>
<td><code>PI_WEB_SPAWN_SESSIONS</code></td>
<td>Global/session daemon</td>
<td>Not supported locally</td>
<td>Restart session daemon</td>
</tr>
<tr>
<td>Tracked subsessions (beta)</td>
<td><code>subsessions</code></td>
<td><code>PI_WEB_SUBSESSIONS</code></td>
<td>Global/session daemon</td>
<td>Not supported locally; also requires <code>spawnSessions</code></td>
<td>Restart session daemon</td>
</tr>
<tr>
<td>Plugin enablement/settings</td>
<td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td>
<td></td>
<td>Global</td>
<td>Not core local config; plugins may read their own project files</td>
<td>Reload browser tab</td>
</tr>
<tr>
<td>Keyboard shortcuts</td>
<td><code>shortcuts.&lt;actionId&gt;</code></td>
<td></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Applies after settings save/config refresh</td>
</tr>
<tr>
<td>Project config version</td>
<td><code>version</code></td>
<td></td>
<td>Project</td>
<td>Project-local only; must be <code>1</code> when present</td>
<td>Next project-config read</td>
</tr>
<tr class="table-section"><th scope="rowgroup" colspan="6">Runtime-only environment variables</th></tr>
<tr>
<td>Global config file path</td>
<td></td>
<td><code>PI_WEB_CONFIG</code> (<code>XDG_CONFIG_HOME</code> affects the default path)</td>
<td>Process/env</td>
<td>Selects the global config file; not a project config</td>
<td>Restart services/processes after changing env</td>
</tr>
<tr>
<td>Managed data directory</td>
<td></td>
<td><code>PI_WEB_DATA_DIR</code></td>
<td>Process/env</td>
<td>Not supported locally</td>
<td>Restart services before changing; moves managed state location</td>
</tr>
<tr>
<td>Session daemon socket</td>
<td></td>
<td><code>PI_WEB_SESSIOND_SOCKET</code></td>
<td>Web/API + session daemon env</td>
<td>Not supported locally</td>
<td>Restart daemon and web/API; both must match</td>
</tr>
<tr>
<td>Session daemon TCP port</td>
<td></td>
<td><code>PI_WEB_SESSIOND_PORT</code></td>
<td>Session daemon env</td>
<td>Not supported locally</td>
<td>Restart session daemon; set <code>PI_WEB_SESSIOND_URL</code> for web/API too</td>
</tr>
<tr>
<td>Session daemon TCP host</td>
<td></td>
<td><code>PI_WEB_SESSIOND_HOST</code></td>
<td>Session daemon env</td>
<td>Not supported locally</td>
<td>Restart session daemon</td>
</tr>
<tr>
<td>Web-to-daemon URL</td>
<td></td>
<td><code>PI_WEB_SESSIOND_URL</code></td>
<td>Web/API env</td>
<td>Not supported locally</td>
<td>Restart web/API</td>
</tr>
<tr>
<td>Projects storage file</td>
<td></td>
<td><code>PI_WEB_PROJECTS_FILE</code></td>
<td>Web/API + session daemon env</td>
<td>Not supported locally</td>
<td>Restart services; advanced state override</td>
</tr>
<tr>
<td>Remote machines storage file</td>
<td></td>
<td><code>PI_WEB_MACHINES_FILE</code></td>
<td>Web/API env</td>
<td>Not supported locally</td>
<td>Restart web/API; advanced state override</td>
</tr>
<tr>
<td>Pi session storage directory</td>
<td></td>
<td><code>PI_CODING_AGENT_SESSION_DIR</code></td>
<td>Pi/session daemon env</td>
<td>Not supported locally</td>
<td>Restart session daemon; follows Pi session priority</td>
</tr>
<tr>
<td>Pi agent config directory</td>
<td></td>
<td><code>PI_CODING_AGENT_DIR</code></td>
<td>Pi/Web/API/session daemon env</td>
<td>Not supported locally</td>
<td>Restart services</td>
</tr>
<tr>
<td>Skip update checks</td>
<td></td>
<td><code>PI_WEB_SKIP_VERSION_CHECK</code>, <code>PI_WEB_OFFLINE</code>, <code>PI_SKIP_VERSION_CHECK</code>, <code>PI_OFFLINE</code></td>
<td>Web/API env</td>
<td>Not supported locally</td>
<td>Restart web/API after env changes</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="path-access">
<h2>External path access</h2>
<p>
<code>pathAccess.allowedPaths</code> grants PI WEB's file explorer and absolute <code>@</code> path
completions access to specific filesystem roots outside the current workspace. By default,
workspace-relative file reads stay inside the workspace and absolute paths are denied.
</p>
<p>Accepted root forms:</p>
<ul>
<li>Unix absolute paths, for example <code>/opt/reference</code>.</li>
<li>Home-relative paths, for example <code>~/SDKs</code>.</li>
<li>Windows absolute paths on Windows hosts, for example <code>C:\Users\dev\SDKs</code>.</li>
</ul>
<p>
When an absolute request is served, PI WEB expands <code>~</code>, canonicalizes configured roots with
<code>realpath</code>, requires roots to be existing directories, and rejects symlink escapes outside the
allowed roots.
</p>
<div class="callout warning">
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API
file exposure outside a workspace. Add only roots you trust PI WEB to list and read through the browser UI.
</div>
</section>
<section id="session-tools">
<h2>Session daemon tools</h2>
<h3><code>spawnSessions</code></h3>
<p>
Boolean. Controls whether agents receive the <code>spawn_session</code> tool. Defaults to
<code>true</code>. Set it to <code>false</code> if you do not want an agent to start independent PI WEB sessions.
</p>
<p>Environment override: <code>PI_WEB_SPAWN_SESSIONS=0|1|true|false</code>.</p>
<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.
</p>
<p>
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop
working, and inspect their transcripts. Restart the session daemon after changing this setting.
</p>
<p>Environment override: <code>PI_WEB_SUBSESSIONS=0|1|true|false</code>.</p>
</section>
<section id="completion-tools">
<h2>Optional completion tools</h2>
<p>
File and path <code>@</code> completions work without extra tools. If <code>fzf</code> is available on the
PI WEB server's <code>PATH</code>, PI WEB uses it to improve completion filtering and ranking; otherwise it
falls back to built-in ranking.
</p>
<div class="doc-actions">
<a class="button primary" href="install">Install guide</a>
<a class="button" href="config.md">Markdown reference</a>
</div>
</section>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="container footer-inner">
<span>PI WEB docs</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
</div>
</footer>
<script src="site.js"></script>
</body>
</html>
+166
View File
@@ -0,0 +1,166 @@
# 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, upload limits, and session-daemon tools.
This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>.
## Config files
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, edit a remote machine's config by opening that machine directly or changing files on that machine.
If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`.
## Precedence and reloads
Runtime values are resolved as:
```text
defaults → config file → environment overrides
```
Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`.
Process restarts depend on the key:
- `host` / `port`: restart the web/API service or process.
- `maxUploadBytes`: restart both the web/API process and the session daemon.
- `spawnSessions` / `subsessions`: restart the session daemon.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `plugins`: reload the browser tab after changing plugin enablement.
- `shortcuts`: saved settings apply in the browser after config refresh/save.
## Global config example
```json
{
"host": "127.0.0.1",
"port": 8504,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
},
"maxUploadBytes": 67108864,
"spawnSessions": true,
"subsessions": false,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
"info": { "enabled": false }
},
"shortcuts": {
"core:view.chat": "mod+1",
"core:session.stop": null
}
}
```
## Project-local config
Project-local config lives at `<project>/.pi-web/config.json`. Use it for settings that should follow a repository.
```json
{
"version": 1,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
}
}
```
Project-local `pathAccess.allowedPaths` entries are merged after the global list and deduplicated. Paths must still be host-absolute or `~`-prefixed; relative roots are not supported.
Plugins may own separate project files, such as `.pi-web/tasks.json` for the built-in Workspace Tasks plugin.
## Configuration matrix
Rows with JSON key `—` are runtime-only environment variables, not config-file keys.
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
| --- | --- | --- | --- | --- | --- |
| **Config-file keys** | | | | | |
| Web/API bind host | `host` | `PI_WEB_HOST` | Global | Not supported locally | Restart web/API |
| Web/API port | `port` | `PI_WEB_PORT`, `PORT` | Global | Not supported locally | Restart web/API |
| Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI |
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon |
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon |
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
| Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh |
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
| **Runtime-only environment variables** | | | | | |
| Global config file path | — | `PI_WEB_CONFIG` (`XDG_CONFIG_HOME` affects the default path) | Process/env | Selects the global config file; not a project config | Restart services/processes after changing env |
| Managed data directory | — | `PI_WEB_DATA_DIR` | Process/env | Not supported locally | Restart services before changing; moves managed state location |
| Session daemon socket | — | `PI_WEB_SESSIOND_SOCKET` | Web/API + session daemon env | Not supported locally | Restart daemon and web/API; both must match |
| Session daemon TCP port | — | `PI_WEB_SESSIOND_PORT` | Session daemon env | Not supported locally | Restart session daemon; set `PI_WEB_SESSIOND_URL` for web/API too |
| Session daemon TCP host | — | `PI_WEB_SESSIOND_HOST` | Session daemon env | Not supported locally | Restart session daemon |
| 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 |
| Pi session storage directory | — | `PI_CODING_AGENT_SESSION_DIR` | Pi/session daemon env | Not supported locally | Restart session daemon; follows Pi session priority |
| Pi agent config directory | — | `PI_CODING_AGENT_DIR` | Pi/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
### External path access
`pathAccess.allowedPaths` grants PI WEB's file explorer and absolute `@` path completions access to specific filesystem roots outside the current workspace.
By default, workspace-relative file reads stay inside the workspace and absolute paths are denied. Add only roots you trust PI WEB to list and read through the browser UI.
Accepted root forms:
- Unix absolute paths: `/opt/reference`
- Home-relative paths: `~/SDKs`
- Windows absolute paths on Windows hosts: `C:\Users\dev\SDKs`
When an absolute request is served, PI WEB expands `~`, canonicalizes the configured roots with `realpath`, requires roots to be existing directories, and rejects symlink escapes outside the allowed roots.
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace.
### 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.
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop working, and inspect their transcripts.
### Plugin config
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it.
```json
{
"plugins": {
"workspace-tasks": { "enabled": true, "settings": {} },
"updates": { "enabled": false }
}
}
```
Reload the browser tab after changing plugin enablement. Already-loaded plugin JavaScript is not unloaded from the current page.
### Shortcut config
Shortcut values are keyed by action id. Values are shortcut strings such as `mod+k` or `mod+g p`; `null` disables that action's shortcut.
```json
{
"shortcuts": {
"core:view.chat": "mod+1",
"core:session.stop": null
}
}
```
Prefer Settings → Keyboard for editing shortcuts interactively.
## Optional completion tools
File and path `@` completions work without extra tools. If `fzf` is available on the PI WEB server's `PATH`, PI WEB uses it to improve completion filtering/ranking; otherwise it falls back to built-in ranking.
+46 -15
View File
@@ -3,10 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PI WEB FAQ</title>
<meta name="description" content="Answers for common PI WEB install and runtime issues." />
<meta property="og:title" content="PI WEB FAQ" />
<meta property="og:image" content="assets/pi-web-banner.png" />
<title>PI WEB FAQ — Pi web UI troubleshooting</title>
<meta
name="description"
content="Troubleshoot PI WEB, the web UI for Pi Coding Agent, including install, PATH, services, remote access, and runtime issues."
/>
<link rel="canonical" href="https://pi-web.dev/faq" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="PI WEB FAQ — Pi web UI troubleshooting" />
<meta
property="og:description"
content="Fix common PI WEB install, service, PATH, remote access, and session issues."
/>
<meta property="og:url" content="https://pi-web.dev/faq" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="PI WEB FAQ — Pi web UI troubleshooting" />
<meta
name="twitter:description"
content="Fix common PI WEB install, service, PATH, remote access, and session issues."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
@@ -28,11 +47,12 @@
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html" aria-current="page">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq" aria-current="page">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
@@ -70,6 +90,7 @@
<aside class="toc" aria-label="FAQ contents">
<strong>Questions</strong>
<a href="#is-this-linux-only">What platforms are supported?</a>
<a href="#is-pi-web-a-web-ui">Is PI WEB a Pi web UI?</a>
<a href="#tools-are-not-found">Tools are failing / node not found</a>
<a href="#doctor-fails">What does doctor check?</a>
<a href="#nvm-fnm-asdf">nvm, fnm, or asdf issues</a>
@@ -97,6 +118,15 @@
</ul>
</article>
<article id="is-pi-web-a-web-ui" class="faq-item">
<h2>Is PI WEB a Pi web UI?</h2>
<p>
Yes. PI WEB is a web UI for Pi Coding Agent that runs and supervises persistent sessions from a browser.
Unlike simple session viewers, PI WEB is built around real server-side workspaces, long-running session
daemons, git worktrees, remote machines, terminals, files, and multi-device supervision.
</p>
</article>
<article id="tools-are-not-found" class="faq-item">
<h2>Tools are failing, node is not found, or Pi cannot find commands</h2>
<p>
@@ -209,7 +239,7 @@
<li>Remote plugins are trusted browser code and only appear while that machine is selected.</li>
<li>If the remote reports offline, check that the gateway server can reach the remote URL and try <strong>Actions → Refresh Selected Machine</strong>.</li>
</ul>
<p><a href="machines.html">Read the fleet guide →</a></p>
<p><a href="machines">Read the fleet guide →</a></p>
</article>
<article id="laptop-or-server" class="faq-item">
@@ -233,7 +263,7 @@
development folder there. Reload the browser tab after edits. If <code>PI_WEB_DATA_DIR</code> is set, use
<code>$PI_WEB_DATA_DIR/plugins</code> instead.
</p>
<p><a href="plugins.html">Read the plugin guide →</a></p>
<p><a href="plugins">Read the plugin guide →</a></p>
</article>
<article id="sessions-stop" class="faq-item">
@@ -273,10 +303,11 @@
<span>PI WEB FAQ</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="https://github.com/jmfederico/pi-web/issues">Issues</a>
</div>
</div>
+83 -37
View File
@@ -3,17 +3,45 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PI WEB — persistent AI coding agents in your browser</title>
<title>PI WEB — web UI for Pi Coding Agent</title>
<meta
name="description"
content="PI WEB makes AI coding work persistent by default: agents keep running outside your browser while you supervise from any device."
content="PI WEB is a web UI for Pi Coding Agent that keeps agent sessions running in real workspaces on your machine or server."
/>
<meta property="og:title" content="PI WEB" />
<link rel="canonical" href="https://pi-web.dev/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="PI WEB — web UI for Pi Coding Agent" />
<meta
property="og:description"
content="Run persistent AI coding agents in real workspaces, keep them alive outside your device, and supervise everything from a browser."
content="Run persistent Pi Coding Agent sessions in real workspaces and supervise them from any browser."
/>
<meta property="og:image" content="assets/pi-web-banner.png" />
<meta property="og:url" content="https://pi-web.dev/" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="PI WEB — web UI for Pi Coding Agent" />
<meta
name="twitter:description"
content="Run persistent Pi Coding Agent sessions in real workspaces and supervise them from any browser."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "PI WEB",
"alternateName": ["Pi web UI", "Pi Coding Agent web UI"],
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Linux, macOS, Windows WSL",
"url": "https://pi-web.dev/",
"downloadUrl": "https://www.npmjs.com/package/@jmfederico/pi-web",
"codeRepository": "https://github.com/jmfederico/pi-web",
"description": "PI WEB is a web UI for Pi Coding Agent that keeps persistent agent sessions running in real workspaces on your machine or server.",
"softwareRequirements": "Node.js 22 or newer and Pi Coding Agent",
"license": "https://github.com/jmfederico/pi-web/blob/main/LICENSE"
}
</script>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
@@ -35,11 +63,12 @@
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
@@ -64,20 +93,22 @@
<section class="hero">
<div class="container hero-grid">
<div>
<p class="eyebrow"><span class="pulse"></span> A cockpit for agentic development</p>
<p class="eyebrow"><span class="pulse"></span> Pi Coding Agent web UI</p>
<h1>
<span class="title-context">Your agents keep working.</span>
<span class="gradient-text intro-target" aria-label="You just need a browser.">
<span class="intro-word" style="--i: 0">You</span>
<span class="intro-word" style="--i: 1">just</span>
<span class="intro-word" style="--i: 2">need</span>
<span class="intro-word" style="--i: 3">a</span>
<span class="intro-word" style="--i: 4">browser.</span>
<span class="title-context">The web UI for</span>
<span class="gradient-text intro-target" aria-label="Pi Coding Agent sessions that keep working.">
<span class="intro-word" style="--i: 0">Pi</span>
<span class="intro-word" style="--i: 1">Coding</span>
<span class="intro-word" style="--i: 2">Agent</span>
<span class="intro-word" style="--i: 3">sessions</span>
<span class="intro-word" style="--i: 4">that</span>
<span class="intro-word" style="--i: 5">keep</span>
<span class="intro-word" style="--i: 6">working.</span>
</span>
</h1>
<p class="hero-lede">
PI WEB runs Pi Coding Agent sessions in real server-side workspaces, keeps them alive when your browser
leaves, and gives you a fast web surface to supervise, redirect, and review the work.
PI WEB keeps Pi Coding Agent sessions running in real server-side workspaces, even after your browser
leaves, and gives you a fast web UI to supervise, redirect, and review work from any device.
</p>
<p class="hero-manifesto">
Local development made sense when humans drove every keystroke. Agentic development works better when the
@@ -85,7 +116,7 @@
</p>
<div class="hero-actions">
<a class="button primary" href="#quick-install">Install in minutes</a>
<a class="button" href="install.html">Read the docs</a>
<a class="button" href="install">Read the docs</a>
</div>
<div class="hero-stats" aria-label="Highlights">
<div class="stat"><strong>Persistent</strong><span>sessions survive browser disconnects</span></div>
@@ -118,10 +149,23 @@
<div class="container">
<div class="demo-frame">
<div class="demo-caption">
<strong>Workspaces, sessions, transcripts, terminals — one agent control plane.</strong>
<strong>Workspaces, sessions, transcripts, files — one Pi web UI on every screen.</strong>
<span>Bring your own repositories.</span>
</div>
<img src="assets/pi-web-demo.gif" alt="PI WEB browser UI demo" />
<div class="demo-gallery" aria-label="PI WEB screenshots">
<figure class="demo-shot demo-shot-desktop">
<img src="assets/pi-web-desktop.png" alt="PI WEB desktop screenshot showing an agent-created pi-web.dev screenshot selected in the file preview" />
<figcaption>Desktop: chat beside workspace file preview.</figcaption>
</figure>
<figure class="demo-shot">
<img src="assets/pi-web-tablet.png" alt="PI WEB tablet screenshot" />
<figcaption>Tablet: the same session from a wider touch screen.</figcaption>
</figure>
<figure class="demo-shot demo-shot-mobile">
<img src="assets/pi-web-mobile.png" alt="PI WEB mobile chat screenshot" />
<figcaption>Mobile: the chat stays readable on the go.</figcaption>
</figure>
</div>
</div>
</div>
</section>
@@ -170,10 +214,11 @@
</article>
<article class="card">
<div class="card-icon"></div>
<h3>The browser becomes the control plane</h3>
<h3>Any browser can supervise the work</h3>
<p>
Your device is replaceable. The sessions are not. Move between laptop, phone, tablet, and desktop
without moving the development environment.
without moving the development environment. Under the hood, PI WEB coordinates the running sessions,
files, terminals, and remote machines like a browser-based control plane.
</p>
</article>
<article class="card">
@@ -186,7 +231,7 @@
</article>
</div>
<div class="doc-actions">
<a class="button" href="remote-first.html">Read the remote-first philosophy</a>
<a class="button" href="remote-first">Read the remote-first philosophy</a>
</div>
</div>
</section>
@@ -249,8 +294,8 @@
<li>Open the local URL, or tunnel it from a remote machine.</li>
</ol>
<div class="doc-actions">
<a class="button primary" href="install.html">Complete installation guide</a>
<a class="button" href="faq.html">Troubleshooting FAQ</a>
<a class="button primary" href="install">Complete installation guide</a>
<a class="button" href="faq">Troubleshooting FAQ</a>
</div>
</div>
@@ -276,7 +321,7 @@
Use <code>pi-web install</code> where a supported per-user service manager is available. WSL works with the
installer when systemd is enabled; otherwise use the manual run path.
</p>
<a href="faq.html#is-this-linux-only">Read compatibility notes →</a>
<a href="faq#is-this-linux-only">Read compatibility notes →</a>
</article>
<article class="doc-card">
<h3>Local UI plugins?</h3>
@@ -284,7 +329,7 @@
Ask AI agents to add trusted local UI plugins from <code>~/.pi-web/plugins</code> without rebuilding or
restarting the session daemon.
</p>
<a href="plugins.html">Build a plugin →</a>
<a href="plugins">Build a plugin →</a>
</article>
<article class="doc-card">
<h3>Node or tools not found?</h3>
@@ -292,7 +337,7 @@
Services run login shells. If tools work in your interactive terminal but not in PI WEB, fix PATH in your
login shell startup files and run the doctor command.
</p>
<a href="faq.html#tools-are-not-found">Fix PATH issues →</a>
<a href="faq#tools-are-not-found">Fix PATH issues →</a>
</article>
</div>
</section>
@@ -300,13 +345,14 @@
<footer class="site-footer">
<div class="container footer-inner">
<span>PI WEB · remote control for persistent Pi Coding Agent sessions.</span>
<span>PI WEB · web UI for persistent Pi Coding Agent sessions.</span>
<div class="footer-links">
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
<a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a>
</div>
</div>
+53 -26
View File
@@ -3,10 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Install PI WEB</title>
<meta name="description" content="Complete installation guide for PI WEB on Linux, macOS, and Windows WSL." />
<meta property="og:title" content="Install PI WEB" />
<meta property="og:image" content="assets/pi-web-banner.png" />
<title>Install PI WEB — web UI for Pi Coding Agent</title>
<meta
name="description"
content="Install PI WEB, the web UI for Pi Coding Agent, on Linux, macOS, or Windows WSL with persistent user services."
/>
<link rel="canonical" href="https://pi-web.dev/install" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="Install PI WEB — web UI for Pi Coding Agent" />
<meta
property="og:description"
content="Install PI WEB with npm, Pi, or manual service commands and keep Pi Coding Agent sessions running."
/>
<meta property="og:url" content="https://pi-web.dev/install" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Install PI WEB — web UI for Pi Coding Agent" />
<meta
name="twitter:description"
content="Install PI WEB with npm, Pi, or manual service commands and keep Pi Coding Agent sessions running."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
@@ -28,11 +47,12 @@
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html" aria-current="page">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install" aria-current="page">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
@@ -213,7 +233,7 @@
remote runtime.
</p>
<div class="doc-actions">
<a class="button" href="machines.html">Read the fleet guide</a>
<a class="button" href="machines">Read the fleet guide</a>
</div>
</section>
@@ -246,27 +266,33 @@
</p>
<div class="code-card">
<div class="copy-row">
<strong>Default config</strong>
<strong>Common config</strong>
<button class="copy-button" data-copy="#config-example">Copy</button>
</div>
<pre id="config-example"><code>{
"host": "127.0.0.1",
"port": 8504,
"allowedHosts": []
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
},
"spawnSessions": true,
"subsessions": false
}</code></pre>
</div>
<p>
The web server defaults to <code>127.0.0.1:8504</code> and stores PI WEB state in <code>~/.pi-web</code>.
Use <strong>Settings → General</strong> for host, port, and external filesystem roots; <strong>Settings → Session daemon</strong>
for agent-spawn tools; <strong>Settings → Plugins</strong> for plugin enablement; and <strong>Settings → Keyboard</strong>
for shortcut overrides.
</p>
<div class="callout">
Need the full schema, restart rules, project-local <code>.pi-web/config.json</code>, path access details, and
environment variable reference? Read the <a href="config">configuration reference</a>.
</div>
<p>
The web server defaults to <code>127.0.0.1:8504</code>, and PI WEB-managed state defaults to
<code>~/.pi-web</code>. External filesystem paths are denied by default unless listed in
<code>pathAccess.allowedPaths</code>.
</p>
<ul>
<li><code>PI_WEB_CONFIG</code>: path to a config JSON file. Defaults to <code>~/.config/pi-web/config.json</code>.</li>
<li><code>PI_WEB_PORT</code> or <code>PORT</code>: web server port. Overrides the config file.</li>
<li><code>PI_WEB_HOST</code>: web server bind host. Overrides the config file. Use <code>127.0.0.1</code> for local/tunnel-only access, or a specific VPN/private-network IP for trusted remote access.</li>
<li><code>PI_WEB_DATA_DIR</code>: data directory, default <code>~/.pi-web</code>.</li>
<li><code>PI_WEB_SESSIOND_SOCKET</code>: Unix socket path for daemon communication.</li>
<li><code>PI_CODING_AGENT_SESSION_DIR</code>: Pi session storage directory. PI WEB follows Pi's priority for sessions: this environment variable, then <code>sessionDir</code> in Pi settings for the selected workspace, then Pi's default session directory.</li>
<li><code>PI_CODING_AGENT_DIR</code>: Pi agent config directory for auth, settings, resources, and default session storage.</li>
</ul>
</section>
<section id="uninstall">
@@ -313,10 +339,11 @@
<span>PI WEB docs</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
</div>
+34 -16
View File
@@ -3,13 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PI WEB fleet</title>
<title>PI WEB fleet — remote Pi web UI machines</title>
<meta
name="description"
content="Connect trusted PI WEB runtimes through machine federation so one browser control plane can supervise local and remote projects, sessions, files, git state, terminals, and plugins."
content="Connect trusted PI WEB runtimes through machine federation so one web UI can supervise local and remote projects, sessions, files, git state, terminals, and plugins."
/>
<meta property="og:title" content="PI WEB fleet" />
<meta property="og:image" content="assets/pi-web-banner.png" />
<link rel="canonical" href="https://pi-web.dev/machines" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="PI WEB fleet — remote Pi web UI machines" />
<meta
property="og:description"
content="Use one PI WEB instance to reach trusted local and remote Pi Coding Agent machines."
/>
<meta property="og:url" content="https://pi-web.dev/machines" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="PI WEB fleet — remote Pi web UI machines" />
<meta
name="twitter:description"
content="Use one PI WEB instance to reach trusted local and remote Pi Coding Agent machines."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
@@ -31,11 +47,12 @@
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first.html">Remote-first</a>
<a href="machines.html" aria-current="page">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines" aria-current="page">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
@@ -228,7 +245,7 @@ PI WEB gateway you opened
Remote plugins are still trusted browser-side code. Only federate machines whose PI WEB plugins you are
comfortable loading in the browser.
</p>
<p><a href="plugins.html#remote-machine-plugins">Read the remote machine plugin notes →</a></p>
<p><a href="plugins#remote-machine-plugins">Read the remote machine plugin notes →</a></p>
</section>
<section id="trust-model">
@@ -257,8 +274,8 @@ PI WEB gateway you opened
<li>Check gateway logs with <code>pi-web logs</code> for proxy timeouts or upstream errors.</li>
</ul>
<div class="doc-actions">
<a class="button primary" href="install.html#remote-access">Review remote access setup</a>
<a class="button" href="faq.html#remote-machines">Read remote machine FAQ</a>
<a class="button primary" href="install#remote-access">Review remote access setup</a>
<a class="button" href="faq#remote-machines">Read remote machine FAQ</a>
</div>
</section>
</div>
@@ -271,10 +288,11 @@ PI WEB gateway you opened
<span>PI WEB fleet</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first.html">Remote-first</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
</div>
+37 -16
View File
@@ -3,10 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PI WEB plugins</title>
<meta name="description" content="Use built-in PI WEB plugins and develop trusted local UI plugins." />
<meta property="og:title" content="PI WEB plugins" />
<meta property="og:image" content="assets/pi-web-banner.png" />
<title>PI WEB plugins — extend the Pi web UI</title>
<meta
name="description"
content="Use built-in PI WEB plugins and develop trusted local UI plugins for the Pi Coding Agent web UI."
/>
<link rel="canonical" href="https://pi-web.dev/plugins" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="PI WEB plugins — extend the Pi web UI" />
<meta
property="og:description"
content="Customize PI WEB with trusted browser-side plugins for actions, workspace panels, labels, terminals, and files."
/>
<meta property="og:url" content="https://pi-web.dev/plugins" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="PI WEB plugins — extend the Pi web UI" />
<meta
name="twitter:description"
content="Customize PI WEB with trusted browser-side plugins for actions, workspace panels, labels, terminals, and files."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
@@ -28,11 +47,12 @@
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html" aria-current="page">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins" aria-current="page">Plugins</a>
<a href="faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
@@ -125,7 +145,7 @@
<pre id="plugin-create-prompt"><code>Build a PI WEB plugin for this project.
Goal: &lt;describe the UI behavior&gt;.
Before coding, read the PI WEB plugin docs:
https://pi-web.dev/plugins.html
https://pi-web.dev/plugins
Full API reference:
https://pi-web.dev/plugins.md
Create it as a local plugin under ~/.pi-web/plugins/&lt;plugin-id&gt;.
@@ -140,7 +160,7 @@ Do not modify PI WEB itself.</code></pre>
</div>
<pre id="plugin-improve-prompt"><code>Improve the PI WEB plugin at &lt;path&gt;.
Before coding, read the PI WEB plugin docs:
https://pi-web.dev/plugins.html
https://pi-web.dev/plugins
Full API reference:
https://pi-web.dev/plugins.md
Keep the plugin compatible with the documented v1 API.
@@ -303,7 +323,7 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<section id="remote-machine-plugins">
<h2>Remote machine plugins</h2>
<p>
With <a href="machines.html">machine federation</a>, PI WEB also loads discovered plugins from the selected
With <a href="machines">machine federation</a>, PI WEB also loads discovered plugins from the selected
remote machine. Remote plugins are trusted browser-side code like local plugins, but their actions,
workspace panels, and workspace labels only appear while that machine is selected.
</p>
@@ -408,10 +428,11 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<span>PI WEB plugin docs</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first.html">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="faq.html">FAQ</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
</div>
+3 -3
View File
@@ -42,7 +42,7 @@ Copy-paste prompt for creating a plugin:
Build a PI WEB plugin for this project.
Goal: <describe the UI behavior>.
Before coding, read the PI WEB plugin docs:
https://pi-web.dev/plugins.html
https://pi-web.dev/plugins
Full API reference:
https://pi-web.dev/plugins.md
Create it as a local plugin under ~/.pi-web/plugins/<plugin-id>.
@@ -56,7 +56,7 @@ Copy-paste prompt for modifying a plugin:
```text
Improve the PI WEB plugin at <path>.
Before coding, read the PI WEB plugin docs:
https://pi-web.dev/plugins.html
https://pi-web.dev/plugins
Full API reference:
https://pi-web.dev/plugins.md
Keep the plugin compatible with the documented v1 API.
@@ -131,7 +131,7 @@ Reload the PI WEB browser tab. PI WEB serves plugin modules with an mtime-based
## Remote machine plugins
When [machine federation](https://pi-web.dev/machines.html) is enabled, PI WEB also loads discovered plugins from the selected remote machine. Remote plugins are trusted browser-side code like local plugins, but their contributions are machine-scoped:
When [machine federation](https://pi-web.dev/machines) is enabled, PI WEB also loads discovered plugins from the selected remote machine. Remote plugins are trusted browser-side code like local plugins, but their contributions are machine-scoped:
- actions, workspace panels, and workspace labels only appear while that machine is selected;
- plugin file and terminal helpers run against that machine;
+37 -18
View File
@@ -3,13 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Remote-first development with PI WEB</title>
<title>Remote-first Pi web UI — PI WEB</title>
<meta
name="description"
content="Why PI WEB makes AI coding work persistent by default: agents keep running outside your device while the browser becomes the control plane."
content="Why PI WEB keeps Pi Coding Agent sessions running outside your device while any browser becomes a web UI for supervision and review."
/>
<meta property="og:title" content="Remote-first development with PI WEB" />
<meta property="og:image" content="assets/pi-web-banner.png" />
<link rel="canonical" href="https://pi-web.dev/remote-first" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="Remote-first Pi web UI — PI WEB" />
<meta
property="og:description"
content="Run Pi Coding Agent where work can persist, then supervise sessions from any device with PI WEB."
/>
<meta property="og:url" content="https://pi-web.dev/remote-first" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Remote-first Pi web UI — PI WEB" />
<meta
name="twitter:description"
content="Run Pi Coding Agent where work can persist, then supervise sessions from any device with PI WEB."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
@@ -31,11 +47,12 @@
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first.html" aria-current="page">Remote-first</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="remote-first" aria-current="page">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
@@ -75,7 +92,7 @@
<a href="#why-local-blocks">The old assumption</a>
<a href="#persistence-first">Persistence first-class</a>
<a href="#what-remote-unlocks">What remote unlocks</a>
<a href="#browser-control">Browser as control plane</a>
<a href="#browser-control">Browser as web UI</a>
<a href="#human-role">The human role</a>
<a href="#recommended-shape">Recommended shape</a>
</aside>
@@ -138,11 +155,12 @@
</section>
<section id="browser-control">
<h2>The browser becomes the control plane</h2>
<h2>The browser becomes your Pi web UI</h2>
<p>
Your device should not have to be the editor, terminal, build server, and agent runtime. In PI WEB, the
browser is the cockpit: supervise sessions, redirect agents, inspect transcripts, and review progress
while the development environment stays remote and stable.
while the development environment stays remote and stable. Under the hood, PI WEB still coordinates the
running work like a browser-based control plane.
</p>
<p>
Laptop, phone, tablet, desktop: they are just windows into the same running work. You remain in control,
@@ -173,8 +191,8 @@
keep working even when the laptop is gone.
</div>
<div class="doc-actions">
<a class="button primary" href="install.html">Install PI WEB</a>
<a class="button" href="faq.html#laptop-or-server">Laptop or server FAQ</a>
<a class="button primary" href="install">Install PI WEB</a>
<a class="button" href="faq#laptop-or-server">Laptop or server FAQ</a>
</div>
</section>
</div>
@@ -187,10 +205,11 @@
<span>PI WEB · remote-first control for persistent AI agents.</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="machines.html">Fleet</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
</div>
+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Content-Signal: search=yes,ai-input=yes,ai-train=yes
Allow: /
Sitemap: https://pi-web.dev/sitemap.xml
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://pi-web.dev/</loc></url>
<url><loc>https://pi-web.dev/remote-first</loc></url>
<url><loc>https://pi-web.dev/machines</loc></url>
<url><loc>https://pi-web.dev/install</loc></url>
<url><loc>https://pi-web.dev/config</loc></url>
<url><loc>https://pi-web.dev/plugins</loc></url>
<url><loc>https://pi-web.dev/faq</loc></url>
</urlset>
+100
View File
@@ -618,6 +618,44 @@ code .comment,
color: var(--muted);
}
.demo-gallery {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(220px, 0.72fr);
gap: 18px;
padding: 18px;
}
.demo-shot {
display: grid;
gap: 10px;
align-content: start;
margin: 0;
}
.demo-shot-desktop {
grid-row: span 2;
}
.demo-shot img {
overflow: hidden;
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel-strong);
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.2);
}
.demo-shot-mobile img {
width: min(100%, 250px);
margin-inline: auto;
}
.demo-shot figcaption {
color: var(--muted-2);
font-size: 0.9rem;
line-height: 1.45;
}
.manifesto-section {
padding-top: 46px;
}
@@ -809,6 +847,60 @@ code .comment,
font-size: 1.25rem;
}
.table-scroll {
overflow-x: auto;
margin: 16px 0;
border: 1px solid var(--line);
background: var(--panel);
}
.doc-content table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
}
.doc-content th,
.doc-content td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.doc-content th {
background: var(--panel-strong);
color: var(--text);
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.doc-content .table-section th {
border-top: 2px solid var(--line-bright);
border-bottom-color: var(--line-bright);
background: var(--panel-strong);
color: var(--text);
letter-spacing: 0.08em;
}
.doc-content td {
color: var(--muted);
}
.doc-content td:first-child {
color: var(--text);
font-weight: 700;
}
.doc-content tr:last-child td {
border-bottom: 0;
}
.doc-content table code {
white-space: nowrap;
}
.code-card {
overflow: hidden;
margin: 16px 0;
@@ -1076,6 +1168,14 @@ html[data-theme="light"] .comment {
.manifesto-lines {
align-content: start;
}
.demo-gallery {
grid-template-columns: 1fr;
}
.demo-shot-desktop {
grid-row: auto;
}
}
@media (max-width: 820px) {
+1529 -939
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@jmfederico/pi-web",
"version": "1.202606.3",
"description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.",
"version": "1.202606.5",
"description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
"license": "MIT",
"author": "Federico Jaramillo Martinez",
"type": "module",
@@ -17,6 +17,7 @@
"LICENSE",
"extensions",
"docs/plugins.md",
"docs/config.md",
"docs/assets",
"plugin-api.d.ts",
"plugin-api/unstable.d.ts"
@@ -31,6 +32,7 @@
"build": "tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build",
"build:plugin-api": "tsc -p tsconfig.plugin-api.json",
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
"capture:screenshots": "node scripts/capture-screenshots.mjs",
"typecheck": "tsc --noEmit",
"knip": "knip",
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
@@ -71,6 +73,7 @@
"lit": "^3.3.1",
"marked": "^18.0.3",
"node-pty": "^1.1.0",
"typebox": "1.1.38",
"ws": "^8.20.1"
},
"devDependencies": {
@@ -113,16 +116,19 @@
"keywords": [
"pi-package",
"pi",
"pi-web",
"pi-web-ui",
"pi-webui",
"pi-coding-agent",
"pi-coding-agent-web-ui",
"coding-agent",
"agent",
"web",
"ui",
"ai-coding-agent",
"agentic-development",
"developer-tools",
"web-ui",
"webui",
"remote",
"browser",
"control-plane",
"browser-ui",
"remote-development",
"persistent-sessions"
],
"pi": {
@@ -23,11 +23,9 @@ export function defineTasksPanelElement(): void {
if (!customElements.get(tasksPanelTagName)) customElements.define(tasksPanelTagName, PiWebTasksPanel);
}
export function tasksPanelBadge(context: WorkspacePanelContext): string | number | undefined {
export function tasksPanelBadge(context: WorkspacePanelContext): string | undefined {
const state = getCachedWorkspaceConfig(context);
if (state?.kind === "unavailable") return "!";
if (state?.kind === "loaded" && state.config.tasks.length > 0) return state.config.tasks.length;
return undefined;
return state?.kind === "unavailable" ? "!" : undefined;
}
class PiWebTasksPanel extends HTMLElement {
+652
View File
@@ -0,0 +1,652 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, "..");
const DEFAULT_OUTPUT_DIR = join(REPO_ROOT, "docs", "assets");
const SESSION_ID = "019ef4c0-0000-7000-8000-000000000001";
const DEMO_FILE = "docs/assets/pi-web-dev-screenshot.png";
const DEFAULT_SITE_URL = "https://pi-web.dev/";
const VIEWPORTS = {
desktop: { width: 1440, height: 900, mobile: false },
tablet: { width: 1024, height: 768, mobile: false },
mobile: { width: 390, height: 844, mobile: true },
};
const args = parseArgs(process.argv.slice(2));
const outputDir = resolve(args.outputDir ?? DEFAULT_OUTPUT_DIR);
const keepTemp = args.keepTemp === true;
const siteUrl = args.siteUrl ?? DEFAULT_SITE_URL;
const chromeBin = args.chromeBin ?? process.env["CHROME_BIN"] ?? findChrome();
if (chromeBin === undefined) {
fail("Chromium was not found. Install chromium-browser/chromium or set CHROME_BIN=/path/to/chrome.");
}
const tempRoot = await mkdtemp(join(tmpdir(), "pi-web-screenshots-"));
const children = new Set();
let cleanedUp = false;
process.once("SIGINT", () => {
void cleanup().finally(() => process.exit(130));
});
process.once("SIGTERM", () => {
void cleanup().finally(() => process.exit(143));
});
async function main() {
const logsDir = join(tempRoot, "logs");
const dataDir = join(tempRoot, "pi-web-data");
const configPath = join(tempRoot, "config.json");
const sessionDir = join(tempRoot, "sessions");
const agentDir = join(tempRoot, "pi-agent");
const demoProject = join(tempRoot, "pi-web");
const projectsFile = join(dataDir, "projects.json");
const socketPath = join(dataDir, "sessiond.sock");
await Promise.all([
mkdir(logsDir, { recursive: true }),
mkdir(sessionDir, { recursive: true }),
mkdir(agentDir, { recursive: true }),
mkdir(dataDir, { recursive: true }),
mkdir(outputDir, { recursive: true }),
]);
console.log(`Temporary workspace: ${tempRoot}`);
await cloneDemoProject(demoProject);
await removeLegacyDemoMedia(demoProject);
const projectId = "pi-web-demo";
const workspaceId = createWorkspaceId(projectId, demoProject);
await writeJson(projectsFile, {
projects: [{ id: projectId, name: "pi-web", path: demoProject, createdAt: new Date().toISOString() }],
});
await writeJson(configPath, { host: "127.0.0.1", allowedHosts: true });
await writeDemoSession(sessionDir, demoProject);
const apiPort = await getFreePort();
const clientPort = await getFreePort();
const debugPort = await getFreePort();
const env = {
...process.env,
PI_WEB_DATA_DIR: dataDir,
PI_WEB_CONFIG: configPath,
PI_WEB_PROJECTS_FILE: projectsFile,
PI_WEB_SESSIOND_SOCKET: socketPath,
PI_WEB_HOST: "127.0.0.1",
PI_WEB_PORT: String(apiPort),
PI_WEB_ALLOWED_HOSTS: "true",
PI_CODING_AGENT_DIR: agentDir,
PI_CODING_AGENT_SESSION_DIR: sessionDir,
PI_OFFLINE: "1",
NO_COLOR: "1",
};
const tsxBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
const viteBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
assertExecutable(tsxBin, "Run npm install before capturing screenshots.");
assertExecutable(viteBin, "Run npm install before capturing screenshots.");
console.log("Starting isolated PI WEB session daemon, API server, and Vite client…");
startChild("sessiond", tsxBin, ["src/server/sessiond.ts"], { env, cwd: REPO_ROOT, logsDir });
await waitForFile(socketPath, 10_000);
startChild("api", tsxBin, ["src/server/index.ts"], { env, cwd: REPO_ROOT, logsDir });
await waitForHttp(`http://127.0.0.1:${apiPort}/api/projects`, 15_000);
startChild("vite", viteBin, ["--host", "127.0.0.1", "--port", String(clientPort), "--strictPort", "true"], { env, cwd: REPO_ROOT, logsDir });
await waitForHttp(`http://127.0.0.1:${clientPort}/`, 30_000);
console.log("Starting Chromium and capturing screenshots…");
const chrome = startChild("chromium", chromeBin, chromeArgs(debugPort, join(tempRoot, "chrome-profile")), { env, cwd: REPO_ROOT, logsDir });
await waitForHttp(`http://127.0.0.1:${debugPort}/json/version`, 15_000);
const cdp = await openPage(debugPort);
try {
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await captureWebsiteScreenshot(cdp, join(demoProject, DEMO_FILE), siteUrl);
const appUrl = new URL(`http://127.0.0.1:${clientPort}/`);
appUrl.searchParams.set("project", projectId);
appUrl.searchParams.set("workspace", workspaceId);
appUrl.searchParams.set("session", SESSION_ID);
appUrl.searchParams.set("view", "chat");
await captureDesktop(cdp, appUrl, join(outputDir, "pi-web-desktop.png"));
await captureDefaultApp(cdp, appUrl, VIEWPORTS.tablet, join(outputDir, "pi-web-tablet.png"));
await captureDefaultApp(cdp, appUrl, VIEWPORTS.mobile, join(outputDir, "pi-web-mobile.png"));
} finally {
cdp.close();
chrome.kill("SIGTERM");
}
console.log(`Wrote ${join(outputDir, "pi-web-desktop.png")}`);
console.log(`Wrote ${join(outputDir, "pi-web-tablet.png")}`);
console.log(`Wrote ${join(outputDir, "pi-web-mobile.png")}`);
if (keepTemp) console.log(`Kept temporary workspace: ${tempRoot}`);
}
async function captureWebsiteScreenshot(cdp, outputPath, url) {
await mkdir(dirname(outputPath), { recursive: true });
await setViewport(cdp, { width: 1280, height: 720, mobile: false });
try {
await navigate(cdp, url, 20_000);
await waitForDocumentFonts(cdp);
await sleep(3500);
await capturePng(cdp, outputPath);
} catch (error) {
console.warn(`Unable to capture ${url}; using a local fallback image. ${error instanceof Error ? error.message : String(error)}`);
const fallback = `data:text/html,${encodeURIComponent(fallbackWebsiteHtml(url))}`;
await navigate(cdp, fallback, 10_000);
await sleep(300);
await capturePng(cdp, outputPath);
}
}
async function captureDesktop(cdp, appUrl, outputPath) {
await setViewport(cdp, VIEWPORTS.desktop);
await navigate(cdp, appUrl.href, 15_000);
await waitForApp(cdp);
await selectPreviewImage(cdp);
await sleep(500);
await capturePng(cdp, outputPath);
}
async function captureDefaultApp(cdp, appUrl, viewport, outputPath) {
await setViewport(cdp, viewport);
await navigate(cdp, appUrl.href, 15_000);
await waitForApp(cdp);
await sleep(700);
await capturePng(cdp, outputPath);
}
async function selectPreviewImage(cdp) {
await evaluate(cdp, `(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const app = document.querySelector("pi-web-app");
if (!app) throw new Error("pi-web-app not found");
if (typeof app.openWorkspaceTool !== "function" || app.files === undefined) {
throw new Error("PI WEB app internals needed for deterministic screenshot setup were not available");
}
app.openWorkspaceTool("core:workspace.files");
await app.updateComplete;
await sleep(900);
await app.files.refreshFiles();
await app.updateComplete;
if (app.state?.expandedDirs?.["docs"] === undefined) await app.files.expandDir("docs");
await app.updateComplete;
if (app.state?.expandedDirs?.["docs/assets"] === undefined) await app.files.expandDir("docs/assets");
await app.updateComplete;
await app.files.selectFile(${JSON.stringify(DEMO_FILE)});
await app.updateComplete;
const panel = app.shadowRoot?.querySelector("workspace-panel");
const root = panel?.shadowRoot;
await panel?.updateComplete;
const start = Date.now();
while (Date.now() - start < 8000) {
const image = root?.querySelector(".image-preview img");
if (root?.textContent.includes(${JSON.stringify(DEMO_FILE)}) && image instanceof HTMLImageElement && image.complete) return true;
await sleep(100);
}
throw new Error("Timed out waiting for image preview");
})()`);
}
async function waitForApp(cdp) {
await evaluate(cdp, `new Promise((resolve, reject) => {
const start = Date.now();
const visibleText = () => {
const app = document.querySelector("pi-web-app");
const appRoot = app?.shadowRoot;
const chatRoot = appRoot?.querySelector("chat-view")?.shadowRoot;
return [appRoot?.textContent ?? "", chatRoot?.textContent ?? ""].join("\\n");
};
const check = () => {
const text = visibleText();
if (text.includes("Showing messages") && text.includes("assistant")) {
resolve(true);
return;
}
if (Date.now() - start > 15000) {
reject(new Error("PI WEB app did not restore the seeded session in time. Visible text: " + visibleText()));
return;
}
setTimeout(check, 100);
};
check();
})`);
}
async function waitForDocumentFonts(cdp) {
try {
await evaluate(cdp, `document.fonts?.ready?.then(() => true) ?? true`);
} catch {
// Font loading is best-effort; screenshots still work with fallback fonts.
}
}
async function navigate(cdp, url, timeoutMs) {
const loaded = cdp.waitForEvent("Page.loadEventFired", timeoutMs).catch(() => undefined);
await cdp.send("Page.navigate", { url });
await loaded;
}
async function setViewport(cdp, viewport) {
await cdp.send("Emulation.setDeviceMetricsOverride", {
width: viewport.width,
height: viewport.height,
deviceScaleFactor: 1,
mobile: viewport.mobile,
});
}
async function capturePng(cdp, outputPath) {
await mkdir(dirname(outputPath), { recursive: true });
const { data } = await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
await writeFile(outputPath, Buffer.from(data, "base64"));
}
async function cloneDemoProject(target) {
const result = spawnSync("git", ["clone", "--quiet", "--local", "--no-hardlinks", REPO_ROOT, target], {
cwd: REPO_ROOT,
encoding: "utf8",
});
if (result.status !== 0) throw new Error(`git clone failed:\n${result.stderr || result.stdout}`);
}
async function removeLegacyDemoMedia(projectRoot) {
await Promise.all([
rm(join(projectRoot, "docs", "assets", "pi-web-demo.gif"), { force: true }),
rm(join(projectRoot, "docs", "assets", "pi-web-demo.webm"), { force: true }),
rm(join(projectRoot, "docs", "assets", "pi-web-demo-flow.gif"), { force: true }),
]);
}
async function writeDemoSession(sessionDir, cwd) {
const now = new Date();
const timestamp = now.toISOString();
const file = join(sessionDir, `${timestamp.replaceAll(":", "-")}_${SESSION_ID}.jsonl`);
const ms = now.getTime();
const entries = [
{ type: "session", version: 3, id: SESSION_ID, timestamp, cwd },
{ type: "model_change", id: "10000001", parentId: null, timestamp: iso(ms + 100), provider: "openai-codex", modelId: "gpt-5.5" },
{ type: "thinking_level_change", id: "10000002", parentId: "10000001", timestamp: iso(ms + 200), thinkingLevel: "off" },
{
type: "message",
id: "10000003",
parentId: "10000002",
timestamp: iso(ms + 1000),
message: {
role: "user",
content: [{ type: "text", text: "Take a screenshot of https://pi-web.dev, save it under docs/assets, and tell me where I can preview it." }],
timestamp: ms + 1000,
},
},
{ type: "session_info", id: "10000004", parentId: "10000003", timestamp: iso(ms + 1100), name: "Screenshot pi-web.dev" },
{
type: "message",
id: "10000005",
parentId: "10000004",
timestamp: iso(ms + 2000),
message: {
role: "assistant",
content: [{
type: "toolCall",
id: "call_demo_screenshot",
name: "bash",
arguments: { command: `capture-browser-screenshot https://pi-web.dev ${DEMO_FILE}` },
}],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.5",
usage: zeroUsage(),
stopReason: "toolUse",
timestamp: ms + 2000,
},
},
{
type: "message",
id: "10000006",
parentId: "10000005",
timestamp: iso(ms + 3000),
message: {
role: "toolResult",
toolCallId: "call_demo_screenshot",
toolName: "bash",
content: [{ type: "text", text: `Saved screenshot to ${DEMO_FILE}` }],
isError: false,
timestamp: ms + 3000,
},
},
{
type: "message",
id: "10000007",
parentId: "10000006",
timestamp: iso(ms + 4000),
message: {
role: "assistant",
content: [{ type: "text", text: `Done — I saved the screenshot at \`${DEMO_FILE}\`. Open the Files panel to preview it.` }],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.5",
usage: zeroUsage(),
stopReason: "stop",
timestamp: ms + 4000,
},
},
];
await writeFile(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
}
function fallbackWebsiteHtml(url) {
return `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#07121f,#2b174c);color:#f8fafc;font:24px system-ui,sans-serif}
main{width:min(900px,calc(100vw - 80px));padding:56px;border:1px solid rgba(255,255,255,.22);border-radius:28px;background:rgba(10,16,32,.72);box-shadow:0 24px 80px rgba(0,0,0,.35)}
h1{margin:0 0 14px;font-size:64px;letter-spacing:-.06em}.eyebrow{color:#c084fc;text-transform:uppercase;letter-spacing:.16em;font-size:14px;font-weight:700}p{line-height:1.5;color:#dbeafe}
</style></head><body><main><div class="eyebrow">PI WEB</div><h1>pi-web.dev</h1><p>Fallback screenshot for ${escapeHtml(url)}.</p></main></body></html>`;
}
function chromeArgs(debugPort, userDataDir) {
return [
"--headless=new",
`--remote-debugging-port=${debugPort}`,
"--remote-debugging-address=127.0.0.1",
"--remote-allow-origins=*",
`--user-data-dir=${userDataDir}`,
"--window-size=1440,900",
"--force-device-scale-factor=1",
"--hide-scrollbars",
"--disable-background-networking",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
"--disable-features=Translate,MediaRouter,OptimizationHints",
"--no-default-browser-check",
"--no-first-run",
"--no-sandbox",
"about:blank",
];
}
function startChild(name, command, childArgs, { env, cwd, logsDir }) {
const logPath = join(logsDir, `${name}.log`);
const child = spawn(command, childArgs, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
children.add(child);
const chunks = [];
const collect = (chunk) => {
chunks.push(Buffer.from(chunk));
if (chunks.length > 120) chunks.shift();
};
child.stdout.on("data", collect);
child.stderr.on("data", collect);
child.stdout.on("data", (chunk) => appendLog(logPath, chunk));
child.stderr.on("data", (chunk) => appendLog(logPath, chunk));
child.once("exit", (code, signal) => {
children.delete(child);
if (!cleanedUp && code !== 0 && signal === null) {
const recent = Buffer.concat(chunks).toString("utf8").trim();
console.error(`${name} exited with code ${code}. Recent log:\n${recent}`);
}
});
return child;
}
function appendLog(path, chunk) {
void mkdir(dirname(path), { recursive: true })
.then(() => writeFile(path, chunk, { flag: "a" }))
.catch(() => undefined);
}
async function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
await Promise.all([...children].map((child) => terminate(child)));
if (!keepTemp) await rm(tempRoot, { recursive: true, force: true });
}
async function terminate(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
sleep(2500).then(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}),
]);
}
async function waitForHttp(url, timeoutMs) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) return;
lastError = new Error(`${response.status} ${response.statusText}`);
} catch (error) {
lastError = error;
}
await sleep(150);
}
throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
async function waitForFile(path, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (existsSync(path)) return;
await sleep(100);
}
throw new Error(`Timed out waiting for ${path}`);
}
async function getFreePort() {
return new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address !== null ? address.port : undefined;
server.close(() => {
if (port === undefined) reject(new Error("Unable to allocate a port"));
else resolve(port);
});
});
server.on("error", reject);
});
}
async function openPage(debugPort) {
const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: "PUT" });
if (!response.ok) throw new Error(`Unable to create Chromium tab: ${response.status} ${response.statusText}`);
const info = await response.json();
return CDP.connect(info.webSocketDebuggerUrl);
}
async function evaluate(cdp, expression) {
const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (response.exceptionDetails !== undefined) throw new Error(`Browser evaluation failed: ${JSON.stringify(response.exceptionDetails)}`);
return response.result?.value;
}
class CDP {
static connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const cdp = new CDP(ws);
ws.addEventListener("open", () => resolve(cdp), { once: true });
ws.addEventListener("error", (event) => reject(event.error ?? new Error("CDP websocket error")), { once: true });
});
}
constructor(ws) {
this.ws = ws;
this.nextId = 1;
this.pending = new Map();
this.listeners = new Map();
ws.addEventListener("message", (event) => this.onMessage(event));
ws.addEventListener("close", () => {
for (const { reject } of this.pending.values()) reject(new Error("CDP websocket closed"));
this.pending.clear();
});
}
send(method, params = {}) {
const id = this.nextId++;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
}
waitForEvent(method, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanupListener();
reject(new Error(`Timed out waiting for ${method}`));
}, timeoutMs);
const listener = (params) => {
cleanupListener();
resolve(params);
};
const cleanupListener = () => {
clearTimeout(timer);
const listeners = this.listeners.get(method) ?? [];
this.listeners.set(method, listeners.filter((candidate) => candidate !== listener));
};
this.listeners.set(method, [...this.listeners.get(method) ?? [], listener]);
});
}
close() {
this.ws.close();
}
onMessage(event) {
const message = JSON.parse(String(event.data));
if (message.id !== undefined) {
const pending = this.pending.get(message.id);
if (pending === undefined) return;
this.pending.delete(message.id);
if (message.error !== undefined) pending.reject(new Error(JSON.stringify(message.error)));
else pending.resolve(message.result ?? {});
return;
}
if (message.method !== undefined) {
for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {});
}
}
}
function createWorkspaceId(projectId, path) {
return createHash("sha1").update(`${projectId}:${path}`).digest("hex").slice(0, 12);
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function iso(ms) {
return new Date(ms).toISOString();
}
function zeroUsage() {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function parseArgs(argv) {
const parsed = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log(`Usage: node scripts/capture-screenshots.mjs [--output-dir docs/assets] [--site-url https://pi-web.dev/] [--keep-temp] [--chrome-bin /path/to/chrome]\n\nCaptures desktop, tablet, and mobile PI WEB screenshots from an isolated temporary instance.`);
process.exit(0);
}
if (arg === "--keep-temp") {
parsed.keepTemp = true;
continue;
}
if (arg === "--output-dir") {
parsed.outputDir = requireValue(argv, ++i, arg);
continue;
}
if (arg.startsWith("--output-dir=")) {
parsed.outputDir = arg.slice("--output-dir=".length);
continue;
}
if (arg === "--site-url") {
parsed.siteUrl = requireValue(argv, ++i, arg);
continue;
}
if (arg.startsWith("--site-url=")) {
parsed.siteUrl = arg.slice("--site-url=".length);
continue;
}
if (arg === "--chrome-bin") {
parsed.chromeBin = requireValue(argv, ++i, arg);
continue;
}
if (arg.startsWith("--chrome-bin=")) {
parsed.chromeBin = arg.slice("--chrome-bin=".length);
continue;
}
fail(`Unknown argument: ${arg}`);
}
return parsed;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (value === undefined || value.startsWith("--")) fail(`${flag} requires a value`);
return value;
}
function findChrome() {
return findExecutable(["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]);
}
function findExecutable(candidates) {
for (const candidate of candidates) {
const result = spawnSync("sh", ["-lc", `command -v ${shellQuote(candidate)}`], { encoding: "utf8" });
if (result.status === 0) return result.stdout.trim().split("\n")[0];
}
return undefined;
}
function shellQuote(value) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function assertExecutable(path, message) {
if (!existsSync(path)) fail(`${path} was not found. ${message}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeHtml(value) {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function fail(message) {
console.error(message);
process.exit(1);
}
try {
await main();
} finally {
await cleanup();
}
+12
View File
@@ -0,0 +1,12 @@
# skills
Distributable agent skills developed alongside pi-web.
## relay
Execute a long or complex plan as a chain of independent sessions, each running
one well-sized leg and handing off to the next via `spawn_session`.
```bash
npx skills add jmfederico/pi-web --skill relay -a pi -g
```
+64
View File
@@ -0,0 +1,64 @@
---
name: relay
description: "How the Relay method works: executing a plan as a chain of independent sessions that each do one slice and hand off to the next via spawn_session. Load this skill only when you already know you are in a relay: a prompt states you are working under the Relay framework (or relay/chain), points you at a relay charter or log, or the user invokes this skill directly. Do not load it for generic multi-step plans or ordinary spawn_session use."
---
# Relay
Relay is a way to execute a long or complex plan as a chain of independent sessions. Each session runs **one leg** — a single well-sized slice of the work — then hands the work off to a fresh session that runs the next leg. The chain continues until the goal is reached.
There is no coordinator and no referee. Each runner is the coordinator for their own leg: smart enough to do the work, adapt to what they discover, and hand off cleanly. Trust is distributed to every agent, not held by a god-agent above them.
The reason this works is **containment**: every leg starts with a fresh, small context. The accumulated knowledge lives in documents on disk, not in any one session's memory. That is also the core constraint you must respect — see below.
## The hard constraint that shapes everything
`spawn_session` is fire-and-forget. When you spawn the next leg, **you do not see its output and you cannot correct it.** The only thing that travels down the chain is what you wrote to disk. A human may be watching in the UI, but they intervene by reading your documents, not by relaying messages between sessions.
Two consequences follow, and they govern the whole method:
- **Make your work durable before you hand off.** Write the log, save/commit the artifacts (commit if the relay says to), and only then spawn the next leg. Anything not on disk is lost.
- **Hand off exactly once, at the end.** Do not spawn early, do not spawn several runners "to parallelize," and never spawn while you still have work in flight. One leg, one handoff.
## The two documents
A relay is carried by two documents. By default they live in `.pi-web/relays/<name>/` unless the user or the dispatching prompt says otherwise — always follow an explicit location if given.
**Charter** (`charter.md`) — the stable agreement, written when the relay is planned. It must contain, at minimum:
- **Goal / finish line.** A concrete, achievable end state. Without this the relay runs forever — this is non-negotiable.
- **Sizing.** How much is *one leg*? This is project- and plan-specific; the charter defines it (a task, a slice, a time/scope budget — whatever fits). The skill does not decide this for you.
- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. Can be as simple as "read the charter and log, then continue," as long as it is stated.
- **Intervention signal.** When and how a runner must stop and get the human, and how that is made visible. The charter must define this; the skill does not define it for you.
The charter *can* be edited, but it should rarely *need* to be. If it is changing every leg, that is a smell — the design wasn't settled, or the goal is drifting. Treat frequent charter edits as a reason to stop and involve the human.
**Log** (`log.md`) — append-only, grows as the relay runs. Each leg appends an entry so the next runner can orient without inheriting your context. An entry records: what this leg did, decisions made and why, the current state, and any blockers. This is the relay's memory.
For a small relay it is fine to collapse both into a single file, as long as the goal, sizing, handover, and intervention signal are all present.
## Running one leg
This is the loop you run when you are dispatched into a relay.
1. **Orient.** Read the charter and the log. Understand the goal and the current state. If you are not sure you are in a relay, the prompt or `.pi-web/relays/` is your clue — and reading this skill means you are.
2. **Re-anchor to the goal.** Does the goal still make sense given what the log shows and what you now see? If reality has diverged from the charter, that is often an intervention moment — don't quietly redefine the task.
3. **Run one leg.** Do exactly one well-sized slice, per the charter's sizing. Resist doing "just a bit more" — extra scope bloats context and breaks the containment that makes Relay work.
4. **Log it.** Append your entry: what you did, why, the new state, and any blocker. Make all work durable (save files, commit if the relay calls for it).
5. **Decide: hand off, or stop.**
- **Hand off** if there is a clear next leg and you are on track. Use `spawn_session` once, with a prompt that names the Relay method and points the next runner at the charter and log (so this skill loads and they can orient). Then you are done. Handoff is deliberately fire-and-forget: `spawn_session` starts an independent session you will not see and cannot steer — do not reach for a tracked subsession to keep an eye on it. Letting go is the point. The next runner is trusted to run their own leg, and the log is the only thread between you; if you feel the need to watch downstream work, that usually means the leg wasn't sized or handed off cleanly, or an intervention signal should have fired.
- **Stop — do not spawn —** if the goal is reached, or you are blocked, or the charter's intervention signal fires. Leave a clear note in the log (and raise the intervention signal) so the watching human sees exactly what happened and what they need to decide. A stalled relay that stopped cleanly with a clear blocker is a success; a relay that spawned a confused next runner is a failure.
## Planning a relay
When the user asks to set up a relay, your job is to produce a charter (and an empty or seeded log) that has the four required slots filled: goal, sizing, handover, intervention signal. Draw each one out from the user rather than inventing it: ask what the finish line is, how much should be one leg, how runners hand off, and when you must stop and get them. Sizing and the intervention signal especially are the user's to decide — propose options if it helps them think, but do not quietly settle them yourself.
Do **not** impose what a "good" plan, leg size, or cadence looks like — those are deeply project-, plan-, and human-specific, and getting them wrong by being prescriptive is worse than leaving them to the user. Your value in planning is making sure the relay is *runnable*: the finish line exists, sizing is stated, handover is stated, and the intervention signal is stated. Once the charter is agreed, you can dispatch the first leg with `spawn_session`.
## Smells to watch for
- **No finish line** → infinite relay. Refuse to run a relay without a defined goal.
- **Goal drift** → each leg quietly restates the task. Re-anchor every leg.
- **Charter churn** → the charter changes every leg. The design isn't settled; involve the human.
- **Eager spawning** → spawning early, spawning several runners, or spawning before work is durable. One leg, one handoff, at the end.
- **Silent stall** → getting stuck and stopping with no note, or spawning anyway. Always log the blocker and surface it.
+62
View File
@@ -0,0 +1,62 @@
{
"skill_name": "relay",
"notes": "Relay is a behavioral framework skill. Test cases are prompts; 'good' is described per case and broken into checkable assertions. Because this project's only spawning primitive is spawn_session (fire-and-forget, real sessions), the standard isolated-subagent benchmark pipeline is not available here. Verify via (a) inline behavioral walkthrough of the skill text and (b) live spawn_session smoke tests against a throwaway sandbox relay, observed by the human in the UI. Assertions tagged \"script\" can be checked by counting spawn_session calls / inspecting files; assertions tagged \"judgment\" need a human or grader read.",
"evals": [
{
"id": 0,
"name": "plan-a-relay",
"prompt": "I want to migrate all our REST endpoints to the new validation layer \u2014 there are around 40 of them across src/server/routes. I won't be able to babysit this. Set it up as a relay so an agent can grind through it across sessions and only pull me in when it actually needs me.",
"expected_output": "Produces a charter (default .pi-web/relays/<name>/charter.md) plus an empty/seeded log. The charter has all four required slots present: goal/finish-line, sizing, handover, intervention signal. The agent ASKS the user to make sizing and the intervention signal concrete rather than inventing strict rules. It does NOT prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the charter is agreed.",
"files": [],
"assertions": [
{ "name": "charter-created", "text": "A charter document is created (default under .pi-web/relays/<name>/ unless the user specified a location).", "type": "script" },
{ "name": "goal-slot-present", "text": "The charter defines a concrete, achievable finish line / goal.", "type": "judgment" },
{ "name": "sizing-slot-present", "text": "The charter states how much work is one leg (sizing), rather than leaving it undefined.", "type": "judgment" },
{ "name": "handover-slot-present", "text": "The charter states the handover mechanism (what the spawn prompt says and what the next runner reads).", "type": "judgment" },
{ "name": "intervention-slot-present", "text": "The charter defines an intervention signal: when/how a runner stops and gets the human.", "type": "judgment" },
{ "name": "asks-not-prescribes", "text": "For sizing and the intervention signal, the agent asks the user to make them concrete instead of imposing its own strict rules/cadence.", "type": "judgment" },
{ "name": "no-premature-spawn", "text": "The agent does not spawn the first leg before the charter is agreed with the user.", "type": "script" }
]
},
{
"id": 1,
"name": "run-one-leg-and-hand-off",
"prompt": "You're working under the Relay framework. Read .pi-web/relays/<sandbox>/charter.md and .pi-web/relays/<sandbox>/log.md, continue the plan, then dispatch the next agent.",
"expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter+log, re-anchors to the goal, does exactly ONE well-sized leg per the charter's sizing, appends a log entry (what/why/new state/blockers), makes work durable (saves files, commits if the charter calls for it), then calls spawn_session exactly once with a handoff prompt that names Relay and points at the charter+log. Does not do extra legs or spawn more than once.",
"files": [],
"assertions": [
{ "name": "skill-loads-from-handoff", "text": "The agent recognizes it is in a relay and loads/consults the relay skill from the handoff prompt.", "type": "judgment" },
{ "name": "reads-charter-and-log", "text": "The agent reads both the charter and the log before acting.", "type": "script" },
{ "name": "exactly-one-leg", "text": "The agent completes exactly one well-sized leg, not several.", "type": "judgment" },
{ "name": "log-appended", "text": "A new log entry is appended recording what was done, decisions, new state, and any blocker.", "type": "script" },
{ "name": "work-durable-before-handoff", "text": "Work is saved (and committed if the charter requires it) before spawn_session is called.", "type": "script" },
{ "name": "spawn-exactly-once", "text": "spawn_session is called exactly once.", "type": "script" },
{ "name": "handoff-names-relay", "text": "The spawn prompt names the Relay framework and points the next runner at the charter and log so the skill loads downstream.", "type": "judgment" }
]
},
{
"id": 2,
"name": "stop-on-blocker-do-not-spawn",
"prompt": "You're working under the Relay framework. Read .pi-web/relays/<sandbox>/charter.md and .pi-web/relays/<sandbox>/log.md and continue. (The next leg hits a condition the charter flagged as requiring the human.)",
"expected_output": "Orients, begins the leg, recognizes the charter's intervention condition has fired. Stops cleanly: logs the blocker clearly, raises the charter's defined intervention signal so the watching human sees it, and does NOT call spawn_session. A clean stop with a clear blocker is the success condition here.",
"files": [],
"assertions": [
{ "name": "blocker-logged", "text": "The agent logs the blocker clearly in the log.", "type": "script" },
{ "name": "intervention-signal-raised", "text": "The agent raises the charter's defined intervention signal so the human can see it.", "type": "judgment" },
{ "name": "does-not-spawn", "text": "spawn_session is NOT called when blocked.", "type": "script" },
{ "name": "no-silent-stall", "text": "The agent does not stop silently; the stop is explained and visible.", "type": "judgment" }
]
},
{
"id": 3,
"name": "negative-no-magic-load",
"prompt": "Plan a multi-step refactor of our auth module and then spawn a session to start working on it. Break it into stages.",
"expected_output": "This prompt mentions a multi-step plan AND spawning a session, but never names the Relay framework, points at a charter/log, or invokes the skill. The relay skill should NOT load. The agent should plan and (optionally) use spawn_session as ordinary tools, without adopting relay ceremony (charter/log/legs/intervention signal).",
"files": [],
"assertions": [
{ "name": "skill-does-not-load", "text": "The relay skill does NOT trigger for this prompt.", "type": "judgment" },
{ "name": "no-relay-ceremony", "text": "The agent does not create a charter/log or impose relay leg/handoff ceremony.", "type": "judgment" }
]
}
]
}
+6 -4
View File
@@ -386,20 +386,21 @@ function restartOrder(refs: ServiceRef[]): ServiceRef[] {
}
function productionServiceDefinitions(options: InstallOptions, configPath: string, executables: ServiceExecutables): ServiceDefinition[] {
const environment = configEnvironment(options, configPath);
return [
{
...serviceRefs.sessiond,
description: "PI WEB session daemon",
shellCommand: `exec ${executables.sessiond.command}`,
restart: "on-failure",
environment: {},
environment,
},
{
...serviceRefs.web,
description: "PI WEB server",
shellCommand: `exec ${executables.web.command}`,
restart: "on-failure",
environment: configEnvironment(options, configPath),
environment,
after: ["sessiond"],
wants: ["sessiond"],
},
@@ -429,13 +430,14 @@ function validateDevCheckout(root: string): void {
}
function devServiceDefinitions(options: InstallOptions, configPath: string, root: string): ServiceDefinition[] {
const environment = configEnvironment(options, configPath);
return [
{
...serviceRefs.sessiond,
description: "PI WEB session daemon (dev)",
shellCommand: "exec npm run start:sessiond",
restart: "never",
environment: {},
environment,
workingDirectory: root,
},
{
@@ -443,7 +445,7 @@ function devServiceDefinitions(options: InstallOptions, configPath: string, root
description: "PI WEB UI dev server",
shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`,
restart: "never",
environment: configEnvironment(options, configPath),
environment,
after: ["sessiond"],
wants: ["sessiond"],
workingDirectory: root,
+21 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -84,6 +84,26 @@ describe("session API compatibility", () => {
});
});
describe("machine-scoped file suggestion API", () => {
it("uses the workspace-scoped route when the caller has enabled workspace-scoped suggestions", async () => {
const fetchMock = stubJsonFetch([]);
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
});
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
const fetchMock = stubJsonFetch([]);
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
});
});
describe("machine-scoped terminal command-run API", () => {
it("deletes workspaces through the selected machine scope", async () => {
const fetchMock = stubJsonFetch(commandRun);
+8 -1
View File
@@ -238,14 +238,21 @@ export interface FileSuggestionQueryOptions {
mode?: "file" | "path" | undefined;
scope?: "tracked" | "all" | undefined;
machineId?: string | undefined;
projectId?: string | undefined;
workspaceId?: string | undefined;
workspaceScoped?: boolean | undefined;
}
export const filesApi = {
files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => {
const params = new URLSearchParams({ cwd, q: query });
const params = new URLSearchParams({ q: query });
if (options.kind !== undefined) params.set("kind", options.kind);
if (options.mode !== undefined) params.set("mode", options.mode);
if (options.scope !== undefined) params.set("scope", options.scope);
if (options.workspaceScoped === true && options.projectId !== undefined && options.workspaceId !== undefined) {
return request(`${machinePrefix(options.machineId)}/projects/${encodeURIComponent(options.projectId)}/workspaces/${encodeURIComponent(options.workspaceId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
}
params.set("cwd", cwd);
return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
},
};
@@ -41,6 +41,7 @@ describe("federated route contract", () => {
ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })),
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
@@ -64,6 +65,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
ignoreParseFailure(sessionsApi.restore(session, machineId)),
ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)),
ignoreParseFailure(sessionsApi.reloadSession(session, machineId)),
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
+6 -6
View File
@@ -7,15 +7,15 @@ describe("API parsers", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
envOverrides: { host: true, port: false, allowedHosts: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
})).toEqual({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
envOverrides: { host: true, port: false, allowedHosts: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
});
});
+36 -2
View File
@@ -473,16 +473,43 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])),
...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])),
...optionalField("plugins", optionalPlugins(record["plugins"])),
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
};
}
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
if (value === undefined) return undefined;
if (value === true) return true;
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
if (isStringArray(value)) return value;
throw new Error("Invalid PI WEB allowedHosts field");
}
function optionalPathAccess(value: unknown): PiWebConfigValues["pathAccess"] | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) throw new Error("Invalid PI WEB pathAccess field");
const allowedPaths = value["allowedPaths"];
return {
...optionalField("allowedPaths", optionalStringArray(allowedPaths, "pathAccess.allowedPaths")),
};
}
function optionalStringArray(value: unknown, field: string): string[] | undefined {
if (value === undefined) return undefined;
if (isNonEmptyStringArray(value)) return value;
throw new Error(`Invalid PI WEB ${field} field`);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
}
function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field");
@@ -507,7 +534,7 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined {
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
const record = requireRecord(value);
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") };
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
}
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
@@ -751,6 +778,13 @@ export function parseReloaded(value: unknown): { reloaded: true } {
return { reloaded: true };
}
function optionalBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB ${key} field`);
return value;
}
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (value === undefined) return undefined;
+2 -2
View File
@@ -162,9 +162,9 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
if (type === "toolCall") {
const toolName = getString(part, "name") ?? "tool";
const args = getProperty(part, "arguments");
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead }];
const toolCallId = getString(part, "id");
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead, ...(toolCallId === undefined ? {} : { toolCallId }) }];
return [{ type: "toolCall", ...(toolCallId === undefined ? {} : { toolCallId }), toolName, summary: summarizeArgs(args), ...(args === undefined ? {} : { args }) }];
}
if (type === "image") {
+72 -3
View File
@@ -101,8 +101,9 @@ describe("applyTranscriptEvent", () => {
]);
});
it("replaces streamed skill reads when the finalized assistant message includes thinking", () => {
it("replaces streamed thinking and skill reads when the finalized assistant message includes thinking", () => {
const streamed: ChatLine[] = [
{ role: "assistant", parts: [{ type: "thinking", text: "load skill" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
{ role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "skill content", isError: false }] },
];
@@ -174,8 +175,8 @@ describe("applyTranscriptEvent", () => {
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "2", summary: "", args: { path: "/skills/sentry-cli/SKILL.md" } }) ?? messages;
expect(messages).toEqual([
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "sentry-cli", path: "/skills/sentry-cli/SKILL.md" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md", toolCallId: "1" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "sentry-cli", path: "/skills/sentry-cli/SKILL.md", toolCallId: "2" }] },
]);
});
@@ -185,6 +186,74 @@ describe("applyTranscriptEvent", () => {
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages;
expect(messages).toEqual([
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md", toolCallId: "1" }] },
]);
});
it("replaces multiple streamed skill reads with the finalized grouped skill message", () => {
const firstTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-1", toolName: "read", summary: "/skills/code-quality-architecture/SKILL.md", status: "success", resultText: "content" }] };
const secondTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-2", toolName: "read", summary: "/skills/relay/SKILL.md", status: "success", resultText: "content" }] };
const thirdTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-3", toolName: "read", summary: "/skills/skill-creator/SKILL.md", status: "success", resultText: "content" }] };
const streamed: ChatLine[] = [
{ role: "skill", parts: [{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" }] },
firstTool,
{ role: "skill", parts: [{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" }] },
secondTool,
{ role: "skill", parts: [{ type: "skillRead", name: "skill-creator", path: "/skills/skill-creator/SKILL.md", toolCallId: "read-3" }] },
thirdTool,
];
expect(applyTranscriptEvent(streamed, {
type: "message.end",
message: {
role: "assistant",
content: [
{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "/skills/code-quality-architecture/SKILL.md" } },
{ type: "toolCall", id: "read-2", name: "read", arguments: { path: "/skills/relay/SKILL.md" } },
{ type: "toolCall", id: "read-3", name: "read", arguments: { path: "/skills/skill-creator/SKILL.md" } },
],
timestamp: "2026-05-09T12:00:00.000Z",
},
})).toEqual([
{
role: "skill",
parts: [
{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" },
{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" },
{ type: "skillRead", name: "skill-creator", path: "/skills/skill-creator/SKILL.md", toolCallId: "read-3" },
],
meta: { timestamp: "2026-05-09T12:00:00.000Z" },
},
firstTool,
secondTool,
thirdTool,
]);
});
it("ignores streamed skill read starts that are already in a finalized grouped skill message", () => {
const messages: ChatLine[] = [
{
role: "skill",
parts: [
{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" },
{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" },
],
meta: { timestamp: "2026-05-09T12:00:00.000Z" },
},
{ role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-1", toolName: "read", summary: "/skills/code-quality-architecture/SKILL.md", status: "success", resultText: "content" }] },
];
expect(applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "read-2", summary: "", args: { path: "/skills/relay/SKILL.md" } })).toEqual(messages);
});
it("allows the same skill read after a user boundary", () => {
const messages: ChatLine[] = [
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
textMessage("user", "load it again"),
];
expect(applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "", summary: "", args: { path: "/skills/playwright/SKILL.md" } })).toEqual([
...messages,
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
]);
});
+71 -12
View File
@@ -35,8 +35,8 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
}
function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] {
const skillReadIndex = findMatchingSkillRead(messages, displayEnded);
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), displayEnded, ...messages.slice(skillReadIndex + 1)];
const skillReadIndexes = findMatchingSkillReadIndexes(messages, displayEnded);
if (skillReadIndexes.length > 0) return replaceSkillReadLines(messages, skillReadIndexes, displayEnded);
const last = messages.at(-1);
if (last?.role !== displayEnded.role) return [...messages, displayEnded];
if (displayEnded.role === "assistant" || sameMessageText(last, displayEnded)) return [...messages.slice(0, -1), displayEnded];
@@ -58,7 +58,9 @@ function parseSkillReadPath(path: string | undefined): { name: string; path: str
function appendToolExecutionStart(messages: ChatLine[], event: Extract<SessionUiEvent, { type: "tool.start" }>): ChatLine[] {
const skillRead = event.toolName === "read" ? parseSkillReadPath(getString(event.args, "path")) : undefined;
if (skillRead !== undefined) return appendLine(messages, { role: "skill", parts: [{ type: "skillRead", ...skillRead }] });
if (skillRead !== undefined) {
return appendLine(messages, { role: "skill", parts: [{ type: "skillRead", ...skillRead, ...(event.toolCallId === "" ? {} : { toolCallId: event.toolCallId }) }] });
}
const part: ToolExecutionPart = {
type: "toolExecution",
@@ -151,16 +153,51 @@ function stringifyToolContent(content: unknown): string {
return "";
}
function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number {
function findMatchingSkillReadIndexes(messages: ChatLine[], ended: ChatLine): number[] {
const endedReads = skillReads(ended);
if (endedReads.length === 0) return -1;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.role !== "skill") continue;
const reads = skillReads(message);
if (sameSkillReads(reads, endedReads)) return index;
if (endedReads.length === 0) return [];
const matchedIndexes: number[] = [];
let readEnd = endedReads.length;
const lowerBound = lastUserBoundaryIndex(messages) + 1;
for (let index = messages.length - 1; index >= lowerBound; index--) {
const reads = skillReads(messages[index]);
if (reads.length === 0) continue;
const readStart = readEnd - reads.length;
if (readStart < 0) continue;
if (!sameSkillReads(reads, endedReads.slice(readStart, readEnd))) continue;
matchedIndexes.unshift(index);
readEnd = readStart;
if (readEnd === 0) return matchedIndexes;
}
return -1;
return [];
}
function replaceSkillReadLines(messages: ChatLine[], indexes: number[], replacement: ChatLine): ChatLine[] {
const replacementIndexes = indexesWithAdjacentAssistantFragment(messages, indexes, replacement);
const insertIndex = replacementIndexes[0];
if (insertIndex === undefined) return messages;
const replaced = new Set(replacementIndexes);
const next: ChatLine[] = [];
for (let index = 0; index < messages.length; index++) {
if (index === insertIndex) next.push(replacement);
const message = messages[index];
if (message !== undefined && !replaced.has(index)) next.push(message);
}
return next;
}
function indexesWithAdjacentAssistantFragment(messages: ChatLine[], indexes: number[], replacement: ChatLine): number[] {
const firstIndex = indexes[0];
if (replacement.role !== "assistant" || firstIndex === undefined) return indexes;
const previousIndex = firstIndex - 1;
return isStreamedAssistantFragment(messages[previousIndex]) ? [previousIndex, ...indexes] : indexes;
}
function isStreamedAssistantFragment(message: ChatLine | undefined): boolean {
return message?.role === "assistant" && message.parts.length > 0 && message.parts.every((part) => part.type === "text" || part.type === "thinking");
}
function skillReads(message: ChatLine | undefined): SkillRead[] {
@@ -176,6 +213,7 @@ function sameSkillReads(left: SkillRead[], right: SkillRead[]): boolean {
function sameSkillRead(left: SkillRead, right: SkillRead | undefined): boolean {
if (right === undefined) return false;
if (left.toolCallId !== undefined && right.toolCallId !== undefined) return left.toolCallId === right.toolCallId;
return normalizeSkillPath(left.path) === normalizeSkillPath(right.path) || left.name === right.name;
}
@@ -201,11 +239,32 @@ function appendNewMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[]
function appendLine(messages: ChatLine[], line: ChatLine): ChatLine[] {
const last = messages.at(-1);
if (line.role === "skill" && sameSkillReads(skillReads(last), skillReads(line))) return messages;
if (isDuplicateSkillLine(messages, line)) return messages;
if (last?.role === line.role && line.role !== "skill") return [...messages.slice(0, -1), { ...last, parts: [...last.parts, ...line.parts] }];
return [...messages, line];
}
function isDuplicateSkillLine(messages: ChatLine[], line: ChatLine): boolean {
const reads = skillReads(line);
if (line.role !== "skill" || reads.length === 0) return false;
const lowerBound = lastUserBoundaryIndex(messages) + 1;
return reads.every((read) => hasMatchingSkillRead(messages, read, lowerBound));
}
function hasMatchingSkillRead(messages: ChatLine[], read: SkillRead, lowerBound: number): boolean {
for (let index = messages.length - 1; index >= lowerBound; index--) {
if (skillReads(messages[index]).some((candidate) => sameSkillRead(candidate, read))) return true;
}
return false;
}
function lastUserBoundaryIndex(messages: ChatLine[]): number {
for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.role === "user") return index;
}
return -1;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+21 -3
View File
@@ -152,6 +152,7 @@ export class PiWebApp extends LitElement {
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>();
private machineNavigationRestoreSeq = 0;
private navigationSelectionSeq = 0;
private routeRestoreSeq = 0;
private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined;
@@ -575,12 +576,16 @@ export class PiWebApp extends LitElement {
if (tool === "core:workspace.git") await this.git.refreshGit();
}
private async withChatScrollTransition(action: () => Promise<void>) {
private async withChatScrollTransition(action: () => Promise<void>, shouldComplete: () => boolean = () => true) {
this.chatView?.saveScrollPosition();
await action();
if (!shouldComplete()) return;
await this.updateComplete;
if (!shouldComplete()) return;
await this.chatView?.updateComplete;
if (!shouldComplete()) return;
await nextFrame();
if (!shouldComplete()) return;
this.chatView?.restoreScrollPosition();
if (this.shouldAutoFocusPrompt()) this.promptEditor?.focusInput();
}
@@ -1004,6 +1009,14 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
}
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
if (machineId === "local") return true;
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
// capability stay on the legacy cwd-based /files route.
const runtime = this.state.machineRuntimes[machineId];
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.workspaceFileSuggestions);
}
private archivedDeleteUnavailableMessage(): string {
const machineName = this.state.selectedMachine?.name ?? "this machine";
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
@@ -1078,10 +1091,15 @@ export class PiWebApp extends LitElement {
}
private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise<void>): Promise<void> {
const seq = ++this.navigationSelectionSeq;
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
await this.withChatScrollTransition(async () => {
this.navigationSections.advanceAfterSelection(section);
await action();
});
}, isCurrentSelection);
if (!isCurrentSelection()) return;
await this.focusNavigationTarget(nextTarget);
}
@@ -1779,7 +1797,7 @@ export class PiWebApp extends LitElement {
<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} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .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=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></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}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
+5 -2
View File
@@ -33,6 +33,9 @@ export class PromptEditor extends LitElement {
@property() sessionId?: string;
@property() cwd?: string;
@property() machineId = "local";
@property() projectId?: string;
@property() workspaceId?: string;
@property({ type: Boolean }) workspaceScopedFileSuggestions = false;
@property({ type: Boolean }) canSteer = false;
@property({ type: Boolean }) isCompacting = false;
@property({ type: Boolean }) canStop = false;
@@ -146,7 +149,7 @@ export class PromptEditor extends LitElement {
<label class="attachment-delivery" title="How attachments are delivered to the agent">
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline">Attach to message</option>
<option value="folder">Save to .pi-web/paste</option>
<option value="folder">Save to .pi-web/attachments</option>
</select>
</label>
` : null}
@@ -298,7 +301,7 @@ export class PromptEditor extends LitElement {
...(command.description === undefined ? {} : { description: command.description }),
}));
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions);
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId, projectId: this.projectId, workspaceId: this.workspaceId, workspaceScoped: this.workspaceScopedFileSuggestions }).catch(emptyFileSuggestions);
if (version !== this.requestVersion) return;
this.completions = files
.slice(0, 12)
+2 -2
View File
@@ -228,10 +228,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
`
: html`
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
`}
</div>
` : null}
@@ -4,6 +4,7 @@ import type { AppAction } from "../actions";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel";
import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel";
@@ -47,6 +48,7 @@ export class SettingsDialog extends LitElement {
<div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
</nav>
@@ -60,6 +62,19 @@ export class SettingsDialog extends LitElement {
}
private renderActiveSection(): TemplateResult {
if (this.section === "sessiond") {
return html`
<settings-sessiond-panel
.configResponse=${this.configResponse}
.loading=${this.loading}
.saving=${this.saving}
.error=${this.error}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
></settings-sessiond-panel>
`;
}
if (this.section === "shortcuts") {
return html`
<settings-shortcuts-panel
+5 -1
View File
@@ -562,7 +562,11 @@ export class TerminalPanel extends LitElement {
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
.terminal-host .xterm-helper-textarea { position: absolute !important; left: -9999em !important; top: 0 !important; width: 0 !important; height: 0 !important; min-width: 0 !important; min-height: 0 !important; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5 !important; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; }
/* Hide the helper textarea without using !important on the positional properties (left/top/width/height/z-index). xterm sets those inline during IME/dead-key composition (e.g. "~" on a Swedish layout) so the composition is positioned at the cursor and committed correctly; forcing them here would pin the textarea off-screen with zero size and break composition. */
.terminal-host .xterm-helper-textarea { position: absolute; left: -9999em; top: 0; width: 0; height: 0; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; }
/* The composition view shows pending dead-key/IME input. Without these rules it renders as a static block in the top-left corner instead of overlaying the cursor. */
.terminal-host .composition-view { position: absolute; display: none; white-space: nowrap; z-index: 1; background: var(--pi-terminal-bg, #000); color: var(--pi-terminal-text, #fff); }
.terminal-host .composition-view.active { display: block; }
.terminal-host .xterm-viewport { position: absolute; inset: 0; overflow-y: scroll; cursor: default; background-color: var(--pi-terminal-bg); }
.terminal-host .xterm-screen { position: relative; }
.terminal-host .xterm-screen canvas { position: absolute; left: 0; top: 0; }
@@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement {
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
</div>
<label class="field">
<span class="field-heading">
<span>External filesystem roots</span>
</span>
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs&#10;/opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
</label>
${this.renderEffectiveConfig()}
<footer class="form-actions">
@@ -102,6 +110,7 @@ export class SettingsGeneralPanel extends LitElement {
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
</dl>
</section>
`;
@@ -173,6 +182,11 @@ function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string |
return html`<span class="muted">Unset</span>`;
}
function formatAllowedPaths(value: string[] | undefined): string | TemplateResult {
if (value === undefined || value.length === 0) return html`<span class="muted">External paths denied</span>`;
return value.join(", ");
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
@@ -0,0 +1,142 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
@customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement {
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
override render(): TemplateResult {
const config = this.configResponse;
const spawnOverridden = config?.envOverrides.spawnSessions === true;
// On by default: the effective config is the source of truth for the toggle
// state, so an unset config file still shows the feature as enabled.
const effectiveSpawn = config?.effectiveConfig.spawnSessions !== false;
const subsessionsOverridden = config?.envOverrides.subsessions === true;
// Beta, off by default; also requires spawn to be enabled.
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
return html`
<div class="section-heading">
<div>
<h2>Session daemon</h2>
<p>These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div>
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config?.path ?? "Unknown"}</code>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSpawn}
?disabled=${this.loading || this.saving || spawnOverridden}
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
>
<span>Enable the <code>spawn_session</code> tool</span>
</label>
<small>When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default.</small>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start tracked subsessions</span>
<span class="beta-badge">beta</span>
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSubsessions}
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
>
<span>Enable the <code>spawn_subsession</code> tools</span>
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
</section>
`}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
return null;
}
private async toggleSpawnSessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {};
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
}
private async toggleSubsessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {};
await this.onSave?.({ ...baseConfig, subsessions: enabled });
}
static override styles = css`
:host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; }
p { color: var(--pi-muted); line-height: 1.45; }
button, input { font: inherit; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card, .effective-card, .restart-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message { margin-bottom: 12px; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card { color: var(--pi-muted); }
.restart-note { margin-bottom: 14px; border-color: var(--pi-warning-border); color: var(--pi-warning); background: var(--pi-warning-surface); line-height: 1.45; }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.field { display: grid; gap: 7px; margin-bottom: 14px; }
.field small { color: var(--pi-muted); line-height: 1.45; }
.field-heading { display: flex; align-items: center; gap: 8px; }
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
.toggle input { width: 16px; height: 16px; }
.toggle input:disabled { cursor: not-allowed; }
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.effective-card { display: grid; gap: 10px; }
.effective-card dl { display: grid; gap: 8px; margin: 0; }
.effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.muted { color: var(--pi-muted); }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; }
}
`;
}
@@ -3,27 +3,62 @@ import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
describe("settings config drafts", () => {
it("converts PI WEB config values to editable general settings drafts", () => {
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
host: "0.0.0.0",
port: "8504",
allowedHostsMode: "list",
allowedHostsText: "example.local\n192.168.1.20",
allowedPathsText: "/tmp\n~/SDKs",
});
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
});
it("converts drafts back to config while preserving shortcut and plugin preferences", () => {
it("converts drafts back to config while preserving non-general preferences", () => {
expect(configFromDraft({
host: " 127.0.0.1 ",
port: "9000",
allowedHostsMode: "list",
allowedHostsText: "example.local, 192.168.1.20\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({
allowedPathsText: "/tmp\n~/SDKs\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({
host: "127.0.0.1",
port: 9000,
allowedHosts: ["example.local", "192.168.1.20"],
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
maxUploadBytes: 1234,
});
});
it("removes global path access when the allowed paths field is cleared", () => {
expect(configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "",
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
});
it("rejects relative external paths before saving", () => {
expect(() => configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "relative/path",
})).toThrow("Allowed external paths must be absolute paths or start with ~");
});
it("preserves the spawnSessions flag when saving general settings", () => {
const result = configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "",
}, { spawnSessions: true });
expect(result.spawnSessions).toBe(true);
});
});
@@ -5,10 +5,11 @@ export interface ConfigDraft {
port: string;
allowedHostsMode: "list" | "all";
allowedHostsText: string;
allowedPathsText: string;
}
export function emptyConfigDraft(): ConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
}
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
@@ -17,6 +18,7 @@ export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
port: config.port === undefined ? "" : String(config.port),
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
};
}
@@ -24,6 +26,9 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
const config: PiWebConfigValues = {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
};
const host = draft.host.trim();
const port = draft.port.trim();
@@ -34,9 +39,22 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
config.port = parsed;
}
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
return config;
}
function parseAllowedHostsText(value: string): string[] {
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
}
function parseAllowedPathsText(value: string): string[] {
const paths = value.split("\n").map((path) => path.trim()).filter((path) => path !== "");
const invalid = paths.find((path) => !isAbsoluteishAllowedPath(path));
if (invalid !== undefined) throw new Error(`Allowed external paths must be absolute paths or start with ~: ${invalid}`);
return paths;
}
function isAbsoluteishAllowedPath(path: string): boolean {
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
}
+4 -4
View File
@@ -24,7 +24,7 @@ export type ChatPart =
| { type: "image"; mimeType: string; data: string }
| { type: "thinking"; text: string }
| { type: "skillInvocation"; name: string; location: string; content: string }
| { type: "skillRead"; name: string; path: string }
| { type: "skillRead"; name: string; path: string; toolCallId?: string }
| { type: "toolCall"; toolCallId?: string; toolName: string; summary: string; args?: unknown }
| ToolExecutionPart
| { type: "toolResult"; toolCallId?: string; toolName: string; text: string; isError: boolean; content?: unknown; details?: unknown }
@@ -280,9 +280,9 @@ export const chatStyles = css`
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
.activity-dock.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; }
.msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-left: 3px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: visible; }
.msg.assistant { border-left-color: var(--pi-text-secondary); background: var(--pi-surface); }
.msg.user { border-color: var(--pi-accent-border); border-left: 3px solid var(--pi-accent); background: var(--pi-selection-bg); }
.msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: visible; }
.msg.assistant { background: var(--pi-surface); }
.msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
.msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
.msg.tool-execution-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); }
.msg.system { color: var(--pi-danger); }
@@ -142,6 +142,68 @@ describe("SessionController", () => {
expect(state.selectedSession?.messageCount).toBe(3);
});
it("adds a newly created session to the list when it belongs to the selected workspace", () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" };
controller.applyGlobalEvent({ type: "session.created", session: spawned });
expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]);
});
it("ignores a created session for a different workspace or a duplicate id", () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } });
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } });
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
});
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const socket = new FakeSocket();
const api: typeof defaultApi = {
...defaultApi,
startSession: () => {
// Simulate the broadcast arriving before the HTTP response resolves.
controller.applyGlobalEvent({ type: "session.created", session: started });
return Promise.resolve(started);
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket },
);
await controller.startSession();
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
});
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
let resolvePrompt: (() => void) | undefined;
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
@@ -207,7 +269,7 @@ describe("SessionController", () => {
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
const api: typeof defaultApi = {
...defaultApi,
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/paste/shot.png", mimeType: "image/png", size: 3 }]); },
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); },
prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
};
const controller = new SessionController(
@@ -221,7 +283,7 @@ describe("SessionController", () => {
await controller.send("check this", undefined, attachments, "folder");
expect(savedCalledWith).toEqual(attachments);
expect(promptText).toBe("check this\n\[email protected]/paste/shot.png");
expect(promptText).toBe("check this\n\[email protected]/attachments/shot.png");
expect(promptAttachments).toBeUndefined();
expect(state.sendingPrompts).toEqual({});
});
@@ -246,6 +308,36 @@ describe("SessionController", () => {
expect(state.sendingPrompts).toEqual({});
});
it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
let resolveCommand: (() => void) | undefined;
const seenDuringCommand: Record<string, true>[] = [];
const api: typeof defaultApi = {
...defaultApi,
runCommand: (_session, text) => new Promise((resolve) => {
seenDuringCommand.push({ ...state.sendingPrompts });
resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); };
}),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const run = controller.send("/skill:skill-creator");
expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]);
// No raw command text is added to the transcript; the agent streams the
// canonical expanded message back instead.
expect(state.messages).toEqual([]);
resolveCommand?.();
await run;
expect(state.messages).toEqual([]);
expect(state.sendingPrompts).toEqual({});
});
it("keeps live message count updates when a cached new session becomes persisted", async () => {
const cachedSession = markCachedNewSessionInfo(oldSession);
let resolvePrompt: (() => void) | undefined;
@@ -51,6 +51,7 @@ export class SessionController {
applyGlobalEvent(event: GlobalSessionEvent): void {
if (event.type === "status.update") this.applyStatus(event.status);
else if (event.type === "activity.update") this.applyActivity(event.activity);
else if (event.type === "session.created") this.applyCreatedSession(event.session);
else this.applySessionName(event.sessionId, event.name);
}
@@ -93,7 +94,10 @@ export class SessionController {
const session = await this.api.startSession(workspace.path, machineId);
rememberCachedNewSession(session, machineId);
const cachedSession = markCachedNewSessionInfo(session, machineId);
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
// Drop any entry the session.created broadcast may have inserted for this
// same session before the HTTP response resolved, so the cached marker
// (and its delete action) wins instead of leaving a duplicate badge.
this.setState({ sessions: [cachedSession, ...this.getState().sessions.filter((candidate) => candidate.id !== cachedSession.id)] });
await this.selectSession(cachedSession);
} catch (error) {
this.setState({ error: String(error) });
@@ -139,9 +143,7 @@ export class SessionController {
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(transcriptKey, page);
const isReceivingPartialStream = status.isStreaming;
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
this.setState({ ...history, isLoadingEarlierMessages: false, ...this.setStreamCatchup(status.isStreaming ? session.id : undefined), status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
this.applyStatus(status);
void this.refreshAvailableThinkingLevels();
for (const event of buffered) this.applyEvent(event);
@@ -231,12 +233,21 @@ export class SessionController {
async runCommand(text: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
// Commands are not inserted into the transcript optimistically: a builtin
// command produces its own result line, and a runtime/skill command is
// forwarded to the agent, which streams back the canonical (expanded)
// message. Inserting the raw text here would leave a line that doesn't
// converge with server history and disappears on reload. Surface the same
// per-session sending indicator that send() uses for the pre-receipt window.
const sessionId = session.id;
this.markSendingPrompt(sessionId, true);
try {
this.applyCommandResult(await this.api.runCommand(session, text, selectedMachineId(this.getState())));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
} finally {
this.markSendingPrompt(sessionId, false);
}
}
@@ -508,7 +519,7 @@ export class SessionController {
...history,
status,
activity: this.getState().sessionActivities[sessionId],
isReceivingPartialStream: status.isStreaming,
...this.setStreamCatchup(status.isStreaming ? sessionId : undefined),
});
this.applyStatus(status);
} catch (error) {
@@ -576,6 +587,16 @@ export class SessionController {
}
}
private applyCreatedSession(session: SessionInfo) {
const state = this.getState();
// Only surface sessions for the workspace currently in view; others are
// picked up when their workspace is opened. Skip if already present (e.g.
// the optimistic insert from startSession in this same tab).
if (state.selectedWorkspace?.path !== session.cwd) return;
if (state.sessions.some((candidate) => candidate.id === session.id)) return;
this.setState({ sessions: [session, ...state.sessions] });
}
private applyActivity(activity: SessionActivity) {
this.setState({
sessionActivities: { ...this.getState().sessionActivities, [activity.sessionId]: activity },
@@ -593,7 +614,7 @@ export class SessionController {
status: state.selectedSession?.id === status.sessionId ? status : state.status,
activity: state.selectedSession?.id === status.sessionId && clearsStaleActivity ? undefined : state.activity,
});
if (this.catchupStreamSessionId === status.sessionId && !status.isStreaming) this.finishStreamCatchup(status.sessionId);
if (!status.isStreaming) this.finishStreamCatchup(status.sessionId);
}
private applySessionName(sessionId: string, name: string | undefined) {
@@ -664,10 +685,24 @@ export class SessionController {
this.pendingTranscriptFrame = undefined;
}
// Stream catch-up is a single mode with two coupled facets that must never
// drift: the private `catchupStreamSessionId` guard (which suppresses live
// transcript events while we lack the in-flight message prefix) and the
// public `isReceivingPartialStream` flag (which drives the "Catching up…"
// badge). Route every mutation of the mode through this helper so the guard
// and the badge can never disagree. Catch-up only ever applies to the
// selected session, so an active session id always implies the badge is on.
private setStreamCatchup(sessionId: string | undefined): Pick<AppState, "isReceivingPartialStream"> {
this.catchupStreamSessionId = sessionId;
return { isReceivingPartialStream: sessionId !== undefined };
}
private finishStreamCatchup(sessionId: string) {
if (this.catchupStreamSessionId !== sessionId) return;
const isSelected = this.getState().selectedSession?.id === sessionId;
const wasCatchingUp = this.catchupStreamSessionId === sessionId || (isSelected && this.getState().isReceivingPartialStream);
if (!wasCatchingUp) return;
this.catchupStreamSessionId = undefined;
if (this.getState().selectedSession?.id === sessionId) this.setState({ isReceivingPartialStream: false });
if (isSelected) this.setState({ isReceivingPartialStream: false });
void this.refreshMessages(sessionId);
}
+2 -2
View File
@@ -131,12 +131,12 @@ export class RealtimeSocket {
function isSessionUiEvent(event: unknown): event is SessionUiEvent {
const type = eventType(event);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "pi.event"].includes(type);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "session.created", "pi.event"].includes(type);
}
function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
const type = eventType(event);
return type === "status.update" || type === "activity.update" || type === "session.name";
return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created";
}
function isRealtimeEvent(event: unknown): event is RealtimeEvent {
+2
View File
@@ -35,6 +35,8 @@ function installWindow(href: string): { pushed: string[]; replaced: string[] } {
describe("settings route helpers", () => {
it("parses supported settings deep links and aliases", () => {
expect(parseSettingsSection("general")).toBe("general");
expect(parseSettingsSection("sessiond")).toBe("sessiond");
expect(parseSettingsSection("sessions")).toBe("sessiond");
expect(parseSettingsSection("plugins")).toBe("plugins");
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
+2 -1
View File
@@ -1,4 +1,4 @@
export type SettingsSection = "general" | "plugins" | "shortcuts";
export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts";
export function readSettingsSection(): SettingsSection | undefined {
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
@@ -17,6 +17,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
if (value === "general") return "general";
if (value === "sessiond" || value === "sessions") return "sessiond";
if (value === "plugins") return "plugins";
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
return undefined;
+42 -6
View File
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig } from "./config.js";
import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
let tempDir: string;
let configPath: string;
@@ -18,18 +18,18 @@ afterEach(async () => {
describe("PI WEB config persistence", () => {
it("writes and reads the configured PI WEB config path", () => {
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } }, testOptions());
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, testOptions());
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } } });
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } } });
expect(loadPiWebConfig(testOptions())).toEqual(saved);
});
it("preserves unrelated config keys while replacing managed keys", async () => {
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, future: { enabled: true } }, null, 2)}\n`, "utf8");
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, future: { enabled: true } }, null, 2)}\n`, "utf8");
savePiWebConfig({ port: 9000, allowedHosts: [] }, testOptions());
savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }, testOptions());
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [] });
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } });
});
it("rejects invalid plugin config", async () => {
@@ -38,6 +38,12 @@ describe("PI WEB config persistence", () => {
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans");
});
it("rejects invalid path access config", async () => {
await writeFile(configPath, `${JSON.stringify({ pathAccess: { allowedPaths: [""] } }, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
});
it("persists and reads maxUploadBytes", () => {
savePiWebConfig({ maxUploadBytes: 1234 }, testOptions());
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
@@ -58,6 +64,36 @@ describe("maxUploadBytes", () => {
});
});
describe("spawnSessionsEnabled", () => {
it("is on by default when nothing is configured", () => {
expect(spawnSessionsEnabled({}, {})).toBe(true);
});
it("honors an explicit config opt-out", () => {
expect(spawnSessionsEnabled({}, { spawnSessions: false })).toBe(false);
});
it("lets the env var override the config in both directions", () => {
expect(spawnSessionsEnabled({ PI_WEB_SPAWN_SESSIONS: "0" }, { spawnSessions: true })).toBe(false);
expect(spawnSessionsEnabled({ PI_WEB_SPAWN_SESSIONS: "1" }, { spawnSessions: false })).toBe(true);
});
});
describe("subsessionsEnabled", () => {
it("is off by default while the capability is in beta", () => {
expect(subsessionsEnabled({}, {})).toBe(false);
});
it("honors an explicit config opt-in", () => {
expect(subsessionsEnabled({}, { subsessions: true })).toBe(true);
});
it("lets the env var override the config in both directions", () => {
expect(subsessionsEnabled({ PI_WEB_SUBSESSIONS: "1" }, { subsessions: false })).toBe(true);
expect(subsessionsEnabled({ PI_WEB_SUBSESSIONS: "0" }, { subsessions: true })).toBe(false);
});
});
function testOptions(): { env: NodeJS.ProcessEnv } {
return { env: { PI_WEB_CONFIG: configPath } };
}
+63
View File
@@ -82,6 +82,11 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}),
...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}),
...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}),
// Always resolved (on by default) so the effective config is the single
// source of truth for the runtime state and the settings UI toggle.
spawnSessions: spawnSessionsEnabled(env, loaded.config),
// Beta capability, resolved off by default.
subsessions: subsessionsEnabled(env, loaded.config),
},
};
}
@@ -96,7 +101,10 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
delete existing["allowedHosts"];
delete existing["shortcuts"];
delete existing["plugins"];
delete existing["pathAccess"];
delete existing["maxUploadBytes"];
delete existing["spawnSessions"];
delete existing["subsessions"];
const merged = { ...existing, ...piWebConfigRecord(normalized) };
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
@@ -117,7 +125,10 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
};
}
@@ -128,7 +139,10 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}),
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
};
}
@@ -138,6 +152,42 @@ function parseMaxUploadBytes(value: unknown, key: string, path = "environment"):
return bytes;
}
function parseSpawnSessions(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`PI WEB config spawnSessions must be a boolean: ${path}`);
return value;
}
/**
* Whether LLMs may start new sessions via the spawn_session tool. On by default
* (spawned sessions appear in the session list, so humans notice them); set the
* env var `PI_WEB_SPAWN_SESSIONS` or the `spawnSessions` config key to `false`
* to disable. The env var takes precedence over the config file.
*/
export function spawnSessionsEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean {
const fromEnv = env["PI_WEB_SPAWN_SESSIONS"];
if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true";
return config.spawnSessions ?? true;
}
function parseSubsessions(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`PI WEB config subsessions must be a boolean: ${path}`);
return value;
}
/**
* Beta: whether LLMs may start tracked child sessions via the spawn_subsession
* family of tools. Off by default while the capability stabilizes, so it can
* ship in main without affecting releases; enable with the env var
* `PI_WEB_SUBSESSIONS` or the `subsessions` config key. The env var takes
* precedence over the config file. Subsessions also require spawnSessions to be
* enabled (they share the same project-scope resolver).
*/
export function subsessionsEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean {
const fromEnv = env["PI_WEB_SUBSESSIONS"];
if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true";
return config.subsessions ?? false;
}
function parseString(value: unknown, key: string, path: string): string {
if (typeof value !== "string" || value === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`);
return value;
@@ -162,6 +212,19 @@ function parseAllowedHostsEnv(value: string): string[] | true {
return value.split(",").map((host) => host.trim()).filter((host) => host !== "");
}
export function parsePathAccessConfig(value: unknown, path: string): NonNullable<PiWebConfigValues["pathAccess"]> {
if (!isRecord(value)) throw new Error(`PI WEB config pathAccess must be an object: ${path}`);
const allowedPaths = value["allowedPaths"];
return {
...(allowedPaths !== undefined ? { allowedPaths: parseAllowedPaths(allowedPaths, path) } : {}),
};
}
function parseAllowedPaths(value: unknown, path: string): string[] {
if (!isNonEmptyStringArray(value)) throw new Error(`PI WEB config pathAccess.allowedPaths must be an array of non-empty strings: ${path}`);
return value;
}
function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
+111 -49
View File
@@ -15,6 +15,7 @@ import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { Project, Workspace } from "./types.js";
let app: FastifyInstance;
@@ -22,12 +23,14 @@ let tempDir: string;
let projectDir: string;
let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
let piWebConfig: PiWebConfigValues;
beforeEach(async () => {
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
projectDir = join(tempDir, "project");
remoteClient = undefined;
sessionDaemonRequests = [];
piWebConfig = {};
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(),
@@ -48,6 +51,7 @@ beforeEach(async () => {
}),
}),
sessionDaemon: fakeSessionDaemon(),
config: fakeConfigService(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
@@ -206,6 +210,23 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
});
it("proxies remote session reloads through the selected machine", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ reloaded: true })]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ reloaded: true });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
});
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
@@ -479,6 +500,64 @@ describe("buildApp", () => {
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
});
it("keeps normal file suggestions workspace-local when path access config is invalid", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Local Suggestions", path: projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
await writeFile(join(projectDir, "sdk.md"), "local sdk\n");
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`);
const response = await app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(projectDir)}&q=sdk&scope=all` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]);
});
it("serves project-configured allowed external files through the workspace explorer", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "External", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const externalDir = join(tempDir, "external-docs");
const deniedFile = join(tempDir, "secret.md");
await mkdir(externalDir);
await writeFile(join(externalDir, "sdk.md"), "external sdk\n");
await writeFile(deniedFile, "secret\n");
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const fileResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` });
const treeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` });
const suggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` });
const localSuggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` });
const deniedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` });
expect(fileResponse.statusCode).toBe(200);
expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false });
expect(treeResponse.statusCode).toBe(200);
expect(treeResponse.json()).toMatchObject({
path: externalDir,
entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })],
truncated: false,
});
expect(suggestionResponse.statusCode).toBe(200);
expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]);
expect(localSuggestionResponse.statusCode).toBe(200);
expect(localSuggestionResponse.json()).toEqual([]);
expect(deniedResponse.statusCode).toBe(400);
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
});
it("writes workspace files through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
@@ -490,7 +569,6 @@ describe("buildApp", () => {
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
// Write a text file
const writeTextResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
@@ -499,15 +577,11 @@ describe("buildApp", () => {
});
expect(writeTextResponse.statusCode).toBe(200);
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
const writeBody = writeTextResponse.json<Record<string, unknown>>();
expect(typeof writeBody['size']).toBe("number");
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
// Read it back
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
const readBody = readResponse.json<Record<string, unknown>>();
expect(readBody['content']).toBe("hello world");
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
// Write binary content
const writeBinaryResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
@@ -517,7 +591,6 @@ describe("buildApp", () => {
expect(writeBinaryResponse.statusCode).toBe(200);
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
// Create intermediate directories (default)
const writeDeepResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
@@ -526,12 +599,9 @@ describe("buildApp", () => {
});
expect(writeDeepResponse.statusCode).toBe(200);
// Verify the nested file was written
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
const readDeepBody = readDeepResponse.json<Record<string, unknown>>();
expect(readDeepBody['content']).toBe("deep content");
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
// Overwrite an existing file (default)
const overwriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
@@ -540,7 +610,6 @@ describe("buildApp", () => {
});
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
// Reject overwrite=false when file exists
const noOverwriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
@@ -548,10 +617,8 @@ describe("buildApp", () => {
headers: { "content-type": "text/plain" },
});
expect(noOverwriteResponse.statusCode).toBe(400);
const noOverwriteBody = noOverwriteResponse.json<Record<string, unknown>>();
expect(noOverwriteBody['error']).toContain("File already exists");
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
// Reject path traversal
const traversalResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
@@ -559,10 +626,8 @@ describe("buildApp", () => {
headers: { "content-type": "text/plain" },
});
expect(traversalResponse.statusCode).toBe(400);
const traversalBody = traversalResponse.json<Record<string, unknown>>();
expect(traversalBody['error']).toContain("Path traversal");
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
// Reject missing path
const noPathResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
@@ -570,10 +635,8 @@ describe("buildApp", () => {
headers: { "content-type": "text/plain" },
});
expect(noPathResponse.statusCode).toBe(400);
const noPathBody = noPathResponse.json<Record<string, unknown>>();
expect(noPathBody['error']).toContain("path query parameter is required");
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
// Fail when createDirs=false and parent directory does not exist
const noDirsResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
@@ -582,7 +645,6 @@ describe("buildApp", () => {
});
expect(noDirsResponse.statusCode).toBe(400);
// Reject writing to a directory path
await mkdir(join(projectDir, "subdir"), { recursive: true });
const dirWriteResponse = await app.inject({
method: "PUT",
@@ -604,7 +666,6 @@ describe("buildApp", () => {
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
// Write a file first so we can delete it
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
@@ -612,7 +673,6 @@ describe("buildApp", () => {
headers: { "content-type": "text/plain" },
});
// Delete existing file
const deleteResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
@@ -620,7 +680,6 @@ describe("buildApp", () => {
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
// Delete non-existent file (idempotent)
const deleteMissingResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
@@ -628,23 +687,19 @@ describe("buildApp", () => {
expect(deleteMissingResponse.statusCode).toBe(200);
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
// Reject path traversal
const traversalResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
});
expect(traversalResponse.statusCode).toBe(400);
const deleteTraversalBody = traversalResponse.json<Record<string, unknown>>();
expect(deleteTraversalBody['error']).toContain("Path traversal");
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
// Reject missing path
const noPathResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
});
expect(noPathResponse.statusCode).toBe(400);
const deleteNoPathBody = noPathResponse.json<Record<string, unknown>>();
expect(deleteNoPathBody['error']).toContain("path query parameter is required");
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
});
it("moves workspace files through the HTTP contract", async () => {
@@ -658,7 +713,6 @@ describe("buildApp", () => {
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
// Write a file first so we can move it
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
@@ -666,27 +720,21 @@ describe("buildApp", () => {
headers: { "content-type": "text/plain" },
});
// Move a file to a new path
const moveResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
});
expect(moveResponse.statusCode).toBe(200);
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
const moveBody = moveResponse.json<Record<string, unknown>>();
expect(typeof moveBody['size']).toBe("number");
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
// Verify source is gone
const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
expect(readSourceResponse.statusCode).toBe(400);
// Verify target exists
const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
expect(readTargetResponse.statusCode).toBe(200);
const targetBody = readTargetResponse.json<Record<string, unknown>>();
expect(targetBody['content']).toBe("move me");
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
// Write another file for overwrite test
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
@@ -700,14 +748,12 @@ describe("buildApp", () => {
headers: { "content-type": "text/plain" },
});
// Move with overwrite=true succeeds
const overwriteResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
});
expect(overwriteResponse.statusCode).toBe(200);
// Move with overwrite=false (default) fails when target exists
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
@@ -725,24 +771,20 @@ describe("buildApp", () => {
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
});
expect(noOverwriteResponse.statusCode).toBe(400);
const moveNoOverwriteBody = noOverwriteResponse.json<Record<string, unknown>>();
expect(moveNoOverwriteBody['error']).toContain("File already exists");
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
// Reject path traversal in fromPath
const traversalFromResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
});
expect(traversalFromResponse.statusCode).toBe(400);
// Reject missing params
const noParamsResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
});
expect(noParamsResponse.statusCode).toBe(400);
const noParamsBody = noParamsResponse.json<Record<string, unknown>>();
expect(noParamsBody['error']).toContain("fromPath query parameter is required");
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
});
});
@@ -752,6 +794,26 @@ interface CapturedSessionDaemonRequest {
body?: unknown;
}
function fakeConfigService() {
return {
read: () => piWebConfigResponse(piWebConfig),
write: (config: PiWebConfigValues) => {
piWebConfig = config;
return piWebConfigResponse(config);
},
};
}
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: join(tempDir, "config.json"),
exists: false,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function fakeSessionDaemon(): SessionProxyDaemon {
return {
request: (method, path, body) => {
+18 -10
View File
@@ -7,7 +7,8 @@ import fastifyWebsocket from "@fastify/websocket";
import { ProjectStore } from "./storage/projectStore.js";
import { ProjectService } from "./projects/projectService.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js";
import { normalizeRequestCwd } from "./workingDirectory.js";
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
@@ -16,7 +17,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
@@ -76,13 +77,19 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
});
}
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
interface LocalFileSuggestionRouteOptions {
config?: Pick<PiWebConfigService, "read">;
}
function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalFileSuggestionRouteOptions = {}): void {
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
const cwd = normalizeRequestCwd(request.query.cwd);
if (request.query.mode === "path") return await listPathSuggestions(cwd, request.query.q ?? "");
return await listFileSuggestions(cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
const query = request.query.q ?? "";
const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForCwd(cwd, projects, workspaces, options.config) : undefined;
if (request.query.mode === "path") return await listPathSuggestions(cwd, query, pathAccess);
return await listFileSuggestions(cwd, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -96,6 +103,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const configService = deps.config ?? createFilePiWebConfigService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
@@ -118,7 +126,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
registerConfigRoutes(app, deps.config);
registerConfigRoutes(app, configService);
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
@@ -128,8 +136,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerSessionProxyRoutes(app, sessionDaemon);
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
registerWorkspaceExplorerRoutes(app, projects, workspaces);
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api", { config: configService });
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
registerGitRoutes(app, projects, workspaces);
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
@@ -137,8 +145,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon);
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
registerLocalFileSuggestionRoutes(app, "/api");
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api", { config: configService });
registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
registerMachineProxyRoutes(app, machines);
+27 -3
View File
@@ -37,11 +37,11 @@ describe("config routes", () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } },
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } },
});
expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } });
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 });
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
});
@@ -56,6 +56,30 @@ describe("config routes", () => {
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
it("rejects invalid path access payloads before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { pathAccess: { allowedPaths: [""] } } },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
it("rejects invalid max upload bytes before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { maxUploadBytes: 0 } },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
});
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
@@ -64,6 +88,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
exists,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
+40
View File
@@ -58,6 +58,10 @@ function parseConfigRequest(value: unknown): PiWebConfig {
const allowedHosts = value["allowedHosts"];
const shortcuts = value["shortcuts"];
const plugins = value["plugins"];
const pathAccess = value["pathAccess"];
const maxUploadBytes = value["maxUploadBytes"];
const spawnSessions = value["spawnSessions"];
const subsessions = value["subsessions"];
if (host !== undefined) {
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
config.host = host;
@@ -69,6 +73,16 @@ function parseConfigRequest(value: unknown): PiWebConfig {
if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts);
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes);
if (spawnSessions !== undefined) {
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
config.spawnSessions = spawnSessions;
}
if (subsessions !== undefined) {
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
config.subsessions = subsessions;
}
return config;
}
@@ -88,6 +102,30 @@ function parseShortcutsRequest(value: unknown): Record<string, string | null> {
}));
}
function parsePathAccessRequest(value: unknown): NonNullable<PiWebConfig["pathAccess"]> {
if (!isRecord(value)) throw new Error("PI WEB config pathAccess must be an object");
const allowedPaths = value["allowedPaths"];
return {
...(allowedPaths === undefined ? {} : { allowedPaths: parseAllowedPathsRequest(allowedPaths) }),
};
}
function parseAllowedPathsRequest(value: unknown): string[] {
if (!isNonEmptyStringArray(value)) {
throw new Error("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
}
return value;
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
}
function parseMaxUploadBytesRequest(value: unknown): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error("PI WEB config maxUploadBytes must be a positive integer");
return value;
}
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
@@ -106,6 +144,8 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverride
host: isEnvSet(env["PI_WEB_HOST"]),
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
};
}
+17 -3
View File
@@ -10,20 +10,34 @@ import { AuthService } from "./sessions/authService.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import { sessiondSocketPath } from "../sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { maxUploadBytes } from "../config.js";
import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js";
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes() });
const { config } = effectivePiWebConfig();
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
await app.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService();
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, workspaceActivity });
const spawnTargets = spawnSessionsEnabled(process.env, config)
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry,
workspaceActivity,
logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
});
auth.subscribe((change) => { sessions.applyAuthChange(change); });
const terminals = new TerminalService(eventHub, workspaceActivity);
registerWorkspaceActivityRoutes(app, workspaceActivity);
@@ -30,12 +30,12 @@ describe("saveAttachmentsToWorkspace", () => {
);
expect(saved).toHaveLength(2);
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/paste-`)).toBe(true);
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
expect(saved[0]?.path.endsWith(".png")).toBe(true);
expect(saved[1]?.path.endsWith(".webp")).toBe(true);
expect(saved[0]?.size).toBe(pngBytes.byteLength);
const folderEntries = await readdir(join(workspace, ".pi-web", "paste"));
const folderEntries = await readdir(join(workspace, ".pi-web", "attachments"));
expect(folderEntries).toHaveLength(2);
const firstPath = saved[0]?.path ?? "";
+3 -3
View File
@@ -10,7 +10,7 @@ import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
* Default workspace-relative folder used when saving pasted/dropped
* attachments for the agent to read with its own tools.
*/
export const DEFAULT_ATTACHMENT_FOLDER = ".pi-web/paste";
export const DEFAULT_ATTACHMENT_FOLDER = ".pi-web/attachments";
export interface InlineImage {
image: ImageContent;
@@ -42,7 +42,7 @@ export async function attachmentsToInlineImages(attachments: PromptAttachment[])
}
export interface SaveAttachmentsOptions {
/** Workspace-relative folder to write into. Defaults to `.pi-web/paste`. */
/** Workspace-relative folder to write into. Defaults to `.pi-web/attachments`. */
folder?: string;
/** Clock injection for deterministic tests. */
now?: () => Date;
@@ -66,7 +66,7 @@ export async function saveAttachmentsToWorkspace(
const saved: SavedPromptAttachment[] = [];
for (const [index, attachment] of attachments.entries()) {
const bytes = Buffer.from(attachment.data, "base64");
const filename = `paste-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
const relativePath = `${folder}/${filename}`;
await writeFile(join(folderTarget, filename), bytes);
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
@@ -63,9 +63,9 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
return filterSessionsForCwd(await listSessionsInDir(resolution.sessionDir), cwd);
}
create(cwd: string): PiSessionManager {
create(cwd: string, options?: { parentSession?: string }): PiSessionManager {
const resolution = this.resolver.resolve(cwd);
return SessionManager.create(cwd, resolution.sessionDir);
return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession });
}
listAll(): Promise<PiSessionListEntry[]> {
+236 -1
View File
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
class CapturingSessionEventHub extends SessionEventHub {
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
@@ -49,9 +50,10 @@ function sessionRef(id: string, cwd = "/workspace") {
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
const promptCalls: { text: string; options: unknown }[] = [];
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls };
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
@@ -86,6 +88,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
calls.prompt.push({ text, options });
return Promise.resolve();
},
sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => {
calls.sendCustomMessage.push({ message, options });
return Promise.resolve();
},
executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }),
abort: () => {
calls.abort += 1;
@@ -158,6 +164,7 @@ describe("PiSessionService", () => {
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true);
await service.dispose();
expect(fake.calls.abort).toBe(1);
@@ -540,6 +547,32 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("echoes the user message for direct prompts but not command-forwarded ones", async () => {
const fake = fakeRuntime("echo-session", {
resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) },
});
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("echo-session"), "Build the thing");
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
// The client optimistically renders command-forwarded prompts (e.g. /skill:*),
// so the server must not publish a second copy via message.append.
await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator");
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
expect(fake.calls.prompt).toEqual([
{ text: "Build the thing", options: undefined },
{ text: "/skill:skill-creator", options: undefined },
]);
await service.dispose();
});
it("rejects malformed prompt text before opening the runtime", async () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -747,4 +780,206 @@ describe("PiSessionService", () => {
expect(fake.calls.clearQueue).toBe(1);
await service.dispose();
});
describe("spawnSession", () => {
function spawnService(decision: SpawnTargetDecision) {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
logger: { info: (details, message) => { log.push({ details, message }); } },
heartbeatIntervalMs: 60_000,
});
return { fake, service, log };
}
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" });
const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]);
await service.dispose();
});
it("rejects an out-of-project target without starting a session", async () => {
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" }))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
expect(fake.calls.prompt).toEqual([]);
expect(service.activeCount()).toBe(0);
await service.dispose();
});
it("rejects when the spawning session is not in a registered project", async () => {
const { service } = spawnService({ allowed: false, reason: "not-registered" });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning session is not in a registered project");
await service.dispose();
});
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
});
describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
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];
let index = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime;
index += 1;
return runtime;
};
const archived = new Map<string, { sessionId: string; cwd: string; archivedAt: string }>();
const archiveStore = {
list: () => Promise.resolve([...archived.values()]),
get: (sessionId: string) => Promise.resolve(archived.get(sessionId)),
archive: (input: { sessionId: string; cwd: string }) => {
const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" };
archived.set(input.sessionId, record);
return Promise.resolve(record);
},
restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); },
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore,
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs,
});
return { parent, child, service };
}
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace"); // bring the parent online so it can be notified
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" });
expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]);
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
]);
void parent;
await service.dispose();
});
it("notifies the parent once when the tracked child stops working", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification
child.session.isStreaming = true;
child.emit({ type: "agent_start" }); // arm the notification
child.session.isStreaming = false;
child.emit({ type: "agent_end" }); // fire once
child.emit({ type: "turn_end" }); // must not re-notify
await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion");
expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message
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");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
parent.calls.prompt.length = 0;
// The child works, then settles silently: agent_end arrives while it still
// reports active work, so the event-driven latch does not fire here.
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.emit({ type: "agent_end" });
expect(parent.calls.sendCustomMessage).toHaveLength(0);
// Once the session settles, the periodic heartbeat re-check notifies.
child.session.isStreaming = false;
await new Promise((resolve) => setTimeout(resolve, 40));
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
await service.dispose();
});
it("does not notify the parent when a tracked child is archived", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
// Arm the notification, as a real working child would.
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
parent.calls.sendCustomMessage.length = 0;
await service.archive("child-1");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
await service.dispose();
});
it("reports an archived child's status in the subsession list", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
await service.archive("child-1");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "archived" },
]);
await service.dispose();
});
it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions");
await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions");
await service.dispose();
});
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
});
});
+288 -17
View File
@@ -31,11 +31,31 @@ import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
import { buildTranscriptView } from "./subsessionTranscript.js";
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
/**
* Minimal structured-logging seam, shaped like Fastify's logger so sessiond can
* pass `app.log` directly. Defaults to a no-op so the service stays usable
* without booting a server (e.g. in tests).
*/
export interface PiSessionLogger {
info(details: Record<string, unknown>, message: string): void;
}
const noopLogger: PiSessionLogger = { info() { /* no-op */ } };
function noop(): void {
// Intentionally empty default unsubscribe callback.
}
function spawnTargetError(decision: Extract<SpawnTargetDecision, { allowed: false }>): Error {
if (decision.reason === "not-registered") return new Error("Spawning session is not in a registered project");
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
}
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
return `${sessionId}:${provider}/${modelId}`;
}
@@ -58,6 +78,7 @@ interface QueuedPrompt {
kind: QueuedPromptKind;
text: string;
images?: ImageContent[];
echoUserMessage?: boolean;
}
function requirePromptText(value: unknown): string {
@@ -108,7 +129,7 @@ export interface PiSessionManager {
export interface PiSessionManagerGateway {
list(cwd: string): Promise<PiSessionListEntry[]>;
create(cwd: string): PiSessionManager;
create(cwd: string, options?: { parentSession?: string }): PiSessionManager;
/**
* Legacy id-only lookup surface for older clients. This intentionally searches
* only Pi's default session store, because custom session directories require
@@ -153,6 +174,7 @@ export interface PiAgentSession {
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
abort(): Promise<void>;
clearQueue(): { steering: string[]; followUp: string[] };
@@ -187,10 +209,16 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto
return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
}
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance): CreateAgentSessionRuntimeFactory {
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): CreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
const customTools = [createPiWebEditToolDefinition(cwd)];
const customTools = [
createPiWebEditToolDefinition(cwd),
...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
];
const options = sessionStartEvent === undefined
? { services, sessionManager, customTools }
: { services, sessionManager, sessionStartEvent, customTools };
@@ -232,6 +260,22 @@ export interface PiSessionServiceDependencies {
modelRegistry?: ModelRegistryInstance;
heartbeatIntervalMs?: number;
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity">;
/**
* When provided, the `spawn_session` tool is registered on every session,
* letting the LLM start new sessions scoped to its project's workspaces.
* Omit to keep the capability disabled (the tool is never registered).
*/
spawnTargets?: SpawnTargetResolver;
/**
* Beta: when true (and `spawnTargets` is provided), the tracked-subsession
* tools (`spawn_subsession`, `list_subsessions`, `check_subsession`,
* `read_subsession`) are
* registered on every session. Off by default so the capability can ship in
* main without being exposed in releases.
*/
subsessionsEnabled?: boolean;
/** Structured logger for notable runtime events (e.g. spawns). */
logger?: PiSessionLogger;
}
export class PiSessionService {
@@ -242,6 +286,16 @@ export class PiSessionService {
private readonly compactionPromptQueues = new Map<string, QueuedPrompt[]>();
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
private readonly authLossWarnings = new Set<string>();
/** Tracked subsession id -> the parent session id that spawned it. */
private readonly subsessionParents = new Map<string, string>();
/** Parent session id -> the set of tracked subsession ids it spawned. */
private readonly subsessionChildren = new Map<string, Set<string>>();
/**
* Tracked subsession id -> whether a completion notification is armed.
* Armed when the child starts working; firing on completion disarms it so a
* child that works again (and stops again) notifies the parent each time.
*/
private readonly subsessionNotifyArmed = new Map<string, boolean>();
private readonly archiveStore: SessionArchiveRepository;
private readonly agentDir: string;
private readonly sessionManager: PiSessionManagerGateway;
@@ -249,19 +303,36 @@ export class PiSessionService {
private readonly createAgentRuntime: CreateAgentRuntime;
private readonly modelRegistry: ModelRegistryInstance;
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir ?? getAgentDir();
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
// Subsessions are a beta capability gated behind their own flag, and they
// also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
this.modelRegistry.authStorage,
this.modelRegistry,
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
!subsessionsActive ? undefined : {
spawn: (input) => this.spawnSubsession(input),
list: (parentSessionId) => this.listSubsessions(parentSessionId),
check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId),
read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query),
},
);
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
this.workspaceActivity = deps.workspaceActivity;
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
this.commandService = new SessionCommandService(
(sessionId) => this.getActive(sessionId),
(sessionId, text) => this.prompt(sessionId, text),
(sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }),
events,
{
onCompactionStart: (session) => {
@@ -289,6 +360,9 @@ export class PiSessionService {
this.activities.clear();
this.compactionPromptQueues.clear();
this.authLossWarnings.clear();
this.subsessionParents.clear();
this.subsessionChildren.clear();
this.subsessionNotifyArmed.clear();
await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe();
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
@@ -315,10 +389,10 @@ export class PiSessionService {
return [...unarchivedSessions, ...archivedSessions];
}
async start(cwd: string): Promise<ClientSession> {
const active = await this.create(this.sessionManager.create(cwd), cwd);
async start(cwd: string, parentSession?: string): Promise<ClientSession> {
const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
const { session } = active.runtime;
return {
const created: ClientSession = {
id: session.sessionId,
path: session.sessionFile ?? "",
cwd,
@@ -326,7 +400,163 @@ export class PiSessionService {
modified: new Date().toISOString(),
messageCount: session.messages.length,
firstMessage: "",
// Include the parent so listeners can nest the new session in the tree
// immediately, instead of showing it flat until the next reload.
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
};
// Broadcast so other clients (and the spawning agent's UI) can add the new
// session to their list without a manual reload.
this.events.publishGlobal({ type: "session.created", session: created });
return created;
}
/**
* Start a new session on behalf of a LLM and deliver an initial prompt to it.
* The target cwd is constrained to a workspace of the same registered project
* as the spawning session so the new session is visible in the web UI.
*/
async spawnSession(input: SpawnSessionInvocation): Promise<SpawnSessionResult> {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd);
await this.prompt(created.id, input.prompt);
this.logger.info(
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
"spawn_session started a new session",
);
return { sessionId: created.id, cwd: decision.cwd };
}
/**
* Start a *tracked* child session on behalf of a LLM. Identical to
* {@link spawnSession} in how the target cwd is resolved, but the child
* records its parent (so it shows in the session tree) and is registered so
* the parent is notified when it stops working and can inspect it later.
*/
async spawnSubsession(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult> {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd, input.parentSessionFile);
this.registerSubsession(input.parentSessionId, created.id);
await this.prompt(created.id, input.prompt);
this.logger.info(
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
"spawn_subsession started a tracked child session",
);
return { sessionId: created.id, cwd: decision.cwd };
}
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
async listSubsessions(parentSessionId: string): Promise<SubsessionSummary[]> {
const childIds = this.subsessionChildren.get(parentSessionId);
if (childIds === undefined) return [];
return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
}
/** Status and final result of a subsession, scoped to the caller's children. */
async checkSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult> {
const session = await this.openSubsession(parentSessionId, sessionId);
const messages = historyMessages(session);
return {
sessionId,
cwd: session.sessionManager.getCwd(),
status: await this.subsessionStatus(session),
finalText: finalAssistantText(messages),
messageCount: messages.length,
};
}
/** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult> {
const session = await this.openSubsession(parentSessionId, sessionId);
const view = buildTranscriptView(historyMessages(session), query);
return {
sessionId,
cwd: session.sessionManager.getCwd(),
status: await this.subsessionStatus(session),
...view,
};
}
/** Open a session after verifying it is one of the caller's tracked children. */
private async openSubsession(parentSessionId: string, sessionId: string): Promise<PiAgentSession> {
if (this.subsessionParents.get(sessionId) !== parentSessionId) {
throw new Error(`Session ${sessionId} is not one of your subsessions`);
}
return this.getOrOpen(sessionId);
}
private registerSubsession(parentSessionId: string, childSessionId: string): void {
this.subsessionParents.set(childSessionId, parentSessionId);
const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>();
children.add(childSessionId);
this.subsessionChildren.set(parentSessionId, children);
this.subsessionNotifyArmed.set(childSessionId, false);
}
private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> {
const active = this.active.get(childSessionId);
if (active !== undefined) {
return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
}
const archived = await this.archiveStore.get(childSessionId);
if (archived !== undefined) return { cwd: archived.cwd, status: "archived" };
return { cwd: "", status: "unknown" };
}
private async subsessionStatus(session: PiAgentSession): Promise<SubsessionStatus> {
if (await this.archiveStore.isArchived(session.sessionId)) return "archived";
if (this.hasActiveWork(session)) return "working";
if (this.activities.get(session.sessionId)?.phase === "error") return "error";
return "idle";
}
/**
* 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
* follow-up message to the parent via {@link prompt} (which queues if the
* parent is busy and delivers immediately when it is idle).
*/
private updateSubsessionTracking(session: PiAgentSession): void {
const childId = session.sessionId;
const parentId = this.subsessionParents.get(childId);
if (parentId === undefined) return;
if (this.hasActiveWork(session)) {
this.subsessionNotifyArmed.set(childId, true);
return;
}
if (this.subsessionNotifyArmed.get(childId) !== true) return;
this.subsessionNotifyArmed.set(childId, false);
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\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
void this.notifyParentOfSubsession(parentId, childId, text);
}
/**
* Deliver a subsession-completion notice to the parent as a system-authored
* custom message rather than a user message, so it is not attributed to the
* human in the transcript. It still wakes an idle parent (`triggerTurn`) and
* queues behind in-flight work (`deliverAs: "followUp"`), preserving the
* established "queue if busy, send and act if idle" behavior.
*/
private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> {
try {
const session = await this.getOrOpen(parentId);
await session.sendCustomMessage(
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } },
{ triggerTurn: true, deliverAs: "followUp" },
);
this.publishStatus(session);
} catch (error: unknown) {
this.logger.info(
{ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) },
"failed to notify parent of subsession completion",
);
}
}
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
@@ -417,8 +647,13 @@ export class PiSessionService {
return commands.sort((a, b) => a.name.localeCompare(b.name));
}
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown, options?: { echoUserMessage?: boolean }): Promise<void> {
const promptText = requirePromptText(text);
// Command-forwarded prompts (e.g. /skill:*) are expanded by the agent, which
// streams the canonical message back. The client doesn't render the raw
// command text, so the server must not echo it either, or it would show up
// as a transient line that vanishes on reload.
const echoUserMessage = options?.echoUserMessage !== false;
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
@@ -433,15 +668,15 @@ export class PiSessionService {
return;
}
if (session.isCompacting) {
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images);
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
return;
}
void this.submitPrompt(session, promptText, behavior, images);
void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
}
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined, images: ImageContent[] = []): Promise<void> {
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined, images: ImageContent[] = [], echoUserMessage = true): Promise<void> {
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
if (behavior === undefined && echoUserMessage) this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
const promptOptions = buildPromptOptions(behavior, images);
const promptPromise = session.prompt(text, promptOptions).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
@@ -452,9 +687,9 @@ export class PiSessionService {
return promptPromise;
}
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind, images: ImageContent[] = []): void {
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind, images: ImageContent[] = [], echoUserMessage = true): void {
const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}) });
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
this.compactionPromptQueues.set(session.sessionId, queue);
this.publishActivity(session, "message queued during compaction", "active");
this.publishStatus(session);
@@ -684,6 +919,10 @@ export class PiSessionService {
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
this.clearAuthLossWarningsForSession(sessionId);
this.clearCompactionPromptQueue(sessionId);
// Disarm subsession notification before teardown so the abort below cannot
// emit a "stopped working" event that notifies the parent (e.g. on archive).
// The parent/children link is kept so the parent can still see the child.
this.subsessionNotifyArmed.delete(sessionId);
clearSessionQueue(active.runtime.session);
active.unsubscribe();
try {
@@ -772,6 +1011,7 @@ export class PiSessionService {
if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
this.publishStatus(session);
this.updateSubsessionTracking(session);
});
this.active.set(session.sessionId, active);
}
@@ -798,14 +1038,14 @@ export class PiSessionService {
const queued = this.takeCompactionPromptQueue(sessionId);
if (queued.length === 0) return;
this.publishStatus(session);
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images);
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images, prompt.echoUserMessage ?? true);
return;
}
const prompt = this.shiftCompactionPrompt(sessionId);
if (prompt === undefined) return;
this.publishStatus(session);
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images);
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
}
@@ -902,6 +1142,10 @@ export class PiSessionService {
private publishHeartbeats(): void {
for (const active of this.active.values()) {
const { session } = active.runtime;
// Re-evaluate subsession completion here too: agent_end can arrive while
// the session still reports active work transiently, so the event-driven
// latch may not fire. The heartbeat re-checks once the session settles.
this.updateSubsessionTracking(session);
const activity = this.activities.get(session.sessionId);
if (!this.hasActiveWork(session)) {
if (activity?.phase === "active") this.publishStatus(session);
@@ -1231,6 +1475,33 @@ function historyMessages(session: PiAgentSession): unknown[] {
return messages;
}
/** customType marking a parent-facing subsession-completion notice. */
const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;
function truncateForNotification(text: string): string {
if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS) return text;
return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}`;
}
/** Most recent assistant text from a history message list, or "" if none. */
function finalAssistantText(messages: readonly unknown[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!isRecord(message) || message["role"] !== "assistant") continue;
const content = message["content"];
if (typeof content === "string") return content;
if (!Array.isArray(content)) continue;
const texts: string[] = [];
for (const part of content) {
if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string") texts.push(part["text"]);
}
if (texts.length > 0) return texts.join("\n").trim();
}
return "";
}
function toClientEvent(event: unknown): SessionUiEvent {
const eventType = getString(event, "type");
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
@@ -60,7 +60,9 @@ describe("SessionCommandService", () => {
const service = new SessionCommandService(() => getActive(active), prompt, eventPublisher());
await expect(service.run("s1", "/missing")).resolves.toEqual({ type: "unsupported", message: "Unknown command: /missing" });
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done", message: "Accepted /ext arg" });
// Forwarded runtime commands return a bare done result: the agent streams
// back the canonical expanded message, so no synthetic "Accepted" line.
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done" });
await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" });
await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" });
expect(prompt).toHaveBeenCalledTimes(3);
+5 -1
View File
@@ -80,8 +80,12 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
if (!isBuiltinCommand(name)) {
if (this.isRuntimeCommand(session, name)) {
// The command is forwarded to the agent, which expands it (e.g. /skill:*
// into a skill block) and streams the canonical message back. That is the
// authoritative feedback, so we don't synthesize an extra "Accepted" line
// that would only vanish on reload.
await this.prompt(sessionId, text);
return { type: "done", message: `Accepted ${text}` };
return { type: "done" };
}
return { type: "unsupported", message: `Unknown command: /${name}` };
}
+1 -1
View File
@@ -175,7 +175,7 @@ class CapturingRouteSessionService extends PiSessionService {
override saveAttachments(_lookup: string | PiSessionRef, attachments: unknown, folder?: string) {
const list = Array.isArray(attachments) ? attachments : [];
return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({
path: `${folder ?? ".pi-web/paste"}/${attachment.name ?? "file.png"}`,
path: `${folder ?? ".pi-web/attachments"}/${attachment.name ?? "file.png"}`,
mimeType: attachment.mimeType,
size: Buffer.from(attachment.data, "base64").byteLength,
})));
@@ -0,0 +1,37 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
// The spawn tool's execute() never reads ctx, so an empty stub is sufficient.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub; execute() does not use ctx.
const ctx = {} as ExtensionContext;
describe("createSpawnSessionToolDefinition", () => {
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctx);
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature" });
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
});
it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctx);
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
});
it("propagates the spawn callback error so the agent loop reports it", async () => {
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await expect(tool.execute("call-3", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
});
});
+54
View File
@@ -0,0 +1,54 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
export interface SpawnSessionResult {
sessionId: string;
cwd: string;
}
export interface SpawnSessionInvocation {
spawningCwd: string;
prompt: string;
cwd: string | undefined;
}
export interface SpawnSessionToolDeps {
spawn(input: SpawnSessionInvocation): Promise<SpawnSessionResult>;
}
type SpawnSessionToolDetails = SpawnSessionResult;
const SpawnSessionParams = Type.Object({
prompt: Type.String({
description: "The first instruction to send to the newly created session. The new session runs independently; you do not receive its output.",
}),
cwd: Type.Optional(Type.String({
description: "Working directory for the new session. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
})),
});
/**
* Custom tool that lets the LLM start a new, independent pi-web session and
* deliver an initial prompt to it. The spawned session is a normal pi-web session
* a human can open and interact with. The tool is constructed per-session, so it
* carries the spawning session's cwd for project-scope validation.
*/
export function createSpawnSessionToolDefinition(spawningCwd: string, deps: SpawnSessionToolDeps) {
return defineTool<typeof SpawnSessionParams, SpawnSessionToolDetails>({
name: "spawn_session",
label: "Spawn session",
description: "Start a new, independent pi-web session and send it an initial prompt. Use this to dispatch a fresh agent to continue work or follow a plan. The new session runs on its own and a human can interact with it; you do not receive its output.",
promptSnippet: "spawn_session: start a new independent session with a first prompt",
parameters: SpawnSessionParams,
async execute(_toolCallId, params) {
// Failures throw: the agent loop turns the thrown message into an error
// tool result the model sees, so the spawning agent can adapt (e.g. pick a
// valid workspace) rather than crash.
const result = await deps.spawn({ spawningCwd, prompt: params.prompt, cwd: params.cwd });
return {
content: [{ type: "text", text: `Started session ${result.sessionId} in ${result.cwd}.` }],
details: result,
};
},
});
}
@@ -0,0 +1,149 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
function ctxFor(sessionId: string, sessionFile: string | undefined): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
// The subsession tools only read sessionManager.getSessionId/getSessionFile.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
return { sessionManager } as unknown as ExtensionContext;
}
function tools(deps: Partial<SubsessionToolDeps>) {
const full: SubsessionToolDeps = {
spawn: deps.spawn ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a" })),
list: deps.list ?? vi.fn(() => Promise.resolve([])),
check: deps.check ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, finalText: "", messageCount: 0 })),
read: deps.read ?? vi.fn(() => Promise.resolve({ sessionId: "x", cwd: "/repos/a", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
};
const definitions = createSubsessionToolDefinitions("/repos/a", full);
const find = (name: string) => {
const tool = definitions.find((definition) => definition.name === name);
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") };
}
function firstText(content: readonly (TextContent | ImageContent)[]): string {
const first = content[0];
return first?.type === "text" ? first.text : "";
}
describe("createSubsessionToolDefinitions", () => {
it("spawn_subsession forwards parent identity and params from the live context", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
const { spawn: spawnTool } = tools({ spawn });
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a",
parentSessionId: "parent-1",
parentSessionFile: "/sessions/parent-1.jsonl",
prompt: "do it",
cwd: "/repos/a-feature",
});
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
expect(firstText(result.content)).toContain("Started subsession child-1");
});
it("list_subsessions reports the caller's subsessions and their status", async () => {
const list = vi.fn(() => Promise.resolve([
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" as const },
]));
const { list: listTool } = tools({ list });
const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", undefined));
expect(list).toHaveBeenCalledWith("parent-1");
expect(result.details).toEqual({ subsessions: [
{ sessionId: "child-1", cwd: "/repos/a", status: "working" },
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" },
] });
expect(firstText(result.content)).toContain("child-1 [working]");
});
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: "You have not spawned any subsessions." });
});
it("check_subsession scopes by parent and returns the final result", async () => {
const check = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 }));
const { check: checkTool } = tools({ check });
const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
expect(check).toHaveBeenCalledWith("parent-1", "child-1");
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
expect(firstText(result.content)).toContain("all done");
});
it("check_subsession propagates scope errors so the agent loop reports them", async () => {
const check = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
const { check: checkTool } = tools({ check });
await expect(checkTool.execute("call-5", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
.rejects.toThrow("not one of your subsessions");
});
it("read_subsession forwards filter params and renders the transcript", async () => {
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,
}));
const { read: readTool } = tools({ read });
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", undefined));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 });
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
expect(firstText(result.content)).toContain("the answer");
});
it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => {
const read = vi.fn(() => Promise.resolve({
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
entries: [{
index: 1, role: "assistant" as const, parts: [
{ kind: "tool_call" as const, toolName: "bash", summary: "ls", args: { command: "ls -la" } },
{ kind: "text" as const, text: "clipped", truncated: { shown: 7, full: 50 } },
],
}],
total: 3, matched: 1, start: 1, hasMore: false,
}));
const { read: readTool } = tools({ read });
const result = await readTool.execute("call-7", { sessionId: "child-1", includeToolArgs: true }, undefined, undefined, ctxFor("parent-1", undefined));
const text = firstText(result.content);
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
});
it("read_subsession distinguishes an empty page-window from a zero-match result", async () => {
const read = vi.fn(() => Promise.resolve({
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
entries: [], total: 5, matched: 4, start: 0, hasMore: false,
}));
const { read: readTool } = tools({ read });
const result = await readTool.execute("call-8", { sessionId: "child-1", before: 0 }, undefined, undefined, ctxFor("parent-1", undefined));
const text = firstText(result.content);
expect(text).toContain("4 matched"); // not "nothing matched"
expect(text).not.toContain("nothing matched");
});
it("read_subsession propagates scope errors so the agent loop reports them", async () => {
const read = vi.fn(() => Promise.reject(new Error("Session child-9 is not one of your subsessions")));
const { read: readTool } = tools({ read });
await expect(readTool.execute("call-9", { sessionId: "child-9" }, undefined, undefined, ctxFor("parent-1", undefined)))
.rejects.toThrow("not one of your subsessions");
});
});
+242
View File
@@ -0,0 +1,242 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
/** Lifecycle phase of a tracked subsession as seen by its parent. */
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown";
export interface SpawnSubsessionResult {
sessionId: string;
cwd: string;
}
export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */
spawningCwd: string;
/** Session id of the parent; the spawned session is tracked against it. */
parentSessionId: string;
/** Session file of the parent, recorded in the child's `parentSession` header. */
parentSessionFile: string | undefined;
prompt: string;
cwd: string | undefined;
}
export interface SubsessionSummary {
sessionId: string;
cwd: string;
status: SubsessionStatus;
}
/** Quick glance at a subsession: status plus its most recent assistant output. */
export interface SubsessionCheckResult {
sessionId: string;
cwd: string;
status: SubsessionStatus;
finalText: string;
messageCount: number;
}
/** Exploratory transcript read: a filtered, paginated slice of the subsession's history. */
export interface SubsessionReadResult extends TranscriptView {
sessionId: string;
cwd: string;
status: SubsessionStatus;
}
/** Filters the parent passes to narrow a transcript read; mirrors {@link TranscriptQuery}. */
export interface SubsessionReadQuery {
roles?: TranscriptRole[];
include?: TranscriptContentKind[];
search?: string;
maxChars?: number;
includeToolArgs?: boolean;
before?: number;
limit?: number;
}
export interface SubsessionToolDeps {
spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>;
list(parentSessionId: string): Promise<SubsessionSummary[]>;
check(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult>;
read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult>;
}
const SpawnSubsessionParams = Type.Object({
prompt: Type.String({
description: "The first instruction to send to the new tracked subsession.",
}),
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.",
})),
});
const ListSubsessionsParams = Type.Object({});
const CheckSubsessionParams = Type.Object({
sessionId: Type.String({
description: "Id of a subsession you spawned (as returned by spawn_subsession or list_subsessions).",
}),
});
const ReadSubsessionParams = Type.Object({
sessionId: Type.String({
description: "Id of a subsession you spawned (as returned by 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." },
)),
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." },
)),
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.",
})),
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).",
})),
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.",
})),
before: Type.Optional(Type.Integer({
minimum: 0,
description: "Return only messages before this transcript index; page backward by passing the previous response's 'start'.",
})),
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.",
})),
});
function statusLine(summary: SubsessionSummary): string {
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
}
function renderEntry(entry: TranscriptEntry): string {
const header = `#${String(entry.index)} ${entry.role}`;
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
return body === "" ? header : `${header}\n${body}`;
}
function clipNotice(part: TranscriptEntry["parts"][number]): string {
if ((part.kind === "text" || part.kind === "thinking" || part.kind === "tool_result") && part.truncated !== undefined) {
return ` [+${String(part.truncated.full - part.truncated.shown)} chars truncated; re-read with a larger maxChars]`;
}
return "";
}
function renderPart(part: TranscriptEntry["parts"][number]): string {
if (part.kind === "text") return `${part.text}${clipNotice(part)}`;
if (part.kind === "thinking") return `[thinking] ${part.text}${clipNotice(part)}`;
if (part.kind === "tool_call") {
// Raw args are only present when the caller asked (includeToolArgs); when
// present, surface them in the model-facing text, not just `details`.
const args = "args" in part && part.args !== undefined ? `\n args: ${JSON.stringify(part.args)}` : "";
return `[tool ${part.toolName}] ${part.summary}${args}`;
}
if (part.kind === "tool_result") return `[result${part.isError ? " error" : ""}${part.toolName === undefined ? "" : ` ${part.toolName}`}] ${part.text}${clipNotice(part)}`;
return "[image]";
}
function renderTranscript(result: SubsessionReadResult): string {
const last = result.entries[result.entries.length - 1];
// Distinguish "nothing matched at all" (widen filters) from "matches exist but
// this page/window is empty" (page differently) so the agent isn't misled.
const range = last === undefined
? (result.matched === 0
? "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\nMore matching messages exist earlier; page back with before: ${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.
const body = result.entries.length > 0
? result.entries.map(renderEntry).join("\n\n")
: (result.matched === 0
? "(nothing matched; try widening roles/include, dropping search, or raising limit)"
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches are later — raise 'before' or omit it)`);
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`;
}
/**
* Tools that let an agent spawn *tracked* child sessions and inspect them.
*
* Unlike `spawn_session` (fire-and-forget peers), a subsession records its
* parent in its session header, the parent is notified when it stops working,
* and the parent may read its transcript/result. The tools are constructed
* per-session, carrying the spawning cwd for project-scope validation; the
* parent's identity is taken from the live extension context at call time.
*/
export function createSubsessionToolDefinitions(spawningCwd: string, deps: SubsessionToolDeps) {
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
name: "spawn_subsession",
label: "Spawn subsession",
description: "Start a tracked child session and send it an initial prompt. The subsession runs independently and a human can interact with it, but unlike spawn_session it is linked to you: you are notified when it stops working (finished, idle, or errored), and you can inspect it with list_subsessions, check_subsession (a quick glance at its latest output), and read_subsession (read through its transcript). Use this to delegate work you intend to follow up on.",
promptSnippet: "spawn_subsession: start a tracked child session you will be notified about",
parameters: SpawnSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.spawn({ spawningCwd, parentSessionId, parentSessionFile, prompt: params.prompt, cwd: params.cwd });
return {
content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }],
details: result,
};
},
});
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
name: "list_subsessions",
label: "List subsessions",
description: "List the tracked subsessions you spawned, with their current status (working, idle, error, or unknown).",
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const subsessions = await deps.list(parentSessionId);
const text = subsessions.length === 0
? "You have not spawned any subsessions."
: `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`;
return { content: [{ type: "text", text }], details: { subsessions } };
},
});
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
name: "check_subsession",
label: "Check subsession",
description: "Quick glance at a subsession you spawned: its current status and most recent assistant output. Use this to react to what a subsession produced. When the summary is not enough, use read_subsession to look through its full transcript.",
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
parameters: CheckSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const result = await deps.check(parentSessionId, params.sessionId);
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
return {
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
details: result,
};
},
});
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
name: "read_subsession",
label: "Read subsession",
description: "Read through the transcript of a subsession you spawned. Returns its messages filtered and paginated however you ask: choose which roles (assistant, user, tool, system, custom) and content kinds (text, thinking, tool_call, tool_result, image) to include, search for a substring (always over full content), cap each value's length with maxChars (omit for full text; clipped parts are flagged so truncation is never silent), optionally include raw tool args, and page backward with 'before'/'limit'. Start narrow (e.g. assistant text with a small maxChars) and widen the filters, raise maxChars, or page further back if you don't find what you need. For just the final result, use check_subsession instead.",
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId();
const { sessionId, ...query } = params;
const result = await deps.read(parentSessionId, sessionId, query);
return {
content: [{ type: "text", text: renderTranscript(result) }],
details: result,
};
},
});
return [spawnTool, listTool, checkTool, readTool];
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { Project, Workspace } from "../types.js";
import { ProjectScopedSpawnTargetResolver } from "./spawnTargetResolver.js";
function project(id: string, path: string): Project {
return { id, name: id, path, createdAt: "2026-01-01T00:00:00.000Z" };
}
function workspace(projectId: string, path: string): Workspace {
return { id: `${projectId}:${path}`, projectId, path, label: path, isMain: false, isGitRepo: true, isGitWorktree: true };
}
function resolverFor(projects: Project[], workspacesByProject: Record<string, Workspace[]>): ProjectScopedSpawnTargetResolver {
return new ProjectScopedSpawnTargetResolver({
projects: { list: () => Promise.resolve(projects) },
workspaces: { list: (p) => Promise.resolve(workspacesByProject[p.id] ?? []) },
});
}
describe("ProjectScopedSpawnTargetResolver", () => {
it("allows a target that is a workspace of the spawning session's project", async () => {
const resolver = resolverFor([project("a", "/repos/a"), project("b", "/repos/b")], {
a: [workspace("a", "/repos/a"), workspace("a", "/repos/a-feature")],
b: [workspace("b", "/repos/b")],
});
await expect(resolver.resolveSpawnTarget("/repos/a", "/repos/a-feature")).resolves.toEqual({ allowed: true, cwd: "/repos/a-feature" });
});
it("defaults the target to the spawning cwd when none is requested", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
await expect(resolver.resolveSpawnTarget("/repos/a", undefined)).resolves.toEqual({ allowed: true, cwd: "/repos/a" });
});
it("returns the canonical workspace path even when the request differs only by trailing slash", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
await expect(resolver.resolveSpawnTarget("/repos/a", "/repos/a/")).resolves.toEqual({ allowed: true, cwd: "/repos/a" });
});
it("rejects a target outside the project's workspaces and lists the allowed ones", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a"), workspace("a", "/repos/a-feature")] });
await expect(resolver.resolveSpawnTarget("/repos/a", "/elsewhere")).resolves.toEqual({
allowed: false,
reason: "out-of-project",
allowedCwds: ["/repos/a", "/repos/a-feature"],
});
});
it("rejects when the spawning cwd is in no registered project", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
await expect(resolver.resolveSpawnTarget("/elsewhere", undefined)).resolves.toEqual({ allowed: false, reason: "not-registered" });
});
});
@@ -0,0 +1,79 @@
import type { Project, Workspace } from "../types.js";
import { cwdPathsEqual } from "../workingDirectory.js";
/**
* Decision describing whether a LLM-spawned session may target a given cwd.
*
* - `allowed: true` carries the canonical workspace path to start the session in
* (always one of the project's known workspace paths, so it is guaranteed
* visible in the web UI).
* - `not-registered` means the spawning session's cwd belongs to no registered
* project, so spawning must be refused to preserve visibility.
* - `out-of-project` means the requested cwd is not a workspace of the spawning
* session's project; `allowedCwds` lists the valid targets for the caller to
* surface.
*/
export type SpawnTargetDecision =
| { allowed: true; cwd: string }
| { allowed: false; reason: "not-registered" }
| { allowed: false; reason: "out-of-project"; allowedCwds: string[] };
/**
* Owns the rule that keeps LLM-spawned sessions visible: a spawned session may
* only target a workspace (worktree, or root) of the registered project that
* owns the spawning session. The rule is evaluated live so a worktree the agent
* just created with `git worktree add` is included.
*/
export interface SpawnTargetResolver {
/**
* Decide whether a session spawned from `spawningCwd` may target
* `requestedCwd` (defaulting to `spawningCwd` when omitted), returning the
* canonical target cwd when allowed.
*/
resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision>;
}
interface ProjectLister {
list(): Promise<Project[]>;
}
interface WorkspaceLister {
list(project: Project): Promise<Workspace[]>;
}
export interface ProjectScopedSpawnTargetResolverDeps {
projects: ProjectLister;
workspaces: WorkspaceLister;
}
/**
* Default resolver composing the project registry and live worktree discovery.
* It finds the registered project whose current workspace set contains the
* spawning session's cwd, then validates the requested target against that set.
*/
export class ProjectScopedSpawnTargetResolver implements SpawnTargetResolver {
constructor(private readonly deps: ProjectScopedSpawnTargetResolverDeps) {}
async resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision> {
const allowedCwds = await this.allowedSpawnTargets(spawningCwd);
if (allowedCwds === undefined) return { allowed: false, reason: "not-registered" };
const target = requestedCwd === undefined || requestedCwd === "" ? spawningCwd : requestedCwd;
const match = allowedCwds.find((path) => cwdPathsEqual(path, target));
if (match === undefined) return { allowed: false, reason: "out-of-project", allowedCwds };
return { allowed: true, cwd: match };
}
/**
* Workspace paths of the registered project that owns `spawningCwd`, or
* `undefined` when no registered project contains it.
*/
private async allowedSpawnTargets(spawningCwd: string): Promise<string[] | undefined> {
const projects = await this.deps.projects.list();
for (const project of projects) {
const workspaces = await this.deps.workspaces.list(project);
const paths = workspaces.map((workspace) => workspace.path);
if (paths.some((path) => cwdPathsEqual(path, spawningCwd))) return paths;
}
return undefined;
}
}
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { buildTranscriptView } from "./subsessionTranscript.js";
const user = (text: string) => ({ role: "user", content: text });
const assistant = (text: string) => ({ role: "assistant", content: [{ type: "text", text }] });
const thinking = (text: string) => ({ role: "assistant", content: [{ type: "thinking", thinking: text }] });
const toolCall = (name: string, args?: unknown) => ({ role: "assistant", content: [{ type: "toolCall", name, ...(args === undefined ? {} : { arguments: args }) }] });
const toolResult = (text: string, toolName = "bash", isError = false) => ({ role: "toolResult", toolName, content: text, isError });
const custom = (text: string) => ({ role: "custom", content: text, customType: "subsession.completion" });
describe("buildTranscriptView", () => {
it("returns all entries with stable indices by default", () => {
const messages = [user("do it"), thinking("plan"), toolCall("bash"), toolResult("ok"), assistant("done")];
const view = buildTranscriptView(messages);
expect(view.total).toBe(5);
expect(view.matched).toBe(5);
expect(view.entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3, 4]);
expect(view.hasMore).toBe(false);
});
it("filters by role", () => {
const messages = [user("do it"), thinking("plan"), assistant("done")];
const view = buildTranscriptView(messages, { roles: ["assistant"] });
expect(view.matched).toBe(2); // thinking + text are both assistant-role
expect(view.entries.every((entry) => entry.role === "assistant")).toBe(true);
});
it("filters by content kind, dropping entries left empty", () => {
const messages = [user("do it"), thinking("plan"), assistant("answer"), toolCall("bash")];
const view = buildTranscriptView(messages, { include: ["text"] });
// user text + assistant text survive; thinking-only and tool_call-only entries drop out
expect(view.matched).toBe(2);
expect(view.entries.flatMap((entry) => entry.parts.map((part) => part.kind))).toEqual(["text", "text"]);
});
it("does not truncate by default and omits tool args", () => {
const long = "x".repeat(800);
const messages = [assistant(long), toolCall("bash", { command: "ls", extra: "y" })];
const view = buildTranscriptView(messages);
const textPart = view.entries[0]?.parts[0];
if (textPart?.kind !== "text") throw new Error("expected text part");
expect(textPart.text).toBe(long);
expect(textPart.truncated).toBeUndefined();
const callPart = view.entries[1]?.parts[0];
if (callPart?.kind !== "tool_call") throw new Error("expected tool_call part");
expect(callPart.summary).toBe("ls");
expect("args" in callPart).toBe(false);
});
it("maxChars clips text and flags it with the full length", () => {
const long = "x".repeat(800);
const messages = [assistant(long)];
const view = buildTranscriptView(messages, { maxChars: 100 });
const textPart = view.entries[0]?.parts[0];
if (textPart?.kind !== "text") throw new Error("expected text part");
expect(textPart.text).toBe("x".repeat(100));
expect(textPart.truncated).toEqual({ shown: 100, full: 800 });
});
it("maxChars does not flag values at or under the cap", () => {
const messages = [assistant("short")];
const view = buildTranscriptView(messages, { maxChars: 100 });
const textPart = view.entries[0]?.parts[0];
if (textPart?.kind !== "text") throw new Error("expected text part");
expect(textPart.text).toBe("short");
expect(textPart.truncated).toBeUndefined();
});
it("includeToolArgs returns raw args alongside the summary", () => {
const messages = [toolCall("bash", { command: "ls" })];
const view = buildTranscriptView(messages, { includeToolArgs: true });
const callPart = view.entries[0]?.parts[0];
if (callPart?.kind !== "tool_call") throw new Error("expected tool_call part");
expect(callPart.summary).toBe("ls");
expect(callPart.args).toEqual({ command: "ls" });
});
it("search keeps only matching entries across text and tool names", () => {
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")];
const view = buildTranscriptView(messages, { search: "auth" });
expect(view.matched).toBe(2);
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]);
});
it("search runs against full content even when maxChars would clip the match away", () => {
// The match sits past the clip point; a window-first or clip-first search would miss it.
const text = `${"a".repeat(300)} NEEDLE ${"b".repeat(300)}`;
const messages = [assistant(text)];
const view = buildTranscriptView(messages, { search: "needle", maxChars: 50 });
expect(view.matched).toBe(1);
const textPart = view.entries[0]?.parts[0];
if (textPart?.kind !== "text") throw new Error("expected text part");
// The match is found, and the returned (clipped) text honestly flags truncation.
expect(textPart.truncated).toEqual({ shown: 50, full: text.length });
});
it("search matches tool-call arguments even without includeToolArgs", () => {
const messages = [toolCall("bash", { command: "grep NEEDLE src" })];
const view = buildTranscriptView(messages, { search: "needle" });
expect(view.matched).toBe(1);
});
it("search finds args the display summary would drop (edit/write content, nested, beyond first 3 keys)", () => {
// summarizeToolArgs collapses these to 'edit text replacement' / 'object' / first-3-keys,
// so matching must serialize the full args, not the summary.
const editArgs = { oldText: "before", newText: "NEEDLE_IN_NEWTEXT" };
const nestedArgs = { a: 1, b: 2, c: 3, payload: { deep: "NEEDLE_NESTED" } };
const messages = [toolCall("edit", editArgs), toolCall("write", nestedArgs)];
expect(buildTranscriptView(messages, { search: "needle_in_newtext" }).matched).toBe(1);
expect(buildTranscriptView(messages, { search: "needle_nested" }).matched).toBe(1);
});
it("maxChars boundary: exact length is not flagged, one over is", () => {
const exact = buildTranscriptView([assistant("x".repeat(50))], { maxChars: 50 }).entries[0]?.parts[0];
if (exact?.kind !== "text") throw new Error("expected text part");
expect(exact.truncated).toBeUndefined();
const over = buildTranscriptView([assistant("x".repeat(51))], { maxChars: 50 }).entries[0]?.parts[0];
if (over?.kind !== "text") throw new Error("expected text part");
expect(over.truncated).toEqual({ shown: 50, full: 51 });
});
it("maxChars: 0 clips everything and flags it (not treated as 'no cap')", () => {
const part = buildTranscriptView([assistant("abc")], { maxChars: 0 }).entries[0]?.parts[0];
if (part?.kind !== "text") throw new Error("expected text part");
expect(part.text).toBe("");
expect(part.truncated).toEqual({ shown: 0, full: 3 });
});
it("negative or fractional maxChars is coerced to a safe non-negative integer, never 'no cap'", () => {
const negative = buildTranscriptView([assistant("abc")], { maxChars: -5 }).entries[0]?.parts[0];
if (negative?.kind !== "text") throw new Error("expected text part");
expect(negative.text).toBe(""); // coerced to 0, still truncates
expect(negative.truncated).toEqual({ shown: 0, full: 3 });
const fractional = buildTranscriptView([assistant("abcdef")], { maxChars: 2.9 }).entries[0]?.parts[0];
if (fractional?.kind !== "text") throw new Error("expected text part");
expect(fractional.text).toBe("ab"); // floored to 2
expect(fractional.truncated).toEqual({ shown: 2, full: 6 });
});
it("empty window with matches reports matched > 0 (paged past all matches)", () => {
const messages = [assistant("a"), assistant("b"), assistant("c")];
const view = buildTranscriptView(messages, { before: 0 });
expect(view.entries).toEqual([]);
expect(view.matched).toBe(3); // matches exist, the window just excluded them
expect(view.start).toBe(0);
expect(view.hasMore).toBe(false);
});
it("pages from the end and reports hasMore", () => {
const messages = [assistant("a"), assistant("b"), assistant("c"), assistant("d")];
const view = buildTranscriptView(messages, { limit: 2 });
expect(view.entries.map((entry) => entry.index)).toEqual([2, 3]);
expect(view.matched).toBe(4);
expect(view.start).toBe(2);
expect(view.hasMore).toBe(true);
});
it("pages backward using before: previous start", () => {
const messages = [assistant("a"), assistant("b"), assistant("c"), assistant("d")];
const view = buildTranscriptView(messages, { limit: 2, before: 2 });
expect(view.entries.map((entry) => entry.index)).toEqual([0, 1]);
expect(view.start).toBe(0);
expect(view.hasMore).toBe(false);
});
it("limit bounds matched entries, not raw messages", () => {
const messages = [user("u1"), assistant("a1"), user("u2"), assistant("a2"), user("u3"), assistant("a3")];
const view = buildTranscriptView(messages, { roles: ["assistant"], limit: 2 });
expect(view.matched).toBe(3);
expect(view.entries.map((entry) => entry.index)).toEqual([3, 5]);
expect(view.hasMore).toBe(true);
});
it("reports start as before when nothing matches in the window", () => {
const messages = [assistant("a"), assistant("b")];
const view = buildTranscriptView(messages, { search: "absent" });
expect(view.entries).toEqual([]);
expect(view.matched).toBe(0);
expect(view.start).toBe(2);
expect(view.hasMore).toBe(false);
});
it("includes custom and system roles", () => {
const messages = [custom("subsession done"), { role: "system", source: "compaction", content: "Compacted history:\n\nstuff" }];
const all = buildTranscriptView(messages);
expect(all.entries.map((entry) => entry.role)).toEqual(["custom", "system"]);
const onlyCustom = buildTranscriptView(messages, { roles: ["custom"] });
expect(onlyCustom.matched).toBe(1);
});
});
+312
View File
@@ -0,0 +1,312 @@
/**
* Pure helpers for the `read_subsession` tool: turn a subsession's normalized
* history (as produced by `historyMessages`) into a filtered, projected,
* paginated view the parent agent can explore.
*
* The agent drives the read: it picks which roles and content kinds it cares
* about, how much detail it wants, and how far back to look. If a narrow read
* does not answer its question it can widen the filters or page further back,
* the same grep-then-read loop it already uses on files. Everything here is a
* pure transform over an array so it can be unit-tested without a live session.
*/
/** Message roles the parent can ask for, mapped from raw history roles. */
export type TranscriptRole = "assistant" | "user" | "tool" | "system" | "custom";
/** Content kinds the parent can keep within retained messages. */
export type TranscriptContentKind = "text" | "thinking" | "tool_call" | "tool_result" | "image";
/**
* Marks a text value that the caller's `maxChars` clipped. Carries the full
* length so the consumer knows *how much* was dropped and can re-read with a
* larger `maxChars` (or none). Truncation only ever happens when the caller
* passes `maxChars`, so a `truncated` marker is always something they asked
* for and should expect, never a silent surprise. Its presence (not a ``
* glyph, which is indistinguishable from real content) is the reliable signal.
*/
export interface TranscriptTruncation {
/** Characters retained in `text`. */
shown: number;
/** Length of the original, untruncated text. */
full: number;
}
export type TranscriptPart =
| { kind: "text"; text: string; truncated?: TranscriptTruncation }
| { kind: "thinking"; text: string; truncated?: TranscriptTruncation }
| { kind: "tool_call"; toolName: string; summary: string; args?: unknown }
| { kind: "tool_result"; toolName?: string; text: string; isError: boolean; truncated?: TranscriptTruncation }
| { kind: "image" };
export interface TranscriptEntry {
/** Position of this message in the full transcript (stable across reads). */
index: number;
role: TranscriptRole;
parts: TranscriptPart[];
}
export interface TranscriptQuery {
/** Message roles to include. Omit for all roles. */
roles?: TranscriptRole[];
/** Content kinds to keep within retained messages. Omit for all kinds. */
include?: TranscriptContentKind[];
/** Case-insensitive substring; keep only entries whose text matches. */
search?: string;
/**
* Truncate each text/thinking/tool_result value to this many characters,
* flagging clipped parts with `truncated`. Omit for full, untruncated text:
* there is deliberately no default, so truncation only happens when asked for
* and a `truncated` marker is always expected. `search` always runs against
* the full content regardless, so clipping never hides a match.
*/
maxChars?: number;
/** Include raw tool-call arguments (can be large). The compact `summary` is always present. */
includeToolArgs?: boolean;
/** Upper bound (exclusive) on original index; page backward by passing the previous `start`. */
before?: number;
/** Keep at most this many of the most-recent matches in the window; entries are returned in chronological order. */
limit?: number;
}
export interface TranscriptView {
entries: TranscriptEntry[];
/** Total messages in the full transcript, before any filtering. */
total: number;
/** Entries matching the role/content/search filters across the whole transcript. */
matched: number;
/** Original index of the first returned entry, or `before` when nothing matched in-window. */
start: number;
/** True when matching entries exist before `start` (page back with `before: start`). */
hasMore: boolean;
}
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
/**
* Build a filtered, projected, paginated view of a normalized transcript.
*
* Filtering happens before paging for *semantics*, not speed: `search`, the
* `matched` count, and "page backward through matches" all require scanning the
* whole transcript, so a window-first approach could not answer them. The
* tradeoff is an O(total) scan per call (paging a raw window first would be
* cheaper), but `total` is a single session's history and this runs once per
* tool call, so the scan is negligible. The cost that matters for an LLM tool,
* the tokens returned, is bounded by `limit` regardless of ordering; `matched`
* is only a count, so the agent learns whether widening or paging is worthwhile
* without paying to receive every match.
*/
export function buildTranscriptView(messages: readonly unknown[], query: TranscriptQuery = {}): TranscriptView {
const total = messages.length;
// Explicit, caller-owned truncation: only when provided, and a malformed
// value (negative/fractional) is coerced to a safe non-negative integer
// rather than silently meaning "no cap".
const maxChars = query.maxChars === undefined ? undefined : Math.max(0, Math.floor(query.maxChars));
const includeToolArgs = query.includeToolArgs === true;
const roleFilter = query.roles === undefined ? undefined : new Set(query.roles);
const includeFilter = query.include === undefined ? undefined : new Set(query.include);
const search = query.search !== undefined && query.search !== "" ? query.search.toLowerCase() : undefined;
// Extract *full* (untruncated) parts and run all filtering/search on them, so
// matching never depends on `maxChars`. Projection (clipping, arg dropping)
// happens later and only on the entries we actually return.
const matchedEntries: FullEntry[] = [];
for (let index = 0; index < total; index++) {
const role = roleOf(messages[index]);
if (role === undefined) continue;
if (roleFilter !== undefined && !roleFilter.has(role)) continue;
let parts = fullPartsOf(messages[index], role);
if (includeFilter !== undefined) parts = parts.filter((part) => includeFilter.has(part.kind));
if (parts.length === 0) continue;
if (search !== undefined && !partsMatchSearch(parts, search)) continue;
matchedEntries.push({ index, role, parts });
}
const matched = matchedEntries.length;
const before = clampInteger(query.before ?? total, 0, total);
const limit = clampInteger(query.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT);
const inWindow = matchedEntries.filter((entry) => entry.index < before);
const windowed = inWindow.slice(Math.max(0, inWindow.length - limit));
const entries = windowed.map((entry) => projectEntry(entry, maxChars, includeToolArgs));
const first = windowed[0];
const start = first === undefined ? before : first.index;
const hasMore = inWindow.length > windowed.length;
return { entries, total, matched, start, hasMore };
}
/**
* A part before projection: tool calls keep their raw `args`, text-bearing
* parts keep their full untruncated `text`. Search and filtering run on these
* so a match is never hidden by `summary` truncation.
*/
type FullPart =
| { kind: "text"; text: string }
| { kind: "thinking"; text: string }
| { kind: "tool_call"; toolName: string; args?: unknown }
| { kind: "tool_result"; toolName?: string; text: string; isError: boolean }
| { kind: "image" };
interface FullEntry {
index: number;
role: TranscriptRole;
parts: FullPart[];
}
function partsMatchSearch(parts: readonly FullPart[], needle: string): boolean {
return parts.some((part) => {
if (part.kind === "text" || part.kind === "thinking") return part.text.toLowerCase().includes(needle);
if (part.kind === "tool_result") return part.text.toLowerCase().includes(needle) || (part.toolName?.toLowerCase().includes(needle) ?? false);
// Search the *full* serialized args, not the lossy one-line summary, so a
// term inside edit/write content, nested objects, or long values is found.
if (part.kind === "tool_call") return part.toolName.toLowerCase().includes(needle) || stringifyArgs(part.args).toLowerCase().includes(needle);
return false;
});
}
/** Full, search-friendly serialization of tool-call args (distinct from the lossy display summary). */
function stringifyArgs(args: unknown): string {
if (args === undefined) return "";
if (typeof args === "string") return args;
try {
// JSON.stringify can return undefined at runtime (e.g. a function/symbol),
// despite its string-typed signature; normalize that to "".
const json: unknown = JSON.stringify(args);
return typeof json === "string" ? json : "";
} catch {
return "";
}
}
/** Project a fully-extracted entry into the returned shape, clipping only when `maxChars` is set. */
function projectEntry(entry: FullEntry, maxChars: number | undefined, includeToolArgs: boolean): TranscriptEntry {
return { index: entry.index, role: entry.role, parts: entry.parts.map((part) => projectPart(part, maxChars, includeToolArgs)) };
}
function projectPart(part: FullPart, maxChars: number | undefined, includeToolArgs: boolean): TranscriptPart {
if (part.kind === "text") return { kind: "text", ...clip(part.text, maxChars) };
if (part.kind === "thinking") return { kind: "thinking", ...clip(part.text, maxChars) };
if (part.kind === "tool_result") {
return {
kind: "tool_result",
...(part.toolName === undefined ? {} : { toolName: part.toolName }),
isError: part.isError,
...clip(part.text, maxChars),
};
}
if (part.kind === "tool_call") {
return {
kind: "tool_call",
toolName: part.toolName,
summary: summarizeToolArgs(part.args),
...(includeToolArgs && part.args !== undefined ? { args: part.args } : {}),
};
}
return { kind: "image" };
}
/** Clip text to `maxChars`, attaching a `truncated` marker when it actually shortens. */
function clip(text: string, maxChars: number | undefined): { text: string; truncated?: TranscriptTruncation } {
if (maxChars === undefined || text.length <= maxChars) return { text };
return { text: text.slice(0, maxChars), truncated: { shown: maxChars, full: text.length } };
}
/** Map a raw history message to one of the agent-facing roles, or undefined to drop it. */
function roleOf(message: unknown): TranscriptRole | undefined {
const role = getString(message, "role");
if (role === "assistant") return "assistant";
if (role === "user") return "user";
if (role === "toolResult") return "tool";
if (role === "custom") return "custom";
if (role === "system") return "system";
return undefined;
}
/** Extract a message's *full* (untruncated) parts; projection happens later. */
function fullPartsOf(message: unknown, role: TranscriptRole): FullPart[] {
if (role === "tool") return toolResultParts(message);
const content = getProperty(message, "content");
if (typeof content === "string") return content === "" ? [] : [{ kind: "text", text: content }];
if (!Array.isArray(content)) return [];
return content.flatMap(contentPart);
}
function toolResultParts(message: unknown): FullPart[] {
const text = stringifyContent(getProperty(message, "content")) || (getString(message, "text") ?? "");
const toolName = getString(message, "toolName");
const isError = getProperty(message, "isError") === true;
return [{ kind: "tool_result", ...(toolName === undefined ? {} : { toolName }), text, isError }];
}
function contentPart(part: unknown): FullPart[] {
const type = getString(part, "type");
if (type === "text") {
const text = getString(part, "text") ?? "";
return text === "" ? [] : [{ kind: "text", text }];
}
if (type === "thinking") {
const text = getString(part, "thinking") ?? getString(part, "text") ?? "";
return text === "" ? [] : [{ kind: "thinking", text }];
}
if (type === "toolCall") {
const toolName = getString(part, "name") ?? "tool";
const args = getProperty(part, "arguments");
return [{ kind: "tool_call", toolName, ...(args === undefined ? {} : { args }) }];
}
if (type === "image") return [{ kind: "image" }];
return [];
}
/** Compact one-line description of tool arguments (mirrors the UI's summary). */
function summarizeToolArgs(args: unknown): string {
if (!isRecord(args)) return typeof args === "string" ? args : "";
const command = getString(args, "command");
if (command !== undefined) return command;
const path = getString(args, "path");
if (path !== undefined) return path;
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement";
const edits = args["edits"];
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · ");
}
function shortValue(value: unknown): string {
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}` : value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
if (typeof value === "object" && value !== null) return "object";
return "";
}
function stringifyContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => (getString(part, "type") === "image" ? "[image]" : getString(part, "text") ?? ""))
.filter((text) => text !== "")
.join("\n");
}
return "";
}
function clampInteger(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return max;
return Math.max(min, Math.min(max, Math.floor(value)));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getProperty(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function getString(value: unknown, key: string): string | undefined {
const property = getProperty(value, key);
return typeof property === "string" ? property : undefined;
}
+40 -16
View File
@@ -1,23 +1,26 @@
import type { FastifyInstance } from "fastify";
import type { ProjectService } from "./projects/projectService.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import type { PiWebConfigService } from "./configRoutes.js";
import type { ProjectService } from "./projects/projectService.js";
import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
export interface WorkspaceExplorerRouteOptions {
config?: Pick<PiWebConfigService, "read">;
}
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
registerWorkspaceFileContentParsers(app);
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
// Register content type parsers for workspace file writes.
// Fastify's default parser only handles application/json.
// Guard against re-registration since this function may be called multiple times.
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_req, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ }
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/, { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ }
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await listWorkspaceTree(context.root, request.query.path);
return await listWorkspaceTree(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -26,7 +29,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await readWorkspaceFile(context.root, request.query.path);
return await readWorkspaceFile(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -35,11 +38,11 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const options: WriteWorkspaceFileOptions = {
const writeOptions: WriteWorkspaceFileOptions = {
createDirs: request.query.createDirs !== "false",
overwrite: request.query.overwrite !== "false",
};
return await writeWorkspaceFile(context.root, request.query.path, request.body, options);
return await writeWorkspaceFile(context.root, request.query.path, request.body, writeOptions);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -69,7 +72,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
const preview = await readWorkspaceImagePreview(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
return await reply
.type(preview.mimeType)
.header("Cache-Control", "private, max-age=3600")
@@ -82,4 +85,25 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/files`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const query = request.query.q ?? "";
const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForWorkspaceContext(context, options.config) : undefined;
if (request.query.mode === "path") return await listPathSuggestions(context.root, query, pathAccess);
return await listFileSuggestions(context.root, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
function registerWorkspaceFileContentParsers(app: FastifyInstance): void {
// Fastify's default parser only handles JSON; workspace file writes need to
// accept text and arbitrary binary payloads. This route module is registered
// for both local aliases, so parser registration must tolerate repeats.
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_request, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/u, { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
}
@@ -0,0 +1,29 @@
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import type { PiWebConfigService } from "../configRoutes.js";
import type { ProjectService } from "../projects/projectService.js";
import type { WorkspaceContext } from "./workspaceContext.js";
import type { WorkspaceService } from "./workspaceService.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import { loadEffectiveProjectPathAccess } from "./projectPiWebConfig.js";
export async function pathAccessForWorkspaceContext(context: WorkspaceContext, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
if (config === undefined) return undefined;
const response = await config.read();
return loadEffectiveProjectPathAccess(context.project.path, response.effectiveConfig);
}
export async function pathAccessForCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
if (config === undefined) return undefined;
const response = await config.read();
const projectPath = await projectPathForWorkspaceCwd(cwd, projects, workspaces);
if (projectPath === undefined) return response.effectiveConfig.pathAccess;
return loadEffectiveProjectPathAccess(projectPath, response.effectiveConfig);
}
async function projectPathForWorkspaceCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService): Promise<string | undefined> {
for (const project of await projects.list()) {
if (cwdPathsEqual(project.path, cwd)) return project.path;
if ((await workspaces.list(project)).some((workspace) => cwdPathsEqual(workspace.path, cwd))) return project.path;
}
return undefined;
}
@@ -50,6 +50,23 @@ describe("readWorkspaceFile", () => {
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("reads allowed absolute files outside the workspace", async () => {
const root = await tempWorkspace();
const external = await tempWorkspace();
await writeFile(join(external, "README.md"), "external docs\n");
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
expect(file).toMatchObject({
path: join(external, "README.md"),
language: "markdown",
content: "external docs\n",
truncated: false,
binary: false,
});
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
});
it("detects binary files and omits binary content", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
+7 -6
View File
@@ -1,23 +1,24 @@
import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, PiWebPathAccessConfig, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
import { imageMimeTypeForPath } from "./imagePreviewService.js";
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
const MAX_BYTES = 512 * 1024;
export async function readWorkspaceFile(rootPath: string, path: string | undefined): Promise<FileContentResponse> {
export async function readWorkspaceFile(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileContentResponse> {
if (path === undefined || path === "") throw new Error("path query parameter is required");
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
const s = await stat(target);
if (!s.isFile()) throw new Error("Path is not a file");
const bytesToRead = Math.min(s.size, MAX_BYTES);
const buffer = await readFilePrefix(target, bytesToRead);
const media = mediaForPath(relativePath);
const media = mediaForPath(displayPath);
const binary = media.mediaType === "image" || isProbablyBinary(buffer);
return {
path: relativePath,
...languageForPath(relativePath),
path: displayPath,
...languageForPath(displayPath),
...media,
encoding: "utf8",
size: s.size,
+213 -4
View File
@@ -1,8 +1,8 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
import { listFileSuggestions, listPathSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
const temporaryRoots: string[] = [];
@@ -12,6 +12,26 @@ async function tempWorkspace(): Promise<string> {
return root;
}
function fzfRecords(input: string | Buffer | undefined): string[] {
if (typeof input === "string") return input.split("\0").filter(Boolean);
if (Buffer.isBuffer(input)) return input.toString("utf8").split("\0").filter(Boolean);
return [];
}
async function trySymlink(target: string, path: string): Promise<boolean> {
try {
await symlink(target, path, "dir");
return true;
} catch (error) {
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
throw error;
}
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
@@ -86,6 +106,67 @@ describe("file suggestions", () => {
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
});
it("uses fzf to filter and rank file suggestions after candidates are gathered", async () => {
const fzfInputs: string[][] = [];
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/server/app.ts\0scripts/start.ts\0docs/reference.md\0" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
fzf: (file, args, options) => {
expect(file).toBe("fzf");
expect(args).toEqual(["--filter", "st", "--read0", "--print0"]);
fzfInputs.push(fzfRecords(options.input));
return Promise.resolve({ stdout: "scripts/start.ts\0src/server/app.ts\0" });
},
};
await expect(listFileSuggestions("/repo", "st", { scope: "tracked" }, deps)).resolves.toEqual([
{ path: "scripts/start.ts", kind: "tracked" },
{ path: "src/server/app.ts", kind: "tracked" },
]);
expect(fzfInputs).toEqual([[
"src/",
"src/server/",
"src/server/app.ts",
"scripts/",
"scripts/start.ts",
"docs/",
"docs/reference.md",
]]);
});
it("falls back to TypeScript file ranking when fzf fails", async () => {
let fzfCalls = 0;
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "klingit-go/cli/cmd/dev/main.go\0MD PRojects here.md\0" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
fzf: () => {
fzfCalls += 1;
return Promise.reject(Object.assign(new Error("spawn fzf ENOENT"), { code: "ENOENT" }));
},
};
const suggestions = await listFileSuggestions("/repo", "MD", { scope: "tracked" }, deps);
expect(fzfCalls).toBe(1);
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
});
it("treats an fzf no-match exit as an empty filtered result", async () => {
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
fzf: () => Promise.reject(Object.assign(new Error("no match"), { exitCode: 1 })),
};
await expect(listFileSuggestions("/repo", "app", { scope: "tracked" }, deps)).resolves.toEqual([]);
});
it("preserves git filenames without trimming whitespace", async () => {
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
@@ -120,4 +201,132 @@ describe("file suggestions", () => {
{ path: "src/app.ts", kind: "other" },
]);
});
it("uses allowed roots for absolute-ish file suggestion queries", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
await mkdir(workspace);
await mkdir(external);
await writeFile(join(external, "sdk.md"), "external sdk\n");
await expect(listFileSuggestions(workspace, join(external, "s"), { pathAccess: { allowedPaths: [external] } })).resolves.toEqual([
{ path: join(external, "sdk.md"), kind: "other" },
]);
});
it("skips absolute-ish suggestions that would escape an allowed root through symlinks", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
const secret = join(root, "secret");
await mkdir(workspace);
await mkdir(external);
await mkdir(secret);
await writeFile(join(external, "sdk.md"), "external sdk\n");
await writeFile(join(secret, "token.txt"), "secret\n");
if (!await trySymlink(secret, join(external, "escape"))) return;
await expect(listPathSuggestions(workspace, `${external}/`, { allowedPaths: [external] })).resolves.toEqual([
{ path: join(external, "sdk.md"), kind: "other" },
]);
});
it("keeps tilde-prefixed allowed-root suggestions matchable by fzf", async () => {
const workspace = await tempWorkspace();
const homeEntry = await mkdtemp(join(homedir(), ".pi-web-files-"));
temporaryRoots.push(homeEntry);
const expectedPath = `~/${basename(homeEntry)}/`;
const deps: FileSuggestionDependencies = {
fzf: (file, args, options) => {
expect(file).toBe("fzf");
expect(args).toEqual(["--filter", "~/", "--read0", "--print0"]);
expect(fzfRecords(options.input)).toContain(expectedPath);
return Promise.resolve({ stdout: `${expectedPath}\0` });
},
};
await expect(listFileSuggestions(workspace, "~/", { pathAccess: { allowedPaths: ["~/"] } }, deps)).resolves.toEqual([
{ path: expectedPath, kind: "other" },
]);
});
it("keeps normal file suggestions workspace-local even when allowed roots are configured", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
await mkdir(workspace);
await mkdir(external);
await writeFile(join(external, "sdk.md"), "external sdk\n");
const deps: FileSuggestionDependencies = {
execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })),
};
await expect(listFileSuggestions(workspace, "sdk", { scope: "all", pathAccess: { allowedPaths: [external] } }, deps)).resolves.toEqual([]);
});
it("keeps relative path suggestions workspace-local and skips symlink escapes", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const outside = join(root, "outside");
await mkdir(workspace);
await mkdir(outside);
await writeFile(join(workspace, "local.md"), "local\n");
await writeFile(join(outside, "outside.txt"), "outside\n");
await expect(listPathSuggestions(workspace, "../out")).resolves.toEqual([]);
if (!await trySymlink(outside, join(workspace, "link"))) return;
await expect(listPathSuggestions(workspace, "link/")).resolves.toEqual([]);
await expect(listPathSuggestions(workspace, "")).resolves.toEqual([{ path: "local.md", kind: "other" }]);
});
it("uses fzf to filter path suggestions after directory candidates are gathered", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "scripts"));
await mkdir(join(root, "src"));
await writeFile(join(root, "notes.md"), "notes\n");
const deps: FileSuggestionDependencies = {
fzf: (file, args, options) => {
expect(file).toBe("fzf");
expect(args).toEqual(["--filter", "sc", "--read0", "--print0"]);
expect(fzfRecords(options.input)).toEqual(["scripts/", "src/", "notes.md"]);
return Promise.resolve({ stdout: "../secret\0scripts/\0" });
},
};
await expect(listPathSuggestions(root, "sc", undefined, deps)).resolves.toEqual([
{ path: "scripts/", kind: "other" },
]);
});
it("falls back to path-prefix ordering when fzf fails", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "scripts"));
await mkdir(join(root, "src"));
await writeFile(join(root, "server.md"), "server\n");
const deps: FileSuggestionDependencies = {
fzf: () => Promise.reject(Object.assign(new Error("fzf failed"), { exitCode: 2 })),
};
await expect(listPathSuggestions(root, "s", undefined, deps)).resolves.toEqual([
{ path: "scripts/", kind: "other" },
{ path: "src/", kind: "other" },
{ path: "server.md", kind: "other" },
]);
});
it("suggests configured allowed roots without reading parent directories", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
await mkdir(workspace);
await mkdir(external);
await expect(listPathSuggestions(workspace, external.slice(0, -4), { allowedPaths: [external] })).resolves.toEqual([
{ path: `${external}/`, kind: "other" },
]);
});
});
+348 -34
View File
@@ -1,19 +1,36 @@
import { execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, sep, win32 } from "node:path";
import { promisify } from "node:util";
import { sanitizedGitEnv } from "../git/gitEnv.js";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import type { ClientFileSuggestion } from "../types.js";
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, type PathAccessPolicy } from "./pathAccessPolicy.js";
const execFileAsync = promisify(execFile);
const commandMaxBuffer = 1024 * 1024 * 8;
const maxFilesystemFallbackPaths = 20_000;
const maxFileSuggestions = 80;
interface ExecFileOptions {
interface CommandRunnerOptions {
cwd: string;
maxBuffer: number;
env?: NodeJS.ProcessEnv;
input?: string | Buffer;
}
type CommandRunner = (file: string, args: string[], options: CommandRunnerOptions) => Promise<{ stdout: string }>;
class CommandExitError extends Error {
readonly exitCode?: number;
constructor(file: string, code: number | null, stderr: string) {
const codeText = code === null ? "unknown" : String(code);
super(`${file} exited with code ${codeText}${stderr === "" ? "" : `: ${stderr}`}`);
this.name = "CommandExitError";
if (code !== null) this.exitCode = code;
}
}
export type FileSuggestionScope = "tracked" | "all";
@@ -21,56 +38,221 @@ export type FileSuggestionScope = "tracked" | "all";
export interface FileSuggestionOptions {
kind?: ClientFileSuggestion["kind"] | undefined;
scope?: FileSuggestionScope | undefined;
pathAccess?: PiWebPathAccessConfig | undefined;
}
export interface FileSuggestionDependencies {
execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>;
execFile?: CommandRunner;
fzf?: CommandRunner;
}
export function isAbsoluteishFileSuggestionQuery(query = ""): boolean {
return isAbsoluteishPath(fileQueryText(query));
}
export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
const queryText = fileQueryText(query);
if (isAbsoluteishFileSuggestionQuery(query)) {
return (await listPathSuggestions(cwd, queryText, options.pathAccess, deps))
.filter((file) => options.kind === undefined || file.kind === options.kind)
.slice(0, maxFileSuggestions);
}
const normalizedQuery = normalizeFileQuery(query);
const exec = deps.execFile ?? execFileAsync;
const files = await listFilesForScope(cwd, options.scope, exec);
return rankFileSuggestions(
const command = deps.execFile ?? runCommand;
const files = await listFilesForScope(cwd, options.scope, command);
return (await rankFileSuggestionsWithOptionalFzf(
cwd,
files.filter((file) => options.kind === undefined || file.kind === options.kind),
normalizedQuery,
).slice(0, maxFileSuggestions);
fzfRunnerForDependencies(deps),
)).slice(0, maxFileSuggestions);
}
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> {
const normalizedPrefix = prefix.replace(/^@/, "").replace(/\\/g, "/");
export async function listPathSuggestions(cwd: string, prefix = "", pathAccess?: PiWebPathAccessConfig, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
const query = fileQueryText(prefix);
const fzf = fzfRunnerForDependencies(deps);
if (isAbsoluteishPath(query)) return listAllowedPathSuggestions(cwd, query, pathAccess, fzf);
const normalizedPrefix = query.replace(/\\/g, "/");
const directoryPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix : dirname(normalizedPrefix) === "." ? "" : `${dirname(normalizedPrefix)}/`;
const searchPrefix = normalizedPrefix.endsWith("/") ? "" : basename(normalizedPrefix);
const entries = await readdir(join(cwd, directoryPrefix), { withFileTypes: true });
const suggestions: ClientFileSuggestion[] = [];
for (const entry of entries) {
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
let isDirectory = entry.isDirectory();
if (!isDirectory && entry.isSymbolicLink()) {
try {
isDirectory = (await stat(join(cwd, directoryPrefix, entry.name))).isDirectory();
} catch {
isDirectory = false;
}
}
suggestions.push({ path: `${directoryPrefix}${entry.name}${isDirectory ? "/" : ""}`, kind: "other" });
}
return suggestions
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
.slice(0, 80);
const candidates = await listDirectoryEntrySuggestions(cwd, directoryPrefix);
return (await rankPathSuggestionsWithOptionalFzf(
cwd,
candidates,
searchPrefix,
() => prefixPathSuggestions(candidates, searchPrefix),
fzf,
)).slice(0, maxFileSuggestions);
}
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
async function listDirectoryEntrySuggestions(cwd: string, directoryPrefix: string): Promise<ClientFileSuggestion[]> {
const policy = await createPathAccessPolicy(cwd, undefined);
const resolved = await resolveWorkspaceSuggestionDirectory(policy, directoryPrefix);
if (resolved === undefined) return [];
const entries = await readdir(resolved.target, { withFileTypes: true });
const suggestions: ClientFileSuggestion[] = [];
for (const entry of entries.sort(compareDirectoryEntries)) {
const childPath = appendRequestPath(resolved.displayPath, entry.name);
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
if (isDirectory === undefined) continue;
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
}
return suggestions;
}
async function resolveWorkspaceSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
try {
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
return resolved.kind === "workspace" ? resolved : undefined;
} catch (error) {
if (isPathSuggestionMiss(error)) return undefined;
throw error;
}
}
async function listAllowedPathSuggestions(cwd: string, query: string, pathAccess: PiWebPathAccessConfig | undefined, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
const policy = await createPathAccessPolicy(cwd, pathAccess);
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
const rootCandidates = allowedRootSuggestionCandidates(policy, query);
const directoryCandidates = await listAllowedDirectoryEntryCandidates(policy, query);
return (await rankPathSuggestionsWithOptionalFzf(
cwd,
mergeSuggestions(rootCandidates, directoryCandidates),
query,
() => mergeSuggestions(allowedRootPrefixSuggestions(policy, query), prefixPathSuggestions(directoryCandidates, pathSuggestionPrefix(query).searchPrefix)).sort(compareFileSuggestions),
fzf,
)).slice(0, maxFileSuggestions);
}
function allowedRootPrefixSuggestions(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
return allowedRootSuggestionCandidates(policy, query).filter((suggestion) => pathStartsWith(suggestion.path, query));
}
function allowedRootSuggestionCandidates(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
const suggestions: ClientFileSuggestion[] = [];
const seen = new Set<string>();
for (const root of policy.allowedRoots) {
for (const displayPath of allowedRootDisplayPaths(root.path, query)) {
const path = ensureTrailingPathSeparator(displayPath);
if (hasTrailingPathSeparator(query) && stripTrailingPathSeparators(path) === stripTrailingPathSeparators(query)) continue;
if (seen.has(path)) continue;
seen.add(path);
suggestions.push({ path, kind: "other" });
}
}
return suggestions;
}
function allowedRootDisplayPaths(rootPath: string, query: string): string[] {
if (query !== "~" && !query.startsWith("~/") && !query.startsWith("~\\")) return [rootPath];
const home = homedir();
const homeRelativePath = relative(home, rootPath);
if (!isInsideRelativePath(homeRelativePath)) return [rootPath];
const separator = query.startsWith("~\\") ? "\\" : "/";
const tildePath = homeRelativePath === "" ? "~" : `~${separator}${homeRelativePath.split(/[\\/]+/u).join(separator)}`;
return [tildePath, rootPath];
}
async function listAllowedDirectoryEntryCandidates(policy: PathAccessPolicy, query: string): Promise<ClientFileSuggestion[]> {
const { directoryPrefix } = pathSuggestionPrefix(query);
const resolved = await resolveSuggestionDirectory(policy, directoryPrefix);
if (resolved === undefined) return [];
const entries = await readdir(resolved.target, { withFileTypes: true });
const suggestions: ClientFileSuggestion[] = [];
for (const entry of entries.sort(compareDirectoryEntries)) {
const childPath = appendRequestPath(directoryPrefix, entry.name);
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
if (isDirectory === undefined) continue;
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
}
return suggestions;
}
async function resolveSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
try {
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
return resolved.kind === "allowed" ? resolved : undefined;
} catch (error) {
if (isPathSuggestionMiss(error)) return undefined;
throw error;
}
}
async function suggestionEntryIsDirectory(policy: PathAccessPolicy, childPath: string, entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): Promise<boolean | undefined> {
if (!entry.isSymbolicLink()) return entry.isDirectory();
try {
const resolved = await resolvePathAccessTarget(policy, childPath);
const result = await stat(resolved.target);
if (result.isDirectory()) return true;
if (result.isFile()) return false;
return undefined;
} catch (error) {
if (isPathSuggestionMiss(error)) return undefined;
throw error;
}
}
function pathSuggestionPrefix(query: string): { directoryPrefix: string; searchPrefix: string } {
if (query === "~" || hasTrailingPathSeparator(query)) return { directoryPrefix: query, searchPrefix: "" };
const directory = dirname(query);
return { directoryPrefix: directory === "." ? "" : directory, searchPrefix: basename(query) };
}
function appendRequestPath(base: string, name: string): string {
if (base === "") return name;
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
if (hasTrailingPathSeparator(base)) return `${base}${name}`;
return `${base}/${name}`;
}
function pathStartsWith(path: string, query: string): boolean {
return path.toLowerCase().startsWith(query.toLowerCase());
}
function ensureTrailingPathSeparator(path: string): string {
return hasTrailingPathSeparator(path) ? path : `${path}/`;
}
function hasTrailingPathSeparator(path: string): boolean {
return path.endsWith("/") || path.endsWith("\\");
}
function stripTrailingPathSeparators(path: string): string {
let end = path.length;
while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
return path.slice(0, end);
}
function isInsideRelativePath(path: string): boolean {
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
}
function isPathSuggestionMiss(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return error.message === "Path is outside allowed paths"
|| error.message === "Path does not exist"
|| error.message === "Path traversal is not allowed"
|| error.message === "Path escapes workspace"
|| error.message.startsWith("Path is not absolute:");
}
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
if (scope === "all") return listAllFiles(cwd, exec);
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
}
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked");
}
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
const [tracked, untracked] = await Promise.all([
git(cwd, ["ls-files", "-z"], exec),
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
@@ -81,7 +263,7 @@ async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
];
}
async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
async function listAllFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
const [gitFiles, plainFiles] = await Promise.all([
listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []),
listPlainFiles(cwd, exec, true),
@@ -89,7 +271,7 @@ async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
return mergeSuggestions(gitFiles, plainFiles);
}
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
async function listPlainFiles(cwd: string, exec: CommandRunner, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
try {
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"];
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
@@ -138,13 +320,75 @@ async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink:
}
}
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
async function git(cwd: string, args: string[], exec: CommandRunner): Promise<string> {
const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
return stdout;
}
function normalizeFileQuery(query: string): string {
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "").toLowerCase();
return fileQueryText(query).toLowerCase();
}
function fileQueryText(query: string): string {
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "");
}
function fzfRunnerForDependencies(deps: FileSuggestionDependencies): CommandRunner | undefined {
return deps.fzf ?? (deps.execFile === undefined ? runCommand : undefined);
}
async function rankFileSuggestionsWithOptionalFzf(cwd: string, files: ClientFileSuggestion[], normalizedQuery: string, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
return rankSuggestionsWithOptionalFzf(cwd, files, normalizedQuery, () => rankFileSuggestions(files, normalizedQuery), fzf);
}
async function rankPathSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
return rankSuggestionsWithOptionalFzf(cwd, candidates, query, fallback, fzf);
}
async function rankSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
if (fzf === undefined || query === "" || candidates.length === 0) return fallback();
try {
return await fzfFilterSuggestions(cwd, candidates, query, fzf);
} catch {
return fallback();
}
}
async function fzfFilterSuggestions(cwd: string, candidates: ClientFileSuggestion[], query: string, fzf: CommandRunner): Promise<ClientFileSuggestion[]> {
const byPath = new Map(candidates.map((suggestion) => [suggestion.path, suggestion]));
const { stdout } = await runFzf(cwd, [...byPath.keys()], query, fzf);
const suggestions: ClientFileSuggestion[] = [];
const seen = new Set<string>();
for (const path of nulRecords(stdout)) {
const suggestion = byPath.get(path);
if (suggestion === undefined || seen.has(suggestion.path)) continue;
seen.add(suggestion.path);
suggestions.push(suggestion);
}
if (suggestions.length === 0 && stdout !== "") throw new Error("fzf returned paths outside the gathered suggestions");
return suggestions;
}
async function runFzf(cwd: string, candidates: string[], query: string, fzf: CommandRunner): Promise<{ stdout: string }> {
try {
return await fzf("fzf", ["--filter", query, "--read0", "--print0"], { cwd, maxBuffer: commandMaxBuffer, input: `${candidates.join("\0")}\0` });
} catch (error) {
if (errorExitCode(error) === 1) return { stdout: "" };
throw error;
}
}
function prefixPathSuggestions(candidates: ClientFileSuggestion[], searchPrefix: string): ClientFileSuggestion[] {
const normalizedSearchPrefix = searchPrefix.toLowerCase();
return candidates
.filter((suggestion) => pathSuggestionName(suggestion.path).toLowerCase().startsWith(normalizedSearchPrefix))
.sort(compareFileSuggestions);
}
function pathSuggestionName(path: string): string {
const stripped = stripTrailingPathSeparators(path);
return stripped.split(/[\\/]+/u).filter(Boolean).at(-1) ?? stripped;
}
function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] {
@@ -197,6 +441,10 @@ function compareFileSuggestions(a: ClientFileSuggestion, b: ClientFileSuggestion
return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path);
}
function compareDirectoryEntries(a: { isDirectory(): boolean; name: string }, b: { isDirectory(): boolean; name: string }): number {
return Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name);
}
function kindRank(kind: ClientFileSuggestion["kind"]): number {
switch (kind) {
case "tracked": return 0;
@@ -209,6 +457,72 @@ function pathDepth(path: string): number {
return path.split("/").filter(Boolean).length;
}
async function runCommand(file: string, args: string[], options: CommandRunnerOptions): Promise<{ stdout: string }> {
const { input, ...execOptions } = options;
if (input === undefined) return execFileAsync(file, args, execOptions);
return runCommandWithInput(file, args, { ...execOptions, input });
}
async function runCommandWithInput(file: string, args: string[], options: CommandRunnerOptions & { input: string | Buffer }): Promise<{ stdout: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(file, args, {
cwd: options.cwd,
...(options.env === undefined ? {} : { env: options.env }),
stdio: ["pipe", "pipe", "pipe"],
});
let settled = false;
let stdout = "";
let stderr = "";
let stdoutBytes = 0;
let stderrBytes = 0;
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
child.stdout.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes > options.maxBuffer) {
child.kill();
rejectOnce(new Error(`${file} stdout exceeded maxBuffer`));
return;
}
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes > options.maxBuffer) {
child.kill();
rejectOnce(new Error(`${file} stderr exceeded maxBuffer`));
return;
}
stderr += chunk.toString("utf8");
});
child.on("error", rejectOnce);
child.on("close", (code) => {
if (settled) return;
settled = true;
if (code === 0) {
resolve({ stdout });
return;
}
reject(new CommandExitError(file, code, stderr));
});
child.stdin.on("error", () => undefined);
child.stdin.end(options.input);
});
}
function errorExitCode(error: unknown): number | undefined {
if (error instanceof CommandExitError) return error.exitCode;
if (!(error instanceof Error)) return undefined;
if ("exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
if ("code" in error && typeof error.code === "number") return error.code;
return undefined;
}
function textLines(text: string): string[] {
return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== "");
}
@@ -55,6 +55,22 @@ describe("listWorkspaceTree", () => {
expect(tree.entries[0]).toMatchObject({ name: "main.ts", path: "src/client/main.ts", type: "file" });
});
it("lists allowed absolute directories outside the workspace", async () => {
const root = await tempWorkspace();
const external = await tempWorkspace();
await mkdir(join(external, "docs"));
await writeFile(join(external, "sdk.ts"), "export {};\n");
const tree = await listWorkspaceTree(root, external, { allowedPaths: [external] });
expect(tree.path).toBe(external);
expect(tree.entries.map((entry) => [entry.name, entry.path, entry.type])).toEqual([
["docs", join(external, "docs"), "directory"],
["sdk.ts", join(external, "sdk.ts"), "file"],
]);
await expect(listWorkspaceTree(root, external)).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects non-directory targets and unsafe paths", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "file.txt"), "content");
+15 -8
View File
@@ -1,12 +1,12 @@
import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path";
import type { FileTreeEntry, FileTreeResponse } from "../../shared/apiTypes.js";
import { resolveInsideWorkspace } from "./pathSafety.js";
import { isAbsolute, join, win32 } from "node:path";
import type { FileTreeEntry, FileTreeResponse, PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
const MAX_ENTRIES = 1000;
export async function listWorkspaceTree(rootPath: string, path: string | undefined): Promise<FileTreeResponse> {
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
export async function listWorkspaceTree(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileTreeResponse> {
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
const stat = await lstat(target);
if (!stat.isDirectory()) throw new Error("Path is not a directory");
@@ -18,11 +18,18 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
const selected = sorted.slice(0, MAX_ENTRIES);
const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => {
const absolute = join(target, entry.name);
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`;
const childPath = appendRequestPath(displayPath, entry.name);
const childStat = await lstat(absolute);
const type: FileTreeEntry["type"] = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file";
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
return { name: entry.name, path: childPath, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
}));
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
return { path: displayPath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
}
function appendRequestPath(base: string, name: string): string {
if (base === "") return name;
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
if (base.endsWith("/") || base.endsWith("\\")) return `${base}${name}`;
return `${base}/${name}`;
}
+6 -5
View File
@@ -1,8 +1,9 @@
import { createReadStream, type ReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname } from "node:path";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../shared/workspaceFiles.js";
import { resolveInsideWorkspace } from "./pathSafety.js";
import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
const IMAGE_MIME_TYPES: Record<string, string | undefined> = {
".avif": "image/avif",
@@ -28,16 +29,16 @@ export function imageMimeTypeForPath(path: string): string | undefined {
return IMAGE_MIME_TYPES[extname(path).toLowerCase()];
}
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined): Promise<WorkspaceImagePreview> {
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<WorkspaceImagePreview> {
if (path === undefined || path === "") throw new Error("path query parameter is required");
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
const s = await stat(target);
if (!s.isFile()) throw new Error("Path is not a file");
const mimeType = imageMimeTypeForPath(relativePath);
const mimeType = imageMimeTypeForPath(displayPath);
if (mimeType === undefined) throw new Error("Image preview is not supported for this file type");
if (s.size > MAX_IMAGE_PREVIEW_BYTES) throw new Error(`Image is too large to preview (limit ${MAX_IMAGE_PREVIEW_LABEL})`);
return {
path: relativePath,
path: displayPath,
mimeType,
size: s.size,
modifiedAt: s.mtime.toISOString(),
@@ -0,0 +1,139 @@
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
const roots: string[] = [];
async function tempRoot(prefix = "pi-web-path-access-"): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
roots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("path access policy", () => {
it("keeps relative requests workspace-local and identifies absolute-ish paths", async () => {
const workspace = await tempRoot();
await mkdir(join(workspace, "src"));
await writeFile(join(workspace, "src", "main.ts"), "export {};\n");
const policy = await createPathAccessPolicy(workspace, undefined);
await expect(resolvePathAccessTarget(policy, "./src//main.ts")).resolves.toMatchObject({
kind: "workspace",
root: await realpath(workspace),
target: await realpath(join(workspace, "src", "main.ts")),
displayPath: "src/main.ts",
});
expect(isAbsoluteishPath("src/main.ts")).toBe(false);
expect(isAbsoluteishPath("/tmp/file.txt")).toBe(true);
expect(isAbsoluteishPath("~/SDKs/readme.md")).toBe(true);
expect(isAbsoluteishPath("C:\\Users\\dev\\file.txt")).toBe(true);
expect(isAbsoluteishPath("\\\\server\\share\\file.txt")).toBe(true);
await expect(resolvePathAccessTarget(policy, join(workspace, "src", "main.ts"))).rejects.toThrow("Absolute paths are not allowed");
});
it("expands and canonicalizes allowed roots before resolving absolute targets", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const home = join(root, "home");
const sdk = join(home, "SDKs");
await mkdir(workspace);
await mkdir(sdk, { recursive: true });
await writeFile(join(sdk, "readme.md"), "sdk docs\n");
const policy = await createPathAccessPolicy(workspace, { allowedPaths: ["~/SDKs"] }, { homeDir: home });
expect(policy.allowedRoots).toEqual([{ source: "~/SDKs", path: sdk, realPath: await realpath(sdk) }]);
await expect(resolvePathAccessTarget(policy, "~/SDKs/readme.md", { homeDir: home })).resolves.toMatchObject({
kind: "allowed",
root: await realpath(sdk),
target: await realpath(join(sdk, "readme.md")),
displayPath: join(sdk, "readme.md"),
});
});
it("validates configured roots as existing directories", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const fileRoot = join(root, "not-a-directory.txt");
await mkdir(workspace);
await writeFile(fileRoot, "not a directory");
await expect(createPathAccessPolicy(workspace, { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
await expect(createPathAccessPolicy(workspace, { allowedPaths: [fileRoot] })).rejects.toThrow("must be a directory");
await expect(createPathAccessPolicy(workspace, { allowedPaths: ["relative/root"] })).rejects.toThrow("Allowed path must be absolute or start with ~");
});
it("does not validate stale allowed roots for workspace-relative requests", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
await mkdir(workspace);
await writeFile(join(workspace, "local.txt"), "local\n");
await expect(resolveWorkspacePathAccessTarget(workspace, "local.txt", { allowedPaths: [join(root, "missing")] })).resolves.toMatchObject({
kind: "workspace",
target: await realpath(join(workspace, "local.txt")),
displayPath: "local.txt",
});
await expect(resolveWorkspacePathAccessTarget(workspace, join(workspace, "local.txt"), { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
});
it("denies absolute targets outside allowed roots and through symlink escapes", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const allowed = join(root, "allowed");
const secret = join(root, "secret");
await mkdir(workspace);
await mkdir(allowed);
await mkdir(secret);
await writeFile(join(secret, "token.txt"), "secret\n");
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [allowed] });
await expect(resolvePathAccessTarget(policy, join(secret, "token.txt"))).rejects.toThrow("Path is outside allowed paths");
if (await trySymlink(secret, join(allowed, "escape"))) {
await expect(resolvePathAccessTarget(policy, join(allowed, "escape", "token.txt"))).rejects.toThrow("Path is outside allowed paths");
}
});
it("allows roots configured through symlinks by checking canonical paths", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const realAllowed = join(root, "real-allowed");
const linkedAllowed = join(root, "linked-allowed");
await mkdir(workspace);
await mkdir(realAllowed);
await writeFile(join(realAllowed, "data.txt"), "allowed\n");
if (!await trySymlink(realAllowed, linkedAllowed)) return;
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [linkedAllowed] });
expect(policy.allowedRoots).toEqual([{ source: linkedAllowed, path: linkedAllowed, realPath: await realpath(realAllowed) }]);
await expect(resolvePathAccessTarget(policy, join(linkedAllowed, "data.txt"))).resolves.toMatchObject({
kind: "allowed",
root: await realpath(realAllowed),
target: await realpath(join(realAllowed, "data.txt")),
displayPath: join(linkedAllowed, "data.txt"),
});
});
});
async function trySymlink(target: string, path: string): Promise<boolean> {
try {
await symlink(target, path, "dir");
return true;
} catch (error) {
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
throw error;
}
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+122
View File
@@ -0,0 +1,122 @@
import { realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, relative, resolve, sep, win32 } from "node:path";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import { normalizeRelativePath } from "./pathSafety.js";
export interface AllowedPathRoot {
/** Raw config value for diagnostics. */
source: string;
/** Host-absolute path after expanding ~ and normalizing syntax. */
path: string;
/** Canonical directory root used for containment checks. */
realPath: string;
}
export interface PathAccessPolicy {
workspaceRoot: string;
allowedRoots: AllowedPathRoot[];
}
export type PathAccessTargetKind = "workspace" | "allowed";
export interface ResolvedPathAccessTarget {
kind: PathAccessTargetKind;
/** Canonical root that granted access: workspace root or allowed root. */
root: string;
/** Canonical existing target path. */
target: string;
/** Requestable path returned to clients and used to build child paths. */
displayPath: string;
}
export interface PathAccessPolicyOptions {
homeDir?: string;
}
export async function createPathAccessPolicy(workspaceRootPath: string, pathAccess: PiWebPathAccessConfig | undefined, options: PathAccessPolicyOptions = {}): Promise<PathAccessPolicy> {
return {
workspaceRoot: await canonicalDirectory(workspaceRootPath, "Workspace path"),
allowedRoots: await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options),
};
}
export async function resolveWorkspacePathAccessTarget(rootPath: string, requestedPath: string | undefined, pathAccess?: PiWebPathAccessConfig, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
const request = requestedPath ?? "";
const workspaceRoot = await canonicalDirectory(rootPath, "Workspace path");
const allowedRoots = isAbsoluteishPath(request) ? await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options) : [];
return resolvePathAccessTarget({ workspaceRoot, allowedRoots }, requestedPath, options);
}
export async function resolvePathAccessTarget(policy: PathAccessPolicy, requestedPath: string | undefined, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
const request = requestedPath ?? "";
if (isAbsoluteishPath(request)) return resolveAllowedTarget(policy, request, options);
const displayPath = normalizeRelativePath(request);
const target = await canonicalExistingPath(resolve(policy.workspaceRoot, displayPath));
ensureInside(policy.workspaceRoot, target, "Path escapes workspace");
return { kind: "workspace", root: policy.workspaceRoot, target, displayPath };
}
export function isAbsoluteishPath(path: string): boolean {
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || isAbsolute(path) || win32.isAbsolute(path);
}
async function resolveAllowedRoots(allowedPaths: readonly string[], options: PathAccessPolicyOptions): Promise<AllowedPathRoot[]> {
const roots: AllowedPathRoot[] = [];
for (const source of allowedPaths) {
const expanded = expandAbsoluteishPath(source, options, `Allowed path must be absolute or start with ~: ${source}`);
const realPath = await canonicalDirectory(expanded, `Allowed path ${source}`);
if (roots.some((root) => root.realPath === realPath)) continue;
roots.push({ source, path: expanded, realPath });
}
return roots;
}
async function resolveAllowedTarget(policy: PathAccessPolicy, request: string, options: PathAccessPolicyOptions): Promise<ResolvedPathAccessTarget> {
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
const displayPath = expandAbsoluteishPath(request, options, `Path is not absolute: ${request}`);
const target = await canonicalExistingPath(displayPath);
const root = policy.allowedRoots.find((allowedRoot) => isInsideOrSame(allowedRoot.realPath, target));
if (root === undefined) throw new Error("Path is outside allowed paths");
return { kind: "allowed", root: root.realPath, target, displayPath };
}
function expandAbsoluteishPath(path: string, options: PathAccessPolicyOptions, relativeMessage: string): string {
const home = options.homeDir ?? homedir();
if (path === "~") return home;
if (path.startsWith("~/") || path.startsWith("~\\")) return resolve(home, path.slice(2));
if (isAbsolute(path)) return resolve(path);
if (win32.isAbsolute(path)) throw new Error(`Absolute path is not valid on this host: ${path}`);
throw new Error(relativeMessage);
}
async function canonicalDirectory(path: string, label: string): Promise<string> {
const canonical = await canonicalExistingPath(path, `${label} does not exist`);
const result = await stat(canonical);
if (!result.isDirectory()) throw new Error(`${label} must be a directory`);
return canonical;
}
async function canonicalExistingPath(path: string, missingMessage = "Path does not exist"): Promise<string> {
try {
return await realpath(path);
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error(missingMessage, { cause: error });
throw error;
}
}
function ensureInside(root: string, target: string, message: string): void {
if (!isInsideOrSame(root, target)) throw new Error(message);
}
function isInsideOrSame(root: string, target: string): boolean {
const rel = relative(root, target);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
@@ -0,0 +1,74 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
let tempDir: string;
let projectPath: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-project-config-test-"));
projectPath = join(tempDir, "project");
await mkdir(projectPath, { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("project PI WEB config", () => {
it("returns an empty config when the project-local config is absent", async () => {
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
exists: false,
config: {},
});
});
it("loads project-local path access config", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } });
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
exists: true,
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } },
});
});
it("rejects unsupported project config versions", async () => {
await writeProjectConfig({ version: 2 });
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB project config version must be 1");
});
it("reuses PI WEB path access schema validation", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: [""] } });
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
});
it("merges global and project path access in order", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
await expect(loadEffectiveProjectPathAccess(projectPath, { pathAccess: { allowedPaths: ["/global-sdk", "/shared"] } })).resolves.toEqual({
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"],
});
});
});
describe("mergePathAccessConfigs", () => {
it("returns undefined when no roots are configured", () => {
expect(mergePathAccessConfigs(undefined, {})).toBeUndefined();
});
it("deduplicates configured roots", () => {
expect(mergePathAccessConfigs({ allowedPaths: ["/a", "/b"] }, { allowedPaths: ["/b", "/c"] })).toEqual({ allowedPaths: ["/a", "/b", "/c"] });
});
});
async function writeProjectConfig(value: unknown): Promise<void> {
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
@@ -0,0 +1,71 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { parsePathAccessConfig, type PiWebConfig } from "../../config.js";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
export interface ProjectPiWebConfig {
version?: 1;
pathAccess?: PiWebPathAccessConfig;
}
export interface LoadedProjectPiWebConfig {
path: string;
exists: boolean;
config: ProjectPiWebConfig;
}
export async function loadProjectPiWebConfig(projectPath: string): Promise<LoadedProjectPiWebConfig> {
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
try {
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRecord(parsed)) throw new Error(`PI WEB project config must be a JSON object: ${path}`);
return { path, exists: true, config: parseProjectPiWebConfig(parsed, path) };
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) return { path, exists: false, config: {} };
throw error;
}
}
export async function loadEffectiveProjectPathAccess(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebPathAccessConfig | undefined> {
const projectConfig = await loadProjectPiWebConfig(projectPath);
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess);
}
export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
return allowedPaths.length === 0 ? undefined : { allowedPaths };
}
function parseProjectPiWebConfig(value: Record<string, unknown>, path: string): ProjectPiWebConfig {
const version = value["version"];
return {
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
};
}
function parseProjectConfigVersion(value: unknown, path: string): 1 {
if (value !== 1) throw new Error(`PI WEB project config version must be 1: ${path}`);
return 1;
}
function dedupe(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+20 -1
View File
@@ -5,6 +5,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsDeleteArchived: "sessions.deleteArchived",
sessionsReload: "sessions.reload",
promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions",
} as const;
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
@@ -51,14 +52,29 @@ export interface PiWebPluginConfig {
[key: string]: unknown;
}
export interface PiWebPathAccessConfig {
allowedPaths?: string[];
}
export interface PiWebConfigValues {
host?: string;
port?: number;
allowedHosts?: string[] | true;
shortcuts?: PiWebShortcutConfig;
plugins?: PiWebPluginConfigMap;
/** External filesystem roots PI WEB may expose outside a workspace. */
pathAccess?: PiWebPathAccessConfig;
/** Maximum accepted HTTP request body size in bytes (uploads/attachments). */
maxUploadBytes?: number;
/** When true, LLMs can start new sessions via the spawn_session tool. */
spawnSessions?: boolean;
/**
* Beta: when true, LLMs can start tracked child sessions via the
* spawn_subsession / list_subsessions / check_subsession / read_subsession
* tools. Off by default
* while the capability stabilizes. Requires spawnSessions to be enabled.
*/
subsessions?: boolean;
}
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
@@ -80,6 +96,8 @@ export interface PiWebConfigEnvOverrides {
host: boolean;
port: boolean;
allowedHosts: boolean;
spawnSessions: boolean;
subsessions: boolean;
}
export interface PiWebConfigResponse {
@@ -527,7 +545,8 @@ export type SessionUiEvent =
| { type: "command.output"; level: "info" | "success" | "error"; message: string }
| { type: "session.error"; message: string }
| { type: "session.name"; sessionId: string; name?: string }
| { type: "session.created"; session: SessionInfo }
| { type: "pi.event"; eventType: string };
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" }>;
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" | "session.created" }>;
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;
+2 -1
View File
@@ -6,13 +6,14 @@ export type { PiWebCapability };
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[];
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
export function isPiWebCapability(value: unknown): value is PiWebCapability {

Some files were not shown because too many files have changed in this diff Show More