Merge branch 'main' into chat-bidi-rtl-support

This commit is contained in:
Federico Jaramillo Martinez
2026-06-26 00:16:01 +02:00
committed by GitHub
83 changed files with 6594 additions and 705 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches.
@@ -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
@@ -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
---
Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line).
+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.
+6
View File
@@ -1,5 +1,11 @@
# @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 ## 1.202606.5
### Patch Changes ### Patch Changes
+100 -312
View File
@@ -1,170 +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 desktop screenshot showing an agent-created pi-web.dev screenshot selected in the file preview](docs/assets/pi-web-desktop.png) Agentic development works better when the work environment is persistent.
<p align="center"> PI WEB lets you:
<img src="docs/assets/pi-web-tablet.png" alt="PI WEB tablet screenshot" width="58%" />
<img src="docs/assets/pi-web-mobile.png" alt="PI WEB mobile chat screenshot" width="28%" />
</p>
With PI WEB you can: - keep Pi Coding Agent sessions alive after browser disconnects;
- run agents inside real repositories and git worktrees;
- supervise multiple sessions in parallel;
- switch between laptop, phone, tablet, and desktop;
- use a server, workstation, or remote dev box as your agent runtime;
- manage projects, workspaces, files, terminals, sessions, and remote machines from one web UI.
- launch and supervise multiple coding-agent sessions in parallel; Your browser is the control surface. The work stays where it can keep running.
- keep sessions running when your browser disconnects or the UI restarts;
- organize agent work by project, workspace, branch, experiment, or review;
- use git worktrees to isolate concurrent features and fixes;
- chat with Pi Coding Agent through a realtime web UI;
- move fluidly between laptop, phone, tablet, and desktop without moving the development environment;
- turn any server, desktop, or remote dev box into an agent-first development hub.
## Why use PI WEB? ## Quick start
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. Requirements:
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. - Node.js 22 or newer
- npm
- Pi Coding Agent configured for your user
- git and the development tools your agents need
### Is PI WEB a Pi web UI? Install and start PI WEB as per-user services:
Yes. PI WEB is a Pi web UI for running and supervising Pi Coding Agent sessions from a browser. Unlike simple session viewers, PI WEB is built around persistent server-side workspaces, long-running session daemons, git worktrees, remote machines, and multi-device supervision.
## Core model
PI WEB organizes work into four levels:
```text
Machine a local or remote PI WEB runtime endpoint
Project a folder on that machine
Workspace a git worktree, or the project folder for non-git projects
Session a chat with Pi Coding Agent running inside a workspace
```
This maps naturally to real development work:
- select the local machine or another registered PI WEB runtime;
- add a project once on the selected machine;
- use worktrees to separate branches, features, experiments, and reviews;
- start one or more agent sessions inside each workspace;
- leave sessions running even when the browser disconnects or the UI restarts.
## Features
- Add and list local or remote PI WEB machines from the action palette.
- Proxy remote projects, workspaces, files, git state, sessions, and terminals through the currently opened PI WEB server.
- Add and list server-side projects.
- Discover git worktrees automatically with `git worktree list --porcelain`.
- Support non-git folders as single-workspace projects.
- Start, resume, archive, and restore Pi sessions per workspace.
- Chat with Pi Coding Agent through realtime WebSocket events.
- Keep active agent runtimes alive across browser disconnects and web/API restarts.
- Explicitly stop or abort active session work.
- View live session status: streaming, compaction, bash activity, token usage, cost, model, and context usage.
- Send prompts, shell input, and supported commands through the Pi SDK path.
- Reuse your existing Pi auth and model configuration from `~/.pi/agent`.
- Extend the UI with trusted plugins that add actions, workspace panels, and workspace-label metadata. See [Plugin API](docs/plugins.md) for LLM-friendly plugin-building docs.
## Architecture
PI WEB uses a split-process architecture so agent runtimes are not owned by the browser-facing dev server. Under the hood, it acts as a browser-based control plane for sessions, workspaces, files, terminals, and trusted remote machines.
```text
Browser UI
Fastify Web/API process
│ HTTP + WebSocket proxy
Session daemon
Pi Coding Agent SDK
```
### Session daemon
The session daemon owns active Pi session runtimes. It is intended to be long-lived so sessions can survive browser disconnects and web/API restarts.
### Web/API/UI server
The web process serves the API and browser UI. In development it can autoreload freely while active sessions continue running in the daemon.
## State model
PI WEB keeps its own state intentionally small:
- Machines: `~/.pi-web/machines.json` stores only opt-in remote machine records; the local machine is synthesized.
- Projects: `~/.pi-web/projects.json`
- Workspaces: discovered from git worktrees, not stored
- Sessions and chat history: Pi's default JSONL session storage on the selected machine
- Active session runtimes and WebSockets: memory in each selected machine's session daemon
## Machine federation
The Machines section lets one PI WEB instance act as a gateway to other PI WEB runtimes. Register a remote machine from **Actions → Add Machine** with the remote PI WEB base URL, for example a URL reachable over NetBird, Tailscale, WireGuard, an SSH tunnel, or a trusted reverse proxy. The browser continues talking to the local PI WEB origin; project, workspace, file, git, session, activity, and terminal HTTP/WebSocket traffic is proxied server-to-server. See the [Fleet guide](https://pi-web.dev/machines) for setup, trust model, and troubleshooting details.
Remote model-provider credentials and OAuth state stay on the target machine. API-key provider configuration can be proxied, but OAuth login should be completed by opening the remote PI WEB directly. Register remote machines only when you trust the endpoint and the network path: adding a machine gives this PI WEB server permission to contact that URL with the optional bearer token you configured.
## Plugins
PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed.
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module` plus optional `machineSpecific` metadata, and a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
A useful prompt for AI agents:
```text
Build a PI WEB plugin for this project. Goal: <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
@@ -176,52 +64,84 @@ 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: ## Core model
```bash PI WEB organizes work like this:
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh
```
PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi:
```bash
pi install npm:@jmfederico/pi-web
```
Then in Pi:
```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
@@ -229,159 +149,27 @@ npm run dev:web
npm run dev:client npm run dev:client
``` ```
Or install the split development setup as native per-user services from the checkout: Validate changes with:
```bash
pi-web install --dev
```
`pi-web install --dev` writes the session daemon plus a UI development service using the native user-service backend. `pi-web uninstall` removes both production and development service files; no uninstall flags are needed.
`dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions.
## Production-style run from a checkout
```bash
npm run build
npm run start:sessiond
PI_WEB_PORT=8504 npm start
```
## Packaging and publishing
```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.78.0 <1`) and a development dependency for local builds. This keeps published installs flexible: npm 7+ installs the peer automatically, and users can upgrade the Pi package within the compatible range without PI WEB pinning a separate copy. 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
## Configuration - [Website](https://pi-web.dev/)
- [Install](https://pi-web.dev/install)
Global PI WEB config lives at `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`. Project-local core config lives at `<project>/.pi-web/config.json`. - [Remote-first development](https://pi-web.dev/remote-first)
- [Machines / fleet](https://pi-web.dev/machines)
See the full [Configuration reference](docs/config.md) for config-file precedence, project-local config, external path access, session daemon settings, plugins, shortcuts, upload limits, and environment variables. - [Configuration](https://pi-web.dev/config)
- [Plugins](https://pi-web.dev/plugins)
The web server defaults to `127.0.0.1:8504`. Set `PI_WEB_HOST=0.0.0.0` only when you intentionally want to bind directly on all interfaces behind a trusted network, firewall, or authenticated proxy. - [FAQ](https://pi-web.dev/faq)
The session daemon defaults to a private Unix socket at:
```text
~/.pi-web/sessiond.sock
```
Common config keys:
- `host` / `port` — web/API bind address. Environment overrides: `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`.
- `pathAccess.allowedPaths` — external filesystem roots that PI WEB may list/read through the file explorer and absolute `@` path completions. Absolute paths are denied by default.
- `maxUploadBytes` — maximum accepted request body size. Defaults to 64 MB. Environment override: `PI_WEB_MAX_UPLOAD_BYTES`.
- `spawnSessions` — enable the `spawn_session` tool. Defaults to `true`. Environment override: `PI_WEB_SPAWN_SESSIONS`.
- `subsessions` — beta tracked-subsession tools (`spawn_subsession`, `list_subsessions`, `check_subsession`, `read_subsession`). Defaults to `false`, requires `spawnSessions`, and requires a session daemon restart after changes. Environment override: `PI_WEB_SUBSESSIONS`.
- `plugins` — plugin enablement/settings. Reload the browser after changing plugin enablement.
- `shortcuts` — keyboard shortcut overrides; use `null` to disable an action shortcut.
Operational environment variables:
- `PI_WEB_CONFIG` — path to the global config JSON file.
- `PI_WEB_DATA_DIR` — PI WEB-managed data directory. Defaults to `~/.pi-web`.
- `PI_WEB_SESSIOND_SOCKET` — Unix socket path used by both the daemon and web process when `PI_WEB_SESSIOND_URL` is not set. Defaults to `$PI_WEB_DATA_DIR/sessiond.sock`.
- `PI_WEB_SESSIOND_PORT` — optional TCP port for the daemon. If unset, the daemon listens on the Unix socket instead.
- `PI_WEB_SESSIOND_HOST` — daemon TCP bind host when `PI_WEB_SESSIOND_PORT` is set. Defaults to `127.0.0.1`.
- `PI_WEB_SESSIOND_URL` — daemon URL used by the web process when connecting over TCP, for example `http://127.0.0.1:3001`. If you set `PI_WEB_SESSIOND_PORT`, set this for the web process too.
- `PI_WEB_PROJECTS_FILE` — optional override for the projects storage JSON file. Defaults to `$PI_WEB_DATA_DIR/projects.json`.
- `PI_WEB_MACHINES_FILE` — optional override for the remote machine registry JSON file. Defaults to `$PI_WEB_DATA_DIR/machines.json`.
- `PI_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
+40 -3
View File
@@ -1,6 +1,6 @@
# PI WEB configuration reference # PI WEB configuration reference
PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload limits, and session-daemon tools. PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, and session-daemon tools.
This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>. This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>.
@@ -17,12 +17,14 @@ If you installed services with a custom config path, rerun `pi-web install --con
## Precedence and reloads ## Precedence and reloads
Runtime values are resolved as: Machine-global runtime values are resolved as:
```text ```text
defaults → config file → environment overrides 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`. 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: Process restarts depend on the key:
@@ -31,6 +33,7 @@ Process restarts depend on the key:
- `maxUploadBytes`: restart both the web/API process and the session daemon. - `maxUploadBytes`: restart both the web/API process and the session daemon.
- `spawnSessions` / `subsessions`: restart the session daemon. - `spawnSessions` / `subsessions`: restart the session daemon.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `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. - `plugins`: reload the browser tab after changing plugin enablement.
- `shortcuts`: saved settings apply in the browser after config refresh/save. - `shortcuts`: saved settings apply in the browser after config refresh/save.
@@ -43,6 +46,9 @@ Process restarts depend on the key:
"pathAccess": { "pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"] "allowedPaths": ["~/SDKs", "/opt/reference"]
}, },
"uploads": {
"defaultFolder": ".pi-web/uploads"
},
"maxUploadBytes": 67108864, "maxUploadBytes": 67108864,
"spawnSessions": true, "spawnSessions": true,
"subsessions": false, "subsessions": false,
@@ -67,12 +73,17 @@ Project-local config lives at `<project>/.pi-web/config.json`. Use it for settin
"version": 1, "version": 1,
"pathAccess": { "pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"] "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 `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. Plugins may own separate project files, such as `.pi-web/tasks.json` for the built-in Workspace Tasks plugin.
## Configuration matrix ## Configuration matrix
@@ -86,6 +97,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Web/API port | `port` | `PI_WEB_PORT`, `PORT` | 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 | | 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 | | 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 | | 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 | | 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 | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon |
@@ -123,6 +135,31 @@ When an absolute request is served, PI WEB expands `~`, canonicalizes the config
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. 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 ### Session daemon tools
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
+46 -13
View File
@@ -147,25 +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, files — one Pi web UI on every screen.</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 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>
<div class="demo-gallery" aria-label="PI WEB screenshots"> <div class="demo-gallery" data-demo-gallery tabindex="0" aria-label="PI WEB screenshots">
<figure class="demo-shot demo-shot-desktop"> <figure class="demo-shot demo-shot-desktop" data-demo-slide>
<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" /> <div class="demo-shot-media">
<figcaption>Desktop: chat beside workspace file preview.</figcaption> <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>
<figure class="demo-shot"> <figure class="demo-shot" data-demo-slide>
<img src="assets/pi-web-tablet.png" alt="PI WEB tablet screenshot" /> <div class="demo-shot-media">
<figcaption>Tablet: the same session from a wider touch screen.</figcaption> <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>
<figure class="demo-shot demo-shot-mobile"> <figure class="demo-shot demo-shot-mobile" data-demo-slide>
<img src="assets/pi-web-mobile.png" alt="PI WEB mobile chat screenshot" /> <div class="demo-shot-media">
<figcaption>Mobile: the chat stays readable on the go.</figcaption> <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> </figure>
</div> </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>
+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.
+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) {
+262 -21
View File
@@ -610,52 +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-gallery { .demo-caption-copy {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(220px, 0.72fr); 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; gap: 18px;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 18px; 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 { .demo-shot {
display: grid; display: grid;
gap: 10px; flex: 1 0 calc((100% - 36px) / 3);
min-width: 286px;
gap: 12px;
align-content: start; align-content: start;
margin: 0; margin: 0;
scroll-snap-align: start;
} }
.demo-shot-desktop { .demo-shot-media {
grid-row: span 2; position: relative;
} display: grid;
.demo-shot img {
overflow: hidden; overflow: hidden;
width: 100%; aspect-ratio: 16 / 10;
place-items: center;
padding: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: var(--radius);
background: var(--panel-strong); 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); box-shadow: 0 18px 46px rgba(0, 0, 0, 0.2);
} }
.demo-shot-mobile img { .demo-lightbox-trigger {
width: min(100%, 250px); position: absolute;
margin-inline: auto; 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 { .demo-shot figcaption {
display: grid;
gap: 4px;
color: var(--muted-2); color: var(--muted-2);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.45; 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;
} }
@@ -1169,12 +1372,8 @@ html[data-theme="light"] .comment {
align-content: start; align-content: start;
} }
.demo-gallery { .demo-shot {
grid-template-columns: 1fr; flex-basis: min(58vw, 360px);
}
.demo-shot-desktop {
grid-row: auto;
} }
} }
@@ -1253,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;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.5", "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.5", "version": "1.202606.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.5", "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",
+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);
});
});
+23 -7
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";
@@ -902,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 {
@@ -1088,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 {
console.error(error instanceof Error ? error.message : String(error)); if (entrypoint === undefined) return false;
process.exit(1); if (entrypoint === modulePath) return true;
}); try {
return realpathSync(entrypoint) === realpathSync(modulePath);
} catch {
return false;
}
}
if (isCliEntrypoint()) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
+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";
+63
View File
@@ -167,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>>;
+30 -1
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 = {
@@ -37,6 +37,10 @@ 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", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })), 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)),
+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 } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, 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 } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, 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",
+47 -1
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");
@@ -446,6 +483,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
...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("pathAccess", optionalPathAccess(record["pathAccess"])),
...optionalField("uploads", optionalUploads(record["uploads"])),
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")), ...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")),
@@ -474,6 +512,14 @@ function optionalStringArray(value: unknown, field: string): string[] | undefine
throw new Error(`Invalid PI WEB ${field} field`); 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[] { function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string"); return Array.isArray(value) && value.every((item) => typeof item === "string");
} }
+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,
+51 -3
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";
@@ -169,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);
@@ -324,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);
} }
@@ -332,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> {
@@ -1216,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;
},
}; };
} }
@@ -1235,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 }),
@@ -1255,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); },
@@ -1387,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); },
+103 -36
View File
@@ -7,25 +7,19 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { captureImageAttachments } from "../promptAttachmentCapture"; import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft } from "../inputModes"; import { inputModeForDraft } from "../inputModes";
import { machineSessionKey } from "../machineKeys"; import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
import { promptEditorStyles, type CompletionItem } from "./shared"; import { promptEditorStyles, type CompletionItem } from "./shared";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu"; import "./AutocompleteMenu";
interface PendingAttachment { type PendingAttachment = CapturedAttachment & { id: string };
id: string;
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
@customElement("prompt-editor") @customElement("prompt-editor")
export class PromptEditor extends LitElement { export class PromptEditor extends LitElement {
@@ -59,6 +53,8 @@ export class PromptEditor extends LitElement {
private editor: EditorView | undefined; private editor: EditorView | undefined;
private readonly editableCompartment = new Compartment(); private readonly editableCompartment = new Compartment();
private readonly readOnlyCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment();
private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia();
private explicitShiftKeyActive = false;
protected override willUpdate(changed: PropertyValues<this>) { protected override willUpdate(changed: PropertyValues<this>) {
if (!changed.has("sessionId") && !changed.has("machineId")) return; if (!changed.has("sessionId") && !changed.has("machineId")) return;
@@ -96,8 +92,8 @@ export class PromptEditor extends LitElement {
<footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}> <footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
<div class="editor-wrap"> <div class="editor-wrap">
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div> <div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
<input class="attachment-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} /> <input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach images" aria-label="Attach images" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button> <button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null} ${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null} ${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()} ${this.renderAttachments()}
@@ -117,6 +113,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;
@@ -132,18 +133,20 @@ export class PromptEditor extends LitElement {
private renderAttachments() { private renderAttachments() {
if (this.attachments.length === 0 && this.attachmentError === undefined) return null; if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
const canUseInlineDelivery = promptAttachmentsCanUseInlineDelivery(this.attachments);
const delivery = this.effectiveAttachmentDelivery();
return html` return html`
<div class="attachments" aria-label="Pending attachments"> <div class="attachments" aria-label="Pending attachments">
${this.attachments.map((attachment) => html` ${this.attachments.map((attachment) => html`
<div class="attachment-chip" title=${attachment.name}> <div class=${`attachment-chip ${isInlinePromptAttachment(attachment) ? "attachment-chip-image" : "attachment-chip-file"}`} title=${attachment.name}>
<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} /> ${this.renderAttachmentPreview(attachment)}
<button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button> <button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button>
</div> </div>
`)} `)}
${this.attachments.length > 0 ? html` ${this.attachments.length > 0 ? html`
<label class="attachment-delivery" title="How attachments are delivered to the agent"> <label class="attachment-delivery" title=${canUseInlineDelivery ? "How attachments are delivered to the agent" : "General files are saved and mentioned from the workspace"}>
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}> <select .value=${delivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline">Attach to message</option> <option value="inline" ?disabled=${!canUseInlineDelivery}>Attach to message${canUseInlineDelivery ? "" : " (images only)"}</option>
<option value="folder">Save to .pi-web/attachments</option> <option value="folder">Save to .pi-web/attachments</option>
</select> </select>
</label> </label>
@@ -153,9 +156,24 @@ export class PromptEditor extends LitElement {
`; `;
} }
private renderAttachmentPreview(attachment: PendingAttachment) {
if (isInlinePromptAttachment(attachment)) {
return html`<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />`;
}
return html`
<div class="attachment-file-preview" aria-hidden="true">${fileExtensionLabel(attachment.name)}</div>
<span class="attachment-file-name">${attachment.name}</span>
`;
}
private changeDelivery(event: Event) { private changeDelivery(event: Event) {
if (!(event.target instanceof HTMLSelectElement)) return; if (!(event.target instanceof HTMLSelectElement)) return;
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline"; const requested = event.target.value === "folder" ? "folder" : "inline";
if (requested === "inline" && !promptAttachmentsCanUseInlineDelivery(this.attachments)) {
event.target.value = "folder";
return;
}
this.attachmentDelivery = requested;
saveAttachmentDelivery(this.attachmentDelivery); saveAttachmentDelivery(this.attachmentDelivery);
} }
@@ -164,7 +182,7 @@ export class PromptEditor extends LitElement {
} }
private async handlePaste(event: ClipboardEvent) { private async handlePaste(event: ClipboardEvent) {
const files = imageFilesFromDataTransfer(event.clipboardData); const files = filesFromDataTransfer(event.clipboardData);
if (files.length === 0) return; if (files.length === 0) return;
event.preventDefault(); event.preventDefault();
await this.addAttachmentFiles(files); await this.addAttachmentFiles(files);
@@ -172,13 +190,11 @@ export class PromptEditor extends LitElement {
private handleDragOver(event: DragEvent) { private handleDragOver(event: DragEvent) {
if (event.dataTransfer === null) return; if (event.dataTransfer === null) return;
if (Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"))) { if (dataTransferHasFiles(event.dataTransfer)) event.preventDefault();
event.preventDefault();
}
} }
private async handleDrop(event: DragEvent) { private async handleDrop(event: DragEvent) {
const files = imageFilesFromDataTransfer(event.dataTransfer); const files = filesFromDataTransfer(event.dataTransfer);
if (files.length === 0) return; if (files.length === 0) return;
event.preventDefault(); event.preventDefault();
await this.addAttachmentFiles(files); await this.addAttachmentFiles(files);
@@ -193,7 +209,7 @@ export class PromptEditor extends LitElement {
private async addAttachmentFiles(files: File[]) { private async addAttachmentFiles(files: File[]) {
this.attachmentError = undefined; this.attachmentError = undefined;
const { attachments, error } = await captureImageAttachments(files, readFileAsBase64); const { attachments, error } = await capturePromptAttachments(files, readFileAsBase64);
if (attachments.length > 0) { if (attachments.length > 0) {
this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))]; this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))];
} }
@@ -201,12 +217,11 @@ export class PromptEditor extends LitElement {
} }
private currentAttachments(): PromptAttachment[] { private currentAttachments(): PromptAttachment[] {
return this.attachments.map((attachment) => ({ return this.attachments.map((attachment) => pendingToPromptAttachment(attachment));
kind: "image", }
mimeType: attachment.mimeType,
data: attachment.data, private effectiveAttachmentDelivery(): PromptAttachmentDelivery {
name: attachment.name, return effectivePromptAttachmentDelivery(this.attachmentDelivery, this.attachments);
}));
} }
private createEditor() { private createEditor() {
@@ -223,6 +238,10 @@ export class PromptEditor extends LitElement {
syntaxHighlighting(defaultHighlightStyle, { fallback: true }), syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))), EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
EditorView.domEventHandlers({
keyup: (event) => this.handleEditorKeyUp(event),
blur: () => this.resetEditorModifierState(),
}),
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
@@ -230,11 +249,10 @@ export class PromptEditor extends LitElement {
if (update.docChanged) this.updateDraft(update.state.doc.toString()); if (update.docChanged) this.updateDraft(update.state.doc.toString());
}), }),
keymap.of([ keymap.of([
{ any: (view, event) => this.handleEditorKeyDown(event, view) },
{ key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowDown", run: () => this.moveCompletion(1) },
{ key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) },
{ key: "Escape", run: () => this.closeCompletions() }, { key: "Escape", run: () => this.closeCompletions() },
{ key: "Enter", run: () => this.handleEditorEnter() },
{ key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) },
{ key: "Tab", run: (view) => this.handleEditorTab(view) }, { key: "Tab", run: (view) => this.handleEditorTab(view) },
{ key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false }, { key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false },
{ key: "Backspace", run: (view) => deleteMarkupBackward(view) }, { key: "Backspace", run: (view) => deleteMarkupBackward(view) },
@@ -330,12 +348,41 @@ export class PromptEditor extends LitElement {
return true; return true;
} }
private handleEditorEnter(): boolean { private handleEditorKeyDown(event: KeyboardEvent, view: EditorView): boolean {
if (this.completions.length) { if (event.key === "Shift") {
this.explicitShiftKeyActive = true;
return false;
}
if (event.key !== "Enter") {
this.explicitShiftKeyActive = false;
return false;
}
if (event.defaultPrevented || event.isComposing || view.composing) return false;
const shiftKey = shouldUsePromptEnterShiftShortcut(event.shiftKey, this.explicitShiftKeyActive, this.mobilePromptEnterMedia);
this.explicitShiftKeyActive = false;
return this.handleEditorEnter(view, shiftKey);
}
private handleEditorKeyUp(event: KeyboardEvent): boolean {
if (event.key === "Shift") this.explicitShiftKeyActive = false;
return false;
}
private resetEditorModifierState(): boolean {
this.explicitShiftKeyActive = false;
return false;
}
private handleEditorEnter(view: EditorView, shiftKey: boolean): boolean {
if (!shiftKey && this.completions.length) {
const completion = this.completions[this.selectedIndex]; const completion = this.completions[this.selectedIndex];
if (completion !== undefined) this.pick(completion); if (completion !== undefined) this.pick(completion);
return true; return true;
} }
if (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) {
return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view);
}
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
return true; return true;
} }
@@ -375,7 +422,7 @@ export class PromptEditor extends LitElement {
if (text === "" && pending.length === 0) return; if (text === "" && pending.length === 0) return;
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined; const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
const attachments = pending.length > 0 ? this.currentAttachments() : undefined; const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
const delivery = this.attachmentDelivery; const delivery = this.effectiveAttachmentDelivery();
this.resetComposer(); this.resetComposer();
// Sending is owned by the controller (it drives the chat activity dock and, // Sending is owned by the controller (it drives the chat activity dock and,
// for folder mode, orchestrates the upload + reference rewrite), so this is // for folder mode, orchestrates the upload + reference rewrite), so this is
@@ -409,9 +456,29 @@ function emptyFileSuggestions(): FileSuggestion[] {
return []; return [];
} }
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] { function filesFromDataTransfer(data: DataTransfer | null): File[] {
if (data === null) return []; if (data === null) return [];
return Array.from(data.files).filter((file) => file.type.startsWith("image/")); return Array.from(data.files);
}
function dataTransferHasFiles(data: DataTransfer): boolean {
const items = Array.from(data.items);
if (items.length > 0) return items.some((item) => item.kind === "file");
return Array.from(data.types).includes("Files");
}
function pendingToPromptAttachment(attachment: PendingAttachment): PromptAttachment {
if (attachment.kind === "image") {
return { kind: "image", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
return { kind: "file", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
function fileExtensionLabel(name: string): string {
const trimmed = name.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex >= 0 && dotIndex < trimmed.length - 1) return trimmed.slice(dotIndex + 1, dotIndex + 5).toUpperCase();
return "FILE";
} }
function readFileAsBase64(file: File): Promise<string> { function readFileAsBase64(file: File): Promise<string> {
+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);
}
@@ -3,9 +3,28 @@ import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../../actions"; import type { AppAction } from "../../actions";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api"; import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts"; import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior";
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: string; description: string }[] = [
{
value: "auto",
label: "Auto/default",
description: "Desktop-like Enter sends; mobile, coarse pointer, or narrow screens insert a new line.",
},
{
value: "send",
label: "Enter sends message",
description: "Enter sends the chat message; Shift+Enter adds a new line when supported.",
},
{
value: "newline",
label: "Enter inserts new line",
description: "Enter adds a line break; Shift+Enter sends the chat message when supported.",
},
];
@customElement("settings-shortcuts-panel") @customElement("settings-shortcuts-panel")
export class SettingsShortcutsPanel extends LitElement { export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) actions: AppAction[] = []; @property({ attribute: false }) actions: AppAction[] = [];
@@ -18,6 +37,7 @@ export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>; @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private drafts: Record<string, string> = {}; @state() private drafts: Record<string, string> = {};
@state() private localError = ""; @state() private localError = "";
@state() private promptEnterPreference: PromptEnterPreference = readPromptEnterPreference();
@state() private recording: RecordingState | undefined; @state() private recording: RecordingState | undefined;
private recordingTimer: number | undefined; private recordingTimer: number | undefined;
private recordingListenerActive = false; private recordingListenerActive = false;
@@ -75,6 +95,7 @@ export class SettingsShortcutsPanel extends LitElement {
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button> <button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div> </div>
${this.renderMessages()} ${this.renderMessages()}
${this.renderPromptEnterPreferenceCard()}
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html` ${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
<div class="config-path-card"> <div class="config-path-card">
<span>Config file</span> <span>Config file</span>
@@ -100,6 +121,40 @@ export class SettingsShortcutsPanel extends LitElement {
return null; return null;
} }
private renderPromptEnterPreferenceCard(): TemplateResult {
return html`
<section class="prompt-enter-card" aria-labelledby="prompt-enter-preference-title">
<div class="prompt-enter-copy">
<span class="card-eyebrow">Chat composer</span>
<h3 id="prompt-enter-preference-title">Enter key behavior</h3>
<p>Choose what Enter does in this browser. Shift+Enter does the opposite when supported; automatic touch-keyboard capitalization is ignored to avoid accidental sends.</p>
</div>
<div class="prompt-enter-options" role="radiogroup" aria-label="Enter and Shift Enter behavior in the chat composer">
${PROMPT_ENTER_OPTIONS.map((option) => html`
<label class="prompt-enter-option">
<input
type="radio"
name="prompt-enter-preference"
.value=${option.value}
.checked=${this.promptEnterPreference === option.value}
@change=${() => { this.updatePromptEnterPreference(option.value); }}
>
<span>
<strong>${option.label}</strong>
<small>${option.description}</small>
</span>
</label>
`)}
</div>
</section>
`;
}
private updatePromptEnterPreference(preference: PromptEnterPreference): void {
this.promptEnterPreference = preference;
writePromptEnterPreference(preference);
}
private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult { private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult {
const shortcuts = this.configResponse?.config.shortcuts; const shortcuts = this.configResponse?.config.shortcuts;
const configured = shortcutPreference(action.id, shortcuts); const configured = shortcutPreference(action.id, shortcuts);
@@ -291,13 +346,22 @@ export class SettingsShortcutsPanel extends LitElement {
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; } button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
.secondary { flex: 0 0 auto; } .secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .message, .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message { margin-bottom: 12px; } .message { margin-bottom: 12px; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card, .config-path-card { color: var(--pi-muted); } .loading-card, .config-path-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } .config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
.prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; margin-bottom: 14px; }
.prompt-enter-copy { display: grid; gap: 5px; min-width: 0; }
.prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; }
.prompt-enter-options { display: grid; gap: 7px; }
.prompt-enter-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; color: var(--pi-text); }
.prompt-enter-option input { box-sizing: border-box; width: 14px; min-width: 14px; height: 14px; margin: 3px 0 0; padding: 0; border: 0; background: transparent; accent-color: var(--pi-accent); font-family: inherit; }
.prompt-enter-option input:focus { border-color: transparent; box-shadow: none; outline: 2px solid var(--pi-accent-border); outline-offset: 2px; }
.prompt-enter-option span { display: grid; gap: 2px; }
.prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.shortcut-group { margin: 0 0 16px; } .shortcut-group { margin: 0 0 16px; }
.shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
@@ -330,6 +394,7 @@ export class SettingsShortcutsPanel extends LitElement {
@media (max-width: 760px) { @media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; } .section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; } .section-heading .secondary { justify-self: start; }
.prompt-enter-card { grid-template-columns: minmax(0, 1fr); }
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
.shortcut-status, .shortcut-actions { justify-content: flex-start; } .shortcut-status, .shortcut-actions { justify-content: flex-start; }
} }
@@ -20,13 +20,14 @@ describe("settings config drafts", () => {
allowedHostsMode: "list", allowedHostsMode: "list",
allowedHostsText: "example.local, 192.168.1.20\n", allowedHostsText: "example.local, 192.168.1.20\n",
allowedPathsText: "/tmp\n~/SDKs\n", allowedPathsText: "/tmp\n~/SDKs\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({ }, { 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"] }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 1234, maxUploadBytes: 1234,
}); });
}); });
@@ -26,6 +26,7 @@ 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.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 }),
+4 -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; }
@@ -476,6 +476,9 @@ export const promptEditorStyles = css`
.attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; } .attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
.attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); } .attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); }
.attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; } .attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-chip-file { display: grid; place-items: center; }
.attachment-file-preview { display: grid; place-items: center; width: 34px; height: 26px; border: 1px solid var(--pi-border-muted); border-radius: 4px; background: var(--pi-surface); color: var(--pi-muted); font: 700 10px/1 system-ui, sans-serif; letter-spacing: .03em; }
.attachment-file-name { position: absolute; right: 4px; bottom: 3px; left: 4px; overflow: hidden; color: var(--pi-muted); font-size: 10px; line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; } .attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; }
.attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; } .attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; }
.attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; } .attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; }
@@ -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;
+48 -18
View File
@@ -1,51 +1,81 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture"; import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
function file(name: string, type: string, size = 10): CapturableFile { function file(name: string, type: string, size = 10): CapturableFile {
return { name, type, size }; return { name, type, size };
} }
describe("captureImageAttachments", () => { describe("capturePromptAttachments", () => {
it("reads supported images as base64 attachments", async () => { it("reads supported images as native inline image attachments", async () => {
const result = await captureImageAttachments( const result = await capturePromptAttachments(
[file("shot.png", "image/png"), file("pic.webp", "image/webp")], [file("shot.png", "image/png"), file("pic.webp", "image/webp")],
(f) => Promise.resolve(`data-for-${f.name}`), (f) => Promise.resolve(`data-for-${f.name}`),
); );
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([ expect(result.attachments).toEqual([
{ name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 }, { kind: "image", name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 }, { kind: "image", name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
]); ]);
}); });
it("derives a name from the mime type when the file is unnamed", async () => { it("captures generic files with their browser MIME type", async () => {
const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x")); const result = await capturePromptAttachments(
expect(result.attachments[0]?.name).toBe("pasted-image.jpg"); [file("report.pdf", "application/pdf", 1234), file("vector.svg", "image/svg+xml")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "data-for-report.pdf", size: 1234 },
{ kind: "file", name: "vector.svg", mimeType: "image/svg+xml", data: "data-for-vector.svg", size: 10 },
]);
}); });
it("skips unsupported types and reports a single error while keeping valid ones", async () => { it("uses application/octet-stream when the browser does not provide a MIME type", async () => {
const result = await captureImageAttachments( const result = await capturePromptAttachments([file("archive", "")], () => Promise.resolve("x"));
[file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")],
expect(result.attachments[0]).toMatchObject({ kind: "file", name: "archive", mimeType: DEFAULT_FILE_MIME_TYPE });
});
it("derives fallback names for unnamed pasted attachments", async () => {
const result = await capturePromptAttachments(
[file("", "image/jpeg"), file("", "application/pdf")],
() => Promise.resolve("x"), () => Promise.resolve("x"),
); );
expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE); expect(result.attachments.map((attachment) => attachment.name)).toEqual(["pasted-image.jpg", "pasted-file.bin"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]);
}); });
it("reports a read failure without dropping other attachments", async () => { it("reports a read failure without dropping other attachments", async () => {
const result = await captureImageAttachments( const result = await capturePromptAttachments(
[file("bad.png", "image/png"), file("good.png", "image/png")], [file("bad.png", "image/png"), file("good.txt", "text/plain")],
(f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"), (f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"),
); );
expect(result.error).toBe(READ_FAILURE_MESSAGE); expect(result.error).toBe(READ_FAILURE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]); expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.txt"]);
}); });
it("returns no attachments and no error for an empty batch", async () => { it("returns no attachments and no error for an empty batch", async () => {
const result = await captureImageAttachments([], () => Promise.resolve("x")); const result = await capturePromptAttachments([], () => Promise.resolve("x"));
expect(result).toEqual({ attachments: [] }); expect(result).toEqual({ attachments: [] });
}); });
}); });
describe("effectivePromptAttachmentDelivery", () => {
it("preserves inline delivery when all pending attachments are supported images", () => {
expect(effectivePromptAttachmentDelivery("inline", [{ kind: "image", mimeType: "image/png" }])).toBe("inline");
});
it("preserves an explicit folder preference for supported images", () => {
expect(effectivePromptAttachmentDelivery("folder", [{ kind: "image", mimeType: "image/png" }])).toBe("folder");
});
it("forces folder delivery when any attachment is a generic file", () => {
expect(effectivePromptAttachmentDelivery("inline", [
{ kind: "image", mimeType: "image/png" },
{ kind: "file", mimeType: "application/pdf" },
])).toBe("folder");
});
});
+55 -18
View File
@@ -1,3 +1,4 @@
import type { PromptAttachmentDelivery } from "../../shared/apiTypes";
import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments"; import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments";
/** /**
@@ -11,44 +12,51 @@ export interface CapturableFile {
size: number; size: number;
} }
export interface CapturedAttachment { export type CapturedAttachment =
name: string; | {
mimeType: string; kind: "image";
/** Base64 payload without the data: URL prefix. */ name: string;
data: string; mimeType: string;
size: number; /** Base64 payload without the data: URL prefix. */
} data: string;
size: number;
}
| {
kind: "file";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
};
export interface CaptureResult { export interface CaptureResult {
attachments: CapturedAttachment[]; attachments: CapturedAttachment[];
error?: string; error?: string;
} }
export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported."; export const DEFAULT_FILE_MIME_TYPE = "application/octet-stream";
export const READ_FAILURE_MESSAGE = "Failed to read an attachment."; export const READ_FAILURE_MESSAGE = "Failed to read an attachment.";
/** /**
* Validate a batch of files and read the supported images as base64. * Read a batch of browser files as prompt attachments.
* *
* Pure orchestration: the actual byte reading is injected so the side effect * Pure orchestration: the actual byte reading is injected so the side effect
* (FileReader/Blob access) stays at the component boundary and tests can supply * (FileReader/Blob access) stays at the component boundary and tests can supply
* a fake reader. Unsupported types and read failures are collected into a single * a fake reader. Supported image MIME types stay marked as native inline images;
* user-facing error while still returning every attachment that did succeed. * every other file is captured as a generic file attachment that must be saved
* into the workspace before being mentioned in the prompt.
*/ */
export async function captureImageAttachments<T extends CapturableFile>( export async function capturePromptAttachments<T extends CapturableFile>(
files: readonly T[], files: readonly T[],
readBase64: (file: T) => Promise<string>, readBase64: (file: T) => Promise<string>,
): Promise<CaptureResult> { ): Promise<CaptureResult> {
const attachments: CapturedAttachment[] = []; const attachments: CapturedAttachment[] = [];
let error: string | undefined; let error: string | undefined;
for (const file of files) { for (const file of files) {
if (!isSupportedImageMimeType(file.type)) {
error = UNSUPPORTED_IMAGE_MESSAGE;
continue;
}
try { try {
const data = await readBase64(file); const data = await readBase64(file);
attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size }); attachments.push(capturedAttachment(file, data));
} catch { } catch {
error = READ_FAILURE_MESSAGE; error = READ_FAILURE_MESSAGE;
} }
@@ -56,6 +64,35 @@ export async function captureImageAttachments<T extends CapturableFile>(
return { attachments, ...(error === undefined ? {} : { error }) }; return { attachments, ...(error === undefined ? {} : { error }) };
} }
export function isInlinePromptAttachment(attachment: Pick<CapturedAttachment, "kind" | "mimeType">): boolean {
return attachment.kind === "image" && isSupportedImageMimeType(attachment.mimeType);
}
export function promptAttachmentsCanUseInlineDelivery(attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[]): boolean {
return attachments.every((attachment) => isInlinePromptAttachment(attachment));
}
export function effectivePromptAttachmentDelivery(
preferredDelivery: PromptAttachmentDelivery,
attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[],
): PromptAttachmentDelivery {
return promptAttachmentsCanUseInlineDelivery(attachments) ? preferredDelivery : "folder";
}
function capturedAttachment(file: CapturableFile, data: string): CapturedAttachment {
if (isSupportedImageMimeType(file.type)) {
return { kind: "image", name: attachmentName(file), mimeType: file.type, data, size: file.size };
}
return { kind: "file", name: attachmentName(file), mimeType: fileMimeType(file), data, size: file.size };
}
function fileMimeType(file: CapturableFile): string {
const mimeType = file.type.trim();
return mimeType === "" ? DEFAULT_FILE_MIME_TYPE : mimeType;
}
function attachmentName(file: CapturableFile): string { function attachmentName(file: CapturableFile): string {
return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`; if (file.name !== "") return file.name;
if (isSupportedImageMimeType(file.type)) return `pasted-image.${extensionForImageMimeType(file.type)}`;
return "pasted-file.bin";
} }
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
MOBILE_PROMPT_ENTER_MEDIA_QUERY,
parsePromptEnterPreference,
PROMPT_ENTER_PREFERENCE_STORAGE_KEY,
readPromptEnterPreference,
shouldSendPromptOnEnter,
shouldSendPromptOnEnterShortcut,
shouldUsePromptEnterShiftShortcut,
writePromptEnterPreference,
type PromptEnterMedia,
} from "./promptEnterBehavior";
describe("promptEnterBehavior", () => {
it("uses the expected mobile media query", () => {
expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)");
});
it("uses the environment default when the preference is auto", () => {
expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnter(undefined, "auto")).toBe(true);
expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "auto")).toBe(false);
});
it("lets explicit preferences override the environment", () => {
expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "send")).toBe(true);
expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "newline")).toBe(false);
expect(shouldSendPromptOnEnter(undefined, "newline")).toBe(false);
});
it("swaps Shift+Enter with the plain Enter behavior", () => {
expect(shouldSendPromptOnEnterShortcut(false, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnterShortcut(true, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(false, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(true, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(true);
expect(shouldSendPromptOnEnterShortcut(true, undefined, "send")).toBe(false);
expect(shouldSendPromptOnEnterShortcut(true, undefined, "newline")).toBe(true);
});
it("ignores implicit Shift state on mobile-like keyboards", () => {
expect(shouldUsePromptEnterShiftShortcut(false, true, { matches: true } satisfies PromptEnterMedia)).toBe(false);
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: true } satisfies PromptEnterMedia)).toBe(false);
expect(shouldUsePromptEnterShiftShortcut(true, true, { matches: true } satisfies PromptEnterMedia)).toBe(true);
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: false } satisfies PromptEnterMedia)).toBe(true);
expect(shouldUsePromptEnterShiftShortcut(true, false, undefined)).toBe(true);
});
it("parses local storage preference values", () => {
expect(parsePromptEnterPreference("auto")).toBe("auto");
expect(parsePromptEnterPreference("send")).toBe("send");
expect(parsePromptEnterPreference("newline")).toBe("newline");
expect(parsePromptEnterPreference(null)).toBe("auto");
expect(parsePromptEnterPreference("return")).toBe("auto");
});
it("reads and writes the stored preference", () => {
const storage = new FakeStorage();
expect(readPromptEnterPreference(storage)).toBe("auto");
writePromptEnterPreference("send", storage);
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("send");
expect(readPromptEnterPreference(storage)).toBe("send");
writePromptEnterPreference("newline", storage);
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("newline");
expect(readPromptEnterPreference(storage)).toBe("newline");
writePromptEnterPreference("auto", storage);
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("auto");
expect(readPromptEnterPreference(storage)).toBe("auto");
});
it("ignores storage failures", () => {
const storage = new ThrowingStorage();
expect(readPromptEnterPreference(storage)).toBe("auto");
expect(() => { writePromptEnterPreference("send", storage); }).not.toThrow();
});
});
class FakeStorage {
private readonly values = new Map<string, string>();
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
value(key: string): string | undefined {
return this.values.get(key);
}
}
class ThrowingStorage {
getItem(): string | null {
throw new Error("blocked");
}
setItem(): void {
throw new Error("blocked");
}
}
+61
View File
@@ -0,0 +1,61 @@
export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)";
export const PROMPT_ENTER_PREFERENCE_STORAGE_KEY = "pi-web.promptEnterPreference";
export type PromptEnterPreference = "auto" | "send" | "newline";
export type PromptEnterMedia = Pick<MediaQueryList, "matches">;
export type PromptEnterPreferenceStorage = Pick<Storage, "getItem" | "setItem">;
export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined {
return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined;
}
export function parsePromptEnterPreference(value: string | null): PromptEnterPreference {
if (value === "send" || value === "newline") return value;
return "auto";
}
export function readPromptEnterPreference(storage = browserStorage()): PromptEnterPreference {
if (storage === undefined) return "auto";
try {
return parsePromptEnterPreference(storage.getItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY));
} catch {
return "auto";
}
}
export function writePromptEnterPreference(preference: PromptEnterPreference, storage = browserStorage()): void {
if (storage === undefined) return;
try {
storage.setItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY, preference);
} catch {
// Ignore localStorage quota/privacy errors; Auto remains the safe fallback.
}
}
export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean {
if (preference === "send") return true;
if (preference === "newline") return false;
return media?.matches !== true;
}
export function shouldUsePromptEnterShiftShortcut(shiftKey: boolean, explicitShiftKeyActive: boolean, media = createMobilePromptEnterMedia()): boolean {
// Touch keyboards can report autocapitalization as Shift on Enter after a line break.
// On mobile-like screens, only trust Shift when the editor saw an explicit Shift keydown.
if (!shiftKey) return false;
if (media?.matches === true) return explicitShiftKeyActive;
return true;
}
export function shouldSendPromptOnEnterShortcut(shiftKey: boolean, media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean {
const plainEnterSends = shouldSendPromptOnEnter(media, preference);
return shiftKey ? !plainEnterSends : plainEnterSends;
}
function browserStorage(): PromptEnterPreferenceStorage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
} catch {
return undefined;
}
}
+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));
}
+16 -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" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, 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" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } } }); 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 } }, pathAccess: { allowedPaths: ["/old"] }, 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: [], pathAccess: { allowedPaths: ["/new"] } }, 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: [], pathAccess: { allowedPaths: ["/new"] } }); 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 () => {
@@ -48,6 +48,16 @@ describe("PI WEB config persistence", () => {
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", () => {
+33 -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),
@@ -102,6 +109,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
delete existing["shortcuts"]; delete existing["shortcuts"];
delete existing["plugins"]; delete existing["plugins"];
delete existing["pathAccess"]; delete existing["pathAccess"];
delete existing["uploads"];
delete existing["maxUploadBytes"]; delete existing["maxUploadBytes"];
delete existing["spawnSessions"]; delete existing["spawnSessions"];
delete existing["subsessions"]; delete existing["subsessions"];
@@ -126,6 +134,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
...(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.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 } : {}),
@@ -140,6 +149,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
...(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["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) } : {}),
@@ -225,6 +235,28 @@ function parseAllowedPaths(value: unknown, path: string): string[] {
return value; 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;
} }
+320
View File
@@ -155,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 }>();
@@ -181,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 }>();
@@ -465,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",
@@ -557,6 +648,235 @@ describe("buildApp", () => {
expect(deniedResponse.statusCode).toBe(400); expect(deniedResponse.statusCode).toBe(400);
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" }); 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 {
+23 -4
View File
@@ -9,6 +9,7 @@ import { ProjectService } from "./projects/projectService.js";
import { WorkspaceService } from "./workspaces/workspaceService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js";
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js"; import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { pathAccessForCwd } from "./workspaces/effectivePathAccess.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";
@@ -25,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;
@@ -39,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) => {
@@ -70,13 +76,26 @@ 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) });
} }
}); });
} }
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 { interface LocalFileSuggestionRouteOptions {
config?: Pick<PiWebConfigService, "read">; config?: Pick<PiWebConfigService, "read">;
} }
@@ -131,8 +150,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
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");
+14 -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" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } }, 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" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }); 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);
}); });
@@ -80,6 +80,18 @@ 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 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 {
+3 -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";
@@ -59,6 +59,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
const shortcuts = value["shortcuts"]; const shortcuts = value["shortcuts"];
const plugins = value["plugins"]; const plugins = value["plugins"];
const pathAccess = value["pathAccess"]; const pathAccess = value["pathAccess"];
const uploads = value["uploads"];
const maxUploadBytes = value["maxUploadBytes"]; const maxUploadBytes = value["maxUploadBytes"];
const spawnSessions = value["spawnSessions"]; const spawnSessions = value["spawnSessions"];
const subsessions = value["subsessions"]; const subsessions = value["subsessions"];
@@ -74,6 +75,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
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 (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
if (uploads !== undefined) config.uploads = parseUploadsConfig(uploads, "request");
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes); 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");
+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;
+73 -2
View File
@@ -1,17 +1,22 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, readdir, rm, symlink } 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_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js"; import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
let workspace: string; let workspace: string;
let externalDirectories: string[] = [];
beforeEach(async () => { beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-")); workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
externalDirectories = [];
}); });
afterEach(async () => { afterEach(async () => {
await rm(workspace, { recursive: true, force: true }); await Promise.all([
rm(workspace, { recursive: true, force: true }),
...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
]);
}); });
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
@@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => {
expect(written.equals(pngBytes)).toBe(true); expect(written.equals(pngBytes)).toBe(true);
}); });
it("saves generic files with sanitized original filenames", async () => {
const pdfBytes = Buffer.from("PDF bytes");
const saved = await saveAttachmentsToWorkspace(
workspace,
[
{ kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" },
{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" },
],
{ now: () => new Date("2026-06-13T12:05:01.123Z") },
);
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true);
expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength });
expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true);
expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 });
expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true);
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
});
it("does not overwrite an existing attachment name", async () => {
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
const first = await saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
{ now: fixedNow },
);
const second = await saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }],
{ now: fixedNow },
);
expect(second[0]?.path).not.toBe(first[0]?.path);
expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true);
expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC");
expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF");
});
it("rejects unsafe custom folders", async () => {
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
{ folder: "/tmp/uploads" },
)).rejects.toThrow(/Absolute paths/);
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
{ folder: "../uploads" },
)).rejects.toThrow(/Path traversal/);
});
it("honors a custom folder", async () => { it("honors a custom folder", async () => {
const saved = await saveAttachmentsToWorkspace( const saved = await saveAttachmentsToWorkspace(
workspace, workspace,
@@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => {
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true); expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
}); });
it("rejects attachment folders that resolve outside the workspace", async () => {
const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-"));
externalDirectories.push(outside);
await mkdir(join(workspace, ".pi-web"));
await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir");
await expect(saveAttachmentsToWorkspace(
workspace,
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
)).rejects.toThrow(/Path escapes workspace/);
await expect(readdir(outside)).resolves.toEqual([]);
});
it("returns empty for no attachments", async () => { it("returns empty for no attachments", async () => {
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]); expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
}); });
+71 -13
View File
@@ -1,10 +1,10 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { basename, extname, join } from "node:path";
import type { ImageContent } from "@earendil-works/pi-ai"; import type { ImageContent } from "@earendil-works/pi-ai";
import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent"; import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js"; import type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
import { extensionForImageMimeType } from "../../shared/promptAttachments.js"; import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js"; import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
/** /**
* Default workspace-relative folder used when saving pasted/dropped * Default workspace-relative folder used when saving pasted/dropped
@@ -26,7 +26,7 @@ export interface InlineImage {
* (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit * (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit
* are dropped, matching pi's `[Image omitted]` behaviour. * are dropped, matching pi's `[Image omitted]` behaviour.
*/ */
export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise<InlineImage[]> { export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise<InlineImage[]> {
const results: InlineImage[] = []; const results: InlineImage[] = [];
for (const attachment of attachments) { for (const attachment of attachments) {
const bytes = Buffer.from(attachment.data, "base64"); const bytes = Buffer.from(attachment.data, "base64");
@@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace(
attachments: PromptAttachment[], attachments: PromptAttachment[],
options: SaveAttachmentsOptions = {}, options: SaveAttachmentsOptions = {},
): Promise<SavedPromptAttachment[]> { ): Promise<SavedPromptAttachment[]> {
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER); const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER;
const now = options.now ?? (() => new Date()); const now = options.now ?? (() => new Date());
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder); const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder);
await mkdir(folderTarget, { recursive: true }); await mkdir(requestedFolderTarget, { recursive: true });
const folderTarget = await realpath(requestedFolderTarget);
ensureInside(root, folderTarget);
const stamp = timestamp(now()); const stamp = timestamp(now());
const saved: SavedPromptAttachment[] = []; const saved: SavedPromptAttachment[] = [];
for (const [index, attachment] of attachments.entries()) { for (const [index, attachment] of attachments.entries()) {
const bytes = Buffer.from(attachment.data, "base64"); const bytes = Buffer.from(attachment.data, "base64");
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`; const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes);
const relativePath = `${folder}/${filename}`; const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`;
await writeFile(join(folderTarget, filename), bytes);
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength }); saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
} }
return saved; return saved;
} }
function normalizeFolder(folder: string): string { async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise<string> {
return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/"); for (let attempt = 0; attempt < 100; attempt += 1) {
const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1);
try {
await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" });
return candidate;
} catch (error: unknown) {
if (!isNodeErrorWithCode(error, "EEXIST")) throw error;
}
}
throw new Error("Unable to choose a unique attachment filename");
}
function addCollisionSuffix(filename: string, suffix: number): string {
const extension = extname(filename);
const stem = filename.slice(0, filename.length - extension.length);
return `${stem}-${String(suffix)}${extension}`;
}
function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string {
const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment);
return `attachment-${stamp}-${String(index + 1)}-${originalName}`;
}
function fallbackAttachmentFilename(attachment: PromptAttachment): string {
if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`;
return "file.bin";
}
const MAX_ORIGINAL_FILENAME_LENGTH = 96;
function sanitizeOriginalFilename(name: string | undefined): string | undefined {
const trimmed = name?.trim();
if (trimmed === undefined || trimmed === "") return undefined;
const leaf = basename(trimmed.replace(/\\/g, "/"));
const sanitized = stripControlCharacters(leaf)
.normalize("NFKC")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/-+\./g, ".")
.replace(/^\.+/, "")
.replace(/[.-]+$/, "");
if (sanitized === "") return undefined;
return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH);
}
function stripControlCharacters(value: string): string {
return Array.from(value).filter((character) => {
const codePoint = character.codePointAt(0);
return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
}).join("");
}
function truncateFilename(filename: string, maxLength: number): string {
if (filename.length <= maxLength) return filename;
const extension = extname(filename);
if (extension.length >= maxLength) return filename.slice(0, maxLength);
const stem = filename.slice(0, filename.length - extension.length);
return `${stem.slice(0, maxLength - extension.length)}${extension}`;
} }
function timestamp(date: Date): string { function timestamp(date: Date): string {
+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();
}); });
+418 -31
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 registerSubsession(parentSessionId: string, childSessionId: string): void { 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);
}
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" },
@@ -696,7 +957,7 @@ export class PiSessionService {
} }
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> { async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false }); const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
if (parsed.length === 0) return []; if (parsed.length === 0) return [];
await this.assertWritable(ref); await this.assertWritable(ref);
const active = await this.getActive(ref); const active = await this.getActive(ref);
@@ -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,
+53 -7
View File
@@ -1,19 +1,22 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { ProjectService } from "./projects/projectService.js"; import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
import type { PiWebConfigService } from "./configRoutes.js"; import type { PiWebConfigService } from "./configRoutes.js";
import type { ProjectService } from "./projects/projectService.js";
import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js";
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js"; import { pathAccessForWorkspaceContext } from "./workspaces/effectivePathAccess.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
export interface WorkspaceExplorerRouteOptions { export interface WorkspaceExplorerRouteOptions {
config?: Pick<PiWebConfigService, "read">; config?: Pick<PiWebConfigService, "read">;
} }
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void { export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api", options: WorkspaceExplorerRouteOptions = {}): void {
registerWorkspaceFileContentParsers(app);
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);
@@ -32,6 +35,40 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
} }
}); });
app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
try {
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
const 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) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
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);
@@ -61,3 +98,12 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
} }
}); });
} }
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 */ }
}
@@ -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[] = [];
@@ -113,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();
});
});
+109 -2
View File
@@ -1,7 +1,9 @@
import { open, stat } from "node:fs/promises"; import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
import type { FileContentResponse, PiWebPathAccessConfig } 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 { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js"; import { resolveWorkspacePathAccessTarget } from "./pathAccessPolicy.js";
import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js";
const MAX_BYTES = 512 * 1024; const MAX_BYTES = 512 * 1024;
@@ -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);
+17 -1
View File
@@ -1,6 +1,18 @@
import { realpath } from "node:fs/promises"; import { realpath } from "node:fs/promises";
import { isAbsolute, join, relative, sep } from "node:path"; import { isAbsolute, join, relative, sep } from "node:path";
export async function resolveInsideWorkspace(rootPath: string, relativePath: string | undefined): Promise<{ root: string; target: string; relativePath: string }> {
const requested = normalizeRelativePath(relativePath);
const root = await realpath(rootPath);
const joined = join(root, requested);
const target = await realpath(joined).catch((error: unknown) => {
if (isNodeErrorWithCode(error, "ENOENT")) throw new Error("Path does not exist");
throw error;
});
ensureInside(root, target);
return { root, target, relativePath: requested };
}
export async function resolveParentInsideWorkspace(rootPath: string, relativePath: string): Promise<{ root: string; target: string; relativePath: string }> { export async function resolveParentInsideWorkspace(rootPath: string, relativePath: string): Promise<{ root: string; target: string; relativePath: string }> {
const requested = normalizeRelativePath(relativePath); const requested = normalizeRelativePath(relativePath);
const root = await realpath(rootPath); const root = await realpath(rootPath);
@@ -18,7 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string {
return parts.join("/"); return parts.join("/");
} }
function ensureInside(root: string, target: string): void { export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
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");
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, 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 { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js"; import { loadEffectiveProjectPathAccess, loadEffectiveProjectUploadsConfig, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
let tempDir: string; let tempDir: string;
let projectPath: string; let projectPath: string;
@@ -26,13 +26,13 @@ describe("project PI WEB config", () => {
}); });
}); });
it("loads project-local path access config", async () => { it("loads project-local path access and upload config", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }); await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } });
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({ await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH), path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
exists: true, exists: true,
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } },
}); });
}); });
@@ -48,6 +48,12 @@ describe("project PI WEB config", () => {
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings"); 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 () => { it("merges global and project path access in order", async () => {
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } }); await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
@@ -55,6 +61,14 @@ describe("project PI WEB config", () => {
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"], 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", () => { describe("mergePathAccessConfigs", () => {
+9 -2
View File
@@ -1,13 +1,14 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { parsePathAccessConfig, type PiWebConfig } from "../../config.js"; import { effectiveUploadsConfig, parsePathAccessConfig, parseUploadsConfig, type PiWebConfig } from "../../config.js";
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js"; import type { PiWebPathAccessConfig, PiWebUploadsConfig } from "../../shared/apiTypes.js";
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json"; export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
export interface ProjectPiWebConfig { export interface ProjectPiWebConfig {
version?: 1; version?: 1;
pathAccess?: PiWebPathAccessConfig; pathAccess?: PiWebPathAccessConfig;
uploads?: PiWebUploadsConfig;
} }
export interface LoadedProjectPiWebConfig { export interface LoadedProjectPiWebConfig {
@@ -33,6 +34,11 @@ export async function loadEffectiveProjectPathAccess(projectPath: string, global
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess); 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 { export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? [])); const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
return allowedPaths.length === 0 ? undefined : { allowedPaths }; return allowedPaths.length === 0 ? undefined : { allowedPaths };
@@ -43,6 +49,7 @@ function parseProjectPiWebConfig(value: Record<string, unknown>, path: string):
return { return {
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}), ...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}), ...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
}; };
} }
+59 -6
View File
@@ -56,6 +56,10 @@ export interface PiWebPathAccessConfig {
allowedPaths?: string[]; allowedPaths?: string[];
} }
export interface PiWebUploadsConfig {
defaultFolder?: string;
}
export interface PiWebConfigValues { export interface PiWebConfigValues {
host?: string; host?: string;
port?: number; port?: number;
@@ -64,6 +68,8 @@ export interface PiWebConfigValues {
plugins?: PiWebPluginConfigMap; plugins?: PiWebPluginConfigMap;
/** External filesystem roots PI WEB may expose outside a workspace. */ /** External filesystem roots PI WEB may expose outside a workspace. */
pathAccess?: PiWebPathAccessConfig; 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. */
@@ -115,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;
@@ -124,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 {
@@ -164,14 +176,13 @@ export interface QueuedSessionMessage {
} }
/** /**
* A binary attachment carried with a prompt. The wire format mirrors pi's own * A pi-native image attachment carried with a prompt. The wire format mirrors
* `ImageContent` shape (`{ type: "image", data, mimeType }`) so attachments are * pi's own `ImageContent` shape (`{ type: "image", data, mimeType }`) so these
* fully compatible with the underlying pi coding agent. * attachments are compatible with native multimodal delivery after validation.
*/ */
export interface PromptAttachment { export interface PromptImageAttachment {
/** Kind of attachment. Only images are supported by pi today. */
kind: "image"; kind: "image";
/** IANA mime type (for example "image/png"). */ /** Supported image MIME type (image/png, image/jpeg, image/gif, or image/webp). */
mimeType: string; mimeType: string;
/** Base64-encoded binary payload (no data: URL prefix). */ /** Base64-encoded binary payload (no data: URL prefix). */
data: string; data: string;
@@ -179,6 +190,19 @@ export interface PromptAttachment {
name?: string; name?: string;
} }
/** A general file attachment that must be saved into the workspace before use. */
export interface PromptFileAttachment {
kind: "file";
/** Non-empty IANA MIME type (for example "application/pdf"). */
mimeType: string;
/** Base64-encoded binary payload (no data: URL prefix). Empty for zero-byte files. */
data: string;
/** Optional original filename, used for previews and folder-mode filenames. */
name?: string;
}
export type PromptAttachment = PromptImageAttachment | PromptFileAttachment;
/** /**
* How prompt attachments should be delivered to the session. * How prompt attachments should be delivered to the session.
* - "inline": send the binary to pi as native image content (multimodal input). * - "inline": send the binary to pi as native image content (multimodal input).
@@ -315,6 +339,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 {
+4 -1
View File
@@ -1,4 +1,4 @@
export type FederatedHttpMethod = "GET" | "POST" | "DELETE"; export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE";
export interface FederatedHttpRouteSpec { export interface FederatedHttpRouteSpec {
method: FederatedHttpMethod; method: FederatedHttpMethod;
@@ -15,6 +15,9 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId" }, { method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
{ method: "PUT", path: "/projects/:projectId/workspaces/:workspaceId/file" },
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/file" },
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/file/move" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/files" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/files" },
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
+25
View File
@@ -58,9 +58,34 @@ describe("parsePromptAttachments", () => {
it("rejects unsupported kinds and mime types", () => { it("rejects unsupported kinds and mime types", () => {
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/); expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/); expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/);
}); });
it("accepts generic files only when file attachments are allowed", () => {
const result = parsePromptAttachments(
[{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }],
{ allowFileAttachments: true },
);
expect(result).toEqual([{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }]);
});
it("accepts zero-byte generic files", () => {
const result = parsePromptAttachments(
[{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }],
{ allowFileAttachments: true },
);
expect(result).toEqual([{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }]);
});
it("rejects generic files with empty mime types", () => {
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "", data: "QUJD" }], { allowFileAttachments: true })).toThrow(/invalid file type/);
});
it("keeps image MIME validation when file attachments are allowed", () => {
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
});
it("rejects invalid base64 data", () => { it("rejects invalid base64 data", () => {
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: "not base64!!!" }])).toThrow(/invalid base64/); expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: "not base64!!!" }])).toThrow(/invalid base64/);
}); });
+39 -6
View File
@@ -1,4 +1,4 @@
import type { PromptAttachment } from "./apiTypes.js"; import type { PromptAttachment, PromptFileAttachment, PromptImageAttachment } from "./apiTypes.js";
/** /**
* Image mime types supported by the pi coding agent. Mirrors * Image mime types supported by the pi coding agent. Mirrors
@@ -44,13 +44,20 @@ export function base64ByteLength(data: string): number {
export interface AttachmentValidationOptions { export interface AttachmentValidationOptions {
/** When true, enforce the per-image base64 size cap (inline delivery). */ /** When true, enforce the per-image base64 size cap (inline delivery). */
enforceInlineSizeLimit?: boolean; enforceInlineSizeLimit?: boolean;
/** When true, accept general file attachments for save-to-folder delivery. */
allowFileAttachments?: boolean;
maxAttachments?: number; maxAttachments?: number;
} }
type ImageOnlyAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments?: false | undefined };
type SaveAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments: true };
/** /**
* Validate and normalize untrusted prompt attachments. Throws on malformed, * Validate and normalize untrusted prompt attachments. Throws on malformed,
* unsupported, or oversized input so routes can return a 400. * unsupported, or oversized input so routes can return a 400.
*/ */
export function parsePromptAttachments(value: unknown, options?: ImageOnlyAttachmentValidationOptions): PromptImageAttachment[];
export function parsePromptAttachments(value: unknown, options: SaveAttachmentValidationOptions): PromptAttachment[];
export function parsePromptAttachments(value: unknown, options: AttachmentValidationOptions = {}): PromptAttachment[] { export function parsePromptAttachments(value: unknown, options: AttachmentValidationOptions = {}): PromptAttachment[] {
if (value === undefined) return []; if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error("attachments must be an array"); if (!Array.isArray(value)) throw new Error("attachments must be an array");
@@ -67,19 +74,45 @@ function parsePromptAttachment(value: unknown, index: number, options: Attachmen
if (!isRecord(value)) throw new Error(`attachment ${String(index)} must be an object`); if (!isRecord(value)) throw new Error(`attachment ${String(index)} must be an object`);
const record = value; const record = value;
const kind = record["kind"]; const kind = record["kind"];
if (kind !== "image") throw new Error(`attachment ${String(index)} has unsupported kind`); if (kind === "image") return parseImageAttachment(record, index, options);
if (kind === "file" && options.allowFileAttachments === true) return parseFileAttachment(record, index);
throw new Error(`attachment ${String(index)} has unsupported kind`);
}
function parseImageAttachment(record: Record<string, unknown>, index: number, options: AttachmentValidationOptions): PromptImageAttachment {
const mimeType = record["mimeType"]; const mimeType = record["mimeType"];
if (!isSupportedImageMimeType(mimeType)) throw new Error(`attachment ${String(index)} has unsupported image type`); if (!isSupportedImageMimeType(mimeType)) throw new Error(`attachment ${String(index)} has unsupported image type`);
const data = record["data"]; const data = requireBase64Data(record["data"], index, { allowEmpty: false });
if (typeof data !== "string" || data === "" || !base64Pattern.test(data)) throw new Error(`attachment ${String(index)} has invalid base64 data`);
if (options.enforceInlineSizeLimit === true && base64ByteLength(data) > MAX_INLINE_IMAGE_BASE64_BYTES) { if (options.enforceInlineSizeLimit === true && base64ByteLength(data) > MAX_INLINE_IMAGE_BASE64_BYTES) {
throw new Error(`attachment ${String(index)} exceeds the inline image size limit`); throw new Error(`attachment ${String(index)} exceeds the inline image size limit`);
} }
const name = record["name"];
return { return {
kind: "image", kind: "image",
mimeType, mimeType,
data, data,
...(typeof name === "string" && name !== "" ? { name } : {}), ...attachmentName(record),
}; };
} }
function parseFileAttachment(record: Record<string, unknown>, index: number): PromptFileAttachment {
const mimeType = record["mimeType"];
if (typeof mimeType !== "string" || mimeType.trim() === "") throw new Error(`attachment ${String(index)} has invalid file type`);
return {
kind: "file",
mimeType: mimeType.trim(),
data: requireBase64Data(record["data"], index, { allowEmpty: true }),
...attachmentName(record),
};
}
function requireBase64Data(value: unknown, index: number, options: { allowEmpty: boolean }): string {
if (typeof value !== "string" || (!options.allowEmpty && value === "") || !base64Pattern.test(value)) {
throw new Error(`attachment ${String(index)} has invalid base64 data`);
}
return value;
}
function attachmentName(record: Record<string, unknown>): { name?: string } {
const name = record["name"];
return typeof name === "string" && name !== "" ? { name } : {};
}