diff --git a/.changeset/fix-fish-doctor-version-check.md b/.changeset/fix-fish-doctor-version-check.md
new file mode 100644
index 0000000..8163c01
--- /dev/null
+++ b/.changeset/fix-fish-doctor-version-check.md
@@ -0,0 +1,9 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check
+wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`,
+which fish parses as a command substitution in command position and rejects
+(`command substitutions not allowed in command position`), producing a false
+negative. Emit fish's `begin; ...; end` grouping when the service shell is fish.
diff --git a/.changeset/fix-skill-read-duplicates.md b/.changeset/fix-skill-read-duplicates.md
deleted file mode 100644
index d12accd..0000000
--- a/.changeset/fix-skill-read-duplicates.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Prevent live skill-loading cards from duplicating when the finalized transcript groups multiple skill reads.
diff --git a/.changeset/fix-subsessions-setting-save.md b/.changeset/fix-subsessions-setting-save.md
deleted file mode 100644
index c1ef89a..0000000
--- a/.changeset/fix-subsessions-setting-save.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-Persist the Settings → Session daemon tracked subsessions toggle so it remains enabled after restart.
diff --git a/.changeset/fix-swedish-dead-key-terminal.md b/.changeset/fix-swedish-dead-key-terminal.md
deleted file mode 100644
index 903057c..0000000
--- a/.changeset/fix-swedish-dead-key-terminal.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-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.
diff --git a/.changeset/git-inline-diff-highlights.md b/.changeset/git-inline-diff-highlights.md
new file mode 100644
index 0000000..26e3890
--- /dev/null
+++ b/.changeset/git-inline-diff-highlights.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Highlight within-line changes in the Git diff viewer.
diff --git a/.changeset/manual-workspace-uploads.md b/.changeset/manual-workspace-uploads.md
new file mode 100644
index 0000000..033fb63
--- /dev/null
+++ b/.changeset/manual-workspace-uploads.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations.
diff --git a/.changeset/persist-subsession-links.md b/.changeset/persist-subsession-links.md
new file mode 100644
index 0000000..8199eaa
--- /dev/null
+++ b/.changeset/persist-subsession-links.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications.
diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md
new file mode 100644
index 0000000..5a3b3f3
--- /dev/null
+++ b/.changeset/plugin-api-completeness.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer.
diff --git a/.changeset/plugin-panel-prompt-context.md b/.changeset/plugin-panel-prompt-context.md
new file mode 100644
index 0000000..e7ee9c2
--- /dev/null
+++ b/.changeset/plugin-panel-prompt-context.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt.
diff --git a/.changeset/tracked-subsessions.md b/.changeset/tracked-subsessions.md
deleted file mode 100644
index b1012bd..0000000
--- a/.changeset/tracked-subsessions.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-"@jmfederico/pi-web": patch
----
-
-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.
diff --git a/AGENTS.md b/AGENTS.md
index ad93e79..c503ccf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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: `/.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`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a1e1b8..7617ac9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# @jmfederico/pi-web
+## 1.202606.6
+
+### Patch Changes
+
+- c479a0d: Fix the session daemon startup when PI WEB runs with compatible Pi packages that moved legacy provider registry exports to the Pi AI compatibility entrypoint.
+
+## 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
diff --git a/README.md b/README.md
index 3e7dda5..dba168a 100644
--- a/README.md
+++ b/README.md
@@ -1,165 +1,58 @@
-# PI WEB — web UI for Pi Coding Agent
+# PI WEB
[](https://github.com/jmfederico/pi-web/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/@jmfederico/pi-web)
[](package.json)
[](LICENSE)
-[](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)
-Website:
+**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 in real workspaces on your machine or server.**
+
+Run agents where your code, tools, credentials, and build caches live. Supervise them from any browser.
+
+Website and docs:

-**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 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.
+## Why PI WEB?
-
+Agentic development works better when the work environment is persistent.
-With PI WEB you can:
+PI WEB lets you:
-- launch and supervise multiple coding-agent sessions in parallel;
-- keep sessions running when your browser disconnects or the UI restarts;
-- organize agent work by project, workspace, branch, experiment, or review;
-- use git worktrees to isolate concurrent features and fixes;
-- chat with Pi Coding Agent through a realtime web UI;
-- move fluidly between laptop, phone, tablet, and desktop without moving the development environment;
-- turn any server, desktop, or remote dev box into an agent-first development hub.
+- keep Pi Coding Agent sessions alive after browser disconnects;
+- run agents inside real repositories and git worktrees;
+- supervise multiple sessions in parallel;
+- switch between laptop, phone, tablet, and desktop;
+- use a server, workstation, or remote dev box as your agent runtime;
+- manage projects, workspaces, files, terminals, sessions, and remote machines from one web UI.
-## Why use PI WEB?
+Your browser is the control surface. The work stays where it can keep running.
-Agentic development works best when agents are not trapped inside a single local terminal. They need stable environments, access to real repositories, and room to work across branches and tasks. Humans need the opposite: a clear place to supervise, redirect, review, and decide.
+## Quick start
-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.
+Requirements:
-### Is PI WEB a Pi web UI?
+- Node.js 22 or newer
+- npm
+- Pi Coding Agent configured for your user
+- git and the development tools your agents need
-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:
-
-```text
-Machine a local or remote PI WEB runtime endpoint
-Project a folder on that machine
-Workspace a git worktree, or the project folder for non-git projects
-Session a chat with Pi Coding Agent running inside a workspace
-```
-
-This maps naturally to real development work:
-
-- select the local machine or another registered PI WEB runtime;
-- add a project once on the selected machine;
-- use worktrees to separate branches, features, experiments, and reviews;
-- start one or more agent sessions inside each workspace;
-- leave sessions running even when the browser disconnects or the UI restarts.
-
-## Features
-
-- Add and list local or remote PI WEB machines from the action palette.
-- Proxy remote projects, workspaces, files, git state, sessions, and terminals through the currently opened PI WEB server.
-- Add and list server-side projects.
-- Discover git worktrees automatically with `git worktree list --porcelain`.
-- Support non-git folders as single-workspace projects.
-- Start, resume, archive, and restore Pi sessions per workspace.
-- Chat with Pi Coding Agent through realtime WebSocket events.
-- Keep active agent runtimes alive across browser disconnects and web/API restarts.
-- Explicitly stop or abort active session work.
-- View live session status: streaming, compaction, bash activity, token usage, cost, model, and context usage.
-- Send prompts, shell input, and supported commands through the Pi SDK path.
-- Reuse your existing Pi auth and model configuration from `~/.pi/agent`.
-- Extend the UI with trusted plugins that add actions, workspace panels, and workspace-label metadata. See [Plugin API](docs/plugins.md) for LLM-friendly plugin-building docs.
-
-## Architecture
-
-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
- │
- ▼
-Fastify Web/API process
- │ HTTP + WebSocket proxy
- ▼
-Session daemon
- │
- ▼
-Pi Coding Agent SDK
-```
-
-### Session daemon
-
-The session daemon owns active Pi session runtimes. It is intended to be long-lived so sessions can survive browser disconnects and web/API restarts.
-
-### Web/API/UI server
-
-The web process serves the API and browser UI. In development it can autoreload freely while active sessions continue running in the daemon.
-
-## State model
-
-PI WEB keeps its own state intentionally small:
-
-- Machines: `~/.pi-web/machines.json` stores only opt-in remote machine records; the local machine is synthesized.
-- Projects: `~/.pi-web/projects.json`
-- Workspaces: discovered from git worktrees, not stored
-- Sessions and chat history: Pi's default JSONL session storage on the selected machine
-- Active session runtimes and WebSockets: memory in each selected machine's session daemon
-
-## 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) 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.
-
-## Plugins
-
-PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed.
-
-The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module` plus optional `machineSpecific` metadata, and a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
-
-A useful prompt for AI agents:
-
-```text
-Build a PI WEB plugin for this project. Goal: .
-Before coding, read https://pi-web.dev/plugins and https://pi-web.dev/plugins.md.
-Create it under ~/.pi-web/plugins/ 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.
-```
-
-Manage discovered plugins in **Settings → Plugins** or with the top-level `plugins` config key. Plugins are enabled by default; set `plugins..enabled` to `false` and reload the browser tab to prevent PI WEB from importing that plugin.
-
-Reload the browser tab after adding or editing a plugin. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead of `~/.pi-web/plugins`. Check discovery with:
-
-```bash
-curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
-```
-
-See the full [Plugin API](docs/plugins.md) for contribution types, package metadata, and troubleshooting.
-
-## Install
-
-Recommended install uses npm plus native per-user services.
+Install and start PI WEB as per-user services:
```bash
npm install -g @jmfederico/pi-web
pi-web install
+pi-web doctor
```
-On Linux servers, `loginctl enable-linger` is optional but recommended so the user systemd manager starts at boot and continues running after logout:
+Then open:
-```bash
-sudo loginctl enable-linger "$USER"
-loginctl show-user "$USER" -p Linger
+```text
+http://127.0.0.1:8504
```
-This writes and starts PI WEB's session daemon and web/API user services. The native user-service backend is selected automatically.
-
-The generated services run through your detected login shell (`bash`, `zsh`, or `fish` with `-lc`) so they see a shell environment similar to running `pi` from your terminal.
-
-Open .
-
Useful commands:
```bash
@@ -171,64 +64,108 @@ pi-web version
pi-web uninstall
```
-Use `pi-web version` to compare the installed package version with the versions reported by the running Web/UI and session daemon services.
+For more install options, including one-line install, Pi package install, WSL/manual usage, and remote access, see the [installation guide](https://pi-web.dev/install).
-One-line install is also available for users who prefer it:
+Common alternatives:
```bash
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh
```
-### Docker local-build runtime
-
-A Docker runtime is available for trusted local/server installs without using prebuilt images:
+For trusted local/server installs, PI WEB also has a Docker local-build runtime:
```bash
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
```
-It builds an openSUSE Tumbleweed based local image from npm with Node.js 22, npx, Corepack, and common development/agent tooling, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access.
+The Docker setup builds an openSUSE Tumbleweed based local image from npm, runs split `sessiond` and `web` services, and binds the browser UI to `127.0.0.1:8504` by default. It intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access.
-See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, openSUSE package customization, custom image hooks for optional CLIs, host command examples, and development Compose usage.
+See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, package customization, host command examples, and development Compose usage.
-PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi:
+PI WEB is also published as a Pi package:
```bash
pi install npm:@jmfederico/pi-web
```
-Then in Pi:
+In Pi, use `/pi-web install`, `/pi-web status`, `/pi-web logs`, `/pi-web restart`, `/pi-web doctor`, and `/pi-web version`.
+
+## Core model
+
+PI WEB organizes work like this:
```text
-/pi-web install
-/pi-web status
-/pi-web logs
-/pi-web restart
-/pi-web doctor
-/pi-web version
+Machine a local or remote PI WEB runtime endpoint
+Project a folder on that machine
+Workspace a git worktree, or the project folder for non-git projects
+Session a Pi Coding Agent chat running inside a workspace
```
-The Pi command is a convenience wrapper around the same service installer. When installed this way, the service installer can use PI WEB's package-local server entrypoints, so `pi-web-server` and `pi-web-sessiond` do not need to be on your shell `PATH`. `/pi-web logs` shows the last 100 service log lines; use `pi-web logs` in a shell when you want to follow logs continuously.
+A typical flow:
-Advanced users may run the binaries however they prefer:
+1. Add a project.
+2. Choose a workspace or git worktree.
+3. Start a session.
+4. Let the agent work.
+5. Come back later from any browser.
-```bash
-pi-web-sessiond
-PI_WEB_PORT=8504 pi-web-server
+## Remote-first development
+
+PI WEB is designed for remote AI-driven development.
+
+Instead of tying agent work to your laptop session, run PI WEB on a machine that stays available: a server, desktop, cloud VM, home lab machine, or remote dev box.
+
+Use a private network, SSH tunnel, trusted reverse proxy, or federated PI WEB machine setup when accessing it remotely.
+
+Read more: [Remote-first development](https://pi-web.dev/remote-first)
+
+## Machines and fleets
+
+PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, and activity from trusted remote machines.
+
+Read more: [Fleet and machines guide](https://pi-web.dev/machines)
+
+## Plugins
+
+PI WEB supports trusted local browser-side plugins that can add actions, workspace panels, and workspace metadata.
+
+Read more: [Plugin API](https://pi-web.dev/plugins)
+
+## Configuration
+
+Global config lives at:
+
+```text
+$PI_WEB_CONFIG
+~/.config/pi-web/config.json
```
-## Development quick start
+Project-local PI WEB config lives at:
+
+```text
+/.pi-web/config.json
+```
+
+Common configuration includes host/port, path access, uploads, plugins, shortcuts, and session daemon options.
+
+Read more: [Configuration reference](https://pi-web.dev/config)
+
+## Development
+
+Clone the repository and run:
```bash
npm install
npm run dev
```
-Open the Vite URL, usually .
+Open the Vite URL, usually:
-During development, the static marketing/docs site is also served by the Vite dev server at .
+```text
+http://localhost:8505
+```
-For the recommended split development setup, run these in separate terminals:
+For the split development setup:
```bash
npm run dev:sessiond
@@ -257,7 +194,7 @@ docker compose -f docker/compose.dev.yml up --build
Open . The Docker dev setup keeps `sessiond` separate from the autoreloading web/API/client service and uses the runtime Docker data directory by default so sessions can be shared across modes. See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md#development-docker-setup).
-## Production-style run from a checkout
+For a production-style run from a checkout:
```bash
npm run build
@@ -265,127 +202,27 @@ npm run start:sessiond
PI_WEB_PORT=8504 npm start
```
-## Packaging and publishing
+Validate changes with:
```bash
npm run verify
-npm run pack:dry
-npm publish --access public
```
-`prepack` builds `dist/` and bundled plugin JavaScript before npm creates the tarball, and `prepublishOnly` runs verification before publishing. Releases can also be published by the GitHub Actions npm workflow when a GitHub release is published.
+## Security model
-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 assumes trusted users, trusted repositories, and trusted server paths.
-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.
+It is not a sandbox, permission system, or multi-tenant platform. Do not expose it directly to the public internet without a trusted network, firewall, VPN, SSH tunnel, or authenticated reverse proxy.
+## Documentation
-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.
-
-The session daemon defaults to a private Unix socket at:
-
-```text
-~/.pi-web/sessiond.sock
-```
-
-Environment variables:
-
-- `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`.
-- `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.
-
-## Development services
-
-`pi-web install --dev` creates a practical local setup with two native per-user services:
-
-- `pi-web-sessiond` runs `npm run start:sessiond` from the checkout without autoreload.
-- `pi-web-ui-dev` runs `npm run dev:web` and `npm run dev:client` for API reloads, bundled plugin rebuilds, and Vite HMR.
-
-Under the hood, the native backends are systemd user services and LaunchAgents. For reference, an equivalent systemd setup looks like:
-
-```ini
-# ~/.config/systemd/user/pi-web-sessiond.service
-[Unit]
-Description=PI WEB session daemon
-
-[Service]
-Type=simple
-WorkingDirectory=/srv/dev/pi-web
-ExecStart=/bin/bash -lc 'exec npm run start:sessiond'
-Restart=no
-
-[Install]
-WantedBy=default.target
-```
-
-```ini
-# ~/.config/systemd/user/pi-web-ui-dev.service
-[Unit]
-Description=PI WEB UI dev server
-After=pi-web-sessiond.service
-Wants=pi-web-sessiond.service
-
-[Service]
-Type=simple
-WorkingDirectory=/srv/dev/pi-web
-ExecStart=/bin/bash -lc 'trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait'
-Restart=no
-
-[Install]
-WantedBy=default.target
-```
-
-On Linux servers, enable persistent user services so the user systemd manager starts at boot and remains running after logout:
-
-```bash
-sudo loginctl enable-linger "$USER"
-loginctl show-user "$USER" -p Linger
-```
-
-Install or refresh the development services with:
-
-```bash
-pi-web install --dev
-```
-
-Useful logs:
-
-```bash
-pi-web logs
-```
-
-If code affecting the session daemon changes, restart it manually:
-
-```bash
-pi-web restart
-```
-
-## Current limitations
-
-- Assumes trusted users and trusted server paths.
-- Not a sandbox, permission model, or secure multi-tenant platform.
-- Some Pi TUI slash-command behavior is not yet represented exactly in the web UI.
-- Workspaces are discovered from existing git worktrees; UI-driven worktree management is a natural next step.
-
-## Vision
-
-PI WEB is the beginning of an agent-first development environment:
-
-- agents run persistently on servers;
-- humans connect through the browser;
-- work is organized by projects, workspaces, and sessions;
-- the UI grows around the needs of agentic development rather than the habits of local IDEs.
-
-The goal is simple: make it practical to run more development remotely, in parallel, with agents as first-class participants and humans focused on direction, judgment, and review.
+- [Website](https://pi-web.dev/)
+- [Install](https://pi-web.dev/install)
+- [Remote-first development](https://pi-web.dev/remote-first)
+- [Machines / fleet](https://pi-web.dev/machines)
+- [Configuration](https://pi-web.dev/config)
+- [Plugins](https://pi-web.dev/plugins)
+- [FAQ](https://pi-web.dev/faq)
## License
diff --git a/docs/404.html b/docs/404.html
index 2f313ac..b80d812 100644
--- a/docs/404.html
+++ b/docs/404.html
@@ -40,6 +40,7 @@
Remote-firstFleetInstall
+ ConfigPluginsFAQ
@@ -90,6 +91,7 @@
@@ -305,6 +306,7 @@
Remote-firstFleetInstall
+ ConfigPluginsIssues
diff --git a/docs/index.html b/docs/index.html
index cb4805d..e17f81b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -66,6 +66,7 @@
Remote-firstFleetInstall
+ ConfigPluginsFAQ
@@ -146,12 +147,58 @@
-
+
- Workspaces, sessions, transcripts, terminals — one Pi web UI.
- Bring your own repositories.
+
+ Workspaces, sessions, transcripts, files — one Pi web UI on every screen.
+ Bring your own repositories. Swipe through the screenshots or click one to enlarge.
+
- The web server defaults to 127.0.0.1:8504 and stores PI WEB state in ~/.pi-web.
+ Use Settings → General for host, port, and external filesystem roots; Settings → Session daemon
+ for agent-spawn tools; Settings → Plugins for plugin enablement; and Settings → Keyboard
+ for shortcut overrides.
+
+
+ Need the full schema, restart rules, project-local .pi-web/config.json, path access details, and
+ environment variable reference? Read the configuration reference.
+
+
+ The web server defaults to 127.0.0.1:8504, and PI WEB-managed state defaults to
+ ~/.pi-web. External filesystem paths are denied by default unless listed in
+ pathAccess.allowedPaths.
-
-
PI_WEB_CONFIG: path to a config JSON file. Defaults to ~/.config/pi-web/config.json.
-
PI_WEB_PORT or PORT: web server port. Overrides the config file.
-
PI_WEB_HOST: web server bind host. Overrides the config file. Use 127.0.0.1 for local/tunnel-only access, or a specific VPN/private-network IP for trusted remote access.
-
PI_WEB_DATA_DIR: data directory, default ~/.pi-web.
-
PI_WEB_SESSIOND_SOCKET: Unix socket path for daemon communication.
-
PI_CODING_AGENT_SESSION_DIR: Pi session storage directory. PI WEB follows Pi's priority for 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 for auth, settings, resources, and default session storage.
-
@@ -334,6 +341,7 @@
HomeRemote-firstFleet
+ ConfigPluginsFAQGitHub
diff --git a/docs/machines.html b/docs/machines.html
index 4d7005e..665f222 100644
--- a/docs/machines.html
+++ b/docs/machines.html
@@ -50,6 +50,7 @@
Remote-firstFleetInstall
+ ConfigPluginsFAQ
@@ -289,6 +290,7 @@ PI WEB gateway you opened
HomeRemote-firstInstall
+ ConfigPluginsFAQGitHub
diff --git a/docs/plugins.html b/docs/plugins.html
index 53a2936..8068d5b 100644
--- a/docs/plugins.html
+++ b/docs/plugins.html
@@ -50,6 +50,7 @@
Remote-firstFleetInstall
+ ConfigPluginsFAQ
@@ -430,6 +431,7 @@ After editing, check the manifest endpoint and browser-console failure cases.Remote-first
FleetInstall
+ ConfigFAQGitHub
diff --git a/docs/plugins.md b/docs/plugins.md
index e201051..e328513 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -439,6 +439,7 @@ interface PluginRuntimeContext {
selectedSession?: unknown;
piWebStatus?: PiWebStatusResponse;
};
+ prompt: PluginPromptEditor;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise;
@@ -464,6 +465,29 @@ Notes:
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear.
+### Prompt editor API
+
+The `prompt` helper on `PluginRuntimeContext` and `WorkspacePanelContext` provides stable access to the chat prompt editor:
+
+| Method | Description |
+| --- | --- |
+| `insertText(text)` | Insert text at cursor position. When text is selected, replaces the selection. Focuses the editor first if not focused. |
+| `getText()` | Returns the full prompt text. |
+| `getSelection()` | Returns `{ start, end, text }` if text is selected, or `null`. |
+
+Usage:
+
+```js
+// Insert text at the cursor (e.g. a file mention)
+context.prompt.insertText("@file.txt");
+
+// Read the current prompt and selection
+const text = context.prompt.getText();
+const selection = context.prompt.getSelection(); // { start, end, text } | null
+```
+
+Use `focusPrompt()` on `PluginRuntimeContext` to move focus to the prompt editor. Workspace panels can call `context.prompt.insertText()` from explicit user interactions such as button clicks; panel contexts target the currently selected session's mounted prompt editor.
+
#### Keyboard shortcuts
- App-level keyboard shortcuts must be attached to actions. PI WEB does not support standalone plugin keyboard commands; contribute an action first, then add a `shortcut` if it needs a keybinding.
@@ -521,7 +545,11 @@ interface WorkspacePanelContext {
state?: PluginRuntimeState;
files: {
readFile(path: string): Promise;
+ writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise;
+ deleteFile(path: string): Promise;
+ moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise;
};
+ prompt: PluginPromptEditor;
terminal: {
open(options?: { terminalId?: string }): void;
runCommand(input: {
@@ -539,7 +567,7 @@ interface WorkspacePanelContext {
`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide.
-`machine`, `workspace`, `files`, `terminal`, and `host` are documented as stable for panel callbacks. Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`.
+`machine`, `workspace`, `files`, `prompt`, `terminal`, and `host` are documented as stable for panel callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). The `prompt` helper supports panel interactions that insert workspace context into the current prompt — see [Prompt editor API](#prompt-editor-api). Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`.
For compatibility, PI WEB still provides the old `context.openTerminal()` workspace-panel helper at runtime. It is deprecated, intentionally omitted from the public TypeScript declarations, and planned for removal in v2. Existing JavaScript plugins keep working, while typed plugins should migrate to `context.terminal.open()`.
@@ -607,6 +635,9 @@ interface WorkspaceLabelContext {
state?: PluginRuntimeState;
files: {
readFile(path: string): Promise;
+ writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise;
+ deleteFile(path: string): Promise;
+ moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise;
};
host: {
requestRender(): void;
@@ -614,7 +645,7 @@ interface WorkspaceLabelContext {
}
```
-`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks.
+`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks.
Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes.
@@ -753,6 +784,96 @@ workspaceLabels: [
The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.
+## Writing, deleting, and moving workspace files
+
+Workspace panels and workspace labels can write, delete, and move files through the documented `files` helper. Like `readFile`, PI WEB binds these helpers to the callback's machine and workspace, so they work the same for local and federated machines.
+
+### Writing files
+
+```js
+workspacePanels: [
+ {
+ id: "workspace.generate",
+ title: "Generate",
+ render: ({ files }) => html`
+
+ `,
+ },
+]
+```
+
+### Binary writes
+
+Pass a `Uint8Array` for binary content such as images:
+
+```js
+const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
+await files.writeFile("screenshots/thumb.png", png);
+```
+
+### Options
+
+`files.writeFile` accepts an optional third argument:
+
+- `createDirs` (default `true`): create intermediate directories, like `mkdir -p`.
+- `overwrite` (default `true`): overwrite existing files. Set to `false` to throw if the file already exists.
+
+```js
+// Create only — throw if the file already exists
+await files.writeFile("config/new-config.json", jsonContent, { overwrite: false });
+```
+
+### Deleting files
+
+`files.deleteFile` removes a workspace file. It is idempotent: deleting a file that does not exist returns `{ existed: false }` instead of throwing.
+
+```js
+const result = await files.deleteFile("temp/cache.json");
+console.log(result.existed ? "File deleted" : "File did not exist");
+```
+
+### Moving files
+
+`files.moveFile` renames or moves a file within the workspace, like `mv`. The default is safe: it will not overwrite an existing target file.
+
+```js
+// Rename a file
+await files.moveFile("old-name.txt", "new-name.txt");
+
+// Move into a subdirectory (creates intermediate dirs by default)
+await files.moveFile("file.txt", "archive/file.txt");
+
+// Overwrite an existing target
+await files.moveFile("incoming.txt", "current.txt", { overwrite: true });
+
+// Move without creating intermediate directories
+await files.moveFile("file.txt", "deep/nested/file.txt", { createDirs: false }); // throws if dirs don't exist
+```
+
+`files.moveFile` accepts an optional third argument:
+
+- `createDirs` (default `true`): create intermediate directories for the target path.
+- `overwrite` (default `false`): overwrite the target file if it exists. The default is safer than `writeFile` because moving is a more destructive operation.
+
+### Error handling
+
+All file mutations share the same safety layer:
+
+- `overwrite: false` on `writeFile` or existing target on `moveFile` (default) throws if the file already exists.
+- Path traversal (e.g., `../../etc/passwd`) is blocked by the workspace safety layer.
+- Writing to or moving to a path that is a directory returns an error.
+- Deleting a directory returns an error.
+- Intermediate directory creation with `createDirs: false` fails if the parent directory does not exist.
+
+After any mutation (`writeFile`, `deleteFile`, or `moveFile`), the File Explorer updates automatically. No explicit `refreshFiles()` call is needed from plugin code. For label and badge updates, call `context.host.requestRender()` if the UI should reflect the change.
+
+### Security
+
+Plugins are trusted browser code. File writes go through the same path safety validation as reads — paths are resolved and checked to stay inside the workspace root.
+
## Running workspace terminal commands
Workspace panels can start terminal commands through the documented `terminal` helper. Commands run in the current workspace on the panel's machine.
@@ -801,7 +922,7 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis
9. Add workspace panels for larger workspace UI.
10. Add workspace labels for compact inline metadata.
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
-12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
+12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, and `prompt`.
13. Do not fetch PI WEB `/api/...` endpoints directly unless you intentionally accept private API churn; prefer documented helpers.
14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
diff --git a/docs/remote-first.html b/docs/remote-first.html
index 4943dce..fd7b5fd 100644
--- a/docs/remote-first.html
+++ b/docs/remote-first.html
@@ -50,6 +50,7 @@
Remote-firstFleetInstall
+ ConfigPluginsFAQ
@@ -206,6 +207,7 @@
HomeFleetInstall
+ ConfigPluginsFAQGitHub
diff --git a/docs/site.js b/docs/site.js
index 95672c7..23b9bec 100644
--- a/docs/site.js
+++ b/docs/site.js
@@ -61,6 +61,167 @@ for (const button of themeButtons) {
});
}
+const screenshotCarousels = document.querySelectorAll("[data-demo-carousel]");
+const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+
+function setupScreenshotCarousel(carousel) {
+ const gallery = carousel.querySelector("[data-demo-gallery]");
+ const controls = carousel.querySelector("[data-demo-controls]");
+ const previousButton = carousel.querySelector("[data-demo-prev]");
+ const nextButton = carousel.querySelector("[data-demo-next]");
+ const dots = Array.from(carousel.querySelectorAll("[data-demo-dot]"));
+ const slides = Array.from(carousel.querySelectorAll("[data-demo-slide]"));
+ const lightbox = carousel.querySelector("[data-demo-lightbox]");
+ const lightboxImage = carousel.querySelector("[data-demo-lightbox-image]");
+ const lightboxCaption = carousel.querySelector("[data-demo-lightbox-caption]");
+ const lightboxCloseButton = carousel.querySelector("[data-demo-lightbox-close]");
+ const lightboxTriggers = Array.from(carousel.querySelectorAll("[data-demo-lightbox-trigger]"));
+
+ if (gallery === null || slides.length === 0) return;
+
+ let updateQueued = false;
+
+ function galleryHasOverflow() {
+ return gallery.scrollWidth > gallery.clientWidth + 4;
+ }
+
+ function closestSlideIndex() {
+ const galleryRect = gallery.getBoundingClientRect();
+ const galleryCenter = galleryRect.left + galleryRect.width / 2;
+ let closestIndex = 0;
+ let closestDistance = Number.POSITIVE_INFINITY;
+
+ slides.forEach((slide, index) => {
+ const rect = slide.getBoundingClientRect();
+ const distance = Math.abs(rect.left + rect.width / 2 - galleryCenter);
+ if (distance < closestDistance) {
+ closestIndex = index;
+ closestDistance = distance;
+ }
+ });
+
+ return closestIndex;
+ }
+
+ function scrollToSlide(index) {
+ const slide = slides[index];
+ if (slide === undefined) return;
+
+ slide.scrollIntoView({
+ behavior: reducedMotionQuery.matches ? "auto" : "smooth",
+ block: "nearest",
+ inline: "start",
+ });
+ }
+
+ function closeLightbox() {
+ if (lightbox === null) return;
+
+ if (typeof lightbox.close === "function" && lightbox.open) {
+ lightbox.close();
+ } else {
+ lightbox.removeAttribute("open");
+ }
+ }
+
+ function openLightbox(trigger) {
+ const image = trigger.querySelector("img");
+ if (image === null || lightbox === null || lightboxImage === null) return;
+
+ const figure = trigger.closest("figure");
+ const captionParts = Array.from(figure?.querySelectorAll("figcaption strong, figcaption span") ?? [])
+ .map((node) => node.textContent?.trim())
+ .filter(Boolean);
+ const caption = captionParts.length > 0 ? captionParts.join(" — ") : "PI WEB screenshot";
+
+ lightboxImage.src = image.currentSrc || image.src;
+ lightboxImage.alt = image.alt;
+ if (lightboxCaption !== null) lightboxCaption.textContent = caption;
+
+ if (typeof lightbox.showModal === "function") {
+ lightbox.showModal();
+ } else {
+ lightbox.setAttribute("open", "");
+ }
+
+ lightboxCloseButton?.focus({ preventScroll: true });
+ }
+
+ function updateControls() {
+ const overflow = galleryHasOverflow();
+ const activeIndex = closestSlideIndex();
+ const atStart = gallery.scrollLeft <= 2;
+ const atEnd = gallery.scrollLeft + gallery.clientWidth >= gallery.scrollWidth - 2;
+
+ carousel.dataset.overflow = overflow ? "true" : "false";
+ gallery.tabIndex = overflow ? 0 : -1;
+ if (controls !== null) controls.hidden = !overflow;
+ if (previousButton !== null) previousButton.disabled = !overflow || atStart;
+ if (nextButton !== null) nextButton.disabled = !overflow || atEnd;
+
+ dots.forEach((dot, index) => {
+ dot.setAttribute("aria-current", index === activeIndex ? "true" : "false");
+ });
+ }
+
+ function queueUpdateControls() {
+ if (updateQueued) return;
+ updateQueued = true;
+ window.requestAnimationFrame(() => {
+ updateQueued = false;
+ updateControls();
+ });
+ }
+
+ previousButton?.addEventListener("click", () => {
+ scrollToSlide(Math.max(closestSlideIndex() - 1, 0));
+ });
+
+ nextButton?.addEventListener("click", () => {
+ scrollToSlide(Math.min(closestSlideIndex() + 1, slides.length - 1));
+ });
+
+ dots.forEach((dot) => {
+ const targetIndex = Number.parseInt(dot.getAttribute("data-demo-dot") ?? "", 10);
+ if (Number.isNaN(targetIndex)) return;
+
+ dot.addEventListener("click", () => {
+ scrollToSlide(targetIndex);
+ });
+ });
+
+ lightboxTriggers.forEach((trigger) => {
+ trigger.addEventListener("click", () => {
+ openLightbox(trigger);
+ });
+ });
+
+ lightboxCloseButton?.addEventListener("click", closeLightbox);
+
+ lightbox?.addEventListener("click", (event) => {
+ if (event.target === lightbox) closeLightbox();
+ });
+
+ lightbox?.addEventListener("close", () => {
+ lightboxImage?.removeAttribute("src");
+ });
+
+ gallery.addEventListener("scroll", queueUpdateControls, { passive: true });
+ window.addEventListener("resize", queueUpdateControls);
+
+ if ("ResizeObserver" in window) {
+ const resizeObserver = new window.ResizeObserver(queueUpdateControls);
+ resizeObserver.observe(gallery);
+ slides.forEach((slide) => resizeObserver.observe(slide));
+ }
+
+ updateControls();
+}
+
+for (const carousel of screenshotCarousels) {
+ setupScreenshotCarousel(carousel);
+}
+
const copyButtons = document.querySelectorAll("[data-copy]");
for (const button of copyButtons) {
diff --git a/docs/sitemap.xml b/docs/sitemap.xml
index 6913ebc..27b7161 100644
--- a/docs/sitemap.xml
+++ b/docs/sitemap.xml
@@ -4,6 +4,7 @@
https://pi-web.dev/remote-firsthttps://pi-web.dev/machineshttps://pi-web.dev/install
+ https://pi-web.dev/confighttps://pi-web.dev/pluginshttps://pi-web.dev/faq
diff --git a/docs/styles.css b/docs/styles.css
index d622208..f983592 100644
--- a/docs/styles.css
+++ b/docs/styles.css
@@ -610,14 +610,255 @@ code .comment,
.demo-caption {
display: flex;
align-items: center;
+ flex-wrap: wrap;
justify-content: space-between;
- gap: 16px;
+ gap: 14px 18px;
padding: 15px 18px;
border-bottom: 1px solid var(--line);
background: #0c1020;
color: var(--muted);
}
+.demo-caption-copy {
+ display: grid;
+ min-width: min(100%, 340px);
+ gap: 3px;
+}
+
+.demo-caption-copy span {
+ color: var(--muted-2);
+}
+
+.demo-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-left: auto;
+}
+
+.demo-controls[hidden] {
+ display: none;
+}
+
+.demo-control,
+.demo-dot {
+ appearance: none;
+ border: 1px solid var(--line-bright);
+ background: var(--panel-strong);
+ color: var(--text);
+ cursor: pointer;
+}
+
+.demo-control {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ padding: 0;
+ place-items: center;
+ font: inherit;
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.demo-control:hover:not(:disabled),
+.demo-dot:hover {
+ border-color: var(--brand-2);
+ color: var(--brand-2);
+}
+
+.demo-control:focus-visible,
+.demo-dot:focus-visible {
+ outline: 2px solid var(--brand-2);
+ outline-offset: 3px;
+}
+
+.demo-control:disabled {
+ cursor: not-allowed;
+ opacity: 0.35;
+}
+
+.demo-dots {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.demo-dot {
+ width: 11px;
+ height: 11px;
+ padding: 0;
+ border-radius: 999px;
+ background: transparent;
+}
+
+.demo-dot[aria-current="true"] {
+ border-color: var(--brand-2);
+ background: var(--brand-2);
+}
+
+.demo-gallery {
+ display: flex;
+ gap: 18px;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ padding: 18px;
+ scroll-padding-inline: 18px;
+ scroll-snap-type: x mandatory;
+ scrollbar-color: var(--line-bright) transparent;
+ scrollbar-width: thin;
+ -webkit-overflow-scrolling: touch;
+}
+
+.demo-gallery::-webkit-scrollbar {
+ height: 10px;
+}
+
+.demo-gallery::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.demo-gallery::-webkit-scrollbar-thumb {
+ border: 3px solid transparent;
+ background: var(--line-bright);
+ background-clip: content-box;
+}
+
+.demo-gallery:focus-visible {
+ outline: 2px solid var(--brand-2);
+ outline-offset: -4px;
+}
+
+.demo-shot {
+ display: grid;
+ flex: 1 0 calc((100% - 36px) / 3);
+ min-width: 286px;
+ gap: 12px;
+ align-content: start;
+ margin: 0;
+ scroll-snap-align: start;
+}
+
+.demo-shot-media {
+ position: relative;
+ display: grid;
+ overflow: hidden;
+ aspect-ratio: 16 / 10;
+ place-items: center;
+ padding: 10px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background:
+ radial-gradient(circle at 0 0, rgba(124, 60, 255, 0.22), transparent 34%),
+ var(--panel-strong);
+ box-shadow: 0 18px 46px rgba(0, 0, 0, 0.2);
+}
+
+.demo-lightbox-trigger {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: zoom-in;
+}
+
+.demo-lightbox-trigger:focus-visible {
+ outline: 2px solid var(--brand-2);
+ outline-offset: -2px;
+}
+
+.demo-shot img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.demo-shot figcaption {
+ display: grid;
+ gap: 4px;
+ color: var(--muted-2);
+ font-size: 0.9rem;
+ line-height: 1.45;
+}
+
+.demo-shot figcaption strong {
+ color: var(--text);
+ font-size: 0.94rem;
+}
+
+.demo-lightbox {
+ width: min(1120px, calc(100vw - 28px));
+ max-height: calc(100vh - 28px);
+ padding: 0;
+ border: 1px solid var(--line-bright);
+ background: var(--panel);
+ color: var(--text);
+}
+
+.demo-lightbox::backdrop {
+ background: rgba(5, 7, 16, 0.78);
+ backdrop-filter: blur(6px);
+}
+
+.demo-lightbox-panel {
+ position: relative;
+ display: grid;
+ gap: 12px;
+ max-height: calc(100vh - 28px);
+ padding: clamp(14px, 2vw, 22px);
+}
+
+.demo-lightbox-panel img {
+ width: auto;
+ height: auto;
+ max-width: 100%;
+ max-height: calc(100vh - 116px);
+ justify-self: center;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--panel-strong);
+ box-shadow: 0 18px 46px rgba(0, 0, 0, 0.28);
+}
+
+.demo-lightbox-caption {
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.5;
+ text-align: center;
+}
+
+.demo-lightbox-close {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 1;
+ display: grid;
+ width: 42px;
+ height: 42px;
+ padding: 0;
+ place-items: center;
+ border: 1px solid var(--line-bright);
+ background: var(--panel);
+ color: var(--text);
+ cursor: pointer;
+ font: inherit;
+ font-size: 1.35rem;
+ line-height: 1;
+}
+
+.demo-lightbox-close:hover,
+.demo-lightbox-close:focus-visible {
+ border-color: var(--brand-2);
+ color: var(--brand-2);
+}
+
.manifesto-section {
padding-top: 46px;
}
@@ -809,6 +1050,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 +1371,10 @@ html[data-theme="light"] .comment {
.manifesto-lines {
align-content: start;
}
+
+ .demo-shot {
+ flex-basis: min(58vw, 360px);
+ }
}
@media (max-width: 820px) {
@@ -1153,6 +1452,48 @@ html[data-theme="light"] .comment {
font-size: clamp(3rem, 15vw, 4.2rem);
}
+ .demo-caption {
+ align-items: flex-start;
+ }
+
+ .demo-controls {
+ justify-content: space-between;
+ width: 100%;
+ margin-left: 0;
+ }
+
+ .demo-dots {
+ flex: 1;
+ justify-content: center;
+ }
+
+ .demo-gallery {
+ gap: 14px;
+ padding: 14px;
+ scroll-padding-inline: 14px;
+ }
+
+ .demo-shot {
+ flex-basis: min(84vw, 340px);
+ min-width: 0;
+ }
+
+ .demo-shot-media {
+ padding: 8px;
+ }
+
+ .demo-lightbox {
+ width: calc(100vw - 20px);
+ }
+
+ .demo-lightbox-panel {
+ padding: 12px;
+ }
+
+ .demo-lightbox-panel img {
+ max-height: calc(100vh - 98px);
+ }
+
.footer-inner {
align-items: flex-start;
flex-direction: column;
diff --git a/package-lock.json b/package-lock.json
index 3784ed7..de4644b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@jmfederico/pi-web",
- "version": "1.202606.4",
+ "version": "1.202606.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@jmfederico/pi-web",
- "version": "1.202606.4",
+ "version": "1.202606.6",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.10.3",
@@ -1575,7 +1575,7 @@
"typebox": "1.1.38"
},
"bin": {
- "pi-ai": "dist/cli.js"
+ "pi-ai": "./dist/cli.js"
},
"engines": {
"node": ">=22.19.0"
diff --git a/package.json b/package.json
index eeee526..ee6caef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@jmfederico/pi-web",
- "version": "1.202606.4",
+ "version": "1.202606.6",
"description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
"license": "MIT",
"author": "Federico Jaramillo Martinez",
@@ -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",
diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs
new file mode 100755
index 0000000..9297f38
--- /dev/null
+++ b/scripts/capture-screenshots.mjs
@@ -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 `
PI WEB
pi-web.dev
Fallback screenshot for ${escapeHtml(url)}.
`;
+}
+
+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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
+}
+
+function fail(message) {
+ console.error(message);
+ process.exit(1);
+}
+
+try {
+ await main();
+} finally {
+ await cleanup();
+}
diff --git a/skills/relay/SKILL.md b/skills/relay/SKILL.md
index a70f8a9..d10d7c4 100644
--- a/skills/relay/SKILL.md
+++ b/skills/relay/SKILL.md
@@ -1,6 +1,6 @@
---
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."
+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/status/log, or the user invokes this skill directly. Do not load it for generic multi-step plans or ordinary spawn_session use."
---
# Relay
@@ -9,7 +9,7 @@ Relay is a way to execute a long or complex plan as a chain of independent sessi
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.
+Relay works because it does not try to recreate human management structures. The point is fewer boundaries, less hierarchy, and more fluid execution. The thing that makes that safe is **context containment**: every leg starts with a fresh, small context, and the accumulated knowledge lives in compact documents on disk rather than in any one session's memory.
## The hard constraint that shapes everything
@@ -17,48 +17,96 @@ The reason this works is **containment**: every leg starts with a fresh, small c
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.
+- **Make your work durable before you hand off.** Update the status, append 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
+## The relay packet
-A relay is carried by two documents. By default they live in `.pi-web/relays//` unless the user or the dispatching prompt says otherwise — always follow an explicit location if given.
+A relay is carried by a small packet of documents. By default they live in `.pi-web/relays//` unless the user or the dispatching prompt says otherwise — always follow an explicit location if given.
+
+Every relay has these three core files:
**Charter** (`charter.md`) — the stable agreement, written when the relay is planned. It must contain, at minimum:
+- **Relay identity.** The relay name and root path, so runners know exactly which relay they are on.
- **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.
+- **Task selection policy.** How a runner chooses the next task when `status.md` does not name one explicitly.
+- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. A normal handoff points at `charter.md` and `status.md`, not the full log.
- **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.
+- **Reading discipline.** The files a runner should read to orient, and any files that should not be read defensively.
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.
+**Status** (`status.md`) — the compact baton/current state. This is the file every runner reads after the charter, and every runner updates before handoff or stop. Keep it short enough that a fresh runner can load it cheaply. It should answer:
-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.
+- **Current position.** Where the relay is now.
+- **Current or next task.** The next leg if known; otherwise enough information to apply the charter's task selection policy.
+- **Relevant context.** Only the files, sections, commands, artifacts, or specific log entries needed for the next leg.
+- **Progress documentation.** Where this runner must write progress: update `status.md`, append `log.md`, update artifacts, commit, etc.
+- **Blockers / intervention state.** Current risks, open decisions, or active reasons to stop.
+
+Think of `status.md` as the thing passed from runner to runner. If it grows into a history dump, compress it back into current state plus pointers.
+
+**Log** (`log.md`) — append-only history. Each leg appends a concise entry recording what it did, decisions made and why, durable artifacts changed, status updates made, and blockers. The log preserves auditability, but it is **not** orientation memory.
+
+Do not read `log.md` end-to-end by default. Read targeted log entries only when `status.md` points to them, when the charter requires a specific lookup, or when there is an inconsistency you must resolve before continuing.
+
+Optional files such as `plan.md`, `backlog.md`, or artifact notes are fine, but runners should read them only when the charter/status points to the relevant part.
+
+## Context containment rule
+
+A runner normally reads:
+
+1. `charter.md`
+2. `status.md`
+3. Only the specific files or log entries referenced for the current leg
+
+Do not defensively rebuild the relay's full history. Do not read the full log, the full backlog, or a large artifact tree just because they exist. The relay stays scalable because each runner pays only for the context needed now.
+
+If `status.md` is insufficient, fix the baton rather than compensating by reading everything. Use targeted inspection to clarify the current state, update `status.md` so the next runner has a clean start, and continue only if the task is still clear. If reconstructing the state would require broad archaeology or judgment about past intent, stop and raise the intervention signal.
## 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.
+1. **Orient from the packet.** Read `charter.md` and `status.md`. Confirm the relay name/root, goal, sizing, handoff protocol, intervention signal, and current/next task. 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. **Choose the leg.** Prefer the explicit current/next task in `status.md`. If none is named, apply the charter's task selection policy. If that still requires context, inspect only the referenced plan/backlog/artifact sections. If the next task is still ambiguous or would materially change direction, stop and involve the human.
+3. **Re-anchor to the goal.** Does the goal still make sense given the status and what you now see? If reality has diverged from the charter, that is often an intervention moment — don't quietly redefine the task.
+4. **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.
+5. **Document progress.** Make all work durable. Update `status.md` with the new current state, next task or task-selection pointer, relevant context for the next runner, and blockers. Append a concise `log.md` entry with what you did, why, decisions made, artifacts changed, and whether you are handing off or stopping.
+6. **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 `charter.md` and `status.md` (so this skill loads and they can orient cheaply). 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 relay packet 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. Update `status.md`, append a clear note in `log.md`, 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.
+
+A good handoff prompt is short and explicit:
+
+```text
+You are continuing Relay "".
+
+Read:
+- .pi-web/relays//charter.md
+- .pi-web/relays//status.md
+
+Do not read log.md end-to-end. Use it only for targeted lookup if status.md or charter.md points you there.
+
+Run one leg according to the charter. Before handing off, update status.md, append log.md, make work durable, then either spawn the next leg once or stop with a clear intervention note.
+```
## 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.
+When the user asks to set up a relay, your job is to produce the relay packet: `charter.md`, `status.md`, and `log.md`. The charter must have the required slots filled: relay identity, goal, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status must give the first runner a compact baton: current position, first task or task selection pointer, relevant context, documentation expectations, and known blockers. The log may start empty or with a short seed entry explaining that the relay was created.
-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`.
+Draw the required choices out from the user rather than inventing them: ask what the finish line is, how much should be one leg, how runners pick tasks, how runners hand off, what they should read, and when they must stop and get the human. Sizing, task selection, 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, task selection is stated, handover is stated, reading discipline is stated, and the intervention signal is stated. Once the packet 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.
+- **Status bloat** → `status.md` turns into a history dump. Compress it to current state plus targeted pointers.
+- **Defensive reading** → reading the full log/backlog/artifact tree to feel safe. Use the packet and targeted lookups; stop if the baton is not enough.
- **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.
+- **Silent stall** → getting stuck and stopping with no note, or spawning anyway. Always update status, log the blocker, and surface it.
diff --git a/skills/relay/evals/evals.json b/skills/relay/evals/evals.json
index 2092e5b..5214ded 100644
--- a/skills/relay/evals/evals.json
+++ b/skills/relay/evals/evals.json
@@ -1,61 +1,81 @@
{
"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.",
+ "notes": "Relay is a behavioral framework skill. Test cases are prompts; 'good' is described per case and broken into checkable assertions. For live behavior tests, the evaluator should launch the test runner with spawn_subsession so its transcript can be inspected. Inside that runner, spawn_session is still the Relay behavior under test: handoff assertions count whether the runner calls spawn_session exactly once after durable status/log updates. Assertions tagged \"script\" can be checked by transcript/file inspection; assertions tagged \"judgment\" need a human or grader read. The negative trigger assertion must be run separately without forcing the agent to read this skill; if the harness does force-read the skill, only grade whether the agent avoids relay ceremony.",
"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//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.",
+ "prompt": "I want to migrate all our REST endpoints to the new validation layer — 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 relay packet (default .pi-web/relays//) with charter.md, status.md, and log.md. The charter has all required slots present: relay identity/root, goal/finish-line, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status is a compact baton with current position, first task or task-selection pointer, relevant context, progress documentation expectations, and known blockers. The agent asks the user to make sizing, task selection, reading discipline, 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 packet is agreed.",
"files": [],
"assertions": [
- { "name": "charter-created", "text": "A charter document is created (default under .pi-web/relays// unless the user specified a location).", "type": "script" },
+ { "name": "packet-created", "text": "A relay packet is created with charter.md, status.md, and log.md under the relay location (default .pi-web/relays// unless specified).", "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": "task-selection-slot-present", "text": "The charter states how a runner chooses the next task when status.md does not name one explicitly.", "type": "judgment" },
+ { "name": "handover-slot-present", "text": "The charter states the handover mechanism, including that the next runner reads charter.md and status.md.", "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" }
+ { "name": "reading-discipline-present", "text": "The charter states the reading discipline, including not reading log.md end-to-end by default.", "type": "judgment" },
+ { "name": "status-seeded", "text": "status.md is seeded as a compact baton with current position, first task or task-selection pointer, relevant context, documentation expectations, and known blockers.", "type": "judgment" },
+ { "name": "asks-not-prescribes", "text": "For sizing, task selection, reading discipline, 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 relay packet 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//charter.md and .pi-web/relays//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.",
+ "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//status.md, continue the plan, then dispatch the next agent.",
+ "expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter.md and status.md, not the full log. Re-anchors to the goal, chooses the next task from status.md or the charter's task-selection policy, does exactly ONE well-sized leg per the charter's sizing, updates status.md as a compact baton, appends a concise log.md entry, 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 charter.md and status.md. Does not do extra legs, does not spawn more than once, and does not tell the next runner to read log.md end-to-end.",
"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": "reads-charter-and-status", "text": "The agent reads both charter.md and status.md before acting.", "type": "script" },
+ { "name": "does-not-read-full-log", "text": "The agent does not read log.md end-to-end by default; any log use is targeted and justified by status.md or charter.md.", "type": "script" },
+ { "name": "task-picked-from-status-or-policy", "text": "The agent chooses the leg from status.md, or applies the charter's task-selection policy if status.md does not name a task.", "type": "judgment" },
{ "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": "status-updated", "text": "status.md is updated with the new current state, next task or task-selection pointer, relevant context for the next runner, and blockers.", "type": "script" },
+ { "name": "log-appended", "text": "A concise log.md entry is appended recording what was done, decisions, artifacts changed, status updates made, 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" }
+ { "name": "handoff-names-relay-and-status", "text": "The spawn prompt names the Relay framework and points the next runner at charter.md and status.md, not the full log, so the skill loads downstream with bounded context.", "type": "judgment" }
]
},
{
"id": 2,
"name": "stop-on-blocker-do-not-spawn",
- "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//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.",
+ "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//status.md and continue. (The next leg hits a condition the charter flagged as requiring the human.)",
+ "expected_output": "Orients from charter.md and status.md, begins the leg, recognizes the charter's intervention condition has fired. Stops cleanly: updates status.md with the blocker/intervention state, appends a clear log.md entry, 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": "status-records-blocker", "text": "status.md is updated with the blocker/intervention state so the next human or runner sees the current position immediately.", "type": "script" },
+ { "name": "blocker-logged", "text": "The agent logs the blocker clearly in log.md.", "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" }
+ { "name": "no-silent-stall", "text": "The agent does not stop silently; the stop is explained and visible in status.md/log.md.", "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).",
+ "expected_output": "This prompt mentions a multi-step plan AND spawning a session, but never names the Relay framework, points at a relay packet, 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/status/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" }
+ { "name": "no-relay-ceremony", "text": "The agent does not create a charter/status/log packet or impose relay leg/handoff ceremony.", "type": "judgment" }
+ ]
+ },
+ {
+ "id": 4,
+ "name": "long-relay-context-containment",
+ "prompt": "You're continuing Relay \"big-cleanup\". The relay has a huge log.md from dozens of prior legs. Read .pi-web/relays/big-cleanup/charter.md and .pi-web/relays/big-cleanup/status.md, then do the next leg without blowing up context.",
+ "expected_output": "Orients from charter.md and status.md, follows only the relevant context pointers in status.md, and avoids reading the huge log.md end-to-end. If status.md is insufficient, performs targeted inspection and repairs/compresses status.md for the next runner; if the state cannot be safely reconstructed without broad archaeology, stops and raises the intervention signal rather than reading everything and guessing.",
+ "files": [],
+ "assertions": [
+ { "name": "bounded-orientation", "text": "The agent orients from charter.md and status.md rather than rebuilding full relay history.", "type": "judgment" },
+ { "name": "no-defensive-log-read", "text": "The agent does not read the huge log.md end-to-end defensively.", "type": "script" },
+ { "name": "targeted-context-only", "text": "The agent reads only files/sections/log entries specifically referenced by status.md or needed for the current leg.", "type": "judgment" },
+ { "name": "repairs-status-or-stops", "text": "If status.md is insufficient, the agent either repairs it with targeted context or stops with an intervention note rather than reading everything and guessing.", "type": "judgment" }
]
}
]
diff --git a/skills/relay/evals/live-behavior-testing.md b/skills/relay/evals/live-behavior-testing.md
new file mode 100644
index 0000000..b23e799
--- /dev/null
+++ b/skills/relay/evals/live-behavior-testing.md
@@ -0,0 +1,134 @@
+# Live behavior testing guide
+
+Use live behavior tests when you want to know how the relay skill behaves **right now** with real agent sessions. These tests are not regression tests and they are not text checks; they exercise the model, tools, relay files, and handoff behavior together.
+
+## Basic idea
+
+Run each eval as a **tracked subsession** so you can inspect what happened afterward. The subsession acts like the agent using the relay skill. The parent session acts as the evaluator.
+
+Inside the eval, the agent may still use `spawn_session` when the relay behavior calls for a real handoff. That is intentional: `spawn_subsession` gives the evaluator visibility, while `spawn_session` tests the actual Relay handoff rule.
+
+## What to test
+
+A useful small live suite covers these behaviors:
+
+- **Planning a relay:** the agent drafts `charter.md`, `status.md`, and `log.md`; asks for missing human choices; does not spawn before approval.
+- **Running one leg:** the agent reads `charter.md` and `status.md`, runs exactly one slice, updates status, appends the log, and hands off once.
+- **Stopping on intervention:** the agent recognizes the charter's intervention signal, updates status/log, and does not spawn.
+- **Long relay containment:** the agent does not read a huge `log.md`; it uses `status.md` plus targeted files only.
+- **Negative/non-relay prompt:** the agent does not create relay ceremony for an ordinary multi-step task.
+
+## Sandbox shape
+
+Put throwaway relay files outside the repo or under a clearly temporary path, for example:
+
+```text
+/tmp/pi-web-relay-live-evals/iteration-1//
+ sandbox/.pi-web/relays//
+ charter.md
+ status.md
+ log.md
+ work/...
+ with_skill/outputs/
+```
+
+Keep the sandbox tiny. The point is to test relay behavior, not the complexity of the toy task.
+
+For the handoff eval, make the spawned receiver bounded. The charter can say something like:
+
+```text
+If you are the spawned receiver for this eval, do not run another relay leg and do not spawn again. Write spawned-next-runner.txt containing "received", then stop.
+```
+
+This lets you verify that the parent called `spawn_session` without starting an open-ended relay.
+
+## Running the evals
+
+For each eval, spawn a tracked subsession with a prompt that says:
+
+- read the skill under test, e.g. `skills/relay/SKILL.md`
+- execute the eval prompt
+- work only in the sandbox/output directory
+- save a final response to `with_skill/outputs/final_response.md`
+
+Example shape:
+
+```text
+You are a live behavior eval runner for the relay skill. Act as the target assistant, not as an evaluator.
+
+Use the current skill under test by reading:
+/path/to/skills/relay/SKILL.md
+
+Task prompt to execute:
+"You're working under the Relay framework. Read /tmp/.../charter.md and /tmp/.../status.md, continue the plan, then dispatch the next agent."
+
+Constraints:
+- Work only inside /tmp/...// except for reading the skill file.
+- Save your final answer to /tmp/...//with_skill/outputs/final_response.md.
+```
+
+Important handoff detail: `spawn_session` must use a valid project workspace/worktree as `cwd`. It cannot start a session with an arbitrary temp sandbox directory as its working directory. During testing, one eval runner tried to hand off with `cwd` set to `/tmp/.../sandbox`; the tool rejected it because only project workspaces/worktrees are allowed. The runner then retried with the project worktree as `cwd` and absolute paths to the relay files, which worked.
+
+So when testing or running a relay whose packet lives outside the repo, keep `cwd` at a valid project workspace/worktree (``) and make the handoff prompt point to the relay files by absolute path:
+
+```text
+spawn_session cwd:
+
+Prompt:
+You are continuing Relay "sandbox".
+Read:
+- /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/charter.md
+- /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/status.md
+```
+
+This matters for the "spawn exactly once" assertion: a failed first `spawn_session` call still counts as an attempted handoff. Avoid trial-and-error cwd choices by using a known project workspace from the start.
+
+## Reviewing results
+
+After each subsession finishes, review both transcript and files:
+
+- Did it read `charter.md` and `status.md` before acting?
+- Did it avoid reading `log.md` end-to-end unless explicitly targeted?
+- Did it do exactly one leg?
+- Did it update `status.md` as the next runner's baton?
+- Did it append a concise `log.md` entry?
+- Did it call `spawn_session` exactly once when handing off?
+- Did it avoid spawning when blocked or complete?
+- Did any spawned bounded receiver write the expected marker file?
+
+Record a short result summary in the eval workspace, for example:
+
+```text
+/tmp/pi-web-relay-live-evals/iteration-1/live-results.md
+/tmp/pi-web-relay-live-evals/iteration-1/live-results.json
+```
+
+## Interpreting negative tests
+
+If the harness explicitly tells the subsession to read the relay skill, you cannot fairly test whether the skill would have triggered on its own. In that setup, only check the behavior after reading the skill: did the agent avoid relay ceremony for a non-relay task?
+
+That means the live behavior suite covers **"does not use relay ceremony for a non-relay task"**, but it does **not** prove **"the relay skill was not triggered"**. A true non-trigger test must run without telling the agent to read the skill.
+
+## Testing that Relay does not trigger
+
+Use a separate trigger test when you care about whether the skill loads automatically. Give the agent a realistic non-relay prompt, but do not mention the relay skill path, do not say "Relay", and do not point at `charter.md`, `status.md`, or `log.md`.
+
+A good non-trigger prompt is close enough to be tempting:
+
+```text
+Plan a multi-step refactor of our auth module and spawn a session to start the first stage. Break it into stages.
+```
+
+Review the transcript and outputs for:
+
+- no read of `skills/relay/SKILL.md`
+- no `Skill`/skill-load event for `relay`, if the harness exposes one
+- no creation of `charter.md`, `status.md`, or `log.md`
+- no relay-specific terms such as leg, baton, intervention signal, relay packet, or handoff protocol unless the user used them first
+- ordinary `spawn_session` use is allowed if the user asked for it; spawning alone is not Relay
+
+Keep this separate from behavior evals. Behavior evals intentionally load the skill so they can test what the skill tells the agent to do; trigger evals test whether the skill is selected in the first place.
+
+## Why not Docker/static checks?
+
+Static checks can confirm that certain words exist in `SKILL.md`, but they do not show whether an agent follows the skill. For relay, the important behavior is dynamic: bounded reading, status updates, stop vs handoff decisions, and actual `spawn_session` use. Use live subsessions for that.
diff --git a/src/cli.test.ts b/src/cli.test.ts
new file mode 100644
index 0000000..72da8f8
--- /dev/null
+++ b/src/cli.test.ts
@@ -0,0 +1,60 @@
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js";
+
+const originalShell = process.env["SHELL"];
+
+afterEach(() => {
+ if (originalShell === undefined) {
+ delete process.env["SHELL"];
+ } else {
+ process.env["SHELL"] = originalShell;
+ }
+});
+
+describe("commandWithVersionCheck", () => {
+ it("emits a POSIX subshell group for bash", () => {
+ process.env["SHELL"] = "/bin/bash";
+ expect(commandWithVersionCheck("npm")).toBe("command -v npm && (npm --version 2>&1 || true)");
+ });
+
+ it("emits a POSIX subshell group for zsh", () => {
+ process.env["SHELL"] = "/bin/zsh";
+ expect(commandWithVersionCheck("pi")).toBe("command -v pi && (pi --version 2>&1 || true)");
+ });
+
+ it("uses fish begin/end grouping instead of a POSIX subshell", () => {
+ process.env["SHELL"] = "/usr/local/bin/fish";
+ const command = commandWithVersionCheck("npm");
+ expect(command).toBe("command -v npm && begin; npm --version 2>&1 || true; end");
+ expect(command).not.toContain("(");
+ });
+});
+
+describe("isCliEntrypoint", () => {
+ it("matches direct execution paths", () => {
+ expect(isCliEntrypoint("/tmp/pi-web-cli.js", "/tmp/pi-web-cli.js")).toBe(true);
+ });
+
+ it("matches npm-style symlinked bin entrypoints", () => {
+ const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
+ try {
+ const target = join(dir, "dist", "cli.js");
+ const symlink = join(dir, "bin", "pi-web");
+ mkdirSync(join(dir, "dist"));
+ mkdirSync(join(dir, "bin"));
+ writeFileSync(target, "#!/usr/bin/env node\n", { mode: 0o755 });
+ symlinkSync(target, symlink);
+
+ expect(isCliEntrypoint(symlink, target)).toBe(true);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("does not match unrelated paths", () => {
+ expect(isCliEntrypoint("/tmp/pi-web", "/tmp/other-pi-web")).toBe(false);
+ });
+});
diff --git a/src/cli.ts b/src/cli.ts
index d7f9dea..00f1409 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
-import { existsSync, readFileSync } from "node:fs";
+import { existsSync, readFileSync, realpathSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
@@ -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,
@@ -900,8 +902,12 @@ function commandCheck(command: string): string {
return `command -v ${command}`;
}
-function commandWithVersionCheck(command: string): string {
- return `${commandCheck(command)} && (${command} --version 2>&1 || true)`;
+export function commandWithVersionCheck(command: string): string {
+ const found = commandCheck(command);
+ if (detectServiceShell().name === "fish") {
+ return `${found} && begin; ${command} --version 2>&1 || true; end`;
+ }
+ return `${found} && (${command} --version 2>&1 || true)`;
}
function nodeVersionCheck(): string {
@@ -1086,7 +1092,19 @@ async function main(): Promise {
else throw new Error(`Unknown command: ${command}`);
}
-main().catch((error: unknown) => {
- console.error(error instanceof Error ? error.message : String(error));
- process.exit(1);
-});
+export function isCliEntrypoint(entrypoint: string | undefined = process.argv[1], modulePath: string = fileURLToPath(import.meta.url)): boolean {
+ if (entrypoint === undefined) return false;
+ if (entrypoint === modulePath) return true;
+ try {
+ return realpathSync(entrypoint) === realpathSync(modulePath);
+ } catch {
+ return false;
+ }
+}
+
+if (isCliEntrypoint()) {
+ main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ });
+}
diff --git a/src/client/src/api.ts b/src/client/src/api.ts
index 63d4243..11bdca8 100644
--- a/src/client/src/api.ts
+++ b/src/client/src/api.ts
@@ -1,3 +1,5 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
-export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
+export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
+export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts
index 90b865a..161d892 100644
--- a/src/client/src/api/clients.test.ts
+++ b/src/client/src/api/clients.test.ts
@@ -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);
@@ -147,6 +167,69 @@ describe("machine-scoped terminal command-run API", () => {
});
});
+describe("workspace file write API", () => {
+ it("sends text content with Content-Type text/plain", async () => {
+ const fetchMock = stubJsonFetch({ path: "hello.txt", size: 11, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
+
+ await workspacesApi.writeWorkspaceFile("p 1", "w/1", "hello.txt", "hello world");
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchCall(fetchMock, 0);
+ expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
+ expect(init?.method).toBe("PUT");
+ expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
+ });
+
+ it("sends binary content with Content-Type application/octet-stream", async () => {
+ const fetchMock = stubJsonFetch({ path: "image.png", size: 4, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
+ const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
+
+ await workspacesApi.writeWorkspaceFile("p 1", "w/1", "image.png", binary);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchCall(fetchMock, 0);
+ expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
+ expect(init?.method).toBe("PUT");
+ expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
+ });
+
+ it("sends createDirs and overwrite query parameters", async () => {
+ const fetchMock = stubJsonFetch({ path: "config/new.json", size: 10, modifiedAt: "2026-06-10T00:00:00.000Z", created: true });
+
+ await workspacesApi.writeWorkspaceFile("p 1", "w/1", "config/new.json", "{\"a\":1}", { createDirs: false, overwrite: false });
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url] = fetchCall(fetchMock, 0);
+ expect(url).toContain("createDirs=false");
+ expect(url).toContain("overwrite=false");
+ });
+
+ it("parses WriteWorkspaceFileResponse correctly", async () => {
+ const fetchMock = stubJsonFetch({ path: "output/result.txt", size: 42, modifiedAt: "2026-06-10T12:00:00.000Z", created: true });
+
+ const result = await workspacesApi.writeWorkspaceFile("p 1", "w/1", "output/result.txt", "content");
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+
+ expect(result).toEqual({
+ path: "output/result.txt",
+ size: 42,
+ modifiedAt: "2026-06-10T12:00:00.000Z",
+ created: true,
+ });
+ });
+
+ it("routes through machine prefix for remote machines", async () => {
+ const fetchMock = stubJsonFetch({ path: "file.txt", size: 5, modifiedAt: "2026-06-10T00:00:00.000Z", created: false });
+
+ await workspacesApi.writeWorkspaceFile("p 1", "w/1", "file.txt", "data", undefined, "remote a");
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url] = fetchCall(fetchMock, 0);
+ expect(url).toContain("/api/machines/remote%20a/");
+ });
+});
+
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise;
type FetchMock = ReturnType>;
diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts
index 628cdd5..375a589 100644
--- a/src/client/src/api/clients.ts
+++ b/src/client/src/api/clients.ts
@@ -1,4 +1,4 @@
-import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
+import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -9,6 +9,7 @@ import {
parseClosed,
parseCommandResult,
parseDeleted,
+ parseDeleteWorkspaceFileResponse,
parseDetached,
parseFileContentResponse,
parseFileSuggestion,
@@ -21,6 +22,7 @@ import {
parseMachinesResponse,
parseMessagePage,
parseModelSelectionResponse,
+ parseMoveWorkspaceFileResponse,
parseOAuthFlowState,
parsePiWebConfigResponse,
parsePiWebPluginsResponse,
@@ -37,6 +39,7 @@ import {
parseTerminalCommandRun,
parseTerminalInfo,
parseThinkingLevelsResponse,
+ parseWriteWorkspaceFileResponse,
parseWorkspace,
parseWorkspaceActivityResponse,
} from "./parsers";
@@ -118,6 +121,32 @@ export const workspacesApi = {
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
+ writeWorkspaceFile: (projectId: string, workspaceId: string, path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions, machineId = "local") => {
+ const params = new URLSearchParams({ path });
+ if (options?.createDirs === false) params.set("createDirs", "false");
+ if (options?.overwrite === false) params.set("overwrite", "false");
+ const isBinary = content instanceof Uint8Array;
+ const body: BodyInit = isBinary ? new Uint8Array(content) : new TextEncoder().encode(content);
+ return request(
+ `${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`,
+ parseWriteWorkspaceFileResponse,
+ { method: "PUT", body, headers: { "Content-Type": isBinary ? "application/octet-stream" : "text/plain" } },
+ );
+ },
+ deleteWorkspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local"): Promise => {
+ const params = new URLSearchParams({ path });
+ return request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, parseDeleteWorkspaceFileResponse, { method: "DELETE" });
+ },
+ moveWorkspaceFile: (projectId: string, workspaceId: string, fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions, machineId = "local") => {
+ const params = new URLSearchParams({ fromPath, toPath });
+ if (options?.createDirs === false) params.set("createDirs", "false");
+ if (options?.overwrite === true) params.set("overwrite", "true");
+ return request(
+ `${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/move?${params.toString()}`,
+ parseMoveWorkspaceFileResponse,
+ { method: "POST" },
+ );
+ },
};
export const sessionsApi = {
@@ -209,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));
},
};
diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts
index 059212e..faec42e 100644
--- a/src/client/src/api/federatedRouteContract.test.ts
+++ b/src/client/src/api/federatedRouteContract.test.ts
@@ -37,7 +37,11 @@ describe("federated route contract", () => {
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
+ ignoreParseFailure(workspacesApi.writeWorkspaceFile("p 1", "w 1", "README.md", "hello", { overwrite: false }, machineId)),
+ 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)),
@@ -61,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)),
diff --git a/src/client/src/api/http.ts b/src/client/src/api/http.ts
index f1c0da9..dd553ff 100644
--- a/src/client/src/api/http.ts
+++ b/src/client/src/api/http.ts
@@ -1,6 +1,6 @@
export async function request(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise {
const headers = new Headers(init?.headers);
- if (init?.body !== undefined) headers.set("content-type", "application/json");
+ if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, { ...init, headers });
if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({}));
diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts
index 7e8beff..69e39bc 100644
--- a/src/client/src/api/parsers.test.ts
+++ b/src/client/src/api/parsers.test.ts
@@ -1,20 +1,20 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
-import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
+import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
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 },
+ 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"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
+ effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
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 },
+ 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"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
+ effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
});
});
@@ -74,6 +74,50 @@ describe("API parsers", () => {
});
});
+ it("parses workspace effective upload config when present", () => {
+ expect(parseWorkspace({
+ id: "w1",
+ projectId: "p1",
+ path: "/repo",
+ label: "main",
+ branch: "main",
+ isMain: true,
+ isGitRepo: true,
+ isGitWorktree: false,
+ effectiveConfig: { uploads: { defaultFolder: "manual/uploads" } },
+ })).toEqual({
+ id: "w1",
+ projectId: "p1",
+ path: "/repo",
+ label: "main",
+ branch: "main",
+ isMain: true,
+ isGitRepo: true,
+ isGitWorktree: false,
+ effectiveConfig: { uploads: { defaultFolder: "manual/uploads" } },
+ });
+ });
+
+ it("accepts legacy workspace responses without effective config", () => {
+ expect(parseWorkspace({
+ id: "w1",
+ projectId: "p1",
+ path: "/repo",
+ label: "main",
+ isMain: true,
+ isGitRepo: false,
+ isGitWorktree: false,
+ })).toEqual({
+ id: "w1",
+ projectId: "p1",
+ path: "/repo",
+ label: "main",
+ isMain: true,
+ isGitRepo: false,
+ isGitWorktree: false,
+ });
+ });
+
it("parses workspace activity snapshots", () => {
expect(parseWorkspaceActivityResponse({
generatedAt: "now",
diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts
index 53923cb..dd8dae2 100644
--- a/src/client/src/api/parsers.ts
+++ b/src/client/src/api/parsers.ts
@@ -1,4 +1,4 @@
-import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
+import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record {
@@ -141,6 +141,15 @@ export function parseWorkspace(value: unknown): Workspace {
isMain: requireBoolean(record, "isMain"),
isGitRepo: requireBoolean(record, "isGitRepo"),
isGitWorktree: requireBoolean(record, "isGitWorktree"),
+ ...optionalField("effectiveConfig", optionalWorkspaceEffectiveConfig(record["effectiveConfig"])),
+ };
+}
+
+function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveConfig"] | undefined {
+ if (value === undefined) return undefined;
+ if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid workspace effectiveConfig field");
+ return {
+ ...optionalField("uploads", optionalUploads(value["uploads"])),
};
}
@@ -336,6 +345,34 @@ export function parseFileContentResponse(value: unknown): FileContentResponse {
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), ...optionalField("mediaType", optionalFileMediaType(record["mediaType"])), ...optionalField("mimeType", optionalString(record, "mimeType")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
}
+export function parseWriteWorkspaceFileResponse(value: unknown): WriteWorkspaceFileResponse {
+ const record = requireRecord(value);
+ return {
+ path: requireString(record, "path"),
+ size: requireNumber(record, "size"),
+ modifiedAt: requireString(record, "modifiedAt"),
+ created: requireBoolean(record, "created"),
+ };
+}
+
+export function parseDeleteWorkspaceFileResponse(value: unknown): DeleteWorkspaceFileResponse {
+ const record = requireRecord(value);
+ return {
+ path: requireString(record, "path"),
+ existed: requireBoolean(record, "existed"),
+ };
+}
+
+export function parseMoveWorkspaceFileResponse(value: unknown): MoveWorkspaceFileResponse {
+ const record = requireRecord(value);
+ return {
+ fromPath: requireString(record, "fromPath"),
+ toPath: requireString(record, "toPath"),
+ size: requireNumber(record, "size"),
+ modifiedAt: requireString(record, "modifiedAt"),
+ };
+}
+
function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined {
if (value === undefined) return undefined;
if (value !== "image") throw new Error("Invalid file media type");
@@ -445,6 +482,9 @@ 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("uploads", optionalUploads(record["uploads"])),
+ ...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
};
@@ -453,10 +493,41 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
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 optionalUploads(value: unknown): PiWebConfigValues["uploads"] | undefined {
+ if (value === undefined) return undefined;
+ if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB uploads field");
+ return {
+ ...optionalField("defaultFolder", optionalString(value, "defaultFolder")),
+ };
+}
+
+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");
diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts
index 744c82d..b532924 100644
--- a/src/client/src/api/urls.ts
+++ b/src/client/src/api/urls.ts
@@ -28,6 +28,14 @@ export function messageUrl(session: SessionLookup, options?: { limit?: number; b
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
}
+export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string {
+ const params = new URLSearchParams({ path });
+ if (options?.createDirs === false) params.set("createDirs", "false");
+ if (options?.overwrite === false) params.set("overwrite", "false");
+ const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
+ return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`;
+}
+
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
const params = new URLSearchParams();
params.set("path", path);
diff --git a/src/client/src/api/workspaceUploads.test.ts b/src/client/src/api/workspaceUploads.test.ts
new file mode 100644
index 0000000..e36e855
--- /dev/null
+++ b/src/client/src/api/workspaceUploads.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from "vitest";
+import {
+ effectiveWorkspaceUploadFolder,
+ uploadWorkspaceFile,
+ uploadWorkspaceFiles,
+ workspaceEffectiveUploadFolder,
+ workspaceUploadPath,
+ WorkspaceUploadBatchError,
+ WorkspaceUploadCancelledError,
+ type WorkspaceUploadBatchProgress,
+ type WorkspaceFileUploadProgress,
+ type WorkspaceUploadXhr,
+} from "./workspaceUploads";
+
+describe("workspace upload helpers", () => {
+ it("resolves effective upload defaults and workspace-relative paths", () => {
+ expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads");
+ expect(effectiveWorkspaceUploadFolder({ uploads: { defaultFolder: "manual/uploads" } })).toBe("manual/uploads");
+ expect(workspaceEffectiveUploadFolder({ uploads: { defaultFolder: "project/uploads" } }, "global/uploads")).toBe("project/uploads");
+ expect(workspaceEffectiveUploadFolder(undefined, "global/uploads")).toBe("global/uploads");
+ expect(workspaceUploadPath(" uploads\\manual// ", "./report.txt")).toBe("uploads/manual/report.txt");
+ expect(workspaceUploadPath("", "report.txt")).toBe("report.txt");
+
+ expect(() => workspaceUploadPath("/tmp", "report.txt")).toThrow("workspace-relative");
+ expect(() => workspaceUploadPath("uploads", "../secret.txt")).toThrow("path traversal");
+ expect(() => workspaceUploadPath("uploads", " ")).toThrow("must not be empty");
+ });
+
+ it("uploads one workspace file through XHR with progress and parses the final response", async () => {
+ const xhrs = new FakeXhrQueue();
+ const progress: WorkspaceFileUploadProgress[] = [];
+ const file = new File(["hello"], "hello.txt", { type: "text/plain" });
+
+ const task = uploadWorkspaceFile("p 1", "w/1", { path: "manual/hello.txt", file }, {
+ machineId: "remote a",
+ overwrite: false,
+ xhrFactory: xhrs.factory,
+ onProgress: (event) => { progress.push(event); },
+ });
+
+ const xhr = xhrs.only();
+ expect(xhr.method).toBe("PUT");
+ expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
+ expect(xhr.headers.get("content-type")).toBe("text/plain");
+ expect(xhr.body).toBe(file);
+
+ xhr.emitUploadProgress(2, 5);
+ xhr.respondJson(200, { path: "manual/hello.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
+
+ await expect(task.promise).resolves.toEqual({ path: "manual/hello.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
+ expect(progress).toEqual([
+ { loaded: 2, total: 5, percent: 0.4, lengthComputable: true },
+ { loaded: 5, total: 5, percent: 1, lengthComputable: true },
+ ]);
+ });
+
+ it("cancels an in-flight workspace file upload", async () => {
+ const xhrs = new FakeXhrQueue();
+ const file = new File(["hello"], "hello.txt");
+
+ const task = uploadWorkspaceFile("p1", "w1", { path: "uploads/hello.txt", file }, { xhrFactory: xhrs.factory });
+ task.cancel();
+
+ await expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError);
+ expect(xhrs.only().aborted).toBe(true);
+ });
+
+ it("uploads a batch sequentially and reports aggregate progress", async () => {
+ const xhrs = new FakeXhrQueue();
+ const progress: WorkspaceUploadBatchProgress[] = [];
+ const files = [new File(["ab"], "a.txt", { type: "text/plain" }), new File(["cde"], "b.txt")];
+
+ const task = uploadWorkspaceFiles("p 1", "w/1", files, {
+ destinationFolder: "uploads//manual",
+ machineId: "remote a",
+ xhrFactory: xhrs.factory,
+ onProgress: (event) => { progress.push(event); },
+ });
+
+ const first = xhrs.at(0);
+ expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
+ first.emitUploadProgress(1, 2);
+ first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
+ await Promise.resolve();
+
+ const second = xhrs.at(1);
+ expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
+ second.emitUploadProgress(3, 3);
+ second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true });
+
+ await expect(task.promise).resolves.toEqual([
+ { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true },
+ { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true },
+ ]);
+ expect(progress[0]).toMatchObject({ currentFileIndex: 0, loaded: 1, total: 5, percent: 0.2, done: false });
+ expect(progress.at(-1)).toMatchObject({ currentFileIndex: 1, loaded: 5, total: 5, percent: 1, done: true });
+ expect(progress.at(-1)?.files.map((file) => ({ path: file.path, loaded: file.loaded, total: file.total, done: file.done }))).toEqual([
+ { path: "uploads/manual/a.txt", loaded: 2, total: 2, done: true },
+ { path: "uploads/manual/b.txt", loaded: 3, total: 3, done: true },
+ ]);
+ });
+
+ it("continues batch uploads after per-file failures and reports the failed file only", async () => {
+ const xhrs = new FakeXhrQueue();
+ const progress: WorkspaceUploadBatchProgress[] = [];
+ const files = [new File(["ab"], "duplicate.txt"), new File(["cde"], "new.txt")];
+
+ const task = uploadWorkspaceFiles("p1", "w1", files, {
+ destinationFolder: "uploads",
+ overwrite: false,
+ xhrFactory: xhrs.factory,
+ onProgress: (event) => { progress.push(event); },
+ });
+
+ xhrs.at(0).respondJson(409, { error: "File already exists: uploads/duplicate.txt" }, "Conflict");
+ await Promise.resolve();
+ xhrs.at(1).respondJson(200, { path: "uploads/new.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true });
+
+ await expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadBatchError);
+ await task.promise.catch((error: unknown) => {
+ if (!(error instanceof WorkspaceUploadBatchError)) throw error;
+ expect(error.failures).toEqual([{ index: 0, name: "duplicate.txt", path: "uploads/duplicate.txt", error: "File already exists: uploads/duplicate.txt" }]);
+ expect(error.responses).toEqual([{ path: "uploads/new.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }]);
+ });
+ expect(progress.at(-1)?.files.map((file) => ({ path: file.path, done: file.done, error: file.error }))).toEqual([
+ { path: "uploads/duplicate.txt", done: true, error: "File already exists: uploads/duplicate.txt" },
+ { path: "uploads/new.txt", done: true, error: undefined },
+ ]);
+ });
+});
+
+class FakeXhrQueue {
+ private readonly instances: FakeXMLHttpRequest[] = [];
+
+ readonly factory = (): WorkspaceUploadXhr => {
+ const xhr = new FakeXMLHttpRequest();
+ this.instances.push(xhr);
+ return xhr;
+ };
+
+ only(): FakeXMLHttpRequest {
+ expect(this.instances).toHaveLength(1);
+ return this.instances[0] ?? failTest("missing XHR instance");
+ }
+
+ at(index: number): FakeXMLHttpRequest {
+ return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`);
+ }
+}
+
+class FakeXMLHttpRequest implements WorkspaceUploadXhr {
+ readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null };
+ readonly headers = new Map();
+ method = "";
+ url = "";
+ async = true;
+ body: XMLHttpRequestBodyInit | Document | null = null;
+ responseType: XMLHttpRequestResponseType = "";
+ response: unknown;
+ responseText = "";
+ status = 0;
+ statusText = "";
+ aborted = false;
+ onload: ((event: ProgressEvent) => void) | null = null;
+ onerror: ((event: ProgressEvent) => void) | null = null;
+ onabort: ((event: ProgressEvent) => void) | null = null;
+
+ open(method: string, url: string, async = true): void {
+ this.method = method;
+ this.url = url;
+ this.async = async;
+ }
+
+ setRequestHeader(name: string, value: string): void {
+ this.headers.set(name.toLowerCase(), value);
+ }
+
+ send(body?: XMLHttpRequestBodyInit | Document | null): void {
+ this.body = body ?? null;
+ }
+
+ abort(): void {
+ this.aborted = true;
+ this.onabort?.(fakeProgressEvent());
+ }
+
+ emitUploadProgress(loaded: number, total: number, lengthComputable = true): void {
+ this.upload.onprogress?.(fakeProgressEvent(loaded, total, lengthComputable));
+ }
+
+ respondJson(status: number, body: unknown, statusText = "OK"): void {
+ this.status = status;
+ this.statusText = statusText;
+ this.response = body;
+ this.responseText = JSON.stringify(body);
+ this.onload?.(fakeProgressEvent());
+ }
+}
+
+function fakeProgressEvent(loaded = 0, total = 0, lengthComputable = false): ProgressEvent {
+ return new FakeProgressEvent(loaded, total, lengthComputable);
+}
+
+class FakeProgressEvent extends Event implements ProgressEvent {
+ readonly loaded: number;
+ readonly total: number;
+ readonly lengthComputable: boolean;
+
+ constructor(loaded: number, total: number, lengthComputable: boolean) {
+ super("progress");
+ this.loaded = loaded;
+ this.total = total;
+ this.lengthComputable = lengthComputable;
+ }
+}
+
+function failTest(message: string): never {
+ throw new Error(message);
+}
diff --git a/src/client/src/api/workspaceUploads.ts b/src/client/src/api/workspaceUploads.ts
new file mode 100644
index 0000000..7ad1897
--- /dev/null
+++ b/src/client/src/api/workspaceUploads.ts
@@ -0,0 +1,355 @@
+import type { WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../../shared/apiTypes";
+import { parseWriteWorkspaceFileResponse } from "./parsers";
+import { workspaceFileWriteUrl } from "./urls";
+
+export const DEFAULT_WORKSPACE_UPLOADS_FOLDER = ".pi-web/uploads";
+
+export interface WorkspaceUploadFileInput {
+ path: string;
+ file: Blob;
+ contentType?: string;
+}
+
+export interface WorkspaceFileUploadProgress {
+ loaded: number;
+ total: number;
+ percent: number;
+ lengthComputable: boolean;
+}
+
+export interface WorkspaceUploadBatchFileProgress extends WorkspaceFileUploadProgress {
+ index: number;
+ name: string;
+ path: string;
+ done: boolean;
+ error?: string;
+}
+
+export interface WorkspaceUploadFileFailure {
+ index: number;
+ name: string;
+ path: string;
+ error: string;
+}
+
+export interface WorkspaceUploadBatchProgress {
+ currentFileIndex: number;
+ files: WorkspaceUploadBatchFileProgress[];
+ loaded: number;
+ total: number;
+ percent: number;
+ done: boolean;
+}
+
+export interface WorkspaceUploadTask {
+ promise: Promise;
+ cancel(): void;
+}
+
+export interface WorkspaceUploadXhr {
+ upload: { onprogress: ((event: ProgressEvent) => void) | null };
+ responseType: XMLHttpRequestResponseType;
+ response: unknown;
+ responseText: string;
+ status: number;
+ statusText: string;
+ onload: ((event: ProgressEvent) => void) | null;
+ onerror: ((event: ProgressEvent) => void) | null;
+ onabort: ((event: ProgressEvent) => void) | null;
+ open(method: string, url: string, async?: boolean): void;
+ setRequestHeader(name: string, value: string): void;
+ send(body?: XMLHttpRequestBodyInit | Document | null): void;
+ abort(): void;
+}
+
+export type WorkspaceUploadXhrFactory = () => WorkspaceUploadXhr;
+
+export interface UploadWorkspaceFileOptions extends WriteWorkspaceFileOptions {
+ machineId?: string;
+ xhrFactory?: WorkspaceUploadXhrFactory;
+ onProgress?: (progress: WorkspaceFileUploadProgress) => void;
+}
+
+export interface UploadWorkspaceFilesOptions extends WriteWorkspaceFileOptions {
+ destinationFolder?: string;
+ machineId?: string;
+ xhrFactory?: WorkspaceUploadXhrFactory;
+ onProgress?: (progress: WorkspaceUploadBatchProgress) => void;
+}
+
+export class WorkspaceUploadCancelledError extends Error {
+ constructor(message = "Workspace upload cancelled") {
+ super(message);
+ this.name = "WorkspaceUploadCancelledError";
+ }
+}
+
+export class WorkspaceUploadBatchError extends Error {
+ readonly failures: WorkspaceUploadFileFailure[];
+ readonly responses: WriteWorkspaceFileResponse[];
+
+ constructor(failures: readonly WorkspaceUploadFileFailure[], responses: readonly WriteWorkspaceFileResponse[]) {
+ super(uploadBatchErrorMessage(failures));
+ this.name = "WorkspaceUploadBatchError";
+ this.failures = failures.map((failure) => ({ ...failure }));
+ this.responses = responses.map((response) => ({ ...response }));
+ }
+}
+
+export interface WorkspaceUploadFolderConfig {
+ uploads?: {
+ defaultFolder?: string;
+ };
+}
+
+export function effectiveWorkspaceUploadFolder(config: WorkspaceUploadFolderConfig | undefined): string {
+ return config?.uploads?.defaultFolder ?? DEFAULT_WORKSPACE_UPLOADS_FOLDER;
+}
+
+export function workspaceEffectiveUploadFolder(config: WorkspaceUploadFolderConfig | undefined, fallbackFolder: string): string {
+ return config?.uploads?.defaultFolder ?? fallbackFolder;
+}
+
+export function workspaceUploadPath(destinationFolder: string, fileName: string): string {
+ const folder = normalizeWorkspaceUploadPath(destinationFolder, "upload destination", { allowEmpty: true });
+ const name = normalizeWorkspaceUploadPath(fileName, "upload file name", { allowEmpty: false });
+ return folder === "" ? name : `${folder}/${name}`;
+}
+
+export function uploadWorkspaceFile(
+ projectId: string,
+ workspaceId: string,
+ input: WorkspaceUploadFileInput,
+ options: UploadWorkspaceFileOptions = {},
+): WorkspaceUploadTask {
+ const xhr: WorkspaceUploadXhr = options.xhrFactory?.() ?? new XMLHttpRequest();
+ let settled = false;
+ let cancelled = false;
+
+ const promise = new Promise((resolve, reject) => {
+ const fail = (error: Error) => {
+ if (settled) return;
+ settled = true;
+ reject(error);
+ };
+ const succeed = (response: WriteWorkspaceFileResponse) => {
+ if (settled) return;
+ settled = true;
+ resolve(response);
+ };
+
+ xhr.open("PUT", workspaceFileWriteUrl(projectId, workspaceId, input.path, uploadWriteUrlOptions(options)), true);
+ xhr.responseType = "json";
+ xhr.setRequestHeader("Content-Type", (input.contentType ?? input.file.type) || "application/octet-stream");
+ xhr.upload.onprogress = (event) => {
+ options.onProgress?.(progressFromEvent(event, input.file.size));
+ };
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ try {
+ options.onProgress?.({ loaded: input.file.size, total: input.file.size, percent: 1, lengthComputable: true });
+ succeed(parseWriteWorkspaceFileResponse(readXhrJson(xhr)));
+ } catch (error) {
+ fail(error instanceof Error ? error : new Error(String(error)));
+ }
+ return;
+ }
+ fail(new Error(readXhrErrorMessage(xhr)));
+ };
+ xhr.onerror = () => { fail(new Error("Workspace upload failed")); };
+ xhr.onabort = () => { fail(new WorkspaceUploadCancelledError(cancelled ? undefined : "Workspace upload aborted")); };
+ xhr.send(input.file);
+ });
+
+ return {
+ promise,
+ cancel: () => {
+ if (settled) return;
+ cancelled = true;
+ xhr.abort();
+ },
+ };
+}
+
+export function uploadWorkspaceFiles(
+ projectId: string,
+ workspaceId: string,
+ files: readonly File[],
+ options: UploadWorkspaceFilesOptions = {},
+): WorkspaceUploadTask {
+ const destinationFolder = options.destinationFolder ?? DEFAULT_WORKSPACE_UPLOADS_FOLDER;
+ const progressFiles = files.map((file, index): WorkspaceUploadBatchFileProgress => ({
+ index,
+ name: file.name,
+ path: workspaceUploadPath(destinationFolder, file.name),
+ loaded: 0,
+ total: file.size,
+ percent: percentFor(0, file.size),
+ lengthComputable: true,
+ done: false,
+ }));
+ let currentTask: WorkspaceUploadTask | undefined;
+ let currentFileIndex = 0;
+ const cancellation = { requested: false };
+
+ const emit = () => {
+ options.onProgress?.(batchProgressSnapshot(progressFiles, currentFileIndex, progressFiles.every((file) => file.done)));
+ };
+
+ const promise = (async (): Promise => {
+ const responses: WriteWorkspaceFileResponse[] = [];
+ const failures: WorkspaceUploadFileFailure[] = [];
+ for (let index = 0; index < files.length; index += 1) {
+ if (cancellation.requested) throw new WorkspaceUploadCancelledError();
+ currentFileIndex = index;
+ const file = files[index];
+ const progressFile = progressFiles[index];
+ if (file === undefined || progressFile === undefined) continue;
+ currentTask = uploadWorkspaceFile(projectId, workspaceId, { path: progressFile.path, file }, {
+ ...uploadWriteOptions(options),
+ onProgress: (progress) => {
+ progressFile.total = progress.total;
+ progressFile.loaded = Math.min(progress.loaded, progressFile.total);
+ progressFile.percent = progress.percent;
+ progressFile.lengthComputable = progress.lengthComputable;
+ emit();
+ },
+ });
+ try {
+ const response = await currentTask.promise;
+ progressFile.loaded = progressFile.total;
+ progressFile.percent = 1;
+ progressFile.lengthComputable = true;
+ progressFile.done = true;
+ responses.push(response);
+ emit();
+ } catch (error) {
+ if (isUploadCancellation(error, cancellation)) throw error;
+ const message = errorMessage(error);
+ progressFile.loaded = progressFile.total;
+ progressFile.percent = 1;
+ progressFile.lengthComputable = true;
+ progressFile.done = true;
+ progressFile.error = message;
+ failures.push({ index, name: file.name, path: progressFile.path, error: message });
+ emit();
+ } finally {
+ currentTask = undefined;
+ }
+ }
+ if (failures.length > 0) throw new WorkspaceUploadBatchError(failures, responses);
+ return responses;
+ })();
+
+ return {
+ promise,
+ cancel: () => {
+ cancellation.requested = true;
+ currentTask?.cancel();
+ },
+ };
+}
+
+function uploadWriteOptions(options: UploadWorkspaceFilesOptions): UploadWorkspaceFileOptions {
+ return {
+ ...(options.createDirs === undefined ? {} : { createDirs: options.createDirs }),
+ ...(options.overwrite === undefined ? {} : { overwrite: options.overwrite }),
+ ...(options.machineId === undefined ? {} : { machineId: options.machineId }),
+ ...(options.xhrFactory === undefined ? {} : { xhrFactory: options.xhrFactory }),
+ };
+}
+
+function uploadWriteUrlOptions(options: UploadWorkspaceFileOptions): { createDirs?: boolean; overwrite?: boolean; machineId?: string } {
+ return {
+ ...(options.createDirs === undefined ? {} : { createDirs: options.createDirs }),
+ ...(options.overwrite === undefined ? {} : { overwrite: options.overwrite }),
+ ...(options.machineId === undefined ? {} : { machineId: options.machineId }),
+ };
+}
+
+function progressFromEvent(event: ProgressEvent, fallbackTotal: number): WorkspaceFileUploadProgress {
+ const total = event.lengthComputable ? event.total : fallbackTotal;
+ return {
+ loaded: event.loaded,
+ total,
+ percent: percentFor(event.loaded, total),
+ lengthComputable: event.lengthComputable,
+ };
+}
+
+function batchProgressSnapshot(files: WorkspaceUploadBatchFileProgress[], currentFileIndex: number, done: boolean): WorkspaceUploadBatchProgress {
+ const total = files.reduce((sum, file) => sum + file.total, 0);
+ const loaded = files.reduce((sum, file) => sum + file.loaded, 0);
+ return {
+ currentFileIndex,
+ files: files.map((file) => ({ ...file })),
+ loaded,
+ total,
+ percent: percentFor(loaded, total),
+ done,
+ };
+}
+
+function percentFor(loaded: number, total: number): number {
+ if (total <= 0) return loaded <= 0 ? 0 : 1;
+ return Math.max(0, Math.min(1, loaded / total));
+}
+
+function uploadBatchErrorMessage(failures: readonly WorkspaceUploadFileFailure[]): string {
+ if (failures.length === 1) return failures[0]?.error ?? "Workspace upload failed";
+ return `${String(failures.length)} files failed to upload`;
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function isUploadCancellation(error: unknown, cancellation: { requested: boolean }): boolean {
+ return cancellation.requested || error instanceof WorkspaceUploadCancelledError;
+}
+
+function normalizeWorkspaceUploadPath(value: string, label: string, options: { allowEmpty: boolean }): string {
+ const trimmed = value.trim();
+ if (trimmed === "") {
+ if (options.allowEmpty) return "";
+ throw new Error(`${label} must not be empty`);
+ }
+ if (isAbsoluteLike(trimmed)) throw new Error(`${label} must be workspace-relative`);
+ const parts = trimmed.split(/[\\/]+/u).filter((part) => part !== "" && part !== ".");
+ if (parts.length === 0) {
+ if (options.allowEmpty) return "";
+ throw new Error(`${label} must not be empty`);
+ }
+ if (parts.some((part) => part === "..")) throw new Error(`${label} must not contain path traversal`);
+ return parts.join("/");
+}
+
+function isAbsoluteLike(value: string): boolean {
+ const withForwardSlashes = value.replace(/\\/g, "/");
+ return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
+}
+
+function readXhrJson(xhr: WorkspaceUploadXhr): unknown {
+ if (xhr.response !== undefined && xhr.response !== null && xhr.response !== "") return xhr.response;
+ if (xhr.responseText === "") return {};
+ const parsed: unknown = JSON.parse(xhr.responseText);
+ return parsed;
+}
+
+function readXhrErrorMessage(xhr: WorkspaceUploadXhr): string {
+ const body = safeReadXhrJson(xhr);
+ if (isRecord(body) && typeof body["error"] === "string") return body["error"];
+ return xhr.statusText || `HTTP ${String(xhr.status)}`;
+}
+
+function safeReadXhrJson(xhr: WorkspaceUploadXhr): unknown {
+ try {
+ return readXhrJson(xhr);
+ } catch {
+ return undefined;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts
index dc59f43..83ac086 100644
--- a/src/client/src/appState.ts
+++ b/src/client/src/appState.ts
@@ -1,6 +1,7 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids";
+import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
export interface AppState {
machines: Machine[];
@@ -49,6 +50,8 @@ export interface AppState {
selectedFilePath: string | undefined;
selectedFileContent: FileContentResponse | undefined;
fileTreeStale: boolean;
+ /** Manual workspace file upload batches, keyed by client-owned batch id. */
+ workspaceUploadBatches: Record;
gitStatus: GitStatusResponse | undefined;
selectedDiffPath: string | undefined;
selectedDiff: GitDiffResponse | undefined;
@@ -147,6 +150,7 @@ export function initialAppState(): AppState {
selectedFilePath: undefined,
selectedFileContent: undefined,
fileTreeStale: false,
+ workspaceUploadBatches: {},
gitStatus: undefined,
selectedDiffPath: undefined,
selectedDiff: undefined,
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 43dfa04..5a438e2 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
-import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
+import { configApi, effectiveWorkspaceUploadFolder, piWebApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
-import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
+import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
@@ -152,6 +152,7 @@ export class PiWebApp extends LitElement {
private readonly handledWorkspaceDeletionRunIds = new Set();
private readonly terminalCommandRunRuntimes = new Map();
private machineNavigationRestoreSeq = 0;
+ private navigationSelectionSeq = 0;
private routeRestoreSeq = 0;
private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined;
@@ -168,6 +169,7 @@ export class PiWebApp extends LitElement {
@state() private isRefreshingApp = false;
@state() private settingsSection: SettingsSection | undefined = readSettingsSection();
@state() private shortcutConfig: PiWebShortcutConfig = {};
+ @state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
private readonly onPopState = () => void this.withChatScrollTransition(async () => {
this.restoreSettingsRoute();
await this.restoreRoute(false);
@@ -323,7 +325,7 @@ export class PiWebApp extends LitElement {
private async loadClientConfig(): Promise {
try {
- this.applyClientConfig((await configApi.config()).config);
+ this.applyClientConfig((await configApi.config()).effectiveConfig);
} catch (error) {
console.warn("Failed to load PI WEB config", error);
}
@@ -331,6 +333,7 @@ export class PiWebApp extends LitElement {
private applyClientConfig(config: PiWebConfigValues): void {
this.shortcutConfig = config.shortcuts ?? {};
+ this.workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(config);
}
private async refreshAppData(): Promise {
@@ -575,12 +578,16 @@ export class PiWebApp extends LitElement {
if (tool === "core:workspace.git") await this.git.refreshGit();
}
- private async withChatScrollTransition(action: () => Promise) {
+ private async withChatScrollTransition(action: () => Promise, 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 +1011,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 +1093,15 @@ export class PiWebApp extends LitElement {
}
private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise): Promise {
+ 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);
}
@@ -1198,6 +1218,21 @@ export class PiWebApp extends LitElement {
private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles {
return {
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
+ writeFile: async (path, content, options) => {
+ const result = await workspacesApi.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId);
+ void this.files.refreshFiles();
+ return result;
+ },
+ deleteFile: async (path) => {
+ const result = await workspacesApi.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId);
+ void this.files.refreshFiles();
+ return result;
+ },
+ moveFile: async (fromPath, toPath, options) => {
+ const result = await workspacesApi.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId);
+ void this.files.refreshFiles();
+ return result;
+ },
};
}
@@ -1217,6 +1252,7 @@ export class PiWebApp extends LitElement {
workspace,
state: this.state,
files: this.createWorkspaceFiles(workspace, machineId),
+ prompt: this.createPromptEditor(),
terminal: {
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
@@ -1237,9 +1273,13 @@ export class PiWebApp extends LitElement {
activeTerminalCount: this.state.activeTerminalCount,
selectedTerminalId: this.state.selectedTerminalId,
terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id,
+ workspaceUploadDefaultFolder: workspaceEffectiveUploadFolder(workspace.effectiveConfig, this.workspaceUploadDefaultFolder),
onRefreshFiles: () => { void this.files.refreshFiles(); },
onExpandDir: (path: string) => { void this.files.expandDir(path); },
onSelectFile: (path: string) => { void this.files.selectFile(path); },
+ onStartWorkspaceUpload: (files, options) => this.files.startWorkspaceUpload(files, options),
+ onCancelWorkspaceUpload: (batchId) => { this.files.cancelWorkspaceUpload(batchId); },
+ onClearWorkspaceUpload: (batchId) => { this.files.clearWorkspaceUpload(batchId); },
onRefreshGit: () => { void this.git.refreshGit(); },
onSelectDiff: (path: string) => { void this.git.selectDiff(path); },
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); },
@@ -1369,9 +1409,35 @@ export class PiWebApp extends LitElement {
}
}
+ private createPromptEditor(): PluginPromptEditor {
+ return {
+ insertText: (text: string) => {
+ const editor = this.promptEditor?.view;
+ if (!editor) return;
+ if (!editor.hasFocus) editor.focus();
+ const sel = editor.state.selection.main;
+ editor.dispatch({
+ changes: { from: sel.from, to: sel.to, insert: text },
+ selection: { anchor: sel.from + text.length },
+ });
+ },
+ getText: () => {
+ return this.promptEditor?.view?.state.doc.toString() ?? "";
+ },
+ getSelection: () => {
+ const editor = this.promptEditor?.view;
+ if (!editor) return null;
+ const sel = editor.state.selection.main;
+ if (sel.empty) return null;
+ return { start: sel.from, end: sel.to, text: editor.state.sliceDoc(sel.from, sel.to) };
+ },
+ };
+ }
+
private createPluginRuntimeContext(): PluginRuntimeContext {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state,
+ prompt: this.createPromptEditor(),
piWebUnstable: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); },
@@ -1738,7 +1804,7 @@ export class PiWebApp extends LitElement {