diff --git a/.changeset/chat-file-uploads.md b/.changeset/chat-file-uploads.md new file mode 100644 index 0000000..d65244b --- /dev/null +++ b/.changeset/chat-file-uploads.md @@ -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. diff --git a/.changeset/fix-fish-doctor-version-check.md b/.changeset/fix-fish-doctor-version-check.md new file mode 100644 index 0000000..8163c01 --- /dev/null +++ b/.changeset/fix-fish-doctor-version-check.md @@ -0,0 +1,9 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check +wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`, +which fish parses as a command substitution in command position and rejects +(`command substitutions not allowed in command position`), producing a false +negative. Emit fish's `begin; ...; end` grouping when the service shell is fish. diff --git a/.changeset/git-inline-diff-highlights.md b/.changeset/git-inline-diff-highlights.md new file mode 100644 index 0000000..26e3890 --- /dev/null +++ b/.changeset/git-inline-diff-highlights.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Highlight within-line changes in the Git diff viewer. diff --git a/.changeset/manual-workspace-uploads.md b/.changeset/manual-workspace-uploads.md new file mode 100644 index 0000000..033fb63 --- /dev/null +++ b/.changeset/manual-workspace-uploads.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md new file mode 100644 index 0000000..f3978be --- /dev/null +++ b/.changeset/mobile-enter-newline.md @@ -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). diff --git a/.changeset/persist-subsession-links.md b/.changeset/persist-subsession-links.md new file mode 100644 index 0000000..8199eaa --- /dev/null +++ b/.changeset/persist-subsession-links.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications. diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md new file mode 100644 index 0000000..5a3b3f3 --- /dev/null +++ b/.changeset/plugin-api-completeness.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer. diff --git a/.changeset/plugin-panel-prompt-context.md b/.changeset/plugin-panel-prompt-context.md new file mode 100644 index 0000000..e7ee9c2 --- /dev/null +++ b/.changeset/plugin-panel-prompt-context.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt. diff --git a/CHANGELOG.md b/CHANGELOG.md index 568c1bd..7617ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @jmfederico/pi-web +## 1.202606.6 + +### Patch Changes + +- c479a0d: Fix the session daemon startup when PI WEB runs with compatible Pi packages that moved legacy provider registry exports to the Pi AI compatibility entrypoint. + ## 1.202606.5 ### Patch Changes diff --git a/README.md b/README.md index 801f4b5..86c68c6 100644 --- a/README.md +++ b/README.md @@ -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) [![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) [![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: +**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: ![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. -

- PI WEB tablet screenshot - PI WEB mobile chat screenshot -

+PI WEB lets you: -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; -- 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. +Your browser is the control surface. The work stays where it can keep running. -## 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? - -Yes. PI WEB is a Pi web UI for running and supervising Pi Coding Agent sessions from a browser. Unlike simple session viewers, PI WEB is built around persistent server-side workspaces, long-running session daemons, git worktrees, remote machines, and multi-device supervision. - -## Core model - -PI WEB organizes work into four levels: - -```text -Machine a local or remote PI WEB runtime endpoint -Project a folder on that machine -Workspace a git worktree, or the project folder for non-git projects -Session a chat with Pi Coding Agent running inside a workspace -``` - -This maps naturally to real development work: - -- select the local machine or another registered PI WEB runtime; -- add a project once on the selected machine; -- use worktrees to separate branches, features, experiments, and reviews; -- start one or more agent sessions inside each workspace; -- leave sessions running even when the browser disconnects or the UI restarts. - -## Features - -- Add and list local or remote PI WEB machines from the action palette. -- Proxy remote projects, workspaces, files, git state, sessions, and terminals through the currently opened PI WEB server. -- Add and list server-side projects. -- Discover git worktrees automatically with `git worktree list --porcelain`. -- Support non-git folders as single-workspace projects. -- Start, resume, archive, and restore Pi sessions per workspace. -- Chat with Pi Coding Agent through realtime WebSocket events. -- Keep active agent runtimes alive across browser disconnects and web/API restarts. -- Explicitly stop or abort active session work. -- View live session status: streaming, compaction, bash activity, token usage, cost, model, and context usage. -- Send prompts, shell input, and supported commands through the Pi SDK path. -- Reuse your existing Pi auth and model configuration from `~/.pi/agent`. -- Extend the UI with trusted plugins that add actions, workspace panels, and workspace-label metadata. See [Plugin API](docs/plugins.md) for LLM-friendly plugin-building docs. - -## Architecture - -PI WEB uses a split-process architecture so agent runtimes are not owned by the browser-facing dev server. Under the hood, it acts as a browser-based control plane for sessions, workspaces, files, terminals, and trusted remote machines. - -```text -Browser UI - │ - ▼ -Fastify Web/API process - │ HTTP + WebSocket proxy - ▼ -Session daemon - │ - ▼ -Pi Coding Agent SDK -``` - -### Session daemon - -The session daemon owns active Pi session runtimes. It is intended to be long-lived so sessions can survive browser disconnects and web/API restarts. - -### Web/API/UI server - -The web process serves the API and browser UI. In development it can autoreload freely while active sessions continue running in the daemon. - -## State model - -PI WEB keeps its own state intentionally small: - -- Machines: `~/.pi-web/machines.json` stores only opt-in remote machine records; the local machine is synthesized. -- Projects: `~/.pi-web/projects.json` -- Workspaces: discovered from git worktrees, not stored -- Sessions and chat history: Pi's default JSONL session storage on the selected machine -- Active session runtimes and WebSockets: memory in each selected machine's session daemon - -## Machine federation - -The Machines section lets one PI WEB instance act as a gateway to other PI WEB runtimes. Register a remote machine from **Actions → Add Machine** with the remote PI WEB base URL, for example a URL reachable over NetBird, Tailscale, WireGuard, an SSH tunnel, or a trusted reverse proxy. The browser continues talking to the local PI WEB origin; project, workspace, file, git, session, activity, and terminal HTTP/WebSocket traffic is proxied server-to-server. See the [Fleet guide](https://pi-web.dev/machines) for setup, trust model, and troubleshooting details. - -Remote model-provider credentials and OAuth state stay on the target machine. API-key provider configuration can be proxied, but OAuth login should be completed by opening the remote PI WEB directly. Register remote machines only when you trust the endpoint and the network path: adding a machine gives this PI WEB server permission to contact that URL with the optional bearer token you configured. - -## Plugins - -PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed. - -The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module` plus optional `machineSpecific` metadata, and a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals. - -A useful prompt for AI agents: - -```text -Build a PI WEB plugin for this project. Goal: . -Before coding, read https://pi-web.dev/plugins and https://pi-web.dev/plugins.md. -Create it under ~/.pi-web/plugins/ using the documented PI WEB v1 plugin API. -Validate with /pi-web-plugins/manifest.json and explain reload/debug steps. -Do not modify PI WEB itself. -``` - -Manage discovered plugins in **Settings → Plugins** or with the top-level `plugins` config key. Plugins are enabled by default; set `plugins..enabled` to `false` and reload the browser tab to prevent PI WEB from importing that plugin. - -Reload the browser tab after adding or editing a plugin. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead of `~/.pi-web/plugins`. Check discovery with: - -```bash -curl http://127.0.0.1:8504/pi-web-plugins/manifest.json -``` - -See the full [Plugin API](docs/plugins.md) for contribution types, package metadata, and troubleshooting. - -## Install - -Recommended install uses npm plus native per-user services. +Install and start PI WEB as per-user services: ```bash npm install -g @jmfederico/pi-web pi-web install +pi-web doctor ``` -On Linux servers, `loginctl enable-linger` is optional but recommended so the user systemd manager starts at boot and continues running after logout: +Then open: -```bash -sudo loginctl enable-linger "$USER" -loginctl show-user "$USER" -p Linger +```text +http://127.0.0.1:8504 ``` -This writes and starts PI WEB's session daemon and web/API user services. The native user-service backend is selected automatically. - -The generated services run through your detected login shell (`bash`, `zsh`, or `fish` with `-lc`) so they see a shell environment similar to running `pi` from your terminal. - -Open . - Useful commands: ```bash @@ -176,52 +64,84 @@ pi-web version pi-web uninstall ``` -Use `pi-web version` to compare the installed package version with the versions reported by the running Web/UI and session daemon services. +For more install options, including one-line install, Pi package install, WSL/manual usage, and remote access, see the [installation guide](https://pi-web.dev/install). -One-line install is also available for users who prefer it: +## Core model -```bash -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: +PI WEB organizes work like this: ```text -/pi-web install -/pi-web status -/pi-web logs -/pi-web restart -/pi-web doctor -/pi-web version +Machine a local or remote PI WEB runtime endpoint +Project a folder on that machine +Workspace a git worktree, or the project folder for non-git projects +Session a Pi Coding Agent chat running inside a workspace ``` -The Pi command is a convenience wrapper around the same service installer. When installed this way, the service installer can use PI WEB's package-local server entrypoints, so `pi-web-server` and `pi-web-sessiond` do not need to be on your shell `PATH`. `/pi-web logs` shows the last 100 service log lines; use `pi-web logs` in a shell when you want to follow logs continuously. +A typical flow: -Advanced users may run the binaries however they prefer: +1. Add a project. +2. Choose a workspace or git worktree. +3. Start a session. +4. Let the agent work. +5. Come back later from any browser. -```bash -pi-web-sessiond -PI_WEB_PORT=8504 pi-web-server +## Remote-first development + +PI WEB is designed for remote AI-driven development. + +Instead of tying agent work to your laptop session, run PI WEB on a machine that stays available: a server, desktop, cloud VM, home lab machine, or remote dev box. + +Use a private network, SSH tunnel, trusted reverse proxy, or federated PI WEB machine setup when accessing it remotely. + +Read more: [Remote-first development](https://pi-web.dev/remote-first) + +## Machines and fleets + +PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, and activity from trusted remote machines. + +Read more: [Fleet and machines guide](https://pi-web.dev/machines) + +## Plugins + +PI WEB supports trusted local browser-side plugins that can add actions, workspace panels, and workspace metadata. + +Read more: [Plugin API](https://pi-web.dev/plugins) + +## Configuration + +Global config lives at: + +```text +$PI_WEB_CONFIG +~/.config/pi-web/config.json ``` -## Development quick start +Project-local PI WEB config lives at: + +```text +/.pi-web/config.json +``` + +Common configuration includes host/port, path access, uploads, plugins, shortcuts, and session daemon options. + +Read more: [Configuration reference](https://pi-web.dev/config) + +## Development + +Clone the repository and run: ```bash npm install npm run dev ``` -Open the Vite URL, usually . +Open the Vite URL, usually: -During development, the static marketing/docs site is also served by the Vite dev server at . +```text +http://localhost:8505 +``` -For the recommended split development setup, run these in separate terminals: +For the split development setup: ```bash npm run dev:sessiond @@ -229,159 +149,27 @@ npm run dev:web npm run dev:client ``` -Or install the split development setup as native per-user services from the checkout: - -```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 +Validate changes with: ```bash npm run verify -npm run pack:dry -npm publish --access public ``` -`prepack` builds `dist/` and bundled plugin JavaScript before npm creates the tarball, and `prepublishOnly` runs verification before publishing. Releases can also be published by the GitHub Actions npm workflow when a GitHub release is published. +## Security model -PI WEB uses a single-line CalVer-inspired npm version: `MAJOR.YYYYMM.SEQUENCE`, for example `1.202605.1`. The major number signals breaking-change eras; the middle number is the release month; the final number increments for additional releases in that month. Older major eras may be deprecated rather than maintained in parallel. +PI WEB assumes trusted users, trusted repositories, and trusted server paths. -PI WEB declares `@earendil-works/pi-coding-agent` as a peer dependency (`>=0.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 - -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 `/.pi-web/config.json`. - -See the full [Configuration reference](docs/config.md) for config-file precedence, project-local config, external path access, session daemon settings, plugins, shortcuts, upload limits, and environment variables. - -The web server defaults to `127.0.0.1:8504`. Set `PI_WEB_HOST=0.0.0.0` only when you intentionally want to bind directly on all interfaces behind a trusted network, firewall, or authenticated proxy. - -The session daemon defaults to a private Unix socket at: - -```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. +- [Website](https://pi-web.dev/) +- [Install](https://pi-web.dev/install) +- [Remote-first development](https://pi-web.dev/remote-first) +- [Machines / fleet](https://pi-web.dev/machines) +- [Configuration](https://pi-web.dev/config) +- [Plugins](https://pi-web.dev/plugins) +- [FAQ](https://pi-web.dev/faq) ## License diff --git a/docs/config.md b/docs/config.md index d437ca2..2707a9c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,6 +1,6 @@ # PI WEB configuration reference -PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, 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 . @@ -17,12 +17,14 @@ If you installed services with a custom config path, rerun `pi-web install --con ## Precedence and reloads -Runtime values are resolved as: +Machine-global runtime values are resolved as: ```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, `/.pi-web/config.json` overrides the global value. + Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`. Process restarts depend on the key: @@ -31,6 +33,7 @@ Process restarts depend on the key: - `maxUploadBytes`: restart both the web/API process and the session daemon. - `spawnSessions` / `subsessions`: restart the session daemon. - `pathAccess`: applies on the next request; existing file views may need a browser refresh. +- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `plugins`: reload the browser tab after changing plugin enablement. - `shortcuts`: saved settings apply in the browser after config refresh/save. @@ -43,6 +46,9 @@ Process restarts depend on the key: "pathAccess": { "allowedPaths": ["~/SDKs", "/opt/reference"] }, + "uploads": { + "defaultFolder": ".pi-web/uploads" + }, "maxUploadBytes": 67108864, "spawnSessions": true, "subsessions": false, @@ -67,12 +73,17 @@ Project-local config lives at `/.pi-web/config.json`. Use it for settin "version": 1, "pathAccess": { "allowedPaths": ["~/SDKs", "/opt/reference"] + }, + "uploads": { + "defaultFolder": "manual/uploads" } } ``` Project-local `pathAccess.allowedPaths` entries are merged after the global list and deduplicated. Paths must still be host-absolute or `~`-prefixed; relative roots are not supported. +Project-local `uploads.defaultFolder` overrides the global upload destination for workspaces in that project. Current PI WEB servers include this workspace-effective value on the existing workspace responses used locally and through machine federation. Older remote servers may omit the optional field; the browser falls back to the global/default upload folder. + Plugins may own separate project files, such as `.pi-web/tasks.json` for the built-in Workspace Tasks plugin. ## Configuration matrix @@ -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 | | Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI | | External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed | +| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon | @@ -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. +### 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 `/.pi-web/config.json` sets a project-local override. + +```json +{ + "uploads": { + "defaultFolder": "manual/uploads" + } +} +``` + +The value must be a non-empty workspace-relative folder. PI WEB normalizes repeated separators and backslashes to `/`, and rejects absolute paths or `..` traversal. In the upload dialog only, clearing the destination field uploads that batch to the workspace root. + +Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth. + +For machine federation, current remote PI WEB servers return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder. + +The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES`. + ### Session daemon tools `spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions. diff --git a/docs/index.html b/docs/index.html index bfa5fce..e17f81b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -147,25 +147,58 @@
-
+
- Workspaces, sessions, transcripts, files — one Pi web UI on every screen. - Bring your own repositories. +
+ Workspaces, sessions, transcripts, files — one Pi web UI on every screen. + Bring your own repositories. Swipe through the screenshots or click one to enlarge. +
+
-
diff --git a/docs/plugins.md b/docs/plugins.md index e201051..e328513 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -439,6 +439,7 @@ interface PluginRuntimeContext { selectedSession?: unknown; piWebStatus?: PiWebStatusResponse; }; + prompt: PluginPromptEditor; openActionPalette: () => void; focusPrompt: () => void; addProject: () => void | Promise; @@ -464,6 +465,29 @@ Notes: - `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal. - Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear. +### Prompt editor API + +The `prompt` helper on `PluginRuntimeContext` and `WorkspacePanelContext` provides stable access to the chat prompt editor: + +| Method | Description | +| --- | --- | +| `insertText(text)` | Insert text at cursor position. When text is selected, replaces the selection. Focuses the editor first if not focused. | +| `getText()` | Returns the full prompt text. | +| `getSelection()` | Returns `{ start, end, text }` if text is selected, or `null`. | + +Usage: + +```js +// Insert text at the cursor (e.g. a file mention) +context.prompt.insertText("@file.txt"); + +// Read the current prompt and selection +const text = context.prompt.getText(); +const selection = context.prompt.getSelection(); // { start, end, text } | null +``` + +Use `focusPrompt()` on `PluginRuntimeContext` to move focus to the prompt editor. Workspace panels can call `context.prompt.insertText()` from explicit user interactions such as button clicks; panel contexts target the currently selected session's mounted prompt editor. + #### Keyboard shortcuts - App-level keyboard shortcuts must be attached to actions. PI WEB does not support standalone plugin keyboard commands; contribute an action first, then add a `shortcut` if it needs a keybinding. @@ -521,7 +545,11 @@ interface WorkspacePanelContext { state?: PluginRuntimeState; files: { readFile(path: string): Promise; + writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise; + deleteFile(path: string): Promise; + moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; }; + prompt: PluginPromptEditor; terminal: { open(options?: { terminalId?: string }): void; runCommand(input: { @@ -539,7 +567,7 @@ interface WorkspacePanelContext { `icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide. -`machine`, `workspace`, `files`, `terminal`, and `host` are documented as stable for panel callbacks. Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`. +`machine`, `workspace`, `files`, `prompt`, `terminal`, and `host` are documented as stable for panel callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). The `prompt` helper supports panel interactions that insert workspace context into the current prompt — see [Prompt editor API](#prompt-editor-api). Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`. For compatibility, PI WEB still provides the old `context.openTerminal()` workspace-panel helper at runtime. It is deprecated, intentionally omitted from the public TypeScript declarations, and planned for removal in v2. Existing JavaScript plugins keep working, while typed plugins should migrate to `context.terminal.open()`. @@ -607,6 +635,9 @@ interface WorkspaceLabelContext { state?: PluginRuntimeState; files: { readFile(path: string): Promise; + writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise; + deleteFile(path: string): Promise; + moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; }; host: { requestRender(): void; @@ -614,7 +645,7 @@ interface WorkspaceLabelContext { } ``` -`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks. +`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks. Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes. @@ -753,6 +784,96 @@ workspaceLabels: [ The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin. +## Writing, deleting, and moving workspace files + +Workspace panels and workspace labels can write, delete, and move files through the documented `files` helper. Like `readFile`, PI WEB binds these helpers to the callback's machine and workspace, so they work the same for local and federated machines. + +### Writing files + +```js +workspacePanels: [ + { + id: "workspace.generate", + title: "Generate", + render: ({ files }) => html` + + `, + }, +] +``` + +### Binary writes + +Pass a `Uint8Array` for binary content such as images: + +```js +const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); +await files.writeFile("screenshots/thumb.png", png); +``` + +### Options + +`files.writeFile` accepts an optional third argument: + +- `createDirs` (default `true`): create intermediate directories, like `mkdir -p`. +- `overwrite` (default `true`): overwrite existing files. Set to `false` to throw if the file already exists. + +```js +// Create only — throw if the file already exists +await files.writeFile("config/new-config.json", jsonContent, { overwrite: false }); +``` + +### Deleting files + +`files.deleteFile` removes a workspace file. It is idempotent: deleting a file that does not exist returns `{ existed: false }` instead of throwing. + +```js +const result = await files.deleteFile("temp/cache.json"); +console.log(result.existed ? "File deleted" : "File did not exist"); +``` + +### Moving files + +`files.moveFile` renames or moves a file within the workspace, like `mv`. The default is safe: it will not overwrite an existing target file. + +```js +// Rename a file +await files.moveFile("old-name.txt", "new-name.txt"); + +// Move into a subdirectory (creates intermediate dirs by default) +await files.moveFile("file.txt", "archive/file.txt"); + +// Overwrite an existing target +await files.moveFile("incoming.txt", "current.txt", { overwrite: true }); + +// Move without creating intermediate directories +await files.moveFile("file.txt", "deep/nested/file.txt", { createDirs: false }); // throws if dirs don't exist +``` + +`files.moveFile` accepts an optional third argument: + +- `createDirs` (default `true`): create intermediate directories for the target path. +- `overwrite` (default `false`): overwrite the target file if it exists. The default is safer than `writeFile` because moving is a more destructive operation. + +### Error handling + +All file mutations share the same safety layer: + +- `overwrite: false` on `writeFile` or existing target on `moveFile` (default) throws if the file already exists. +- Path traversal (e.g., `../../etc/passwd`) is blocked by the workspace safety layer. +- Writing to or moving to a path that is a directory returns an error. +- Deleting a directory returns an error. +- Intermediate directory creation with `createDirs: false` fails if the parent directory does not exist. + +After any mutation (`writeFile`, `deleteFile`, or `moveFile`), the File Explorer updates automatically. No explicit `refreshFiles()` call is needed from plugin code. For label and badge updates, call `context.host.requestRender()` if the UI should reflect the change. + +### Security + +Plugins are trusted browser code. File writes go through the same path safety validation as reads — paths are resolved and checked to stay inside the workspace root. + ## Running workspace terminal commands Workspace panels can start terminal commands through the documented `terminal` helper. Commands run in the current workspace on the panel's machine. @@ -801,7 +922,7 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis 9. Add workspace panels for larger workspace UI. 10. Add workspace labels for compact inline metadata. 11. Return arrays from workspace label `items()`; return an empty array to render nothing. -12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. +12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebStatus`, and `prompt`. 13. Do not fetch PI WEB `/api/...` endpoints directly unless you intentionally accept private API churn; prefer documented helpers. 14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional. 15. After local edits, tell the user to hard reload the browser and check the console for plugin errors. diff --git a/docs/site.js b/docs/site.js index 95672c7..23b9bec 100644 --- a/docs/site.js +++ b/docs/site.js @@ -61,6 +61,167 @@ for (const button of themeButtons) { }); } +const screenshotCarousels = document.querySelectorAll("[data-demo-carousel]"); +const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + +function setupScreenshotCarousel(carousel) { + const gallery = carousel.querySelector("[data-demo-gallery]"); + const controls = carousel.querySelector("[data-demo-controls]"); + const previousButton = carousel.querySelector("[data-demo-prev]"); + const nextButton = carousel.querySelector("[data-demo-next]"); + const dots = Array.from(carousel.querySelectorAll("[data-demo-dot]")); + const slides = Array.from(carousel.querySelectorAll("[data-demo-slide]")); + const lightbox = carousel.querySelector("[data-demo-lightbox]"); + const lightboxImage = carousel.querySelector("[data-demo-lightbox-image]"); + const lightboxCaption = carousel.querySelector("[data-demo-lightbox-caption]"); + const lightboxCloseButton = carousel.querySelector("[data-demo-lightbox-close]"); + const lightboxTriggers = Array.from(carousel.querySelectorAll("[data-demo-lightbox-trigger]")); + + if (gallery === null || slides.length === 0) return; + + let updateQueued = false; + + function galleryHasOverflow() { + return gallery.scrollWidth > gallery.clientWidth + 4; + } + + function closestSlideIndex() { + const galleryRect = gallery.getBoundingClientRect(); + const galleryCenter = galleryRect.left + galleryRect.width / 2; + let closestIndex = 0; + let closestDistance = Number.POSITIVE_INFINITY; + + slides.forEach((slide, index) => { + const rect = slide.getBoundingClientRect(); + const distance = Math.abs(rect.left + rect.width / 2 - galleryCenter); + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + }); + + return closestIndex; + } + + function scrollToSlide(index) { + const slide = slides[index]; + if (slide === undefined) return; + + slide.scrollIntoView({ + behavior: reducedMotionQuery.matches ? "auto" : "smooth", + block: "nearest", + inline: "start", + }); + } + + function closeLightbox() { + if (lightbox === null) return; + + if (typeof lightbox.close === "function" && lightbox.open) { + lightbox.close(); + } else { + lightbox.removeAttribute("open"); + } + } + + function openLightbox(trigger) { + const image = trigger.querySelector("img"); + if (image === null || lightbox === null || lightboxImage === null) return; + + const figure = trigger.closest("figure"); + const captionParts = Array.from(figure?.querySelectorAll("figcaption strong, figcaption span") ?? []) + .map((node) => node.textContent?.trim()) + .filter(Boolean); + const caption = captionParts.length > 0 ? captionParts.join(" — ") : "PI WEB screenshot"; + + lightboxImage.src = image.currentSrc || image.src; + lightboxImage.alt = image.alt; + if (lightboxCaption !== null) lightboxCaption.textContent = caption; + + if (typeof lightbox.showModal === "function") { + lightbox.showModal(); + } else { + lightbox.setAttribute("open", ""); + } + + lightboxCloseButton?.focus({ preventScroll: true }); + } + + function updateControls() { + const overflow = galleryHasOverflow(); + const activeIndex = closestSlideIndex(); + const atStart = gallery.scrollLeft <= 2; + const atEnd = gallery.scrollLeft + gallery.clientWidth >= gallery.scrollWidth - 2; + + carousel.dataset.overflow = overflow ? "true" : "false"; + gallery.tabIndex = overflow ? 0 : -1; + if (controls !== null) controls.hidden = !overflow; + if (previousButton !== null) previousButton.disabled = !overflow || atStart; + if (nextButton !== null) nextButton.disabled = !overflow || atEnd; + + dots.forEach((dot, index) => { + dot.setAttribute("aria-current", index === activeIndex ? "true" : "false"); + }); + } + + function queueUpdateControls() { + if (updateQueued) return; + updateQueued = true; + window.requestAnimationFrame(() => { + updateQueued = false; + updateControls(); + }); + } + + previousButton?.addEventListener("click", () => { + scrollToSlide(Math.max(closestSlideIndex() - 1, 0)); + }); + + nextButton?.addEventListener("click", () => { + scrollToSlide(Math.min(closestSlideIndex() + 1, slides.length - 1)); + }); + + dots.forEach((dot) => { + const targetIndex = Number.parseInt(dot.getAttribute("data-demo-dot") ?? "", 10); + if (Number.isNaN(targetIndex)) return; + + dot.addEventListener("click", () => { + scrollToSlide(targetIndex); + }); + }); + + lightboxTriggers.forEach((trigger) => { + trigger.addEventListener("click", () => { + openLightbox(trigger); + }); + }); + + lightboxCloseButton?.addEventListener("click", closeLightbox); + + lightbox?.addEventListener("click", (event) => { + if (event.target === lightbox) closeLightbox(); + }); + + lightbox?.addEventListener("close", () => { + lightboxImage?.removeAttribute("src"); + }); + + gallery.addEventListener("scroll", queueUpdateControls, { passive: true }); + window.addEventListener("resize", queueUpdateControls); + + if ("ResizeObserver" in window) { + const resizeObserver = new window.ResizeObserver(queueUpdateControls); + resizeObserver.observe(gallery); + slides.forEach((slide) => resizeObserver.observe(slide)); + } + + updateControls(); +} + +for (const carousel of screenshotCarousels) { + setupScreenshotCarousel(carousel); +} + const copyButtons = document.querySelectorAll("[data-copy]"); for (const button of copyButtons) { diff --git a/docs/styles.css b/docs/styles.css index 9ea31df..f983592 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -610,52 +610,255 @@ code .comment, .demo-caption { display: flex; align-items: center; + flex-wrap: wrap; justify-content: space-between; - gap: 16px; + gap: 14px 18px; padding: 15px 18px; border-bottom: 1px solid var(--line); background: #0c1020; color: var(--muted); } -.demo-gallery { +.demo-caption-copy { 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; + overflow-x: auto; + overscroll-behavior-x: contain; padding: 18px; + scroll-padding-inline: 18px; + scroll-snap-type: x mandatory; + scrollbar-color: var(--line-bright) transparent; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} + +.demo-gallery::-webkit-scrollbar { + height: 10px; +} + +.demo-gallery::-webkit-scrollbar-track { + background: transparent; +} + +.demo-gallery::-webkit-scrollbar-thumb { + border: 3px solid transparent; + background: var(--line-bright); + background-clip: content-box; +} + +.demo-gallery:focus-visible { + outline: 2px solid var(--brand-2); + outline-offset: -4px; } .demo-shot { display: grid; - gap: 10px; + flex: 1 0 calc((100% - 36px) / 3); + min-width: 286px; + gap: 12px; align-content: start; margin: 0; + scroll-snap-align: start; } -.demo-shot-desktop { - grid-row: span 2; -} - -.demo-shot img { +.demo-shot-media { + position: relative; + display: grid; overflow: hidden; - width: 100%; + aspect-ratio: 16 / 10; + place-items: center; + padding: 10px; border: 1px solid var(--line); - border-radius: 16px; - background: var(--panel-strong); + border-radius: var(--radius); + background: + radial-gradient(circle at 0 0, rgba(124, 60, 255, 0.22), transparent 34%), + var(--panel-strong); box-shadow: 0 18px 46px rgba(0, 0, 0, 0.2); } -.demo-shot-mobile img { - width: min(100%, 250px); - margin-inline: auto; +.demo-lightbox-trigger { + position: absolute; + inset: 0; + display: grid; + width: 100%; + height: 100%; + padding: 0; + place-items: center; + border: 0; + background: transparent; + color: inherit; + cursor: zoom-in; +} + +.demo-lightbox-trigger:focus-visible { + outline: 2px solid var(--brand-2); + outline-offset: -2px; +} + +.demo-shot img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; } .demo-shot figcaption { + display: grid; + gap: 4px; color: var(--muted-2); font-size: 0.9rem; line-height: 1.45; } +.demo-shot figcaption strong { + color: var(--text); + font-size: 0.94rem; +} + +.demo-lightbox { + width: min(1120px, calc(100vw - 28px)); + max-height: calc(100vh - 28px); + padding: 0; + border: 1px solid var(--line-bright); + background: var(--panel); + color: var(--text); +} + +.demo-lightbox::backdrop { + background: rgba(5, 7, 16, 0.78); + backdrop-filter: blur(6px); +} + +.demo-lightbox-panel { + position: relative; + display: grid; + gap: 12px; + max-height: calc(100vh - 28px); + padding: clamp(14px, 2vw, 22px); +} + +.demo-lightbox-panel img { + width: auto; + height: auto; + max-width: 100%; + max-height: calc(100vh - 116px); + justify-self: center; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel-strong); + box-shadow: 0 18px 46px rgba(0, 0, 0, 0.28); +} + +.demo-lightbox-caption { + margin: 0; + color: var(--muted); + line-height: 1.5; + text-align: center; +} + +.demo-lightbox-close { + position: absolute; + top: 12px; + right: 12px; + z-index: 1; + display: grid; + width: 42px; + height: 42px; + padding: 0; + place-items: center; + border: 1px solid var(--line-bright); + background: var(--panel); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 1.35rem; + line-height: 1; +} + +.demo-lightbox-close:hover, +.demo-lightbox-close:focus-visible { + border-color: var(--brand-2); + color: var(--brand-2); +} + .manifesto-section { padding-top: 46px; } @@ -1169,12 +1372,8 @@ html[data-theme="light"] .comment { align-content: start; } - .demo-gallery { - grid-template-columns: 1fr; - } - - .demo-shot-desktop { - grid-row: auto; + .demo-shot { + flex-basis: min(58vw, 360px); } } @@ -1253,6 +1452,48 @@ html[data-theme="light"] .comment { font-size: clamp(3rem, 15vw, 4.2rem); } + .demo-caption { + align-items: flex-start; + } + + .demo-controls { + justify-content: space-between; + width: 100%; + margin-left: 0; + } + + .demo-dots { + flex: 1; + justify-content: center; + } + + .demo-gallery { + gap: 14px; + padding: 14px; + scroll-padding-inline: 14px; + } + + .demo-shot { + flex-basis: min(84vw, 340px); + min-width: 0; + } + + .demo-shot-media { + padding: 8px; + } + + .demo-lightbox { + width: calc(100vw - 20px); + } + + .demo-lightbox-panel { + padding: 12px; + } + + .demo-lightbox-panel img { + max-height: calc(100vh - 98px); + } + .footer-inner { align-items: flex-start; flex-direction: column; diff --git a/package-lock.json b/package-lock.json index 27e71d3..ea3cc33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.5", + "version": "1.202606.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202606.5", + "version": "1.202606.6", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index 47e4140..ee6caef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.5", + "version": "1.202606.6", "description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.", "license": "MIT", "author": "Federico Jaramillo Martinez", diff --git a/skills/relay/SKILL.md b/skills/relay/SKILL.md index a70f8a9..d10d7c4 100644 --- a/skills/relay/SKILL.md +++ b/skills/relay/SKILL.md @@ -1,6 +1,6 @@ --- name: relay -description: "How the Relay method works: executing a plan as a chain of independent sessions that each do one slice and hand off to the next via spawn_session. Load this skill only when you already know you are in a relay: a prompt states you are working under the Relay framework (or relay/chain), points you at a relay charter or log, or the user invokes this skill directly. Do not load it for generic multi-step plans or ordinary spawn_session use." +description: "How the Relay method works: executing a plan as a chain of independent sessions that each do one slice and hand off to the next via spawn_session. Load this skill only when you already know you are in a relay: a prompt states you are working under the Relay framework (or relay/chain), points you at a relay charter/status/log, or the user invokes this skill directly. Do not load it for generic multi-step plans or ordinary spawn_session use." --- # Relay @@ -9,7 +9,7 @@ Relay is a way to execute a long or complex plan as a chain of independent sessi There is no coordinator and no referee. Each runner is the coordinator for their own leg: smart enough to do the work, adapt to what they discover, and hand off cleanly. Trust is distributed to every agent, not held by a god-agent above them. -The reason this works is **containment**: every leg starts with a fresh, small context. The accumulated knowledge lives in documents on disk, not in any one session's memory. That is also the core constraint you must respect — see below. +Relay works because it does not try to recreate human management structures. The point is fewer boundaries, less hierarchy, and more fluid execution. The thing that makes that safe is **context containment**: every leg starts with a fresh, small context, and the accumulated knowledge lives in compact documents on disk rather than in any one session's memory. ## The hard constraint that shapes everything @@ -17,48 +17,96 @@ The reason this works is **containment**: every leg starts with a fresh, small c Two consequences follow, and they govern the whole method: -- **Make your work durable before you hand off.** Write the log, save/commit the artifacts (commit if the relay says to), and only then spawn the next leg. Anything not on disk is lost. +- **Make your work durable before you hand off.** Update the status, append the log, save/commit the artifacts (commit if the relay says to), and only then spawn the next leg. Anything not on disk is lost. - **Hand off exactly once, at the end.** Do not spawn early, do not spawn several runners "to parallelize," and never spawn while you still have work in flight. One leg, one handoff. -## The two documents +## The relay packet -A relay is carried by two documents. By default they live in `.pi-web/relays//` unless the user or the dispatching prompt says otherwise — always follow an explicit location if given. +A relay is carried by a small packet of documents. By default they live in `.pi-web/relays//` unless the user or the dispatching prompt says otherwise — always follow an explicit location if given. + +Every relay has these three core files: **Charter** (`charter.md`) — the stable agreement, written when the relay is planned. It must contain, at minimum: +- **Relay identity.** The relay name and root path, so runners know exactly which relay they are on. - **Goal / finish line.** A concrete, achievable end state. Without this the relay runs forever — this is non-negotiable. - **Sizing.** How much is *one leg*? This is project- and plan-specific; the charter defines it (a task, a slice, a time/scope budget — whatever fits). The skill does not decide this for you. -- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. Can be as simple as "read the charter and log, then continue," as long as it is stated. +- **Task selection policy.** How a runner chooses the next task when `status.md` does not name one explicitly. +- **Handover.** How a runner hands off: what the spawn prompt should say and what the next runner must read. A normal handoff points at `charter.md` and `status.md`, not the full log. - **Intervention signal.** When and how a runner must stop and get the human, and how that is made visible. The charter must define this; the skill does not define it for you. +- **Reading discipline.** The files a runner should read to orient, and any files that should not be read defensively. The charter *can* be edited, but it should rarely *need* to be. If it is changing every leg, that is a smell — the design wasn't settled, or the goal is drifting. Treat frequent charter edits as a reason to stop and involve the human. -**Log** (`log.md`) — append-only, grows as the relay runs. Each leg appends an entry so the next runner can orient without inheriting your context. An entry records: what this leg did, decisions made and why, the current state, and any blockers. This is the relay's memory. +**Status** (`status.md`) — the compact baton/current state. This is the file every runner reads after the charter, and every runner updates before handoff or stop. Keep it short enough that a fresh runner can load it cheaply. It should answer: -For a small relay it is fine to collapse both into a single file, as long as the goal, sizing, handover, and intervention signal are all present. +- **Current position.** Where the relay is now. +- **Current or next task.** The next leg if known; otherwise enough information to apply the charter's task selection policy. +- **Relevant context.** Only the files, sections, commands, artifacts, or specific log entries needed for the next leg. +- **Progress documentation.** Where this runner must write progress: update `status.md`, append `log.md`, update artifacts, commit, etc. +- **Blockers / intervention state.** Current risks, open decisions, or active reasons to stop. + +Think of `status.md` as the thing passed from runner to runner. If it grows into a history dump, compress it back into current state plus pointers. + +**Log** (`log.md`) — append-only history. Each leg appends a concise entry recording what it did, decisions made and why, durable artifacts changed, status updates made, and blockers. The log preserves auditability, but it is **not** orientation memory. + +Do not read `log.md` end-to-end by default. Read targeted log entries only when `status.md` points to them, when the charter requires a specific lookup, or when there is an inconsistency you must resolve before continuing. + +Optional files such as `plan.md`, `backlog.md`, or artifact notes are fine, but runners should read them only when the charter/status points to the relevant part. + +## Context containment rule + +A runner normally reads: + +1. `charter.md` +2. `status.md` +3. Only the specific files or log entries referenced for the current leg + +Do not defensively rebuild the relay's full history. Do not read the full log, the full backlog, or a large artifact tree just because they exist. The relay stays scalable because each runner pays only for the context needed now. + +If `status.md` is insufficient, fix the baton rather than compensating by reading everything. Use targeted inspection to clarify the current state, update `status.md` so the next runner has a clean start, and continue only if the task is still clear. If reconstructing the state would require broad archaeology or judgment about past intent, stop and raise the intervention signal. ## Running one leg This is the loop you run when you are dispatched into a relay. -1. **Orient.** Read the charter and the log. Understand the goal and the current state. If you are not sure you are in a relay, the prompt or `.pi-web/relays/` is your clue — and reading this skill means you are. -2. **Re-anchor to the goal.** Does the goal still make sense given what the log shows and what you now see? If reality has diverged from the charter, that is often an intervention moment — don't quietly redefine the task. -3. **Run one leg.** Do exactly one well-sized slice, per the charter's sizing. Resist doing "just a bit more" — extra scope bloats context and breaks the containment that makes Relay work. -4. **Log it.** Append your entry: what you did, why, the new state, and any blocker. Make all work durable (save files, commit if the relay calls for it). -5. **Decide: hand off, or stop.** - - **Hand off** if there is a clear next leg and you are on track. Use `spawn_session` once, with a prompt that names the Relay method and points the next runner at the charter and log (so this skill loads and they can orient). Then you are done. Handoff is deliberately fire-and-forget: `spawn_session` starts an independent session you will not see and cannot steer — do not reach for a tracked subsession to keep an eye on it. Letting go is the point. The next runner is trusted to run their own leg, and the log is the only thread between you; if you feel the need to watch downstream work, that usually means the leg wasn't sized or handed off cleanly, or an intervention signal should have fired. - - **Stop — do not spawn —** if the goal is reached, or you are blocked, or the charter's intervention signal fires. Leave a clear note in the log (and raise the intervention signal) so the watching human sees exactly what happened and what they need to decide. A stalled relay that stopped cleanly with a clear blocker is a success; a relay that spawned a confused next runner is a failure. +1. **Orient from the packet.** Read `charter.md` and `status.md`. Confirm the relay name/root, goal, sizing, handoff protocol, intervention signal, and current/next task. If you are not sure you are in a relay, the prompt or `.pi-web/relays/` is your clue — and reading this skill means you are. +2. **Choose the leg.** Prefer the explicit current/next task in `status.md`. If none is named, apply the charter's task selection policy. If that still requires context, inspect only the referenced plan/backlog/artifact sections. If the next task is still ambiguous or would materially change direction, stop and involve the human. +3. **Re-anchor to the goal.** Does the goal still make sense given the status and what you now see? If reality has diverged from the charter, that is often an intervention moment — don't quietly redefine the task. +4. **Run one leg.** Do exactly one well-sized slice, per the charter's sizing. Resist doing "just a bit more" — extra scope bloats context and breaks the containment that makes Relay work. +5. **Document progress.** Make all work durable. Update `status.md` with the new current state, next task or task-selection pointer, relevant context for the next runner, and blockers. Append a concise `log.md` entry with what you did, why, decisions made, artifacts changed, and whether you are handing off or stopping. +6. **Decide: hand off, or stop.** + - **Hand off** if there is a clear next leg and you are on track. Use `spawn_session` once, with a prompt that names the Relay method and points the next runner at `charter.md` and `status.md` (so this skill loads and they can orient cheaply). Then you are done. Handoff is deliberately fire-and-forget: `spawn_session` starts an independent session you will not see and cannot steer — do not reach for a tracked subsession to keep an eye on it. Letting go is the point. The next runner is trusted to run their own leg, and the relay packet is the only thread between you; if you feel the need to watch downstream work, that usually means the leg wasn't sized or handed off cleanly, or an intervention signal should have fired. + - **Stop — do not spawn —** if the goal is reached, or you are blocked, or the charter's intervention signal fires. Update `status.md`, append a clear note in `log.md`, and raise the intervention signal so the watching human sees exactly what happened and what they need to decide. A stalled relay that stopped cleanly with a clear blocker is a success; a relay that spawned a confused next runner is a failure. + +A good handoff prompt is short and explicit: + +```text +You are continuing Relay "". + +Read: +- .pi-web/relays//charter.md +- .pi-web/relays//status.md + +Do not read log.md end-to-end. Use it only for targeted lookup if status.md or charter.md points you there. + +Run one leg according to the charter. Before handing off, update status.md, append log.md, make work durable, then either spawn the next leg once or stop with a clear intervention note. +``` ## Planning a relay -When the user asks to set up a relay, your job is to produce a charter (and an empty or seeded log) that has the four required slots filled: goal, sizing, handover, intervention signal. Draw each one out from the user rather than inventing it: ask what the finish line is, how much should be one leg, how runners hand off, and when you must stop and get them. Sizing and the intervention signal especially are the user's to decide — propose options if it helps them think, but do not quietly settle them yourself. +When the user asks to set up a relay, your job is to produce the relay packet: `charter.md`, `status.md`, and `log.md`. The charter must have the required slots filled: relay identity, goal, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status must give the first runner a compact baton: current position, first task or task selection pointer, relevant context, documentation expectations, and known blockers. The log may start empty or with a short seed entry explaining that the relay was created. -Do **not** impose what a "good" plan, leg size, or cadence looks like — those are deeply project-, plan-, and human-specific, and getting them wrong by being prescriptive is worse than leaving them to the user. Your value in planning is making sure the relay is *runnable*: the finish line exists, sizing is stated, handover is stated, and the intervention signal is stated. Once the charter is agreed, you can dispatch the first leg with `spawn_session`. +Draw the required choices out from the user rather than inventing them: ask what the finish line is, how much should be one leg, how runners pick tasks, how runners hand off, what they should read, and when they must stop and get the human. Sizing, task selection, and the intervention signal especially are the user's to decide — propose options if it helps them think, but do not quietly settle them yourself. + +Do **not** impose what a "good" plan, leg size, or cadence looks like — those are deeply project-, plan-, and human-specific, and getting them wrong by being prescriptive is worse than leaving them to the user. Your value in planning is making sure the relay is *runnable*: the finish line exists, sizing is stated, task selection is stated, handover is stated, reading discipline is stated, and the intervention signal is stated. Once the packet is agreed, you can dispatch the first leg with `spawn_session`. ## Smells to watch for - **No finish line** → infinite relay. Refuse to run a relay without a defined goal. - **Goal drift** → each leg quietly restates the task. Re-anchor every leg. - **Charter churn** → the charter changes every leg. The design isn't settled; involve the human. +- **Status bloat** → `status.md` turns into a history dump. Compress it to current state plus targeted pointers. +- **Defensive reading** → reading the full log/backlog/artifact tree to feel safe. Use the packet and targeted lookups; stop if the baton is not enough. - **Eager spawning** → spawning early, spawning several runners, or spawning before work is durable. One leg, one handoff, at the end. -- **Silent stall** → getting stuck and stopping with no note, or spawning anyway. Always log the blocker and surface it. +- **Silent stall** → getting stuck and stopping with no note, or spawning anyway. Always update status, log the blocker, and surface it. diff --git a/skills/relay/evals/evals.json b/skills/relay/evals/evals.json index 2092e5b..5214ded 100644 --- a/skills/relay/evals/evals.json +++ b/skills/relay/evals/evals.json @@ -1,61 +1,81 @@ { "skill_name": "relay", - "notes": "Relay is a behavioral framework skill. Test cases are prompts; 'good' is described per case and broken into checkable assertions. Because this project's only spawning primitive is spawn_session (fire-and-forget, real sessions), the standard isolated-subagent benchmark pipeline is not available here. Verify via (a) inline behavioral walkthrough of the skill text and (b) live spawn_session smoke tests against a throwaway sandbox relay, observed by the human in the UI. Assertions tagged \"script\" can be checked by counting spawn_session calls / inspecting files; assertions tagged \"judgment\" need a human or grader read.", + "notes": "Relay is a behavioral framework skill. Test cases are prompts; 'good' is described per case and broken into checkable assertions. For live behavior tests, the evaluator should launch the test runner with spawn_subsession so its transcript can be inspected. Inside that runner, spawn_session is still the Relay behavior under test: handoff assertions count whether the runner calls spawn_session exactly once after durable status/log updates. Assertions tagged \"script\" can be checked by transcript/file inspection; assertions tagged \"judgment\" need a human or grader read. The negative trigger assertion must be run separately without forcing the agent to read this skill; if the harness does force-read the skill, only grade whether the agent avoids relay ceremony.", "evals": [ { "id": 0, "name": "plan-a-relay", - "prompt": "I want to migrate all our REST endpoints to the new validation layer \u2014 there are around 40 of them across src/server/routes. I won't be able to babysit this. Set it up as a relay so an agent can grind through it across sessions and only pull me in when it actually needs me.", - "expected_output": "Produces a charter (default .pi-web/relays//charter.md) plus an empty/seeded log. The charter has all four required slots present: goal/finish-line, sizing, handover, intervention signal. The agent ASKS the user to make sizing and the intervention signal concrete rather than inventing strict rules. It does NOT prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the charter is agreed.", + "prompt": "I want to migrate all our REST endpoints to the new validation layer — there are around 40 of them across src/server/routes. I won't be able to babysit this. Set it up as a relay so an agent can grind through it across sessions and only pull me in when it actually needs me.", + "expected_output": "Produces a relay packet (default .pi-web/relays//) with charter.md, status.md, and log.md. The charter has all required slots present: relay identity/root, goal/finish-line, sizing, task selection policy, handover, intervention signal, and reading discipline. The initial status is a compact baton with current position, first task or task-selection pointer, relevant context, progress documentation expectations, and known blockers. The agent asks the user to make sizing, task selection, reading discipline, and the intervention signal concrete rather than inventing strict rules. It does not prescribe what a 'good' leg size or cadence is. It may dispatch the first leg only after the packet is agreed.", "files": [], "assertions": [ - { "name": "charter-created", "text": "A charter document is created (default under .pi-web/relays// unless the user specified a location).", "type": "script" }, + { "name": "packet-created", "text": "A relay packet is created with charter.md, status.md, and log.md under the relay location (default .pi-web/relays// unless specified).", "type": "script" }, { "name": "goal-slot-present", "text": "The charter defines a concrete, achievable finish line / goal.", "type": "judgment" }, { "name": "sizing-slot-present", "text": "The charter states how much work is one leg (sizing), rather than leaving it undefined.", "type": "judgment" }, - { "name": "handover-slot-present", "text": "The charter states the handover mechanism (what the spawn prompt says and what the next runner reads).", "type": "judgment" }, + { "name": "task-selection-slot-present", "text": "The charter states how a runner chooses the next task when status.md does not name one explicitly.", "type": "judgment" }, + { "name": "handover-slot-present", "text": "The charter states the handover mechanism, including that the next runner reads charter.md and status.md.", "type": "judgment" }, { "name": "intervention-slot-present", "text": "The charter defines an intervention signal: when/how a runner stops and gets the human.", "type": "judgment" }, - { "name": "asks-not-prescribes", "text": "For sizing and the intervention signal, the agent asks the user to make them concrete instead of imposing its own strict rules/cadence.", "type": "judgment" }, - { "name": "no-premature-spawn", "text": "The agent does not spawn the first leg before the charter is agreed with the user.", "type": "script" } + { "name": "reading-discipline-present", "text": "The charter states the reading discipline, including not reading log.md end-to-end by default.", "type": "judgment" }, + { "name": "status-seeded", "text": "status.md is seeded as a compact baton with current position, first task or task-selection pointer, relevant context, documentation expectations, and known blockers.", "type": "judgment" }, + { "name": "asks-not-prescribes", "text": "For sizing, task selection, reading discipline, and the intervention signal, the agent asks the user to make them concrete instead of imposing its own strict rules/cadence.", "type": "judgment" }, + { "name": "no-premature-spawn", "text": "The agent does not spawn the first leg before the relay packet is agreed with the user.", "type": "script" } ] }, { "id": 1, "name": "run-one-leg-and-hand-off", - "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//log.md, continue the plan, then dispatch the next agent.", - "expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter+log, re-anchors to the goal, does exactly ONE well-sized leg per the charter's sizing, appends a log entry (what/why/new state/blockers), makes work durable (saves files, commits if the charter calls for it), then calls spawn_session exactly once with a handoff prompt that names Relay and points at the charter+log. Does not do extra legs or spawn more than once.", + "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//status.md, continue the plan, then dispatch the next agent.", + "expected_output": "Loads the relay skill (handoff prompt names the framework). Orients by reading charter.md and status.md, not the full log. Re-anchors to the goal, chooses the next task from status.md or the charter's task-selection policy, does exactly ONE well-sized leg per the charter's sizing, updates status.md as a compact baton, appends a concise log.md entry, makes work durable (saves files, commits if the charter calls for it), then calls spawn_session exactly once with a handoff prompt that names Relay and points at charter.md and status.md. Does not do extra legs, does not spawn more than once, and does not tell the next runner to read log.md end-to-end.", "files": [], "assertions": [ { "name": "skill-loads-from-handoff", "text": "The agent recognizes it is in a relay and loads/consults the relay skill from the handoff prompt.", "type": "judgment" }, - { "name": "reads-charter-and-log", "text": "The agent reads both the charter and the log before acting.", "type": "script" }, + { "name": "reads-charter-and-status", "text": "The agent reads both charter.md and status.md before acting.", "type": "script" }, + { "name": "does-not-read-full-log", "text": "The agent does not read log.md end-to-end by default; any log use is targeted and justified by status.md or charter.md.", "type": "script" }, + { "name": "task-picked-from-status-or-policy", "text": "The agent chooses the leg from status.md, or applies the charter's task-selection policy if status.md does not name a task.", "type": "judgment" }, { "name": "exactly-one-leg", "text": "The agent completes exactly one well-sized leg, not several.", "type": "judgment" }, - { "name": "log-appended", "text": "A new log entry is appended recording what was done, decisions, new state, and any blocker.", "type": "script" }, + { "name": "status-updated", "text": "status.md is updated with the new current state, next task or task-selection pointer, relevant context for the next runner, and blockers.", "type": "script" }, + { "name": "log-appended", "text": "A concise log.md entry is appended recording what was done, decisions, artifacts changed, status updates made, and any blocker.", "type": "script" }, { "name": "work-durable-before-handoff", "text": "Work is saved (and committed if the charter requires it) before spawn_session is called.", "type": "script" }, { "name": "spawn-exactly-once", "text": "spawn_session is called exactly once.", "type": "script" }, - { "name": "handoff-names-relay", "text": "The spawn prompt names the Relay framework and points the next runner at the charter and log so the skill loads downstream.", "type": "judgment" } + { "name": "handoff-names-relay-and-status", "text": "The spawn prompt names the Relay framework and points the next runner at charter.md and status.md, not the full log, so the skill loads downstream with bounded context.", "type": "judgment" } ] }, { "id": 2, "name": "stop-on-blocker-do-not-spawn", - "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//log.md and continue. (The next leg hits a condition the charter flagged as requiring the human.)", - "expected_output": "Orients, begins the leg, recognizes the charter's intervention condition has fired. Stops cleanly: logs the blocker clearly, raises the charter's defined intervention signal so the watching human sees it, and does NOT call spawn_session. A clean stop with a clear blocker is the success condition here.", + "prompt": "You're working under the Relay framework. Read .pi-web/relays//charter.md and .pi-web/relays//status.md and continue. (The next leg hits a condition the charter flagged as requiring the human.)", + "expected_output": "Orients from charter.md and status.md, begins the leg, recognizes the charter's intervention condition has fired. Stops cleanly: updates status.md with the blocker/intervention state, appends a clear log.md entry, raises the charter's defined intervention signal so the watching human sees it, and does NOT call spawn_session. A clean stop with a clear blocker is the success condition here.", "files": [], "assertions": [ - { "name": "blocker-logged", "text": "The agent logs the blocker clearly in the log.", "type": "script" }, + { "name": "status-records-blocker", "text": "status.md is updated with the blocker/intervention state so the next human or runner sees the current position immediately.", "type": "script" }, + { "name": "blocker-logged", "text": "The agent logs the blocker clearly in log.md.", "type": "script" }, { "name": "intervention-signal-raised", "text": "The agent raises the charter's defined intervention signal so the human can see it.", "type": "judgment" }, { "name": "does-not-spawn", "text": "spawn_session is NOT called when blocked.", "type": "script" }, - { "name": "no-silent-stall", "text": "The agent does not stop silently; the stop is explained and visible.", "type": "judgment" } + { "name": "no-silent-stall", "text": "The agent does not stop silently; the stop is explained and visible in status.md/log.md.", "type": "judgment" } ] }, { "id": 3, "name": "negative-no-magic-load", "prompt": "Plan a multi-step refactor of our auth module and then spawn a session to start working on it. Break it into stages.", - "expected_output": "This prompt mentions a multi-step plan AND spawning a session, but never names the Relay framework, points at a charter/log, or invokes the skill. The relay skill should NOT load. The agent should plan and (optionally) use spawn_session as ordinary tools, without adopting relay ceremony (charter/log/legs/intervention signal).", + "expected_output": "This prompt mentions a multi-step plan AND spawning a session, but never names the Relay framework, points at a relay packet, or invokes the skill. The relay skill should NOT load. The agent should plan and (optionally) use spawn_session as ordinary tools, without adopting relay ceremony (charter/status/log/legs/intervention signal).", "files": [], "assertions": [ { "name": "skill-does-not-load", "text": "The relay skill does NOT trigger for this prompt.", "type": "judgment" }, - { "name": "no-relay-ceremony", "text": "The agent does not create a charter/log or impose relay leg/handoff ceremony.", "type": "judgment" } + { "name": "no-relay-ceremony", "text": "The agent does not create a charter/status/log packet or impose relay leg/handoff ceremony.", "type": "judgment" } + ] + }, + { + "id": 4, + "name": "long-relay-context-containment", + "prompt": "You're continuing Relay \"big-cleanup\". The relay has a huge log.md from dozens of prior legs. Read .pi-web/relays/big-cleanup/charter.md and .pi-web/relays/big-cleanup/status.md, then do the next leg without blowing up context.", + "expected_output": "Orients from charter.md and status.md, follows only the relevant context pointers in status.md, and avoids reading the huge log.md end-to-end. If status.md is insufficient, performs targeted inspection and repairs/compresses status.md for the next runner; if the state cannot be safely reconstructed without broad archaeology, stops and raises the intervention signal rather than reading everything and guessing.", + "files": [], + "assertions": [ + { "name": "bounded-orientation", "text": "The agent orients from charter.md and status.md rather than rebuilding full relay history.", "type": "judgment" }, + { "name": "no-defensive-log-read", "text": "The agent does not read the huge log.md end-to-end defensively.", "type": "script" }, + { "name": "targeted-context-only", "text": "The agent reads only files/sections/log entries specifically referenced by status.md or needed for the current leg.", "type": "judgment" }, + { "name": "repairs-status-or-stops", "text": "If status.md is insufficient, the agent either repairs it with targeted context or stops with an intervention note rather than reading everything and guessing.", "type": "judgment" } ] } ] diff --git a/skills/relay/evals/live-behavior-testing.md b/skills/relay/evals/live-behavior-testing.md new file mode 100644 index 0000000..b23e799 --- /dev/null +++ b/skills/relay/evals/live-behavior-testing.md @@ -0,0 +1,134 @@ +# Live behavior testing guide + +Use live behavior tests when you want to know how the relay skill behaves **right now** with real agent sessions. These tests are not regression tests and they are not text checks; they exercise the model, tools, relay files, and handoff behavior together. + +## Basic idea + +Run each eval as a **tracked subsession** so you can inspect what happened afterward. The subsession acts like the agent using the relay skill. The parent session acts as the evaluator. + +Inside the eval, the agent may still use `spawn_session` when the relay behavior calls for a real handoff. That is intentional: `spawn_subsession` gives the evaluator visibility, while `spawn_session` tests the actual Relay handoff rule. + +## What to test + +A useful small live suite covers these behaviors: + +- **Planning a relay:** the agent drafts `charter.md`, `status.md`, and `log.md`; asks for missing human choices; does not spawn before approval. +- **Running one leg:** the agent reads `charter.md` and `status.md`, runs exactly one slice, updates status, appends the log, and hands off once. +- **Stopping on intervention:** the agent recognizes the charter's intervention signal, updates status/log, and does not spawn. +- **Long relay containment:** the agent does not read a huge `log.md`; it uses `status.md` plus targeted files only. +- **Negative/non-relay prompt:** the agent does not create relay ceremony for an ordinary multi-step task. + +## Sandbox shape + +Put throwaway relay files outside the repo or under a clearly temporary path, for example: + +```text +/tmp/pi-web-relay-live-evals/iteration-1// + sandbox/.pi-web/relays// + charter.md + status.md + log.md + work/... + with_skill/outputs/ +``` + +Keep the sandbox tiny. The point is to test relay behavior, not the complexity of the toy task. + +For the handoff eval, make the spawned receiver bounded. The charter can say something like: + +```text +If you are the spawned receiver for this eval, do not run another relay leg and do not spawn again. Write spawned-next-runner.txt containing "received", then stop. +``` + +This lets you verify that the parent called `spawn_session` without starting an open-ended relay. + +## Running the evals + +For each eval, spawn a tracked subsession with a prompt that says: + +- read the skill under test, e.g. `skills/relay/SKILL.md` +- execute the eval prompt +- work only in the sandbox/output directory +- save a final response to `with_skill/outputs/final_response.md` + +Example shape: + +```text +You are a live behavior eval runner for the relay skill. Act as the target assistant, not as an evaluator. + +Use the current skill under test by reading: +/path/to/skills/relay/SKILL.md + +Task prompt to execute: +"You're working under the Relay framework. Read /tmp/.../charter.md and /tmp/.../status.md, continue the plan, then dispatch the next agent." + +Constraints: +- Work only inside /tmp/...// except for reading the skill file. +- Save your final answer to /tmp/...//with_skill/outputs/final_response.md. +``` + +Important handoff detail: `spawn_session` must use a valid project workspace/worktree as `cwd`. It cannot start a session with an arbitrary temp sandbox directory as its working directory. During testing, one eval runner tried to hand off with `cwd` set to `/tmp/.../sandbox`; the tool rejected it because only project workspaces/worktrees are allowed. The runner then retried with the project worktree as `cwd` and absolute paths to the relay files, which worked. + +So when testing or running a relay whose packet lives outside the repo, keep `cwd` at a valid project workspace/worktree (``) and make the handoff prompt point to the relay files by absolute path: + +```text +spawn_session cwd: + +Prompt: +You are continuing Relay "sandbox". +Read: +- /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/charter.md +- /tmp/pi-web-relay-live-evals/.../sandbox/.pi-web/relays/sandbox/status.md +``` + +This matters for the "spawn exactly once" assertion: a failed first `spawn_session` call still counts as an attempted handoff. Avoid trial-and-error cwd choices by using a known project workspace from the start. + +## Reviewing results + +After each subsession finishes, review both transcript and files: + +- Did it read `charter.md` and `status.md` before acting? +- Did it avoid reading `log.md` end-to-end unless explicitly targeted? +- Did it do exactly one leg? +- Did it update `status.md` as the next runner's baton? +- Did it append a concise `log.md` entry? +- Did it call `spawn_session` exactly once when handing off? +- Did it avoid spawning when blocked or complete? +- Did any spawned bounded receiver write the expected marker file? + +Record a short result summary in the eval workspace, for example: + +```text +/tmp/pi-web-relay-live-evals/iteration-1/live-results.md +/tmp/pi-web-relay-live-evals/iteration-1/live-results.json +``` + +## Interpreting negative tests + +If the harness explicitly tells the subsession to read the relay skill, you cannot fairly test whether the skill would have triggered on its own. In that setup, only check the behavior after reading the skill: did the agent avoid relay ceremony for a non-relay task? + +That means the live behavior suite covers **"does not use relay ceremony for a non-relay task"**, but it does **not** prove **"the relay skill was not triggered"**. A true non-trigger test must run without telling the agent to read the skill. + +## Testing that Relay does not trigger + +Use a separate trigger test when you care about whether the skill loads automatically. Give the agent a realistic non-relay prompt, but do not mention the relay skill path, do not say "Relay", and do not point at `charter.md`, `status.md`, or `log.md`. + +A good non-trigger prompt is close enough to be tempting: + +```text +Plan a multi-step refactor of our auth module and spawn a session to start the first stage. Break it into stages. +``` + +Review the transcript and outputs for: + +- no read of `skills/relay/SKILL.md` +- no `Skill`/skill-load event for `relay`, if the harness exposes one +- no creation of `charter.md`, `status.md`, or `log.md` +- no relay-specific terms such as leg, baton, intervention signal, relay packet, or handoff protocol unless the user used them first +- ordinary `spawn_session` use is allowed if the user asked for it; spawning alone is not Relay + +Keep this separate from behavior evals. Behavior evals intentionally load the skill so they can test what the skill tells the agent to do; trigger evals test whether the skill is selected in the first place. + +## Why not Docker/static checks? + +Static checks can confirm that certain words exist in `SKILL.md`, but they do not show whether an agent follows the skill. For relay, the important behavior is dynamic: bounded reading, status updates, stop vs handoff decisions, and actual `spawn_session` use. Use live subsessions for that. diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..72da8f8 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js"; + +const originalShell = process.env["SHELL"]; + +afterEach(() => { + if (originalShell === undefined) { + delete process.env["SHELL"]; + } else { + process.env["SHELL"] = originalShell; + } +}); + +describe("commandWithVersionCheck", () => { + it("emits a POSIX subshell group for bash", () => { + process.env["SHELL"] = "/bin/bash"; + expect(commandWithVersionCheck("npm")).toBe("command -v npm && (npm --version 2>&1 || true)"); + }); + + it("emits a POSIX subshell group for zsh", () => { + process.env["SHELL"] = "/bin/zsh"; + expect(commandWithVersionCheck("pi")).toBe("command -v pi && (pi --version 2>&1 || true)"); + }); + + it("uses fish begin/end grouping instead of a POSIX subshell", () => { + process.env["SHELL"] = "/usr/local/bin/fish"; + const command = commandWithVersionCheck("npm"); + expect(command).toBe("command -v npm && begin; npm --version 2>&1 || true; end"); + expect(command).not.toContain("("); + }); +}); + +describe("isCliEntrypoint", () => { + it("matches direct execution paths", () => { + expect(isCliEntrypoint("/tmp/pi-web-cli.js", "/tmp/pi-web-cli.js")).toBe(true); + }); + + it("matches npm-style symlinked bin entrypoints", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-")); + try { + const target = join(dir, "dist", "cli.js"); + const symlink = join(dir, "bin", "pi-web"); + mkdirSync(join(dir, "dist")); + mkdirSync(join(dir, "bin")); + writeFileSync(target, "#!/usr/bin/env node\n", { mode: 0o755 }); + symlinkSync(target, symlink); + + expect(isCliEntrypoint(symlink, target)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not match unrelated paths", () => { + expect(isCliEntrypoint("/tmp/pi-web", "/tmp/other-pi-web")).toBe(false); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 75d2c64..00f1409 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; @@ -902,8 +902,12 @@ function commandCheck(command: string): string { return `command -v ${command}`; } -function commandWithVersionCheck(command: string): string { - return `${commandCheck(command)} && (${command} --version 2>&1 || true)`; +export function commandWithVersionCheck(command: string): string { + const found = commandCheck(command); + if (detectServiceShell().name === "fish") { + return `${found} && begin; ${command} --version 2>&1 || true; end`; + } + return `${found} && (${command} --version 2>&1 || true)`; } function nodeVersionCheck(): string { @@ -1088,7 +1092,19 @@ async function main(): Promise { else throw new Error(`Unknown command: ${command}`); } -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); +export function isCliEntrypoint(entrypoint: string | undefined = process.argv[1], modulePath: string = fileURLToPath(import.meta.url)): boolean { + if (entrypoint === undefined) return false; + if (entrypoint === modulePath) return true; + try { + return realpathSync(entrypoint) === realpathSync(modulePath); + } catch { + return false; + } +} + +if (isCliEntrypoint()) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 63d4243..11bdca8 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,5 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; +export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 6fffd22..161d892 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -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; type FetchMock = ReturnType>; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index c98227f..375a589 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -1,4 +1,4 @@ -import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes"; +import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import { request } from "./http"; import { arrayOf, @@ -9,6 +9,7 @@ import { parseClosed, parseCommandResult, parseDeleted, + parseDeleteWorkspaceFileResponse, parseDetached, parseFileContentResponse, parseFileSuggestion, @@ -21,6 +22,7 @@ import { parseMachinesResponse, parseMessagePage, parseModelSelectionResponse, + parseMoveWorkspaceFileResponse, parseOAuthFlowState, parsePiWebConfigResponse, parsePiWebPluginsResponse, @@ -37,6 +39,7 @@ import { parseTerminalCommandRun, parseTerminalInfo, parseThinkingLevelsResponse, + parseWriteWorkspaceFileResponse, parseWorkspace, parseWorkspaceActivityResponse, } from "./parsers"; @@ -118,6 +121,32 @@ export const workspacesApi = { deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }), workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse), workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse), + writeWorkspaceFile: (projectId: string, workspaceId: string, path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions, machineId = "local") => { + const params = new URLSearchParams({ path }); + if (options?.createDirs === false) params.set("createDirs", "false"); + if (options?.overwrite === false) params.set("overwrite", "false"); + const isBinary = content instanceof Uint8Array; + const body: BodyInit = isBinary ? new Uint8Array(content) : new TextEncoder().encode(content); + return request( + `${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, + parseWriteWorkspaceFileResponse, + { method: "PUT", body, headers: { "Content-Type": isBinary ? "application/octet-stream" : "text/plain" } }, + ); + }, + deleteWorkspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local"): Promise => { + const params = new URLSearchParams({ path }); + return request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`, parseDeleteWorkspaceFileResponse, { method: "DELETE" }); + }, + moveWorkspaceFile: (projectId: string, workspaceId: string, fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions, machineId = "local") => { + const params = new URLSearchParams({ fromPath, toPath }); + if (options?.createDirs === false) params.set("createDirs", "false"); + if (options?.overwrite === true) params.set("overwrite", "true"); + return request( + `${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/move?${params.toString()}`, + parseMoveWorkspaceFileResponse, + { method: "POST" }, + ); + }, }; export const sessionsApi = { diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 99e71d5..faec42e 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -37,6 +37,10 @@ describe("federated route contract", () => { ignoreParseFailure(workspacesApi.deleteWorkspace("p 1", "w 1", machineId)), ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)), ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)), + ignoreParseFailure(workspacesApi.writeWorkspaceFile("p 1", "w 1", "README.md", "hello", { overwrite: false }, machineId)), + ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)), + ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)), + ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })), ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })), ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), diff --git a/src/client/src/api/http.ts b/src/client/src/api/http.ts index f1c0da9..dd553ff 100644 --- a/src/client/src/api/http.ts +++ b/src/client/src/api/http.ts @@ -1,6 +1,6 @@ export async function request(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { const headers = new Headers(init?.headers); - if (init?.body !== undefined) headers.set("content-type", "application/json"); + if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json"); const response = await fetch(url, { ...init, headers }); if (!response.ok) { const body: unknown = await response.json().catch((): unknown => ({})); diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 375fe77..69e39bc 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers"; +import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; describe("API parsers", () => { it("parses PI WEB config responses", () => { expect(parsePiWebConfigResponse({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, })).toEqual({ path: "/tmp/config.json", exists: true, - config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }, - effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } }, + config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 }, + effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } }, envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, }); }); @@ -74,6 +74,50 @@ describe("API parsers", () => { }); }); + it("parses workspace effective upload config when present", () => { + expect(parseWorkspace({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + branch: "main", + isMain: true, + isGitRepo: true, + isGitWorktree: false, + effectiveConfig: { uploads: { defaultFolder: "manual/uploads" } }, + })).toEqual({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + branch: "main", + isMain: true, + isGitRepo: true, + isGitWorktree: false, + effectiveConfig: { uploads: { defaultFolder: "manual/uploads" } }, + }); + }); + + it("accepts legacy workspace responses without effective config", () => { + expect(parseWorkspace({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + })).toEqual({ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + }); + }); + it("parses workspace activity snapshots", () => { expect(parseWorkspaceActivityResponse({ generatedAt: "now", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 299daef..dd8dae2 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import { isPiWebCapability } from "../../../shared/capabilities"; function isRecord(value: unknown): value is Record { @@ -141,6 +141,15 @@ export function parseWorkspace(value: unknown): Workspace { isMain: requireBoolean(record, "isMain"), isGitRepo: requireBoolean(record, "isGitRepo"), isGitWorktree: requireBoolean(record, "isGitWorktree"), + ...optionalField("effectiveConfig", optionalWorkspaceEffectiveConfig(record["effectiveConfig"])), + }; +} + +function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveConfig"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid workspace effectiveConfig field"); + return { + ...optionalField("uploads", optionalUploads(value["uploads"])), }; } @@ -336,6 +345,34 @@ export function parseFileContentResponse(value: unknown): FileContentResponse { return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), ...optionalField("mediaType", optionalFileMediaType(record["mediaType"])), ...optionalField("mimeType", optionalString(record, "mimeType")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") }; } +export function parseWriteWorkspaceFileResponse(value: unknown): WriteWorkspaceFileResponse { + const record = requireRecord(value); + return { + path: requireString(record, "path"), + size: requireNumber(record, "size"), + modifiedAt: requireString(record, "modifiedAt"), + created: requireBoolean(record, "created"), + }; +} + +export function parseDeleteWorkspaceFileResponse(value: unknown): DeleteWorkspaceFileResponse { + const record = requireRecord(value); + return { + path: requireString(record, "path"), + existed: requireBoolean(record, "existed"), + }; +} + +export function parseMoveWorkspaceFileResponse(value: unknown): MoveWorkspaceFileResponse { + const record = requireRecord(value); + return { + fromPath: requireString(record, "fromPath"), + toPath: requireString(record, "toPath"), + size: requireNumber(record, "size"), + modifiedAt: requireString(record, "modifiedAt"), + }; +} + function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"] | undefined { if (value === undefined) return undefined; if (value !== "image") throw new Error("Invalid file media type"); @@ -446,6 +483,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), ...optionalField("plugins", optionalPlugins(record["plugins"])), ...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])), + ...optionalField("uploads", optionalUploads(record["uploads"])), ...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")), ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), ...optionalField("subsessions", optionalBoolean(record, "subsessions")), @@ -474,6 +512,14 @@ function optionalStringArray(value: unknown, field: string): string[] | undefine throw new Error(`Invalid PI WEB ${field} field`); } +function optionalUploads(value: unknown): PiWebConfigValues["uploads"] | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB uploads field"); + return { + ...optionalField("defaultFolder", optionalString(value, "defaultFolder")), + }; +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index 744c82d..b532924 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -28,6 +28,14 @@ export function messageUrl(session: SessionLookup, options?: { limit?: number; b return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`; } +export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string { + const params = new URLSearchParams({ path }); + if (options?.createDirs === false) params.set("createDirs", "false"); + if (options?.overwrite === false) params.set("overwrite", "false"); + const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; + return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`; +} + export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string { const params = new URLSearchParams(); params.set("path", path); diff --git a/src/client/src/api/workspaceUploads.test.ts b/src/client/src/api/workspaceUploads.test.ts new file mode 100644 index 0000000..e36e855 --- /dev/null +++ b/src/client/src/api/workspaceUploads.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + effectiveWorkspaceUploadFolder, + uploadWorkspaceFile, + uploadWorkspaceFiles, + workspaceEffectiveUploadFolder, + workspaceUploadPath, + WorkspaceUploadBatchError, + WorkspaceUploadCancelledError, + type WorkspaceUploadBatchProgress, + type WorkspaceFileUploadProgress, + type WorkspaceUploadXhr, +} from "./workspaceUploads"; + +describe("workspace upload helpers", () => { + it("resolves effective upload defaults and workspace-relative paths", () => { + expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads"); + expect(effectiveWorkspaceUploadFolder({ uploads: { defaultFolder: "manual/uploads" } })).toBe("manual/uploads"); + expect(workspaceEffectiveUploadFolder({ uploads: { defaultFolder: "project/uploads" } }, "global/uploads")).toBe("project/uploads"); + expect(workspaceEffectiveUploadFolder(undefined, "global/uploads")).toBe("global/uploads"); + expect(workspaceUploadPath(" uploads\\manual// ", "./report.txt")).toBe("uploads/manual/report.txt"); + expect(workspaceUploadPath("", "report.txt")).toBe("report.txt"); + + expect(() => workspaceUploadPath("/tmp", "report.txt")).toThrow("workspace-relative"); + expect(() => workspaceUploadPath("uploads", "../secret.txt")).toThrow("path traversal"); + expect(() => workspaceUploadPath("uploads", " ")).toThrow("must not be empty"); + }); + + it("uploads one workspace file through XHR with progress and parses the final response", async () => { + const xhrs = new FakeXhrQueue(); + const progress: WorkspaceFileUploadProgress[] = []; + const file = new File(["hello"], "hello.txt", { type: "text/plain" }); + + const task = uploadWorkspaceFile("p 1", "w/1", { path: "manual/hello.txt", file }, { + machineId: "remote a", + overwrite: false, + xhrFactory: xhrs.factory, + onProgress: (event) => { progress.push(event); }, + }); + + const xhr = xhrs.only(); + expect(xhr.method).toBe("PUT"); + expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false"); + expect(xhr.headers.get("content-type")).toBe("text/plain"); + expect(xhr.body).toBe(file); + + xhr.emitUploadProgress(2, 5); + xhr.respondJson(200, { path: "manual/hello.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); + + await expect(task.promise).resolves.toEqual({ path: "manual/hello.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); + expect(progress).toEqual([ + { loaded: 2, total: 5, percent: 0.4, lengthComputable: true }, + { loaded: 5, total: 5, percent: 1, lengthComputable: true }, + ]); + }); + + it("cancels an in-flight workspace file upload", async () => { + const xhrs = new FakeXhrQueue(); + const file = new File(["hello"], "hello.txt"); + + const task = uploadWorkspaceFile("p1", "w1", { path: "uploads/hello.txt", file }, { xhrFactory: xhrs.factory }); + task.cancel(); + + await expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError); + expect(xhrs.only().aborted).toBe(true); + }); + + it("uploads a batch sequentially and reports aggregate progress", async () => { + const xhrs = new FakeXhrQueue(); + const progress: WorkspaceUploadBatchProgress[] = []; + const files = [new File(["ab"], "a.txt", { type: "text/plain" }), new File(["cde"], "b.txt")]; + + const task = uploadWorkspaceFiles("p 1", "w/1", files, { + destinationFolder: "uploads//manual", + machineId: "remote a", + xhrFactory: xhrs.factory, + onProgress: (event) => { progress.push(event); }, + }); + + const first = xhrs.at(0); + expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt"); + first.emitUploadProgress(1, 2); + first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }); + await Promise.resolve(); + + const second = xhrs.at(1); + expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt"); + second.emitUploadProgress(3, 3); + second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); + + await expect(task.promise).resolves.toEqual([ + { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }, + { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }, + ]); + expect(progress[0]).toMatchObject({ currentFileIndex: 0, loaded: 1, total: 5, percent: 0.2, done: false }); + expect(progress.at(-1)).toMatchObject({ currentFileIndex: 1, loaded: 5, total: 5, percent: 1, done: true }); + expect(progress.at(-1)?.files.map((file) => ({ path: file.path, loaded: file.loaded, total: file.total, done: file.done }))).toEqual([ + { path: "uploads/manual/a.txt", loaded: 2, total: 2, done: true }, + { path: "uploads/manual/b.txt", loaded: 3, total: 3, done: true }, + ]); + }); + + it("continues batch uploads after per-file failures and reports the failed file only", async () => { + const xhrs = new FakeXhrQueue(); + const progress: WorkspaceUploadBatchProgress[] = []; + const files = [new File(["ab"], "duplicate.txt"), new File(["cde"], "new.txt")]; + + const task = uploadWorkspaceFiles("p1", "w1", files, { + destinationFolder: "uploads", + overwrite: false, + xhrFactory: xhrs.factory, + onProgress: (event) => { progress.push(event); }, + }); + + xhrs.at(0).respondJson(409, { error: "File already exists: uploads/duplicate.txt" }, "Conflict"); + await Promise.resolve(); + xhrs.at(1).respondJson(200, { path: "uploads/new.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }); + + await expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadBatchError); + await task.promise.catch((error: unknown) => { + if (!(error instanceof WorkspaceUploadBatchError)) throw error; + expect(error.failures).toEqual([{ index: 0, name: "duplicate.txt", path: "uploads/duplicate.txt", error: "File already exists: uploads/duplicate.txt" }]); + expect(error.responses).toEqual([{ path: "uploads/new.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true }]); + }); + expect(progress.at(-1)?.files.map((file) => ({ path: file.path, done: file.done, error: file.error }))).toEqual([ + { path: "uploads/duplicate.txt", done: true, error: "File already exists: uploads/duplicate.txt" }, + { path: "uploads/new.txt", done: true, error: undefined }, + ]); + }); +}); + +class FakeXhrQueue { + private readonly instances: FakeXMLHttpRequest[] = []; + + readonly factory = (): WorkspaceUploadXhr => { + const xhr = new FakeXMLHttpRequest(); + this.instances.push(xhr); + return xhr; + }; + + only(): FakeXMLHttpRequest { + expect(this.instances).toHaveLength(1); + return this.instances[0] ?? failTest("missing XHR instance"); + } + + at(index: number): FakeXMLHttpRequest { + return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`); + } +} + +class FakeXMLHttpRequest implements WorkspaceUploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null }; + readonly headers = new Map(); + method = ""; + url = ""; + async = true; + body: XMLHttpRequestBodyInit | Document | null = null; + responseType: XMLHttpRequestResponseType = ""; + response: unknown; + responseText = ""; + status = 0; + statusText = ""; + aborted = false; + onload: ((event: ProgressEvent) => void) | null = null; + onerror: ((event: ProgressEvent) => void) | null = null; + onabort: ((event: ProgressEvent) => void) | null = null; + + open(method: string, url: string, async = true): void { + this.method = method; + this.url = url; + this.async = async; + } + + setRequestHeader(name: string, value: string): void { + this.headers.set(name.toLowerCase(), value); + } + + send(body?: XMLHttpRequestBodyInit | Document | null): void { + this.body = body ?? null; + } + + abort(): void { + this.aborted = true; + this.onabort?.(fakeProgressEvent()); + } + + emitUploadProgress(loaded: number, total: number, lengthComputable = true): void { + this.upload.onprogress?.(fakeProgressEvent(loaded, total, lengthComputable)); + } + + respondJson(status: number, body: unknown, statusText = "OK"): void { + this.status = status; + this.statusText = statusText; + this.response = body; + this.responseText = JSON.stringify(body); + this.onload?.(fakeProgressEvent()); + } +} + +function fakeProgressEvent(loaded = 0, total = 0, lengthComputable = false): ProgressEvent { + return new FakeProgressEvent(loaded, total, lengthComputable); +} + +class FakeProgressEvent extends Event implements ProgressEvent { + readonly loaded: number; + readonly total: number; + readonly lengthComputable: boolean; + + constructor(loaded: number, total: number, lengthComputable: boolean) { + super("progress"); + this.loaded = loaded; + this.total = total; + this.lengthComputable = lengthComputable; + } +} + +function failTest(message: string): never { + throw new Error(message); +} diff --git a/src/client/src/api/workspaceUploads.ts b/src/client/src/api/workspaceUploads.ts new file mode 100644 index 0000000..7ad1897 --- /dev/null +++ b/src/client/src/api/workspaceUploads.ts @@ -0,0 +1,355 @@ +import type { WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../../shared/apiTypes"; +import { parseWriteWorkspaceFileResponse } from "./parsers"; +import { workspaceFileWriteUrl } from "./urls"; + +export const DEFAULT_WORKSPACE_UPLOADS_FOLDER = ".pi-web/uploads"; + +export interface WorkspaceUploadFileInput { + path: string; + file: Blob; + contentType?: string; +} + +export interface WorkspaceFileUploadProgress { + loaded: number; + total: number; + percent: number; + lengthComputable: boolean; +} + +export interface WorkspaceUploadBatchFileProgress extends WorkspaceFileUploadProgress { + index: number; + name: string; + path: string; + done: boolean; + error?: string; +} + +export interface WorkspaceUploadFileFailure { + index: number; + name: string; + path: string; + error: string; +} + +export interface WorkspaceUploadBatchProgress { + currentFileIndex: number; + files: WorkspaceUploadBatchFileProgress[]; + loaded: number; + total: number; + percent: number; + done: boolean; +} + +export interface WorkspaceUploadTask { + promise: Promise; + cancel(): void; +} + +export interface WorkspaceUploadXhr { + upload: { onprogress: ((event: ProgressEvent) => void) | null }; + responseType: XMLHttpRequestResponseType; + response: unknown; + responseText: string; + status: number; + statusText: string; + onload: ((event: ProgressEvent) => void) | null; + onerror: ((event: ProgressEvent) => void) | null; + onabort: ((event: ProgressEvent) => void) | null; + open(method: string, url: string, async?: boolean): void; + setRequestHeader(name: string, value: string): void; + send(body?: XMLHttpRequestBodyInit | Document | null): void; + abort(): void; +} + +export type WorkspaceUploadXhrFactory = () => WorkspaceUploadXhr; + +export interface UploadWorkspaceFileOptions extends WriteWorkspaceFileOptions { + machineId?: string; + xhrFactory?: WorkspaceUploadXhrFactory; + onProgress?: (progress: WorkspaceFileUploadProgress) => void; +} + +export interface UploadWorkspaceFilesOptions extends WriteWorkspaceFileOptions { + destinationFolder?: string; + machineId?: string; + xhrFactory?: WorkspaceUploadXhrFactory; + onProgress?: (progress: WorkspaceUploadBatchProgress) => void; +} + +export class WorkspaceUploadCancelledError extends Error { + constructor(message = "Workspace upload cancelled") { + super(message); + this.name = "WorkspaceUploadCancelledError"; + } +} + +export class WorkspaceUploadBatchError extends Error { + readonly failures: WorkspaceUploadFileFailure[]; + readonly responses: WriteWorkspaceFileResponse[]; + + constructor(failures: readonly WorkspaceUploadFileFailure[], responses: readonly WriteWorkspaceFileResponse[]) { + super(uploadBatchErrorMessage(failures)); + this.name = "WorkspaceUploadBatchError"; + this.failures = failures.map((failure) => ({ ...failure })); + this.responses = responses.map((response) => ({ ...response })); + } +} + +export interface WorkspaceUploadFolderConfig { + uploads?: { + defaultFolder?: string; + }; +} + +export function effectiveWorkspaceUploadFolder(config: WorkspaceUploadFolderConfig | undefined): string { + return config?.uploads?.defaultFolder ?? DEFAULT_WORKSPACE_UPLOADS_FOLDER; +} + +export function workspaceEffectiveUploadFolder(config: WorkspaceUploadFolderConfig | undefined, fallbackFolder: string): string { + return config?.uploads?.defaultFolder ?? fallbackFolder; +} + +export function workspaceUploadPath(destinationFolder: string, fileName: string): string { + const folder = normalizeWorkspaceUploadPath(destinationFolder, "upload destination", { allowEmpty: true }); + const name = normalizeWorkspaceUploadPath(fileName, "upload file name", { allowEmpty: false }); + return folder === "" ? name : `${folder}/${name}`; +} + +export function uploadWorkspaceFile( + projectId: string, + workspaceId: string, + input: WorkspaceUploadFileInput, + options: UploadWorkspaceFileOptions = {}, +): WorkspaceUploadTask { + const xhr: WorkspaceUploadXhr = options.xhrFactory?.() ?? new XMLHttpRequest(); + let settled = false; + let cancelled = false; + + const promise = new Promise((resolve, reject) => { + const fail = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + const succeed = (response: WriteWorkspaceFileResponse) => { + if (settled) return; + settled = true; + resolve(response); + }; + + xhr.open("PUT", workspaceFileWriteUrl(projectId, workspaceId, input.path, uploadWriteUrlOptions(options)), true); + xhr.responseType = "json"; + xhr.setRequestHeader("Content-Type", (input.contentType ?? input.file.type) || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + options.onProgress?.(progressFromEvent(event, input.file.size)); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + options.onProgress?.({ loaded: input.file.size, total: input.file.size, percent: 1, lengthComputable: true }); + succeed(parseWriteWorkspaceFileResponse(readXhrJson(xhr))); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + return; + } + fail(new Error(readXhrErrorMessage(xhr))); + }; + xhr.onerror = () => { fail(new Error("Workspace upload failed")); }; + xhr.onabort = () => { fail(new WorkspaceUploadCancelledError(cancelled ? undefined : "Workspace upload aborted")); }; + xhr.send(input.file); + }); + + return { + promise, + cancel: () => { + if (settled) return; + cancelled = true; + xhr.abort(); + }, + }; +} + +export function uploadWorkspaceFiles( + projectId: string, + workspaceId: string, + files: readonly File[], + options: UploadWorkspaceFilesOptions = {}, +): WorkspaceUploadTask { + const destinationFolder = options.destinationFolder ?? DEFAULT_WORKSPACE_UPLOADS_FOLDER; + const progressFiles = files.map((file, index): WorkspaceUploadBatchFileProgress => ({ + index, + name: file.name, + path: workspaceUploadPath(destinationFolder, file.name), + loaded: 0, + total: file.size, + percent: percentFor(0, file.size), + lengthComputable: true, + done: false, + })); + let currentTask: WorkspaceUploadTask | undefined; + let currentFileIndex = 0; + const cancellation = { requested: false }; + + const emit = () => { + options.onProgress?.(batchProgressSnapshot(progressFiles, currentFileIndex, progressFiles.every((file) => file.done))); + }; + + const promise = (async (): Promise => { + const responses: WriteWorkspaceFileResponse[] = []; + const failures: WorkspaceUploadFileFailure[] = []; + for (let index = 0; index < files.length; index += 1) { + if (cancellation.requested) throw new WorkspaceUploadCancelledError(); + currentFileIndex = index; + const file = files[index]; + const progressFile = progressFiles[index]; + if (file === undefined || progressFile === undefined) continue; + currentTask = uploadWorkspaceFile(projectId, workspaceId, { path: progressFile.path, file }, { + ...uploadWriteOptions(options), + onProgress: (progress) => { + progressFile.total = progress.total; + progressFile.loaded = Math.min(progress.loaded, progressFile.total); + progressFile.percent = progress.percent; + progressFile.lengthComputable = progress.lengthComputable; + emit(); + }, + }); + try { + const response = await currentTask.promise; + progressFile.loaded = progressFile.total; + progressFile.percent = 1; + progressFile.lengthComputable = true; + progressFile.done = true; + responses.push(response); + emit(); + } catch (error) { + if (isUploadCancellation(error, cancellation)) throw error; + const message = errorMessage(error); + progressFile.loaded = progressFile.total; + progressFile.percent = 1; + progressFile.lengthComputable = true; + progressFile.done = true; + progressFile.error = message; + failures.push({ index, name: file.name, path: progressFile.path, error: message }); + emit(); + } finally { + currentTask = undefined; + } + } + if (failures.length > 0) throw new WorkspaceUploadBatchError(failures, responses); + return responses; + })(); + + return { + promise, + cancel: () => { + cancellation.requested = true; + currentTask?.cancel(); + }, + }; +} + +function uploadWriteOptions(options: UploadWorkspaceFilesOptions): UploadWorkspaceFileOptions { + return { + ...(options.createDirs === undefined ? {} : { createDirs: options.createDirs }), + ...(options.overwrite === undefined ? {} : { overwrite: options.overwrite }), + ...(options.machineId === undefined ? {} : { machineId: options.machineId }), + ...(options.xhrFactory === undefined ? {} : { xhrFactory: options.xhrFactory }), + }; +} + +function uploadWriteUrlOptions(options: UploadWorkspaceFileOptions): { createDirs?: boolean; overwrite?: boolean; machineId?: string } { + return { + ...(options.createDirs === undefined ? {} : { createDirs: options.createDirs }), + ...(options.overwrite === undefined ? {} : { overwrite: options.overwrite }), + ...(options.machineId === undefined ? {} : { machineId: options.machineId }), + }; +} + +function progressFromEvent(event: ProgressEvent, fallbackTotal: number): WorkspaceFileUploadProgress { + const total = event.lengthComputable ? event.total : fallbackTotal; + return { + loaded: event.loaded, + total, + percent: percentFor(event.loaded, total), + lengthComputable: event.lengthComputable, + }; +} + +function batchProgressSnapshot(files: WorkspaceUploadBatchFileProgress[], currentFileIndex: number, done: boolean): WorkspaceUploadBatchProgress { + const total = files.reduce((sum, file) => sum + file.total, 0); + const loaded = files.reduce((sum, file) => sum + file.loaded, 0); + return { + currentFileIndex, + files: files.map((file) => ({ ...file })), + loaded, + total, + percent: percentFor(loaded, total), + done, + }; +} + +function percentFor(loaded: number, total: number): number { + if (total <= 0) return loaded <= 0 ? 0 : 1; + return Math.max(0, Math.min(1, loaded / total)); +} + +function uploadBatchErrorMessage(failures: readonly WorkspaceUploadFileFailure[]): string { + if (failures.length === 1) return failures[0]?.error ?? "Workspace upload failed"; + return `${String(failures.length)} files failed to upload`; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isUploadCancellation(error: unknown, cancellation: { requested: boolean }): boolean { + return cancellation.requested || error instanceof WorkspaceUploadCancelledError; +} + +function normalizeWorkspaceUploadPath(value: string, label: string, options: { allowEmpty: boolean }): string { + const trimmed = value.trim(); + if (trimmed === "") { + if (options.allowEmpty) return ""; + throw new Error(`${label} must not be empty`); + } + if (isAbsoluteLike(trimmed)) throw new Error(`${label} must be workspace-relative`); + const parts = trimmed.split(/[\\/]+/u).filter((part) => part !== "" && part !== "."); + if (parts.length === 0) { + if (options.allowEmpty) return ""; + throw new Error(`${label} must not be empty`); + } + if (parts.some((part) => part === "..")) throw new Error(`${label} must not contain path traversal`); + return parts.join("/"); +} + +function isAbsoluteLike(value: string): boolean { + const withForwardSlashes = value.replace(/\\/g, "/"); + return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes); +} + +function readXhrJson(xhr: WorkspaceUploadXhr): unknown { + if (xhr.response !== undefined && xhr.response !== null && xhr.response !== "") return xhr.response; + if (xhr.responseText === "") return {}; + const parsed: unknown = JSON.parse(xhr.responseText); + return parsed; +} + +function readXhrErrorMessage(xhr: WorkspaceUploadXhr): string { + const body = safeReadXhrJson(xhr); + if (isRecord(body) && typeof body["error"] === "string") return body["error"]; + return xhr.statusText || `HTTP ${String(xhr.status)}`; +} + +function safeReadXhrJson(xhr: WorkspaceUploadXhr): unknown { + try { + return readXhrJson(xhr); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index dc59f43..83ac086 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -1,6 +1,7 @@ import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api"; import type { ChatLine } from "./components/shared"; import type { QualifiedContributionId } from "./plugins/ids"; +import type { WorkspaceUploadBatchState } from "./workspaceUploadState"; export interface AppState { machines: Machine[]; @@ -49,6 +50,8 @@ export interface AppState { selectedFilePath: string | undefined; selectedFileContent: FileContentResponse | undefined; fileTreeStale: boolean; + /** Manual workspace file upload batches, keyed by client-owned batch id. */ + workspaceUploadBatches: Record; gitStatus: GitStatusResponse | undefined; selectedDiffPath: string | undefined; selectedDiff: GitDiffResponse | undefined; @@ -147,6 +150,7 @@ export function initialAppState(): AppState { selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, + workspaceUploadBatches: {}, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index a662288..5a438e2 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; +import { configApi, effectiveWorkspaceUploadFolder, piWebApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -20,7 +20,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { selectedMachineId } from "../controllers/types"; import { RealtimeSocket } from "../sessionSocket"; -import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; +import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme"; import { corePlugin } from "../plugins/core"; import { themePackPlugin } from "../plugins/themes"; @@ -169,6 +169,7 @@ export class PiWebApp extends LitElement { @state() private isRefreshingApp = false; @state() private settingsSection: SettingsSection | undefined = readSettingsSection(); @state() private shortcutConfig: PiWebShortcutConfig = {}; + @state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined); private readonly onPopState = () => void this.withChatScrollTransition(async () => { this.restoreSettingsRoute(); await this.restoreRoute(false); @@ -324,7 +325,7 @@ export class PiWebApp extends LitElement { private async loadClientConfig(): Promise { try { - this.applyClientConfig((await configApi.config()).config); + this.applyClientConfig((await configApi.config()).effectiveConfig); } catch (error) { console.warn("Failed to load PI WEB config", error); } @@ -332,6 +333,7 @@ export class PiWebApp extends LitElement { private applyClientConfig(config: PiWebConfigValues): void { this.shortcutConfig = config.shortcuts ?? {}; + this.workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(config); } private async refreshAppData(): Promise { @@ -1216,6 +1218,21 @@ export class PiWebApp extends LitElement { private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles { return { readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId), + writeFile: async (path, content, options) => { + const result = await workspacesApi.writeWorkspaceFile(workspace.projectId, workspace.id, path, content, options, machineId); + void this.files.refreshFiles(); + return result; + }, + deleteFile: async (path) => { + const result = await workspacesApi.deleteWorkspaceFile(workspace.projectId, workspace.id, path, machineId); + void this.files.refreshFiles(); + return result; + }, + moveFile: async (fromPath, toPath, options) => { + const result = await workspacesApi.moveWorkspaceFile(workspace.projectId, workspace.id, fromPath, toPath, options, machineId); + void this.files.refreshFiles(); + return result; + }, }; } @@ -1235,6 +1252,7 @@ export class PiWebApp extends LitElement { workspace, state: this.state, files: this.createWorkspaceFiles(workspace, machineId), + prompt: this.createPromptEditor(), terminal: { open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); }, runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }), @@ -1255,9 +1273,13 @@ export class PiWebApp extends LitElement { activeTerminalCount: this.state.activeTerminalCount, selectedTerminalId: this.state.selectedTerminalId, terminalAutoStart: this.terminalAutoStartWorkspaceId === workspace.id, + workspaceUploadDefaultFolder: workspaceEffectiveUploadFolder(workspace.effectiveConfig, this.workspaceUploadDefaultFolder), onRefreshFiles: () => { void this.files.refreshFiles(); }, onExpandDir: (path: string) => { void this.files.expandDir(path); }, onSelectFile: (path: string) => { void this.files.selectFile(path); }, + onStartWorkspaceUpload: (files, options) => this.files.startWorkspaceUpload(files, options), + onCancelWorkspaceUpload: (batchId) => { this.files.cancelWorkspaceUpload(batchId); }, + onClearWorkspaceUpload: (batchId) => { this.files.clearWorkspaceUpload(batchId); }, onRefreshGit: () => { void this.git.refreshGit(); }, onSelectDiff: (path: string) => { void this.git.selectDiff(path); }, onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); }, @@ -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 { const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({ state: this.state, + prompt: this.createPromptEditor(), piWebUnstable: { terminalCommandRuns: this.terminalCommandRunsForOrigin(origin), openSettings: (section) => { this.openSettings(section); }, diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index ee88588..0fcffaf 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -7,25 +7,19 @@ import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; -import { captureImageAttachments } from "../promptAttachmentCapture"; +import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture"; import { inputModeForDraft } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; +import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; import "./AutocompleteMenu"; -interface PendingAttachment { - id: string; - name: string; - mimeType: string; - /** Base64 payload without the data: URL prefix. */ - data: string; - size: number; -} +type PendingAttachment = CapturedAttachment & { id: string }; @customElement("prompt-editor") export class PromptEditor extends LitElement { @@ -59,6 +53,8 @@ export class PromptEditor extends LitElement { private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); + private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia(); + private explicitShiftKeyActive = false; protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -96,8 +92,8 @@ export class PromptEditor extends LitElement {