Merge remote-tracking branch 'origin/main' into feat/docker-runtime-host-admin

# Conflicts:
#	README.md
This commit is contained in:
Pi Web Agent
2026-06-25 18:58:44 +00:00
102 changed files with 8773 additions and 646 deletions
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Prevent live skill-loading cards from duplicating when the finalized transcript groups multiple skill reads.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Persist the Settings → Session daemon tracked subsessions toggle so it remains enabled after restart.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Highlight within-line changes in the Git diff viewer.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
@@ -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.
-7
View File
@@ -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.
+8
View File
@@ -10,3 +10,11 @@ When working on this project, assume the session runtime owner is long-lived and
If you make changes that affect `src/server/sessiond.ts`, session runtime ownership, the session daemon protocol, or any code path only loaded by the session daemon, inform the user that a manual restart of the session daemon is needed. 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. Changes to the web/API/UI side generally only require the `pi-web-ui-dev.service` autoreload/restart path.
## Configuration conventions
- `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API.
- Global user/machine config lives at `$PI_WEB_CONFIG` or `~/.config/pi-web/config.json`.
- Project-local PI WEB core config should use one commit-able file: `<project>/.pi-web/config.json`.
- Core features should add keys to these config files, not create one project file per feature.
- Plugins may own separate project config files, such as `.pi-web/tasks.json`.
+22
View File
@@ -1,5 +1,27 @@
# @jmfederico/pi-web # @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 ## 1.202606.4
### Patch Changes ### Patch Changes
+109 -272
View File
@@ -1,165 +1,58 @@
# PI WEB — web UI for Pi Coding Agent # PI WEB
[![CI](https://github.com/jmfederico/pi-web/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jmfederico/pi-web/actions/workflows/ci.yml) [![CI](https://github.com/jmfederico/pi-web/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jmfederico/pi-web/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@jmfederico/pi-web)](https://www.npmjs.com/package/@jmfederico/pi-web) [![npm version](https://img.shields.io/npm/v/@jmfederico/pi-web)](https://www.npmjs.com/package/@jmfederico/pi-web)
[![Node.js](https://img.shields.io/node/v/@jmfederico/pi-web)](package.json) [![Node.js](https://img.shields.io/node/v/@jmfederico/pi-web)](package.json)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Pi Coding Agent](https://img.shields.io/badge/Pi-Coding%20Agent-6f42c1)](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)
Website: <https://pi-web.dev/> **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: <https://pi-web.dev/>
![PI WEB](docs/assets/pi-web-banner.png) ![PI WEB](docs/assets/pi-web-banner.png)
**Run Pi Coding Agent from a web UI, keep sessions alive in real workspaces, and supervise them from any device.** ![PI WEB desktop screenshot](docs/assets/pi-web-desktop.png)
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?
![PI WEB demo](docs/assets/pi-web-demo.gif) 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 Pi Coding Agent sessions alive after browser disconnects;
- keep sessions running when your browser disconnects or the UI restarts; - run agents inside real repositories and git worktrees;
- organize agent work by project, workspace, branch, experiment, or review; - supervise multiple sessions in parallel;
- use git worktrees to isolate concurrent features and fixes; - switch between laptop, phone, tablet, and desktop;
- chat with Pi Coding Agent through a realtime web UI; - use a server, workstation, or remote dev box as your agent runtime;
- move fluidly between laptop, phone, tablet, and desktop without moving the development environment; - manage projects, workspaces, files, terminals, sessions, and remote machines from one web UI.
- turn any server, desktop, or remote dev box into an agent-first development hub.
## 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. Install and start PI WEB as per-user services:
## 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: <describe the UI behavior>.
Before coding, read https://pi-web.dev/plugins and https://pi-web.dev/plugins.md.
Create it under ~/.pi-web/plugins/<plugin-id> using the documented PI WEB v1 plugin API.
Validate with /pi-web-plugins/manifest.json and explain reload/debug steps.
Do not modify PI WEB itself.
```
Manage discovered plugins in **Settings → Plugins** or with the top-level `plugins` config key. Plugins are enabled by default; set `plugins.<plugin-id>.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.
```bash ```bash
npm install -g @jmfederico/pi-web npm install -g @jmfederico/pi-web
pi-web install 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 ```text
sudo loginctl enable-linger "$USER" http://127.0.0.1:8504
loginctl show-user "$USER" -p Linger
``` ```
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 <http://127.0.0.1:8504>.
Useful commands: Useful commands:
```bash ```bash
@@ -171,64 +64,108 @@ pi-web version
pi-web uninstall 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 ```bash
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh
``` ```
### Docker local-build runtime For trusted local/server installs, PI WEB also has a Docker local-build runtime:
A Docker runtime is available for trusted local/server installs without using prebuilt images:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh 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 ```bash
pi install npm:@jmfederico/pi-web 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 ```text
/pi-web install Machine a local or remote PI WEB runtime endpoint
/pi-web status Project a folder on that machine
/pi-web logs Workspace a git worktree, or the project folder for non-git projects
/pi-web restart Session a Pi Coding Agent chat running inside a workspace
/pi-web doctor
/pi-web version
``` ```
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 ## Remote-first development
pi-web-sessiond
PI_WEB_PORT=8504 pi-web-server 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
<project>/.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 ```bash
npm install npm install
npm run dev npm run dev
``` ```
Open the Vite URL, usually <http://localhost:8505>. Open the Vite URL, usually:
During development, the static marketing/docs site is also served by the Vite dev server at <http://localhost:8505/site/>. ```text
http://localhost:8505
```
For the recommended split development setup, run these in separate terminals: For the split development setup:
```bash ```bash
npm run dev:sessiond npm run dev:sessiond
@@ -257,7 +194,7 @@ docker compose -f docker/compose.dev.yml up --build
Open <http://127.0.0.1:8505>. 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). Open <http://127.0.0.1:8505>. 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 ```bash
npm run build npm run build
@@ -265,127 +202,27 @@ npm run start:sessiond
PI_WEB_PORT=8504 npm start PI_WEB_PORT=8504 npm start
``` ```
## Packaging and publishing Validate changes with:
```bash ```bash
npm run verify 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. - [Website](https://pi-web.dev/)
- [Install](https://pi-web.dev/install)
The session daemon defaults to a private Unix socket at: - [Remote-first development](https://pi-web.dev/remote-first)
- [Machines / fleet](https://pi-web.dev/machines)
```text - [Configuration](https://pi-web.dev/config)
~/.pi-web/sessiond.sock - [Plugins](https://pi-web.dev/plugins)
``` - [FAQ](https://pi-web.dev/faq)
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.
## License ## License
+2
View File
@@ -40,6 +40,7 @@
<a href="/remote-first">Remote-first</a> <a href="/remote-first">Remote-first</a>
<a href="/machines">Fleet</a> <a href="/machines">Fleet</a>
<a href="/install">Install</a> <a href="/install">Install</a>
<a href="/config">Config</a>
<a href="/plugins">Plugins</a> <a href="/plugins">Plugins</a>
<a href="/faq">FAQ</a> <a href="/faq">FAQ</a>
</div> </div>
@@ -90,6 +91,7 @@
<div class="footer-links"> <div class="footer-links">
<a href="/machines">Fleet</a> <a href="/machines">Fleet</a>
<a href="/install">Install</a> <a href="/install">Install</a>
<a href="/config">Config</a>
<a href="/plugins">Plugins</a> <a href="/plugins">Plugins</a>
<a href="/faq">FAQ</a> <a href="/faq">FAQ</a>
<a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a> <a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+481
View File
@@ -0,0 +1,481 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Configure PI WEB — config files, paths, and session tools</title>
<meta
name="description"
content="Configure PI WEB config files, external path access, session daemon tools, plugins, shortcuts, uploads, and runtime environment variables."
/>
<link rel="canonical" href="https://pi-web.dev/config" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PI WEB" />
<meta property="og:title" content="Configure PI WEB" />
<meta
property="og:description"
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables."
/>
<meta property="og:url" content="https://pi-web.dev/config" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<meta property="og:image:alt" content="PI WEB browser UI for persistent Pi Coding Agent sessions" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Configure PI WEB" />
<meta
name="twitter:description"
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables."
/>
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<script>
(() => {
const theme = window.localStorage.getItem("pi-web-theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="site-header">
<nav class="container nav" aria-label="Main navigation">
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
<div class="nav-links">
<div class="nav-pages">
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config" aria-current="page">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
</div>
<div class="nav-actions">
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
/>
</svg>
<span>GitHub</span>
</a>
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
<span data-theme-icon aria-hidden="true"></span>
<span data-theme-label>Theme</span>
</button>
</div>
</div>
</nav>
</header>
<main>
<section class="page-hero">
<div class="container">
<p class="eyebrow"><span class="pulse"></span> Configuration reference</p>
<h1>Configure PI WEB where your agents work.</h1>
<p>
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload
limits, and session-daemon tools.
</p>
</div>
</section>
<section class="section compact">
<div class="container doc-layout">
<aside class="toc" aria-label="Config page contents">
<strong>On this page</strong>
<a href="#files">Config files</a>
<a href="#precedence">Precedence and reloads</a>
<a href="#global-config">Global config</a>
<a href="#project-config">Project config</a>
<a href="#keys">Config matrix</a>
<a href="#path-access">External path access</a>
<a href="#session-tools">Session tools</a>
<a href="#completion-tools">Completion tools</a>
</aside>
<div class="doc-content">
<section id="files">
<h2>Config files</h2>
<p>PI WEB uses a global config file for machine-local settings and a project-local file for repository settings.</p>
<ul>
<li><strong>Global config:</strong> <code>$PI_WEB_CONFIG</code>, or <code>$XDG_CONFIG_HOME/pi-web/config.json</code>, or <code>~/.config/pi-web/config.json</code>.</li>
<li><strong>Project config:</strong> <code>&lt;project&gt;/.pi-web/config.json</code> for commit-able project settings.</li>
</ul>
<p>
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's
config by opening that machine directly or changing files on that machine.
</p>
<p>
If you installed services with a custom config path, rerun
<code>pi-web install --config /path/to/config.json</code> after changing that path or after upgrading from
a version that only applied the custom path to the web service. This regenerates service files so the
web/API and session daemon use the same <code>PI_WEB_CONFIG</code>.
</p>
</section>
<section id="precedence">
<h2>Precedence and reloads</h2>
<p>Runtime values are resolved in this order:</p>
<div class="code-card">
<pre><code>defaults → config file → environment overrides</code></pre>
</div>
<p>
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_SPAWN_SESSIONS</code>,
and <code>PI_WEB_SUBSESSIONS</code>.
</p>
<ul>
<li><code>host</code> / <code>port</code>: restart the web/API service or process.</li>
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon.</li>
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon.</li>
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing plugin enablement.</li>
<li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li>
</ul>
</section>
<section id="global-config">
<h2>Global config example</h2>
<p>
<code>pi-web install</code> creates the initial file. You can also save settings from
<strong>Settings → General</strong>, <strong>Settings → Plugins</strong>, <strong>Settings → Keyboard</strong>,
and <strong>Settings → Session daemon</strong>.
</p>
<div class="code-card">
<div class="copy-row">
<strong>Example config.json</strong>
<button class="copy-button" data-copy="#global-config-example">Copy</button>
</div>
<pre id="global-config-example"><code>{
"host": "127.0.0.1",
"port": 8504,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
},
"maxUploadBytes": 67108864,
"spawnSessions": true,
"subsessions": false,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
"info": { "enabled": false }
},
"shortcuts": {
"core:view.chat": "mod+1",
"core:session.stop": null
}
}</code></pre>
</div>
</section>
<section id="project-config">
<h2>Project-local config</h2>
<p>
Project-local config lives at <code>&lt;project&gt;/.pi-web/config.json</code>. Use it for settings that should
follow a repository. When a project config defines <code>pathAccess</code>, PI WEB merges it after the
global path list.
</p>
<div class="code-card">
<div class="copy-row">
<strong>.pi-web/config.json</strong>
<button class="copy-button" data-copy="#project-config-example">Copy</button>
</div>
<pre id="project-config-example"><code>{
"version": 1,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
}
}</code></pre>
</div>
<p>
Project-local <code>pathAccess.allowedPaths</code> entries must still be host-absolute or
<code>~</code>-prefixed; relative roots are not supported. Plugins may own separate project files, such as
<code>.pi-web/tasks.json</code> for the built-in Workspace Tasks plugin.
</p>
</section>
<section id="keys">
<h2>Config matrix</h2>
<p>
Use this table as the quick reference for where a setting can live, which environment variable overrides
it, and whether project-local config overrides or merges with global config. Rows with JSON key
<code></code> are runtime-only environment variables, not config-file keys.
</p>
<div class="table-scroll" role="region" aria-label="PI WEB configuration matrix" tabindex="0">
<table class="config-matrix">
<thead>
<tr>
<th scope="col">Config</th>
<th scope="col">JSON key</th>
<th scope="col">Env var</th>
<th scope="col">Scope</th>
<th scope="col">Project-local behavior</th>
<th scope="col">Applies / restart</th>
</tr>
</thead>
<tbody>
<tr class="table-section"><th scope="rowgroup" colspan="6">Config-file keys</th></tr>
<tr>
<td>Web/API bind host</td>
<td><code>host</code></td>
<td><code>PI_WEB_HOST</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart web/API</td>
</tr>
<tr>
<td>Web/API port</td>
<td><code>port</code></td>
<td><code>PI_WEB_PORT</code>, <code>PORT</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart web/API</td>
</tr>
<tr>
<td>Dev-server allowed hosts</td>
<td><code>allowedHosts</code></td>
<td><code>PI_WEB_ALLOWED_HOSTS</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart dev web/UI</td>
</tr>
<tr>
<td>External filesystem roots</td>
<td><code>pathAccess.allowedPaths</code></td>
<td></td>
<td>Global + project</td>
<td><strong>Merges:</strong> global roots first, then project roots; duplicates removed</td>
<td>Next file request; refresh existing views if needed</td>
</tr>
<tr>
<td>Upload/body limit</td>
<td><code>maxUploadBytes</code></td>
<td><code>PI_WEB_MAX_UPLOAD_BYTES</code></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Restart web/API and session daemon</td>
</tr>
<tr>
<td>Agent can spawn sessions</td>
<td><code>spawnSessions</code></td>
<td><code>PI_WEB_SPAWN_SESSIONS</code></td>
<td>Global/session daemon</td>
<td>Not supported locally</td>
<td>Restart session daemon</td>
</tr>
<tr>
<td>Tracked subsessions (beta)</td>
<td><code>subsessions</code></td>
<td><code>PI_WEB_SUBSESSIONS</code></td>
<td>Global/session daemon</td>
<td>Not supported locally; also requires <code>spawnSessions</code></td>
<td>Restart session daemon</td>
</tr>
<tr>
<td>Plugin enablement/settings</td>
<td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td>
<td></td>
<td>Global</td>
<td>Not core local config; plugins may read their own project files</td>
<td>Reload browser tab</td>
</tr>
<tr>
<td>Keyboard shortcuts</td>
<td><code>shortcuts.&lt;actionId&gt;</code></td>
<td></td>
<td>Global</td>
<td>Not supported locally</td>
<td>Applies after settings save/config refresh</td>
</tr>
<tr>
<td>Project config version</td>
<td><code>version</code></td>
<td></td>
<td>Project</td>
<td>Project-local only; must be <code>1</code> when present</td>
<td>Next project-config read</td>
</tr>
<tr class="table-section"><th scope="rowgroup" colspan="6">Runtime-only environment variables</th></tr>
<tr>
<td>Global config file path</td>
<td></td>
<td><code>PI_WEB_CONFIG</code> (<code>XDG_CONFIG_HOME</code> affects the default path)</td>
<td>Process/env</td>
<td>Selects the global config file; not a project config</td>
<td>Restart services/processes after changing env</td>
</tr>
<tr>
<td>Managed data directory</td>
<td></td>
<td><code>PI_WEB_DATA_DIR</code></td>
<td>Process/env</td>
<td>Not supported locally</td>
<td>Restart services before changing; moves managed state location</td>
</tr>
<tr>
<td>Session daemon socket</td>
<td></td>
<td><code>PI_WEB_SESSIOND_SOCKET</code></td>
<td>Web/API + session daemon env</td>
<td>Not supported locally</td>
<td>Restart daemon and web/API; both must match</td>
</tr>
<tr>
<td>Session daemon TCP port</td>
<td></td>
<td><code>PI_WEB_SESSIOND_PORT</code></td>
<td>Session daemon env</td>
<td>Not supported locally</td>
<td>Restart session daemon; set <code>PI_WEB_SESSIOND_URL</code> for web/API too</td>
</tr>
<tr>
<td>Session daemon TCP host</td>
<td></td>
<td><code>PI_WEB_SESSIOND_HOST</code></td>
<td>Session daemon env</td>
<td>Not supported locally</td>
<td>Restart session daemon</td>
</tr>
<tr>
<td>Web-to-daemon URL</td>
<td></td>
<td><code>PI_WEB_SESSIOND_URL</code></td>
<td>Web/API env</td>
<td>Not supported locally</td>
<td>Restart web/API</td>
</tr>
<tr>
<td>Projects storage file</td>
<td></td>
<td><code>PI_WEB_PROJECTS_FILE</code></td>
<td>Web/API + session daemon env</td>
<td>Not supported locally</td>
<td>Restart services; advanced state override</td>
</tr>
<tr>
<td>Remote machines storage file</td>
<td></td>
<td><code>PI_WEB_MACHINES_FILE</code></td>
<td>Web/API env</td>
<td>Not supported locally</td>
<td>Restart web/API; advanced state override</td>
</tr>
<tr>
<td>Pi session storage directory</td>
<td></td>
<td><code>PI_CODING_AGENT_SESSION_DIR</code></td>
<td>Pi/session daemon env</td>
<td>Not supported locally</td>
<td>Restart session daemon; follows Pi session priority</td>
</tr>
<tr>
<td>Pi agent config directory</td>
<td></td>
<td><code>PI_CODING_AGENT_DIR</code></td>
<td>Pi/Web/API/session daemon env</td>
<td>Not supported locally</td>
<td>Restart services</td>
</tr>
<tr>
<td>Skip update checks</td>
<td></td>
<td><code>PI_WEB_SKIP_VERSION_CHECK</code>, <code>PI_WEB_OFFLINE</code>, <code>PI_SKIP_VERSION_CHECK</code>, <code>PI_OFFLINE</code></td>
<td>Web/API env</td>
<td>Not supported locally</td>
<td>Restart web/API after env changes</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="path-access">
<h2>External path access</h2>
<p>
<code>pathAccess.allowedPaths</code> grants PI WEB's file explorer and absolute <code>@</code> path
completions access to specific filesystem roots outside the current workspace. By default,
workspace-relative file reads stay inside the workspace and absolute paths are denied.
</p>
<p>Accepted root forms:</p>
<ul>
<li>Unix absolute paths, for example <code>/opt/reference</code>.</li>
<li>Home-relative paths, for example <code>~/SDKs</code>.</li>
<li>Windows absolute paths on Windows hosts, for example <code>C:\Users\dev\SDKs</code>.</li>
</ul>
<p>
When an absolute request is served, PI WEB expands <code>~</code>, canonicalizes configured roots with
<code>realpath</code>, requires roots to be existing directories, and rejects symlink escapes outside the
allowed roots.
</p>
<div class="callout warning">
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API
file exposure outside a workspace. Add only roots you trust PI WEB to list and read through the browser UI.
</div>
</section>
<section id="session-tools">
<h2>Session daemon tools</h2>
<h3><code>spawnSessions</code></h3>
<p>
Boolean. Controls whether agents receive the <code>spawn_session</code> tool. Defaults to
<code>true</code>. Set it to <code>false</code> if you do not want an agent to start independent PI WEB sessions.
</p>
<p>Environment override: <code>PI_WEB_SPAWN_SESSIONS=0|1|true|false</code>.</p>
<h3><code>subsessions</code></h3>
<p>
Boolean. Beta. Controls whether agents receive the tracked-subsession tools:
<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, and
<code>read_subsession</code>. Defaults to <code>false</code> and also requires <code>spawnSessions</code>
to be enabled.
</p>
<p>
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop
working, and inspect their transcripts. Restart the session daemon after changing this setting.
</p>
<p>Environment override: <code>PI_WEB_SUBSESSIONS=0|1|true|false</code>.</p>
</section>
<section id="completion-tools">
<h2>Optional completion tools</h2>
<p>
File and path <code>@</code> completions work without extra tools. If <code>fzf</code> is available on the
PI WEB server's <code>PATH</code>, PI WEB uses it to improve completion filtering and ranking; otherwise it
falls back to built-in ranking.
</p>
<div class="doc-actions">
<a class="button primary" href="install">Install guide</a>
<a class="button" href="config.md">Markdown reference</a>
</div>
</section>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="container footer-inner">
<span>PI WEB docs</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a>
<a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a>
<a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
</div>
</footer>
<script src="site.js"></script>
</body>
</html>
+203
View File
@@ -0,0 +1,203 @@
# PI WEB configuration reference
PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, and session-daemon tools.
This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>.
## Config files
PI WEB uses two config files:
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's config by opening that machine directly or changing files on that machine.
If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`.
## Precedence and reloads
Machine-global runtime values are resolved as:
```text
defaults → global config file → environment overrides
```
Supported project-local settings are then applied for that project's workspaces. For upload defaults, `<project>/.pi-web/config.json` overrides the global value.
Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`.
Process restarts depend on the key:
- `host` / `port`: restart the web/API service or process.
- `maxUploadBytes`: restart both the web/API process and the session daemon.
- `spawnSessions` / `subsessions`: restart the session daemon.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing plugin enablement.
- `shortcuts`: saved settings apply in the browser after config refresh/save.
## Global config example
```json
{
"host": "127.0.0.1",
"port": 8504,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
},
"uploads": {
"defaultFolder": ".pi-web/uploads"
},
"maxUploadBytes": 67108864,
"spawnSessions": true,
"subsessions": false,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
"info": { "enabled": false }
},
"shortcuts": {
"core:view.chat": "mod+1",
"core:session.stop": null
}
}
```
## Project-local config
Project-local config lives at `<project>/.pi-web/config.json`. Use it for settings that should follow a repository.
```json
{
"version": 1,
"pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
},
"uploads": {
"defaultFolder": "manual/uploads"
}
}
```
Project-local `pathAccess.allowedPaths` entries are merged after the global list and deduplicated. Paths must still be host-absolute or `~`-prefixed; relative roots are not supported.
Project-local `uploads.defaultFolder` overrides the global upload destination for workspaces in that project. Current PI WEB servers include this workspace-effective value on the existing workspace responses used locally and through machine federation. Older remote servers may omit the optional field; the browser falls back to the global/default upload folder.
Plugins may own separate project files, such as `.pi-web/tasks.json` for the built-in Workspace Tasks plugin.
## Configuration matrix
Rows with JSON key `—` are runtime-only environment variables, not config-file keys.
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
| --- | --- | --- | --- | --- | --- |
| **Config-file keys** | | | | | |
| Web/API bind host | `host` | `PI_WEB_HOST` | Global | Not supported locally | Restart web/API |
| Web/API port | `port` | `PI_WEB_PORT`, `PORT` | Global | Not supported locally | Restart web/API |
| Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI |
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon |
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon |
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
| Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh |
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
| **Runtime-only environment variables** | | | | | |
| Global config file path | — | `PI_WEB_CONFIG` (`XDG_CONFIG_HOME` affects the default path) | Process/env | Selects the global config file; not a project config | Restart services/processes after changing env |
| Managed data directory | — | `PI_WEB_DATA_DIR` | Process/env | Not supported locally | Restart services before changing; moves managed state location |
| Session daemon socket | — | `PI_WEB_SESSIOND_SOCKET` | Web/API + session daemon env | Not supported locally | Restart daemon and web/API; both must match |
| Session daemon TCP port | — | `PI_WEB_SESSIOND_PORT` | Session daemon env | Not supported locally | Restart session daemon; set `PI_WEB_SESSIOND_URL` for web/API too |
| Session daemon TCP host | — | `PI_WEB_SESSIOND_HOST` | Session daemon env | Not supported locally | Restart session daemon |
| Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API |
| Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override |
| Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override |
| Pi session storage directory | — | `PI_CODING_AGENT_SESSION_DIR` | Pi/session daemon env | Not supported locally | Restart session daemon; follows Pi session priority |
| Pi agent config directory | — | `PI_CODING_AGENT_DIR` | Pi/Web/API/session daemon env | Not supported locally | Restart services |
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes |
## Key details
### External path access
`pathAccess.allowedPaths` grants PI WEB's file explorer and absolute `@` path completions access to specific filesystem roots outside the current workspace.
By default, workspace-relative file reads stay inside the workspace and absolute paths are denied. Add only roots you trust PI WEB to list and read through the browser UI.
Accepted root forms:
- Unix absolute paths: `/opt/reference`
- Home-relative paths: `~/SDKs`
- Windows absolute paths on Windows hosts: `C:\Users\dev\SDKs`
When an absolute request is served, PI WEB expands `~`, canonicalizes the configured roots with `realpath`, requires roots to be existing directories, and rejects symlink escapes outside the allowed roots.
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace.
### Manual upload defaults
The Files panel can upload one or more files in two ways:
- Drop files onto the Files panel to upload immediately to the workspace-effective default folder.
- Use the toolbar **Upload** button to open the review dialog, edit the destination, and opt into upload options.
`uploads.defaultFolder` sets the workspace-effective default destination. The built-in default is `.pi-web/uploads`; a global config value applies to every project unless `<project>/.pi-web/config.json` sets a project-local override.
```json
{
"uploads": {
"defaultFolder": "manual/uploads"
}
}
```
The value must be a non-empty workspace-relative folder. PI WEB normalizes repeated separators and backslashes to `/`, and rejects absolute paths or `..` traversal. In the upload dialog only, clearing the destination field uploads that batch to the workspace root.
Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth.
For machine federation, current remote PI WEB servers return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder.
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES`.
### Session daemon tools
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
`subsessions` is beta and controls whether agents receive the tracked-subsession tools: `spawn_subsession`, `list_subsessions`, `check_subsession`, and `read_subsession`. It defaults to `false` and also requires `spawnSessions` to be enabled.
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop working, and inspect their transcripts.
### Plugin config
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it.
```json
{
"plugins": {
"workspace-tasks": { "enabled": true, "settings": {} },
"updates": { "enabled": false }
}
}
```
Reload the browser tab after changing plugin enablement. Already-loaded plugin JavaScript is not unloaded from the current page.
### Shortcut config
Shortcut values are keyed by action id. Values are shortcut strings such as `mod+k` or `mod+g p`; `null` disables that action's shortcut.
```json
{
"shortcuts": {
"core:view.chat": "mod+1",
"core:session.stop": null
}
}
```
Prefer Settings → Keyboard for editing shortcuts interactively.
## Optional completion tools
File and path `@` completions work without extra tools. If `fzf` is available on the PI WEB server's `PATH`, PI WEB uses it to improve completion filtering/ranking; otherwise it falls back to built-in ranking.
+2
View File
@@ -50,6 +50,7 @@
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq" aria-current="page">FAQ</a> <a href="faq" aria-current="page">FAQ</a>
</div> </div>
@@ -305,6 +306,7 @@
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="https://github.com/jmfederico/pi-web/issues">Issues</a> <a href="https://github.com/jmfederico/pi-web/issues">Issues</a>
</div> </div>
+52 -4
View File
@@ -66,6 +66,7 @@
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
</div> </div>
@@ -146,12 +147,58 @@
<section class="section compact"> <section class="section compact">
<div class="container"> <div class="container">
<div class="demo-frame"> <div class="demo-frame" data-demo-carousel>
<div class="demo-caption"> <div class="demo-caption">
<strong>Workspaces, sessions, transcripts, terminals — one Pi web UI.</strong> <div class="demo-caption-copy">
<span>Bring your own repositories.</span> <strong>Workspaces, sessions, transcripts, files — one Pi web UI on every screen.</strong>
<span>Bring your own repositories. Swipe through the screenshots or click one to enlarge.</span>
</div> </div>
<img src="assets/pi-web-demo.gif" alt="PI WEB browser UI demo" /> <div class="demo-controls" data-demo-controls hidden role="group" aria-label="Screenshot gallery controls">
<button class="demo-control" type="button" data-demo-prev aria-label="Previous screenshot"></button>
<div class="demo-dots" role="group" aria-label="Choose screenshot">
<button class="demo-dot" type="button" data-demo-dot="0" aria-label="Show desktop screenshot" aria-current="true"></button>
<button class="demo-dot" type="button" data-demo-dot="1" aria-label="Show tablet screenshot" aria-current="false"></button>
<button class="demo-dot" type="button" data-demo-dot="2" aria-label="Show mobile screenshot" aria-current="false"></button>
</div>
<button class="demo-control" type="button" data-demo-next aria-label="Next screenshot"></button>
</div>
</div>
<div class="demo-gallery" data-demo-gallery tabindex="0" aria-label="PI WEB screenshots">
<figure class="demo-shot demo-shot-desktop" data-demo-slide>
<div class="demo-shot-media">
<button class="demo-lightbox-trigger" type="button" data-demo-lightbox-trigger aria-label="Open desktop screenshot preview">
<img
src="assets/pi-web-desktop.png"
alt="PI WEB desktop screenshot showing an agent-created pi-web.dev screenshot selected in the file preview"
/>
</button>
</div>
<figcaption><strong>Desktop</strong><span>Chat beside workspace file preview.</span></figcaption>
</figure>
<figure class="demo-shot" data-demo-slide>
<div class="demo-shot-media">
<button class="demo-lightbox-trigger" type="button" data-demo-lightbox-trigger aria-label="Open tablet screenshot preview">
<img src="assets/pi-web-tablet.png" alt="PI WEB tablet screenshot" />
</button>
</div>
<figcaption><strong>Tablet</strong><span>The same live session on a wider touch screen.</span></figcaption>
</figure>
<figure class="demo-shot demo-shot-mobile" data-demo-slide>
<div class="demo-shot-media">
<button class="demo-lightbox-trigger" type="button" data-demo-lightbox-trigger aria-label="Open mobile screenshot preview">
<img src="assets/pi-web-mobile.png" alt="PI WEB mobile chat screenshot" />
</button>
</div>
<figcaption><strong>Mobile</strong><span>Readable chat controls when you are away from the desk.</span></figcaption>
</figure>
</div>
<dialog class="demo-lightbox" data-demo-lightbox aria-label="Screenshot preview">
<div class="demo-lightbox-panel">
<button class="demo-lightbox-close" type="button" data-demo-lightbox-close aria-label="Close screenshot preview">×</button>
<img data-demo-lightbox-image alt="" />
<p data-demo-lightbox-caption></p>
</div>
</dialog>
</div> </div>
</div> </div>
</section> </section>
@@ -336,6 +383,7 @@
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
<a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a> <a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a>
+20 -12
View File
@@ -50,6 +50,7 @@
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install" aria-current="page">Install</a> <a href="install" aria-current="page">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
</div> </div>
@@ -265,27 +266,33 @@
</p> </p>
<div class="code-card"> <div class="code-card">
<div class="copy-row"> <div class="copy-row">
<strong>Default config</strong> <strong>Common config</strong>
<button class="copy-button" data-copy="#config-example">Copy</button> <button class="copy-button" data-copy="#config-example">Copy</button>
</div> </div>
<pre id="config-example"><code>{ <pre id="config-example"><code>{
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8504, "port": 8504,
"allowedHosts": [] "pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"]
},
"spawnSessions": true,
"subsessions": false
}</code></pre> }</code></pre>
</div> </div>
<p> <p>
The web server defaults to <code>127.0.0.1:8504</code> and stores PI WEB state in <code>~/.pi-web</code>. Use <strong>Settings → General</strong> for host, port, and external filesystem roots; <strong>Settings → Session daemon</strong>
for agent-spawn tools; <strong>Settings → Plugins</strong> for plugin enablement; and <strong>Settings → Keyboard</strong>
for shortcut overrides.
</p>
<div class="callout">
Need the full schema, restart rules, project-local <code>.pi-web/config.json</code>, path access details, and
environment variable reference? Read the <a href="config">configuration reference</a>.
</div>
<p>
The web server defaults to <code>127.0.0.1:8504</code>, and PI WEB-managed state defaults to
<code>~/.pi-web</code>. External filesystem paths are denied by default unless listed in
<code>pathAccess.allowedPaths</code>.
</p> </p>
<ul>
<li><code>PI_WEB_CONFIG</code>: path to a config JSON file. Defaults to <code>~/.config/pi-web/config.json</code>.</li>
<li><code>PI_WEB_PORT</code> or <code>PORT</code>: web server port. Overrides the config file.</li>
<li><code>PI_WEB_HOST</code>: web server bind host. Overrides the config file. Use <code>127.0.0.1</code> for local/tunnel-only access, or a specific VPN/private-network IP for trusted remote access.</li>
<li><code>PI_WEB_DATA_DIR</code>: data directory, default <code>~/.pi-web</code>.</li>
<li><code>PI_WEB_SESSIOND_SOCKET</code>: Unix socket path for daemon communication.</li>
<li><code>PI_CODING_AGENT_SESSION_DIR</code>: Pi session storage directory. PI WEB follows Pi's priority for sessions: this environment variable, then <code>sessionDir</code> in Pi settings for the selected workspace, then Pi's default session directory.</li>
<li><code>PI_CODING_AGENT_DIR</code>: Pi agent config directory for auth, settings, resources, and default session storage.</li>
</ul>
</section> </section>
<section id="uninstall"> <section id="uninstall">
@@ -334,6 +341,7 @@
<a href="./">Home</a> <a href="./">Home</a>
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a> <a href="https://github.com/jmfederico/pi-web">GitHub</a>
+2
View File
@@ -50,6 +50,7 @@
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines" aria-current="page">Fleet</a> <a href="machines" aria-current="page">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
</div> </div>
@@ -289,6 +290,7 @@ PI WEB gateway you opened
<a href="./">Home</a> <a href="./">Home</a>
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a> <a href="https://github.com/jmfederico/pi-web">GitHub</a>
+2
View File
@@ -50,6 +50,7 @@
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins" aria-current="page">Plugins</a> <a href="plugins" aria-current="page">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
</div> </div>
@@ -430,6 +431,7 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<a href="remote-first">Remote-first</a> <a href="remote-first">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a> <a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div> </div>
+124 -3
View File
@@ -439,6 +439,7 @@ interface PluginRuntimeContext {
selectedSession?: unknown; selectedSession?: unknown;
piWebStatus?: PiWebStatusResponse; piWebStatus?: PiWebStatusResponse;
}; };
prompt: PluginPromptEditor;
openActionPalette: () => void; openActionPalette: () => void;
focusPrompt: () => void; focusPrompt: () => void;
addProject: () => void | Promise<void>; addProject: () => void | Promise<void>;
@@ -464,6 +465,29 @@ Notes:
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal. - `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. - 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 #### 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. - 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; state?: PluginRuntimeState;
files: { files: {
readFile(path: string): Promise<FileContentResponse>; readFile(path: string): Promise<FileContentResponse>;
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
}; };
prompt: PluginPromptEditor;
terminal: { terminal: {
open(options?: { terminalId?: string }): void; open(options?: { terminalId?: string }): void;
runCommand(input: { 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. `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()`. 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; state?: PluginRuntimeState;
files: { files: {
readFile(path: string): Promise<FileContentResponse>; readFile(path: string): Promise<FileContentResponse>;
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
}; };
host: { host: {
requestRender(): void; 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. 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. 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`
<button @click=${async () => {
const result = await files.writeFile("output/result.txt", "Generated content\n");
console.log("Wrote", result.path, result.size, "bytes");
}}>Generate</button>
`,
},
]
```
### 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 ## 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. 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. 9. Add workspace panels for larger workspace UI.
10. Add workspace labels for compact inline metadata. 10. Add workspace labels for compact inline metadata.
11. Return arrays from workspace label `items()`; return an empty array to render nothing. 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. 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. 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. 15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
+2
View File
@@ -50,6 +50,7 @@
<a href="remote-first" aria-current="page">Remote-first</a> <a href="remote-first" aria-current="page">Remote-first</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
</div> </div>
@@ -206,6 +207,7 @@
<a href="./">Home</a> <a href="./">Home</a>
<a href="machines">Fleet</a> <a href="machines">Fleet</a>
<a href="install">Install</a> <a href="install">Install</a>
<a href="config">Config</a>
<a href="plugins">Plugins</a> <a href="plugins">Plugins</a>
<a href="faq">FAQ</a> <a href="faq">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a> <a href="https://github.com/jmfederico/pi-web">GitHub</a>
+161
View File
@@ -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]"); const copyButtons = document.querySelectorAll("[data-copy]");
for (const button of copyButtons) { for (const button of copyButtons) {
+1
View File
@@ -4,6 +4,7 @@
<url><loc>https://pi-web.dev/remote-first</loc></url> <url><loc>https://pi-web.dev/remote-first</loc></url>
<url><loc>https://pi-web.dev/machines</loc></url> <url><loc>https://pi-web.dev/machines</loc></url>
<url><loc>https://pi-web.dev/install</loc></url> <url><loc>https://pi-web.dev/install</loc></url>
<url><loc>https://pi-web.dev/config</loc></url>
<url><loc>https://pi-web.dev/plugins</loc></url> <url><loc>https://pi-web.dev/plugins</loc></url>
<url><loc>https://pi-web.dev/faq</loc></url> <url><loc>https://pi-web.dev/faq</loc></url>
</urlset> </urlset>
+342 -1
View File
@@ -610,14 +610,255 @@ code .comment,
.demo-caption { .demo-caption {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 14px 18px;
padding: 15px 18px; padding: 15px 18px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: #0c1020; background: #0c1020;
color: var(--muted); 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 { .manifesto-section {
padding-top: 46px; padding-top: 46px;
} }
@@ -809,6 +1050,60 @@ code .comment,
font-size: 1.25rem; 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 { .code-card {
overflow: hidden; overflow: hidden;
margin: 16px 0; margin: 16px 0;
@@ -1076,6 +1371,10 @@ html[data-theme="light"] .comment {
.manifesto-lines { .manifesto-lines {
align-content: start; align-content: start;
} }
.demo-shot {
flex-basis: min(58vw, 360px);
}
} }
@media (max-width: 820px) { @media (max-width: 820px) {
@@ -1153,6 +1452,48 @@ html[data-theme="light"] .comment {
font-size: clamp(3rem, 15vw, 4.2rem); 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 { .footer-inner {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.4", "version": "1.202606.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.4", "version": "1.202606.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
@@ -1575,7 +1575,7 @@
"typebox": "1.1.38" "typebox": "1.1.38"
}, },
"bin": { "bin": {
"pi-ai": "dist/cli.js" "pi-ai": "./dist/cli.js"
}, },
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.4", "version": "1.202606.6",
"description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.", "description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
"license": "MIT", "license": "MIT",
"author": "Federico Jaramillo Martinez", "author": "Federico Jaramillo Martinez",
@@ -17,6 +17,7 @@
"LICENSE", "LICENSE",
"extensions", "extensions",
"docs/plugins.md", "docs/plugins.md",
"docs/config.md",
"docs/assets", "docs/assets",
"plugin-api.d.ts", "plugin-api.d.ts",
"plugin-api/unstable.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": "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:plugin-api": "tsc -p tsconfig.plugin-api.json",
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs", "build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
"capture:screenshots": "node scripts/capture-screenshots.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"knip": "knip", "knip": "knip",
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts", "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
+652
View File
@@ -0,0 +1,652 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, "..");
const DEFAULT_OUTPUT_DIR = join(REPO_ROOT, "docs", "assets");
const SESSION_ID = "019ef4c0-0000-7000-8000-000000000001";
const DEMO_FILE = "docs/assets/pi-web-dev-screenshot.png";
const DEFAULT_SITE_URL = "https://pi-web.dev/";
const VIEWPORTS = {
desktop: { width: 1440, height: 900, mobile: false },
tablet: { width: 1024, height: 768, mobile: false },
mobile: { width: 390, height: 844, mobile: true },
};
const args = parseArgs(process.argv.slice(2));
const outputDir = resolve(args.outputDir ?? DEFAULT_OUTPUT_DIR);
const keepTemp = args.keepTemp === true;
const siteUrl = args.siteUrl ?? DEFAULT_SITE_URL;
const chromeBin = args.chromeBin ?? process.env["CHROME_BIN"] ?? findChrome();
if (chromeBin === undefined) {
fail("Chromium was not found. Install chromium-browser/chromium or set CHROME_BIN=/path/to/chrome.");
}
const tempRoot = await mkdtemp(join(tmpdir(), "pi-web-screenshots-"));
const children = new Set();
let cleanedUp = false;
process.once("SIGINT", () => {
void cleanup().finally(() => process.exit(130));
});
process.once("SIGTERM", () => {
void cleanup().finally(() => process.exit(143));
});
async function main() {
const logsDir = join(tempRoot, "logs");
const dataDir = join(tempRoot, "pi-web-data");
const configPath = join(tempRoot, "config.json");
const sessionDir = join(tempRoot, "sessions");
const agentDir = join(tempRoot, "pi-agent");
const demoProject = join(tempRoot, "pi-web");
const projectsFile = join(dataDir, "projects.json");
const socketPath = join(dataDir, "sessiond.sock");
await Promise.all([
mkdir(logsDir, { recursive: true }),
mkdir(sessionDir, { recursive: true }),
mkdir(agentDir, { recursive: true }),
mkdir(dataDir, { recursive: true }),
mkdir(outputDir, { recursive: true }),
]);
console.log(`Temporary workspace: ${tempRoot}`);
await cloneDemoProject(demoProject);
await removeLegacyDemoMedia(demoProject);
const projectId = "pi-web-demo";
const workspaceId = createWorkspaceId(projectId, demoProject);
await writeJson(projectsFile, {
projects: [{ id: projectId, name: "pi-web", path: demoProject, createdAt: new Date().toISOString() }],
});
await writeJson(configPath, { host: "127.0.0.1", allowedHosts: true });
await writeDemoSession(sessionDir, demoProject);
const apiPort = await getFreePort();
const clientPort = await getFreePort();
const debugPort = await getFreePort();
const env = {
...process.env,
PI_WEB_DATA_DIR: dataDir,
PI_WEB_CONFIG: configPath,
PI_WEB_PROJECTS_FILE: projectsFile,
PI_WEB_SESSIOND_SOCKET: socketPath,
PI_WEB_HOST: "127.0.0.1",
PI_WEB_PORT: String(apiPort),
PI_WEB_ALLOWED_HOSTS: "true",
PI_CODING_AGENT_DIR: agentDir,
PI_CODING_AGENT_SESSION_DIR: sessionDir,
PI_OFFLINE: "1",
NO_COLOR: "1",
};
const tsxBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
const viteBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
assertExecutable(tsxBin, "Run npm install before capturing screenshots.");
assertExecutable(viteBin, "Run npm install before capturing screenshots.");
console.log("Starting isolated PI WEB session daemon, API server, and Vite client…");
startChild("sessiond", tsxBin, ["src/server/sessiond.ts"], { env, cwd: REPO_ROOT, logsDir });
await waitForFile(socketPath, 10_000);
startChild("api", tsxBin, ["src/server/index.ts"], { env, cwd: REPO_ROOT, logsDir });
await waitForHttp(`http://127.0.0.1:${apiPort}/api/projects`, 15_000);
startChild("vite", viteBin, ["--host", "127.0.0.1", "--port", String(clientPort), "--strictPort", "true"], { env, cwd: REPO_ROOT, logsDir });
await waitForHttp(`http://127.0.0.1:${clientPort}/`, 30_000);
console.log("Starting Chromium and capturing screenshots…");
const chrome = startChild("chromium", chromeBin, chromeArgs(debugPort, join(tempRoot, "chrome-profile")), { env, cwd: REPO_ROOT, logsDir });
await waitForHttp(`http://127.0.0.1:${debugPort}/json/version`, 15_000);
const cdp = await openPage(debugPort);
try {
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await captureWebsiteScreenshot(cdp, join(demoProject, DEMO_FILE), siteUrl);
const appUrl = new URL(`http://127.0.0.1:${clientPort}/`);
appUrl.searchParams.set("project", projectId);
appUrl.searchParams.set("workspace", workspaceId);
appUrl.searchParams.set("session", SESSION_ID);
appUrl.searchParams.set("view", "chat");
await captureDesktop(cdp, appUrl, join(outputDir, "pi-web-desktop.png"));
await captureDefaultApp(cdp, appUrl, VIEWPORTS.tablet, join(outputDir, "pi-web-tablet.png"));
await captureDefaultApp(cdp, appUrl, VIEWPORTS.mobile, join(outputDir, "pi-web-mobile.png"));
} finally {
cdp.close();
chrome.kill("SIGTERM");
}
console.log(`Wrote ${join(outputDir, "pi-web-desktop.png")}`);
console.log(`Wrote ${join(outputDir, "pi-web-tablet.png")}`);
console.log(`Wrote ${join(outputDir, "pi-web-mobile.png")}`);
if (keepTemp) console.log(`Kept temporary workspace: ${tempRoot}`);
}
async function captureWebsiteScreenshot(cdp, outputPath, url) {
await mkdir(dirname(outputPath), { recursive: true });
await setViewport(cdp, { width: 1280, height: 720, mobile: false });
try {
await navigate(cdp, url, 20_000);
await waitForDocumentFonts(cdp);
await sleep(3500);
await capturePng(cdp, outputPath);
} catch (error) {
console.warn(`Unable to capture ${url}; using a local fallback image. ${error instanceof Error ? error.message : String(error)}`);
const fallback = `data:text/html,${encodeURIComponent(fallbackWebsiteHtml(url))}`;
await navigate(cdp, fallback, 10_000);
await sleep(300);
await capturePng(cdp, outputPath);
}
}
async function captureDesktop(cdp, appUrl, outputPath) {
await setViewport(cdp, VIEWPORTS.desktop);
await navigate(cdp, appUrl.href, 15_000);
await waitForApp(cdp);
await selectPreviewImage(cdp);
await sleep(500);
await capturePng(cdp, outputPath);
}
async function captureDefaultApp(cdp, appUrl, viewport, outputPath) {
await setViewport(cdp, viewport);
await navigate(cdp, appUrl.href, 15_000);
await waitForApp(cdp);
await sleep(700);
await capturePng(cdp, outputPath);
}
async function selectPreviewImage(cdp) {
await evaluate(cdp, `(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const app = document.querySelector("pi-web-app");
if (!app) throw new Error("pi-web-app not found");
if (typeof app.openWorkspaceTool !== "function" || app.files === undefined) {
throw new Error("PI WEB app internals needed for deterministic screenshot setup were not available");
}
app.openWorkspaceTool("core:workspace.files");
await app.updateComplete;
await sleep(900);
await app.files.refreshFiles();
await app.updateComplete;
if (app.state?.expandedDirs?.["docs"] === undefined) await app.files.expandDir("docs");
await app.updateComplete;
if (app.state?.expandedDirs?.["docs/assets"] === undefined) await app.files.expandDir("docs/assets");
await app.updateComplete;
await app.files.selectFile(${JSON.stringify(DEMO_FILE)});
await app.updateComplete;
const panel = app.shadowRoot?.querySelector("workspace-panel");
const root = panel?.shadowRoot;
await panel?.updateComplete;
const start = Date.now();
while (Date.now() - start < 8000) {
const image = root?.querySelector(".image-preview img");
if (root?.textContent.includes(${JSON.stringify(DEMO_FILE)}) && image instanceof HTMLImageElement && image.complete) return true;
await sleep(100);
}
throw new Error("Timed out waiting for image preview");
})()`);
}
async function waitForApp(cdp) {
await evaluate(cdp, `new Promise((resolve, reject) => {
const start = Date.now();
const visibleText = () => {
const app = document.querySelector("pi-web-app");
const appRoot = app?.shadowRoot;
const chatRoot = appRoot?.querySelector("chat-view")?.shadowRoot;
return [appRoot?.textContent ?? "", chatRoot?.textContent ?? ""].join("\\n");
};
const check = () => {
const text = visibleText();
if (text.includes("Showing messages") && text.includes("assistant")) {
resolve(true);
return;
}
if (Date.now() - start > 15000) {
reject(new Error("PI WEB app did not restore the seeded session in time. Visible text: " + visibleText()));
return;
}
setTimeout(check, 100);
};
check();
})`);
}
async function waitForDocumentFonts(cdp) {
try {
await evaluate(cdp, `document.fonts?.ready?.then(() => true) ?? true`);
} catch {
// Font loading is best-effort; screenshots still work with fallback fonts.
}
}
async function navigate(cdp, url, timeoutMs) {
const loaded = cdp.waitForEvent("Page.loadEventFired", timeoutMs).catch(() => undefined);
await cdp.send("Page.navigate", { url });
await loaded;
}
async function setViewport(cdp, viewport) {
await cdp.send("Emulation.setDeviceMetricsOverride", {
width: viewport.width,
height: viewport.height,
deviceScaleFactor: 1,
mobile: viewport.mobile,
});
}
async function capturePng(cdp, outputPath) {
await mkdir(dirname(outputPath), { recursive: true });
const { data } = await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
await writeFile(outputPath, Buffer.from(data, "base64"));
}
async function cloneDemoProject(target) {
const result = spawnSync("git", ["clone", "--quiet", "--local", "--no-hardlinks", REPO_ROOT, target], {
cwd: REPO_ROOT,
encoding: "utf8",
});
if (result.status !== 0) throw new Error(`git clone failed:\n${result.stderr || result.stdout}`);
}
async function removeLegacyDemoMedia(projectRoot) {
await Promise.all([
rm(join(projectRoot, "docs", "assets", "pi-web-demo.gif"), { force: true }),
rm(join(projectRoot, "docs", "assets", "pi-web-demo.webm"), { force: true }),
rm(join(projectRoot, "docs", "assets", "pi-web-demo-flow.gif"), { force: true }),
]);
}
async function writeDemoSession(sessionDir, cwd) {
const now = new Date();
const timestamp = now.toISOString();
const file = join(sessionDir, `${timestamp.replaceAll(":", "-")}_${SESSION_ID}.jsonl`);
const ms = now.getTime();
const entries = [
{ type: "session", version: 3, id: SESSION_ID, timestamp, cwd },
{ type: "model_change", id: "10000001", parentId: null, timestamp: iso(ms + 100), provider: "openai-codex", modelId: "gpt-5.5" },
{ type: "thinking_level_change", id: "10000002", parentId: "10000001", timestamp: iso(ms + 200), thinkingLevel: "off" },
{
type: "message",
id: "10000003",
parentId: "10000002",
timestamp: iso(ms + 1000),
message: {
role: "user",
content: [{ type: "text", text: "Take a screenshot of https://pi-web.dev, save it under docs/assets, and tell me where I can preview it." }],
timestamp: ms + 1000,
},
},
{ type: "session_info", id: "10000004", parentId: "10000003", timestamp: iso(ms + 1100), name: "Screenshot pi-web.dev" },
{
type: "message",
id: "10000005",
parentId: "10000004",
timestamp: iso(ms + 2000),
message: {
role: "assistant",
content: [{
type: "toolCall",
id: "call_demo_screenshot",
name: "bash",
arguments: { command: `capture-browser-screenshot https://pi-web.dev ${DEMO_FILE}` },
}],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.5",
usage: zeroUsage(),
stopReason: "toolUse",
timestamp: ms + 2000,
},
},
{
type: "message",
id: "10000006",
parentId: "10000005",
timestamp: iso(ms + 3000),
message: {
role: "toolResult",
toolCallId: "call_demo_screenshot",
toolName: "bash",
content: [{ type: "text", text: `Saved screenshot to ${DEMO_FILE}` }],
isError: false,
timestamp: ms + 3000,
},
},
{
type: "message",
id: "10000007",
parentId: "10000006",
timestamp: iso(ms + 4000),
message: {
role: "assistant",
content: [{ type: "text", text: `Done — I saved the screenshot at \`${DEMO_FILE}\`. Open the Files panel to preview it.` }],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.5",
usage: zeroUsage(),
stopReason: "stop",
timestamp: ms + 4000,
},
},
];
await writeFile(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
}
function fallbackWebsiteHtml(url) {
return `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#07121f,#2b174c);color:#f8fafc;font:24px system-ui,sans-serif}
main{width:min(900px,calc(100vw - 80px));padding:56px;border:1px solid rgba(255,255,255,.22);border-radius:28px;background:rgba(10,16,32,.72);box-shadow:0 24px 80px rgba(0,0,0,.35)}
h1{margin:0 0 14px;font-size:64px;letter-spacing:-.06em}.eyebrow{color:#c084fc;text-transform:uppercase;letter-spacing:.16em;font-size:14px;font-weight:700}p{line-height:1.5;color:#dbeafe}
</style></head><body><main><div class="eyebrow">PI WEB</div><h1>pi-web.dev</h1><p>Fallback screenshot for ${escapeHtml(url)}.</p></main></body></html>`;
}
function chromeArgs(debugPort, userDataDir) {
return [
"--headless=new",
`--remote-debugging-port=${debugPort}`,
"--remote-debugging-address=127.0.0.1",
"--remote-allow-origins=*",
`--user-data-dir=${userDataDir}`,
"--window-size=1440,900",
"--force-device-scale-factor=1",
"--hide-scrollbars",
"--disable-background-networking",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
"--disable-features=Translate,MediaRouter,OptimizationHints",
"--no-default-browser-check",
"--no-first-run",
"--no-sandbox",
"about:blank",
];
}
function startChild(name, command, childArgs, { env, cwd, logsDir }) {
const logPath = join(logsDir, `${name}.log`);
const child = spawn(command, childArgs, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
children.add(child);
const chunks = [];
const collect = (chunk) => {
chunks.push(Buffer.from(chunk));
if (chunks.length > 120) chunks.shift();
};
child.stdout.on("data", collect);
child.stderr.on("data", collect);
child.stdout.on("data", (chunk) => appendLog(logPath, chunk));
child.stderr.on("data", (chunk) => appendLog(logPath, chunk));
child.once("exit", (code, signal) => {
children.delete(child);
if (!cleanedUp && code !== 0 && signal === null) {
const recent = Buffer.concat(chunks).toString("utf8").trim();
console.error(`${name} exited with code ${code}. Recent log:\n${recent}`);
}
});
return child;
}
function appendLog(path, chunk) {
void mkdir(dirname(path), { recursive: true })
.then(() => writeFile(path, chunk, { flag: "a" }))
.catch(() => undefined);
}
async function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
await Promise.all([...children].map((child) => terminate(child)));
if (!keepTemp) await rm(tempRoot, { recursive: true, force: true });
}
async function terminate(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
sleep(2500).then(() => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}),
]);
}
async function waitForHttp(url, timeoutMs) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) return;
lastError = new Error(`${response.status} ${response.statusText}`);
} catch (error) {
lastError = error;
}
await sleep(150);
}
throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
async function waitForFile(path, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (existsSync(path)) return;
await sleep(100);
}
throw new Error(`Timed out waiting for ${path}`);
}
async function getFreePort() {
return new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address !== null ? address.port : undefined;
server.close(() => {
if (port === undefined) reject(new Error("Unable to allocate a port"));
else resolve(port);
});
});
server.on("error", reject);
});
}
async function openPage(debugPort) {
const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: "PUT" });
if (!response.ok) throw new Error(`Unable to create Chromium tab: ${response.status} ${response.statusText}`);
const info = await response.json();
return CDP.connect(info.webSocketDebuggerUrl);
}
async function evaluate(cdp, expression) {
const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (response.exceptionDetails !== undefined) throw new Error(`Browser evaluation failed: ${JSON.stringify(response.exceptionDetails)}`);
return response.result?.value;
}
class CDP {
static connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const cdp = new CDP(ws);
ws.addEventListener("open", () => resolve(cdp), { once: true });
ws.addEventListener("error", (event) => reject(event.error ?? new Error("CDP websocket error")), { once: true });
});
}
constructor(ws) {
this.ws = ws;
this.nextId = 1;
this.pending = new Map();
this.listeners = new Map();
ws.addEventListener("message", (event) => this.onMessage(event));
ws.addEventListener("close", () => {
for (const { reject } of this.pending.values()) reject(new Error("CDP websocket closed"));
this.pending.clear();
});
}
send(method, params = {}) {
const id = this.nextId++;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
}
waitForEvent(method, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanupListener();
reject(new Error(`Timed out waiting for ${method}`));
}, timeoutMs);
const listener = (params) => {
cleanupListener();
resolve(params);
};
const cleanupListener = () => {
clearTimeout(timer);
const listeners = this.listeners.get(method) ?? [];
this.listeners.set(method, listeners.filter((candidate) => candidate !== listener));
};
this.listeners.set(method, [...this.listeners.get(method) ?? [], listener]);
});
}
close() {
this.ws.close();
}
onMessage(event) {
const message = JSON.parse(String(event.data));
if (message.id !== undefined) {
const pending = this.pending.get(message.id);
if (pending === undefined) return;
this.pending.delete(message.id);
if (message.error !== undefined) pending.reject(new Error(JSON.stringify(message.error)));
else pending.resolve(message.result ?? {});
return;
}
if (message.method !== undefined) {
for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {});
}
}
}
function createWorkspaceId(projectId, path) {
return createHash("sha1").update(`${projectId}:${path}`).digest("hex").slice(0, 12);
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function iso(ms) {
return new Date(ms).toISOString();
}
function zeroUsage() {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function parseArgs(argv) {
const parsed = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log(`Usage: node scripts/capture-screenshots.mjs [--output-dir docs/assets] [--site-url https://pi-web.dev/] [--keep-temp] [--chrome-bin /path/to/chrome]\n\nCaptures desktop, tablet, and mobile PI WEB screenshots from an isolated temporary instance.`);
process.exit(0);
}
if (arg === "--keep-temp") {
parsed.keepTemp = true;
continue;
}
if (arg === "--output-dir") {
parsed.outputDir = requireValue(argv, ++i, arg);
continue;
}
if (arg.startsWith("--output-dir=")) {
parsed.outputDir = arg.slice("--output-dir=".length);
continue;
}
if (arg === "--site-url") {
parsed.siteUrl = requireValue(argv, ++i, arg);
continue;
}
if (arg.startsWith("--site-url=")) {
parsed.siteUrl = arg.slice("--site-url=".length);
continue;
}
if (arg === "--chrome-bin") {
parsed.chromeBin = requireValue(argv, ++i, arg);
continue;
}
if (arg.startsWith("--chrome-bin=")) {
parsed.chromeBin = arg.slice("--chrome-bin=".length);
continue;
}
fail(`Unknown argument: ${arg}`);
}
return parsed;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (value === undefined || value.startsWith("--")) fail(`${flag} requires a value`);
return value;
}
function findChrome() {
return findExecutable(["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]);
}
function findExecutable(candidates) {
for (const candidate of candidates) {
const result = spawnSync("sh", ["-lc", `command -v ${shellQuote(candidate)}`], { encoding: "utf8" });
if (result.status === 0) return result.stdout.trim().split("\n")[0];
}
return undefined;
}
function shellQuote(value) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function assertExecutable(path, message) {
if (!existsSync(path)) fail(`${path} was not found. ${message}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeHtml(value) {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function fail(message) {
console.error(message);
process.exit(1);
}
try {
await main();
} finally {
await cleanup();
}
+66 -18
View File
@@ -1,6 +1,6 @@
--- ---
name: relay 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 # 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. 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 ## 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: 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. - **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/<name>/` 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/<name>/` 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: **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. - **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. - **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. - **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. 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 ## Running one leg
This is the loop you run when you are dispatched into a relay. 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. 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. **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. 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. **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. 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. **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). 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. **Decide: hand off, or stop.** 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.
- **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. 6. **Decide: hand off, or stop.**
- **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. - **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 "<name>".
Read:
- .pi-web/relays/<name>/charter.md
- .pi-web/relays/<name>/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 ## 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 ## Smells to watch for
- **No finish line** → infinite relay. Refuse to run a relay without a defined goal. - **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. - **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. - **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. - **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.
+38 -18
View File
@@ -1,61 +1,81 @@
{ {
"skill_name": "relay", "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": [ "evals": [
{ {
"id": 0, "id": 0,
"name": "plan-a-relay", "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.", "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 charter (default .pi-web/relays/<name>/charter.md) plus an empty/seeded log. The charter has all four required slots present: goal/finish-line, sizing, handover, intervention signal. The agent ASKS the user to make sizing and the intervention signal concrete rather than inventing strict rules. It does NOT prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the charter is agreed.", "expected_output": "Produces a relay packet (default .pi-web/relays/<name>/) 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": [], "files": [],
"assertions": [ "assertions": [
{ "name": "charter-created", "text": "A charter document is created (default under .pi-web/relays/<name>/ unless the user specified a location).", "type": "script" }, { "name": "packet-created", "text": "A relay packet is created with charter.md, status.md, and log.md under the relay location (default .pi-web/relays/<name>/ unless specified).", "type": "script" },
{ "name": "goal-slot-present", "text": "The charter defines a concrete, achievable finish line / goal.", "type": "judgment" }, { "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": "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": "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": "reading-discipline-present", "text": "The charter states the reading discipline, including not reading log.md end-to-end by default.", "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": "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, "id": 1,
"name": "run-one-leg-and-hand-off", "name": "run-one-leg-and-hand-off",
"prompt": "You're working under the Relay framework. Read .pi-web/relays/<sandbox>/charter.md and .pi-web/relays/<sandbox>/log.md, continue the plan, then dispatch the next agent.", "prompt": "You're working under the Relay framework. Read .pi-web/relays/<sandbox>/charter.md and .pi-web/relays/<sandbox>/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+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.", "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": [], "files": [],
"assertions": [ "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": "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": "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": "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": "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, "id": 2,
"name": "stop-on-blocker-do-not-spawn", "name": "stop-on-blocker-do-not-spawn",
"prompt": "You're working under the Relay framework. Read .pi-web/relays/<sandbox>/charter.md and .pi-web/relays/<sandbox>/log.md and continue. (The next leg hits a condition the charter flagged as requiring the human.)", "prompt": "You're working under the Relay framework. Read .pi-web/relays/<sandbox>/charter.md and .pi-web/relays/<sandbox>/status.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.", "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": [], "files": [],
"assertions": [ "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": "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": "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, "id": 3,
"name": "negative-no-magic-load", "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.", "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": [], "files": [],
"assertions": [ "assertions": [
{ "name": "skill-does-not-load", "text": "The relay skill does NOT trigger for this prompt.", "type": "judgment" }, { "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" }
] ]
} }
] ]
+134
View File
@@ -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/<eval-name>/
sandbox/.pi-web/relays/<relay-name>/
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/.../<eval-name>/ except for reading the skill file.
- Save your final answer to /tmp/.../<eval-name>/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 (`<project-root>`) and make the handoff prompt point to the relay files by absolute path:
```text
spawn_session cwd: <project-root>
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.
+60
View File
@@ -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);
});
});
+27 -9
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawnSync } from "node:child_process"; 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 { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os"; import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; 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[] { function productionServiceDefinitions(options: InstallOptions, configPath: string, executables: ServiceExecutables): ServiceDefinition[] {
const environment = configEnvironment(options, configPath);
return [ return [
{ {
...serviceRefs.sessiond, ...serviceRefs.sessiond,
description: "PI WEB session daemon", description: "PI WEB session daemon",
shellCommand: `exec ${executables.sessiond.command}`, shellCommand: `exec ${executables.sessiond.command}`,
restart: "on-failure", restart: "on-failure",
environment: {}, environment,
}, },
{ {
...serviceRefs.web, ...serviceRefs.web,
description: "PI WEB server", description: "PI WEB server",
shellCommand: `exec ${executables.web.command}`, shellCommand: `exec ${executables.web.command}`,
restart: "on-failure", restart: "on-failure",
environment: configEnvironment(options, configPath), environment,
after: ["sessiond"], after: ["sessiond"],
wants: ["sessiond"], wants: ["sessiond"],
}, },
@@ -429,13 +430,14 @@ function validateDevCheckout(root: string): void {
} }
function devServiceDefinitions(options: InstallOptions, configPath: string, root: string): ServiceDefinition[] { function devServiceDefinitions(options: InstallOptions, configPath: string, root: string): ServiceDefinition[] {
const environment = configEnvironment(options, configPath);
return [ return [
{ {
...serviceRefs.sessiond, ...serviceRefs.sessiond,
description: "PI WEB session daemon (dev)", description: "PI WEB session daemon (dev)",
shellCommand: "exec npm run start:sessiond", shellCommand: "exec npm run start:sessiond",
restart: "never", restart: "never",
environment: {}, environment,
workingDirectory: root, workingDirectory: root,
}, },
{ {
@@ -443,7 +445,7 @@ function devServiceDefinitions(options: InstallOptions, configPath: string, root
description: "PI WEB UI dev server", 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')}`, shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`,
restart: "never", restart: "never",
environment: configEnvironment(options, configPath), environment,
after: ["sessiond"], after: ["sessiond"],
wants: ["sessiond"], wants: ["sessiond"],
workingDirectory: root, workingDirectory: root,
@@ -900,8 +902,12 @@ function commandCheck(command: string): string {
return `command -v ${command}`; return `command -v ${command}`;
} }
function commandWithVersionCheck(command: string): string { export function commandWithVersionCheck(command: string): string {
return `${commandCheck(command)} && (${command} --version 2>&1 || true)`; 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 { function nodeVersionCheck(): string {
@@ -1086,7 +1092,19 @@ async function main(): Promise<void> {
else throw new Error(`Unknown command: ${command}`); else throw new Error(`Unknown command: ${command}`);
} }
main().catch((error: unknown) => { 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)); console.error(error instanceof Error ? error.message : String(error));
process.exit(1); process.exit(1);
}); });
}
+3 -1
View File
@@ -1,3 +1,5 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; 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 { 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";
+84 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; 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 = { const workspace: Workspace = {
id: "w/1", 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", () => { describe("machine-scoped terminal command-run API", () => {
it("deletes workspaces through the selected machine scope", async () => { it("deletes workspaces through the selected machine scope", async () => {
const fetchMock = stubJsonFetch(commandRun); 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<Response>; type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>; type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
+38 -2
View File
@@ -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 { request } from "./http";
import { import {
arrayOf, arrayOf,
@@ -9,6 +9,7 @@ import {
parseClosed, parseClosed,
parseCommandResult, parseCommandResult,
parseDeleted, parseDeleted,
parseDeleteWorkspaceFileResponse,
parseDetached, parseDetached,
parseFileContentResponse, parseFileContentResponse,
parseFileSuggestion, parseFileSuggestion,
@@ -21,6 +22,7 @@ import {
parseMachinesResponse, parseMachinesResponse,
parseMessagePage, parseMessagePage,
parseModelSelectionResponse, parseModelSelectionResponse,
parseMoveWorkspaceFileResponse,
parseOAuthFlowState, parseOAuthFlowState,
parsePiWebConfigResponse, parsePiWebConfigResponse,
parsePiWebPluginsResponse, parsePiWebPluginsResponse,
@@ -37,6 +39,7 @@ import {
parseTerminalCommandRun, parseTerminalCommandRun,
parseTerminalInfo, parseTerminalInfo,
parseThinkingLevelsResponse, parseThinkingLevelsResponse,
parseWriteWorkspaceFileResponse,
parseWorkspace, parseWorkspace,
parseWorkspaceActivityResponse, parseWorkspaceActivityResponse,
} from "./parsers"; } 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" }), 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), 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), 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<DeleteWorkspaceFileResponse> => {
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 = { export const sessionsApi = {
@@ -209,14 +238,21 @@ export interface FileSuggestionQueryOptions {
mode?: "file" | "path" | undefined; mode?: "file" | "path" | undefined;
scope?: "tracked" | "all" | undefined; scope?: "tracked" | "all" | undefined;
machineId?: string | undefined; machineId?: string | undefined;
projectId?: string | undefined;
workspaceId?: string | undefined;
workspaceScoped?: boolean | undefined;
} }
export const filesApi = { export const filesApi = {
files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => { 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.kind !== undefined) params.set("kind", options.kind);
if (options.mode !== undefined) params.set("mode", options.mode); if (options.mode !== undefined) params.set("mode", options.mode);
if (options.scope !== undefined) params.set("scope", options.scope); 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)); return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
}, },
}; };
@@ -37,7 +37,11 @@ describe("federated route contract", () => {
ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)), ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)),
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)), ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", 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", 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.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)), ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
@@ -61,6 +65,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)), ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
ignoreParseFailure(sessionsApi.restore(session, machineId)), ignoreParseFailure(sessionsApi.restore(session, machineId)),
ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)), ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)),
ignoreParseFailure(sessionsApi.reloadSession(session, machineId)),
ignoreParseFailure(sessionsApi.detachParent(session, machineId)), ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })), ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)), ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
+1 -1
View File
@@ -1,6 +1,6 @@
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> { export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers); 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 }); const response = await fetch(url, { ...init, headers });
if (!response.ok) { if (!response.ok) {
const body: unknown = await response.json().catch((): unknown => ({})); const body: unknown = await response.json().catch((): unknown => ({}));
+49 -5
View File
@@ -1,20 +1,20 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; 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", () => { describe("API parsers", () => {
it("parses PI WEB config responses", () => { it("parses PI WEB config responses", () => {
expect(parsePiWebConfigResponse({ expect(parsePiWebConfigResponse({
path: "/tmp/config.json", path: "/tmp/config.json",
exists: true, 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 } } } }, 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 }, 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 }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
})).toEqual({ })).toEqual({
path: "/tmp/config.json", path: "/tmp/config.json",
exists: true, 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 } } } }, 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 }, 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 }, 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", () => { it("parses workspace activity snapshots", () => {
expect(parseWorkspaceActivityResponse({ expect(parseWorkspaceActivityResponse({
generatedAt: "now", generatedAt: "now",
+73 -2
View File
@@ -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"; import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -141,6 +141,15 @@ export function parseWorkspace(value: unknown): Workspace {
isMain: requireBoolean(record, "isMain"), isMain: requireBoolean(record, "isMain"),
isGitRepo: requireBoolean(record, "isGitRepo"), isGitRepo: requireBoolean(record, "isGitRepo"),
isGitWorktree: requireBoolean(record, "isGitWorktree"), 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") }; 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 { function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (value !== "image") throw new Error("Invalid file media type"); 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("allowedHosts", optionalAllowedHosts(record["allowedHosts"])),
...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])),
...optionalField("plugins", optionalPlugins(record["plugins"])), ...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("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")),
}; };
@@ -453,10 +493,41 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined { function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (value === true) return true; 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"); 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 { function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field"); if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field");
+8
View File
@@ -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}`}`; 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 { export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("path", path); params.set("path", path);
+219
View File
@@ -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<string, string>();
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);
}
+355
View File
@@ -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<T> {
promise: Promise<T>;
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<WriteWorkspaceFileResponse> {
const xhr: WorkspaceUploadXhr = options.xhrFactory?.() ?? new XMLHttpRequest();
let settled = false;
let cancelled = false;
const promise = new Promise<WriteWorkspaceFileResponse>((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<WriteWorkspaceFileResponse[]> {
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<WriteWorkspaceFileResponse> | undefined;
let currentFileIndex = 0;
const cancellation = { requested: false };
const emit = () => {
options.onProgress?.(batchProgressSnapshot(progressFiles, currentFileIndex, progressFiles.every((file) => file.done)));
};
const promise = (async (): Promise<WriteWorkspaceFileResponse[]> => {
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<string, unknown> {
return typeof value === "object" && value !== null;
}
+4
View File
@@ -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 { 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 { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids"; import type { QualifiedContributionId } from "./plugins/ids";
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
export interface AppState { export interface AppState {
machines: Machine[]; machines: Machine[];
@@ -49,6 +50,8 @@ export interface AppState {
selectedFilePath: string | undefined; selectedFilePath: string | undefined;
selectedFileContent: FileContentResponse | undefined; selectedFileContent: FileContentResponse | undefined;
fileTreeStale: boolean; fileTreeStale: boolean;
/** Manual workspace file upload batches, keyed by client-owned batch id. */
workspaceUploadBatches: Record<string, WorkspaceUploadBatchState>;
gitStatus: GitStatusResponse | undefined; gitStatus: GitStatusResponse | undefined;
selectedDiffPath: string | undefined; selectedDiffPath: string | undefined;
selectedDiff: GitDiffResponse | undefined; selectedDiff: GitDiffResponse | undefined;
@@ -147,6 +150,7 @@ export function initialAppState(): AppState {
selectedFilePath: undefined, selectedFilePath: undefined,
selectedFileContent: undefined, selectedFileContent: undefined,
fileTreeStale: false, fileTreeStale: false,
workspaceUploadBatches: {},
gitStatus: undefined, gitStatus: undefined,
selectedDiffPath: undefined, selectedDiffPath: undefined,
selectedDiff: undefined, selectedDiff: undefined,
+72 -6
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; 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 type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
@@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types"; import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket"; 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 { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core"; import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes"; import { themePackPlugin } from "../plugins/themes";
@@ -152,6 +152,7 @@ export class PiWebApp extends LitElement {
private readonly handledWorkspaceDeletionRunIds = new Set<string>(); private readonly handledWorkspaceDeletionRunIds = new Set<string>();
private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>(); private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>();
private machineNavigationRestoreSeq = 0; private machineNavigationRestoreSeq = 0;
private navigationSelectionSeq = 0;
private routeRestoreSeq = 0; private routeRestoreSeq = 0;
private routeRestoreDepth = 0; private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined; private restoringRouteTerminalId: string | undefined;
@@ -168,6 +169,7 @@ export class PiWebApp extends LitElement {
@state() private isRefreshingApp = false; @state() private isRefreshingApp = false;
@state() private settingsSection: SettingsSection | undefined = readSettingsSection(); @state() private settingsSection: SettingsSection | undefined = readSettingsSection();
@state() private shortcutConfig: PiWebShortcutConfig = {}; @state() private shortcutConfig: PiWebShortcutConfig = {};
@state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
private readonly onPopState = () => void this.withChatScrollTransition(async () => { private readonly onPopState = () => void this.withChatScrollTransition(async () => {
this.restoreSettingsRoute(); this.restoreSettingsRoute();
await this.restoreRoute(false); await this.restoreRoute(false);
@@ -323,7 +325,7 @@ export class PiWebApp extends LitElement {
private async loadClientConfig(): Promise<void> { private async loadClientConfig(): Promise<void> {
try { try {
this.applyClientConfig((await configApi.config()).config); this.applyClientConfig((await configApi.config()).effectiveConfig);
} catch (error) { } catch (error) {
console.warn("Failed to load PI WEB config", error); console.warn("Failed to load PI WEB config", error);
} }
@@ -331,6 +333,7 @@ export class PiWebApp extends LitElement {
private applyClientConfig(config: PiWebConfigValues): void { private applyClientConfig(config: PiWebConfigValues): void {
this.shortcutConfig = config.shortcuts ?? {}; this.shortcutConfig = config.shortcuts ?? {};
this.workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(config);
} }
private async refreshAppData(): Promise<void> { private async refreshAppData(): Promise<void> {
@@ -575,12 +578,16 @@ export class PiWebApp extends LitElement {
if (tool === "core:workspace.git") await this.git.refreshGit(); if (tool === "core:workspace.git") await this.git.refreshGit();
} }
private async withChatScrollTransition(action: () => Promise<void>) { private async withChatScrollTransition(action: () => Promise<void>, shouldComplete: () => boolean = () => true) {
this.chatView?.saveScrollPosition(); this.chatView?.saveScrollPosition();
await action(); await action();
if (!shouldComplete()) return;
await this.updateComplete; await this.updateComplete;
if (!shouldComplete()) return;
await this.chatView?.updateComplete; await this.chatView?.updateComplete;
if (!shouldComplete()) return;
await nextFrame(); await nextFrame();
if (!shouldComplete()) return;
this.chatView?.restoreScrollPosition(); this.chatView?.restoreScrollPosition();
if (this.shouldAutoFocusPrompt()) this.promptEditor?.focusInput(); 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); 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 { private archivedDeleteUnavailableMessage(): string {
const machineName = this.state.selectedMachine?.name ?? "this machine"; const machineName = this.state.selectedMachine?.name ?? "this machine";
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`; 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<void>): Promise<void> { private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise<void>): Promise<void> {
const seq = ++this.navigationSelectionSeq;
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
await this.withChatScrollTransition(async () => { await this.withChatScrollTransition(async () => {
this.navigationSections.advanceAfterSelection(section); this.navigationSections.advanceAfterSelection(section);
await action(); await action();
}); }, isCurrentSelection);
if (!isCurrentSelection()) return;
await this.focusNavigationTarget(nextTarget); await this.focusNavigationTarget(nextTarget);
} }
@@ -1198,6 +1218,21 @@ export class PiWebApp extends LitElement {
private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles { private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles {
return { return {
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId), 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, workspace,
state: this.state, state: this.state,
files: this.createWorkspaceFiles(workspace, machineId), files: this.createWorkspaceFiles(workspace, machineId),
prompt: this.createPromptEditor(),
terminal: { terminal: {
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); }, open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }), runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
@@ -1237,9 +1273,13 @@ export class PiWebApp extends LitElement {
activeTerminalCount: this.state.activeTerminalCount, activeTerminalCount: this.state.activeTerminalCount,
selectedTerminalId: this.state.selectedTerminalId, selectedTerminalId: this.state.selectedTerminalId,
terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id, terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id,
workspaceUploadDefaultFolder: workspaceEffectiveUploadFolder(workspace.effectiveConfig, this.workspaceUploadDefaultFolder),
onRefreshFiles: () => { void this.files.refreshFiles(); }, onRefreshFiles: () => { void this.files.refreshFiles(); },
onExpandDir: (path: string) => { void this.files.expandDir(path); }, onExpandDir: (path: string) => { void this.files.expandDir(path); },
onSelectFile: (path: string) => { void this.files.selectFile(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(); }, onRefreshGit: () => { void this.git.refreshGit(); },
onSelectDiff: (path: string) => { void this.git.selectDiff(path); }, onSelectDiff: (path: string) => { void this.git.selectDiff(path); },
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); }, 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 { private createPluginRuntimeContext(): PluginRuntimeContext {
const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({ const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({
state: this.state, state: this.state,
prompt: this.createPromptEditor(),
piWebUnstable: { piWebUnstable: {
terminalCommandRuns: this.terminalCommandRunsForOrigin(origin), terminalCommandRuns: this.terminalCommandRunsForOrigin(origin),
openSettings: (section) => { this.openSettings(section); }, openSettings: (section) => { this.openSettings(section); },
@@ -1738,7 +1804,7 @@ export class PiWebApp extends LitElement {
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div> <div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html` ${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> <chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor> <prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status}></status-bar> <status-bar .status=${state.status}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null} ${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null} ${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
+9 -1
View File
@@ -33,6 +33,9 @@ export class PromptEditor extends LitElement {
@property() sessionId?: string; @property() sessionId?: string;
@property() cwd?: string; @property() cwd?: string;
@property() machineId = "local"; @property() machineId = "local";
@property() projectId?: string;
@property() workspaceId?: string;
@property({ type: Boolean }) workspaceScopedFileSuggestions = false;
@property({ type: Boolean }) canSteer = false; @property({ type: Boolean }) canSteer = false;
@property({ type: Boolean }) isCompacting = false; @property({ type: Boolean }) isCompacting = false;
@property({ type: Boolean }) canStop = false; @property({ type: Boolean }) canStop = false;
@@ -114,6 +117,11 @@ export class PromptEditor extends LitElement {
this.editor?.focus(); this.editor?.focus();
} }
/** Get the underlying CM6 EditorView, or undefined if not yet mounted. */
get view(): EditorView | undefined {
return this.editor;
}
private renderCompactStatus() { private renderCompactStatus() {
const status = this.status; const status = this.status;
if (status === undefined) return null; if (status === undefined) return null;
@@ -293,7 +301,7 @@ export class PromptEditor extends LitElement {
...(command.description === undefined ? {} : { description: command.description }), ...(command.description === undefined ? {} : { description: command.description }),
})); }));
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions); const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId, projectId: this.projectId, workspaceId: this.workspaceId, workspaceScoped: this.workspaceScopedFileSuggestions }).catch(emptyFileSuggestions);
if (version !== this.requestVersion) return; if (version !== this.requestVersion) return;
this.completions = files this.completions = files
.slice(0, 12) .slice(0, 12)
+1 -1
View File
@@ -166,7 +166,7 @@ export class SettingsDialog extends LitElement {
try { try {
const response = await configApi.saveConfig(config); const response = await configApi.saveConfig(config);
this.configResponse = response; this.configResponse = response;
this.onConfigSaved?.(response.config); this.onConfigSaved?.(response.effectiveConfig);
this.showSavedMessage(); this.showSavedMessage();
} catch (error) { } catch (error) {
this.error = `Failed to save config: ${errorMessage(error)}`; this.error = `Failed to save config: ${errorMessage(error)}`;
@@ -0,0 +1,59 @@
import { LitElement, css, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffTextSpan } from "../diff/unifiedDiff";
@customElement("unified-diff-viewer")
export class UnifiedDiffViewer extends LitElement {
@property() diff = "";
override render(): TemplateResult {
const lines = parseUnifiedDiff(this.diff);
if (lines.length === 0) return html`<p class="empty">No diff.</p>`;
return html`
<div class="scroller">
<div class="diff-grid" role="table" aria-label="Unified diff">
${lines.map((line) => this.renderLine(line))}
</div>
</div>
`;
}
private renderLine(line: UnifiedDiffLine): TemplateResult {
const kindClass = line.kind;
return html`
<div class="line" role="row">
<span class=${`cell line-number old ${kindClass}`} role="cell">${formatLineNumber(line.oldLineNumber)}</span>
<span class=${`cell line-number new ${kindClass}`} role="cell">${formatLineNumber(line.newLineNumber)}</span>
<span class=${`cell prefix ${kindClass}`} role="cell">${line.prefix}</span>
<span class=${`cell content ${kindClass}`} role="cell">${renderSpans(line.spans)}</span>
</div>
`;
}
static override styles = css`
:host { display: block; min-height: 0; height: 100%; color: var(--pi-text); background: var(--pi-bg); }
.empty { box-sizing: border-box; margin: 0; padding: 10px; color: var(--pi-muted); }
.scroller { height: 100%; min-height: 0; overflow: auto; background: var(--pi-bg); }
.diff-grid { display: grid; grid-template-columns: max-content max-content 2ch max-content; width: max-content; min-width: 100%; padding: 6px 0; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
.line { display: contents; }
.cell { min-height: 1.45em; white-space: pre; }
.line-number { min-width: 4ch; padding: 0 8px; border-right: 1px solid var(--pi-border-muted); color: var(--pi-dim); text-align: right; user-select: none; }
.prefix { padding: 0 4px; color: var(--pi-dim); text-align: center; user-select: none; }
.content { padding: 0 12px 0 4px; }
.meta { color: var(--pi-dim); }
.hunk { background: color-mix(in srgb, var(--pi-accent) 9%, transparent); color: var(--pi-accent); }
.add { background: color-mix(in srgb, var(--pi-success) 12%, transparent); }
.remove { background: color-mix(in srgb, var(--pi-danger) 12%, transparent); }
.marker { color: var(--pi-dim); }
.content.add .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-success) 36%, transparent); color: var(--pi-text); }
.content.remove .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-danger) 36%, transparent); color: var(--pi-text); }
`;
}
function renderSpans(spans: UnifiedDiffTextSpan[]): TemplateResult[] {
return spans.map((span) => html`<span class=${span.changed ? "inline-change" : ""}>${span.text}</span>`);
}
function formatLineNumber(lineNumber: number | undefined): string {
return lineNumber === undefined ? "" : String(lineNumber);
}
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
import { startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel";
describe("workspaceUploadBatchesForScope", () => {
it("filters upload batches to the selected project, workspace, and machine", () => {
const matchingOlder = uploadBatch({ id: "older", startedAt: "2026-06-25T00:00:00.000Z" });
const matchingNewer = uploadBatch({ id: "newer", startedAt: "2026-06-25T00:01:00.000Z" });
const batches = {
older: matchingOlder,
otherProject: uploadBatch({ id: "otherProject", projectId: "project-2" }),
otherWorkspace: uploadBatch({ id: "otherWorkspace", workspaceId: "workspace-2" }),
otherMachine: uploadBatch({ id: "otherMachine", machineId: "remote-1" }),
newer: matchingNewer,
};
expect(workspaceUploadBatchesForScope(batches, { projectId: "project-1", workspaceId: "workspace-1", machineId: "local" })).toEqual([matchingNewer, matchingOlder]);
});
});
describe("workspace upload terminal display", () => {
it("uses terminal labels and full progress for failed batches instead of stale partial percentages", () => {
const failed = uploadBatch({ status: "error", percent: 0.31 });
expect(uploadBatchStatusLabel(failed)).toBe("Failed");
expect(uploadBatchProgressValue(failed)).toBe(1);
});
it("keeps live percentages while a batch is uploading", () => {
const uploading = uploadBatch({ status: "uploading", percent: 0.31 });
expect(uploadBatchStatusLabel(uploading)).toBe("31%");
expect(uploadBatchProgressValue(uploading)).toBe(0.31);
});
});
describe("workspace upload defaults", () => {
it("uses safe defaults for the review dialog", () => {
expect(workspaceUploadReviewDefaults("project/uploads")).toEqual({
destinationFolder: "project/uploads",
createDirs: true,
overwrite: false,
});
});
it("starts drag/drop uploads directly with safe defaults", () => {
const files = [new File(["a"], "a.txt")];
const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() }));
const run = startDirectWorkspaceUpload({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }, files);
expect(run?.batchId).toBe("batch-1");
expect(onStartWorkspaceUpload).toHaveBeenCalledWith(files, {
destinationFolder: "project/uploads",
createDirs: true,
overwrite: false,
selectUploadedFile: true,
});
});
it("ignores empty drag/drop uploads", () => {
const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() }));
expect(startDirectWorkspaceUpload({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }, [])).toBeUndefined();
expect(onStartWorkspaceUpload).not.toHaveBeenCalled();
});
});
describe("workspaceUploadReviewError", () => {
it("accepts one or more files with a workspace-relative destination", () => {
expect(workspaceUploadReviewError([
new File(["a"], "a.txt"),
new File(["b"], "b.txt"),
], ".pi-web/uploads")).toBeUndefined();
});
it("rejects empty selections and unsafe destinations before starting an upload", () => {
expect(workspaceUploadReviewError([], ".pi-web/uploads")).toBe("Choose at least one file to upload.");
expect(workspaceUploadReviewError([new File(["a"], "a.txt")], "../outside")).toContain("path traversal");
});
});
function uploadBatch(patch: Partial<WorkspaceUploadBatchState> = {}): WorkspaceUploadBatchState {
return {
id: patch.id ?? "batch-1",
projectId: patch.projectId ?? "project-1",
workspaceId: patch.workspaceId ?? "workspace-1",
machineId: patch.machineId ?? "local",
destinationFolder: patch.destinationFolder ?? ".pi-web/uploads",
overwrite: patch.overwrite ?? true,
createDirs: patch.createDirs ?? true,
files: patch.files ?? [],
currentFileIndex: patch.currentFileIndex ?? -1,
loaded: patch.loaded ?? 0,
total: patch.total ?? 0,
percent: patch.percent ?? 0,
status: patch.status ?? "uploading",
startedAt: patch.startedAt ?? "2026-06-25T00:00:00.000Z",
...(patch.completedAt === undefined ? {} : { completedAt: patch.completedAt }),
...(patch.error === undefined ? {} : { error: patch.error }),
};
}
@@ -0,0 +1,492 @@
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { FileContentResponse, FileTreeEntry } from "../api";
import { workspaceImagePreviewUrl } from "../api/urls";
import { workspaceUploadPath } from "../api/workspaceUploads";
import type { WorkspaceUploadBatchState, WorkspaceUploadFileState } from "../workspaceUploadState";
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../shared/workspaceFiles";
import type { WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared";
interface PendingWorkspaceUploadReview {
files: File[];
}
export interface WorkspaceUploadScope {
projectId: string;
workspaceId: string;
machineId: string;
}
@customElement("workspace-files-panel")
export class WorkspaceFilesPanel extends LitElement {
@property({ attribute: false }) context: WorkspacePanelContext | undefined;
@query("#workspace-upload-input") private uploadInput?: HTMLInputElement;
@state() private pendingUpload: PendingWorkspaceUploadReview | undefined;
@state() private destinationFolder = "";
@state() private overwrite = false;
@state() private createDirs = true;
@state() private formError = "";
@state() private dragActive = false;
private dragDepth = 0;
protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (!changedProperties.has("context")) return;
const previous = changedProperties.get("context");
if (previous !== undefined && this.context !== undefined && workspaceContextKey(previous) !== workspaceContextKey(this.context)) this.resetPendingUpload();
}
override render(): TemplateResult {
const context = this.context;
if (context === undefined) return html`<p class="muted">Files unavailable.</p>`;
return html`
<section
class=${this.dragActive ? "files-panel dragging" : "files-panel"}
@dragenter=${this.handleDragEnter}
@dragover=${this.handleDragOver}
@dragleave=${this.handleDragLeave}
@drop=${this.handleDrop}
>
<section class="toolbar">
<strong>Files</strong>
${context.fileTreeStale ? html`<span class="stale">stale</span>` : null}
<div class="toolbar-actions">
<button @click=${this.openFilePicker}>Upload</button>
<button @click=${context.onRefreshFiles}>Refresh</button>
</div>
<input id="workspace-upload-input" class="visually-hidden" type="file" multiple @change=${this.handleFileInputChange} />
</section>
${this.renderUploadProgress(context)}
<section class="split">
<div class="list tree">
${context.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : context.fileTree.map((entry) => this.renderTreeEntry(context, entry, 0))}
</div>
<div class="viewer">
${this.renderFileViewer(context)}
</div>
</section>
<div class="drop-overlay" aria-hidden=${this.dragActive ? "false" : "true"}>
<div>
<strong>Drop files to upload</strong>
<span>Uploads immediately to the default folder.</span>
</div>
</div>
${this.pendingUpload === undefined ? null : this.renderUploadDialog(context, this.pendingUpload)}
</section>
`;
}
private renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult {
const children = context.expandedDirs[entry.path];
const hasChildren = children !== undefined;
const selected = entry.type !== "directory" && context.selectedFilePath === entry.path;
return html`
<button class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { this.selectTreeEntry(context, entry); }}>
<span>${entry.type === "directory" ? (hasChildren ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${hasChildren ? children.map((child) => this.renderTreeEntry(context, child, depth + 1)) : null}
`;
}
private selectTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry): void {
if (entry.type === "directory") context.onExpandDir(entry.path);
else context.onSelectFile(entry.path);
}
private renderFileViewer(context: WorkspacePanelContext): TemplateResult {
const file = context.selectedFileContent;
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`<p class="muted">Select a file.</p>`;
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
if (file.mediaType === "image") return this.renderImageViewer(context, file);
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
loadCodeViewer();
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${file.content} .language=${file.language}></code-viewer>
`;
}
private renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult {
const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`;
if (file.size > MAX_IMAGE_PREVIEW_BYTES) {
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
`;
}
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id });
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<div class="image-preview">
<img src=${src} alt=${file.path} decoding="async" />
</div>
`;
}
private renderUploadProgress(context: WorkspacePanelContext): TemplateResult | null {
const batches = workspaceUploadBatchesForScope(context.state.workspaceUploadBatches, {
projectId: context.workspace.projectId,
workspaceId: context.workspace.id,
machineId: context.machine.id,
});
if (batches.length === 0) return null;
return html`
<section class="upload-progress" aria-label="Workspace uploads">
<div class="upload-progress-header">
<strong>Uploads</strong>
<small>${uploadSummaryLabel(batches)}</small>
</div>
${batches.map((batch) => this.renderUploadBatch(context, batch))}
</section>
`;
}
private renderUploadBatch(context: WorkspacePanelContext, batch: WorkspaceUploadBatchState): TemplateResult {
return html`
<article class=${`upload-batch ${batch.status}`}>
<div class="upload-batch-heading">
<div>
<strong>${uploadBatchTitle(batch)}</strong>
<small>${batch.destinationFolder === "" ? "workspace root" : batch.destinationFolder}</small>
</div>
<span>${uploadBatchStatusLabel(batch)}</span>
</div>
<progress max="1" .value=${uploadBatchProgressValue(batch)}></progress>
<div class="upload-file-list">
${batch.files.map((file) => this.renderUploadFile(file))}
</div>
<div class="upload-actions">
${batch.status === "uploading" ? html`<button @click=${() => { context.onCancelWorkspaceUpload(batch.id); }}>Cancel</button>` : html`<button @click=${() => { context.onClearWorkspaceUpload(batch.id); }}>Dismiss</button>`}
</div>
</article>
`;
}
private renderUploadFile(file: WorkspaceUploadFileState): TemplateResult {
const detail = uploadFileDetail(file);
return html`
<div class=${`upload-file ${file.status}`}>
<div class="upload-file-main">
<span>${file.name}</span>
<small>${detail}</small>
</div>
<span class="upload-file-status">${uploadFileStatusLabel(file)}</span>
</div>
`;
}
private renderUploadDialog(context: WorkspacePanelContext, review: PendingWorkspaceUploadReview): TemplateResult {
const fileCount = review.files.length;
return html`
<div class="dialog-backdrop" @mousedown=${() => { this.closeUploadDialog(); }}>
<section class="upload-dialog" role="dialog" aria-modal="true" aria-label="Review file upload" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${this.handleDialogKeyDown}>
<header>
<div>
<span class="eyebrow">Upload</span>
<h2>Review ${fileCount === 1 ? "file" : `${String(fileCount)} files`}</h2>
</div>
<button class="close-button" title="Cancel upload" aria-label="Cancel upload" @click=${() => { this.closeUploadDialog(); }}>×</button>
</header>
<form @submit=${(event: SubmitEvent) => { this.submitUploadReview(event, context, review); }}>
<label>
<span>Destination folder</span>
<input .value=${this.destinationFolder} placeholder=${context.workspaceUploadDefaultFolder} @input=${this.handleDestinationInput} />
<small>Workspace-relative. Leave empty to upload at the workspace root.</small>
</label>
<div class="dialog-options">
<label>
<input type="checkbox" .checked=${this.createDirs} @change=${this.handleCreateDirsChange} />
<span>Create parent folders</span>
</label>
<label>
<input type="checkbox" .checked=${this.overwrite} @change=${this.handleOverwriteChange} />
<span>Overwrite existing files</span>
</label>
</div>
<section class="review-files" aria-label="Files to upload">
<strong>${fileCount === 1 ? "File" : "Files"}</strong>
${review.files.map((file) => html`
<div class="review-file">
<span>${file.name}</span>
<small>${formatFileSize(file.size)}</small>
</div>
`)}
</section>
${this.formError === "" ? null : html`<div class="dialog-error" role="alert">${this.formError}</div>`}
<footer>
<button type="button" @click=${() => { this.closeUploadDialog(); }}>Cancel</button>
<button type="submit">Upload</button>
</footer>
</form>
</section>
</div>
`;
}
private readonly openFilePicker = (): void => {
this.uploadInput?.click();
};
private readonly handleFileInputChange = (event: Event): void => {
const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined;
const files = fileListToArray(input?.files);
if (input !== undefined) input.value = "";
if (files.length > 0) this.openUploadReview(files);
};
private readonly handleDragEnter = (event: DragEvent): void => {
if (!isFileDrag(event)) return;
event.preventDefault();
this.dragDepth += 1;
this.dragActive = true;
};
private readonly handleDragOver = (event: DragEvent): void => {
if (!isFileDrag(event)) return;
event.preventDefault();
if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "copy";
this.dragActive = true;
};
private readonly handleDragLeave = (event: DragEvent): void => {
if (!isFileDrag(event)) return;
event.preventDefault();
this.dragDepth = Math.max(0, this.dragDepth - 1);
if (this.dragDepth === 0) this.dragActive = false;
};
private readonly handleDrop = (event: DragEvent): void => {
if (!isFileDrag(event)) return;
event.preventDefault();
this.dragDepth = 0;
this.dragActive = false;
const files = fileListToArray(event.dataTransfer?.files);
const context = this.context;
if (files.length > 0 && context !== undefined) startDirectWorkspaceUpload(context, files);
};
private readonly handleDestinationInput = (event: Event): void => {
const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined;
this.destinationFolder = input?.value ?? "";
this.formError = "";
};
private readonly handleCreateDirsChange = (event: Event): void => {
const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined;
this.createDirs = input?.checked ?? true;
};
private readonly handleOverwriteChange = (event: Event): void => {
const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined;
this.overwrite = input?.checked ?? false;
};
private readonly handleDialogKeyDown = (event: KeyboardEvent): void => {
if (event.key !== "Escape") return;
event.preventDefault();
this.closeUploadDialog();
};
private openUploadReview(files: File[]): void {
const context = this.context;
const defaults = workspaceUploadReviewDefaults(context?.workspaceUploadDefaultFolder ?? "");
this.pendingUpload = { files };
this.destinationFolder = defaults.destinationFolder;
this.overwrite = defaults.overwrite;
this.createDirs = defaults.createDirs;
this.formError = "";
}
private submitUploadReview(event: SubmitEvent, context: WorkspacePanelContext, review: PendingWorkspaceUploadReview): void {
event.preventDefault();
const validationError = workspaceUploadReviewError(review.files, this.destinationFolder);
if (validationError !== undefined) {
this.formError = validationError;
return;
}
const run = context.onStartWorkspaceUpload(review.files, {
destinationFolder: this.destinationFolder,
createDirs: this.createDirs,
overwrite: this.overwrite,
selectUploadedFile: true,
});
if (run !== undefined) this.closeUploadDialog();
}
private closeUploadDialog(): void {
this.pendingUpload = undefined;
this.formError = "";
}
private resetPendingUpload(): void {
this.closeUploadDialog();
this.dragDepth = 0;
this.dragActive = false;
}
static override styles = [
workspacePanelStyles,
css`
:host { flex: 1 1 auto; }
.files-panel { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.toolbar .toolbar-actions button { margin-left: 0; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
.drop-overlay { position: absolute; inset: 52px 10px 10px; z-index: 15; display: grid; place-items: center; border: 2px dashed var(--pi-accent); border-radius: 12px; background: color-mix(in srgb, var(--pi-bg-overlay) 90%, var(--pi-accent) 10%); color: var(--pi-text); opacity: 0; pointer-events: none; transition: opacity .12s ease; }
.files-panel.dragging .drop-overlay { opacity: 1; }
.drop-overlay div { display: grid; gap: 4px; justify-items: center; padding: 18px; border-radius: 10px; background: var(--pi-bg-overlay); box-shadow: 0 8px 24px var(--pi-shadow); }
.drop-overlay span { color: var(--pi-muted); }
.upload-progress { flex: 0 0 auto; display: grid; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: color-mix(in srgb, var(--pi-surface) 55%, transparent); }
.upload-progress-header, .upload-batch-heading, .upload-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.upload-batch { display: grid; gap: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: var(--pi-bg); padding: 8px; }
.upload-batch.error { border-color: var(--pi-danger); }
.upload-batch.cancelled { border-color: var(--pi-warning-border); }
.upload-batch.completed { border-color: var(--pi-success-border); }
.upload-batch-heading > div { min-width: 0; display: grid; gap: 2px; }
.upload-batch-heading strong, .upload-batch-heading small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
progress { width: 100%; accent-color: var(--pi-accent); }
.upload-file-list { display: grid; gap: 4px; max-height: 180px; overflow: auto; padding-right: 2px; }
.upload-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; color: var(--pi-muted); }
.upload-file.completed .upload-file-status { color: var(--pi-success); }
.upload-file.error { color: var(--pi-danger); }
.upload-file.cancelled .upload-file-status { color: var(--pi-warning); }
.upload-file-main { min-width: 0; display: grid; gap: 1px; }
.upload-file-main span, .upload-file-main small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-status { font-size: 12px; white-space: nowrap; }
.upload-actions { justify-content: end; }
.dialog-backdrop { position: fixed; inset: 0; z-index: 100; box-sizing: border-box; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); }
.upload-dialog { box-sizing: border-box; width: min(560px, 100%); max-height: min(720px, 100%); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 18px 70px var(--pi-shadow-strong); }
.upload-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border-muted); }
.upload-dialog h2 { margin: 2px 0 0; font-size: 18px; line-height: 1.2; }
.eyebrow { color: var(--pi-muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
.close-button { font-size: 20px; line-height: 1; padding: 4px 9px; }
form { min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: auto; padding: 16px; }
form > label { display: grid; gap: 6px; }
form > label > span, .review-files > strong { font-weight: 600; }
input[type="text"], form > label > input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 9px; font: inherit; }
input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
.dialog-options { display: grid; gap: 8px; }
.dialog-options label { display: flex; align-items: center; gap: 8px; color: var(--pi-text); }
.review-files { display: grid; gap: 6px; min-height: 0; max-height: 180px; overflow: auto; border: 1px solid var(--pi-border-muted); border-radius: 8px; padding: 8px; }
.review-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
.review-file span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dialog-error { border: 1px solid var(--pi-danger); border-radius: 8px; background: color-mix(in srgb, var(--pi-danger) 10%, transparent); color: var(--pi-danger); padding: 9px; line-height: 1.35; overflow-wrap: anywhere; }
footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; }
`,
];
}
export function workspaceUploadBatchesForScope(batches: Record<string, WorkspaceUploadBatchState>, scope: WorkspaceUploadScope): WorkspaceUploadBatchState[] {
return Object.values(batches)
.filter((batch) => batch.projectId === scope.projectId && batch.workspaceId === scope.workspaceId && batch.machineId === scope.machineId)
.sort((left, right) => right.startedAt.localeCompare(left.startedAt));
}
export function workspaceUploadReviewError(files: readonly File[], destinationFolder: string): string | undefined {
if (files.length === 0) return "Choose at least one file to upload.";
for (const file of files) {
try {
workspaceUploadPath(destinationFolder, file.name);
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
return undefined;
}
export function workspaceUploadReviewDefaults(destinationFolder: string): { destinationFolder: string; createDirs: boolean; overwrite: boolean } {
return { destinationFolder, createDirs: true, overwrite: false };
}
export function startDirectWorkspaceUpload(
context: Pick<WorkspacePanelContext, "workspaceUploadDefaultFolder" | "onStartWorkspaceUpload">,
files: readonly File[],
): ReturnType<WorkspacePanelContext["onStartWorkspaceUpload"]> {
if (files.length === 0) return undefined;
return context.onStartWorkspaceUpload(files, {
destinationFolder: context.workspaceUploadDefaultFolder,
createDirs: true,
overwrite: false,
selectUploadedFile: true,
});
}
function workspaceContextKey(context: WorkspacePanelContext): string {
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
}
function fileListToArray(files: FileList | null | undefined): File[] {
return files === null || files === undefined ? [] : Array.from(files);
}
function isFileDrag(event: DragEvent): boolean {
return Array.from(event.dataTransfer?.types ?? []).includes("Files");
}
function uploadSummaryLabel(batches: readonly WorkspaceUploadBatchState[]): string {
const uploading = batches.filter((batch) => batch.status === "uploading").length;
return uploading === 0 ? `${String(batches.length)} recent` : `${String(uploading)} uploading`;
}
function uploadBatchTitle(batch: WorkspaceUploadBatchState): string {
const count = batch.files.length;
const files = count === 1 ? "file" : "files";
switch (batch.status) {
case "completed": return `Uploaded ${String(count)} ${files}`;
case "error": return `Upload failed for ${String(count)} ${files}`;
case "cancelled": return `Upload cancelled for ${String(count)} ${files}`;
case "uploading": return `Uploading ${String(count)} ${files}`;
}
}
export function uploadBatchStatusLabel(batch: WorkspaceUploadBatchState): string {
switch (batch.status) {
case "completed": return "Done";
case "error": return "Failed";
case "cancelled": return "Cancelled";
case "uploading": return formatPercent(batch.percent);
}
}
export function uploadBatchProgressValue(batch: WorkspaceUploadBatchState): number {
return batch.status === "uploading" ? batch.percent : 1;
}
function uploadFileStatusLabel(file: WorkspaceUploadFileState): string {
switch (file.status) {
case "pending": return "Pending";
case "uploading": return formatPercent(file.percent);
case "completed": return "Done";
case "error": return "Error";
case "cancelled": return "Cancelled";
}
}
function uploadFileDetail(file: WorkspaceUploadFileState): string {
if (file.error !== undefined) return file.error;
if (file.response !== undefined) return `Wrote ${file.response.path}`;
return `${file.path} · ${formatFileSize(file.loaded)} / ${formatFileSize(file.total)}`;
}
function formatPercent(value: number): string {
return `${String(Math.round(Math.max(0, Math.min(1, value)) * 100))}%`;
}
function loadCodeViewer(): void {
void import("./CodeViewer");
}
function formatFileSize(size: number): string {
if (!Number.isFinite(size) || size < 0) return "0 B";
if (size < 1024) return `${String(size)} B`;
const kib = size / 1024;
if (kib < 1024) return `${formatScaledFileSize(kib)} KB`;
const mib = kib / 1024;
if (mib < 1024) return `${formatScaledFileSize(mib)} MB`;
return `${formatScaledFileSize(mib / 1024)} GB`;
}
function formatScaledFileSize(value: number): string {
return value >= 10 ? String(Math.round(value)) : value.toFixed(1);
}
@@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement {
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small> <small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
</div> </div>
<label class="field">
<span class="field-heading">
<span>External filesystem roots</span>
</span>
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs&#10;/opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
</label>
${this.renderEffectiveConfig()} ${this.renderEffectiveConfig()}
<footer class="form-actions"> <footer class="form-actions">
@@ -102,6 +110,7 @@ export class SettingsGeneralPanel extends LitElement {
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div> <div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div> <div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div> <div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
</dl> </dl>
</section> </section>
`; `;
@@ -173,6 +182,11 @@ function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string |
return html`<span class="muted">Unset</span>`; return html`<span class="muted">Unset</span>`;
} }
function formatAllowedPaths(value: string[] | undefined): string | TemplateResult {
if (value === undefined || value.length === 0) return html`<span class="muted">External paths denied</span>`;
return value.join(", ");
}
function inputValue(event: Event): string { function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : ""; return event.target instanceof HTMLInputElement ? event.target.value : "";
} }
@@ -3,36 +3,62 @@ import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
describe("settings config drafts", () => { describe("settings config drafts", () => {
it("converts PI WEB config values to editable general settings drafts", () => { it("converts PI WEB config values to editable general settings drafts", () => {
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({ expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
host: "0.0.0.0", host: "0.0.0.0",
port: "8504", port: "8504",
allowedHostsMode: "list", allowedHostsMode: "list",
allowedHostsText: "example.local\n192.168.1.20", allowedHostsText: "example.local\n192.168.1.20",
allowedPathsText: "/tmp\n~/SDKs",
}); });
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
}); });
it("converts drafts back to config while preserving shortcut and plugin preferences", () => { it("converts drafts back to config while preserving non-general preferences", () => {
expect(configFromDraft({ expect(configFromDraft({
host: " 127.0.0.1 ", host: " 127.0.0.1 ",
port: "9000", port: "9000",
allowedHostsMode: "list", allowedHostsMode: "list",
allowedHostsText: "example.local, 192.168.1.20\n", allowedHostsText: "example.local, 192.168.1.20\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({ allowedPathsText: "/tmp\n~/SDKs\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({
host: "127.0.0.1", host: "127.0.0.1",
port: 9000, port: 9000,
allowedHosts: ["example.local", "192.168.1.20"], allowedHosts: ["example.local", "192.168.1.20"],
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
plugins: { info: { enabled: false } }, plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 1234,
}); });
}); });
it("removes global path access when the allowed paths field is cleared", () => {
expect(configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "",
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
});
it("rejects relative external paths before saving", () => {
expect(() => configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "relative/path",
})).toThrow("Allowed external paths must be absolute paths or start with ~");
});
it("preserves the spawnSessions flag when saving general settings", () => { it("preserves the spawnSessions flag when saving general settings", () => {
const result = configFromDraft({ const result = configFromDraft({
host: "", host: "",
port: "", port: "",
allowedHostsMode: "list", allowedHostsMode: "list",
allowedHostsText: "", allowedHostsText: "",
allowedPathsText: "",
}, { spawnSessions: true }); }, { spawnSessions: true });
expect(result.spawnSessions).toBe(true); expect(result.spawnSessions).toBe(true);
}); });
@@ -5,10 +5,11 @@ export interface ConfigDraft {
port: string; port: string;
allowedHostsMode: "list" | "all"; allowedHostsMode: "list" | "all";
allowedHostsText: string; allowedHostsText: string;
allowedPathsText: string;
} }
export function emptyConfigDraft(): ConfigDraft { export function emptyConfigDraft(): ConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
} }
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft { export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
@@ -17,6 +18,7 @@ export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
port: config.port === undefined ? "" : String(config.port), port: config.port === undefined ? "" : String(config.port),
allowedHostsMode: config.allowedHosts === true ? "all" : "list", allowedHostsMode: config.allowedHosts === true ? "all" : "list",
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "", allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
}; };
} }
@@ -24,6 +26,8 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
const config: PiWebConfigValues = { const config: PiWebConfigValues = {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), ...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
}; };
@@ -36,9 +40,22 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
config.port = parsed; config.port = parsed;
} }
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText); config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
return config; return config;
} }
function parseAllowedHostsText(value: string): string[] { function parseAllowedHostsText(value: string): string[] {
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== ""); return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
} }
function parseAllowedPathsText(value: string): string[] {
const paths = value.split("\n").map((path) => path.trim()).filter((path) => path !== "");
const invalid = paths.find((path) => !isAbsoluteishAllowedPath(path));
if (invalid !== undefined) throw new Error(`Allowed external paths must be absolute paths or start with ~: ${invalid}`);
return paths;
}
function isAbsoluteishAllowedPath(path: string): boolean {
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
}
+1 -1
View File
@@ -200,7 +200,7 @@ export const workspacePanelStyles = css`
.diff-section:last-child { border-bottom: 0; } .diff-section:last-child { border-bottom: 0; }
.viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } .viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
code-viewer { flex: 1 1 auto; min-height: 0; } code-viewer, unified-diff-viewer { flex: 1 1 auto; min-height: 0; }
.image-preview { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; } .image-preview { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; }
.image-preview img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid var(--pi-border-muted); border-radius: 8px; background-color: var(--pi-surface); background-image: linear-gradient(45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; box-shadow: 0 8px 24px var(--pi-shadow-soft); } .image-preview img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid var(--pi-border-muted); border-radius: 8px; background-color: var(--pi-surface); background-image: linear-gradient(45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; box-shadow: 0 8px 24px var(--pi-shadow-soft); }
pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; } pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -0,0 +1,305 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { initialAppState, type AppState } from "../appState";
import {
WorkspaceUploadBatchError,
WorkspaceUploadCancelledError,
type FileContentResponse,
type FileTreeResponse,
type Machine,
type Project,
type Workspace,
type WorkspaceUploadBatchProgress,
type WriteWorkspaceFileResponse,
} from "../api";
import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController";
type UploadWorkspaceFiles = NonNullable<FileExplorerControllerDependencies["uploadWorkspaceFiles"]>;
type UploadWorkspaceFilesOptions = NonNullable<Parameters<UploadWorkspaceFiles>[3]>;
const originalWindow = globalThis.window;
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true });
});
const machine: Machine = {
id: "remote-1",
name: "Remote",
kind: "remote",
createdAt: "2026-06-25T00:00:00.000Z",
updatedAt: "2026-06-25T00:00:00.000Z",
};
const project: Project = {
id: "project-1",
name: "Project",
path: "/repo",
createdAt: "2026-06-25T00:00:00.000Z",
};
const workspace: Workspace = {
id: "workspace-1",
projectId: project.id,
path: "/repo",
label: "repo",
isMain: true,
isGitRepo: true,
isGitWorktree: false,
};
describe("FileExplorerController workspace uploads", () => {
it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => {
const upload = controllableUpload();
const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "complete") });
const files = [new File(["aa"], "a.txt", { type: "text/plain" }), new File(["bbb"], "b.txt")];
const run = harness.controller.startWorkspaceUpload(files, { destinationFolder: "uploads/manual", overwrite: false });
expect(run?.batchId).toBe("batch-1");
expect(upload.fn).toHaveBeenCalledWith("project-1", "workspace-1", files, expect.objectContaining({
destinationFolder: "uploads/manual",
machineId: "remote-1",
overwrite: false,
createDirs: true,
}));
expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({
destinationFolder: "uploads/manual",
overwrite: false,
createDirs: true,
status: "uploading",
startedAt: "start",
total: 5,
files: [
{ name: "a.txt", path: "uploads/manual/a.txt", status: "uploading", total: 2 },
{ name: "b.txt", path: "uploads/manual/b.txt", status: "pending", total: 3 },
],
});
upload.emitProgress({
currentFileIndex: 0,
files: [
{ index: 0, name: "a.txt", path: "uploads/manual/a.txt", loaded: 1, total: 2, percent: 0.5, lengthComputable: true, done: false },
{ index: 1, name: "b.txt", path: "uploads/manual/b.txt", loaded: 0, total: 3, percent: 0, lengthComputable: true, done: false },
],
loaded: 1,
total: 5,
percent: 0.2,
done: false,
});
expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({
loaded: 1,
percent: 0.2,
files: [
{ path: "uploads/manual/a.txt", loaded: 1, percent: 0.5, status: "uploading" },
{ path: "uploads/manual/b.txt", loaded: 0, status: "pending" },
],
});
upload.resolve([
writeResponse("uploads/manual/a.txt", 2),
writeResponse("uploads/manual/b.txt", 3),
]);
await run?.done;
expect(harness.api.workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1");
expect(harness.api.workspaceFile).toHaveBeenCalledWith("project-1", "workspace-1", "uploads/manual/a.txt", "remote-1");
expect(harness.updateUrl).toHaveBeenCalledWith({ replace: true });
expect(harness.state.selectedFilePath).toBe("uploads/manual/a.txt");
expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({
status: "completed",
completedAt: "complete",
loaded: 5,
percent: 1,
files: [
{ status: "completed", response: { path: "uploads/manual/a.txt", size: 2 } },
{ status: "completed", response: { path: "uploads/manual/b.txt", size: 3 } },
],
});
});
it("defaults uploads to create parent folders without overwriting existing files", () => {
const upload = controllableUpload();
const harness = createHarness({ uploadWorkspaceFiles: upload.fn });
const files = [new File(["aa"], "a.txt")];
const run = harness.controller.startWorkspaceUpload(files, { destinationFolder: "uploads" });
expect(run?.batchId).toBe("batch-1");
expect(upload.fn).toHaveBeenCalledWith("project-1", "workspace-1", files, expect.objectContaining({
destinationFolder: "uploads",
machineId: "remote-1",
overwrite: false,
createDirs: true,
}));
expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({
destinationFolder: "uploads",
overwrite: false,
createDirs: true,
});
});
it("cancels an in-flight upload without setting the global error", async () => {
const upload = controllableUpload({ rejectOnCancel: true });
const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "cancel") });
const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "uploads" });
harness.controller.cancelWorkspaceUpload(run?.batchId ?? "missing");
await run?.done;
expect(upload.cancel).toHaveBeenCalledTimes(1);
expect(harness.state.error).toBe("");
expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({
status: "cancelled",
completedAt: "cancel",
error: "Upload cancelled",
files: [{ status: "cancelled", error: "Upload cancelled" }],
});
});
it("keeps per-file errors accurate and refreshes after partial batch success", async () => {
const upload = controllableUpload();
const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "fail") });
const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt"), new File(["bbbb"], "b.txt")], { destinationFolder: "uploads" });
upload.emitProgress({
currentFileIndex: 1,
files: [
{ index: 0, name: "a.txt", path: "uploads/a.txt", loaded: 2, total: 2, percent: 1, lengthComputable: true, done: true, error: "File already exists: uploads/a.txt" },
{ index: 1, name: "b.txt", path: "uploads/b.txt", loaded: 4, total: 4, percent: 1, lengthComputable: true, done: true },
],
loaded: 6,
total: 6,
percent: 1,
done: true,
});
upload.reject(new WorkspaceUploadBatchError(
[{ index: 0, name: "a.txt", path: "uploads/a.txt", error: "File already exists: uploads/a.txt" }],
[writeResponse("uploads/b.txt", 4)],
));
await run?.done;
expect(harness.api.workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1");
expect(harness.api.workspaceFile).toHaveBeenCalledWith("project-1", "workspace-1", "uploads/b.txt", "remote-1");
expect(harness.state.error).toBe("");
expect(harness.state.selectedFilePath).toBe("uploads/b.txt");
expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({
status: "error",
completedAt: "fail",
error: "File already exists: uploads/a.txt",
loaded: 6,
total: 6,
percent: 1,
files: [
{ path: "uploads/a.txt", status: "error", error: "File already exists: uploads/a.txt" },
{ path: "uploads/b.txt", status: "completed" },
],
});
});
it("rejects unsafe upload destinations before starting a batch", () => {
const upload = controllableUpload();
const harness = createHarness({ uploadWorkspaceFiles: upload.fn });
const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "../outside" });
expect(run).toBeUndefined();
expect(upload.fn).not.toHaveBeenCalled();
expect(harness.state.workspaceUploadBatches).toEqual({});
expect(harness.state.error).toContain("upload destination must not contain path traversal");
});
});
function createHarness(deps: FileExplorerControllerDependencies = {}) {
installWindow("http://localhost/app");
let state: AppState = {
...initialAppState(),
selectedMachine: machine,
selectedProject: project,
selectedWorkspace: workspace,
};
const api: NonNullable<FileExplorerControllerDependencies["api"]> = deps.api ?? {
workspaceTree: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
workspaceFile: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceFile"]>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))),
};
const updateUrl = vi.fn();
let batchSequence = 0;
const controller = new FileExplorerController(
() => state,
(patch) => { state = { ...state, ...patch }; },
updateUrl,
{
...deps,
api,
createUploadBatchId: deps.createUploadBatchId ?? (() => {
batchSequence += 1;
return `batch-${String(batchSequence)}`;
}),
},
);
return {
controller,
api,
updateUrl,
get state(): AppState { return state; },
};
}
function installWindow(href: string): void {
const url = new URL(href);
const fakeWindow = {
location: {
href: url.href,
pathname: url.pathname,
search: url.search,
hash: url.hash,
},
history: {
pushState: vi.fn(),
replaceState: vi.fn(),
},
};
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
}
function controllableUpload(options: { rejectOnCancel?: boolean } = {}) {
let resolveUpload: ((responses: WriteWorkspaceFileResponse[]) => void) | undefined;
let rejectUpload: ((error: unknown) => void) | undefined;
let uploadOptions: UploadWorkspaceFilesOptions | undefined;
const cancel = vi.fn(() => {
if (options.rejectOnCancel === true) rejectUpload?.(new WorkspaceUploadCancelledError());
});
const fn = vi.fn<UploadWorkspaceFiles>((_projectId, _workspaceId, _files, sentOptions = {}) => {
uploadOptions = sentOptions;
const promise = new Promise<WriteWorkspaceFileResponse[]>((resolve, reject) => {
resolveUpload = resolve;
rejectUpload = reject;
});
return { promise, cancel };
});
return {
fn,
cancel,
emitProgress: (progress: WorkspaceUploadBatchProgress) => { uploadOptions?.onProgress?.(progress); },
resolve: (responses: WriteWorkspaceFileResponse[]) => { resolveUpload?.(responses); },
reject: (error: unknown) => { rejectUpload?.(error); },
};
}
function sequenceNow(...values: string[]): () => string {
let index = 0;
return () => values[index++] ?? values.at(-1) ?? "now";
}
function treeResponse(path: string): FileTreeResponse {
return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
}
function fileResponse(path: string): FileContentResponse {
return { path, encoding: "utf8", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", content: "aa", truncated: false, binary: false };
}
function writeResponse(path: string, size: number): WriteWorkspaceFileResponse {
return { path, size, modifiedAt: "2026-06-25T00:00:00.000Z", created: true };
}
@@ -1,11 +1,69 @@
import { api } from "../api"; import {
api as defaultApi,
uploadWorkspaceFiles as defaultUploadWorkspaceFiles,
WorkspaceUploadBatchError,
WorkspaceUploadCancelledError,
type WorkspaceUploadBatchProgress,
type WorkspaceUploadTask,
type WriteWorkspaceFileResponse,
} from "../api";
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs"; import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
import {
cancelWorkspaceUploadBatch,
completeWorkspaceUploadBatch,
createWorkspaceUploadBatchState,
failWorkspaceUploadBatch,
updateWorkspaceUploadBatchProgress,
type WorkspaceUploadBatchState,
} from "../workspaceUploadState";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files"); const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
type FileExplorerApi = Pick<typeof defaultApi, "workspaceFile" | "workspaceTree">;
type UploadWorkspaceFiles = typeof defaultUploadWorkspaceFiles;
export interface FileExplorerControllerDependencies {
api?: FileExplorerApi;
uploadWorkspaceFiles?: UploadWorkspaceFiles;
createUploadBatchId?: () => string;
now?: () => string;
}
export interface StartWorkspaceUploadOptions {
destinationFolder: string;
createDirs?: boolean;
overwrite?: boolean;
selectUploadedFile?: boolean;
}
export interface WorkspaceUploadRun {
batchId: string;
done: Promise<void>;
}
export class FileExplorerController { export class FileExplorerController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} private readonly api: FileExplorerApi;
private readonly uploadWorkspaceFiles: UploadWorkspaceFiles;
private readonly createUploadBatchId: () => string;
private readonly now: () => string;
private readonly uploadTasks = new Map<string, WorkspaceUploadTask<WriteWorkspaceFileResponse[]>>();
private uploadBatchSequence = 0;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
private readonly updateUrl: UpdateUrl,
deps: FileExplorerControllerDependencies = {},
) {
this.api = deps.api ?? defaultApi;
this.uploadWorkspaceFiles = deps.uploadWorkspaceFiles ?? defaultUploadWorkspaceFiles;
this.createUploadBatchId = deps.createUploadBatchId ?? (() => {
this.uploadBatchSequence += 1;
return `workspace-upload-${String(this.uploadBatchSequence)}`;
});
this.now = deps.now ?? (() => new Date().toISOString());
}
async refreshFiles(): Promise<void> { async refreshFiles(): Promise<void> {
const project = this.getState().selectedProject; const project = this.getState().selectedProject;
@@ -13,9 +71,9 @@ export class FileExplorerController {
if (project === undefined || workspace === undefined) return; if (project === undefined || workspace === undefined) return;
try { try {
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
const root = await api.workspaceTree(project.id, workspace.id, "", machineId); const root = await this.api.workspaceTree(project.id, workspace.id, "", machineId);
const expanded = { ...this.getState().expandedDirs }; const expanded = { ...this.getState().expandedDirs };
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path, machineId)).entries; })); await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await this.api.workspaceTree(project.id, workspace.id, path, machineId)).entries; }));
this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" }); this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" });
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
@@ -31,7 +89,7 @@ export class FileExplorerController {
return; return;
} }
try { try {
const response = await api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState())); const response = await this.api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState()));
this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" }); this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" });
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
@@ -51,7 +109,7 @@ export class FileExplorerController {
if (project === undefined || workspace === undefined) return; if (project === undefined || workspace === undefined) return;
this.setState({ selectedFilePath: path, selectedFileContent: undefined }); this.setState({ selectedFilePath: path, selectedFileContent: undefined });
try { try {
const content = await api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState())); const content = await this.api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState()));
if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" }); if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" });
} catch (error) { } catch (error) {
if (this.getState().selectedFilePath !== path) return; if (this.getState().selectedFilePath !== path) return;
@@ -64,6 +122,123 @@ export class FileExplorerController {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
} }
} }
startWorkspaceUpload(files: readonly File[], options: StartWorkspaceUploadOptions): WorkspaceUploadRun | undefined {
const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) {
this.setState({ error: "Select a workspace before uploading files." });
return undefined;
}
if (files.length === 0) return undefined;
const machineId = selectedMachineId(this.getState());
const overwrite = options.overwrite ?? false;
const createDirs = options.createDirs ?? true;
let batch: WorkspaceUploadBatchState;
try {
batch = createWorkspaceUploadBatchState({
id: this.createUploadBatchId(),
projectId: project.id,
workspaceId: workspace.id,
machineId,
destinationFolder: options.destinationFolder,
overwrite,
createDirs,
files,
startedAt: this.now(),
});
} catch (error) {
this.setState({ error: String(error) });
return undefined;
}
this.setUploadBatch(batch);
let task: WorkspaceUploadTask<WriteWorkspaceFileResponse[]>;
try {
task = this.uploadWorkspaceFiles(project.id, workspace.id, files, {
destinationFolder: options.destinationFolder,
machineId,
overwrite,
createDirs,
onProgress: (progress) => { this.updateUploadProgress(batch.id, progress); },
});
} catch (error) {
this.failUploadBatch(batch.id, error);
return { batchId: batch.id, done: Promise.resolve() };
}
this.uploadTasks.set(batch.id, task);
const done = task.promise
.then(async (responses) => { await this.completeUploadBatch(batch.id, responses, options); })
.catch(async (error: unknown) => { await this.handleUploadFailure(batch.id, error, options); })
.finally(() => { this.uploadTasks.delete(batch.id); });
return { batchId: batch.id, done };
}
cancelWorkspaceUpload(batchId: string): void {
const batch = this.getUploadBatch(batchId);
if (batch?.status !== "uploading") return;
this.setUploadBatch(cancelWorkspaceUploadBatch(batch, this.now()));
this.uploadTasks.get(batchId)?.cancel();
}
clearWorkspaceUpload(batchId: string): void {
this.uploadTasks.get(batchId)?.cancel();
this.uploadTasks.delete(batchId);
this.setState({ workspaceUploadBatches: omitKey(this.getState().workspaceUploadBatches, batchId) });
}
private updateUploadProgress(batchId: string, progress: WorkspaceUploadBatchProgress): void {
const batch = this.getUploadBatch(batchId);
if (batch?.status !== "uploading") return;
this.setUploadBatch(updateWorkspaceUploadBatchProgress(batch, progress));
}
private async completeUploadBatch(batchId: string, responses: WriteWorkspaceFileResponse[], options: StartWorkspaceUploadOptions): Promise<void> {
const batch = this.getUploadBatch(batchId);
if (batch?.status !== "uploading") return;
this.setUploadBatch(completeWorkspaceUploadBatch(batch, responses, this.now()), { error: "" });
if (!this.isCurrentWorkspaceBatch(batch)) return;
await this.refreshFiles();
const uploadedPath = responses[0]?.path;
if (options.selectUploadedFile !== false && uploadedPath !== undefined && this.isCurrentWorkspaceBatch(batch)) await this.selectFile(uploadedPath);
}
private async handleUploadFailure(batchId: string, error: unknown, options: StartWorkspaceUploadOptions): Promise<void> {
const batch = this.failUploadBatch(batchId, error);
if (!(error instanceof WorkspaceUploadBatchError) || error.responses.length === 0 || batch === undefined || !this.isCurrentWorkspaceBatch(batch)) return;
await this.refreshFiles();
const uploadedPath = error.responses[0]?.path;
if (options.selectUploadedFile !== false && uploadedPath !== undefined && this.isCurrentWorkspaceBatch(batch)) await this.selectFile(uploadedPath);
}
private failUploadBatch(batchId: string, error: unknown): WorkspaceUploadBatchState | undefined {
const batch = this.getUploadBatch(batchId);
if (batch?.status !== "uploading") return undefined;
if (isWorkspaceUploadCancelled(error)) {
const cancelled = cancelWorkspaceUploadBatch(batch, this.now());
this.setUploadBatch(cancelled);
return cancelled;
}
const message = errorMessage(error);
const failed = failWorkspaceUploadBatch(batch, message, this.now());
this.setUploadBatch(failed, { error: message });
return failed;
}
private getUploadBatch(batchId: string): WorkspaceUploadBatchState | undefined {
return this.getState().workspaceUploadBatches[batchId];
}
private setUploadBatch(batch: WorkspaceUploadBatchState, patch: { error?: string } = {}): void {
this.setState({ workspaceUploadBatches: { ...this.getState().workspaceUploadBatches, [batch.id]: batch }, ...patch });
}
private isCurrentWorkspaceBatch(batch: WorkspaceUploadBatchState): boolean {
const state = this.getState();
return state.selectedProject?.id === batch.projectId && state.selectedWorkspace?.id === batch.workspaceId && selectedMachineId(state) === batch.machineId;
}
} }
function isUnavailableFileError(error: unknown): boolean { function isUnavailableFileError(error: unknown): boolean {
@@ -71,6 +246,14 @@ function isUnavailableFileError(error: unknown): boolean {
return message.includes("Path does not exist") || message.includes("ENOENT") || message.includes("no such file or directory"); return message.includes("Path does not exist") || message.includes("ENOENT") || message.includes("no such file or directory");
} }
function isWorkspaceUploadCancelled(error: unknown): boolean {
return error instanceof WorkspaceUploadCancelledError;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> { function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit)); return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit));
} }
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffLineKind } from "./unifiedDiff";
describe("parseUnifiedDiff", () => {
it("computes inline spans for paired removed and added lines", () => {
const diff = [
"diff --git a/src/app.ts b/src/app.ts",
"index 1111111..2222222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -10,2 +10,2 @@ export function demo() {",
"- const name = \"fooBar\";",
"+ const name = \"fooBaz\";",
" return name;",
].join("\n");
const lines = parseUnifiedDiff(diff);
const removed = firstLineOfKind(lines, "remove");
const added = firstLineOfKind(lines, "add");
const context = firstLineOfKind(lines, "context");
expect(removed.oldLineNumber).toBe(10);
expect(removed.newLineNumber).toBeUndefined();
expect(changedText(removed)).toEqual(["r"]);
expect(added.oldLineNumber).toBeUndefined();
expect(added.newLineNumber).toBe(10);
expect(changedText(added)).toEqual(["z"]);
expect(context.oldLineNumber).toBe(11);
expect(context.newLineNumber).toBe(11);
});
it("keeps file headers as metadata before a hunk starts", () => {
const diff = [
"diff --git a/README.md b/README.md",
"index 1111111..2222222 100644",
"--- a/README.md",
"+++ b/README.md",
"@@ -1 +1 @@",
"-old",
"+new",
].join("\n");
expect(parseUnifiedDiff(diff).slice(0, 5).map((line) => line.kind)).toEqual(["meta", "meta", "meta", "meta", "hunk"]);
});
it("parses changed content that starts with file header markers inside hunks", () => {
const diff = [
"diff --git a/README.md b/README.md",
"--- a/README.md",
"+++ b/README.md",
"@@ -1 +1 @@",
"---- removed heading",
"++++ added heading",
].join("\n");
const removed = firstLineOfKind(parseUnifiedDiff(diff), "remove");
const added = firstLineOfKind(parseUnifiedDiff(diff), "add");
expect(removed.text).toBe("--- removed heading");
expect(added.text).toBe("+++ added heading");
});
it("pairs a single removed line with the closest added line in uneven blocks", () => {
const diff = [
"diff --git a/src/app.ts b/src/app.ts",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1,2 @@",
"-const label = \"old\";",
"+const label = \"new\";",
"+const extra = true;",
].join("\n");
const addedLines = linesOfKind(parseUnifiedDiff(diff), "add");
const firstAdded = lineAt(addedLines, 0);
const secondAdded = lineAt(addedLines, 1);
expect(changedText(firstAdded)).toEqual(["new"]);
expect(secondAdded.spans.every((span) => !span.changed)).toBe(true);
});
it("leaves pure additions without inline change spans", () => {
const diff = [
"diff --git a/new.txt b/new.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1 @@",
"+brand new",
].join("\n");
const added = firstLineOfKind(parseUnifiedDiff(diff), "add");
expect(added.newLineNumber).toBe(1);
expect(added.spans).toEqual([{ text: "brand new", changed: false }]);
});
});
function firstLineOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine {
const found = lines.find((line) => line.kind === kind);
if (found === undefined) throw new Error(`Missing ${kind} line`);
return found;
}
function linesOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine[] {
return lines.filter((line) => line.kind === kind);
}
function lineAt(lines: UnifiedDiffLine[], index: number): UnifiedDiffLine {
const line = lines[index];
if (line === undefined) throw new Error(`Missing line at ${String(index)}`);
return line;
}
function changedText(line: UnifiedDiffLine): string[] {
return line.spans.filter((span) => span.changed).map((span) => span.text);
}
+224
View File
@@ -0,0 +1,224 @@
import { diffChars } from "diff";
export type UnifiedDiffLineKind = "meta" | "hunk" | "context" | "add" | "remove" | "marker";
export interface UnifiedDiffTextSpan {
text: string;
changed: boolean;
}
export interface UnifiedDiffLine {
kind: UnifiedDiffLineKind;
prefix: string;
text: string;
spans: UnifiedDiffTextSpan[];
oldLineNumber?: number;
newLineNumber?: number;
}
interface InlineDiffResult {
removed: UnifiedDiffTextSpan[];
added: UnifiedDiffTextSpan[];
}
interface DiffLinePair {
removed: UnifiedDiffLine;
added: UnifiedDiffLine;
}
const hunkHeaderPattern = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
const maxInlineLineLength = 5_000;
const maxInlineBlockLines = 20;
const minInlineSimilarity = 0.20;
const minPairSimilarity = 0.25;
export function parseUnifiedDiff(diff: string): UnifiedDiffLine[] {
const parsedLines = parseUnifiedDiffLines(diff);
applyInlineDiffs(parsedLines);
return parsedLines;
}
function parseUnifiedDiffLines(diff: string): UnifiedDiffLine[] {
const lines = splitDiffLines(diff);
const parsedLines: UnifiedDiffLine[] = [];
let oldLineNumber: number | undefined;
let newLineNumber: number | undefined;
for (const rawLine of lines) {
const hunkMatch = hunkHeaderPattern.exec(rawLine);
if (hunkMatch !== null) {
oldLineNumber = Number(hunkMatch[1]);
newLineNumber = Number(hunkMatch[2]);
parsedLines.push(line("hunk", "", rawLine));
continue;
}
if (oldLineNumber !== undefined && newLineNumber !== undefined) {
if (rawLine.startsWith("+")) {
parsedLines.push(line("add", "+", rawLine.slice(1), { newLineNumber }));
newLineNumber++;
continue;
}
if (rawLine.startsWith("-")) {
parsedLines.push(line("remove", "-", rawLine.slice(1), { oldLineNumber }));
oldLineNumber++;
continue;
}
if (rawLine.startsWith(" ")) {
parsedLines.push(line("context", " ", rawLine.slice(1), { oldLineNumber, newLineNumber }));
oldLineNumber++;
newLineNumber++;
continue;
}
if (rawLine.startsWith("\\")) {
parsedLines.push(line("marker", "", rawLine));
continue;
}
}
oldLineNumber = undefined;
newLineNumber = undefined;
parsedLines.push(line("meta", "", rawLine));
}
return parsedLines;
}
function splitDiffLines(diff: string): string[] {
if (diff === "") return [];
const lines = diff.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
if (lines.at(-1) === "") lines.pop();
return lines;
}
function line(kind: UnifiedDiffLineKind, prefix: string, text: string, numbers: { oldLineNumber?: number; newLineNumber?: number } = {}): UnifiedDiffLine {
return {
kind,
prefix,
text,
spans: text === "" ? [] : [{ text, changed: false }],
...numbers,
};
}
function applyInlineDiffs(lines: UnifiedDiffLine[]): void {
let index = 0;
while (index < lines.length) {
const current = lines[index];
if (current?.kind !== "remove") {
index++;
continue;
}
const removedStart = index;
while (lines[index]?.kind === "remove") index++;
const addedStart = index;
while (lines[index]?.kind === "add") index++;
if (addedStart === index) continue;
const removedLines = lines.slice(removedStart, addedStart);
const addedLines = lines.slice(addedStart, index);
applyInlineDiffBlock(removedLines, addedLines);
}
}
function applyInlineDiffBlock(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): void {
if (removedLines.length + addedLines.length > maxInlineBlockLines) return;
for (const pair of pairChangedLines(removedLines, addedLines)) {
const inlineDiff = computeInlineDiff(pair.removed.text, pair.added.text);
if (inlineDiff === undefined) continue;
pair.removed.spans = inlineDiff.removed;
pair.added.spans = inlineDiff.added;
}
}
function pairChangedLines(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): DiffLinePair[] {
if (removedLines.length === addedLines.length) return removedLines.map((removed, index) => ({ removed, added: addedLines[index] })).filter(isCompletePair);
if (removedLines.length === 1) return bestPairsForSingleRemovedLine(removedLines[0], addedLines);
if (addedLines.length === 1) return bestPairsForSingleAddedLine(removedLines, addedLines[0]);
const pairs: DiffLinePair[] = [];
const pairCount = Math.min(removedLines.length, addedLines.length);
for (let index = 0; index < pairCount; index++) {
const removed = removedLines[index];
const added = addedLines[index];
if (removed === undefined || added === undefined) continue;
if (lineSimilarity(removed.text, added.text) >= minPairSimilarity) pairs.push({ removed, added });
}
return pairs;
}
function isCompletePair(pair: { removed: UnifiedDiffLine; added: UnifiedDiffLine | undefined }): pair is DiffLinePair {
return pair.added !== undefined;
}
function bestPairsForSingleRemovedLine(removed: UnifiedDiffLine | undefined, addedLines: UnifiedDiffLine[]): DiffLinePair[] {
if (removed === undefined) return [];
const added = bestMatchingLine(removed.text, addedLines);
return added === undefined ? [] : [{ removed, added }];
}
function bestPairsForSingleAddedLine(removedLines: UnifiedDiffLine[], added: UnifiedDiffLine | undefined): DiffLinePair[] {
if (added === undefined) return [];
const removed = bestMatchingLine(added.text, removedLines);
return removed === undefined ? [] : [{ removed, added }];
}
function bestMatchingLine(text: string, candidates: UnifiedDiffLine[]): UnifiedDiffLine | undefined {
let bestCandidate: UnifiedDiffLine | undefined;
let bestScore = minPairSimilarity;
for (const candidate of candidates) {
const score = lineSimilarity(text, candidate.text);
if (score <= bestScore) continue;
bestCandidate = candidate;
bestScore = score;
}
return bestCandidate;
}
function computeInlineDiff(oldText: string, newText: string): InlineDiffResult | undefined {
if (oldText === newText) return undefined;
if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return undefined;
const changes = diffChars(oldText, newText);
const similarity = similarityFromChanges(changes, oldText, newText);
if (Math.max(oldText.length, newText.length) >= 20 && similarity < minInlineSimilarity) return undefined;
const removed: UnifiedDiffTextSpan[] = [];
const added: UnifiedDiffTextSpan[] = [];
for (const change of changes) {
if (change.value === "") continue;
if (change.added) added.push({ text: change.value, changed: true });
else if (change.removed) removed.push({ text: change.value, changed: true });
else {
removed.push({ text: change.value, changed: false });
added.push({ text: change.value, changed: false });
}
}
if (!removed.some((span) => span.changed) && !added.some((span) => span.changed)) return undefined;
return { removed: mergeAdjacentSpans(removed), added: mergeAdjacentSpans(added) };
}
function lineSimilarity(oldText: string, newText: string): number {
if (oldText === newText) return 1;
if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return 0;
return similarityFromChanges(diffChars(oldText, newText), oldText, newText);
}
function similarityFromChanges(changes: ReturnType<typeof diffChars>, oldText: string, newText: string): number {
const maxLength = Math.max(oldText.length, newText.length);
if (maxLength === 0) return 1;
const unchangedLength = changes.reduce((total, change) => change.added || change.removed ? total : total + change.value.length, 0);
return unchangedLength / maxLength;
}
function mergeAdjacentSpans(spans: UnifiedDiffTextSpan[]): UnifiedDiffTextSpan[] {
const merged: UnifiedDiffTextSpan[] = [];
for (const span of spans) {
const previous = merged[merged.length - 1];
if (previous?.changed === span.changed) previous.text += span.text;
else merged.push({ ...span });
}
return merged;
}
+7 -84
View File
@@ -1,8 +1,7 @@
import { html, type TemplateResult } from "lit"; import { html, type TemplateResult } from "lit";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api"; import type { GitDiffResponse, GitStatusResponse } from "../../api";
import { workspaceImagePreviewUrl } from "../../api/urls";
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../../shared/workspaceFiles";
import { renderBuiltinTabIcon } from "../../components/tabIcons"; import { renderBuiltinTabIcon } from "../../components/tabIcons";
import "../../components/WorkspaceFilesPanel";
import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types"; import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types";
export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
@@ -34,69 +33,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
} }
function renderFiles(context: WorkspacePanelContext): TemplateResult { function renderFiles(context: WorkspacePanelContext): TemplateResult {
return html` return html`<workspace-files-panel .context=${context}></workspace-files-panel>`;
<section class="toolbar">
<strong>Files</strong>
${context.fileTreeStale ? html`<span class="stale">stale</span>` : null}
<button @click=${context.onRefreshFiles}>Refresh</button>
</section>
<section class="split">
<div class="list tree">
${context.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : context.fileTree.map((entry) => renderTreeEntry(context, entry, 0))}
</div>
<div class="viewer">
${renderFileViewer(context)}
</div>
</section>
`;
}
function renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult {
const children = context.expandedDirs[entry.path];
const hasChildren = children !== undefined;
const selected = entry.type !== "directory" && context.selectedFilePath === entry.path;
return html`
<button class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { selectTreeEntry(context, entry); }}>
<span>${entry.type === "directory" ? (hasChildren ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${hasChildren ? children.map((child) => renderTreeEntry(context, child, depth + 1)) : null}
`;
}
function selectTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry): void {
if (entry.type === "directory") context.onExpandDir(entry.path);
else context.onSelectFile(entry.path);
}
function renderFileViewer(context: WorkspacePanelContext): TemplateResult {
const file = context.selectedFileContent;
if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`<p class="muted">Select a file.</p>`;
if (file === undefined) return html`<p class="muted">Loading ${context.selectedFilePath}…</p>`;
if (file.mediaType === "image") return renderImageViewer(context, file);
if (file.binary) return html`<p class="muted">Binary file: ${file.path} · ${formatFileSize(file.size)}</p>`;
loadCodeViewer();
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${file.content} .language=${file.language}></code-viewer>
`;
}
function renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult {
const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`;
if (file.size > MAX_IMAGE_PREVIEW_BYTES) {
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
`;
}
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id });
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
<div class="image-preview">
<img src=${src} alt=${file.path} decoding="async" />
</div>
`;
} }
function renderTerminal(context: WorkspacePanelContext): TemplateResult { function renderTerminal(context: WorkspacePanelContext): TemplateResult {
@@ -146,17 +83,17 @@ function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
} }
function renderDiffSection(diff: GitDiffResponse): TemplateResult { function renderDiffSection(diff: GitDiffResponse): TemplateResult {
loadCodeViewer(); loadUnifiedDiffViewer();
return html` return html`
<section class="diff-section"> <section class="diff-section">
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div> <div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${diff.diff} .language=${"diff"}></code-viewer> <unified-diff-viewer .diff=${diff.diff}></unified-diff-viewer>
</section> </section>
`; `;
} }
function loadCodeViewer(): void { function loadUnifiedDiffViewer(): void {
void import("../../components/CodeViewer"); void import("../../components/UnifiedDiffViewer");
} }
function loadTerminalPanel(): void { function loadTerminalPanel(): void {
@@ -174,17 +111,3 @@ function stateLabel(index: string, workingTree: string): string {
const label = workingTree !== "unmodified" ? workingTree : index; const label = workingTree !== "unmodified" ? workingTree : index;
return label.slice(0, 1).toUpperCase(); return label.slice(0, 1).toUpperCase();
} }
function formatFileSize(size: number): string {
if (!Number.isFinite(size) || size < 0) return "0 B";
if (size < 1024) return `${String(size)} B`;
const kib = size / 1024;
if (kib < 1024) return `${formatScaledFileSize(kib)} KB`;
const mib = kib / 1024;
if (mib < 1024) return `${formatScaledFileSize(mib)} MB`;
return `${formatScaledFileSize(mib / 1024)} GB`;
}
function formatScaledFileSize(value: number): string {
return value >= 10 ? String(Math.round(value)) : value.toFixed(1);
}
+71 -5
View File
@@ -1,6 +1,6 @@
import { html } from "lit"; import { html } from "lit";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api"; import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions"; import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
@@ -14,6 +14,11 @@ function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = []; const calls: string[] = [];
const context: PluginRuntimeContext = { const context: PluginRuntimeContext = {
state: { ...initialAppState(), ...statePatch }, state: { ...initialAppState(), ...statePatch },
prompt: {
insertText: vi.fn(),
getText: vi.fn(() => ""),
getSelection: vi.fn(() => null),
},
piWebUnstable: { piWebUnstable: {
terminalCommandRuns: { terminalCommandRuns: {
runCommand: vi.fn(), runCommand: vi.fn(),
@@ -84,6 +89,37 @@ describe("PluginRegistry", () => {
expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined(); expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined();
}); });
it("exposes the prompt helper to workspace panel callbacks", () => {
const registry = new PluginRegistry();
registry.register({
id: "example",
plugin: {
apiVersion: 1,
name: "Example",
activate: () => ({
contributions: {
workspacePanels: [
{
id: "workspace.prompt",
title: "Prompt",
render: (context) => {
context.prompt.insertText("@docs/example.md");
return html`<p>Prompt</p>`;
},
},
],
},
}),
},
});
const insertText = vi.fn();
const context = createWorkspacePanelContext("local", { insertText, getText: vi.fn(() => ""), getSelection: vi.fn(() => null) });
registry.getWorkspacePanels()[0]?.render(context);
expect(insertText).toHaveBeenCalledWith("@docs/example.md");
});
it("rejects duplicate ids within the same namespace", () => { it("rejects duplicate ids within the same namespace", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
@@ -335,7 +371,7 @@ describe("PluginRegistry", () => {
context.host.requestRender(); context.host.requestRender();
return [{ type: "text", text: context.machine.id }]; return [{ type: "text", text: context.machine.id }];
}); });
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } }); const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } });
registry.register({ registry.register({
id: "example", id: "example",
@@ -545,7 +581,7 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
} }
function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext { function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())) }; const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())), writeFile: vi.fn<WorkspaceFiles["writeFile"]>(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn<WorkspaceFiles["deleteFile"]>(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn<WorkspaceFiles["moveFile"]>(() => Promise.resolve(testMoveFileResponse())) };
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() }; const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
return { return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
@@ -556,13 +592,14 @@ function createWorkspaceLabelContext(machineId: string, workspace = testWorkspac
}; };
} }
function createWorkspacePanelContext(machineId: string): WorkspacePanelContext { function createWorkspacePanelContext(machineId: string, prompt: WorkspacePanelContext["prompt"] = { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }): WorkspacePanelContext {
const workspace = testWorkspace(); const workspace = testWorkspace();
return { return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
workspace, workspace,
state: { ...initialAppState(), selectedMachine: testMachine(machineId) }, state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
files: { readFile: vi.fn() }, files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() },
prompt,
terminal: { open: vi.fn(), runCommand: vi.fn() }, terminal: { open: vi.fn(), runCommand: vi.fn() },
host: { requestRender: vi.fn() }, host: { requestRender: vi.fn() },
fileTree: [], fileTree: [],
@@ -578,9 +615,13 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
activeTerminalCount: 0, activeTerminalCount: 0,
selectedTerminalId: undefined, selectedTerminalId: undefined,
terminalAutoStart: false, terminalAutoStart: false,
workspaceUploadDefaultFolder: ".pi-web/uploads",
onRefreshFiles: vi.fn(), onRefreshFiles: vi.fn(),
onExpandDir: vi.fn(), onExpandDir: vi.fn(),
onSelectFile: vi.fn(), onSelectFile: vi.fn(),
onStartWorkspaceUpload: vi.fn(),
onCancelWorkspaceUpload: vi.fn(),
onClearWorkspaceUpload: vi.fn(),
onRefreshGit: vi.fn(), onRefreshGit: vi.fn(),
onSelectDiff: vi.fn(), onSelectDiff: vi.fn(),
onSelectTerminal: vi.fn(), onSelectTerminal: vi.fn(),
@@ -613,6 +654,31 @@ function testStatus(patch: Partial<SessionStatus> = {}): SessionStatus {
}; };
} }
function testWriteFileResponse(path = "README.md"): WriteWorkspaceFileResponse {
return {
path,
size: 0,
modifiedAt: "2026-05-20T00:00:00.000Z",
created: true,
};
}
function testDeleteFileResponse(path = "README.md"): DeleteWorkspaceFileResponse {
return {
path,
existed: true,
};
}
function testMoveFileResponse(fromPath = "old.txt", toPath = "new.txt"): MoveWorkspaceFileResponse {
return {
fromPath,
toPath,
size: 0,
modifiedAt: "2026-05-20T00:00:00.000Z",
};
}
function testMachine(id: string) { function testMachine(id: string) {
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" }; return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
} }
+16 -1
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import type { AppAction } from "../actions"; import type { AppAction } from "../actions";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api"; import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api";
import type { AppState } from "../appState"; import type { AppState } from "../appState";
import type { SettingsSection } from "../settingsRoute"; import type { SettingsSection } from "../settingsRoute";
import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids"; import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids";
@@ -50,6 +50,9 @@ export interface PluginMachine {
export interface WorkspaceFiles { export interface WorkspaceFiles {
readFile(path: string): Promise<FileContentResponse>; readFile(path: string): Promise<FileContentResponse>;
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
} }
export interface WorkspaceHost { export interface WorkspaceHost {
@@ -83,8 +86,15 @@ export interface TerminalCommandRunsInternalRuntime {
open(options?: { terminalId?: string | undefined }): void; open(options?: { terminalId?: string | undefined }): void;
} }
export interface PluginPromptEditor {
insertText(text: string): void;
getText(): string;
getSelection(): { start: number; end: number; text: string } | null;
}
export interface PluginRuntimeContext { export interface PluginRuntimeContext {
state: AppState; state: AppState;
prompt: PluginPromptEditor;
piWebUnstable?: PiWebUnstableRuntimeContext; piWebUnstable?: PiWebUnstableRuntimeContext;
openActionPalette: () => void; openActionPalette: () => void;
focusPrompt: () => void; focusPrompt: () => void;
@@ -128,6 +138,7 @@ export interface QualifiedPluginAction extends AppAction {
} }
export interface WorkspacePanelContext extends WorkspaceContext { export interface WorkspacePanelContext extends WorkspaceContext {
prompt: PluginPromptEditor;
terminal: WorkspacePanelTerminal; terminal: WorkspacePanelTerminal;
/** /**
* @deprecated Runtime-only compatibility alias for pre-v2 plugins. Use `terminal.open()` instead. * @deprecated Runtime-only compatibility alias for pre-v2 plugins. Use `terminal.open()` instead.
@@ -148,9 +159,13 @@ export interface WorkspacePanelContext extends WorkspaceContext {
activeTerminalCount: number; activeTerminalCount: number;
selectedTerminalId: string | undefined; selectedTerminalId: string | undefined;
terminalAutoStart: boolean; terminalAutoStart: boolean;
workspaceUploadDefaultFolder: string;
onRefreshFiles: () => void; onRefreshFiles: () => void;
onExpandDir: (path: string) => void; onExpandDir: (path: string) => void;
onSelectFile: (path: string) => void; onSelectFile: (path: string) => void;
onStartWorkspaceUpload: (files: readonly File[], options: { destinationFolder: string; createDirs?: boolean; overwrite?: boolean; selectUploadedFile?: boolean }) => { batchId: string; done: Promise<void> } | undefined;
onCancelWorkspaceUpload: (batchId: string) => void;
onClearWorkspaceUpload: (batchId: string) => void;
onRefreshGit: () => void; onRefreshGit: () => void;
onSelectDiff: (path: string) => void; onSelectDiff: (path: string) => void;
onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void; onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void;
+179
View File
@@ -0,0 +1,179 @@
import type { WriteWorkspaceFileResponse } from "../../shared/apiTypes";
import { workspaceUploadPath, type WorkspaceUploadBatchProgress } from "./api/workspaceUploads";
export type WorkspaceUploadFileStatus = "pending" | "uploading" | "completed" | "error" | "cancelled";
export type WorkspaceUploadBatchStatus = "uploading" | "completed" | "error" | "cancelled";
export interface WorkspaceUploadFileState {
index: number;
name: string;
path: string;
size: number;
loaded: number;
total: number;
percent: number;
lengthComputable: boolean;
status: WorkspaceUploadFileStatus;
error?: string;
response?: WriteWorkspaceFileResponse;
}
export interface WorkspaceUploadBatchState {
id: string;
projectId: string;
workspaceId: string;
machineId: string;
destinationFolder: string;
overwrite: boolean;
createDirs: boolean;
files: WorkspaceUploadFileState[];
currentFileIndex: number;
loaded: number;
total: number;
percent: number;
status: WorkspaceUploadBatchStatus;
startedAt: string;
completedAt?: string;
error?: string;
}
export interface WorkspaceUploadFileLike {
name: string;
size: number;
}
export interface CreateWorkspaceUploadBatchStateInput {
id: string;
projectId: string;
workspaceId: string;
machineId: string;
destinationFolder: string;
overwrite: boolean;
createDirs: boolean;
files: readonly WorkspaceUploadFileLike[];
startedAt: string;
}
export function createWorkspaceUploadBatchState(input: CreateWorkspaceUploadBatchStateInput): WorkspaceUploadBatchState {
const files = input.files.map((file, index): WorkspaceUploadFileState => {
const total = file.size;
return {
index,
name: file.name,
path: workspaceUploadPath(input.destinationFolder, file.name),
size: file.size,
loaded: 0,
total,
percent: percentFor(0, total),
lengthComputable: true,
status: index === 0 ? "uploading" : "pending",
};
});
const total = files.reduce((sum, file) => sum + file.total, 0);
return {
id: input.id,
projectId: input.projectId,
workspaceId: input.workspaceId,
machineId: input.machineId,
destinationFolder: input.destinationFolder,
overwrite: input.overwrite,
createDirs: input.createDirs,
files,
currentFileIndex: files.length === 0 ? -1 : 0,
loaded: 0,
total,
percent: percentFor(0, total),
status: "uploading",
startedAt: input.startedAt,
};
}
export function updateWorkspaceUploadBatchProgress(batch: WorkspaceUploadBatchState, progress: WorkspaceUploadBatchProgress): WorkspaceUploadBatchState {
const progressByIndex = new Map(progress.files.map((file) => [file.index, file]));
const files = batch.files.map((file): WorkspaceUploadFileState => {
const progressFile = progressByIndex.get(file.index);
if (progressFile === undefined) return file;
const next: WorkspaceUploadFileState = {
...file,
path: progressFile.path,
loaded: progressFile.loaded,
total: progressFile.total,
percent: progressFile.percent,
lengthComputable: progressFile.lengthComputable,
status: progressFile.error !== undefined ? "error" : progressFile.done ? "completed" : progress.currentFileIndex === file.index ? "uploading" : file.status,
};
if (progressFile.error === undefined) delete next.error;
else next.error = progressFile.error;
return next;
});
return {
...batch,
files,
currentFileIndex: progress.currentFileIndex,
loaded: progress.loaded,
total: progress.total,
percent: progress.percent,
};
}
export function completeWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, responses: readonly WriteWorkspaceFileResponse[], completedAt: string): WorkspaceUploadBatchState {
const files = batch.files.map((file, index): WorkspaceUploadFileState => {
const response = responses[index];
return {
...file,
...(response === undefined ? {} : { path: response.path, response }),
loaded: file.total,
percent: 1,
lengthComputable: true,
status: "completed",
};
});
const progress = terminalBatchProgress(files);
return {
...batch,
files,
currentFileIndex: files.length === 0 ? -1 : files.length - 1,
...progress,
status: "completed",
completedAt,
};
}
export function failWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, error: string, completedAt: string): WorkspaceUploadBatchState {
const files = batch.files.map((file): WorkspaceUploadFileState => {
if (file.status === "completed" || file.status === "error") return file;
if (file.status === "uploading" || file.index === batch.currentFileIndex) return { ...file, status: "error", error };
return { ...file, status: "cancelled", error: "Not uploaded because an earlier file failed." };
});
return {
...batch,
files,
...terminalBatchProgress(files),
status: "error",
error,
completedAt,
};
}
export function cancelWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, completedAt: string): WorkspaceUploadBatchState {
const error = "Upload cancelled";
const files = batch.files.map((file): WorkspaceUploadFileState => file.status === "completed" || file.status === "error" ? file : { ...file, status: "cancelled", error });
return {
...batch,
files,
...terminalBatchProgress(files),
status: "cancelled",
error,
completedAt,
};
}
function terminalBatchProgress(files: readonly WorkspaceUploadFileState[]): Pick<WorkspaceUploadBatchState, "loaded" | "total" | "percent"> {
const total = files.reduce((sum, file) => sum + file.total, 0);
return { loaded: total, total, percent: files.length === 0 ? 0 : 1 };
}
function percentFor(loaded: number, total: number): number {
if (total <= 0) return loaded <= 0 ? 0 : 1;
return Math.max(0, Math.min(1, loaded / total));
}
+22 -6
View File
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
let tempDir: string; let tempDir: string;
let configPath: string; let configPath: string;
@@ -18,18 +18,18 @@ afterEach(async () => {
describe("PI WEB config persistence", () => { describe("PI WEB config persistence", () => {
it("writes and reads the configured PI WEB config path", () => { it("writes and reads the configured PI WEB config path", () => {
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } }, testOptions()); const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }, testOptions());
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } } } }); expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } } });
expect(loadPiWebConfig(testOptions())).toEqual(saved); expect(loadPiWebConfig(testOptions())).toEqual(saved);
}); });
it("preserves unrelated config keys while replacing managed keys", async () => { it("preserves unrelated config keys while replacing managed keys", async () => {
await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, future: { enabled: true } }, null, 2)}\n`, "utf8"); await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old" }, future: { enabled: true } }, null, 2)}\n`, "utf8");
savePiWebConfig({ port: 9000, allowedHosts: [] }, testOptions()); savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } }, testOptions());
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [] }); expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } });
}); });
it("rejects invalid plugin config", async () => { it("rejects invalid plugin config", async () => {
@@ -38,10 +38,26 @@ describe("PI WEB config persistence", () => {
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans"); expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config plugin enabled values must be booleans");
}); });
it("rejects invalid path access config", async () => {
await writeFile(configPath, `${JSON.stringify({ pathAccess: { allowedPaths: [""] } }, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
});
it("persists and reads maxUploadBytes", () => { it("persists and reads maxUploadBytes", () => {
savePiWebConfig({ maxUploadBytes: 1234 }, testOptions()); savePiWebConfig({ maxUploadBytes: 1234 }, testOptions());
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234); expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
}); });
it("exposes the default upload folder in the effective config", () => {
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
});
it("rejects upload defaults that are not workspace-relative", async () => {
await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config uploads.defaultFolder must not contain path traversal");
});
}); });
describe("maxUploadBytes", () => { describe("maxUploadBytes", () => {
+49 -1
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, isAbsolute, join, resolve } from "node:path";
import type { PiWebConfigValues } from "./shared/apiTypes.js"; import type { PiWebConfigValues } from "./shared/apiTypes.js";
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
@@ -33,6 +33,12 @@ export function defaultPiWebDataDir(): string {
*/ */
export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads";
export function effectiveUploadsConfig(config: Pick<PiWebConfig, "uploads"> = {}): NonNullable<PiWebConfig["uploads"]> {
return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER };
}
export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number { export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number {
const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"]; const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"];
if (fromEnv !== undefined && fromEnv !== "") { if (fromEnv !== undefined && fromEnv !== "") {
@@ -82,6 +88,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}), ...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}),
...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}), ...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}),
...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}), ...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}),
uploads: effectiveUploadsConfig(loaded.config),
// Always resolved (on by default) so the effective config is the single // Always resolved (on by default) so the effective config is the single
// source of truth for the runtime state and the settings UI toggle. // source of truth for the runtime state and the settings UI toggle.
spawnSessions: spawnSessionsEnabled(env, loaded.config), spawnSessions: spawnSessionsEnabled(env, loaded.config),
@@ -101,6 +108,8 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
delete existing["allowedHosts"]; delete existing["allowedHosts"];
delete existing["shortcuts"]; delete existing["shortcuts"];
delete existing["plugins"]; delete existing["plugins"];
delete existing["pathAccess"];
delete existing["uploads"];
delete existing["maxUploadBytes"]; delete existing["maxUploadBytes"];
delete existing["spawnSessions"]; delete existing["spawnSessions"];
delete existing["subsessions"]; delete existing["subsessions"];
@@ -124,6 +133,8 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}), ...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}),
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
...(config.plugins !== undefined ? { plugins: config.plugins } : {}), ...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
@@ -137,6 +148,8 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}), ...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}),
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
@@ -209,6 +222,41 @@ function parseAllowedHostsEnv(value: string): string[] | true {
return value.split(",").map((host) => host.trim()).filter((host) => host !== ""); return value.split(",").map((host) => host.trim()).filter((host) => host !== "");
} }
export function parsePathAccessConfig(value: unknown, path: string): NonNullable<PiWebConfigValues["pathAccess"]> {
if (!isRecord(value)) throw new Error(`PI WEB config pathAccess must be an object: ${path}`);
const allowedPaths = value["allowedPaths"];
return {
...(allowedPaths !== undefined ? { allowedPaths: parseAllowedPaths(allowedPaths, path) } : {}),
};
}
function parseAllowedPaths(value: unknown, path: string): string[] {
if (!isNonEmptyStringArray(value)) throw new Error(`PI WEB config pathAccess.allowedPaths must be an array of non-empty strings: ${path}`);
return value;
}
export function parseUploadsConfig(value: unknown, path: string): NonNullable<PiWebConfigValues["uploads"]> {
if (!isRecord(value)) throw new Error(`PI WEB config uploads must be an object: ${path}`);
const defaultFolder = value["defaultFolder"];
return {
...(defaultFolder !== undefined ? { defaultFolder: parseWorkspaceRelativeFolder(defaultFolder, "uploads.defaultFolder", path) } : {}),
};
}
function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string): string {
if (typeof value !== "string" || value.trim() === "") throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`);
if (isAbsoluteLike(value)) throw new Error(`PI WEB config ${key} must be workspace-relative: ${path}`);
const parts = value.split(/[\\/]+/).filter((part) => part !== "" && part !== ".");
if (parts.length === 0) throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`);
if (parts.some((part) => part === "..")) throw new Error(`PI WEB config ${key} must not contain path traversal: ${path}`);
return parts.join("/");
}
function isAbsoluteLike(value: string): boolean {
const withForwardSlashes = value.replace(/\\/g, "/");
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
}
function parseShortcuts(value: unknown, path: string): Record<string, string | null> { function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`); if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {
+29 -1
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle } from "./shared/apiTypes.js"; import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js";
export type { export type {
FileContentMediaType, FileContentMediaType,
@@ -20,6 +20,11 @@ export type {
TerminalCommandRunFilter, TerminalCommandRunFilter,
TerminalCommandRunHandle, TerminalCommandRunHandle,
TerminalCommandRunStatus, TerminalCommandRunStatus,
WriteWorkspaceFileOptions,
WriteWorkspaceFileResponse,
DeleteWorkspaceFileResponse,
MoveWorkspaceFileOptions,
MoveWorkspaceFileResponse,
} from "./shared/apiTypes.js"; } from "./shared/apiTypes.js";
export type PluginId = string; export type PluginId = string;
@@ -67,8 +72,20 @@ export interface PluginRuntimeState {
piWebStatus?: PiWebStatusResponse; piWebStatus?: PiWebStatusResponse;
} }
export interface PluginPromptEditor {
/** Insert text at the current cursor position. Replaces any selection.
* If the editor is not focused, focuses it first.
* No-op if the editor is not mounted. */
insertText(text: string): void;
/** Get the current prompt text content. Returns "" if the editor is not mounted. */
getText(): string;
/** Get the current selection range, or null if no selection or editor not mounted. */
getSelection(): { start: number; end: number; text: string } | null;
}
export interface PluginRuntimeContext { export interface PluginRuntimeContext {
state: PluginRuntimeState; state: PluginRuntimeState;
prompt: PluginPromptEditor;
openActionPalette: () => void; openActionPalette: () => void;
focusPrompt: () => void; focusPrompt: () => void;
addProject: () => void | Promise<void>; addProject: () => void | Promise<void>;
@@ -109,7 +126,17 @@ export interface Workspace {
} }
export interface WorkspaceFiles { export interface WorkspaceFiles {
/** Read a file from the workspace. Works for local and federated machines. */
readFile(path: string): Promise<FileContentResponse>; readFile(path: string): Promise<FileContentResponse>;
/** Write content to a workspace file. Creates intermediate directories by default.
* Works for local and federated machines. Auto-refreshes the file explorer after success. */
writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
/** Delete a file from the workspace. Idempotent returns { existed: false } if file doesn't exist.
* Deletes the entry itself (for symlinks, removes the symlink not the target). */
deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
/** Move or rename a file within the workspace. Unix mv semantics.
* Default overwrite: false (safer than writeFile). Auto-refreshes the file explorer after success. */
moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
} }
export type WorkspacePanelFiles = WorkspaceFiles; export type WorkspacePanelFiles = WorkspaceFiles;
@@ -141,6 +168,7 @@ export interface WorkspacePanelTerminal {
} }
export interface WorkspacePanelContext extends WorkspaceContext { export interface WorkspacePanelContext extends WorkspaceContext {
prompt: PluginPromptEditor;
terminal: WorkspacePanelTerminal; terminal: WorkspacePanelTerminal;
} }
+420 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
@@ -15,6 +15,7 @@ import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js"; import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js"; import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { Project, Workspace } from "./types.js"; import type { Project, Workspace } from "./types.js";
let app: FastifyInstance; let app: FastifyInstance;
@@ -22,12 +23,14 @@ let tempDir: string;
let projectDir: string; let projectDir: string;
let remoteClient: MachineClient | undefined; let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[]; let sessionDaemonRequests: CapturedSessionDaemonRequest[];
let piWebConfig: PiWebConfigValues;
beforeEach(async () => { beforeEach(async () => {
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-"))); tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
projectDir = join(tempDir, "project"); projectDir = join(tempDir, "project");
remoteClient = undefined; remoteClient = undefined;
sessionDaemonRequests = []; sessionDaemonRequests = [];
piWebConfig = {};
app = await buildApp({ app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(), workspaces: new WorkspaceService(),
@@ -48,6 +51,7 @@ beforeEach(async () => {
}), }),
}), }),
sessionDaemon: fakeSessionDaemon(), sessionDaemon: fakeSessionDaemon(),
config: fakeConfigService(),
piWebPlugins: { piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
@@ -151,6 +155,33 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined); expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
}); });
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const remoteWorkspaces = [{
id: "w1",
projectId: "p1",
path: "/repo",
label: "main",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } },
}];
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify(remoteWorkspaces)]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(remoteWorkspaces);
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined);
});
it("preserves remote file preview security headers while proxying safe response metadata", async () => { it("preserves remote file preview security headers while proxying safe response metadata", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>(); const remote = addResponse.json<{ id: string }>();
@@ -177,6 +208,29 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined); expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
}); });
it("proxies remote workspace file writes as raw request bodies", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`,
payload,
headers: { "content-type": "application/octet-stream" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true });
expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" });
});
it("proxies remote terminal command-run and continue routes", async () => { it("proxies remote terminal command-run and continue routes", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>(); const remote = addResponse.json<{ id: string }>();
@@ -206,6 +260,23 @@ describe("buildApp", () => {
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody); expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
}); });
it("proxies remote session reloads through the selected machine", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ reloaded: true })]),
}));
remoteClient = fakeRemoteClient({ request });
const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ reloaded: true });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
});
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => { it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>(); const remote = addResponse.json<{ id: string }>();
@@ -444,6 +515,47 @@ describe("buildApp", () => {
]); ]);
}); });
it("exposes the default upload config on workspace responses", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Upload Defaults", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } },
}),
]);
});
it("lets project-local upload config override global upload config on workspace responses", async () => {
piWebConfig = { uploads: { defaultFolder: "global-uploads" } };
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Project Upload Defaults", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: "project-uploads" } },
}),
]);
});
it("serves supported workspace images as previews", async () => { it("serves supported workspace images as previews", async () => {
const addResponse = await app.inject({ const addResponse = await app.inject({
method: "POST", method: "POST",
@@ -478,6 +590,293 @@ describe("buildApp", () => {
expect(tooLargeResponse.statusCode).toBe(400); expect(tooLargeResponse.statusCode).toBe(400);
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" }); expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
}); });
it("keeps normal file suggestions workspace-local when path access config is invalid", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Local Suggestions", path: projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
await writeFile(join(projectDir, "sdk.md"), "local sdk\n");
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`);
const response = await app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(projectDir)}&q=sdk&scope=all` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]);
});
it("serves project-configured allowed external files through the workspace explorer", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "External", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const externalDir = join(tempDir, "external-docs");
const deniedFile = join(tempDir, "secret.md");
await mkdir(externalDir);
await writeFile(join(externalDir, "sdk.md"), "external sdk\n");
await writeFile(deniedFile, "secret\n");
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`);
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const fileResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` });
const treeResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` });
const suggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` });
const localSuggestionResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` });
const deniedResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` });
expect(fileResponse.statusCode).toBe(200);
expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false });
expect(treeResponse.statusCode).toBe(200);
expect(treeResponse.json()).toMatchObject({
path: externalDir,
entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })],
truncated: false,
});
expect(suggestionResponse.statusCode).toBe(200);
expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]);
expect(localSuggestionResponse.statusCode).toBe(200);
expect(localSuggestionResponse.json()).toEqual([]);
expect(deniedResponse.statusCode).toBe(400);
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
});
it("writes workspace files through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "WriteTest", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const writeTextResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "hello world",
headers: { "content-type": "text/plain" },
});
expect(writeTextResponse.statusCode).toBe(200);
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
const writeBinaryResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
headers: { "content-type": "application/octet-stream" },
});
expect(writeBinaryResponse.statusCode).toBe(200);
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
const writeDeepResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
payload: "deep content",
headers: { "content-type": "text/plain" },
});
expect(writeDeepResponse.statusCode).toBe(200);
const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
const overwriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "updated",
headers: { "content-type": "text/plain" },
});
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
const noOverwriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
payload: "evil",
headers: { "content-type": "text/plain" },
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
payload: "no path",
headers: { "content-type": "text/plain" },
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
const noDirsResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noDirsResponse.statusCode).toBe(400);
await mkdir(join(projectDir, "subdir"), { recursive: true });
const dirWriteResponse = await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(dirWriteResponse.statusCode).toBe(400);
});
it("deletes workspace files through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "DeleteTest", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
payload: "delete me",
headers: { "content-type": "text/plain" },
});
const deleteResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
const deleteMissingResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
});
expect(deleteMissingResponse.statusCode).toBe(200);
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
const traversalResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
});
it("moves workspace files through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "MoveTest", path: projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
payload: "move me",
headers: { "content-type": "text/plain" },
});
const moveResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
});
expect(moveResponse.statusCode).toBe(200);
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
expect(readSourceResponse.statusCode).toBe(400);
const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
expect(readTargetResponse.statusCode).toBe(200);
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
payload: "source",
headers: { "content-type": "text/plain" },
});
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`,
payload: "target",
headers: { "content-type": "text/plain" },
});
const overwriteResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
});
expect(overwriteResponse.statusCode).toBe(200);
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
payload: "s",
headers: { "content-type": "text/plain" },
});
await app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`,
payload: "t",
headers: { "content-type": "text/plain" },
});
const noOverwriteResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalFromResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
});
expect(traversalFromResponse.statusCode).toBe(400);
const noParamsResponse = await app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
});
expect(noParamsResponse.statusCode).toBe(400);
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
});
}); });
interface CapturedSessionDaemonRequest { interface CapturedSessionDaemonRequest {
@@ -486,6 +885,26 @@ interface CapturedSessionDaemonRequest {
body?: unknown; body?: unknown;
} }
function fakeConfigService() {
return {
read: () => piWebConfigResponse(piWebConfig),
write: (config: PiWebConfigValues) => {
piWebConfig = config;
return piWebConfigResponse(config);
},
};
}
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: join(tempDir, "config.json"),
exists: false,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function fakeSessionDaemon(): SessionProxyDaemon { function fakeSessionDaemon(): SessionProxyDaemon {
return { return {
request: (method, path, body) => { request: (method, path, body) => {
+41 -14
View File
@@ -7,7 +7,9 @@ import fastifyWebsocket from "@fastify/websocket";
import { ProjectStore } from "./storage/projectStore.js"; import { ProjectStore } from "./storage/projectStore.js";
import { ProjectService } from "./projects/projectService.js"; import { ProjectService } from "./projects/projectService.js";
import { WorkspaceService } from "./workspaces/workspaceService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js";
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js"; import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js";
import { loadEffectiveProjectUploadsConfig } from "./workspaces/projectPiWebConfig.js";
import { normalizeRequestCwd } from "./workingDirectory.js"; import { normalizeRequestCwd } from "./workingDirectory.js";
import { listDirectorySuggestions } from "./projects/directorySuggestions.js"; import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
@@ -16,7 +18,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js"; import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js"; import { PiWebPluginService } from "./piWebPluginService.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js"; import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
@@ -24,6 +26,7 @@ import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js"; import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js";
import type { Project, Workspace } from "./types.js";
export interface AppDependencies { export interface AppDependencies {
projects?: ProjectService; projects?: ProjectService;
@@ -38,7 +41,11 @@ export interface AppDependencies {
bodyLimit?: number; bodyLimit?: number;
} }
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void { interface LocalProjectRouteOptions {
config?: Pick<PiWebConfigService, "read">;
}
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalProjectRouteOptions = {}): void {
app.get(`${prefix}/projects`, async () => projects.list()); app.get(`${prefix}/projects`, async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => { app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => {
@@ -69,20 +76,39 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => { app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => {
try { try {
const project = await projects.requireProject(request.params.projectId); const project = await projects.requireProject(request.params.projectId);
return await workspaces.list(project); return await listWorkspacesWithEffectiveConfig(project, workspaces, options.config);
} catch (error) { } catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
} }
}); });
} }
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void { async function listWorkspacesWithEffectiveConfig(project: Project, workspaces: WorkspaceService, config?: Pick<PiWebConfigService, "read">): Promise<Workspace[]> {
const [workspaceList, effectiveConfig] = await Promise.all([
workspaces.list(project),
workspaceEffectiveConfig(project.path, config),
]);
return workspaceList.map((workspace) => ({ ...workspace, effectiveConfig }));
}
async function workspaceEffectiveConfig(projectPath: string, config?: Pick<PiWebConfigService, "read">): Promise<NonNullable<Workspace["effectiveConfig"]>> {
const globalConfig = config === undefined ? {} : (await config.read()).effectiveConfig;
return { uploads: await loadEffectiveProjectUploadsConfig(projectPath, globalConfig) };
}
interface LocalFileSuggestionRouteOptions {
config?: Pick<PiWebConfigService, "read">;
}
function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalFileSuggestionRouteOptions = {}): void {
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => { app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try { try {
const cwd = normalizeRequestCwd(request.query.cwd); const cwd = normalizeRequestCwd(request.query.cwd);
if (request.query.mode === "path") return await listPathSuggestions(cwd, request.query.q ?? ""); const query = request.query.q ?? "";
return await listFileSuggestions(cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope }); const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForCwd(cwd, projects, workspaces, options.config) : undefined;
if (request.query.mode === "path") return await listPathSuggestions(cwd, query, pathAccess);
return await listFileSuggestions(cwd, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
} catch (error) { } catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
} }
@@ -96,6 +122,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore()); const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService(); const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService(); const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const configService = deps.config ?? createFilePiWebConfigService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient(); const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), { const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); }, onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
@@ -118,18 +145,18 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon)); app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins()); app.get("/api/plugins", async () => piWebPlugins.plugins());
registerConfigRoutes(app, deps.config); registerConfigRoutes(app, configService);
registerMachineRoutes(app, machines); registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines); registerMachinePluginProxyRoutes(app, machines);
registerLocalProjectRoutes(app, projects, workspaces, "/api"); registerLocalProjectRoutes(app, projects, workspaces, "/api", { config: configService });
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local"); registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
registerSessionProxyRoutes(app, sessionDaemon); registerSessionProxyRoutes(app, sessionDaemon);
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local"); registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
registerWorkspaceExplorerRoutes(app, projects, workspaces); registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api", { config: configService });
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local"); registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
registerGitRoutes(app, projects, workspaces); registerGitRoutes(app, projects, workspaces);
registerGitRoutes(app, projects, workspaces, "/api/machines/local"); registerGitRoutes(app, projects, workspaces, "/api/machines/local");
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon); registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
@@ -137,8 +164,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon); registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon);
registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local"); registerWorkspaceDeletionRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
registerLocalFileSuggestionRoutes(app, "/api"); registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api", { config: configService });
registerLocalFileSuggestionRoutes(app, "/api/machines/local"); registerLocalFileSuggestionRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
registerMachineProxyRoutes(app, machines); registerMachineProxyRoutes(app, machines);
+38 -2
View File
@@ -37,11 +37,11 @@ describe("config routes", () => {
const response = await app.inject({ const response = await app.inject({
method: "PUT", method: "PUT",
url: "/api/config", url: "/api/config",
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } }, payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } }); expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig); expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
}); });
@@ -56,6 +56,42 @@ describe("config routes", () => {
expect(response.json()).toHaveProperty("error"); expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled(); expect(service.write).not.toHaveBeenCalled();
}); });
it("rejects invalid path access payloads before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { pathAccess: { allowedPaths: [""] } } },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
it("rejects invalid max upload bytes before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { maxUploadBytes: 0 } },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
it("rejects invalid upload defaults before writing", async () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { uploads: { defaultFolder: "/tmp" } } },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toHaveProperty("error");
expect(service.write).not.toHaveBeenCalled();
});
}); });
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse { function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
+31 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js"; import { isPiWebPluginId } from "../shared/pluginIds.js";
@@ -58,6 +58,9 @@ function parseConfigRequest(value: unknown): PiWebConfig {
const allowedHosts = value["allowedHosts"]; const allowedHosts = value["allowedHosts"];
const shortcuts = value["shortcuts"]; const shortcuts = value["shortcuts"];
const plugins = value["plugins"]; const plugins = value["plugins"];
const pathAccess = value["pathAccess"];
const uploads = value["uploads"];
const maxUploadBytes = value["maxUploadBytes"];
const spawnSessions = value["spawnSessions"]; const spawnSessions = value["spawnSessions"];
const subsessions = value["subsessions"]; const subsessions = value["subsessions"];
if (host !== undefined) { if (host !== undefined) {
@@ -71,6 +74,9 @@ function parseConfigRequest(value: unknown): PiWebConfig {
if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts); if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts);
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts); if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins); if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
if (uploads !== undefined) config.uploads = parseUploadsConfig(uploads, "request");
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes);
if (spawnSessions !== undefined) { if (spawnSessions !== undefined) {
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean"); if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
config.spawnSessions = spawnSessions; config.spawnSessions = spawnSessions;
@@ -98,6 +104,30 @@ function parseShortcutsRequest(value: unknown): Record<string, string | null> {
})); }));
} }
function parsePathAccessRequest(value: unknown): NonNullable<PiWebConfig["pathAccess"]> {
if (!isRecord(value)) throw new Error("PI WEB config pathAccess must be an object");
const allowedPaths = value["allowedPaths"];
return {
...(allowedPaths === undefined ? {} : { allowedPaths: parseAllowedPathsRequest(allowedPaths) }),
};
}
function parseAllowedPathsRequest(value: unknown): string[] {
if (!isNonEmptyStringArray(value)) {
throw new Error("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
}
return value;
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
}
function parseMaxUploadBytesRequest(value: unknown): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error("PI WEB config maxUploadBytes must be a positive integer");
return value;
}
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> { function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object"); if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { RemoteMachineClient } from "./machineClient.js";
describe("RemoteMachineClient", () => {
it("forwards raw binary request bodies with the provided content type", async () => {
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl);
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await client.request("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "image/png" });
const { input, init } = onlyFetchCall(fetchImpl);
expect(fetchInputUrl(input)).toBe("https://remote.example.test/api/projects/p1/workspaces/w1/file?path=image.png");
expect(init.method).toBe("PUT");
expect(new Headers(init.headers).get("content-type")).toBe("image/png");
if (!(init.body instanceof ArrayBuffer)) throw new Error("Expected binary request body");
expect(Array.from(new Uint8Array(init.body))).toEqual([0x89, 0x50, 0x4e, 0x47]);
});
it("serializes structured request bodies as JSON by default", async () => {
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/base/", token: "secret" }, fetchImpl);
await client.request("POST", "/api/sessions", { cwd: "/repo" });
const { input, init } = onlyFetchCall(fetchImpl);
expect(fetchInputUrl(input)).toBe("https://remote.example.test/base/api/sessions");
expect(new Headers(init.headers).get("authorization")).toBe("Bearer secret");
expect(new Headers(init.headers).get("content-type")).toBe("application/json");
expect(init.body).toBe(JSON.stringify({ cwd: "/repo" }));
});
});
function fetchInputUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
function onlyFetchCall(fetchImpl: ReturnType<typeof vi.fn<typeof fetch>>): { input: RequestInfo | URL; init: RequestInit } {
expect(fetchImpl).toHaveBeenCalledTimes(1);
const call = fetchImpl.mock.calls[0];
if (call === undefined) throw new Error("Expected fetch call");
const [input, init] = call;
if (init === undefined) throw new Error("Expected fetch init");
return { input, init };
}
+34 -4
View File
@@ -16,6 +16,7 @@ export interface MachineJsonResponse {
export interface MachineRequestOptions { export interface MachineRequestOptions {
timeoutMs?: number; timeoutMs?: number;
contentType?: string;
} }
export interface MachineClient { export interface MachineClient {
@@ -82,13 +83,14 @@ export class RemoteMachineClient implements MachineClient {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS); const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS);
try { try {
const requestBody = serializeRequestBody(method, body);
const init: RequestInit = { const init: RequestInit = {
method, method,
headers: this.requestHeaders(body), headers: this.requestHeaders(body, options),
signal: controller.signal, signal: controller.signal,
redirect: "manual", redirect: "manual",
}; };
if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = JSON.stringify(body); if (requestBody !== undefined) init.body = requestBody;
return await this.fetchImpl(this.remoteUrl(path), init); return await this.fetchImpl(this.remoteUrl(path), init);
} catch (error) { } catch (error) {
if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504); if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504);
@@ -98,11 +100,11 @@ export class RemoteMachineClient implements MachineClient {
} }
} }
private requestHeaders(body: unknown): HeadersInit { private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit {
return { return {
...this.remoteHeaders(), ...this.remoteHeaders(),
accept: "*/*", accept: "*/*",
...(body === undefined ? {} : { "content-type": "application/json" }), ...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }),
}; };
} }
@@ -147,6 +149,34 @@ function headersToRecord(headers: Headers): Record<string, string> {
return Object.fromEntries(headers.entries()); return Object.fromEntries(headers.entries());
} }
function serializeRequestBody(method: string, body: unknown): NonNullable<RequestInit["body"]> | undefined {
if (body === undefined || method === "GET" || method === "HEAD") return undefined;
if (isRawRequestBody(body)) return body;
if (ArrayBuffer.isView(body)) return copyArrayBufferView(body);
const serialized: string = JSON.stringify(body);
return serialized;
}
function defaultContentTypeForBody(body: unknown): string {
return isRawRequestBody(body) || ArrayBuffer.isView(body) ? "application/octet-stream" : "application/json";
}
function isRawRequestBody(body: unknown): body is NonNullable<RequestInit["body"]> {
return typeof body === "string"
|| body instanceof URLSearchParams
|| body instanceof Blob
|| body instanceof FormData
|| body instanceof ReadableStream
|| body instanceof ArrayBuffer;
}
function copyArrayBufferView(view: ArrayBufferView): ArrayBuffer {
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream { function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream {
if (body === null) throw new Error("Response body is not readable"); if (body === null) throw new Error("Response body is not readable");
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config.
+21 -4
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws"; import type { WebSocket } from "ws";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js"; import { bridgeSockets } from "../webSocketBridge.js";
import { RemoteMachineRequestError } from "./machineClient.js"; import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
import { MachineService } from "./machineService.js"; import { MachineService } from "./machineService.js";
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES; export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
@@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
app.route<{ Params: { machineId: string }; Body: unknown }>({ app.route<{ Params: { machineId: string }; Body: unknown }>({
method: spec.method, method: spec.method,
url: `/api/machines/:machineId${spec.path}`, url: `/api/machines/:machineId${spec.path}`,
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply), handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
}); });
} }
@@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
} }
} }
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> { async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
if (machineId === "local") { if (machineId === "local") {
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" }); return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
} }
@@ -45,7 +45,10 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
} }
try { try {
const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body); const requestOptions = proxyRequestOptions(body, contentType);
const upstream = requestOptions === undefined
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
reply.code(upstream.statusCode); reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers); applySafeHeaders(reply, upstream.headers);
if (upstream.body === undefined) return await reply.send(); if (upstream.body === undefined) return await reply.send();
@@ -81,6 +84,20 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
return `/api${compatPath}`; return `/api${compatPath}`;
} }
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
if (!isRawProxyBody(body)) return undefined;
const value = firstHeaderValue(contentType);
return value === undefined || value === "" ? undefined : { contentType: value };
}
function isRawProxyBody(body: unknown): boolean {
return typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body);
}
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void { function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
for (const [name, value] of Object.entries(headers)) { for (const [name, value] of Object.entries(headers)) {
if (value === undefined) continue; if (value === undefined) continue;
+2 -2
View File
@@ -21,13 +21,13 @@ import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js";
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes() }); const { config } = effectivePiWebConfig();
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
const eventHub = new SessionEventHub(); const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub); const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService(); const auth = new AuthService();
const { config } = effectivePiWebConfig();
const spawnTargets = spawnSessionsEnabled(process.env, config) const spawnTargets = spawnSessionsEnabled(process.env, config)
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined; : undefined;
+1 -4
View File
@@ -1,8 +1,6 @@
import { getProviders } from "@earendil-works/pi-ai";
import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js"; import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js";
const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]); const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]);
const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders());
export interface AuthProviderModelRegistry { export interface AuthProviderModelRegistry {
authStorage: { authStorage: {
@@ -54,11 +52,10 @@ export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistr
return filterAndSort(options); return filterAndSort(options);
} }
export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>, builtInProviderIds: ReadonlySet<string> = BUILT_IN_MODEL_PROVIDERS): boolean { export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet<string>): boolean {
if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false; if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false;
if (providerId === "anthropic") return true; if (providerId === "anthropic") return true;
if (oauthProviderIds.has(providerId)) return false; if (oauthProviderIds.has(providerId)) return false;
if (builtInProviderIds.has(providerId)) return true;
return true; return true;
} }
+679 -3
View File
@@ -1,3 +1,6 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
@@ -32,11 +35,12 @@ interface TestSession extends PiAgentSession {
getFollowUpMessages: () => readonly string[]; getFollowUpMessages: () => readonly string[];
} }
function fakeSessionManager(cwd = "/workspace"): PiSessionManager { function fakeSessionManager(cwd = "/workspace", patch: Partial<PiSessionManager> = {}): PiSessionManager {
return { return {
getCwd: () => cwd, getCwd: () => cwd,
getBranch: () => [], getBranch: () => [],
getLeafId: () => "leaf-1", getLeafId: () => "leaf-1",
...patch,
}; };
} }
@@ -141,6 +145,16 @@ function sessionGateway(records: ReturnType<typeof sessionRecord>[]): SessionGat
}; };
} }
function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveStore"]> {
return {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("archive should not be called")),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
};
}
describe("PiSessionService", () => { describe("PiSessionService", () => {
it("starts sessions through an injected runtime creator", async () => { it("starts sessions through an injected runtime creator", async () => {
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
@@ -887,6 +901,668 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("persists tracked child links in the parent and child sessions", async () => {
const parentPersisted: { customType: string; data?: unknown }[] = [];
const childPersisted: { customType: string; data?: unknown }[] = [];
const parent = fakeRuntime("parent-1", {
sessionFile: "/tmp/parent-1.jsonl",
sessionManager: fakeSessionManager("/workspace", {
appendCustomEntry: (customType, data) => {
parentPersisted.push({ customType, data });
return "parent-entry-1";
},
}),
});
const child = fakeRuntime("child-1", {
sessionFile: "/tmp/child-1.jsonl",
sessionManager: fakeSessionManager("/workspace-feature", {
appendCustomEntry: (customType, data) => {
childPersisted.push({ customType, data });
return "child-entry-1";
},
}),
});
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
expect(parentPersisted).toEqual([
{
customType: "pi-web.subsession.link",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" },
},
]);
expect(childPersisted).toEqual([
{
customType: "pi-web.subsession.spawned",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" },
},
]);
await service.dispose();
});
it("hydrates persisted child links after a service restart so the parent can inspect them", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }],
});
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
}),
});
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({
sessionId: "child-1",
cwd: "/workspace-feature",
status: "idle",
finalText: "finished",
messageCount: 1,
});
expect(open).toHaveBeenCalledWith(childFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("ignores stale persisted child links when the child no longer records the parent", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8");
try {
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not hydrate persisted links when the exact child file is unavailable", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("does not hydrate parent links without a child file", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("does not invent subsession links from existing child session headers", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile };
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("does not hydrate copied parent links when the opened parent has a different id", async () => {
const forkedParent = fakeRuntime("parent-fork-1", {
sessionFile: "/sessions/parent-fork-1.jsonl",
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(forkedParent.runtime),
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]);
await service.dispose();
});
it("relinks a spawned child when the child session is opened after restart", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
expect(open).toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("notifies the validated parent file instead of an active prefix-matched parent id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-"));
const parentFile = join(tempDir, "parent.jsonl");
const forkParentFile = join(tempDir, "parent-fork.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const forkManager = fakeSessionManager("/workspace");
const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager });
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [fork.runtime, child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => {
if (path === parentFile) return parentManager;
if (path === forkParentFile) return forkManager;
return childManager;
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => forkManager,
list: (cwd: string) => Promise.resolve(cwd === "/workspace"
? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }]
: [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("parent-1-fork", "/workspace"));
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fork.calls.sendCustomMessage).toHaveLength(0);
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(open).toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-"));
const parentFile = join(tempDir, "parent.jsonl");
const originalChildFile = join(tempDir, "original-child.jsonl");
const copiedChildFile = join(tempDir, "copied-child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }],
});
const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("uses the verified child file instead of an active copied child with the same id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-"));
const parentFile = join(tempDir, "parent.jsonl");
const originalChildFile = join(tempDir, "original-child.jsonl");
const copiedChildFile = join(tempDir, "copied-child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const copiedManager = fakeSessionManager("/workspace-feature", {
getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }],
});
const originalManager = fakeSessionManager("/workspace-feature", {
getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }],
});
const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true });
const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => {
if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime);
if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime);
if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime);
throw new Error("unexpected session manager");
};
const open = vi.fn((path: string) => {
if (path === copiedChildFile) return copiedManager;
if (path === originalChildFile) return originalManager;
if (path === parentFile) return parentManager;
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: {
create: () => parentManager,
list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
await service.start("/workspace");
await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
]);
copiedChild.session.isStreaming = true;
copiedChild.emit({ type: "agent_start" });
copiedChild.session.isStreaming = false;
copiedChild.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({
sessionId: "child-1",
cwd: "/workspace-feature",
status: "idle",
finalText: "original child result",
messageCount: 1,
});
const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile);
expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" });
expect(open).toHaveBeenCalledWith(originalChildFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("uses the verified parent file instead of an active copied parent with the same id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-"));
const parentFile = join(tempDir, "parent.jsonl");
const copiedParentFile = join(tempDir, "copied-parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] });
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager });
const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => {
if (options.sessionManager === childManager) return Promise.resolve(child.runtime);
if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime);
if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime);
throw new Error("unexpected session manager");
};
const open = vi.fn((path: string) => {
if (path === childFile) return childManager;
if (path === parentFile) return parentManager;
if (path === copiedParentFile) return copiedParentManager;
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: {
create: () => copiedParentManager,
list: (cwd: string) => Promise.resolve(cwd === "/workspace"
? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }]
: [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
await service.status(sessionRef("parent-1", "/workspace"));
await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]);
await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions");
await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions");
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(copiedParent.calls.sendCustomMessage).toHaveLength(0);
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
expect(open).toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink a child marker when the current child file header no longer records the parent", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: {
...emptyArchiveStore(),
get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
expect(open).not.toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink a child marker when the child header points at a different parent id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-"));
const mismatchedParentFile = join(tempDir, "other-parent.jsonl");
const actualParentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: mismatchedParentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") });
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]),
listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
expect(open).not.toHaveBeenCalledWith(actualParentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink copied child markers when the opened child has a different id", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const childFile = "/sessions/child-fork-1.jsonl";
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-fork-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(open).not.toHaveBeenCalledWith(parentFile);
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("notifies the parent once when the tracked child stops working", async () => { it("notifies the parent once when the tracked child stops working", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace"); await service.start("/workspace");
@@ -947,7 +1623,7 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("reports an archived child's status in the subsession list", async () => { it("reports a missing tracked child file as unknown in the subsession list", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace"); await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
@@ -955,7 +1631,7 @@ describe("PiSessionService", () => {
await service.archive("child-1"); await service.archive("child-1");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([ await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "archived" }, { sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" },
]); ]);
await service.dispose(); await service.dispose();
}); });
+417 -30
View File
@@ -1,4 +1,4 @@
import { readFile, writeFile } from "node:fs/promises"; import { open, readFile, writeFile } from "node:fs/promises";
import type { Api, ImageContent, Model } from "@earendil-works/pi-ai"; import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
import { import {
AuthStorage, AuthStorage,
@@ -81,6 +81,26 @@ interface QueuedPrompt {
echoUserMessage?: boolean; echoUserMessage?: boolean;
} }
interface TrackedSubsessionLink {
parentSessionId: string;
childSessionId: string;
childSessionFile?: string;
parentSessionFile?: string;
cwd?: string;
}
interface PersistedParentSubsessionLink {
spawnedBySessionId: string;
spawnedSessionId: string;
spawnedSessionFile?: string;
cwd?: string;
}
interface PersistedChildSubsessionLink {
spawnedBySessionId: string;
spawnedSessionId: string;
}
function requirePromptText(value: unknown): string { function requirePromptText(value: unknown): string {
if (typeof value !== "string") throw new Error("Prompt text is required"); if (typeof value !== "string") throw new Error("Prompt text is required");
return value; return value;
@@ -123,8 +143,10 @@ type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
export interface PiSessionManager { export interface PiSessionManager {
getCwd(): string; getCwd(): string;
getBranch(): unknown[]; getBranch(): unknown[];
getEntries?(): readonly unknown[];
getLeafId(): string | null; getLeafId(): string | null;
getHeader?(): { parentSession?: string } | null | undefined; getHeader?(): { parentSession?: string } | null | undefined;
appendCustomEntry?(customType: string, data?: unknown): string;
} }
export interface PiSessionManagerGateway { export interface PiSessionManagerGateway {
@@ -290,6 +312,10 @@ export class PiSessionService {
private readonly subsessionParents = new Map<string, string>(); private readonly subsessionParents = new Map<string, string>();
/** Parent session id -> the set of tracked subsession ids it spawned. */ /** Parent session id -> the set of tracked subsession ids it spawned. */
private readonly subsessionChildren = new Map<string, Set<string>>(); private readonly subsessionChildren = new Map<string, Set<string>>();
/** Tracked subsession id -> persisted recovery details for the child. */
private readonly subsessionLinks = new Map<string, TrackedSubsessionLink>();
/** Parent id/file identities whose persisted links have already been loaded. */
private readonly subsessionHydratedParents = new Set<string>();
/** /**
* Tracked subsession id -> whether a completion notification is armed. * Tracked subsession id -> whether a completion notification is armed.
* Armed when the child starts working; firing on completion disarms it so a * Armed when the child starts working; firing on completion disarms it so a
@@ -322,9 +348,9 @@ export class PiSessionService {
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
!subsessionsActive ? undefined : { !subsessionsActive ? undefined : {
spawn: (input) => this.spawnSubsession(input), spawn: (input) => this.spawnSubsession(input),
list: (parentSessionId) => this.listSubsessions(parentSessionId), list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId), check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query), read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
}, },
); );
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime; this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
@@ -362,6 +388,8 @@ export class PiSessionService {
this.authLossWarnings.clear(); this.authLossWarnings.clear();
this.subsessionParents.clear(); this.subsessionParents.clear();
this.subsessionChildren.clear(); this.subsessionChildren.clear();
this.subsessionLinks.clear();
this.subsessionHydratedParents.clear();
this.subsessionNotifyArmed.clear(); this.subsessionNotifyArmed.clear();
await Promise.all(activeSessions.map(async (active) => { await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe(); active.unsubscribe();
@@ -439,7 +467,17 @@ export class PiSessionService {
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd); const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision); if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd, input.parentSessionFile); const created = await this.start(decision.cwd, input.parentSessionFile);
this.registerSubsession(input.parentSessionId, created.id); const parentSessionFile = nonEmptyString(input.parentSessionFile);
const link: TrackedSubsessionLink = {
parentSessionId: input.parentSessionId,
childSessionId: created.id,
...(created.path === "" ? {} : { childSessionFile: created.path }),
...(parentSessionFile === undefined ? {} : { parentSessionFile }),
cwd: decision.cwd,
};
this.registerVerifiedSubsession(link);
this.persistSubsessionLink(link);
this.persistSubsessionChildMarker(input.parentSessionId, created.id);
await this.prompt(created.id, input.prompt); await this.prompt(created.id, input.prompt);
this.logger.info( this.logger.info(
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, { parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
@@ -449,65 +487,272 @@ export class PiSessionService {
} }
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */ /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
async listSubsessions(parentSessionId: string): Promise<SubsessionSummary[]> { async listSubsessions(parentSessionId: string, parentSessionFile?: string): Promise<SubsessionSummary[]> {
const parentFile = nonEmptyString(parentSessionFile);
await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
const childIds = this.subsessionChildren.get(parentSessionId); const childIds = this.subsessionChildren.get(parentSessionId);
if (childIds === undefined) return []; if (childIds === undefined) return [];
return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) }))); const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
} }
/** Status and final result of a subsession, scoped to the caller's children. */ /** Status and final result of a subsession, scoped to the caller's children. */
async checkSubsession(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult> { async checkSubsession(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise<SubsessionCheckResult> {
const session = await this.openSubsession(parentSessionId, sessionId); const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
const messages = historyMessages(session); const messages = historyMessages(session);
return { return {
sessionId, sessionId,
cwd: session.sessionManager.getCwd(), cwd: session.sessionManager.getCwd(),
status: await this.subsessionStatus(session), status: this.subsessionStatus(session),
finalText: finalAssistantText(messages), finalText: finalAssistantText(messages),
messageCount: messages.length, messageCount: messages.length,
}; };
} }
/** Filtered, paginated transcript of a subsession, scoped to the caller's children. */ /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult> { async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery, parentSessionFile?: string): Promise<SubsessionReadResult> {
const session = await this.openSubsession(parentSessionId, sessionId); const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
const view = buildTranscriptView(historyMessages(session), query); const view = buildTranscriptView(historyMessages(session), query);
return { return {
sessionId, sessionId,
cwd: session.sessionManager.getCwd(), cwd: session.sessionManager.getCwd(),
status: await this.subsessionStatus(session), status: this.subsessionStatus(session),
...view, ...view,
}; };
} }
/** Open a session after verifying it is one of the caller's tracked children. */ /** Open a session after verifying it is one of the caller's tracked children. */
private async openSubsession(parentSessionId: string, sessionId: string): Promise<PiAgentSession> { private async openSubsession(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise<PiAgentSession> {
if (this.subsessionParents.get(sessionId) !== parentSessionId) { const parentFile = nonEmptyString(parentSessionFile);
await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
throw new Error(`Session ${sessionId} is not one of your subsessions`); throw new Error(`Session ${sessionId} is not one of your subsessions`);
} }
return this.getOrOpen(sessionId); return this.getOrOpenTrackedSubsession(sessionId);
}
private subsessionLinkBelongsToParent(parentSessionId: string, parentSessionFile: string | undefined, childSessionId: string): boolean {
const link = this.subsessionLinks.get(childSessionId);
if (link?.parentSessionId !== parentSessionId) return false;
return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
}
private activeChildForSubsessionLink(link: TrackedSubsessionLink): ActiveSession<PiSessionRuntime> | undefined {
const active = this.active.get(link.childSessionId);
if (active === undefined) return undefined;
return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
}
private activeParentForSubsessionLink(link: TrackedSubsessionLink): ActiveSession<PiSessionRuntime> | undefined {
const active = this.active.get(link.parentSessionId);
if (active === undefined) return undefined;
return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
}
private subsessionLinkForActiveChild(session: PiAgentSession): TrackedSubsessionLink | undefined {
const childId = session.sessionId;
const parentId = this.subsessionParents.get(childId);
const link = this.subsessionLinks.get(childId);
if (parentId === undefined || link?.parentSessionId !== parentId) return undefined;
return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
}
private registerVerifiedSubsession(link: TrackedSubsessionLink): void {
const { childSessionId, parentSessionId } = link;
const previousParentId = this.subsessionParents.get(childSessionId);
if (previousParentId !== undefined && previousParentId !== parentSessionId) {
const previousChildren = this.subsessionChildren.get(previousParentId);
previousChildren?.delete(childSessionId);
if (previousChildren?.size === 0) this.subsessionChildren.delete(previousParentId);
} }
private registerSubsession(parentSessionId: string, childSessionId: string): void {
this.subsessionParents.set(childSessionId, parentSessionId); this.subsessionParents.set(childSessionId, parentSessionId);
const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>(); const children = this.subsessionChildren.get(parentSessionId) ?? new Set<string>();
children.add(childSessionId); children.add(childSessionId);
this.subsessionChildren.set(parentSessionId, children); this.subsessionChildren.set(parentSessionId, children);
this.subsessionNotifyArmed.set(childSessionId, false);
this.subsessionLinks.set(childSessionId, link);
if (!this.subsessionNotifyArmed.has(childSessionId)) this.subsessionNotifyArmed.set(childSessionId, false);
}
private unregisterSubsession(childSessionId: string): void {
const parentSessionId = this.subsessionParents.get(childSessionId);
this.subsessionParents.delete(childSessionId);
this.subsessionLinks.delete(childSessionId);
this.subsessionNotifyArmed.delete(childSessionId);
if (parentSessionId === undefined) return;
const children = this.subsessionChildren.get(parentSessionId);
children?.delete(childSessionId);
if (children?.size === 0) this.subsessionChildren.delete(parentSessionId);
}
private persistSubsessionLink(link: TrackedSubsessionLink): void {
const parent = this.activeParentForSubsessionLink(link)?.runtime.session;
if (parent === undefined) return;
if (parent.sessionManager.appendCustomEntry === undefined) return;
try {
parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
} catch (error: unknown) {
this.logger.info(
{ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) },
"failed to persist subsession link",
);
}
}
private persistSubsessionChildMarker(parentSessionId: string, childSessionId: string): void {
const child = this.active.get(childSessionId)?.runtime.session;
if (child === undefined) return;
if (child.sessionManager.appendCustomEntry === undefined) return;
try {
child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
} catch (error: unknown) {
this.logger.info(
{ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) },
"failed to persist subsession child marker",
);
}
}
private async hydrateSubsessionsForParent(parentSessionId: string, parentSessionFile?: string): Promise<void> {
const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile);
if (this.subsessionHydratedParents.has(hydrationKey)) return;
const activeParent = this.active.get(parentSessionId);
if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
this.subsessionHydratedParents.add(hydrationKey);
return;
}
if (parentSessionFile === undefined) return;
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
this.subsessionHydratedParents.add(hydrationKey);
return;
}
let parentManager: PiSessionManager;
try {
parentManager = this.sessionManager.open(parentSessionFile);
} catch {
this.subsessionHydratedParents.add(hydrationKey);
return;
}
await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
this.subsessionHydratedParents.add(hydrationKey);
}
private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise<void> {
// Parent custom links are the authoritative recovery record: verify the
// exact live child file/header before tracking.
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
for (const entry of entries) {
const link = parsePersistedParentSubsessionLink(entry);
if (link === undefined) continue;
const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
if (verified === undefined) continue;
this.registerVerifiedSubsession(verified);
}
}
private async verifiedSubsessionLinkFromParentLink(parentSessionId: string, parentSessionFile: string | undefined, link: PersistedParentSubsessionLink): Promise<TrackedSubsessionLink | undefined> {
if (parentSessionFile === undefined) return undefined;
if (link.spawnedBySessionId !== parentSessionId) return undefined;
if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link))) return undefined;
return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
}
private async parentLinkHasValidChildTarget(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise<boolean> {
return link.spawnedSessionFile !== undefined
&& await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
}
private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise<void> {
const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
if (link === undefined) return;
this.registerVerifiedSubsession(link);
}
private async verifiedSubsessionLinkFromOpenedChild(session: PiAgentSession): Promise<TrackedSubsessionLink | undefined> {
// Child markers are only hints; the current child header and reciprocal
// parent custom link must agree on the exact ids and files before relinking.
const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
let marker: PersistedChildSubsessionLink | undefined;
for (const entry of entries) {
const parsed = parsePersistedChildSubsessionLink(entry);
if (parsed?.spawnedSessionId === session.sessionId) marker = parsed;
}
if (marker === undefined) return undefined;
const childSessionFile = nonEmptyString(session.sessionFile);
if (childSessionFile === undefined) return undefined;
const childHeader = await readSessionHeaderSummary(childSessionFile);
if (childHeader?.id !== session.sessionId) return undefined;
const parentSessionFile = nonEmptyString(childHeader.parentSession);
if (parentSessionFile === undefined) return undefined;
const parentHeader = await readSessionHeaderSummary(parentSessionFile);
if (parentHeader?.id !== marker.spawnedBySessionId) return undefined;
const parentLink = this.findReciprocalParentSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
if (parentLink === undefined) return undefined;
return {
parentSessionId: marker.spawnedBySessionId,
childSessionId: session.sessionId,
childSessionFile,
parentSessionFile,
cwd: parentLink.cwd ?? session.sessionManager.getCwd(),
};
}
private findReciprocalParentSubsessionLink(parentSessionFile: string, parentSessionId: string, childSessionId: string, childSessionFile: string): PersistedParentSubsessionLink | undefined {
let parentManager: PiSessionManager;
try {
parentManager = this.sessionManager.open(parentSessionFile);
} catch {
return undefined;
}
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
for (const entry of entries) {
const link = parsePersistedParentSubsessionLink(entry);
if (link === undefined) continue;
if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId) continue;
if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile)) continue;
return link;
}
return undefined;
}
private async getOrOpenTrackedSubsession(sessionId: string): Promise<PiAgentSession> {
const link = this.subsessionLinks.get(sessionId);
if (link === undefined) throw new Error("Session not found");
const active = this.activeChildForSubsessionLink(link);
if (active !== undefined) return active.runtime.session;
if (link.childSessionFile !== undefined) {
if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile }))) throw new Error("Session not found");
const sessionManager = this.sessionManager.open(link.childSessionFile);
return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
}
throw new Error("Session not found");
} }
private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> { private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> {
const active = this.active.get(childSessionId); const link = this.subsessionLinks.get(childSessionId);
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
if (active !== undefined) { if (active !== undefined) {
return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) }; return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
} }
const archived = await this.archiveStore.get(childSessionId); if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
if (archived !== undefined) return { cwd: archived.cwd, status: "archived" }; return { cwd: link.cwd ?? "", status: "idle" };
}
if (link?.cwd !== undefined) return { cwd: link.cwd, status: "unknown" };
return { cwd: "", status: "unknown" }; return { cwd: "", status: "unknown" };
} }
private async subsessionStatus(session: PiAgentSession): Promise<SubsessionStatus> { private subsessionStatus(session: PiAgentSession): SubsessionStatus {
if (await this.archiveStore.isArchived(session.sessionId)) return "archived";
if (this.hasActiveWork(session)) return "working"; if (this.hasActiveWork(session)) return "working";
if (this.activities.get(session.sessionId)?.phase === "error") return "error"; if (this.activities.get(session.sessionId)?.phase === "error") return "error";
return "idle"; return "idle";
@@ -520,9 +765,9 @@ export class PiSessionService {
* parent is busy and delivers immediately when it is idle). * parent is busy and delivers immediately when it is idle).
*/ */
private updateSubsessionTracking(session: PiAgentSession): void { private updateSubsessionTracking(session: PiAgentSession): void {
const childId = session.sessionId; const link = this.subsessionLinkForActiveChild(session);
const parentId = this.subsessionParents.get(childId); if (link === undefined) return;
if (parentId === undefined) return; const childId = link.childSessionId;
if (this.hasActiveWork(session)) { if (this.hasActiveWork(session)) {
this.subsessionNotifyArmed.set(childId, true); this.subsessionNotifyArmed.set(childId, true);
return; return;
@@ -533,7 +778,23 @@ export class PiSessionService {
const finalText = finalAssistantText(historyMessages(session)); const finalText = finalAssistantText(historyMessages(session));
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText); const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`; const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
void this.notifyParentOfSubsession(parentId, childId, text); void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
}
private async getOrOpenParentForSubsession(parentSessionId: string, childSessionId: string): Promise<PiAgentSession> {
const link = this.subsessionLinks.get(childSessionId);
if (link?.parentSessionId !== parentSessionId) throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
const active = this.activeParentForSubsessionLink(link);
if (active !== undefined) return active.runtime.session;
const parentSessionFile = link.parentSessionFile;
if (parentSessionFile === undefined) throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
}
const sessionManager = this.sessionManager.open(parentSessionFile);
return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
} }
/** /**
@@ -545,7 +806,7 @@ export class PiSessionService {
*/ */
private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> { private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise<void> {
try { try {
const session = await this.getOrOpen(parentId); const session = await this.getOrOpenParentForSubsession(parentId, childId);
await session.sendCustomMessage( await session.sendCustomMessage(
{ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } },
{ triggerTurn: true, deliverAs: "followUp" }, { triggerTurn: true, deliverAs: "followUp" },
@@ -809,6 +1070,8 @@ export class PiSessionService {
const sessionFile = session.sessionFile; const sessionFile = session.sessionFile;
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted"); if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
await clearParentSession(sessionFile); await clearParentSession(sessionFile);
clearParentSessionHeader(session.sessionManager);
this.unregisterSubsession(session.sessionId);
} }
async abort(ref: PiSessionLookup): Promise<void> { async abort(ref: PiSessionLookup): Promise<void> {
@@ -922,7 +1185,7 @@ export class PiSessionService {
// Disarm subsession notification before teardown so the abort below cannot // Disarm subsession notification before teardown so the abort below cannot
// emit a "stopped working" event that notifies the parent (e.g. on archive). // emit a "stopped working" event that notifies the parent (e.g. on archive).
// The parent/children link is kept so the parent can still see the child. // The parent/children link is kept so the parent can still see the child.
this.subsessionNotifyArmed.delete(sessionId); if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined) this.subsessionNotifyArmed.delete(sessionId);
clearSessionQueue(active.runtime.session); clearSessionQueue(active.runtime.session);
active.unsubscribe(); active.unsubscribe();
try { try {
@@ -979,8 +1242,10 @@ export class PiSessionService {
runtime.setRebindSession(async (session) => { runtime.setRebindSession(async (session) => {
await this.bindSessionExtensions(session); await this.bindSessionExtensions(session);
this.bindRuntime(active); this.bindRuntime(active);
await this.recoverSubsessionTrackingForOpenedSession(session);
}); });
this.active.set(runtime.session.sessionId, active); this.active.set(runtime.session.sessionId, active);
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
this.publishStatus(runtime.session); this.publishStatus(runtime.session);
return active; return active;
} }
@@ -1411,6 +1676,117 @@ function isDefined<T>(value: T | undefined): value is T {
return value !== undefined; return value !== undefined;
} }
function trackedSubsessionLinkFromParentLink(parentSessionId: string, link: PersistedParentSubsessionLink, parentSessionFile: string): TrackedSubsessionLink {
return {
parentSessionId,
childSessionId: link.spawnedSessionId,
...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
parentSessionFile,
...(link.cwd === undefined ? {} : { cwd: link.cwd }),
};
}
function persistedParentSubsessionLinkData(link: TrackedSubsessionLink): Record<string, unknown> {
return {
version: 1,
spawnedBySessionId: link.parentSessionId,
spawnedSessionId: link.childSessionId,
...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
...(link.cwd === undefined ? {} : { cwd: link.cwd }),
};
}
function persistedChildSubsessionLinkData(parentSessionId: string, childSessionId: string): Record<string, unknown> {
return {
version: 1,
spawnedBySessionId: parentSessionId,
spawnedSessionId: childSessionId,
};
}
function parsePersistedParentSubsessionLink(entry: unknown): PersistedParentSubsessionLink | undefined {
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE) return undefined;
const data = entry["data"];
if (!isRecord(data)) return undefined;
const spawnedBySessionId = getString(data, "spawnedBySessionId");
const spawnedSessionId = getString(data, "spawnedSessionId");
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "") return undefined;
const spawnedSessionFile = getString(data, "spawnedSessionFile");
const cwd = getString(data, "cwd");
return {
spawnedBySessionId,
spawnedSessionId,
...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
...(cwd === undefined || cwd === "" ? {} : { cwd }),
};
}
function parsePersistedChildSubsessionLink(entry: unknown): PersistedChildSubsessionLink | undefined {
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE) return undefined;
const data = entry["data"];
if (!isRecord(data)) return undefined;
const spawnedBySessionId = getString(data, "spawnedBySessionId");
const spawnedSessionId = getString(data, "spawnedSessionId");
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "") return undefined;
return { spawnedBySessionId, spawnedSessionId };
}
function nonEmptyString(value: string | undefined): string | undefined {
return value === undefined || value === "" ? undefined : value;
}
function subsessionHydratedParentKey(parentSessionId: string, parentSessionFile: string | undefined): string {
return `${parentSessionId}\0${parentSessionFile ?? ""}`;
}
function sessionPathsEqual(a: string, b: string): boolean {
return cwdPathsEqual(a, b);
}
function sessionFileMatches(session: PiAgentSession, expectedSessionFile: string | undefined): boolean {
const sessionFile = nonEmptyString(session.sessionFile);
return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
}
function activeSessionFileMatches(active: ActiveSession<PiSessionRuntime>, expectedSessionFile: string | undefined): boolean {
return sessionFileMatches(active.runtime.session, expectedSessionFile);
}
function trackedLinkParentFileMatches(link: TrackedSubsessionLink, parentSessionFile: string): boolean {
return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
}
interface SessionHeaderSummary {
id: string;
parentSession?: string;
}
async function readSessionHeaderSummary(sessionFile: string): Promise<SessionHeaderSummary | undefined> {
let file: Awaited<ReturnType<typeof open>> | undefined;
try {
file = await open(sessionFile, "r");
const buffer = Buffer.alloc(4096);
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
if (firstLine === undefined || firstLine === "") return undefined;
const header: unknown = JSON.parse(firstLine);
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string") return undefined;
const parentSession = getString(header, "parentSession");
return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
} catch {
return undefined;
} finally {
await file?.close().catch(() => undefined);
}
}
async function sessionFileHeaderMatches(sessionFile: string, expected: { sessionId: string; parentSessionFile?: string | undefined }): Promise<boolean> {
const header = await readSessionHeaderSummary(sessionFile);
if (header?.id !== expected.sessionId) return false;
if (expected.parentSessionFile === undefined) return true;
return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
}
async function clearParentSession(sessionFile: string): Promise<void> { async function clearParentSession(sessionFile: string): Promise<void> {
const content = await readFile(sessionFile, "utf8"); const content = await readFile(sessionFile, "utf8");
const newlineIndex = content.indexOf("\n"); const newlineIndex = content.indexOf("\n");
@@ -1423,6 +1799,11 @@ async function clearParentSession(sessionFile: string): Promise<void> {
await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8"); await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
} }
function clearParentSessionHeader(sessionManager: PiSessionManager): void {
const header = sessionManager.getHeader?.();
if (header !== undefined && header !== null) delete header.parentSession;
}
function clearSessionQueue(session: PiAgentSession): void { function clearSessionQueue(session: PiAgentSession): void {
session.clearQueue(); session.clearQueue();
} }
@@ -1475,6 +1856,12 @@ function historyMessages(session: PiAgentSession): unknown[] {
return messages; return messages;
} }
/** custom entry type used to persist parent -> child subsession links outside LLM context. */
const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
/** custom entry type used to mark a child as created by spawn_subsession. */
const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
/** customType marking a parent-facing subsession-completion notice. */ /** customType marking a parent-facing subsession-completion notice. */
const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion"; const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
+52 -2
View File
@@ -1,13 +1,27 @@
import { getApiProvider, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai"; import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
const SESSION_NAME_TIMEOUT_MS = 10_000; const SESSION_NAME_TIMEOUT_MS = 10_000;
const SESSION_NAME_MAX_INPUT_CHARS = 4_000; const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
const SESSION_NAME_MAX_LENGTH = 60; const SESSION_NAME_MAX_LENGTH = 60;
const FALLBACK_SESSION_NAME_MAX_WORDS = 6; const FALLBACK_SESSION_NAME_MAX_WORDS = 6;
const PI_AI_COMPAT_MODULE = ["@earendil-works/pi-ai", "compat"].join("/");
interface SessionNameApiProvider {
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
interface PiAiProviderRegistryModule {
getApiProvider?: (api: Api) => SessionNameApiProvider | undefined;
}
type ModuleImporter = (specifier: string) => Promise<unknown>;
let piAiProviderRegistryModulePromise: Promise<PiAiProviderRegistryModule> | undefined;
export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> { export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
const provider = getApiProvider(model.api); const providerRegistry = await getPiAiProviderRegistryModule();
const provider = providerRegistry.getApiProvider?.(model.api);
if (provider === undefined) return undefined; if (provider === undefined) return undefined;
const auth = await modelRegistry.getApiKeyAndHeaders(model); const auth = await modelRegistry.getApiKeyAndHeaders(model);
@@ -67,6 +81,42 @@ export function cleanSessionName(value: string): string | undefined {
return title === "" ? undefined : title; return title === "" ? undefined : title;
} }
async function getPiAiProviderRegistryModule(importer: ModuleImporter = (specifier) => import(specifier)): Promise<PiAiProviderRegistryModule> {
piAiProviderRegistryModulePromise ??= loadPiAiProviderRegistryModule(importer);
return piAiProviderRegistryModulePromise;
}
async function loadPiAiProviderRegistryModule(importer: ModuleImporter): Promise<PiAiProviderRegistryModule> {
const compatModule = await importOptionalPiAiModule(PI_AI_COMPAT_MODULE, importer);
if (hasGetApiProvider(compatModule)) return compatModule;
const rootModule = await importer("@earendil-works/pi-ai");
if (hasGetApiProvider(rootModule)) return rootModule;
return {};
}
async function importOptionalPiAiModule(specifier: string, importer: ModuleImporter): Promise<unknown> {
try {
return await importer(specifier);
} catch (error) {
if (isModuleUnavailableError(error)) return undefined;
throw error;
}
}
function hasGetApiProvider(moduleValue: unknown): moduleValue is PiAiProviderRegistryModule {
return typeof moduleValue === "object"
&& moduleValue !== null
&& "getApiProvider" in moduleValue
&& typeof moduleValue.getApiProvider === "function";
}
function isModuleUnavailableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = "code" in error ? error.code : undefined;
return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED";
}
function textFromAssistant(message: AssistantMessage): string { function textFromAssistant(message: AssistantMessage): string {
return message.content return message.content
.filter((part) => part.type === "text") .filter((part) => part.type === "text")
@@ -56,9 +56,9 @@ describe("createSubsessionToolDefinitions", () => {
])); ]));
const { list: listTool } = tools({ list }); const { list: listTool } = tools({ list });
const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", undefined)); const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(list).toHaveBeenCalledWith("parent-1"); expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
expect(result.details).toEqual({ subsessions: [ expect(result.details).toEqual({ subsessions: [
{ sessionId: "child-1", cwd: "/repos/a", status: "working" }, { sessionId: "child-1", cwd: "/repos/a", status: "working" },
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" }, { sessionId: "child-2", cwd: "/repos/a", status: "idle" },
@@ -76,9 +76,9 @@ describe("createSubsessionToolDefinitions", () => {
const check = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 })); const check = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 }));
const { check: checkTool } = tools({ check }); const { check: checkTool } = tools({ check });
const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined)); const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(check).toHaveBeenCalledWith("parent-1", "child-1"); expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" }); expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
expect(firstText(result.content)).toContain("all done"); expect(firstText(result.content)).toContain("all done");
}); });
@@ -99,9 +99,9 @@ describe("createSubsessionToolDefinitions", () => {
})); }));
const { read: readTool } = tools({ read }); const { read: readTool } = tools({ read });
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", undefined)); const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }); expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl");
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 }); expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
expect(firstText(result.content)).toContain("the answer"); expect(firstText(result.content)).toContain("the answer");
}); });
+10 -7
View File
@@ -3,7 +3,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js"; import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
/** Lifecycle phase of a tracked subsession as seen by its parent. */ /** Lifecycle phase of a tracked subsession as seen by its parent. */
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown"; export type SubsessionStatus = "working" | "idle" | "error" | "unknown";
export interface SpawnSubsessionResult { export interface SpawnSubsessionResult {
sessionId: string; sessionId: string;
@@ -56,9 +56,9 @@ export interface SubsessionReadQuery {
export interface SubsessionToolDeps { export interface SubsessionToolDeps {
spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>; spawn(input: SpawnSubsessionInvocation): Promise<SpawnSubsessionResult>;
list(parentSessionId: string): Promise<SubsessionSummary[]>; list(parentSessionId: string, parentSessionFile?: string): Promise<SubsessionSummary[]>;
check(parentSessionId: string, sessionId: string): Promise<SubsessionCheckResult>; check(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise<SubsessionCheckResult>;
read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise<SubsessionReadResult>; read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery, parentSessionFile?: string): Promise<SubsessionReadResult>;
} }
const SpawnSubsessionParams = Type.Object({ const SpawnSubsessionParams = Type.Object({
@@ -196,7 +196,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
parameters: ListSubsessionsParams, parameters: ListSubsessionsParams,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
const subsessions = await deps.list(parentSessionId); const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const subsessions = await deps.list(parentSessionId, parentSessionFile);
const text = subsessions.length === 0 const text = subsessions.length === 0
? "You have not spawned any subsessions." ? "You have not spawned any subsessions."
: `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`; : `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`;
@@ -212,7 +213,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
parameters: CheckSubsessionParams, parameters: CheckSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
const result = await deps.check(parentSessionId, params.sessionId); const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
const body = result.finalText === "" ? "(no output yet)" : result.finalText; const body = result.finalText === "" ? "(no output yet)" : result.finalText;
return { return {
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }], content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
@@ -229,8 +231,9 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
parameters: ReadSubsessionParams, parameters: ReadSubsessionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const parentSessionId = ctx.sessionManager.getSessionId(); const parentSessionId = ctx.sessionManager.getSessionId();
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
const { sessionId, ...query } = params; const { sessionId, ...query } = params;
const result = await deps.read(parentSessionId, sessionId, query); const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
return { return {
content: [{ type: "text", text: renderTranscript(result) }], content: [{ type: "text", text: renderTranscript(result) }],
details: result, details: result,
+72 -7
View File
@@ -1,16 +1,26 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
import type { PiWebConfigService } from "./configRoutes.js";
import type { ProjectService } from "./projects/projectService.js"; import type { ProjectService } from "./projects/projectService.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js"; import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js"; import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js"; import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
export interface WorkspaceExplorerRouteOptions {
config?: Pick<PiWebConfigService, "read">;
}
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
registerWorkspaceFileContentParsers(app);
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => { app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
try { try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await listWorkspaceTree(context.root, request.query.path); return await listWorkspaceTree(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
} catch (error) { } catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
} }
@@ -19,7 +29,41 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => { app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try { try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await readWorkspaceFile(context.root, request.query.path); return await readWorkspaceFile(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const writeOptions: WriteWorkspaceFileOptions = {
createDirs: request.query.createDirs !== "false",
overwrite: request.query.overwrite !== "false",
};
return await writeWorkspaceFile(context.root, request.query.path, request.body, writeOptions);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.delete<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await deleteWorkspaceFile(context.root, request.query.path);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { projectId: string; workspaceId: string }; Querystring: { fromPath?: string; toPath?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/move`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
return await moveWorkspaceFile(context.root, request.query.fromPath, request.query.toPath, {
createDirs: request.query.createDirs !== "false",
overwrite: request.query.overwrite === "true",
});
} catch (error) { } catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
} }
@@ -28,7 +72,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => { app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
try { try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const preview = await readWorkspaceImagePreview(context.root, request.query.path); const preview = await readWorkspaceImagePreview(context.root, request.query.path, await pathAccessForWorkspaceContext(context, options.config));
return await reply return await reply
.type(preview.mimeType) .type(preview.mimeType)
.header("Cache-Control", "private, max-age=3600") .header("Cache-Control", "private, max-age=3600")
@@ -41,4 +85,25 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
} }
}); });
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/files`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const query = request.query.q ?? "";
const pathAccess = isAbsoluteishFileSuggestionQuery(query) ? await pathAccessForWorkspaceContext(context, options.config) : undefined;
if (request.query.mode === "path") return await listPathSuggestions(context.root, query, pathAccess);
return await listFileSuggestions(context.root, query, { kind: request.query.kind, scope: request.query.scope, pathAccess });
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
}
function registerWorkspaceFileContentParsers(app: FastifyInstance): void {
// Fastify's default parser only handles JSON; workspace file writes need to
// accept text and arbitrary binary payloads. This route module is registered
// for both local aliases, so parser registration must tolerate repeats.
try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_request, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ }
try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/u, { parseAs: "buffer" }, (_request, body, done) => { done(null, body); }); } catch { /* already registered */ }
} }
@@ -0,0 +1,29 @@
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import type { PiWebConfigService } from "../configRoutes.js";
import type { ProjectService } from "../projects/projectService.js";
import type { WorkspaceContext } from "./workspaceContext.js";
import type { WorkspaceService } from "./workspaceService.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import { loadEffectiveProjectPathAccess } from "./projectPiWebConfig.js";
export async function pathAccessForWorkspaceContext(context: WorkspaceContext, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
if (config === undefined) return undefined;
const response = await config.read();
return loadEffectiveProjectPathAccess(context.project.path, response.effectiveConfig);
}
export async function pathAccessForCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService, config: Pick<PiWebConfigService, "read"> | undefined): Promise<PiWebPathAccessConfig | undefined> {
if (config === undefined) return undefined;
const response = await config.read();
const projectPath = await projectPathForWorkspaceCwd(cwd, projects, workspaces);
if (projectPath === undefined) return response.effectiveConfig.pathAccess;
return loadEffectiveProjectPathAccess(projectPath, response.effectiveConfig);
}
async function projectPathForWorkspaceCwd(cwd: string, projects: ProjectService, workspaces: WorkspaceService): Promise<string | undefined> {
for (const project of await projects.list()) {
if (cwdPathsEqual(project.path, cwd)) return project.path;
if ((await workspaces.list(project)).some((workspace) => cwdPathsEqual(workspace.path, cwd))) return project.path;
}
return undefined;
}
@@ -1,9 +1,10 @@
import { mkdtemp, mkdir, rm, truncate, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js"; import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
import { readWorkspaceFile } from "./fileContentService.js"; import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js";
import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js";
import { readWorkspaceImagePreview } from "./imagePreviewService.js"; import { readWorkspaceImagePreview } from "./imagePreviewService.js";
const roots: string[] = []; const roots: string[] = [];
@@ -49,6 +50,23 @@ describe("readWorkspaceFile", () => {
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
}); });
it("reads allowed absolute files outside the workspace", async () => {
const root = await tempWorkspace();
const external = await tempWorkspace();
await writeFile(join(external, "README.md"), "external docs\n");
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
expect(file).toMatchObject({
path: join(external, "README.md"),
language: "markdown",
content: "external docs\n",
truncated: false,
binary: false,
});
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
});
it("detects binary files and omits binary content", async () => { it("detects binary files and omits binary content", async () => {
const root = await tempWorkspace(); const root = await tempWorkspace();
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f])); await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
@@ -96,3 +114,262 @@ describe("readWorkspaceFile", () => {
expect(file.binary).toBe(false); expect(file.binary).toBe(false);
}); });
}); });
describe("writeWorkspaceFile", () => {
it("writes text content to a new file with normalized paths", async () => {
const root = await tempWorkspace();
const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
expect(result).toMatchObject({ path: "src/hello.ts", created: true });
expect(result.size).toBe(26);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Verify the file was actually written
const content = await readFile(join(root, "src", "hello.ts"), "utf8");
expect(content).toBe("const greeting = 'hello';\n");
});
it("writes binary content", async () => {
const root = await tempWorkspace();
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
const result = await writeWorkspaceFile(root, "image.png", binaryData);
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
});
it("overwrites existing files by default", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "notes.txt"), "old content");
const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
const content = await readFile(join(root, "notes.txt"), "utf8");
expect(content).toBe("new content");
});
it("throws when overwrite is false and file exists", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "existing.txt"), "data");
await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
});
it("creates intermediate directories by default", async () => {
const root = await tempWorkspace();
await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
expect(content).toBe("deep content");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await tempWorkspace();
await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
});
it("rejects missing paths, traversal, and absolute paths", async () => {
const root = await tempWorkspace();
await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects writing to a directory path", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
});
it("prevents writing through symlinks that escape the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// Create a symlink inside the workspace that points outside
const { symlink } = await import("node:fs/promises");
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
roots.push(outsideDir);
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
// Attempting to write through the symlink should be blocked
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow();
});
});
describe("deleteWorkspaceFile", () => {
it("deletes an existing file and returns existed: true", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "notes.txt"), "hello");
const result = await deleteWorkspaceFile(root, "notes.txt");
expect(result).toMatchObject({ path: "notes.txt", existed: true });
await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
});
it("returns existed: false when deleting a non-existent file", async () => {
const root = await tempWorkspace();
const result = await deleteWorkspaceFile(root, "missing.txt");
expect(result).toMatchObject({ path: "missing.txt", existed: false });
});
it("rejects deleting a directory", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
});
it("rejects path traversal", async () => {
const root = await tempWorkspace();
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects missing path", async () => {
const root = await tempWorkspace();
await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
});
it("deletes a symlink itself, not its target", async () => {
const root = await tempWorkspace();
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-"));
roots.push(outsideDir);
await writeFile(join(outsideDir, "real.txt"), "real content");
// Create a symlink inside the workspace pointing outside
await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
const result = await deleteWorkspaceFile(root, "link.txt");
expect(result).toMatchObject({ path: "link.txt", existed: true });
// The symlink should be gone, but the target file should still exist
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow();
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
expect(realContent).toBe("real content");
});
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// A real file living outside the workspace that must not be deletable.
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-"));
roots.push(outsideDir);
await writeFile(join(outsideDir, "victim.txt"), "important");
// A symlinked parent directory inside the workspace pointing outside.
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
// The outside file must survive.
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
expect(realContent).toBe("important");
});
});
describe("moveWorkspaceFile", () => {
it("moves a file to a new path", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "original.txt"), "content");
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(result.size).toBe(7);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Source should no longer exist
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
// Target should exist
const target = await readWorkspaceFile(root, "moved.txt");
expect(target.content).toBe("content");
});
it("creates intermediate directories by default", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
expect(target.content).toBe("data");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
});
it("overwrites target when overwrite is true", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "source content");
await writeFile(join(root, "target.txt"), "target content");
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
expect(result.toPath).toBe("target.txt");
const target = await readWorkspaceFile(root, "target.txt");
expect(target.content).toBe("source content");
});
it("throws when target exists and overwrite is false (default)", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "source");
await writeFile(join(root, "target.txt"), "target");
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
// Source should still exist
const source = await readWorkspaceFile(root, "source.txt");
expect(source.content).toBe("source");
});
it("rejects source path traversal", async () => {
const root = await tempWorkspace();
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
});
it("rejects target path traversal", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "data");
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow();
});
it("rejects moving a directory", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
});
it("rejects missing fromPath or toPath", async () => {
const root = await tempWorkspace();
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
});
it("prevents moving through symlinks that escape the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
await writeFile(join(root, "subdir", "file.txt"), "data");
// Create a symlink inside the workspace that points outside
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-"));
roots.push(outsideDir);
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow();
});
});
+115 -8
View File
@@ -1,22 +1,24 @@
import { open, stat } from "node:fs/promises"; import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
import type { FileContentResponse } from "../../shared/apiTypes.js"; import { basename, dirname, join } from "node:path";
import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, PiWebPathAccessConfig, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js";
import { imageMimeTypeForPath } from "./imagePreviewService.js"; import { imageMimeTypeForPath } from "./imagePreviewService.js";
import { resolveInsideWorkspace } from "./pathSafety.js"; import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
const MAX_BYTES = 512 * 1024; const MAX_BYTES = 512 * 1024;
export async function readWorkspaceFile(rootPath: string, path: string | undefined): Promise<FileContentResponse> { export async function readWorkspaceFile(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileContentResponse> {
if (path === undefined || path === "") throw new Error("path query parameter is required"); if (path === undefined || path === "") throw new Error("path query parameter is required");
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path); const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
const s = await stat(target); const s = await stat(target);
if (!s.isFile()) throw new Error("Path is not a file"); if (!s.isFile()) throw new Error("Path is not a file");
const bytesToRead = Math.min(s.size, MAX_BYTES); const bytesToRead = Math.min(s.size, MAX_BYTES);
const buffer = await readFilePrefix(target, bytesToRead); const buffer = await readFilePrefix(target, bytesToRead);
const media = mediaForPath(relativePath); const media = mediaForPath(displayPath);
const binary = media.mediaType === "image" || isProbablyBinary(buffer); const binary = media.mediaType === "image" || isProbablyBinary(buffer);
return { return {
path: relativePath, path: displayPath,
...languageForPath(relativePath), ...languageForPath(displayPath),
...media, ...media,
encoding: "utf8", encoding: "utf8",
size: s.size, size: s.size,
@@ -39,6 +41,111 @@ async function readFilePrefix(target: string, bytesToRead: number): Promise<Buff
} }
} }
export async function writeWorkspaceFile(rootPath: string, path: string | undefined, content: Buffer, options: WriteWorkspaceFileOptions = {}): Promise<WriteWorkspaceFileResponse> {
if (path === undefined || path === "") throw new Error("path query parameter is required");
const createDirs = options.createDirs ?? true;
const overwrite = options.overwrite ?? true;
let exists = false;
try {
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
const s = await stat(target);
if (!s.isFile()) throw new Error("Path is not a file");
if (!overwrite) throw new Error(`File already exists: ${relativePath}`);
exists = true;
} catch (error: unknown) {
if (error instanceof Error && error.message.startsWith("File already exists")) throw error;
if (isNodeErrorWithCode(error, "ENOENT")) { /* expected for creation — continue */ }
else if (error instanceof Error && error.message === "Path does not exist") { /* expected for creation — continue */ }
else throw error; // re-throw permission errors, "not a file", traversal errors, etc.
}
// Use resolveParentInsideWorkspace for the actual write since the target may not exist yet
const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
if (createDirs) await mkdir(dirname(target), { recursive: true });
// Resolve symlinks in the parent path to prevent escape via symlink
const realParent = await realpath(dirname(target));
const realTarget = join(realParent, basename(target));
ensureInside(root, realTarget);
await writeFile(realTarget, content);
const s = await stat(realTarget);
return {
path: relativePath,
size: s.size,
modifiedAt: s.mtime.toISOString(),
created: !exists,
};
}
export async function deleteWorkspaceFile(rootPath: string, path: string | undefined): Promise<DeleteWorkspaceFileResponse> {
if (path === undefined || path === "") throw new Error("path query parameter is required");
// Use resolveParentInsideWorkspace + lstat so that deleting a symlink
// deletes the symlink itself, not the target it points to.
// resolveInsideWorkspace would call realpath on the target, following
// symlinks and resolving the symlink's destination instead.
const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path);
try {
// Resolve symlinks in the parent path to prevent escape via a symlinked
// parent directory. The final path component is intentionally NOT resolved
// so that lstat/unlink act on the entry itself (deleting a symlink rather
// than the file it points to).
const realParent = await realpath(dirname(target));
const realTarget = join(realParent, basename(target));
ensureInside(root, realTarget);
const s = await lstat(realTarget);
// Allow deleting regular files and symlinks, but not directories
if (s.isDirectory()) throw new Error("Path is a directory, use directory deletion instead");
await unlink(realTarget);
return { path: relativePath, existed: true };
} catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false };
if (error instanceof Error && error.message === "Path does not exist") return { path: relativePath, existed: false };
throw error;
}
}
export async function moveWorkspaceFile(rootPath: string, fromPath: string | undefined, toPath: string | undefined, options: MoveWorkspaceFileOptions = {}): Promise<MoveWorkspaceFileResponse> {
if (fromPath === undefined || fromPath === "") throw new Error("fromPath query parameter is required");
if (toPath === undefined || toPath === "") throw new Error("toPath query parameter is required");
const createDirs = options.createDirs ?? true;
const overwrite = options.overwrite ?? false;
// Source: must exist and be a file (uses realpath via resolveInsideWorkspace)
const { target: source, relativePath: fromRelative } = await resolveInsideWorkspace(rootPath, fromPath);
const sourceStat = await stat(source);
if (!sourceStat.isFile()) throw new Error("Source path is not a file");
// Target: uses resolveParentInsideWorkspace + realpath(dirname) pattern (same as writeFile)
const { root, target: dest, relativePath: destRelative } = await resolveParentInsideWorkspace(rootPath, toPath);
if (createDirs) await mkdir(dirname(dest), { recursive: true });
// Resolve symlinks in the parent path to prevent escape via symlink
const realParent = await realpath(dirname(dest));
const realDest = join(realParent, basename(dest));
ensureInside(root, realDest);
if (!overwrite) {
try {
const destStat = await stat(realDest);
if (destStat.isFile()) throw new Error(`File already exists: ${destRelative}`);
} catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) { /* expected — target doesn't exist */ }
else if (error instanceof Error && error.message.startsWith("File already exists")) throw error;
else throw error;
}
}
await rename(source, realDest);
const finalStat = await stat(realDest);
return { fromPath: fromRelative, toPath: destRelative, size: finalStat.size, modifiedAt: finalStat.mtime.toISOString() };
}
function isProbablyBinary(buffer: Buffer): boolean { function isProbablyBinary(buffer: Buffer): boolean {
const sample = buffer.subarray(0, Math.min(buffer.length, 8192)); const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
return sample.includes(0); return sample.includes(0);
+213 -4
View File
@@ -1,8 +1,8 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { join } from "node:path"; import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions"; import { listFileSuggestions, listPathSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
const temporaryRoots: string[] = []; const temporaryRoots: string[] = [];
@@ -12,6 +12,26 @@ async function tempWorkspace(): Promise<string> {
return root; return root;
} }
function fzfRecords(input: string | Buffer | undefined): string[] {
if (typeof input === "string") return input.split("\0").filter(Boolean);
if (Buffer.isBuffer(input)) return input.toString("utf8").split("\0").filter(Boolean);
return [];
}
async function trySymlink(target: string, path: string): Promise<boolean> {
try {
await symlink(target, path, "dir");
return true;
} catch (error) {
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
throw error;
}
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
afterEach(async () => { afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
}); });
@@ -86,6 +106,67 @@ describe("file suggestions", () => {
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" }); expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
}); });
it("uses fzf to filter and rank file suggestions after candidates are gathered", async () => {
const fzfInputs: string[][] = [];
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/server/app.ts\0scripts/start.ts\0docs/reference.md\0" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
fzf: (file, args, options) => {
expect(file).toBe("fzf");
expect(args).toEqual(["--filter", "st", "--read0", "--print0"]);
fzfInputs.push(fzfRecords(options.input));
return Promise.resolve({ stdout: "scripts/start.ts\0src/server/app.ts\0" });
},
};
await expect(listFileSuggestions("/repo", "st", { scope: "tracked" }, deps)).resolves.toEqual([
{ path: "scripts/start.ts", kind: "tracked" },
{ path: "src/server/app.ts", kind: "tracked" },
]);
expect(fzfInputs).toEqual([[
"src/",
"src/server/",
"src/server/app.ts",
"scripts/",
"scripts/start.ts",
"docs/",
"docs/reference.md",
]]);
});
it("falls back to TypeScript file ranking when fzf fails", async () => {
let fzfCalls = 0;
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "klingit-go/cli/cmd/dev/main.go\0MD PRojects here.md\0" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
fzf: () => {
fzfCalls += 1;
return Promise.reject(Object.assign(new Error("spawn fzf ENOENT"), { code: "ENOENT" }));
},
};
const suggestions = await listFileSuggestions("/repo", "MD", { scope: "tracked" }, deps);
expect(fzfCalls).toBe(1);
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
});
it("treats an fzf no-match exit as an empty filtered result", async () => {
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
fzf: () => Promise.reject(Object.assign(new Error("no match"), { exitCode: 1 })),
};
await expect(listFileSuggestions("/repo", "app", { scope: "tracked" }, deps)).resolves.toEqual([]);
});
it("preserves git filenames without trimming whitespace", async () => { it("preserves git filenames without trimming whitespace", async () => {
const deps: FileSuggestionDependencies = { const deps: FileSuggestionDependencies = {
execFile: (file, args) => { execFile: (file, args) => {
@@ -120,4 +201,132 @@ describe("file suggestions", () => {
{ path: "src/app.ts", kind: "other" }, { path: "src/app.ts", kind: "other" },
]); ]);
}); });
it("uses allowed roots for absolute-ish file suggestion queries", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
await mkdir(workspace);
await mkdir(external);
await writeFile(join(external, "sdk.md"), "external sdk\n");
await expect(listFileSuggestions(workspace, join(external, "s"), { pathAccess: { allowedPaths: [external] } })).resolves.toEqual([
{ path: join(external, "sdk.md"), kind: "other" },
]);
});
it("skips absolute-ish suggestions that would escape an allowed root through symlinks", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
const secret = join(root, "secret");
await mkdir(workspace);
await mkdir(external);
await mkdir(secret);
await writeFile(join(external, "sdk.md"), "external sdk\n");
await writeFile(join(secret, "token.txt"), "secret\n");
if (!await trySymlink(secret, join(external, "escape"))) return;
await expect(listPathSuggestions(workspace, `${external}/`, { allowedPaths: [external] })).resolves.toEqual([
{ path: join(external, "sdk.md"), kind: "other" },
]);
});
it("keeps tilde-prefixed allowed-root suggestions matchable by fzf", async () => {
const workspace = await tempWorkspace();
const homeEntry = await mkdtemp(join(homedir(), ".pi-web-files-"));
temporaryRoots.push(homeEntry);
const expectedPath = `~/${basename(homeEntry)}/`;
const deps: FileSuggestionDependencies = {
fzf: (file, args, options) => {
expect(file).toBe("fzf");
expect(args).toEqual(["--filter", "~/", "--read0", "--print0"]);
expect(fzfRecords(options.input)).toContain(expectedPath);
return Promise.resolve({ stdout: `${expectedPath}\0` });
},
};
await expect(listFileSuggestions(workspace, "~/", { pathAccess: { allowedPaths: ["~/"] } }, deps)).resolves.toEqual([
{ path: expectedPath, kind: "other" },
]);
});
it("keeps normal file suggestions workspace-local even when allowed roots are configured", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
await mkdir(workspace);
await mkdir(external);
await writeFile(join(external, "sdk.md"), "external sdk\n");
const deps: FileSuggestionDependencies = {
execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })),
};
await expect(listFileSuggestions(workspace, "sdk", { scope: "all", pathAccess: { allowedPaths: [external] } }, deps)).resolves.toEqual([]);
});
it("keeps relative path suggestions workspace-local and skips symlink escapes", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const outside = join(root, "outside");
await mkdir(workspace);
await mkdir(outside);
await writeFile(join(workspace, "local.md"), "local\n");
await writeFile(join(outside, "outside.txt"), "outside\n");
await expect(listPathSuggestions(workspace, "../out")).resolves.toEqual([]);
if (!await trySymlink(outside, join(workspace, "link"))) return;
await expect(listPathSuggestions(workspace, "link/")).resolves.toEqual([]);
await expect(listPathSuggestions(workspace, "")).resolves.toEqual([{ path: "local.md", kind: "other" }]);
});
it("uses fzf to filter path suggestions after directory candidates are gathered", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "scripts"));
await mkdir(join(root, "src"));
await writeFile(join(root, "notes.md"), "notes\n");
const deps: FileSuggestionDependencies = {
fzf: (file, args, options) => {
expect(file).toBe("fzf");
expect(args).toEqual(["--filter", "sc", "--read0", "--print0"]);
expect(fzfRecords(options.input)).toEqual(["scripts/", "src/", "notes.md"]);
return Promise.resolve({ stdout: "../secret\0scripts/\0" });
},
};
await expect(listPathSuggestions(root, "sc", undefined, deps)).resolves.toEqual([
{ path: "scripts/", kind: "other" },
]);
});
it("falls back to path-prefix ordering when fzf fails", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "scripts"));
await mkdir(join(root, "src"));
await writeFile(join(root, "server.md"), "server\n");
const deps: FileSuggestionDependencies = {
fzf: () => Promise.reject(Object.assign(new Error("fzf failed"), { exitCode: 2 })),
};
await expect(listPathSuggestions(root, "s", undefined, deps)).resolves.toEqual([
{ path: "scripts/", kind: "other" },
{ path: "src/", kind: "other" },
{ path: "server.md", kind: "other" },
]);
});
it("suggests configured allowed roots without reading parent directories", async () => {
const root = await tempWorkspace();
const workspace = join(root, "workspace");
const external = join(root, "external-docs");
await mkdir(workspace);
await mkdir(external);
await expect(listPathSuggestions(workspace, external.slice(0, -4), { allowedPaths: [external] })).resolves.toEqual([
{ path: `${external}/`, kind: "other" },
]);
});
}); });
+348 -34
View File
@@ -1,19 +1,36 @@
import { execFile } from "node:child_process"; import { execFile, spawn } from "node:child_process";
import { readdir, stat } from "node:fs/promises"; import { readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path"; import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, sep, win32 } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { sanitizedGitEnv } from "../git/gitEnv.js"; import { sanitizedGitEnv } from "../git/gitEnv.js";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import type { ClientFileSuggestion } from "../types.js"; import type { ClientFileSuggestion } from "../types.js";
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, type PathAccessPolicy } from "./pathAccessPolicy.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const commandMaxBuffer = 1024 * 1024 * 8; const commandMaxBuffer = 1024 * 1024 * 8;
const maxFilesystemFallbackPaths = 20_000; const maxFilesystemFallbackPaths = 20_000;
const maxFileSuggestions = 80; const maxFileSuggestions = 80;
interface ExecFileOptions { interface CommandRunnerOptions {
cwd: string; cwd: string;
maxBuffer: number; maxBuffer: number;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
input?: string | Buffer;
}
type CommandRunner = (file: string, args: string[], options: CommandRunnerOptions) => Promise<{ stdout: string }>;
class CommandExitError extends Error {
readonly exitCode?: number;
constructor(file: string, code: number | null, stderr: string) {
const codeText = code === null ? "unknown" : String(code);
super(`${file} exited with code ${codeText}${stderr === "" ? "" : `: ${stderr}`}`);
this.name = "CommandExitError";
if (code !== null) this.exitCode = code;
}
} }
export type FileSuggestionScope = "tracked" | "all"; export type FileSuggestionScope = "tracked" | "all";
@@ -21,56 +38,221 @@ export type FileSuggestionScope = "tracked" | "all";
export interface FileSuggestionOptions { export interface FileSuggestionOptions {
kind?: ClientFileSuggestion["kind"] | undefined; kind?: ClientFileSuggestion["kind"] | undefined;
scope?: FileSuggestionScope | undefined; scope?: FileSuggestionScope | undefined;
pathAccess?: PiWebPathAccessConfig | undefined;
} }
export interface FileSuggestionDependencies { export interface FileSuggestionDependencies {
execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>; execFile?: CommandRunner;
fzf?: CommandRunner;
}
export function isAbsoluteishFileSuggestionQuery(query = ""): boolean {
return isAbsoluteishPath(fileQueryText(query));
} }
export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> { export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
const queryText = fileQueryText(query);
if (isAbsoluteishFileSuggestionQuery(query)) {
return (await listPathSuggestions(cwd, queryText, options.pathAccess, deps))
.filter((file) => options.kind === undefined || file.kind === options.kind)
.slice(0, maxFileSuggestions);
}
const normalizedQuery = normalizeFileQuery(query); const normalizedQuery = normalizeFileQuery(query);
const exec = deps.execFile ?? execFileAsync; const command = deps.execFile ?? runCommand;
const files = await listFilesForScope(cwd, options.scope, exec); const files = await listFilesForScope(cwd, options.scope, command);
return rankFileSuggestions( return (await rankFileSuggestionsWithOptionalFzf(
cwd,
files.filter((file) => options.kind === undefined || file.kind === options.kind), files.filter((file) => options.kind === undefined || file.kind === options.kind),
normalizedQuery, normalizedQuery,
).slice(0, maxFileSuggestions); fzfRunnerForDependencies(deps),
)).slice(0, maxFileSuggestions);
} }
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> { export async function listPathSuggestions(cwd: string, prefix = "", pathAccess?: PiWebPathAccessConfig, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
const normalizedPrefix = prefix.replace(/^@/, "").replace(/\\/g, "/"); const query = fileQueryText(prefix);
const fzf = fzfRunnerForDependencies(deps);
if (isAbsoluteishPath(query)) return listAllowedPathSuggestions(cwd, query, pathAccess, fzf);
const normalizedPrefix = query.replace(/\\/g, "/");
const directoryPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix : dirname(normalizedPrefix) === "." ? "" : `${dirname(normalizedPrefix)}/`; const directoryPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix : dirname(normalizedPrefix) === "." ? "" : `${dirname(normalizedPrefix)}/`;
const searchPrefix = normalizedPrefix.endsWith("/") ? "" : basename(normalizedPrefix); const searchPrefix = normalizedPrefix.endsWith("/") ? "" : basename(normalizedPrefix);
const entries = await readdir(join(cwd, directoryPrefix), { withFileTypes: true }); const candidates = await listDirectoryEntrySuggestions(cwd, directoryPrefix);
const suggestions: ClientFileSuggestion[] = []; return (await rankPathSuggestionsWithOptionalFzf(
for (const entry of entries) { cwd,
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue; candidates,
let isDirectory = entry.isDirectory(); searchPrefix,
if (!isDirectory && entry.isSymbolicLink()) { () => prefixPathSuggestions(candidates, searchPrefix),
try { fzf,
isDirectory = (await stat(join(cwd, directoryPrefix, entry.name))).isDirectory(); )).slice(0, maxFileSuggestions);
} catch {
isDirectory = false;
}
}
suggestions.push({ path: `${directoryPrefix}${entry.name}${isDirectory ? "/" : ""}`, kind: "other" });
}
return suggestions
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
.slice(0, 80);
} }
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> { async function listDirectoryEntrySuggestions(cwd: string, directoryPrefix: string): Promise<ClientFileSuggestion[]> {
const policy = await createPathAccessPolicy(cwd, undefined);
const resolved = await resolveWorkspaceSuggestionDirectory(policy, directoryPrefix);
if (resolved === undefined) return [];
const entries = await readdir(resolved.target, { withFileTypes: true });
const suggestions: ClientFileSuggestion[] = [];
for (const entry of entries.sort(compareDirectoryEntries)) {
const childPath = appendRequestPath(resolved.displayPath, entry.name);
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
if (isDirectory === undefined) continue;
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
}
return suggestions;
}
async function resolveWorkspaceSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
try {
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
return resolved.kind === "workspace" ? resolved : undefined;
} catch (error) {
if (isPathSuggestionMiss(error)) return undefined;
throw error;
}
}
async function listAllowedPathSuggestions(cwd: string, query: string, pathAccess: PiWebPathAccessConfig | undefined, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
const policy = await createPathAccessPolicy(cwd, pathAccess);
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
const rootCandidates = allowedRootSuggestionCandidates(policy, query);
const directoryCandidates = await listAllowedDirectoryEntryCandidates(policy, query);
return (await rankPathSuggestionsWithOptionalFzf(
cwd,
mergeSuggestions(rootCandidates, directoryCandidates),
query,
() => mergeSuggestions(allowedRootPrefixSuggestions(policy, query), prefixPathSuggestions(directoryCandidates, pathSuggestionPrefix(query).searchPrefix)).sort(compareFileSuggestions),
fzf,
)).slice(0, maxFileSuggestions);
}
function allowedRootPrefixSuggestions(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
return allowedRootSuggestionCandidates(policy, query).filter((suggestion) => pathStartsWith(suggestion.path, query));
}
function allowedRootSuggestionCandidates(policy: PathAccessPolicy, query: string): ClientFileSuggestion[] {
const suggestions: ClientFileSuggestion[] = [];
const seen = new Set<string>();
for (const root of policy.allowedRoots) {
for (const displayPath of allowedRootDisplayPaths(root.path, query)) {
const path = ensureTrailingPathSeparator(displayPath);
if (hasTrailingPathSeparator(query) && stripTrailingPathSeparators(path) === stripTrailingPathSeparators(query)) continue;
if (seen.has(path)) continue;
seen.add(path);
suggestions.push({ path, kind: "other" });
}
}
return suggestions;
}
function allowedRootDisplayPaths(rootPath: string, query: string): string[] {
if (query !== "~" && !query.startsWith("~/") && !query.startsWith("~\\")) return [rootPath];
const home = homedir();
const homeRelativePath = relative(home, rootPath);
if (!isInsideRelativePath(homeRelativePath)) return [rootPath];
const separator = query.startsWith("~\\") ? "\\" : "/";
const tildePath = homeRelativePath === "" ? "~" : `~${separator}${homeRelativePath.split(/[\\/]+/u).join(separator)}`;
return [tildePath, rootPath];
}
async function listAllowedDirectoryEntryCandidates(policy: PathAccessPolicy, query: string): Promise<ClientFileSuggestion[]> {
const { directoryPrefix } = pathSuggestionPrefix(query);
const resolved = await resolveSuggestionDirectory(policy, directoryPrefix);
if (resolved === undefined) return [];
const entries = await readdir(resolved.target, { withFileTypes: true });
const suggestions: ClientFileSuggestion[] = [];
for (const entry of entries.sort(compareDirectoryEntries)) {
const childPath = appendRequestPath(directoryPrefix, entry.name);
const isDirectory = await suggestionEntryIsDirectory(policy, childPath, entry);
if (isDirectory === undefined) continue;
suggestions.push({ path: `${childPath}${isDirectory ? "/" : ""}`, kind: "other" });
}
return suggestions;
}
async function resolveSuggestionDirectory(policy: PathAccessPolicy, directoryPrefix: string) {
try {
const resolved = await resolvePathAccessTarget(policy, directoryPrefix);
return resolved.kind === "allowed" ? resolved : undefined;
} catch (error) {
if (isPathSuggestionMiss(error)) return undefined;
throw error;
}
}
async function suggestionEntryIsDirectory(policy: PathAccessPolicy, childPath: string, entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): Promise<boolean | undefined> {
if (!entry.isSymbolicLink()) return entry.isDirectory();
try {
const resolved = await resolvePathAccessTarget(policy, childPath);
const result = await stat(resolved.target);
if (result.isDirectory()) return true;
if (result.isFile()) return false;
return undefined;
} catch (error) {
if (isPathSuggestionMiss(error)) return undefined;
throw error;
}
}
function pathSuggestionPrefix(query: string): { directoryPrefix: string; searchPrefix: string } {
if (query === "~" || hasTrailingPathSeparator(query)) return { directoryPrefix: query, searchPrefix: "" };
const directory = dirname(query);
return { directoryPrefix: directory === "." ? "" : directory, searchPrefix: basename(query) };
}
function appendRequestPath(base: string, name: string): string {
if (base === "") return name;
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
if (hasTrailingPathSeparator(base)) return `${base}${name}`;
return `${base}/${name}`;
}
function pathStartsWith(path: string, query: string): boolean {
return path.toLowerCase().startsWith(query.toLowerCase());
}
function ensureTrailingPathSeparator(path: string): string {
return hasTrailingPathSeparator(path) ? path : `${path}/`;
}
function hasTrailingPathSeparator(path: string): boolean {
return path.endsWith("/") || path.endsWith("\\");
}
function stripTrailingPathSeparators(path: string): string {
let end = path.length;
while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
return path.slice(0, end);
}
function isInsideRelativePath(path: string): boolean {
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
}
function isPathSuggestionMiss(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return error.message === "Path is outside allowed paths"
|| error.message === "Path does not exist"
|| error.message === "Path traversal is not allowed"
|| error.message === "Path escapes workspace"
|| error.message.startsWith("Path is not absolute:");
}
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
if (scope === "all") return listAllFiles(cwd, exec); if (scope === "all") return listAllFiles(cwd, exec);
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true)); if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false)); return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
} }
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> { async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked"); return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked");
} }
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> { async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
const [tracked, untracked] = await Promise.all([ const [tracked, untracked] = await Promise.all([
git(cwd, ["ls-files", "-z"], exec), git(cwd, ["ls-files", "-z"], exec),
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec), git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
@@ -81,7 +263,7 @@ async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
]; ];
} }
async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> { async function listAllFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
const [gitFiles, plainFiles] = await Promise.all([ const [gitFiles, plainFiles] = await Promise.all([
listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []), listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []),
listPlainFiles(cwd, exec, true), listPlainFiles(cwd, exec, true),
@@ -89,7 +271,7 @@ async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDepende
return mergeSuggestions(gitFiles, plainFiles); return mergeSuggestions(gitFiles, plainFiles);
} }
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> { async function listPlainFiles(cwd: string, exec: CommandRunner, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
try { try {
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"]; const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"];
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer }); const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
@@ -138,13 +320,75 @@ async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink:
} }
} }
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> { async function git(cwd: string, args: string[], exec: CommandRunner): Promise<string> {
const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer }); const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
return stdout; return stdout;
} }
function normalizeFileQuery(query: string): string { function normalizeFileQuery(query: string): string {
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "").toLowerCase(); return fileQueryText(query).toLowerCase();
}
function fileQueryText(query: string): string {
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "");
}
function fzfRunnerForDependencies(deps: FileSuggestionDependencies): CommandRunner | undefined {
return deps.fzf ?? (deps.execFile === undefined ? runCommand : undefined);
}
async function rankFileSuggestionsWithOptionalFzf(cwd: string, files: ClientFileSuggestion[], normalizedQuery: string, fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
return rankSuggestionsWithOptionalFzf(cwd, files, normalizedQuery, () => rankFileSuggestions(files, normalizedQuery), fzf);
}
async function rankPathSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
return rankSuggestionsWithOptionalFzf(cwd, candidates, query, fallback, fzf);
}
async function rankSuggestionsWithOptionalFzf(cwd: string, candidates: ClientFileSuggestion[], query: string, fallback: () => ClientFileSuggestion[], fzf: CommandRunner | undefined): Promise<ClientFileSuggestion[]> {
if (fzf === undefined || query === "" || candidates.length === 0) return fallback();
try {
return await fzfFilterSuggestions(cwd, candidates, query, fzf);
} catch {
return fallback();
}
}
async function fzfFilterSuggestions(cwd: string, candidates: ClientFileSuggestion[], query: string, fzf: CommandRunner): Promise<ClientFileSuggestion[]> {
const byPath = new Map(candidates.map((suggestion) => [suggestion.path, suggestion]));
const { stdout } = await runFzf(cwd, [...byPath.keys()], query, fzf);
const suggestions: ClientFileSuggestion[] = [];
const seen = new Set<string>();
for (const path of nulRecords(stdout)) {
const suggestion = byPath.get(path);
if (suggestion === undefined || seen.has(suggestion.path)) continue;
seen.add(suggestion.path);
suggestions.push(suggestion);
}
if (suggestions.length === 0 && stdout !== "") throw new Error("fzf returned paths outside the gathered suggestions");
return suggestions;
}
async function runFzf(cwd: string, candidates: string[], query: string, fzf: CommandRunner): Promise<{ stdout: string }> {
try {
return await fzf("fzf", ["--filter", query, "--read0", "--print0"], { cwd, maxBuffer: commandMaxBuffer, input: `${candidates.join("\0")}\0` });
} catch (error) {
if (errorExitCode(error) === 1) return { stdout: "" };
throw error;
}
}
function prefixPathSuggestions(candidates: ClientFileSuggestion[], searchPrefix: string): ClientFileSuggestion[] {
const normalizedSearchPrefix = searchPrefix.toLowerCase();
return candidates
.filter((suggestion) => pathSuggestionName(suggestion.path).toLowerCase().startsWith(normalizedSearchPrefix))
.sort(compareFileSuggestions);
}
function pathSuggestionName(path: string): string {
const stripped = stripTrailingPathSeparators(path);
return stripped.split(/[\\/]+/u).filter(Boolean).at(-1) ?? stripped;
} }
function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] { function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] {
@@ -197,6 +441,10 @@ function compareFileSuggestions(a: ClientFileSuggestion, b: ClientFileSuggestion
return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path); return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path);
} }
function compareDirectoryEntries(a: { isDirectory(): boolean; name: string }, b: { isDirectory(): boolean; name: string }): number {
return Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name);
}
function kindRank(kind: ClientFileSuggestion["kind"]): number { function kindRank(kind: ClientFileSuggestion["kind"]): number {
switch (kind) { switch (kind) {
case "tracked": return 0; case "tracked": return 0;
@@ -209,6 +457,72 @@ function pathDepth(path: string): number {
return path.split("/").filter(Boolean).length; return path.split("/").filter(Boolean).length;
} }
async function runCommand(file: string, args: string[], options: CommandRunnerOptions): Promise<{ stdout: string }> {
const { input, ...execOptions } = options;
if (input === undefined) return execFileAsync(file, args, execOptions);
return runCommandWithInput(file, args, { ...execOptions, input });
}
async function runCommandWithInput(file: string, args: string[], options: CommandRunnerOptions & { input: string | Buffer }): Promise<{ stdout: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(file, args, {
cwd: options.cwd,
...(options.env === undefined ? {} : { env: options.env }),
stdio: ["pipe", "pipe", "pipe"],
});
let settled = false;
let stdout = "";
let stderr = "";
let stdoutBytes = 0;
let stderrBytes = 0;
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
child.stdout.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes > options.maxBuffer) {
child.kill();
rejectOnce(new Error(`${file} stdout exceeded maxBuffer`));
return;
}
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes > options.maxBuffer) {
child.kill();
rejectOnce(new Error(`${file} stderr exceeded maxBuffer`));
return;
}
stderr += chunk.toString("utf8");
});
child.on("error", rejectOnce);
child.on("close", (code) => {
if (settled) return;
settled = true;
if (code === 0) {
resolve({ stdout });
return;
}
reject(new CommandExitError(file, code, stderr));
});
child.stdin.on("error", () => undefined);
child.stdin.end(options.input);
});
}
function errorExitCode(error: unknown): number | undefined {
if (error instanceof CommandExitError) return error.exitCode;
if (!(error instanceof Error)) return undefined;
if ("exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
if ("code" in error && typeof error.code === "number") return error.code;
return undefined;
}
function textLines(text: string): string[] { function textLines(text: string): string[] {
return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== ""); return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== "");
} }
@@ -55,6 +55,22 @@ describe("listWorkspaceTree", () => {
expect(tree.entries[0]).toMatchObject({ name: "main.ts", path: "src/client/main.ts", type: "file" }); expect(tree.entries[0]).toMatchObject({ name: "main.ts", path: "src/client/main.ts", type: "file" });
}); });
it("lists allowed absolute directories outside the workspace", async () => {
const root = await tempWorkspace();
const external = await tempWorkspace();
await mkdir(join(external, "docs"));
await writeFile(join(external, "sdk.ts"), "export {};\n");
const tree = await listWorkspaceTree(root, external, { allowedPaths: [external] });
expect(tree.path).toBe(external);
expect(tree.entries.map((entry) => [entry.name, entry.path, entry.type])).toEqual([
["docs", join(external, "docs"), "directory"],
["sdk.ts", join(external, "sdk.ts"), "file"],
]);
await expect(listWorkspaceTree(root, external)).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects non-directory targets and unsafe paths", async () => { it("rejects non-directory targets and unsafe paths", async () => {
const root = await tempWorkspace(); const root = await tempWorkspace();
await writeFile(join(root, "file.txt"), "content"); await writeFile(join(root, "file.txt"), "content");
+15 -8
View File
@@ -1,12 +1,12 @@
import { lstat, readdir } from "node:fs/promises"; import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path"; import { isAbsolute, join, win32 } from "node:path";
import type { FileTreeEntry, FileTreeResponse } from "../../shared/apiTypes.js"; import type { FileTreeEntry, FileTreeResponse, PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import { resolveInsideWorkspace } from "./pathSafety.js"; import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
const MAX_ENTRIES = 1000; const MAX_ENTRIES = 1000;
export async function listWorkspaceTree(rootPath: string, path: string | undefined): Promise<FileTreeResponse> { export async function listWorkspaceTree(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<FileTreeResponse> {
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path); const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
const stat = await lstat(target); const stat = await lstat(target);
if (!stat.isDirectory()) throw new Error("Path is not a directory"); if (!stat.isDirectory()) throw new Error("Path is not a directory");
@@ -18,11 +18,18 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
const selected = sorted.slice(0, MAX_ENTRIES); const selected = sorted.slice(0, MAX_ENTRIES);
const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => { const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => {
const absolute = join(target, entry.name); const absolute = join(target, entry.name);
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`; const childPath = appendRequestPath(displayPath, entry.name);
const childStat = await lstat(absolute); const childStat = await lstat(absolute);
const type: FileTreeEntry["type"] = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file"; const type: FileTreeEntry["type"] = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file";
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() }; return { name: entry.name, path: childPath, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
})); }));
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length }; return { path: displayPath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
}
function appendRequestPath(base: string, name: string): string {
if (base === "") return name;
if (isAbsolute(base) || win32.isAbsolute(base)) return join(base, name);
if (base.endsWith("/") || base.endsWith("\\")) return `${base}${name}`;
return `${base}/${name}`;
} }
+6 -5
View File
@@ -1,8 +1,9 @@
import { createReadStream, type ReadStream } from "node:fs"; import { createReadStream, type ReadStream } from "node:fs";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { extname } from "node:path"; import { extname } from "node:path";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../shared/workspaceFiles.js"; import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../shared/workspaceFiles.js";
import { resolveInsideWorkspace } from "./pathSafety.js"; import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
const IMAGE_MIME_TYPES: Record<string, string | undefined> = { const IMAGE_MIME_TYPES: Record<string, string | undefined> = {
".avif": "image/avif", ".avif": "image/avif",
@@ -28,16 +29,16 @@ export function imageMimeTypeForPath(path: string): string | undefined {
return IMAGE_MIME_TYPES[extname(path).toLowerCase()]; return IMAGE_MIME_TYPES[extname(path).toLowerCase()];
} }
export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined): Promise<WorkspaceImagePreview> { export async function readWorkspaceImagePreview(rootPath: string, path: string | undefined, pathAccess?: PiWebPathAccessConfig): Promise<WorkspaceImagePreview> {
if (path === undefined || path === "") throw new Error("path query parameter is required"); if (path === undefined || path === "") throw new Error("path query parameter is required");
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path); const { target, displayPath } = await resolveWorkspacePathAccessTarget(rootPath, path, pathAccess);
const s = await stat(target); const s = await stat(target);
if (!s.isFile()) throw new Error("Path is not a file"); if (!s.isFile()) throw new Error("Path is not a file");
const mimeType = imageMimeTypeForPath(relativePath); const mimeType = imageMimeTypeForPath(displayPath);
if (mimeType === undefined) throw new Error("Image preview is not supported for this file type"); if (mimeType === undefined) throw new Error("Image preview is not supported for this file type");
if (s.size > MAX_IMAGE_PREVIEW_BYTES) throw new Error(`Image is too large to preview (limit ${MAX_IMAGE_PREVIEW_LABEL})`); if (s.size > MAX_IMAGE_PREVIEW_BYTES) throw new Error(`Image is too large to preview (limit ${MAX_IMAGE_PREVIEW_LABEL})`);
return { return {
path: relativePath, path: displayPath,
mimeType, mimeType,
size: s.size, size: s.size,
modifiedAt: s.mtime.toISOString(), modifiedAt: s.mtime.toISOString(),
@@ -0,0 +1,139 @@
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createPathAccessPolicy, isAbsoluteishPath, resolvePathAccessTarget, resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
const roots: string[] = [];
async function tempRoot(prefix = "pi-web-path-access-"): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
roots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("path access policy", () => {
it("keeps relative requests workspace-local and identifies absolute-ish paths", async () => {
const workspace = await tempRoot();
await mkdir(join(workspace, "src"));
await writeFile(join(workspace, "src", "main.ts"), "export {};\n");
const policy = await createPathAccessPolicy(workspace, undefined);
await expect(resolvePathAccessTarget(policy, "./src//main.ts")).resolves.toMatchObject({
kind: "workspace",
root: await realpath(workspace),
target: await realpath(join(workspace, "src", "main.ts")),
displayPath: "src/main.ts",
});
expect(isAbsoluteishPath("src/main.ts")).toBe(false);
expect(isAbsoluteishPath("/tmp/file.txt")).toBe(true);
expect(isAbsoluteishPath("~/SDKs/readme.md")).toBe(true);
expect(isAbsoluteishPath("C:\\Users\\dev\\file.txt")).toBe(true);
expect(isAbsoluteishPath("\\\\server\\share\\file.txt")).toBe(true);
await expect(resolvePathAccessTarget(policy, join(workspace, "src", "main.ts"))).rejects.toThrow("Absolute paths are not allowed");
});
it("expands and canonicalizes allowed roots before resolving absolute targets", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const home = join(root, "home");
const sdk = join(home, "SDKs");
await mkdir(workspace);
await mkdir(sdk, { recursive: true });
await writeFile(join(sdk, "readme.md"), "sdk docs\n");
const policy = await createPathAccessPolicy(workspace, { allowedPaths: ["~/SDKs"] }, { homeDir: home });
expect(policy.allowedRoots).toEqual([{ source: "~/SDKs", path: sdk, realPath: await realpath(sdk) }]);
await expect(resolvePathAccessTarget(policy, "~/SDKs/readme.md", { homeDir: home })).resolves.toMatchObject({
kind: "allowed",
root: await realpath(sdk),
target: await realpath(join(sdk, "readme.md")),
displayPath: join(sdk, "readme.md"),
});
});
it("validates configured roots as existing directories", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const fileRoot = join(root, "not-a-directory.txt");
await mkdir(workspace);
await writeFile(fileRoot, "not a directory");
await expect(createPathAccessPolicy(workspace, { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
await expect(createPathAccessPolicy(workspace, { allowedPaths: [fileRoot] })).rejects.toThrow("must be a directory");
await expect(createPathAccessPolicy(workspace, { allowedPaths: ["relative/root"] })).rejects.toThrow("Allowed path must be absolute or start with ~");
});
it("does not validate stale allowed roots for workspace-relative requests", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
await mkdir(workspace);
await writeFile(join(workspace, "local.txt"), "local\n");
await expect(resolveWorkspacePathAccessTarget(workspace, "local.txt", { allowedPaths: [join(root, "missing")] })).resolves.toMatchObject({
kind: "workspace",
target: await realpath(join(workspace, "local.txt")),
displayPath: "local.txt",
});
await expect(resolveWorkspacePathAccessTarget(workspace, join(workspace, "local.txt"), { allowedPaths: [join(root, "missing")] })).rejects.toThrow("does not exist");
});
it("denies absolute targets outside allowed roots and through symlink escapes", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const allowed = join(root, "allowed");
const secret = join(root, "secret");
await mkdir(workspace);
await mkdir(allowed);
await mkdir(secret);
await writeFile(join(secret, "token.txt"), "secret\n");
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [allowed] });
await expect(resolvePathAccessTarget(policy, join(secret, "token.txt"))).rejects.toThrow("Path is outside allowed paths");
if (await trySymlink(secret, join(allowed, "escape"))) {
await expect(resolvePathAccessTarget(policy, join(allowed, "escape", "token.txt"))).rejects.toThrow("Path is outside allowed paths");
}
});
it("allows roots configured through symlinks by checking canonical paths", async () => {
const root = await tempRoot();
const workspace = join(root, "workspace");
const realAllowed = join(root, "real-allowed");
const linkedAllowed = join(root, "linked-allowed");
await mkdir(workspace);
await mkdir(realAllowed);
await writeFile(join(realAllowed, "data.txt"), "allowed\n");
if (!await trySymlink(realAllowed, linkedAllowed)) return;
const policy = await createPathAccessPolicy(workspace, { allowedPaths: [linkedAllowed] });
expect(policy.allowedRoots).toEqual([{ source: linkedAllowed, path: linkedAllowed, realPath: await realpath(realAllowed) }]);
await expect(resolvePathAccessTarget(policy, join(linkedAllowed, "data.txt"))).resolves.toMatchObject({
kind: "allowed",
root: await realpath(realAllowed),
target: await realpath(join(realAllowed, "data.txt")),
displayPath: join(linkedAllowed, "data.txt"),
});
});
});
async function trySymlink(target: string, path: string): Promise<boolean> {
try {
await symlink(target, path, "dir");
return true;
} catch (error) {
if (isNodeErrorWithCode(error, "EPERM") || isNodeErrorWithCode(error, "EACCES")) return false;
throw error;
}
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+122
View File
@@ -0,0 +1,122 @@
import { realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, relative, resolve, sep, win32 } from "node:path";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
import { normalizeRelativePath } from "./pathSafety.js";
export interface AllowedPathRoot {
/** Raw config value for diagnostics. */
source: string;
/** Host-absolute path after expanding ~ and normalizing syntax. */
path: string;
/** Canonical directory root used for containment checks. */
realPath: string;
}
export interface PathAccessPolicy {
workspaceRoot: string;
allowedRoots: AllowedPathRoot[];
}
export type PathAccessTargetKind = "workspace" | "allowed";
export interface ResolvedPathAccessTarget {
kind: PathAccessTargetKind;
/** Canonical root that granted access: workspace root or allowed root. */
root: string;
/** Canonical existing target path. */
target: string;
/** Requestable path returned to clients and used to build child paths. */
displayPath: string;
}
export interface PathAccessPolicyOptions {
homeDir?: string;
}
export async function createPathAccessPolicy(workspaceRootPath: string, pathAccess: PiWebPathAccessConfig | undefined, options: PathAccessPolicyOptions = {}): Promise<PathAccessPolicy> {
return {
workspaceRoot: await canonicalDirectory(workspaceRootPath, "Workspace path"),
allowedRoots: await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options),
};
}
export async function resolveWorkspacePathAccessTarget(rootPath: string, requestedPath: string | undefined, pathAccess?: PiWebPathAccessConfig, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
const request = requestedPath ?? "";
const workspaceRoot = await canonicalDirectory(rootPath, "Workspace path");
const allowedRoots = isAbsoluteishPath(request) ? await resolveAllowedRoots(pathAccess?.allowedPaths ?? [], options) : [];
return resolvePathAccessTarget({ workspaceRoot, allowedRoots }, requestedPath, options);
}
export async function resolvePathAccessTarget(policy: PathAccessPolicy, requestedPath: string | undefined, options: PathAccessPolicyOptions = {}): Promise<ResolvedPathAccessTarget> {
const request = requestedPath ?? "";
if (isAbsoluteishPath(request)) return resolveAllowedTarget(policy, request, options);
const displayPath = normalizeRelativePath(request);
const target = await canonicalExistingPath(resolve(policy.workspaceRoot, displayPath));
ensureInside(policy.workspaceRoot, target, "Path escapes workspace");
return { kind: "workspace", root: policy.workspaceRoot, target, displayPath };
}
export function isAbsoluteishPath(path: string): boolean {
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || isAbsolute(path) || win32.isAbsolute(path);
}
async function resolveAllowedRoots(allowedPaths: readonly string[], options: PathAccessPolicyOptions): Promise<AllowedPathRoot[]> {
const roots: AllowedPathRoot[] = [];
for (const source of allowedPaths) {
const expanded = expandAbsoluteishPath(source, options, `Allowed path must be absolute or start with ~: ${source}`);
const realPath = await canonicalDirectory(expanded, `Allowed path ${source}`);
if (roots.some((root) => root.realPath === realPath)) continue;
roots.push({ source, path: expanded, realPath });
}
return roots;
}
async function resolveAllowedTarget(policy: PathAccessPolicy, request: string, options: PathAccessPolicyOptions): Promise<ResolvedPathAccessTarget> {
if (policy.allowedRoots.length === 0) throw new Error("Absolute paths are not allowed");
const displayPath = expandAbsoluteishPath(request, options, `Path is not absolute: ${request}`);
const target = await canonicalExistingPath(displayPath);
const root = policy.allowedRoots.find((allowedRoot) => isInsideOrSame(allowedRoot.realPath, target));
if (root === undefined) throw new Error("Path is outside allowed paths");
return { kind: "allowed", root: root.realPath, target, displayPath };
}
function expandAbsoluteishPath(path: string, options: PathAccessPolicyOptions, relativeMessage: string): string {
const home = options.homeDir ?? homedir();
if (path === "~") return home;
if (path.startsWith("~/") || path.startsWith("~\\")) return resolve(home, path.slice(2));
if (isAbsolute(path)) return resolve(path);
if (win32.isAbsolute(path)) throw new Error(`Absolute path is not valid on this host: ${path}`);
throw new Error(relativeMessage);
}
async function canonicalDirectory(path: string, label: string): Promise<string> {
const canonical = await canonicalExistingPath(path, `${label} does not exist`);
const result = await stat(canonical);
if (!result.isDirectory()) throw new Error(`${label} must be a directory`);
return canonical;
}
async function canonicalExistingPath(path: string, missingMessage = "Path does not exist"): Promise<string> {
try {
return await realpath(path);
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error(missingMessage, { cause: error });
throw error;
}
}
function ensureInside(root: string, target: string, message: string): void {
if (!isInsideOrSame(root, target)) throw new Error(message);
}
function isInsideOrSame(root: string, target: string): boolean {
const rel = relative(root, target);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
+2 -2
View File
@@ -30,11 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string {
return parts.join("/"); return parts.join("/");
} }
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error && error.code === code; return typeof error === "object" && error !== null && "code" in error && error.code === code;
} }
function ensureInside(root: string, target: string): void { export function ensureInside(root: string, target: string): void {
const rel = relative(root, target); const rel = relative(root, target);
if (rel === "") return; if (rel === "") return;
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Path escapes workspace"); if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Path escapes workspace");
@@ -0,0 +1,88 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadEffectiveProjectPathAccess, loadEffectiveProjectUploadsConfig, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
let tempDir: string;
let projectPath: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-project-config-test-"));
projectPath = join(tempDir, "project");
await mkdir(projectPath, { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("project PI WEB config", () => {
it("returns an empty config when the project-local config is absent", async () => {
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
exists: false,
config: {},
});
});
it("loads project-local path access and upload config", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } });
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
exists: true,
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } },
});
});
it("rejects unsupported project config versions", async () => {
await writeProjectConfig({ version: 2 });
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB project config version must be 1");
});
it("reuses PI WEB path access schema validation", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: [""] } });
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
});
it("reuses PI WEB upload schema validation", async () => {
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "../outside" } });
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config uploads.defaultFolder must not contain path traversal");
});
it("merges global and project path access in order", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
await expect(loadEffectiveProjectPathAccess(projectPath, { pathAccess: { allowedPaths: ["/global-sdk", "/shared"] } })).resolves.toEqual({
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"],
});
});
it("lets project upload defaults override global upload defaults", async () => {
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "project-uploads" } });
await expect(loadEffectiveProjectUploadsConfig(projectPath, { uploads: { defaultFolder: "global-uploads" } })).resolves.toEqual({
defaultFolder: "project-uploads",
});
});
});
describe("mergePathAccessConfigs", () => {
it("returns undefined when no roots are configured", () => {
expect(mergePathAccessConfigs(undefined, {})).toBeUndefined();
});
it("deduplicates configured roots", () => {
expect(mergePathAccessConfigs({ allowedPaths: ["/a", "/b"] }, { allowedPaths: ["/b", "/c"] })).toEqual({ allowedPaths: ["/a", "/b", "/c"] });
});
});
async function writeProjectConfig(value: unknown): Promise<void> {
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
@@ -0,0 +1,78 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { effectiveUploadsConfig, parsePathAccessConfig, parseUploadsConfig, type PiWebConfig } from "../../config.js";
import type { PiWebPathAccessConfig, PiWebUploadsConfig } from "../../shared/apiTypes.js";
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
export interface ProjectPiWebConfig {
version?: 1;
pathAccess?: PiWebPathAccessConfig;
uploads?: PiWebUploadsConfig;
}
export interface LoadedProjectPiWebConfig {
path: string;
exists: boolean;
config: ProjectPiWebConfig;
}
export async function loadProjectPiWebConfig(projectPath: string): Promise<LoadedProjectPiWebConfig> {
const path = join(projectPath, PROJECT_PI_WEB_CONFIG_PATH);
try {
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRecord(parsed)) throw new Error(`PI WEB project config must be a JSON object: ${path}`);
return { path, exists: true, config: parseProjectPiWebConfig(parsed, path) };
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) return { path, exists: false, config: {} };
throw error;
}
}
export async function loadEffectiveProjectPathAccess(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebPathAccessConfig | undefined> {
const projectConfig = await loadProjectPiWebConfig(projectPath);
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess);
}
export async function loadEffectiveProjectUploadsConfig(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebUploadsConfig> {
const projectConfig = await loadProjectPiWebConfig(projectPath);
return effectiveUploadsConfig({ uploads: { ...(globalConfig.uploads ?? {}), ...(projectConfig.config.uploads ?? {}) } });
}
export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
return allowedPaths.length === 0 ? undefined : { allowedPaths };
}
function parseProjectPiWebConfig(value: Record<string, unknown>, path: string): ProjectPiWebConfig {
const version = value["version"];
return {
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
};
}
function parseProjectConfigVersion(value: unknown, path: string): 1 {
if (value !== 1) throw new Error(`PI WEB project config version must be 1: ${path}`);
return 1;
}
function dedupe(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+48
View File
@@ -5,6 +5,7 @@ export const PI_WEB_CAPABILITIES = {
sessionsDeleteArchived: "sessions.deleteArchived", sessionsDeleteArchived: "sessions.deleteArchived",
sessionsReload: "sessions.reload", sessionsReload: "sessions.reload",
promptAttachments: "prompt.attachments", promptAttachments: "prompt.attachments",
workspaceFileSuggestions: "workspace.fileSuggestions",
} as const; } as const;
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES]; export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
@@ -51,12 +52,24 @@ export interface PiWebPluginConfig {
[key: string]: unknown; [key: string]: unknown;
} }
export interface PiWebPathAccessConfig {
allowedPaths?: string[];
}
export interface PiWebUploadsConfig {
defaultFolder?: string;
}
export interface PiWebConfigValues { export interface PiWebConfigValues {
host?: string; host?: string;
port?: number; port?: number;
allowedHosts?: string[] | true; allowedHosts?: string[] | true;
shortcuts?: PiWebShortcutConfig; shortcuts?: PiWebShortcutConfig;
plugins?: PiWebPluginConfigMap; plugins?: PiWebPluginConfigMap;
/** External filesystem roots PI WEB may expose outside a workspace. */
pathAccess?: PiWebPathAccessConfig;
/** Workspace-relative defaults for manual file uploads. */
uploads?: PiWebUploadsConfig;
/** Maximum accepted HTTP request body size in bytes (uploads/attachments). */ /** Maximum accepted HTTP request body size in bytes (uploads/attachments). */
maxUploadBytes?: number; maxUploadBytes?: number;
/** When true, LLMs can start new sessions via the spawn_session tool. */ /** When true, LLMs can start new sessions via the spawn_session tool. */
@@ -108,6 +121,10 @@ export interface Project {
createdAt: string; createdAt: string;
} }
export interface WorkspaceEffectiveConfig {
uploads?: PiWebUploadsConfig;
}
export interface Workspace { export interface Workspace {
id: string; id: string;
projectId: string; projectId: string;
@@ -117,6 +134,8 @@ export interface Workspace {
isMain: boolean; isMain: boolean;
isGitRepo: boolean; isGitRepo: boolean;
isGitWorktree: boolean; isGitWorktree: boolean;
/** Workspace-effective project/global settings needed by workspace UI features. */
effectiveConfig?: WorkspaceEffectiveConfig;
} }
export interface SessionRef { export interface SessionRef {
@@ -308,6 +327,35 @@ export interface FileContentResponse {
binary: boolean; binary: boolean;
} }
export interface WriteWorkspaceFileOptions {
createDirs?: boolean; // default: true — mkdir -p equivalent
overwrite?: boolean; // default: true — throw if false and file exists
}
export interface WriteWorkspaceFileResponse {
path: string;
size: number;
modifiedAt: string;
created: boolean; // true if file was created, false if overwritten
}
export interface DeleteWorkspaceFileResponse {
path: string;
existed: boolean; // true if file existed and was deleted, false if file did not exist
}
export interface MoveWorkspaceFileOptions {
createDirs?: boolean; // default: true — mkdir -p equivalent for target parent directory
overwrite?: boolean; // default: false — throw if target exists (safer default than writeFile)
}
export interface MoveWorkspaceFileResponse {
fromPath: string;
toPath: string;
size: number;
modifiedAt: string;
}
export type GitFileState = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted"; export type GitFileState = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted";
export interface GitStatusFile { export interface GitStatusFile {

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